Compare commits

..

13 Commits

Author SHA1 Message Date
Abhi Kumar
284526c9a2 feat(dashboards): add the Text panel's Markdown editor and renderer
Building blocks for the Text panel, ahead of the panel kind itself.

MarkdownEditor is the authoring surface that will replace the query-builder
pane for query-less kinds: a formatting toolbar over a CodeMirror document,
variable insertion, and a caret/character-count status bar. Commands are pure
transforms over a text-and-selection snapshot, so they carry no CodeMirror
coupling. Markdown colouring is a decoration pass rather than a grammar, which
keeps it on the CodeMirror packages already bundled.

MarkdownContent renders the body. It is panel-local rather than shared because
the shared MarkdownRenderer enables rehype-raw, which is safe only for the
trusted content it was built for; this one never gets it, so raw HTML in a
user-authored body renders as text with no dangerouslySetInnerHTML on the path.
Its stylesheet reverts the subtree to user-agent styling so no global rule
reaches the rendered body, and Prism runs with useInlineStyles off so the token
palette stays on design tokens. Languages load per fence.

jest.config gains remark-gfm and its ESM-only dependencies; nothing had
exercised the plugin under jest before.
2026-08-27 18:20:33 +05:30
Abhi Kumar
6232781162 chore: pr review changes 2026-08-27 11:57:04 +05:30
Abhi Kumar
144b8cd8b8 feat(dashboards-v2): report panelKind on panel analytics events
Panel events identified the panel only by its legacy panel type, which
cannot tell apart two kinds that map onto the same one — so a newly added
kind is indistinguishable from the kind it shares a type with.

Adds panelKind alongside the existing panelType on all seven events (no
data, clone, delete, move, CSV export, drilldown opened, create alert).
Additive on purpose: existing reports keep resolving.

Assisted-by: Claude Opus 5
2026-08-27 11:57:04 +05:30
Abhi Kumar
926e60df61 refactor(dashboards-v2): drive the query path and builder mode off the declarations
buildQueryRangeRequest now takes the kind's declared query capabilities
instead of a legacy panel type, so the request type, table formatting, bar
step interval and list order tiebreaker all come from the kind itself. The
editor asks the same declarations whether the query builder runs in
list-view mode, offers a trace operator, shows the plot-mode chip, or seeds
a default query, rather than testing "is this the List panel?" in four
places.

The capabilities are passed in rather than looked up by kind: the panel
registry carries every renderer with it, which has no business in the data
path — importing it there pulls the app's API client into any test that
touches the request builder. The call sites already resolve the definition,
so threading it costs nothing. PlotTag takes isListView instead of a panel
type, so a presentational component no longer needs the enum at all.

panelTypeToRequestType moves to persesQueryAdapters, the V1 Query pivot
that is now its only caller — the legacy switch belongs on the V1 side of
the boundary rather than in the middle of the V5 request builder. The
shared QueryBuilderV2 provider keeps its legacy panelType prop: that is
state inside the shared provider, read by its subcomponents, and out of
scope here.

Assisted-by: Claude Opus 5
2026-08-27 11:56:29 +05:30
Abhi Kumar
7c18f0db2b refactor(dashboards-v2): declare per-kind query capabilities
Each panel kind now states how its query behaves — request type, result
formatting, step-interval and order treatment, paging, whether it is
authored as a list view, and whether it offers a trace operator.

These are the questions V2 answered by comparing against the legacy
PANEL_TYPES enum. Declaring them per kind means the compiler asks for an
answer when a kind is added, instead of the kind silently falling through
someone else's switch. The expectations are an exhaustive Record over
PanelKind, so a new kind cannot ship without stating its request shape.

getPanelDefinition also stops lying. It was typed to return a definition
for any PanelKind, but the registry only holds the kinds this build
registers — a dashboard spec written by a newer SigNoz names one it has
never heard of, and callers coped by truthiness-checking a value the type
said could not be falsy. An unregistered kind now resolves to
UNSUPPORTED_PANEL, which declares nothing and renders as unsupported, so
callers read a definition's fields directly and such a panel says why it is
blank instead of leaving a hole in the layout. Whether a kind can be
rendered at all becomes its own question: isPanelKindSupported.

Assisted-by: Claude Opus 5
2026-08-27 11:56:29 +05:30
Abhi Kumar
827b278480 refactor(charts): declare the time axis instead of inferring it from a panel type
The uPlotV2 axis builder decided X-axis date formatting by testing the
panel type against a hardcoded [TIME_SERIES, BAR] list. A chart that plots
time but is not one of those two silently lost its time-formatted ticks —
no type error, no failing test, just wrong-looking ticks.

Axis props now take isTimeAxis and each caller states it: the three V2
kinds through the shared base config (histogram passes false — its X axis
is buckets), and the Meter Explorer, K8s metrics and V1 shared config
builders directly.

Assisted-by: Claude Opus 5
2026-08-27 11:56:29 +05:30
Abhi Kumar
68fb9be9c8 chore(dashboards-v2): remove the unused ViewPanelQueryBuilder
The View modal renders PanelEditorQueryBuilder; this component had no
importers and referenced a stylesheet class that no longer exists.

Assisted-by: Claude Opus 5
2026-08-27 11:56:29 +05:30
Abhi Kumar
0eeeed6de9 fix(dashboard): match the list page wording in the public legacy notice
The list page tells owners a legacy dashboard "isn't available in the new
experience"; the public notice said the same thing in different words.
Reuse the list page's phrasing so the two states read as one message, and
keep the owner-only recovery path, since public viewers are anonymous and
cannot retry the migration themselves.

Assisted-by: Claude Opus 5
2026-08-27 01:22:35 +05:30
Abhi Kumar
acaeaacbc6 chore(dashboard): drop dead fields from IDashboardVariable
modificationUUID, haveCustomValuesSelected, change and defaultValue have
no readers left now that the V1 variable-selection UI is gone. Type order
as the number the sort comparator already treats it as, and make that
comparator explicit about the variables that carry no order.

Assisted-by: Claude Opus 5
2026-08-27 01:22:35 +05:30
Abhi Kumar
052a7babb9 refactor(charts): drop the dead isGraphDisabled prop from ChartManager
The flag came from the V1 store's dashboard-lock state and disabled the
legend's series toggles. V2 gates editing on its own lock, not read-only
interactions like toggling a series, so the prop was left hardcoded false
when the V1 store went away. Remove it rather than rewire it.

Assisted-by: Claude Opus 5
2026-08-27 01:22:35 +05:30
Abhi Kumar
801cc097b5 fix(dashboard): carry the dashboard name on panel-action analytics
The V1 panel-action events sent dashboardName alongside dashboardId;
retiring the V1 store dropped the name with no V2 replacement, because the
V2 store deliberately holds no spec. Read it off the loaded dashboard
instead and send the pair from every panel-action event, so clone, delete,
move and create-alert all report the same dashboard identity.

Assisted-by: Claude Opus 5
2026-08-27 01:22:35 +05:30
Abhi Kumar
e1bef67cf1 refactor(widgets): group the panel stack under WidgetCard/Panels
WidgetCard listed PanelWrapper, TablePanel and ValuePanel as siblings of
Card, EmptyWidget and Header, so the panel renderers read as peers of the
card shell rather than as its contents. Collect them under one Panels
folder and flatten PanelWrapper's nested panels/ directory into it, so the
folder is card shell (Card/Header/EmptyWidget) plus the panels it renders.

Pure moves and import-path updates.

Assisted-by: Claude Opus 5
2026-08-27 01:22:35 +05:30
Abhi Kumar
cf51dee878 refactor(dashboard): drop the V2 suffix now that V1 is gone
With no V1 dashboard code left, the suffix distinguishes nothing.

  pages/DashboardPageV2/                   -> pages/DashboardPage/
  pages/DashboardsListPageV2/              -> pages/DashboardsListPage/
  pages/PublicDashboard/PublicDashboardV2/ -> pages/PublicDashboard/PublicDashboardView/

The public renderer keeps its own directory rather than being flattened into
the page, which would have collided two __tests__ folders; it is renamed to
PublicDashboardView to say what it is next to the route entry and the legacy
notice.

Also updates the no-dashboard-fetch-outside-root allowlist in .oxlintrc.json
and the CODEOWNERS entries. LOCALSTORAGE.DASHBOARD_V2_PANEL_COLUMN_WIDTHS is
deliberately untouched: its value is persisted in users' browsers and renaming
it would discard saved column widths.
2026-08-27 01:22:17 +05:30
801 changed files with 4059 additions and 906 deletions

View File

@@ -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"

View File

@@ -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/'],

View File

@@ -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'
),
);

View 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;

View 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;

View File

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

View File

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

View 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;

View 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;

View File

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

View File

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

View 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 (16 #)' },
{ 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: '![alt](url)', label: 'Image' },
{ syntax: '`code`', label: 'Inline code' },
{ syntax: '```lang', label: 'Code block' },
{ syntax: '> quote', label: 'Blockquote' },
{ syntax: '| a | b |', label: 'Table' },
];

View 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 },
});
}

View 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 },
];

View 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];
}

View 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 ? '' : '+');
}

View 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;
}

View File

@@ -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(

View File

@@ -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';

View File

@@ -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';
/**

View File

@@ -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';

View File

@@ -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) {

View File

@@ -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';

View File

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

View File

@@ -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) {

View File

@@ -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';

View File

@@ -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,

View File

@@ -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,

View File

@@ -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';

View File

@@ -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';

View File

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

View File

@@ -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';

View File

@@ -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', () => ({

View File

@@ -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 {

View File

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

View File

@@ -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,

View File

@@ -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,

View File

@@ -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({

View File

@@ -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', () => {

View File

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

View File

@@ -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,

View File

@@ -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';

View File

@@ -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', () => ({

View File

@@ -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,

View File

@@ -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,

View File

@@ -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', () => {

View File

@@ -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 ||

View File

@@ -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 = {

View File

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

View File

@@ -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,

View File

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

View 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,
};

View File

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

View File

@@ -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 {

View File

@@ -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 {

View File

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

View File

@@ -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[]>;

View File

@@ -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';

View File

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

View File

@@ -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', () => {

View File

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

View File

@@ -117,7 +117,6 @@ export default function ChartManager({
onToggleSeriesOnOff: handleToggleSeriesOnOff,
onToggleSeriesVisibility,
yAxisUnit,
isGraphDisabled: false,
decimalPrecision,
}),
[

View File

@@ -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}
/>
),

View File

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

View File

@@ -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';

View File

@@ -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';

View File

@@ -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';

View File

@@ -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