Compare commits

...

1 Commits

Author SHA1 Message Date
Naman Verma
c8e9e362f7 feat: add spec for text panel (#12711)
Some checks are pending
build-staging / prepare (push) Waiting to run
build-staging / js-build (push) Blocked by required conditions
build-staging / go-build (push) Blocked by required conditions
build-staging / staging (push) Blocked by required conditions
cacheci / tests (push) Waiting to run
Release Drafter / update_release_draft (push) Waiting to run
#### Description

Add a new plugin schema for text panel. This also adds a check on the
query count. If the panel is text, then number of queries should be
zero, otherwise it should be 1.

Frontend changes to be built on top of this

#### Issues closed by this PR

Closes https://github.com/SigNoz/pulse-pod/issues/303
Closes https://github.com/SigNoz/pulse-pod/issues/221

---------

Co-authored-by: Abhi kumar <ahrefabhi@gmail.com>
2026-09-11 13:29:01 +00:00
126 changed files with 4348 additions and 538 deletions

View File

@@ -3536,6 +3536,11 @@ components:
- tags
- spec
type: object
DashboardtypesHeaderOptions:
properties:
hide:
type: boolean
type: object
DashboardtypesHistogramBuckets:
properties:
bucketCount:
@@ -3893,6 +3898,7 @@ components:
signoz/NumberPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesNumberPanelSpec'
signoz/PieChartPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpec'
signoz/TablePanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
signoz/TextPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
signoz/TimeSeriesPanel: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec'
propertyName: kind
oneOf:
@@ -3903,6 +3909,7 @@ components:
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpec'
- $ref: '#/components/schemas/DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec'
type: object
DashboardtypesPanelPluginKind:
enum:
@@ -3913,6 +3920,7 @@ components:
- signoz/TablePanel
- signoz/HistogramPanel
- signoz/ListPanel
- signoz/TextPanel
type: string
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpec:
properties:
@@ -3986,6 +3994,18 @@ components:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpec:
properties:
kind:
enum:
- signoz/TextPanel
type: string
spec:
$ref: '#/components/schemas/DashboardtypesTextPanelSpec'
required:
- kind
- spec
type: object
DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpec:
properties:
kind:
@@ -4277,6 +4297,37 @@ components:
- color
- columnName
type: object
DashboardtypesTextAlign:
enum:
- left
- center
- right
type: string
DashboardtypesTextMode:
enum:
- markdown
type: string
DashboardtypesTextPanelSpec:
properties:
headerOptions:
$ref: '#/components/schemas/DashboardtypesHeaderOptions'
mode:
$ref: '#/components/schemas/DashboardtypesTextMode'
presentation:
$ref: '#/components/schemas/DashboardtypesTextPresentation'
text:
type: string
type: object
DashboardtypesTextPresentation:
properties:
background:
nullable: true
type: string
textAlign:
$ref: '#/components/schemas/DashboardtypesTextAlign'
verticalAlign:
$ref: '#/components/schemas/DashboardtypesVerticalAlign'
type: object
DashboardtypesTextVariableSpec:
properties:
constant:
@@ -4486,6 +4537,12 @@ components:
- kind
- spec
type: object
DashboardtypesVerticalAlign:
enum:
- top
- center
- bottom
type: string
ErrorsJSON:
properties:
code:

View File

@@ -5019,6 +5019,57 @@ export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDa
spec: DashboardtypesListPanelSpecDTO;
}
export enum DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind {
'signoz/TextPanel' = 'signoz/TextPanel',
}
export interface DashboardtypesHeaderOptionsDTO {
/**
* @type boolean
*/
hide?: boolean;
}
export enum DashboardtypesTextModeDTO {
markdown = 'markdown',
}
export enum DashboardtypesTextAlignDTO {
left = 'left',
center = 'center',
right = 'right',
}
export enum DashboardtypesVerticalAlignDTO {
top = 'top',
center = 'center',
bottom = 'bottom',
}
export interface DashboardtypesTextPresentationDTO {
/**
* @type string,null
*/
background?: string | null;
textAlign?: DashboardtypesTextAlignDTO;
verticalAlign?: DashboardtypesVerticalAlignDTO;
}
export interface DashboardtypesTextPanelSpecDTO {
headerOptions?: DashboardtypesHeaderOptionsDTO;
mode?: DashboardtypesTextModeDTO;
presentation?: DashboardtypesTextPresentationDTO;
/**
* @type string
*/
text?: string;
}
export interface DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO {
/**
* @enum signoz/TextPanel
* @type string
*/
kind: DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTOKind;
spec: DashboardtypesTextPanelSpecDTO;
}
export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTimeSeriesPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBarChartPanelSpecDTO
@@ -5026,7 +5077,8 @@ export type DashboardtypesPanelPluginDTO =
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesPieChartPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTablePanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesHistogramPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO;
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesListPanelSpecDTO
| DashboardtypesPanelPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesTextPanelSpecDTO;
export enum Querybuildertypesv5RequestTypeDTO {
scalar = 'scalar',
@@ -5950,6 +6002,7 @@ export enum DashboardtypesPanelPluginKindDTO {
'signoz/TablePanel' = 'signoz/TablePanel',
'signoz/HistogramPanel' = 'signoz/HistogramPanel',
'signoz/ListPanel' = 'signoz/ListPanel',
'signoz/TextPanel' = 'signoz/TextPanel',
}
/**
* @nullable

View File

@@ -31,6 +31,8 @@ export const getComponentForPanelType = (
[PANEL_TYPES.BAR]: Uplot,
[PANEL_TYPES.PIE]: null,
[PANEL_TYPES.HISTOGRAM]: Uplot,
// Dashboards v2 renders this kind; nothing reaches the V1 chart map for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
};

View File

@@ -376,6 +376,7 @@ export enum PANEL_TYPES {
BAR = 'bar',
PIE = 'pie',
HISTOGRAM = 'histogram',
TEXT = 'text',
EMPTY_WIDGET = 'EMPTY_WIDGET',
}

View File

@@ -29,5 +29,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
BAR: true,
PIE: false,
HISTOGRAM: false,
TEXT: false,
EMPTY_WIDGET: false,
};

View File

@@ -14,6 +14,8 @@ export const PanelTypeVsPanelWrapper = {
[PANEL_TYPES.LIST]: ListPanelWrapper,
[PANEL_TYPES.VALUE]: ValuePanelWrapper,
[PANEL_TYPES.TRACE]: null,
// Dashboards v2 renders this kind; the V1 wrapper map is never asked for it.
[PANEL_TYPES.TEXT]: null,
[PANEL_TYPES.EMPTY_WIDGET]: null,
[PANEL_TYPES.PIE]: PiePanelWrapper,
[PANEL_TYPES.BAR]: BarPanel,

View File

@@ -19,6 +19,7 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
promql: 'PromQL',
clickhouse: 'ClickHouse',
variable: 'Variable',
text: 'Markdown body',
};
interface VariableImpactDialogProps {

View File

@@ -0,0 +1,49 @@
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
import { buildVariableImpactPatch } from '../utils/variableImpactPatch';
import type { VariableUsage } from '../utils/variableUsages';
jest.mock('../variableAdapters', () => ({
formModelToDto: (model: unknown): unknown => model,
}));
const dashboard = {
spec: {
panels: {
runbook: {
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text: 'env {{svc}}' } },
queries: [],
},
},
},
variables: [],
},
} as unknown as DashboardtypesGettableDashboardV2DTO;
const textUsage: VariableUsage = {
id: 'panel:runbook:0',
sourceType: 'panel',
sourceId: 'runbook',
sourceLabel: 'Runbook',
kind: 'text',
envelopeIndex: 0,
currentText: 'env {{svc}}',
resultingText: 'env {{zone}}',
};
describe('buildVariableImpactPatch — text panel bodies', () => {
it('replaces the plugin-spec text, never the (empty) queries', () => {
const ops = buildVariableImpactPatch(dashboard, [], [textUsage]);
const panelOps = ops.filter((op) => op.path.includes('/panels/'));
expect(panelOps).toStrictEqual([
{
op: 'replace',
path: '/spec/panels/runbook/spec/plugin/spec/text',
value: 'env {{zone}}',
},
]);
});
});

View File

@@ -45,6 +45,16 @@ function promqlPanel(name: string, query: string): unknown {
};
}
function textPanel(name: string, text: string): unknown {
return {
spec: {
display: { name },
plugin: { kind: 'signoz/TextPanel', spec: { text } },
queries: [],
},
};
}
function dashboard(
panels: Record<string, unknown>,
variables: VariableFormModel[],
@@ -99,6 +109,38 @@ describe('findVariableUsages', () => {
it('returns nothing for an unreferenced variable', () => {
expect(findVariableUsages(dash, 'nope', 'delete')).toStrictEqual([]);
});
describe('text panel bodies (TDD D5)', () => {
const textDash = dashboard(
{
runbook: textPanel(
'Runbook',
'env {{svc}} / {{.svc}} / [[svc]] / $svc / {{svcx}}',
),
unrelated: textPanel('Plain', 'no tokens here'),
},
[variable({ name: 'svc', type: 'QUERY' })],
);
it('finds the body usage and skips bodies without the token', () => {
const usages = findVariableUsages(textDash, 'svc', 'rename', 'zone');
expect(usages.map((u) => u.id)).toStrictEqual(['panel:runbook:0']);
expect(usages[0].kind).toBe('text');
expect(usages[0].sourceLabel).toBe('Runbook');
});
it('rewrites all four syntaxes on rename, leaving other names alone', () => {
const [usage] = findVariableUsages(textDash, 'svc', 'rename', 'zone');
expect(usage.resultingText).toBe(
'env {{zone}} / {{.zone}} / [[zone]] / $zone / {{svcx}}',
);
});
it('leaves the body for review on delete', () => {
const [usage] = findVariableUsages(textDash, 'svc', 'delete');
expect(usage.resultingText).toBe(usage.currentText);
});
});
});
describe('findApplyUsages', () => {

View File

@@ -0,0 +1,16 @@
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { isStaticPanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
/**
* The markdown body of a static (query-less) panel, or null for query kinds.
* One localized cast: the plugin-spec union can't be narrowed by a dynamic kind.
*/
export function getTextPanelBody(
spec: DashboardtypesPanelSpecDTO | undefined,
): string | null {
if (!spec?.plugin || !isStaticPanelKind(spec.plugin.kind)) {
return null;
}
const { text } = spec.plugin.spec as { text?: string };
return typeof text === 'string' ? text : null;
}

View File

@@ -107,6 +107,18 @@ export function buildVariableImpactPatch(
byPanel.forEach((list, panelId) => {
const panel = panels[panelId];
// A static kind's usage edits its markdown body, not a query.
const textUsage = list.find((usage) => usage.kind === 'text');
if (textUsage) {
ops.push({
op: 'replace' as DashboardtypesJSONPatchOperationDTO['op'],
path: `/spec/panels/${panelId}/spec/plugin/spec/text`,
value: textUsage.resultingText,
});
return;
}
if (!panel?.spec?.queries?.length) {
return;
}

View File

@@ -12,6 +12,7 @@ import {
} from 'lib/dashboardVariables/variableReference';
import { toQueryEnvelopes } from '../../../queryV5/buildQueryRangeRequest';
import { getTextPanelBody } from './getTextPanelBody';
import { dtoToFormModel } from '../variableAdapters';
/** The kind of query text a variable is referenced from. */
@@ -19,7 +20,8 @@ export type VariableUsageKind =
| 'builder'
| 'promql'
| 'clickhouse'
| 'variable';
| 'variable'
| 'text';
export type VariableImpactMode = 'rename' | 'delete' | 'apply';
@@ -81,7 +83,7 @@ function computeResultingText(
return rewriteVariableReferences(text, variableName, newName);
}
// delete: only builder filter clauses can be safely auto-stripped; raw PromQL/
// ClickHouse and variable queries are left for the user to edit.
// ClickHouse, markdown bodies and variable queries are left for the user to edit.
return kind === 'builder'
? removeVariableFromExpression(text, variableName)
: text;
@@ -106,6 +108,31 @@ export function findVariableUsages(
const spec = dashboard.spec;
Object.entries(spec.panels ?? {}).forEach(([panelId, panel]) => {
// A static kind references variables from its body, not a query (TDD D5 —
// rename must rewrite text bodies too, or it silently orphans the tokens).
const textBody = getTextPanelBody(panel?.spec);
if (typeof textBody === 'string') {
if (textContainsVariableReference(textBody, variableName)) {
usages.push({
id: `panel:${panelId}:0`,
sourceType: 'panel',
sourceId: panelId,
sourceLabel: panel.spec?.display?.name || panelId,
kind: 'text',
envelopeIndex: 0,
currentText: textBody,
resultingText: computeResultingText(
'text',
textBody,
variableName,
mode,
newName,
),
});
}
return;
}
const queries = panel?.spec?.queries;
if (!queries?.length) {
return;

View File

@@ -5,6 +5,7 @@ import type {
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { SectionKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
@@ -66,7 +67,14 @@ function ConfigPane({
}: ConfigPaneProps): JSX.Element {
const panelKind = spec.plugin.kind;
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
// The header toggle belongs with the title and description it hides, so the kind's
// declaration still gates it but it renders above, out of the display options.
const headerSection = definition.sections.find(
(config) => config.kind === SectionKind.PanelHeader,
);
const sections = definition.sections.filter(
(config) => config.kind !== SectionKind.PanelHeader,
);
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
@@ -105,6 +113,23 @@ function ConfigPane({
onChange={(e): void => setDisplayField('description', e.target.value)}
/>
</div>
{headerSection && (
<SectionSlot
bare
config={headerSection}
spec={spec}
onChangeSpec={onChangeSpec}
legendSeries={legendSeries}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
)}
</div>
{sections.length > 0 && (

View File

@@ -2,6 +2,7 @@ import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import { EQueryType } from 'types/common/dashboard';
import {
isStaticPanelKind,
isQueryTypeSupportedByPanelKind,
isSignalSupported,
} from '../../../Panels/capabilities';
@@ -37,6 +38,12 @@ export function getPanelTypeDisabledReason({
signal?: TelemetrytypesSignalDTO;
label: string;
}): string | undefined {
// A kind that renders without a query pairs with anything — it declares no
// query types or signals, and the checks below would read that as "supports
// nothing" and disable it everywhere.
if (isStaticPanelKind(kind)) {
return undefined;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return `${label} isn't available for ${QUERY_TYPE_LABEL[queryType]} queries`;
}

View File

@@ -16,6 +16,8 @@ type SectionSlotProps = {
config: SectionConfig;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Renders the editor alone, for a section promoted into the Panel Details fields. */
bare?: boolean;
} & Omit<SectionEditorContext, 'yAxisUnit' | 'registerHeaderAction'>;
// Per-section header content; `trigger` expands the section and runs the editor's handler.
@@ -50,6 +52,7 @@ function SectionSlot({
config,
spec,
onChangeSpec,
bare,
legendSeries,
tableColumns,
signal,
@@ -110,6 +113,28 @@ function SectionSlot({
const headerSlot = SECTION_HEADER_SLOT[config.kind]?.(triggerHeaderAction);
const editorElement = (
<Component
value={get(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}
/>
);
if (bare) {
return editorElement;
}
return (
<SettingsSection
title={title}
@@ -118,21 +143,7 @@ function SectionSlot({
onOpenChange={setOpen}
headerSlot={headerSlot}
>
<Component
value={get(spec)}
controls={controls}
onChange={(next): void => onChangeSpec(update(spec, next))}
legendSeries={legendSeries}
yAxisUnit={yAxisUnit}
tableColumns={tableColumns}
signal={signal}
panelKind={panelKind}
onChangePanelKind={onChangePanelKind}
queryType={queryType}
stepInterval={stepInterval}
metricUnit={metricUnit}
registerHeaderAction={registerHeaderAction}
/>
{editorElement}
</SettingsSection>
);
}

View File

@@ -23,6 +23,14 @@ jest.mock(
}),
);
function textSpec(): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Runbook', description: 'steps' },
plugin: { kind: 'signoz/TextPanel', spec: { text: '' } },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
function spec(unit?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'CPU', description: 'usage' },
@@ -93,6 +101,24 @@ describe('ConfigPane', () => {
);
});
// It hides the title strip, so it sits with the title rather than under the
// display options — and only a kind whose spec accepts `headerOptions` shows it.
it('renders the hide-header toggle among the Panel Details fields', () => {
renderConfigPane({ spec: textSpec() });
const toggle = screen.getByTestId('panel-header-hide');
expect(toggle).toBeInTheDocument();
expect(screen.getByText('Hide header')).toBeInTheDocument();
// No collapsible wrapper of its own.
expect(screen.queryByText('Panel header')).not.toBeInTheDocument();
});
it('omits the hide-header toggle for a kind that has no header options', () => {
renderConfigPane();
expect(screen.queryByTestId('panel-header-hide')).not.toBeInTheDocument();
});
it('renders the Formatting section for a kind that declares it', () => {
renderConfigPane();
// The TimeSeries kind declares a Formatting section; its collapsible header shows.

View File

@@ -0,0 +1,76 @@
.row {
display: flex;
align-items: center;
gap: 6px;
}
.swatch {
flex: none;
width: 26px;
height: 26px;
padding: 0;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: none;
cursor: pointer;
position: relative;
// The input carries focus, so the ring is drawn on the swatch around it.
&:has(.input:focus-visible) {
outline: 2px solid var(--bg-robin-400);
outline-offset: 1px;
}
}
// The real control, sized to the swatch and invisible over it: clicks and focus
// land on the radio, the swatch is what the user sees.
.input {
position: absolute;
inset: 0;
margin: 0;
opacity: 0;
cursor: pointer;
}
.selected {
box-shadow: 0 0 0 2px var(--bg-robin-500);
}
// Transparency has no colour to show, so it reads as the conventional checkerboard.
.checkerboard {
background-color: var(--l2-background);
background-image:
linear-gradient(
45deg,
var(--l2-border) 25%,
transparent 25%,
transparent 75%,
var(--l2-border) 75%
),
linear-gradient(
45deg,
var(--l2-border) 25%,
transparent 25%,
transparent 75%,
var(--l2-border) 75%
);
background-size: 8px 8px;
background-position:
0 0,
4px 4px;
}
.defaultSurface {
background: var(--l2-background);
}
.divider {
flex: none;
width: 1px;
height: 18px;
margin: 0 2px;
background: var(--l2-border);
}

View File

@@ -0,0 +1,119 @@
import { Fragment } from 'react';
import { Check } from '@signozhq/icons';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import {
TEXT_BACKGROUND_PAIRS,
TEXT_BACKGROUND_PRESETS,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import type {
PanelTheme,
TextBackgroundPreset,
TextBackgroundSelection,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import { TextBackgroundKind } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import styles from './BackgroundSwatches.module.scss';
const PRESET_TITLES: Record<TextBackgroundPreset, string> = {
robin: 'Robin',
purple: 'Purple',
sakura: 'Sakura',
cherry: 'Cherry',
amber: 'Amber',
forest: 'Forest',
sienna: 'Sienna',
slate: 'Slate',
};
type BaseSelection = TextBackgroundKind.None | TextBackgroundKind.Default;
const BASE_TITLES: Record<BaseSelection, string> = {
none: 'Transparent',
default: 'Default panel',
};
/** Neither base swatch shows a colour, so its tooltip says what it does. */
const BASE_TOOLTIPS: Record<BaseSelection, string> = {
none: 'Transparent — no card, border or title bar',
default: 'Default panel colour',
};
const OPTIONS: TextBackgroundSelection[] = [
TextBackgroundKind.None,
TextBackgroundKind.Default,
...TEXT_BACKGROUND_PRESETS,
];
const DIVIDER_AFTER = 1;
interface BackgroundSwatchesProps {
testId: string;
/** Names the group for assistive tech — the row carries no visible label. */
label: string;
/** `undefined` while a custom colour is active: no swatch is selected. */
value: TextBackgroundSelection | undefined;
/** Swatches paint in this theme's pair, so what the user picks is what they see. */
theme: PanelTheme;
onChange: (value: TextBackgroundSelection) => void;
}
/**
* The Text panel's background choices as one radio group. Native radios sharing a
* `name`, so arrow-key movement, the single tab stop and selection-follows-focus
* are the platform's; each input is transparent and fills its swatch.
*/
function BackgroundSwatches({
testId,
label,
value,
theme,
onChange,
}: BackgroundSwatchesProps): JSX.Element {
return (
<div
className={styles.row}
role="radiogroup"
aria-label={label}
data-testid={testId}
>
{OPTIONS.map((option, index) => {
const isBase =
option === TextBackgroundKind.None ||
option === TextBackgroundKind.Default;
const pair = isBase ? undefined : TEXT_BACKGROUND_PAIRS[option][theme];
const title = isBase ? BASE_TITLES[option] : PRESET_TITLES[option];
return (
<Fragment key={option}>
<TooltipSimple title={isBase ? BASE_TOOLTIPS[option] : title} arrow>
<label
className={cx(styles.swatch, {
[styles.checkerboard]: option === TextBackgroundKind.None,
[styles.defaultSurface]: option === TextBackgroundKind.Default,
[styles.selected]: option === value,
})}
style={pair ? { background: pair.surface, color: pair.ink } : undefined}
data-testid={`${testId}-${option}`}
>
<input
type="radio"
className={styles.input}
name={testId}
value={option}
checked={option === value}
aria-label={title}
onChange={(): void => onChange(option)}
/>
{option === value && <Check size={14} />}
</label>
</TooltipSimple>
{index === DIVIDER_AFTER && <span className={styles.divider} />}
</Fragment>
);
})}
</div>
);
}
export default BackgroundSwatches;

View File

@@ -0,0 +1,67 @@
.row {
display: flex;
width: 100%;
align-items: center;
gap: 10px;
padding: 8px 10px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: transparent;
cursor: pointer;
text-align: left;
}
.active {
border-color: var(--bg-robin-500);
}
.chip {
flex: none;
width: 18px;
height: 18px;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid var(--l2-border);
border-radius: 4px;
}
// No colour to show yet, so the chip advertises that it opens a picker.
.chipEmpty {
background: conic-gradient(
from 0deg,
var(--bg-cherry-400),
var(--bg-amber-400),
var(--bg-forest-400),
var(--bg-robin-400),
var(--bg-sakura-400),
var(--bg-cherry-400)
);
}
.label {
flex: 1;
font-size: 12px;
color: var(--l2-foreground);
}
.hex {
font-family: var(--font-family-sf-mono);
font-size: 12px;
color: var(--text-vanilla-400);
letter-spacing: 0.02em;
}
// Appended under the picker's own panel.
.contrast {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 4px 2px;
font-size: 12px;
color: var(--text-vanilla-400);
}
.warning {
color: var(--bg-amber-400);
}

View File

@@ -0,0 +1,91 @@
import type { ReactNode } from 'react';
import { Check, ChevronDown, TriangleAlert } from '@signozhq/icons';
import { Typography } from '@signozhq/ui/typography';
import { ColorPicker } from 'antd';
import cx from 'classnames';
import {
contrastRatio,
inkForSurface,
MIN_CONTRAST_RATIO,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/contrast';
import styles from './CustomBackgroundRow.module.scss';
const HEX_PLACEHOLDER = '#______';
/** What the picker opens on before a colour is chosen. */
const INITIAL_COLOR = '#3A2A63';
interface CustomBackgroundRowProps {
testId: string;
/** The stored hex while a custom colour is active; `undefined` otherwise. */
value: string | undefined;
onChange: (hex: string) => void;
}
/**
* The custom colour, as a row rather than a swatch: it opens a picker instead of
* committing a value in one click. The picker warns below the contrast floor but
* never blocks the choice.
*/
function CustomBackgroundRow({
testId,
value,
onChange,
}: CustomBackgroundRowProps): JSX.Element {
const color = value ?? INITIAL_COLOR;
const ratio = contrastRatio(inkForSurface(color), color);
const isLegible = ratio >= MIN_CONTRAST_RATIO;
const contrastMessage = isLegible
? `Contrast ${ratio.toFixed(1)}:1`
: `Contrast ${ratio.toFixed(1)}:1 — below ${MIN_CONTRAST_RATIO}:1`;
function renderPanel(panel: ReactNode): ReactNode {
return (
<>
{panel}
<div
className={cx(styles.contrast, { [styles.warning]: !isLegible })}
data-testid={`${testId}-contrast`}
>
{!isLegible && <TriangleAlert size={12} />}
<span className="translate-safe">{contrastMessage}</span>
</div>
</>
);
}
return (
<ColorPicker
value={color}
size="small"
showText={false}
trigger="click"
panelRender={renderPanel}
onChangeComplete={(next): void => onChange(next.toHexString())}
>
<button
type="button"
className={cx(styles.row, { [styles.active]: value !== undefined })}
data-testid={testId}
>
<span
className={cx(styles.chip, { [styles.chipEmpty]: value === undefined })}
style={
value ? { background: value, color: inkForSurface(value) } : undefined
}
>
{value !== undefined && <Check size={14} />}
</span>
<Typography.Text className={styles.label}>Custom</Typography.Text>
<span className={cx(styles.hex, 'translate-safe')}>
{value ?? HEX_PLACEHOLDER}
</span>
<ChevronDown size={14} />
</button>
</ColorPicker>
);
}
export default CustomBackgroundRow;

View File

@@ -0,0 +1,118 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { TEXT_BACKGROUND_PAIRS } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import {
PanelTheme,
TextBackgroundKind,
TextBackgroundPreset,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import BackgroundSwatches from '../BackgroundSwatches';
function renderRow(
props: Partial<React.ComponentProps<typeof BackgroundSwatches>> = {},
): jest.Mock {
const onChange = jest.fn();
render(
<TooltipProvider>
<BackgroundSwatches
testId="background"
label="Panel background"
theme={PanelTheme.Dark}
value={TextBackgroundKind.Default}
onChange={onChange}
{...props}
/>
</TooltipProvider>,
);
return onChange;
}
describe('BackgroundSwatches', () => {
it('offers transparent, the default surface and the eight presets in order', () => {
renderRow();
expect(
screen
.getAllByRole('radio')
.map((swatch) => swatch.getAttribute('aria-label')),
).toStrictEqual([
'Transparent',
'Default panel',
'Robin',
'Purple',
'Sakura',
'Cherry',
'Amber',
'Forest',
'Sienna',
'Slate',
]);
});
it('is one labelled group', () => {
renderRow();
expect(
screen.getByRole('radiogroup', { name: 'Panel background' }),
).toBeInTheDocument();
});
it.each([
['background-none', 'Transparent — no card, border or title bar'],
['background-default', 'Default panel colour'],
['background-sakura', 'Sakura'],
])('explains %s on hover', async (swatchId, copy) => {
renderRow();
fireEvent.focus(screen.getByTestId(swatchId));
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(copy);
});
});
it('paints each preset in the given theme', () => {
renderRow({ theme: PanelTheme.Light });
expect(screen.getByTestId('background-amber')).toHaveStyle({
background: TEXT_BACKGROUND_PAIRS.amber.light.surface,
color: TEXT_BACKGROUND_PAIRS.amber.light.ink,
});
});
it('marks only the selected swatch, and checks it', () => {
renderRow({ value: TextBackgroundPreset.Forest });
expect(screen.getByRole('radio', { name: 'Forest' })).toBeChecked();
expect(
screen.getByRole('radio', { name: 'Default panel' }),
).not.toBeChecked();
expect(
screen.getByTestId('background-forest').querySelector('svg'),
).toBeInTheDocument();
expect(
screen.getByTestId('background-default').querySelector('svg'),
).not.toBeInTheDocument();
});
it('reports the swatch that was clicked', () => {
const onChange = renderRow();
fireEvent.click(screen.getByRole('radio', { name: 'Sienna' }));
expect(onChange).toHaveBeenCalledWith('sienna');
});
// jsdom does not implement radio arrow navigation, so the shared name — what
// makes them one group — is what there is to assert.
it('groups every swatch under one radio name', () => {
renderRow();
const names = new Set(
screen.getAllByRole('radio').map((swatch) => swatch.getAttribute('name')),
);
expect(names).toStrictEqual(new Set(['background']));
});
});

View File

@@ -0,0 +1,77 @@
import { fireEvent, render, screen } from '@testing-library/react';
import CustomBackgroundRow from '../CustomBackgroundRow';
function renderRow(value?: string): jest.Mock {
const onChange = jest.fn();
render(
<CustomBackgroundRow testId="custom" value={value} onChange={onChange} />,
);
return onChange;
}
describe('CustomBackgroundRow', () => {
it('stands in for the hex while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom')).toHaveTextContent('#______');
});
it('shows the stored hex once one is set', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom')).toHaveTextContent('#3A2A63');
});
it('checks the chip only while the custom colour is the selection', () => {
renderRow('#3A2A63');
expect(screen.getByTestId('custom').querySelector('svg')).toBeInTheDocument();
});
it('leaves the chip unchecked while no custom colour is set', () => {
renderRow();
expect(screen.getByTestId('custom').querySelectorAll('svg')).toHaveLength(1);
});
describe('the picker', () => {
it('opens on the row', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toBeInTheDocument();
});
it('reports the contrast the colour achieves', () => {
renderRow('#3A2A63');
fireEvent.click(screen.getByTestId('custom'));
// The derived ink is pure white, not purple's paired ink.
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'Contrast 12.4:1',
);
});
it('warns when no ink clears the floor, without disabling anything', () => {
renderRow('#808080');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).toHaveTextContent(
'below 4.5:1',
);
expect(screen.getByTestId('custom')).toBeEnabled();
});
it('says nothing about the floor when the colour clears it', () => {
renderRow('#111111');
fireEvent.click(screen.getByTestId('custom'));
expect(screen.getByTestId('custom-contrast')).not.toHaveTextContent('below');
});
});
});

View File

@@ -23,6 +23,8 @@ import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppea
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
import FormattingSection from './sections/FormattingSection/FormattingSection';
import LegendSection from './sections/LegendSection/LegendSection';
import PanelHeaderSection from './sections/PanelHeaderSection/PanelHeaderSection';
import TextLayoutSection from './sections/TextLayoutSection/TextLayoutSection';
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
@@ -117,6 +119,23 @@ export const SECTION_REGISTRY: {
update: (spec, buckets): PanelSpec =>
updatePluginSlice(spec, 'histogramBuckets', buckets),
},
[SectionKind.TextLayout]: {
Component: TextLayoutSection,
get: (spec): SectionSpecMap[SectionKind.TextLayout] | undefined =>
getPluginSlice<SectionSpecMap[SectionKind.TextLayout]>(spec, 'presentation'),
update: (spec, presentation): PanelSpec =>
updatePluginSlice(spec, 'presentation', presentation),
},
[SectionKind.PanelHeader]: {
Component: PanelHeaderSection,
get: (spec): SectionSpecMap[SectionKind.PanelHeader] | undefined =>
getPluginSlice<SectionSpecMap[SectionKind.PanelHeader]>(
spec,
'headerOptions',
),
update: (spec, headerOptions): PanelSpec =>
updatePluginSlice(spec, 'headerOptions', headerOptions),
},
[SectionKind.ContextLinks]: {
Component: ContextLinksSection,
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.

View File

@@ -0,0 +1,24 @@
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
/** Edits the Text panel's `headerOptions` slice: the panel card's title strip. */
function PanelHeaderSection({
value,
onChange,
}: SectionEditorProps<SectionKind.PanelHeader>): JSX.Element {
return (
<ConfigSwitch
testId="panel-header-hide"
title="Hide header"
description="Drop the title strip on the dashboard; hovering the panel shows controls for drag and actions."
value={value?.hide === true}
onChange={(hide): void => onChange({ ...value, hide })}
/>
);
}
export default PanelHeaderSection;

View File

@@ -0,0 +1,29 @@
import { fireEvent, render, screen } from '@testing-library/react';
import PanelHeaderSection from '../PanelHeaderSection';
describe('PanelHeaderSection', () => {
it('toggles hide on', () => {
const onChange = jest.fn();
render(<PanelHeaderSection value={undefined} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-header-hide'));
expect(onChange).toHaveBeenCalledWith({ hide: true });
});
it('toggles hide back off', () => {
const onChange = jest.fn();
render(<PanelHeaderSection value={{ hide: true }} onChange={onChange} />);
fireEvent.click(screen.getByTestId('panel-header-hide'));
expect(onChange).toHaveBeenCalledWith({ hide: false });
});
it('shows the header by default when the slice is empty', () => {
render(<PanelHeaderSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByTestId('panel-header-hide')).not.toBeChecked();
});
});

View File

@@ -0,0 +1,11 @@
.section {
display: flex;
flex-direction: column;
gap: 16px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}

View File

@@ -0,0 +1,99 @@
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import { Typography } from '@signozhq/ui/typography';
import { useIsDarkMode } from 'hooks/useDarkMode';
import {
resolveTextBackground,
selectionFromResolved,
storedFromSelection,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/resolveTextBackground';
import type { TextBackgroundSelection } from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import {
PanelTheme,
TextBackgroundKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/types';
import type {
SectionEditorProps,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import BackgroundSwatches from '../../controls/BackgroundSwatches/BackgroundSwatches';
import CustomBackgroundRow from '../../controls/BackgroundSwatches/CustomBackgroundRow';
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
import styles from './TextLayoutSection.module.scss';
const HORIZONTAL_OPTIONS = [
{ value: DashboardtypesTextAlignDTO.left, label: 'Left' },
{ value: DashboardtypesTextAlignDTO.center, label: 'Center' },
{ value: DashboardtypesTextAlignDTO.right, label: 'Right' },
];
const VERTICAL_OPTIONS = [
{ value: DashboardtypesVerticalAlignDTO.top, label: 'Top' },
{ value: DashboardtypesVerticalAlignDTO.center, label: 'Middle' },
{ value: DashboardtypesVerticalAlignDTO.bottom, label: 'Bottom' },
];
/**
* Edits the Text panel's `presentation` slice: body alignment and the card
* background (TDD D7 — scoped to the text spec, not the panel envelope).
*/
function TextLayoutSection({
value,
onChange,
}: SectionEditorProps<SectionKind.TextLayout>): JSX.Element {
const theme = useIsDarkMode() ? PanelTheme.Dark : PanelTheme.Light;
const background = resolveTextBackground(value?.background, theme);
return (
<div className={styles.section}>
<div className={styles.field}>
<Typography.Text>Horizontal alignment</Typography.Text>
<ConfigSegmented
testId="text-layout-horizontal-align"
items={HORIZONTAL_OPTIONS}
value={value?.textAlign ?? DashboardtypesTextAlignDTO.left}
onChange={(textAlign): void => onChange({ ...value, textAlign })}
/>
</div>
<div className={styles.field}>
<Typography.Text>Vertical alignment</Typography.Text>
<ConfigSegmented
testId="text-layout-vertical-align"
items={VERTICAL_OPTIONS}
value={value?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top}
onChange={(verticalAlign): void => onChange({ ...value, verticalAlign })}
/>
</div>
<div className={styles.field}>
<Typography.Text>Background</Typography.Text>
<BackgroundSwatches
testId="text-layout-background"
label="Panel background"
theme={theme}
value={selectionFromResolved(background)}
onChange={(selection: TextBackgroundSelection): void =>
onChange({
...value,
background: storedFromSelection(selection, theme),
})
}
/>
<CustomBackgroundRow
testId="text-layout-background-custom"
value={
background.kind === TextBackgroundKind.Custom
? background.surface
: undefined
}
onChange={(hex): void => onChange({ ...value, background: hex })}
/>
</div>
</div>
);
}
export default TextLayoutSection;

View File

@@ -0,0 +1,144 @@
import type { ReactElement } from 'react';
import {
fireEvent,
render as rtlRender,
type RenderResult,
screen,
} from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import {
TEXT_BACKGROUND_PAIRS,
TRANSPARENT_BACKGROUND,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import TextLayoutSection from '../TextLayoutSection';
const value = {
textAlign: DashboardtypesTextAlignDTO.left,
verticalAlign: DashboardtypesVerticalAlignDTO.top,
};
// The swatch tooltips need a provider; AppLayout supplies one at runtime.
function render(ui: ReactElement): RenderResult {
return rtlRender(<TooltipProvider>{ui}</TooltipProvider>);
}
// The theme context defaults to dark, so the swatches paint the dark pairs.
describe('TextLayoutSection', () => {
it('changes horizontal alignment', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Center'));
expect(onChange).toHaveBeenCalledWith({
...value,
textAlign: DashboardtypesTextAlignDTO.center,
});
});
it('changes vertical alignment', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByText('Bottom'));
expect(onChange).toHaveBeenCalledWith({
...value,
verticalAlign: DashboardtypesVerticalAlignDTO.bottom,
});
});
it('stores the surface of the theme a preset was picked in', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByRole('radio', { name: 'Amber' }));
expect(onChange).toHaveBeenCalledWith({
...value,
background: TEXT_BACKGROUND_PAIRS.amber.dark.surface,
});
});
it('stores a zero-alpha colour for transparent', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByRole('radio', { name: 'Transparent' }));
expect(onChange).toHaveBeenCalledWith({
...value,
background: TRANSPARENT_BACKGROUND,
});
});
it('unsets the background for the default panel surface', () => {
const onChange = jest.fn();
render(
<TextLayoutSection
value={{ ...value, background: TRANSPARENT_BACKGROUND }}
onChange={onChange}
/>,
);
fireEvent.click(screen.getByRole('radio', { name: 'Default panel' }));
expect(onChange).toHaveBeenCalledWith({ ...value, background: undefined });
});
it('lights up the swatch the stored surface belongs to', () => {
render(
<TextLayoutSection
value={{ ...value, background: TEXT_BACKGROUND_PAIRS.sakura.light.surface }}
onChange={jest.fn()}
/>,
);
expect(screen.getByRole('radio', { name: 'Sakura' })).toBeChecked();
});
it('stores a custom colour straight from the picker', () => {
const onChange = jest.fn();
render(<TextLayoutSection value={value} onChange={onChange} />);
fireEvent.click(screen.getByTestId('text-layout-background-custom'));
fireEvent.change(screen.getByRole('textbox'), {
target: { value: '3A2A64' },
});
expect(onChange).toHaveBeenCalledWith({
...value,
background: '#3a2a64',
});
});
it('shows a stored custom colour on the custom row alone', () => {
render(
<TextLayoutSection
value={{ ...value, background: '#3A2A64' }}
onChange={jest.fn()}
/>,
);
expect(screen.getByTestId('text-layout-background-custom')).toHaveTextContent(
'#3A2A64',
);
expect(
screen
.getAllByRole<HTMLInputElement>('radio')
.filter((swatch) => swatch.checked),
).toHaveLength(0);
});
it('selects the default surface when nothing is stored', () => {
render(<TextLayoutSection value={undefined} onChange={jest.fn()} />);
expect(screen.getByRole('radio', { name: 'Default panel' })).toBeChecked();
expect(screen.getByRole('radio', { name: 'Transparent' })).not.toBeChecked();
});
});

View File

@@ -26,16 +26,3 @@
background: var(--l2-border);
}
}
// The static editor's preview: the panel card the grid shows, minus actions.
.staticPreviewSurface {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
margin: 12px;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}

View File

@@ -23,7 +23,7 @@ import { EQueryType } from 'types/common/dashboard';
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
import { toPanelType } from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
@@ -60,7 +60,7 @@ function PanelEditorQueryBuilder({
}: PanelEditorQueryBuilderProps): JSX.Element {
// The shared QueryBuilderV2 provider still speaks the legacy PANEL_TYPES; what the
// builder offers for this kind comes from the kind's own declaration.
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
const panelType = toPanelType(panelDefinition.kind);
// Raw rows: the builder drops its aggregation controls, and with them the trace
// operator that combines aggregated trace queries (V1 parity).
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';

View File

@@ -9,6 +9,13 @@
border-bottom: 1px solid var(--l1-border);
}
// A static pane never scrolls — the panel card clips, and the renderer scrolls its
// own body when the content outgrows it, as on the grid.
.previewStatic {
box-sizing: border-box;
overflow: hidden;
}
.header {
width: 100%;
box-sizing: border-box;
@@ -56,6 +63,14 @@
overflow: visible;
}
// A static kind's card takes its colours from the background the panel declares,
// falling back to the same tokens the query surface uses.
.surfaceStatic {
border-color: var(--text-panel-border, var(--l2-border));
background: var(--text-panel-surface, var(--l2-background));
color: var(--text-panel-ink, inherit);
}
.state {
flex: 1;
display: flex;

View File

@@ -5,10 +5,16 @@ import { PanelMode } from 'lib/visualization/panels/types';
import DateTimeSelectionV2 from 'container/TopNav/DateTimeSelectionV2';
import PanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelBody/PanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import { useTextBackground } from 'pages/DashboardPage/DashboardContainer/Panels/hooks/useTextBackground';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type {
RenderableQueryPanelDefinition,
RenderableStaticPanelDefinition,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { getPanelQueryType } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getPanelQueryType';
import { isPanelHeaderHidden } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isPanelHeaderHidden';
import type {
PanelPagination,
PanelQueryData,
@@ -17,9 +23,15 @@ import type {
import PlotTag from './PlotTag';
import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
interface PreviewPaneBaseProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
panelMode?: PanelMode;
}
interface QueryPreviewPaneProps extends PreviewPaneBaseProps {
mode: 'query';
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
panelDefinition: RenderableQueryPanelDefinition;
data: PanelQueryData;
@@ -34,8 +46,6 @@ interface PreviewPaneProps {
onDragSelect: (start: number, end: number) => void;
/** Server-side pager for raw/list panels; absent for non-paginated panels. */
pagination?: PanelPagination;
/** Render context — defaults to the editor's DASHBOARD_EDIT; the View modal passes STANDALONE_VIEW. */
panelMode?: PanelMode;
/** Hide the preview's top row entirely (query-type badge + time picker) — the View modal has its own header. */
hideHeader?: boolean;
/** Dashboard-wide preferences (cursor sync, …) forwarded to the body; the modal isolates cursor-sync. */
@@ -48,41 +58,43 @@ interface PreviewPaneProps {
enableDrillDown?: boolean;
}
interface StaticPreviewPaneProps extends PreviewPaneBaseProps {
mode: 'static';
/** The kind's definition, narrowed to the static arm — no query, no Run step. */
panelDefinition: RenderableStaticPanelDefinition;
/** Saves an edit made from the rendered body into the draft; absent = read-only. */
onChangeText?: (text: string) => void;
}
type PreviewPaneProps = QueryPreviewPaneProps | StaticPreviewPaneProps;
/**
* Live preview for the panel editor: renders the draft through the same `PanelBody`
* the dashboard grid uses (only `panelMode` differs), so the preview is the
* production render path. The query result is owned by the editor root.
* Live preview for the panel editor and the View modal: the draft rendered through
* the same body the dashboard grid uses (only `panelMode` differs), so the preview
* is the production render path. A query draft's result is owned by the editor
* root; a static draft re-renders straight from the spec on every edit.
*/
function PreviewPane({
panelId,
panel,
panelDefinition,
data,
isFetching,
isPreviousData,
error,
refetch,
onDragSelect,
pagination,
panelMode = PanelMode.DASHBOARD_EDIT,
hideHeader = false,
dashboardPreference,
onCloseStandaloneView,
onClick,
enableDrillDown,
}: PreviewPaneProps): JSX.Element {
const queryType = getPanelQueryType(panel);
function PreviewPane(props: PreviewPaneProps): JSX.Element {
const { panelId, panel, panelMode = PanelMode.DASHBOARD_EDIT } = props;
const query = props.mode === 'query' ? props : null;
const staticDraft = props.mode === 'static' ? props : null;
const background = useTextBackground(panel.spec);
// Search term is ephemeral preview state, threaded to header + renderer but
// not persisted to the draft spec. Only kinds that declare it render the box.
const searchable = !!panelDefinition.actions.search;
const searchable = !!query?.panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
return (
<div className={styles.preview}>
{!hideHeader && (
<div
className={cx(styles.preview, { [styles.previewStatic]: !!staticDraft })}
>
{query && !query.hideHeader && (
<div className={styles.header}>
<PlotTag queryType={queryType} className={styles.queryType} />
<PlotTag
queryType={getPanelQueryType(panel)}
className={styles.queryType}
/>
<div className={styles.dateTimeSelector}>
<DateTimeSelectionV2 showAutoRefresh hideShareModal />
</div>
@@ -91,39 +103,67 @@ function PreviewPane({
<div className={styles.container}>
<div
className={cx(styles.surface, {
[styles.surfaceStacked]: panelMode === PanelMode.STANDALONE_VIEW,
[styles.surfaceStacked]:
!!query && panelMode === PanelMode.STANDALONE_VIEW,
[styles.surfaceStatic]: !!staticDraft,
})}
style={background.style}
>
<PanelHeader
panelId={panelId}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
hideActions
/>
<PanelBody
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
panelMode={panelMode}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onCloseStandaloneView={onCloseStandaloneView}
onClick={onClick}
enableDrillDown={enableDrillDown}
/>
{query ? (
<>
<PanelHeader
mode="query"
panelId={panelId}
panel={panel}
data={query.data}
isFetching={query.isFetching}
error={query.error}
warning={query.data.response?.data?.warning}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
hideActions
/>
<PanelBody
Renderer={query.panelDefinition.Renderer}
panel={panel}
panelId={panelId}
data={query.data}
isFetching={query.isFetching}
isPreviousData={query.isPreviousData}
error={query.error}
refetch={query.refetch}
onDragSelect={query.onDragSelect}
panelMode={panelMode}
dashboardPreference={query.dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={query.pagination}
onCloseStandaloneView={query.onCloseStandaloneView}
onClick={query.onClick}
enableDrillDown={query.enableDrillDown}
/>
</>
) : (
staticDraft && (
<>
{!isPanelHeaderHidden(panel.spec) && (
<PanelHeader
mode="static"
panelId={panelId}
panel={panel}
hideActions
/>
)}
<StaticPanelBody
Renderer={staticDraft.panelDefinition.Renderer}
panel={panel}
panelId={panelId}
panelMode={panelMode}
onChangeText={staticDraft.onChangeText}
/>
</>
)
)}
</div>
</div>
</div>

View File

@@ -8,7 +8,7 @@ import {
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
@@ -217,7 +217,7 @@ function QueryEditorBody({
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
panelType: toPanelType(panelKind),
query: currentQuery,
spec: draft.spec,
});
@@ -286,6 +286,7 @@ function QueryEditorBody({
}
preview={
<PreviewPane
mode="query"
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}

View File

@@ -1,11 +1,8 @@
import { useCallback } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { PanelMode } from 'lib/visualization/panels/types';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { EQueryType } from 'types/common/dashboard';
import { useErrorModal } from 'providers/ErrorModalProvider';
@@ -16,12 +13,12 @@ import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import PreviewPane from './PreviewPane/PreviewPane';
import type { PanelEditorContainerProps } from './index';
import type { PanelEditorDraftApi } from './types';
import { withPanelText } from '../Panels/utils/withPanelText';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import styles from './PanelEditor.module.scss';
interface StaticEditorBodyProps extends PanelEditorContainerProps {
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
@@ -51,7 +48,7 @@ function StaticEditorBody({
useDashboardEditContext();
const { draft, spec, setSpec, isSpecDirty } = draftApi;
const { EditorPane, Renderer } = panelDefinition;
const { EditorPane } = panelDefinition;
const { save, isSaving } = usePanelEditorSave({
dashboardId,
@@ -80,6 +77,11 @@ function StaticEditorBody({
}
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
const onChangeText = useCallback(
(text: string): void => setSpec(withPanelText(spec, text)),
[spec, setSpec],
);
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
@@ -103,22 +105,14 @@ function StaticEditorBody({
/>
}
preview={
<div className={styles.staticPreviewSurface}>
<PanelHeader
panelId={panelId}
panel={draft}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
hideActions
/>
<StaticPanelBody
Renderer={Renderer}
panel={draft}
panelId={panelId}
panelMode={PanelMode.DASHBOARD_EDIT}
/>
</div>
<PreviewPane
mode="static"
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
panelMode={PanelMode.DASHBOARD_EDIT}
onChangeText={isEditable ? onChangeText : undefined}
/>
}
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
config={

View File

@@ -0,0 +1,142 @@
import { fireEvent, render, screen, waitFor } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import StaticEditorBody from '../StaticEditorBody';
import type { PanelEditorDraftApi } from '../types';
import { usePanelEditorSave } from '../hooks/usePanelEditorSave';
jest.mock('../hooks/usePanelEditorSave', () => ({
usePanelEditorSave: jest.fn(),
}));
// Chrome + collaborators stubbed: this suite asserts the static body's wiring —
// the save shape above all — not their internals.
jest.mock('../Header/Header', () => ({
__esModule: true,
default: ({ onSave }: { onSave: () => void }): JSX.Element => (
<button type="button" data-testid="header-save" onClick={onSave}>
Save
</button>
),
}));
jest.mock('../ConfigPane/ConfigPane', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="config-pane" />,
}));
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader',
() => ({ __esModule: true, default: (): null => null }),
);
jest.mock(
'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody',
() => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="static-preview-body" />,
}),
);
jest.mock('@signozhq/ui/sonner', () => ({ toast: { success: jest.fn() } }));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): unknown => ({ showErrorModal: jest.fn() }),
}));
// The derivation has its own suite (useDashboardEditContext.authz); these cases are
// about what the static body does with a given edit context, so control it directly.
let editContext = { isEditable: true, editChecks: [], editDisabledTooltip: '' };
jest.mock(
'pages/DashboardPage/DashboardContainer/hooks/useDashboardEditContext',
() => ({
useDashboardEditContext: (): typeof editContext => editContext,
}),
);
const mockUseSave = usePanelEditorSave as jest.Mock;
// The draft deliberately carries a stray query: Save must strip it — the API
// rejects anything but [] for a static kind.
const draft = {
kind: 'Panel',
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text: '# hi' } },
queries: [{ spec: {} }],
},
} as unknown as DashboardtypesPanelDTO;
const draftApi: PanelEditorDraftApi = {
draft,
spec: draft.spec,
setSpec: jest.fn(),
isSpecDirty: false,
reset: jest.fn(),
};
const definition = {
kind: 'signoz/TextPanel',
displayName: 'Text',
sections: [],
actions: {},
mode: 'static',
Renderer: (): null => null,
EditorPane: (): JSX.Element => <div data-testid="editor-pane" />,
} as unknown as RenderableStaticPanelDefinition;
function renderBody(): void {
render(
<StaticEditorBody
dashboardId="d1"
panelId="p1"
panel={draft}
onClose={jest.fn()}
onSaved={jest.fn()}
draftApi={draftApi}
panelDefinition={definition}
onChangePanelKind={jest.fn()}
/>,
);
}
describe('StaticEditorBody', () => {
beforeEach(() => {
mockUseSave.mockReset();
editContext = { isEditable: true, editChecks: [], editDisabledTooltip: '' };
mockUseSave.mockReturnValue({
save: jest.fn().mockResolvedValue('p1'),
isSaving: false,
});
});
it('renders the editor pane and the live preview, no query builder', () => {
renderBody();
expect(screen.getByTestId('editor-pane')).toBeInTheDocument();
expect(screen.getByTestId('static-preview-body')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-query-builder'),
).not.toBeInTheDocument();
});
it('saves the spec with queries forced to [] — the only shape the API accepts', async () => {
const save = jest.fn().mockResolvedValue('p1');
mockUseSave.mockReturnValue({ save, isSaving: false });
renderBody();
fireEvent.click(screen.getByTestId('header-save'));
await waitFor(() => expect(save).toHaveBeenCalledTimes(1));
expect(save).toHaveBeenCalledWith({ ...draft.spec, queries: [] });
});
it('does not save when the dashboard is not editable', () => {
const save = jest.fn();
mockUseSave.mockReturnValue({ save, isSaving: false });
editContext = {
isEditable: false,
editChecks: [],
editDisabledTooltip: 'Dashboard is locked',
};
renderBody();
fireEvent.click(screen.getByTestId('header-save'));
expect(save).not.toHaveBeenCalled();
});
});

View File

@@ -115,3 +115,15 @@ describe('newPanelRoute', () => {
});
});
});
describe('parseNewPanelKind — kinds without a legacy panel type', () => {
it('accepts a registered static kind', () => {
expect(parseNewPanelKind('new', '?panelKind=signoz%2FTextPanel')).toBe(
'signoz/TextPanel',
);
});
it('still rejects a kind that is not registered', () => {
expect(parseNewPanelKind('new', '?panelKind=signoz%2FNopePanel')).toBeNull();
});
});

View File

@@ -7,7 +7,7 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
@@ -90,7 +90,7 @@ export function usePanelEditSession({
// Hosts fork on `definition.mode` before mounting this session (the editor and
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const panelType = toPanelType(panelKind);
const defaultSignal = panelDefinition.supportedSignals[0];
const query = usePanelQuery({

View File

@@ -19,10 +19,7 @@ import type {
} from 'types/api/queryBuilder/queryBuilderData';
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import { toPanelType, type PanelKind } from '../../Panels/types/panelKind';
import { getBuilderQueries } from '../../Panels/utils/getBuilderQueries';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import {
@@ -110,7 +107,7 @@ export function usePanelTypeSwitch({
builderQuery: query,
});
const newPanelType = PANEL_KIND_TO_PANEL_TYPE[newKind];
const newPanelType = toPanelType(newKind);
// Only `plugin` needs a cast: it's a discriminated union over `kind`, and a
// dynamically-chosen kind can't be correlated with its spec statically (as in

View File

@@ -1,6 +1,6 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
@@ -41,7 +41,7 @@ function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
panelType: toPanelType(panelKind),
setSpec: draftApi.setSpec,
});

View File

@@ -4,8 +4,8 @@ import type { PANEL_TYPES } from 'constants/queryBuilder';
import ROUTES from 'constants/routes';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { PANELS } from '../Panels/registry';
import {
PANEL_KIND_TO_PANEL_TYPE,
PANEL_TYPE_TO_PANEL_KIND,
type PanelKind,
} from '../Panels/types/panelKind';
@@ -40,7 +40,9 @@ export function parseNewPanelKind(
return null;
}
const kind = new URLSearchParams(search).get(PANEL_KIND_PARAM);
return kind && kind in PANEL_KIND_TO_PANEL_TYPE ? (kind as PanelKind) : null;
// Gated on the registry, not the legacy map — a static kind has no legacy
// panel type, and the map would reject its route as a stale link.
return kind && kind in PANELS ? (kind as PanelKind) : null;
}
/**

View File

@@ -34,6 +34,8 @@ const EXPECTED_QUERY_TYPES: Record<PanelKind, EQueryType[]> = {
'signoz/PieChartPanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/TablePanel': [QUERY_BUILDER, CLICKHOUSE],
'signoz/ListPanel': [QUERY_BUILDER],
// Static kind: no query surface at all.
'signoz/TextPanel': [],
};
const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
@@ -45,11 +47,16 @@ const EXPECTED_SIGNALS: Record<PanelKind, TelemetrytypesSignalDTO[]> = {
'signoz/TablePanel': [metrics, logs, traces],
// List renders raw rows; metrics produce no row data.
'signoz/ListPanel': [logs, traces],
'signoz/TextPanel': [],
};
// Exhaustive over PanelKind, so a new kind can't ship without stating how its request is
// shaped — the check that used to be implicit in a legacy PANEL_TYPES switch.
const EXPECTED_QUERY_CAPABILITIES: Record<PanelKind, PanelQueryCapabilities> = {
// Partial: a static kind declares no query capabilities — the lookup below
// resolves undefined on both sides for it.
const EXPECTED_QUERY_CAPABILITIES: Partial<
Record<PanelKind, PanelQueryCapabilities>
> = {
'signoz/TimeSeriesPanel': {
requestType: time_series,
formatTableResultForUI: false,

View File

@@ -8,7 +8,7 @@ import {
selectViewPanelExtendWindow,
useViewPanelStore,
} from '../../../store/useViewPanelStore';
import { PANEL_KIND_TO_PANEL_TYPE } from '../../types/panelKind';
import { toPanelType } from '../../types/panelKind';
import PanelLoader from '../PanelLoader/PanelLoader';
import PanelMessage, { PanelMessageAction } from '../PanelMessage/PanelMessage';
import { useExtendTimeWindow } from './useExtendTimeWindow';
@@ -57,7 +57,7 @@ function NoData({
// `panelType` stays on the event so existing reports keep resolving; `panelKind` is the
// V2 identity, and the only one that can tell two kinds sharing a panel type apart.
const panelKind = panel.spec.plugin.kind;
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const panelType = toPanelType(panelKind);
const extendAction: PanelMessageAction | undefined =
activeExtend?.canExtend && activeExtend.actionLabel

View File

@@ -0,0 +1,147 @@
import { renderHook } from '@testing-library/react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { rgbaFromHex } from '../../kinds/TextPanel/background/contrast';
import {
INK_ALPHAS,
SECONDARY_INK_OPACITY,
TEXT_BACKGROUND_PAIRS,
TRANSPARENT_BACKGROUND,
} from '../../kinds/TextPanel/background/presets';
import { useTextBackground } from '../useTextBackground';
const isDarkMode = jest.fn<boolean, []>(() => true);
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => isDarkMode(),
}));
function textPanel(background?: string): DashboardtypesPanelSpecDTO {
return {
display: { name: 'Panel' },
plugin: {
kind: 'signoz/TextPanel',
spec: { text: '', presentation: { background } },
},
queries: [],
} as unknown as DashboardtypesPanelSpecDTO;
}
describe('useTextBackground', () => {
beforeEach(() => {
isDarkMode.mockReturnValue(true);
});
it('sets no custom properties for the default surface', () => {
const { result } = renderHook(() => useTextBackground(textPanel()));
expect(result.current).toStrictEqual({ kind: 'default', style: {} });
});
// The card, its border and the header's divider all read these.
it('paints a zero-alpha background transparent rather than dropping the card', () => {
const { result } = renderHook(() =>
useTextBackground(textPanel(TRANSPARENT_BACKGROUND)),
);
expect(result.current).toStrictEqual({
kind: 'none',
style: {
'--text-panel-surface': 'transparent',
'--text-panel-border': 'transparent',
},
});
});
it('exposes the preset pair for the current theme', () => {
const { result } = renderHook(() =>
useTextBackground(textPanel(TEXT_BACKGROUND_PAIRS.amber.dark.surface)),
);
const { ink } = TEXT_BACKGROUND_PAIRS.amber.dark;
expect(result.current.style).toStrictEqual({
'--text-panel-surface': TEXT_BACKGROUND_PAIRS.amber.dark.surface,
'--text-panel-ink': ink,
'--text-panel-border': 'rgba(255, 255, 255, 0.09)',
'--text-panel-link-decoration': 'underline',
'--text-panel-ink-secondary': rgbaFromHex(ink, SECONDARY_INK_OPACITY),
'--text-panel-grip': rgbaFromHex(ink, INK_ALPHAS['--text-panel-grip']),
'--scrollbar-thumb': rgbaFromHex(ink, INK_ALPHAS['--scrollbar-thumb']),
'--scrollbar-thumb-hover': rgbaFromHex(
ink,
INK_ALPHAS['--scrollbar-thumb-hover'],
),
'--text-panel-pill-surface': rgbaFromHex(
ink,
INK_ALPHAS['--text-panel-pill-surface'],
),
});
});
it('draws the surface chrome from the ink', () => {
const { result } = renderHook(() =>
useTextBackground(textPanel(TEXT_BACKGROUND_PAIRS.amber.light.surface)),
);
Object.keys(INK_ALPHAS).forEach((name) => {
expect(result.current.style).toHaveProperty(name);
});
});
// Stored light, read in dark: the ink is the dark pair's.
it('carries the secondary ink and the link underline', () => {
const { result } = renderHook(() =>
useTextBackground(textPanel(TEXT_BACKGROUND_PAIRS.sakura.light.surface)),
);
expect(result.current.style).toMatchObject({
'--text-panel-ink-secondary': `rgba(253, 232, 242, ${SECONDARY_INK_OPACITY})`,
'--text-panel-link-decoration': 'underline',
});
});
it('re-resolves a stored surface when the theme changes', () => {
const spec = textPanel(TEXT_BACKGROUND_PAIRS.forest.dark.surface);
const { result, rerender } = renderHook(() => useTextBackground(spec));
expect(result.current.style).toMatchObject({
'--text-panel-surface': TEXT_BACKGROUND_PAIRS.forest.dark.surface,
});
isDarkMode.mockReturnValue(false);
rerender();
expect(result.current.style).toMatchObject({
'--text-panel-surface': TEXT_BACKGROUND_PAIRS.forest.light.surface,
'--text-panel-ink': TEXT_BACKGROUND_PAIRS.forest.light.ink,
'--text-panel-border': 'rgba(0, 0, 0, 0.07)',
});
});
it('paints a custom colour the same in both themes', () => {
const spec = textPanel('#3A2A64');
const { result, rerender } = renderHook(() => useTextBackground(spec));
const inDark = result.current.style;
isDarkMode.mockReturnValue(false);
rerender();
expect(result.current.style).toMatchObject({
'--text-panel-surface': '#3A2A64',
'--text-panel-ink': inDark['--text-panel-ink' as keyof typeof inDark],
});
});
it('leaves a kind without a presentation slice alone', () => {
const { result } = renderHook(() =>
useTextBackground({
display: { name: 'Panel' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
} as unknown as DashboardtypesPanelSpecDTO),
);
expect(result.current).toStrictEqual({ kind: 'default', style: {} });
});
});

View File

@@ -0,0 +1,73 @@
import { renderHook } from '@testing-library/react';
import { useUpdatePanelText } from '../useUpdatePanelText';
const patchAsync = jest.fn<Promise<unknown>, [unknown]>(() =>
Promise.resolve(undefined),
);
const showErrorModal = jest.fn();
let store = { dashboardId: 'dash-1' };
let editContext = { isEditable: true };
jest.mock('../../../hooks/useOptimisticPatch', () => ({
useOptimisticPatch: (): unknown => ({ patchAsync }),
}));
jest.mock('providers/ErrorModalProvider', () => ({
useErrorModal: (): unknown => ({ showErrorModal }),
}));
jest.mock('../../../store/useDashboardStore', () => ({
useDashboardStore: (select: (s: typeof store) => unknown): unknown =>
select(store),
}));
jest.mock('../../../hooks/useDashboardEditContext', () => ({
useDashboardEditContext: (): typeof editContext => editContext,
}));
describe('useUpdatePanelText', () => {
beforeEach(() => {
jest.clearAllMocks();
store = { dashboardId: 'dash-1' };
editContext = { isEditable: true };
});
it('patches the panel body', () => {
const { result } = renderHook(() => useUpdatePanelText('p1'));
result.current?.('- [x] done');
expect(patchAsync).toHaveBeenCalledWith([
{
op: 'add',
path: '/spec/panels/p1/spec/plugin/spec/text',
value: '- [x] done',
},
]);
});
it('gives no callback when the viewer cannot edit', () => {
editContext = { isEditable: false };
const { result } = renderHook(() => useUpdatePanelText('p1'));
expect(result.current).toBeUndefined();
});
it('gives no callback outside a dashboard', () => {
store = { dashboardId: '' };
const { result } = renderHook(() => useUpdatePanelText('p1'));
expect(result.current).toBeUndefined();
});
it('surfaces a failed save', async () => {
const failure = new Error('locked');
patchAsync.mockRejectedValueOnce(failure);
const { result } = renderHook(() => useUpdatePanelText('p1'));
result.current?.('- [x] done');
await Promise.resolve();
expect(showErrorModal).toHaveBeenCalledWith(failure);
});
});

View File

@@ -0,0 +1,67 @@
import {
type RefObject,
useCallback,
useEffect,
useRef,
useState,
} from 'react';
// Within this distance of the end counts as "at the bottom", so the pill isn't
// kept alive by sub-pixel rounding or a trailing margin.
const BOTTOM_EPSILON_PX = 16;
interface UseOverflowBelowResult<T extends HTMLElement> {
scrollRef: RefObject<T>;
/** Content extends below the fold and the user isn't at the bottom yet. */
hasMoreBelow: boolean;
scrollToBottom: () => void;
}
/**
* Tracks whether a scroll container has unseen content below the fold. Re-measures
* on scroll, on container resize, and on every commit — the cheap way to follow
* content growth (a live preview re-rendering as the body is typed) without
* observing the subtree.
*/
export function useOverflowBelow<
T extends HTMLElement,
>(): UseOverflowBelowResult<T> {
const scrollRef = useRef<T>(null);
const [hasMoreBelow, setHasMoreBelow] = useState(false);
const measure = useCallback((): void => {
const el = scrollRef.current;
if (!el) {
return;
}
const remaining = el.scrollHeight - el.scrollTop - el.clientHeight;
setHasMoreBelow(remaining > BOTTOM_EPSILON_PX);
}, []);
// No deps on purpose: runs after every commit. setState bails on unchanged
// values, so this settles instead of looping.
useEffect(() => {
measure();
});
useEffect(() => {
const el = scrollRef.current;
if (!el) {
return undefined;
}
el.addEventListener('scroll', measure, { passive: true });
const observer = new ResizeObserver(measure);
observer.observe(el);
return (): void => {
el.removeEventListener('scroll', measure);
observer.disconnect();
};
}, [measure]);
const scrollToBottom = useCallback((): void => {
const el = scrollRef.current;
el?.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
}, []);
return { scrollRef, hasMoreBelow, scrollToBottom };
}

View File

@@ -1,6 +1,6 @@
import { useEffect, useState } from 'react';
import { isLanguageRegistered, loadLanguage } from './syntaxLanguages';
import { isLanguageRegistered, loadLanguage } from '../utils/syntaxLanguages';
/**
* Registers `language` with Prism on demand, reporting when it is ready to

View File

@@ -0,0 +1,83 @@
import { useMemo } from 'react';
import type { CSSProperties } from 'react';
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { rgbaFromHex } from '../kinds/TextPanel/background/contrast';
import {
INK_ALPHAS,
PRESET_BORDER,
} from '../kinds/TextPanel/background/presets';
import { resolveTextBackground } from '../kinds/TextPanel/background/resolveTextBackground';
import {
PanelTheme,
TextBackgroundKind,
} from '../kinds/TextPanel/background/types';
export interface TextBackground {
kind: TextBackgroundKind;
/**
* Custom properties for the card root. Empty for `default`, so the stylesheet's
* own fallbacks decide — nothing here hardcodes a surface.
*/
style: CSSProperties;
}
const NO_STYLE: CSSProperties = {};
// D7: `None` drops the card so the body sits on the dashboard canvas. It rides the
// same properties as a colour, which takes the header's divider with it.
const CARDLESS_STYLE = {
'--text-panel-surface': 'transparent',
'--text-panel-border': 'transparent',
} as CSSProperties;
function inkShares(ink: string): Record<string, string> {
return Object.fromEntries(
Object.entries(INK_ALPHAS).map(([name, alpha]) => [
name,
rgbaFromHex(ink, alpha) ?? ink,
]),
);
}
/**
* The card is an ancestor of the renderer, so the host owns these properties and
* everything below inherits them.
*
* Reading one plugin-spec field off the kind union is the accepted smell (TDD
* D7): a dynamic kind can't narrow it, hence one localized cast per host.
*/
export function useTextBackground(
spec: DashboardtypesPanelSpecDTO,
): TextBackground {
const isDarkMode = useIsDarkMode();
const background = (
spec.plugin.spec as {
presentation?: { background?: string | null };
}
).presentation?.background;
return useMemo(() => {
const theme = isDarkMode ? PanelTheme.Dark : PanelTheme.Light;
const resolved = resolveTextBackground(background, theme);
if (resolved.kind === TextBackgroundKind.None) {
return { kind: resolved.kind, style: CARDLESS_STYLE };
}
return {
kind: resolved.kind,
style:
resolved.surface && resolved.ink
? ({
'--text-panel-surface': resolved.surface,
'--text-panel-ink': resolved.ink,
'--text-panel-border': PRESET_BORDER[theme],
'--text-panel-link-decoration': 'underline',
...inkShares(resolved.ink),
} as CSSProperties)
: NO_STYLE,
};
}, [background, isDarkMode]);
}

View File

@@ -0,0 +1,33 @@
import { useCallback } from 'react';
import { useErrorModal } from 'providers/ErrorModalProvider';
import type APIError from 'types/api/error';
import { useDashboardEditContext } from '../../hooks/useDashboardEditContext';
import { useOptimisticPatch } from '../../hooks/useOptimisticPatch';
import { setPanelTextOp } from '../../patchOps';
import { useDashboardStore } from '../../store/useDashboardStore';
/**
* Saves a panel's authored body, or `undefined` when the viewer cannot edit it —
* the absent callback is the read-only gate, so nothing downstream re-checks.
* The patch is optimistic: the edit shows at once and rolls back if it fails.
*/
export function useUpdatePanelText(
panelId: string,
): ((text: string) => void) | undefined {
const dashboardId = useDashboardStore((s) => s.dashboardId);
const { isEditable } = useDashboardEditContext();
const { patchAsync } = useOptimisticPatch();
const { showErrorModal } = useErrorModal();
const save = useCallback(
(text: string): void => {
patchAsync([setPanelTextOp(panelId, text)]).catch((error) => {
showErrorModal(error as APIError);
});
},
[panelId, patchAsync, showErrorModal],
);
return dashboardId && isEditable ? save : undefined;
}

View File

@@ -0,0 +1,69 @@
@use '../../../../../../styles/scrollbar' as *;
.panel {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
padding: 8px 12px;
overflow: auto;
@include custom-scrollbar;
}
// Horizontal alignment inherits into the rendered body, which deliberately leaves
// `text-align` alone so the panel can own it.
.alignLeft {
text-align: left;
}
.alignCenter {
text-align: center;
}
.alignRight {
text-align: right;
}
// `text-align` moves only inline content: the table is a block box inside its
// scroll wrapper and stays put, and list markers hang at the list's left edge.
// (0,2,1) beats the body reset at (0,2,0); `list-style-position` goes on the
// `li` directly because the body's `list-style` shorthand on `ul`/`ol` resets
// the inherited position.
.panel.alignRight table {
margin-left: auto;
}
.panel.alignCenter table {
margin-left: auto;
margin-right: auto;
}
.panel.alignRight li,
.panel.alignCenter li {
list-style-position: inside;
}
.alignTop {
justify-content: flex-start;
}
// Auto margins, not `justify-content`: when the body overflows, an auto margin
// resolves to zero so the content's top stays scrollable — `center`/`flex-end`
// push the overflow above the scrollport, where no scroll position reaches it.
// Specificity (0,3,0): the body root's `all: revert` reset sits at (0,2,0) and
// would strip a tied margin rule.
.panel.alignMiddle > *:first-child {
margin-top: auto;
margin-bottom: auto;
}
.panel.alignBottom > *:first-child {
margin-top: auto;
}
// Positioning context for the scroll-to-bottom pill floating over the body.
.host {
position: relative;
height: 100%;
min-height: 0;
}

View File

@@ -0,0 +1,101 @@
import { useMemo } from 'react';
import { Pencil } from '@signozhq/icons';
import cx from 'classnames';
import {
DashboardtypesTextAlignDTO,
DashboardtypesVerticalAlignDTO,
} from 'api/generated/services/sigNoz.schemas';
import { selectResolvedVariables } from 'pages/DashboardPage/DashboardContainer/store/slices/variableSelectionSlice';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import PanelMessage from '../../components/PanelMessage/PanelMessage';
import type { StaticRendererProps } from '../../types/rendererProps';
import { interpolateVariables } from '../../utils/interpolateVariables';
import MarkdownContent from './components/MarkdownContent/MarkdownContent';
import ScrollToBottomPill from './components/ScrollToBottomPill/ScrollToBottomPill';
import { useOverflowBelow } from '../../hooks/useOverflowBelow';
import styles from './Renderer.module.scss';
const HORIZONTAL_ALIGN_CLASS: Record<DashboardtypesTextAlignDTO, string> = {
[DashboardtypesTextAlignDTO.left]: styles.alignLeft,
[DashboardtypesTextAlignDTO.center]: styles.alignCenter,
[DashboardtypesTextAlignDTO.right]: styles.alignRight,
};
// Static, so it is not rebuilt on every variable tick.
const EMPTY_STATE = (
<PanelMessage
icon={<Pencil size={18} />}
title="Nothing written yet"
description="Add Markdown to this panel to show content."
data-testid="text-panel-empty"
/>
);
const VERTICAL_ALIGN_CLASS: Record<DashboardtypesVerticalAlignDTO, string> = {
[DashboardtypesVerticalAlignDTO.top]: styles.alignTop,
[DashboardtypesVerticalAlignDTO.center]: styles.alignMiddle,
[DashboardtypesVerticalAlignDTO.bottom]: styles.alignBottom,
};
/**
* Renders the panel's own Markdown body. The first kind that issues no query, so it
* reads nothing from `data` and has no loading or error state — malformed Markdown
* renders as literal text rather than throwing.
*/
function Renderer({
panel,
dashboardId,
onChangeText,
}: StaticRendererProps<'signoz/TextPanel'>): JSX.Element {
const { text, presentation } = panel.spec.plugin.spec;
const variables = useDashboardStore(
selectResolvedVariables(dashboardId ?? ''),
);
// Interpolate and parse together: a dashboard re-renders on every variable tick,
// and re-parsing every text panel on each one is the cost worth avoiding.
const body = useMemo(
() => interpolateVariables(text ?? '', variables),
[text, variables],
);
// The authored body, not the interpolated one: an edit lands on what is saved.
const interactive = useMemo(
() =>
onChangeText
? { source: text ?? '', onChangeSource: onChangeText }
: undefined,
[onChangeText, text],
);
const { scrollRef, hasMoreBelow, scrollToBottom } =
useOverflowBelow<HTMLDivElement>();
return (
<div className={styles.host}>
<div
ref={scrollRef}
className={cx(
styles.panel,
HORIZONTAL_ALIGN_CLASS[
presentation?.textAlign ?? DashboardtypesTextAlignDTO.left
],
VERTICAL_ALIGN_CLASS[
presentation?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top
],
)}
data-testid="text-panel"
>
<MarkdownContent interactive={interactive} emptyState={EMPTY_STATE}>
{body}
</MarkdownContent>
</div>
{hasMoreBelow && <ScrollToBottomPill onClick={scrollToBottom} />}
</div>
);
}
export default Renderer;

View File

@@ -1,7 +1,8 @@
import userEvent from '@testing-library/user-event';
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import MarkdownContent from '../MarkdownContent';
import { loadLanguage } from '../syntaxLanguages';
import MarkdownContent from '../components/MarkdownContent/MarkdownContent';
import { loadLanguage } from '../../../utils/syntaxLanguages';
describe('MarkdownContent', () => {
describe('security', () => {
@@ -234,6 +235,23 @@ describe('MarkdownContent — interactive task lists', () => {
expect(second).toBeChecked();
});
it('warns on hover that a tick edits the panel', async () => {
const user = userEvent.setup();
render(
<MarkdownContent interactive={{ source, onChangeSource: jest.fn() }}>
{source}
</MarkdownContent>,
);
await user.hover(screen.getAllByRole('checkbox')[0]);
await waitFor(() => {
expect(screen.getByRole('tooltip')).toHaveTextContent(
'Toggling this updates the panel spec',
);
});
});
it('checking one rewrites its marker in the source', () => {
const onChangeSource = jest.fn();
render(

View File

@@ -0,0 +1,51 @@
import { render, screen } from '@testing-library/react';
import { PanelMode } from 'lib/visualization/panels/types';
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import Renderer from '../Renderer';
function textPanel(text?: string): PanelOfKind<'signoz/TextPanel'> {
return {
kind: 'Panel',
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text } },
queries: [],
},
} as unknown as PanelOfKind<'signoz/TextPanel'>;
}
function renderPanel(text?: string): void {
render(
<Renderer
panelId="p1"
panel={textPanel(text)}
panelMode={PanelMode.DASHBOARD_VIEW}
/>,
);
}
describe('Text panel empty state', () => {
it.each([undefined, '', ' \n\t'])('stands in for a body of %j', (text) => {
renderPanel(text);
expect(screen.getByTestId('text-panel-empty')).toBeInTheDocument();
expect(screen.getByText('Nothing written yet')).toBeInTheDocument();
});
it('gives way to the body once there is one', () => {
renderPanel('# Runbook');
expect(screen.queryByTestId('text-panel-empty')).not.toBeInTheDocument();
expect(screen.getByRole('heading', { name: 'Runbook' })).toBeInTheDocument();
});
// An undefined variable renders literally, as queries treat one, so the body
// is not empty and the panel shows it rather than the empty state.
it('does not stand in for an unresolved variable', () => {
renderPanel('$missing');
expect(screen.queryByTestId('text-panel-empty')).not.toBeInTheDocument();
expect(screen.getByText('$missing')).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,81 @@
import { act, fireEvent, render, screen } from '@testing-library/react';
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { PanelMode } from 'lib/visualization/panels/types';
import Renderer from '../Renderer';
const panel = {
kind: 'Panel',
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text: '# hello' } },
queries: [],
},
} as unknown as PanelOfKind<'signoz/TextPanel'>;
/** jsdom has no layout: stub the scroll geometry the hook reads. */
function setScrollGeometry(
el: HTMLElement,
{ scrollHeight, clientHeight }: { scrollHeight: number; clientHeight: number },
): void {
Object.defineProperty(el, 'scrollHeight', {
configurable: true,
value: scrollHeight,
});
Object.defineProperty(el, 'clientHeight', {
configurable: true,
value: clientHeight,
});
}
function renderPanel(): HTMLElement {
render(
<Renderer panelId="p1" panel={panel} panelMode={PanelMode.DASHBOARD_VIEW} />,
);
return screen.getByTestId('text-panel');
}
describe('Text panel scroll-to-bottom pill', () => {
it('is absent when the body fits', () => {
const scroller = renderPanel();
setScrollGeometry(scroller, { scrollHeight: 100, clientHeight: 100 });
fireEvent.scroll(scroller);
expect(
screen.queryByTestId('text-panel-scroll-more'),
).not.toBeInTheDocument();
});
it('appears when content extends below the fold and jumps to the end on click', () => {
const scroller = renderPanel();
setScrollGeometry(scroller, { scrollHeight: 400, clientHeight: 100 });
act(() => {
fireEvent.scroll(scroller);
});
const pill = screen.getByTestId('text-panel-scroll-more');
const scrollTo = jest.fn();
scroller.scrollTo = scrollTo;
fireEvent.click(pill);
expect(scrollTo).toHaveBeenCalledWith({ top: 400, behavior: 'smooth' });
});
it('hides once the user reaches the bottom', () => {
const scroller = renderPanel();
setScrollGeometry(scroller, { scrollHeight: 400, clientHeight: 100 });
act(() => {
fireEvent.scroll(scroller);
});
expect(screen.getByTestId('text-panel-scroll-more')).toBeInTheDocument();
scroller.scrollTop = 300;
act(() => {
fireEvent.scroll(scroller);
});
expect(
screen.queryByTestId('text-panel-scroll-more'),
).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,69 @@
import { fireEvent, render, screen } from '@testing-library/react';
import { TooltipProvider } from '@signozhq/ui/tooltip';
import { PanelMode } from 'lib/visualization/panels/types';
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import Renderer from '../Renderer';
const SOURCE = ['- [ ] first', '- [x] second'].join('\n');
function textPanel(text: string): PanelOfKind<'signoz/TextPanel'> {
return {
kind: 'Panel',
spec: {
display: { name: 'Runbook' },
plugin: { kind: 'signoz/TextPanel', spec: { text } },
queries: [],
},
} as unknown as PanelOfKind<'signoz/TextPanel'>;
}
describe('Text panel task lists', () => {
it('renders them read-only without a write channel', () => {
render(
<Renderer
panelId="p1"
panel={textPanel(SOURCE)}
panelMode={PanelMode.DASHBOARD_VIEW}
/>,
);
screen.getAllByRole('checkbox').forEach((box) => expect(box).toBeDisabled());
});
it('reports the rewritten body when a host can save it', () => {
const onChangeText = jest.fn();
render(
<Renderer
panelId="p1"
panel={textPanel(SOURCE)}
panelMode={PanelMode.DASHBOARD_VIEW}
onChangeText={onChangeText}
/>,
{ wrapper: TooltipProvider },
);
fireEvent.click(screen.getAllByRole('checkbox')[0]);
expect(onChangeText).toHaveBeenCalledWith(
['- [x] first', '- [x] second'].join('\n'),
);
});
it('edits the authored body, not the interpolated one', () => {
const onChangeText = jest.fn();
render(
<Renderer
panelId="p1"
panel={textPanel('- [ ] deploy $service')}
panelMode={PanelMode.DASHBOARD_VIEW}
onChangeText={onChangeText}
/>,
{ wrapper: TooltipProvider },
);
fireEvent.click(screen.getByRole('checkbox'));
expect(onChangeText).toHaveBeenCalledWith('- [x] deploy $service');
});
});

View File

@@ -0,0 +1,265 @@
import {
contrastRatio,
inkForSurface,
meetsContrast,
MIN_CONTRAST_RATIO,
normalizeHex,
parseHex,
rgbaFromHex,
} from '../contrast';
import {
CUSTOM_INK,
TEXT_BACKGROUND_PAIRS,
TEXT_BACKGROUND_PRESETS,
TRANSPARENT_BACKGROUND,
} from '../presets';
import {
presetSurface,
resolveTextBackground,
selectionFromResolved,
storedFromSelection,
toStoredBackground,
} from '../resolveTextBackground';
import type { ResolvedTextBackground } from '../types';
import { PanelTheme, TextBackgroundKind, TextBackgroundPreset } from '../types';
const THEMES: PanelTheme[] = Object.values(PanelTheme);
describe('preset tokens', () => {
const pairs = TEXT_BACKGROUND_PRESETS.flatMap((preset) =>
THEMES.map((theme) => ({
preset,
theme,
...TEXT_BACKGROUND_PAIRS[preset][theme],
})),
);
it('covers all eight presets in both themes', () => {
expect(pairs).toHaveLength(16);
});
it.each(pairs)(
'$preset/$theme clears the contrast floor',
({ surface, ink }) => {
expect(contrastRatio(ink, surface)).toBeGreaterThanOrEqual(
MIN_CONTRAST_RATIO,
);
},
);
// A repeated surface would make the hex → preset lookup ambiguous.
it('keeps all sixteen surfaces distinct', () => {
const surfaces = pairs.map(({ surface }) => surface.toUpperCase());
expect(new Set(surfaces).size).toBe(16);
});
});
describe('parseHex', () => {
it('expands shorthand digits', () => {
expect(parseHex('#abc')).toStrictEqual({ r: 170, g: 187, b: 204, a: 1 });
});
it('reads the alpha channel from the four- and eight-digit forms', () => {
expect(parseHex('#0000')?.a).toBe(0);
expect(parseHex('#00000000')?.a).toBe(0);
expect(parseHex('#aabbccff')?.a).toBe(1);
});
it('treats a form without an alpha channel as opaque', () => {
expect(parseHex('#aabbcc')?.a).toBe(1);
});
it.each(['', 'aabbcc', 'red', '#abcde', '#gggggg'])('rejects %s', (color) => {
expect(parseHex(color)).toBeUndefined();
});
it('normalises to the uppercase six-digit form', () => {
expect(normalizeHex('#dce4ff')).toBe('#DCE4FF');
expect(normalizeHex('#abc')).toBe('#AABBCC');
expect(normalizeHex('#dce4ffcc')).toBe('#DCE4FF');
});
});
describe('rgbaFromHex', () => {
it('takes a share of the colour', () => {
expect(rgbaFromHex('#DCE4FF', 0.82)).toBe('rgba(220, 228, 255, 0.82)');
});
it('expands shorthand and ignores the source alpha', () => {
expect(rgbaFromHex('#abc', 1)).toBe('rgba(170, 187, 204, 1)');
expect(rgbaFromHex('#aabbcc00', 0.5)).toBe('rgba(170, 187, 204, 0.5)');
});
it('returns nothing for a colour it cannot read', () => {
expect(rgbaFromHex('red', 0.82)).toBeUndefined();
});
});
describe('inkForSurface', () => {
it('puts light ink on a dark surface and dark ink on a light one', () => {
expect(inkForSurface('#101010')).toBe(CUSTOM_INK.light);
expect(inkForSurface('#F5F5F5')).toBe(CUSTOM_INK.dark);
});
it('reports a mid surface as short of the floor without failing', () => {
const surface = '#808080';
expect(meetsContrast(surface, inkForSurface(surface))).toBe(false);
});
});
describe('resolveTextBackground', () => {
it.each([undefined, null, ''])('reads %s as the default surface', (stored) => {
expect(resolveTextBackground(stored, PanelTheme.Dark)).toStrictEqual({
kind: TextBackgroundKind.Default,
});
});
it.each([TRANSPARENT_BACKGROUND, '#0000'])(
'reads the zero-alpha colour %s as no card',
(stored) => {
expect(resolveTextBackground(stored, PanelTheme.Dark)).toStrictEqual({
kind: TextBackgroundKind.None,
});
},
);
it('resolves a surface stored in one theme to the pair of the other', () => {
const storedInLight = presetSurface(
TextBackgroundPreset.Amber,
PanelTheme.Light,
);
expect(resolveTextBackground(storedInLight, PanelTheme.Dark)).toStrictEqual({
kind: TextBackgroundKind.Preset,
preset: TextBackgroundPreset.Amber,
...TEXT_BACKGROUND_PAIRS.amber.dark,
});
});
it('recognises a preset surface whatever its case', () => {
const stored = presetSurface(
TextBackgroundPreset.Forest,
PanelTheme.Dark,
).toLowerCase();
expect(resolveTextBackground(stored, PanelTheme.Light)).toMatchObject({
kind: TextBackgroundKind.Preset,
preset: TextBackgroundPreset.Forest,
});
});
it('reads a hex that is not a preset surface as a custom colour', () => {
expect(resolveTextBackground('#3A2A64', PanelTheme.Dark)).toStrictEqual({
kind: TextBackgroundKind.Custom,
surface: '#3A2A64',
ink: CUSTOM_INK.light,
});
});
it('holds a custom colour steady across a theme switch', () => {
expect(resolveTextBackground('#3A2A64', PanelTheme.Light)).toStrictEqual(
resolveTextBackground('#3A2A64', PanelTheme.Dark),
);
});
// The enum the API used to accept; neither value is a hex.
it.each([
['solid', TextBackgroundKind.Default],
['transparent', TextBackgroundKind.None],
])('migrates the legacy %s value to %s', (stored, kind) => {
expect(resolveTextBackground(stored, PanelTheme.Dark)).toStrictEqual({
kind,
});
});
it('falls back to the default surface for an unreadable value', () => {
expect(resolveTextBackground('rgb(1, 2, 3)', PanelTheme.Dark)).toStrictEqual({
kind: TextBackgroundKind.Default,
});
});
});
describe('editor adapters', () => {
it.each([
[undefined, TextBackgroundKind.Default],
[TRANSPARENT_BACKGROUND, TextBackgroundKind.None],
['solid', TextBackgroundKind.Default],
])('lights up the %s swatch', (stored, selection) => {
expect(
selectionFromResolved(resolveTextBackground(stored, PanelTheme.Dark)),
).toBe(selection);
});
it('lights up the preset a stored surface belongs to', () => {
expect(
selectionFromResolved(
resolveTextBackground(
presetSurface(TextBackgroundPreset.Slate, PanelTheme.Light),
PanelTheme.Dark,
),
),
).toBe(TextBackgroundPreset.Slate);
});
it('lights up nothing for a custom colour', () => {
expect(
selectionFromResolved(resolveTextBackground('#3A2A64', PanelTheme.Dark)),
).toBeUndefined();
});
it('stores what each swatch means', () => {
expect(storedFromSelection(TextBackgroundKind.None, PanelTheme.Dark)).toBe(
TRANSPARENT_BACKGROUND,
);
expect(
storedFromSelection(TextBackgroundKind.Default, PanelTheme.Dark),
).toBeUndefined();
expect(
storedFromSelection(TextBackgroundPreset.Cherry, PanelTheme.Light),
).toBe(presetSurface(TextBackgroundPreset.Cherry, PanelTheme.Light));
});
});
describe('round trip', () => {
const cases: ResolvedTextBackground[] = [
{ kind: TextBackgroundKind.None },
{ kind: TextBackgroundKind.Default },
{
kind: TextBackgroundKind.Custom,
surface: '#3A2A64',
ink: CUSTOM_INK.light,
},
...TEXT_BACKGROUND_PRESETS.map((preset) => ({
kind: TextBackgroundKind.Preset,
preset,
})),
];
it.each(cases)(
'preserves $kind $preset through a save and load',
(resolved) => {
THEMES.forEach((theme) => {
const stored = toStoredBackground(resolved, theme);
const reread = resolveTextBackground(stored, theme);
expect(reread.kind).toBe(resolved.kind);
expect(reread.preset).toBe(resolved.preset);
});
},
);
it.each([
undefined,
TRANSPARENT_BACKGROUND,
'#3A2A64',
'solid',
'transparent',
])('re-reading %s changes nothing', (stored) => {
THEMES.forEach((theme) => {
const once = resolveTextBackground(stored, theme);
const twice = resolveTextBackground(toStoredBackground(once, theme), theme);
expect(twice).toStrictEqual(once);
});
});
});

View File

@@ -0,0 +1,91 @@
import { CUSTOM_INK } from './presets';
interface Channels {
r: number;
g: number;
b: number;
a: number;
}
const HEX_PATTERN = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i;
export function isHexColor(color: string): boolean {
return HEX_PATTERN.test(color);
}
/**
* Splits `#rgb`, `#rgba`, `#rrggbb` and `#rrggbbaa` — shorthand digits double,
* and a form without an alpha channel is opaque. `undefined` for anything else.
*/
export function parseHex(color: string): Channels | undefined {
if (!isHexColor(color)) {
return undefined;
}
const hex = color.slice(1);
const short = hex.length <= 4;
const step = short ? 1 : 2;
const channel = (index: number): number => {
const digits = hex.slice(index * step, index * step + step);
return parseInt(short ? digits + digits : digits, 16);
};
return {
r: channel(0),
g: channel(1),
b: channel(2),
a: hex.length === 4 || hex.length === 8 ? channel(3) / 255 : 1,
};
}
/** The uppercase 6-digit form used as the preset lookup key. */
export function normalizeHex(color: string): string | undefined {
const channels = parseHex(color);
if (!channels) {
return undefined;
}
const pad = (value: number): string =>
value.toString(16).padStart(2, '0').toUpperCase();
return `#${pad(channels.r)}${pad(channels.g)}${pad(channels.b)}`;
}
/** The colour at a given alpha; the source's own alpha is ignored. */
export function rgbaFromHex(color: string, alpha: number): string | undefined {
const channels = parseHex(color);
if (!channels) {
return undefined;
}
return `rgba(${channels.r}, ${channels.g}, ${channels.b}, ${alpha})`;
}
/** WCAG 2.1 relative luminance; alpha is ignored. */
export function relativeLuminance(color: string): number {
const channels = parseHex(color);
if (!channels) {
return 0;
}
const linear = ([channels.r, channels.g, channels.b] as const).map((value) => {
const srgb = value / 255;
return srgb <= 0.03928 ? srgb / 12.92 : ((srgb + 0.055) / 1.055) ** 2.4;
});
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
/** WCAG 2.1 contrast ratio, 1 to 21. */
export function contrastRatio(foreground: string, background: string): number {
const a = relativeLuminance(foreground);
const b = relativeLuminance(background);
return (Math.max(a, b) + 0.05) / (Math.min(a, b) + 0.05);
}
export const MIN_CONTRAST_RATIO = 4.5;
/** Whichever fixed ink contrasts further, so a custom colour needs none of its own. */
export function inkForSurface(surface: string): string {
return contrastRatio(CUSTOM_INK.light, surface) >=
contrastRatio(CUSTOM_INK.dark, surface)
? CUSTOM_INK.light
: CUSTOM_INK.dark;
}
export function meetsContrast(surface: string, ink: string): boolean {
return contrastRatio(ink, surface) >= MIN_CONTRAST_RATIO;
}

View File

@@ -0,0 +1,92 @@
import type { PanelTheme, TextBackgroundPair } from './types';
import { TextBackgroundPreset } from './types';
/** Declaration order is the swatch row order, after Transparent and Default panel. */
export const TEXT_BACKGROUND_PRESETS: readonly TextBackgroundPreset[] =
Object.values(TextBackgroundPreset);
/**
* The sixteen surfaces must stay distinct — `resolveTextBackground` recovers a
* preset name from a stored hex — and every pair must clear 4.5:1. Both are
* asserted in `__tests__/textBackground.test.ts`.
*/
export const TEXT_BACKGROUND_PAIRS: Record<
TextBackgroundPreset,
Record<PanelTheme, TextBackgroundPair>
> = {
robin: {
light: { surface: '#DCE4FF', ink: '#16224D' },
dark: { surface: '#24356E', ink: '#EDF1FF' },
},
purple: {
light: { surface: '#E8DEFB', ink: '#2B1B4D' },
dark: { surface: '#3A2A63', ink: '#F1EAFE' },
},
sakura: {
light: { surface: '#FBDCEB', ink: '#4A1730' },
dark: { surface: '#5F2342', ink: '#FDE8F2' },
},
cherry: {
light: { surface: '#FBDCDC', ink: '#4C1717' },
dark: { surface: '#63262A', ink: '#FDE9E9' },
},
amber: {
light: { surface: '#FBEECC', ink: '#45320A' },
dark: { surface: '#5B4415', ink: '#FDF3DC' },
},
forest: {
light: { surface: '#D6F2E2', ink: '#0F3A25' },
dark: { surface: '#1D4A33', ink: '#E3F7EC' },
},
sienna: {
light: { surface: '#F0E4D8', ink: '#40301F' },
dark: { surface: '#56412C', ink: '#F5EADF' },
},
slate: {
light: { surface: '#E4E6EA', ink: '#1D212D' },
dark: { surface: '#2C3140', ink: '#EDEEF0' },
},
};
/** How `TextBackgroundKind.None` survives a string-only schema. */
export const TRANSPARENT_BACKGROUND = '#00000000';
/** The theme's own overlay ink, so a note keeps the edge weight of its neighbours. */
export const PRESET_BORDER: Record<PanelTheme, string> = {
light: 'rgba(0, 0, 0, 0.07)',
dark: 'rgba(255, 255, 255, 0.09)',
};
export const SECONDARY_INK_OPACITY = 0.82;
/**
* Surface chrome, as a share of the pair's ink. Each name falls back to its
* original token in the stylesheet, so a panel with no background is untouched.
* `--scrollbar-thumb*` are unscoped on purpose: they override the shared
* scrollbar mixin, which any surface may want to retint.
*/
export const INK_ALPHAS: Record<string, number> = {
'--text-panel-ink-secondary': SECONDARY_INK_OPACITY,
'--text-panel-grip': 0.28,
'--scrollbar-thumb': 0.24,
'--scrollbar-thumb-hover': 0.4,
'--text-panel-pill-surface': 0.16,
};
/** The two inks a custom surface picks between. */
export const CUSTOM_INK: Record<PanelTheme, string> = {
light: '#FFFFFF',
dark: '#1D212D',
};
/**
* Normalised surface hex to the preset that owns it, both themes: a panel saved
* in dark mode resolves to its preset in light mode, with no re-save.
*/
export const PRESET_BY_SURFACE: Record<string, TextBackgroundPreset> =
Object.fromEntries(
TEXT_BACKGROUND_PRESETS.flatMap((preset) => [
[TEXT_BACKGROUND_PAIRS[preset].light.surface.toUpperCase(), preset],
[TEXT_BACKGROUND_PAIRS[preset].dark.surface.toUpperCase(), preset],
]),
);

View File

@@ -0,0 +1,122 @@
import { inkForSurface, normalizeHex, parseHex } from './contrast';
import {
PRESET_BY_SURFACE,
TEXT_BACKGROUND_PAIRS,
TRANSPARENT_BACKGROUND,
} from './presets';
import type {
PanelTheme,
ResolvedTextBackground,
TextBackgroundPreset,
TextBackgroundSelection,
} from './types';
import { TextBackgroundKind } from './types';
/** `presentation.background` before it was a hex string; the API rejects both now. */
const LEGACY_VALUES: Record<string, TextBackgroundKind> = {
solid: TextBackgroundKind.Default,
transparent: TextBackgroundKind.None,
};
const DEFAULT_BACKGROUND: ResolvedTextBackground = {
kind: TextBackgroundKind.Default,
};
/**
* A stored preset surface resolves to its pair in the *current* theme, so a panel
* follows a theme switch with no re-save; any other hex is a custom colour, which
* does not adapt.
*/
export function resolveTextBackground(
background: string | null | undefined,
theme: PanelTheme,
): ResolvedTextBackground {
if (!background) {
return DEFAULT_BACKGROUND;
}
const legacy = LEGACY_VALUES[background];
if (legacy) {
return legacy === TextBackgroundKind.None
? { kind: TextBackgroundKind.None }
: DEFAULT_BACKGROUND;
}
const channels = parseHex(background);
if (!channels) {
return DEFAULT_BACKGROUND;
}
if (channels.a === 0) {
return { kind: TextBackgroundKind.None };
}
const normalized = normalizeHex(background);
const preset = normalized ? PRESET_BY_SURFACE[normalized] : undefined;
if (preset) {
return {
kind: TextBackgroundKind.Preset,
preset,
...TEXT_BACKGROUND_PAIRS[preset][theme],
};
}
return {
kind: TextBackgroundKind.Custom,
surface: background,
ink: inkForSurface(background),
};
}
/** A preset stores the current theme's surface; `default` stores nothing. */
export function toStoredBackground(
resolved: ResolvedTextBackground,
theme: PanelTheme,
): string | undefined {
switch (resolved.kind) {
case TextBackgroundKind.None:
return TRANSPARENT_BACKGROUND;
case TextBackgroundKind.Preset:
return resolved.preset
? TEXT_BACKGROUND_PAIRS[resolved.preset][theme].surface
: undefined;
case TextBackgroundKind.Custom:
return resolved.surface;
default:
return undefined;
}
}
/** Which swatch lights up; `undefined` for a custom colour, which has no swatch. */
export function selectionFromResolved(
resolved: ResolvedTextBackground,
): TextBackgroundSelection | undefined {
if (resolved.kind === TextBackgroundKind.Preset) {
return resolved.preset;
}
return resolved.kind === TextBackgroundKind.Custom ? undefined : resolved.kind;
}
/** What a swatch click stores. */
export function storedFromSelection(
selection: TextBackgroundSelection,
theme: PanelTheme,
): string | undefined {
if (
selection === TextBackgroundKind.None ||
selection === TextBackgroundKind.Default
) {
return toStoredBackground({ kind: selection }, theme);
}
return toStoredBackground(
{ kind: TextBackgroundKind.Preset, preset: selection },
theme,
);
}
/** The hex a swatch paints in the given theme. */
export function presetSurface(
preset: TextBackgroundPreset,
theme: PanelTheme,
): string {
return TEXT_BACKGROUND_PAIRS[preset][theme].surface;
}

View File

@@ -0,0 +1,41 @@
export enum TextBackgroundPreset {
Robin = 'robin',
Purple = 'purple',
Sakura = 'sakura',
Cherry = 'cherry',
Amber = 'amber',
Forest = 'forest',
Sienna = 'sienna',
Slate = 'slate',
}
export enum TextBackgroundKind {
None = 'none',
Default = 'default',
Preset = 'preset',
Custom = 'custom',
}
/** `Custom` is absent: it opens a picker, so it has its own row. */
export type TextBackgroundSelection =
| TextBackgroundKind.None
| TextBackgroundKind.Default
| TextBackgroundPreset;
export enum PanelTheme {
Light = 'light',
Dark = 'dark',
}
export interface TextBackgroundPair {
surface: string;
ink: string;
}
/** `None` and `Default` carry no colours: the card keeps or drops its own. */
export interface ResolvedTextBackground {
kind: TextBackgroundKind;
preset?: TextBackgroundPreset;
surface?: string;
ink?: string;
}

View File

@@ -2,8 +2,10 @@ import type { CodeProps } from 'react-markdown/lib/ast-to-react';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import SyntaxHighlighter, { resolveLanguage } from './syntaxLanguages';
import { usePrismLanguage } from './usePrismLanguage';
import SyntaxHighlighter, {
resolveLanguage,
} from '../../../../utils/syntaxLanguages';
import { usePrismLanguage } from '../../../../hooks/usePrismLanguage';
import styles from './CodeBlock.module.scss';

View File

@@ -1,4 +1,4 @@
@use '../../../../../../styles/scrollbar' as *;
@use '../../../../../../../../styles/scrollbar' as *;
// Style isolation: the subtree is rolled back to user-agent styling, so no global
// rule reaches the rendered body and the rules below are the only author styles that
@@ -15,12 +15,20 @@
box-sizing: border-box;
}
// Sourced from the panel's ink when it has one — the Robin link colour and the
// neutral muted tone hold no contrast on a coloured surface and from the theme
// token when it does not, which is byte-identical to before.
.content.content {
--md-foreground: var(--text-vanilla-100);
--md-muted: var(--text-neutral-dark-100);
--md-link: var(--text-robin-400);
--md-border: var(--l1-border);
--md-surface: color-mix(in srgb, var(--l1-foreground) 6%, transparent);
--md-foreground: var(--text-panel-ink, var(--text-vanilla-100));
--md-muted: var(--text-panel-ink-secondary, var(--text-neutral-dark-100));
--md-link: var(--text-panel-ink, var(--text-robin-400));
--md-link-decoration: var(--text-panel-link-decoration, none);
--md-border: var(--text-panel-border, var(--l1-border));
--md-surface: color-mix(
in srgb,
var(--text-panel-ink, var(--l1-foreground)) 6%,
transparent
);
--md-code-comment: var(--text-neutral-dark-200);
--md-code-punctuation: var(--text-neutral-dark-100);
--md-code-keyword: var(--text-sakura-400);
@@ -41,9 +49,9 @@
}
:global(body.lightMode) .content.content {
--md-foreground: var(--text-ink-400);
--md-muted: var(--text-neutral-light-100);
--md-link: var(--text-robin-500);
--md-foreground: var(--text-panel-ink, var(--text-ink-400));
--md-muted: var(--text-panel-ink-secondary, var(--text-neutral-light-100));
--md-link: var(--text-panel-ink, var(--text-robin-500));
--md-code-comment: var(--text-neutral-light-100);
--md-code-punctuation: var(--text-neutral-light-100);
--md-code-keyword: var(--text-sakura-600);
@@ -166,7 +174,7 @@
.content.content a {
color: var(--md-link);
text-decoration: none;
text-decoration: var(--md-link-decoration);
&:hover,
&:focus-visible {

View File

@@ -7,10 +7,10 @@ import remarkGfm from 'remark-gfm';
import {
editRenderedOccurrence,
type EditableConstruct,
} from '../../utils/markdownSource';
import { TASK_LIST } from '../../utils/taskList';
import CodeBlock from './CodeBlock';
import TaskCheckbox from './TaskCheckbox';
} from '../../../../utils/markdownSource';
import { TASK_LIST } from '../../../../utils/taskList';
import CodeBlock from '../CodeBlock/CodeBlock';
import TaskCheckbox from '../TaskCheckbox/TaskCheckbox';
import { TaskItemOffsetContext } from './taskItemOffset';
import styles from './MarkdownContent.module.scss';
@@ -75,7 +75,6 @@ function MarkdownContent({
className,
testId = 'markdown-content',
}: MarkdownContentProps): JSX.Element | null {
// Dashboards re-render on every variable tick; parsing is the expensive half.
// Element overrides that write back to the source: one entry per interactive
// construct, pairing an `EditableConstruct` with the element it renders as.
const components = useMemo<Components>(() => {
@@ -103,10 +102,10 @@ function MarkdownContent({
return {
...READ_ONLY_COMPONENTS,
li: ({ node, children: items, ...props }): JSX.Element => (
li: ({ node, children, ...props }): JSX.Element => (
<li {...props}>
<TaskItemOffsetContext.Provider value={node.position?.start.offset}>
{items}
{children}
</TaskItemOffsetContext.Provider>
</li>
),
@@ -124,6 +123,7 @@ function MarkdownContent({
};
}, [interactive, children]);
// Dashboards re-render on every variable tick; parsing is the expensive half.
const body = useMemo(
() =>
children.trim() ? (

View File

@@ -0,0 +1,37 @@
.pill {
all: unset;
position: absolute;
bottom: 8px;
left: 50%;
transform: translateX(-50%);
display: flex;
align-items: center;
justify-content: center;
gap: 4px;
padding: 4px 12px 4px 10px;
background-color: var(--text-panel-pill-surface, var(--l1-border));
border-radius: 20px;
cursor: pointer;
transition: background-color 0.1s;
color: var(--text-panel-ink, var(--l2-foreground));
span {
font-size: 12px;
line-height: 18px;
color: var(--text-panel-ink, var(--l2-foreground));
}
svg {
animation: scroll-pill-pulse 1s infinite;
}
}
@keyframes scroll-pill-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}

View File

@@ -0,0 +1,23 @@
import { ChevronsDown } from '@signozhq/icons';
import styles from './ScrollToBottomPill.module.scss';
interface ScrollToBottomPillProps {
onClick: () => void;
}
function ScrollToBottomPill({ onClick }: ScrollToBottomPillProps): JSX.Element {
return (
<button
type="button"
className={styles.pill}
onClick={onClick}
data-testid="text-panel-scroll-more"
>
<ChevronsDown size={14} />
<span>Scroll for more</span>
</button>
);
}
export default ScrollToBottomPill;

View File

@@ -1,4 +1,10 @@
import { useTaskItemOffset } from './taskItemOffset';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { useTaskItemOffset } from '../MarkdownContent/taskItemOffset';
// A tick is an edit to the panel's markdown, not a per-viewer preference — say so
// before it is made, since the surface otherwise reads like an ordinary checkbox.
const WRITE_BACK_HINT = 'Toggling this updates the panel spec';
interface TaskCheckboxProps {
checked: boolean;
@@ -13,7 +19,7 @@ interface TaskCheckboxProps {
function TaskCheckbox({ checked, onChange }: TaskCheckboxProps): JSX.Element {
const offset = useTaskItemOffset();
return (
const box = (
<input
type="checkbox"
checked={checked}
@@ -27,6 +33,18 @@ function TaskCheckbox({ checked, onChange }: TaskCheckboxProps): JSX.Element {
}}
/>
);
if (offset === undefined) {
return box;
}
// `asChild` on the trigger keeps the input itself as the hover target, so no
// wrapper lands inside the body's style reset.
return (
<TooltipSimple title={WRITE_BACK_HINT} arrow>
{box}
</TooltipSimple>
);
}
export default TaskCheckbox;

View File

@@ -0,0 +1,54 @@
import { useCallback, useMemo } from 'react';
import MarkdownEditor from 'components/MarkdownEditor/MarkdownEditor';
import type { EditorVariable } from 'components/MarkdownEditor/types';
import { dtoToFormModel } from 'pages/DashboardPage/DashboardContainer/DashboardSettings/Variables/variableAdapters';
import { useDashboardFetchRequired } from 'pages/DashboardPage/DashboardContainer/hooks/useDashboardFetchRequired';
import type { StaticEditorPaneProps } from '../../../../types/panelDefinition';
import { withPanelText } from '../../../../utils/withPanelText';
import type { DashboardtypesTextPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
import styles from './TextEditorPane.module.scss';
/**
* The Text panel's authoring pane — the Markdown source editor in the slot where
* query-backed kinds show the query builder. The preview above renders the draft
* spec, so it updates live as the body changes; there is no Run step.
*/
function TextEditorPane({
spec,
onChangeSpec,
}: StaticEditorPaneProps): JSX.Element {
// The plugin-spec union can't be narrowed by a dynamic kind; one localized cast,
// as in the section registry's lenses.
const pluginSpec = spec.plugin.spec as DashboardtypesTextPanelSpecDTO;
const { variables: variableDtos } = useDashboardFetchRequired();
const variables = useMemo(
() =>
variableDtos
.map((dto) => dtoToFormModel(dto))
.flatMap((model): EditorVariable[] =>
model.name ? [{ name: model.name, badge: model.type }] : [],
),
[variableDtos],
);
const onChangeText = useCallback(
(text: string): void => onChangeSpec(withPanelText(spec, text)),
[spec, onChangeSpec],
);
return (
<div className={styles.pane} data-testid="text-panel-editor-pane">
<MarkdownEditor
value={pluginSpec.text ?? ''}
onChange={onChangeText}
variables={variables}
statusHint="Preview updates as you type"
/>
</div>
);
}
export default TextEditorPane;

View File

@@ -0,0 +1,26 @@
import { Type } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import TextEditorPane from './components/TextEditorPane/TextEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
export const definition: PanelDefinition<'signoz/TextPanel'> = {
kind: 'signoz/TextPanel',
displayName: 'Text',
icon: Type,
sections,
mode: 'static',
Renderer,
EditorPane: TextEditorPane,
actions: {
view: true,
edit: true,
clone: true,
// Nothing tabular or chart-like to export; the body is already the readable form.
download: { csv: false, png: false, svg: false },
createAlert: false,
search: false,
drilldown: false,
},
};

View File

@@ -0,0 +1,13 @@
import { SectionKind, type SectionConfig } from '../../types/sections';
// No thresholds, legend, axes or formatting: there is no data to threshold, scale
// or format. No context links either — they resolve against query fields at
// click-time, and a text body has neither.
export const sections: SectionConfig[] = [
{
kind: SectionKind.Visualization,
controls: { switchPanelKind: true },
},
{ kind: SectionKind.TextLayout },
{ kind: SectionKind.PanelHeader },
];

View File

@@ -5,6 +5,7 @@ import { definition as PieChart } from './kinds/PieChartPanel/definition';
import { definition as TimeSeries } from './kinds/TimeSeriesPanel/definition';
import { definition as Table } from './kinds/TablePanel/definition';
import { definition as List } from './kinds/ListPanel/definition';
import { definition as Text } from './kinds/TextPanel/definition';
import { UNSUPPORTED_PANEL } from './kinds/UnsupportedPanel/definition';
import type {
PanelDefinition,
@@ -23,6 +24,7 @@ export const PANELS: PanelRegistry = {
[PieChart.kind]: PieChart,
[Histogram.kind]: Histogram,
[List.kind]: List,
[Text.kind]: Text,
};
export type PanelOption = Pick<

View File

@@ -8,6 +8,13 @@ import type { DashboardtypesPanelPluginKindDTO } from 'api/generated/services/si
*/
export type PanelKind = `${DashboardtypesPanelPluginKindDTO}`;
/**
* Every kind's counterpart in `PANEL_TYPES`, the vocabulary the query builder,
* explorers and alerts all speak. Total by construction: a new kind fails to compile
* here until it declares which visualisation it is, and a kind whose visualisation
* `PANEL_TYPES` doesn't name yet is a signal to add it there rather than to pick a
* near-enough value.
*/
export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TimeSeriesPanel': PANEL_TYPES.TIME_SERIES,
'signoz/BarChartPanel': PANEL_TYPES.BAR,
@@ -16,12 +23,24 @@ export const PANEL_KIND_TO_PANEL_TYPE: Record<PanelKind, PANEL_TYPES> = {
'signoz/TablePanel': PANEL_TYPES.TABLE,
'signoz/HistogramPanel': PANEL_TYPES.HISTOGRAM,
'signoz/ListPanel': PANEL_TYPES.LIST,
'signoz/TextPanel': PANEL_TYPES.TEXT,
};
/**
* The `PANEL_TYPES` a kind maps to, for the query, alert and drilldown surfaces that
* speak that vocabulary. A total lookup — every kind has an answer, so there is
* nothing to default and no call site can be handed a visualisation that isn't its
* own.
*/
export function toPanelType(kind: PanelKind): PANEL_TYPES {
return PANEL_KIND_TO_PANEL_TYPE[kind];
}
/**
* Reverse of {@link PANEL_KIND_TO_PANEL_TYPE} — the mapping is a bijection, so every
* panel kind round-trips. Partial because `PANEL_TYPES` also has types with no V2 kind
* (e.g. trace/empty); a lookup on those returns `undefined`.
* panel kind round-trips. Partial in this direction because `PANEL_TYPES` also names
* visualisations with no dashboard kind (trace, empty); a lookup on those is
* `undefined`.
*/
export const PANEL_TYPE_TO_PANEL_KIND: Partial<Record<PANEL_TYPES, PanelKind>> =
Object.fromEntries(

View File

@@ -86,6 +86,12 @@ export interface StaticRendererProps<K extends PanelKind = PanelKind> {
panel: PanelOfKind<K>;
panelMode: PanelMode;
dashboardId?: string;
/**
* Writes the authored body back. Supplied only by a host with somewhere to put
* it — the grid patches, the editor updates its draft — so its absence is what
* makes a surface read-only.
*/
onChangeText?: (text: string) => void;
}
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's

View File

@@ -3,12 +3,14 @@ import type {
DashboardtypesAxesDTO,
DashboardtypesBarChartVisualizationDTO,
DashboardtypesComparisonThresholdDTO,
DashboardtypesHeaderOptionsDTO,
DashboardtypesHistogramBucketsDTO,
DashboardtypesLegendDTO,
DashboardtypesPanelFormattingDTO,
DashboardtypesPanelSpecDTO,
DashboardtypesTableFormattingDTO,
DashboardtypesTableThresholdDTO,
DashboardtypesTextPresentationDTO,
DashboardtypesThresholdWithLabelDTO,
DashboardtypesTimeSeriesChartAppearanceDTO,
TelemetrytypesTelemetryFieldKeyDTO,
@@ -21,10 +23,12 @@ import {
Hash,
Link2,
Palette,
PanelTop,
PencilRuler,
Scale3D,
Signpost,
Wallpaper,
AlignLeft,
} from '@signozhq/icons';
// Derived from an actual icon component so the type stays exact (size is a
@@ -51,6 +55,8 @@ export enum SectionKind {
Thresholds = 'thresholds',
ContextLinks = 'contextLinks',
Columns = 'columns',
TextLayout = 'presentation',
PanelHeader = 'headerOptions',
}
/**
@@ -93,6 +99,8 @@ export interface SectionSpecMap {
[SectionKind.Thresholds]: AnyThreshold[]; // spec.plugin.spec.thresholds (variant picks the editor)
[SectionKind.ContextLinks]: DashboardtypesLinkDTO[]; // spec.links (PANEL-level)
[SectionKind.Columns]: TelemetrytypesTelemetryFieldKeyDTO[]; // spec.plugin.spec.selectFields (List)
[SectionKind.TextLayout]: DashboardtypesTextPresentationDTO; // spec.plugin.spec.presentation (Text)
[SectionKind.PanelHeader]: DashboardtypesHeaderOptionsDTO; // spec.plugin.spec.headerOptions (Text)
}
/**
@@ -140,7 +148,11 @@ export interface SectionControls {
export type ControlledSectionKind = keyof SectionControls;
/** Atomic sections — no sub-controls; a kind either shows them or not. */
export type AtomicSectionKind = SectionKind.ContextLinks | SectionKind.Columns;
export type AtomicSectionKind =
| SectionKind.ContextLinks
| SectionKind.Columns
| SectionKind.TextLayout
| SectionKind.PanelHeader;
/** Predicate to hide a section from the current spec; returning true removes it. */
export type SectionVisibilityPredicate = (
@@ -173,6 +185,8 @@ export const SECTION_METADATA = {
[SectionKind.Thresholds]: { title: 'Thresholds', icon: Antenna },
[SectionKind.ContextLinks]: { title: 'Context Links', icon: Link2 },
[SectionKind.Columns]: { title: 'Columns', icon: Columns3 },
[SectionKind.TextLayout]: { title: 'Panel appearance', icon: AlignLeft },
[SectionKind.PanelHeader]: { title: 'Panel header', icon: PanelTop },
} as const satisfies Record<SectionKind, SectionMetadata>;
/**

View File

@@ -0,0 +1,56 @@
import { interpolateVariables } from '../interpolateVariables';
const variables = {
env: { value: 'prod' },
service: { value: ['checkout', 'cart'] },
count: { value: 3 },
};
describe('interpolateVariables', () => {
it.each([
['{{env}}', 'prod'],
['{{.env}}', 'prod'],
['[[env]]', 'prod'],
['$env', 'prod'],
])('substitutes the %s syntax', (token, expected) => {
expect(interpolateVariables(`env is ${token}.`, variables)).toBe(
`env is ${expected}.`,
);
});
it('substitutes a dotted name in the $ syntax', () => {
const dotted = { 'service.name': { value: 'checkout' } };
expect(interpolateVariables('svc is $service.name.', dotted)).toBe(
'svc is checkout.',
);
});
it('leaves $__ macros alone', () => {
expect(interpolateVariables('every $__interval', variables)).toBe(
'every $__interval',
);
});
it('joins list values with a comma', () => {
expect(interpolateVariables('on {{service}}', variables)).toBe(
'on checkout, cart',
);
});
it('stringifies numeric values', () => {
expect(interpolateVariables('n={{count}}', variables)).toBe('n=3');
});
it('leaves an undefined variable as literal text', () => {
expect(interpolateVariables('see {{missing}} and $nope', variables)).toBe(
'see {{missing}} and $nope',
);
});
it('injects values as content, not markup boundaries', () => {
const hostile = { env: { value: '**bold** <script>x</script>' } };
expect(interpolateVariables('{{env}}', hostile)).toBe(
'**bold** <script>x</script>',
);
});
});

View File

@@ -2,7 +2,7 @@ import type { DashboardtypesQueryDTO } from 'api/generated/services/sigNoz.schem
import { listViewInitialLogQuery } from 'constants/queryBuilder';
import { toPerses } from '../../queryV5/persesQueryAdapters';
import { PANEL_KIND_TO_PANEL_TYPE, type PanelKind } from '../types/panelKind';
import { toPanelType, type PanelKind } from '../types/panelKind';
/** Seed query for a new panel. Only a list panel needs one (logs, timestamp desc) so its
* preview runs on open; other kinds start empty and seed from the builder. */
@@ -11,5 +11,5 @@ export function buildDefaultQueries(kind: PanelKind): DashboardtypesQueryDTO[] {
return [];
}
// `toPerses` pivots through the V1 `Query`, which is still keyed by panel type.
return toPerses(listViewInitialLogQuery, PANEL_KIND_TO_PANEL_TYPE[kind]);
return toPerses(listViewInitialLogQuery, toPanelType(kind));
}

View File

@@ -6,7 +6,9 @@ import {
DashboardtypesLineStyleDTO,
type DashboardtypesPanelSpecDTO,
DashboardtypesThresholdFormatDTO,
DashboardtypesTextAlignDTO,
DashboardtypesTimePreferenceDTO,
DashboardtypesVerticalAlignDTO,
type TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
@@ -35,6 +37,10 @@ export interface SeededPluginSpec {
>;
selectFields?: SectionSpecMap[SectionKind.Columns];
thresholds?: AnyThreshold[];
presentation?: SectionSpecMap[SectionKind.TextLayout];
headerOptions?: SectionSpecMap[SectionKind.PanelHeader];
/** Text panel body. Not a config section — the editor's main pane owns it. */
text?: string;
}
export interface SeedContext {
@@ -114,6 +120,32 @@ function isEmptySlice(value: object): boolean {
}
const SECTION_SEEDS: SectionSeeds = {
[SectionKind.TextLayout]: {
specKey: 'presentation',
// Explicit alignment defaults (not the API's implicit ones) so the controls
// open on a value, and the body carries across a kind switch and back.
// `background` has no default — an unset field is the standard card.
seed: (
_controls,
{ oldPluginSpec },
): SectionSpecMap[SectionKind.TextLayout] => {
const old = oldPluginSpec?.presentation;
return {
textAlign: old?.textAlign ?? DashboardtypesTextAlignDTO.left,
verticalAlign: old?.verticalAlign ?? DashboardtypesVerticalAlignDTO.top,
...(old?.background && { background: old.background }),
};
},
},
[SectionKind.PanelHeader]: {
specKey: 'headerOptions',
// Only an active opt-out carries; absent = show (the API's zero value).
seed: (
_controls,
{ oldPluginSpec },
): SectionSpecMap[SectionKind.PanelHeader] =>
oldPluginSpec?.headerOptions?.hide ? { hide: true } : {},
},
[SectionKind.Visualization]: {
specKey: 'visualization',
seed: (

View File

@@ -3,7 +3,7 @@ import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getQueryPanelDefinition } from '../capabilities';
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
import { toPanelType } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
/**
@@ -28,5 +28,5 @@ export function getPanelBuilderQuery(
if (panel.spec.queries.length === 0 && defaultSignal) {
return initialQueriesMap[defaultSignal];
}
return fromPerses(panel.spec.queries, PANEL_KIND_TO_PANEL_TYPE[kind]);
return fromPerses(panel.spec.queries, toPanelType(kind));
}

View File

@@ -0,0 +1,51 @@
import type { Querybuildertypesv5QueryRangeRequestDTOVariables } from 'api/generated/services/sigNoz.schemas';
// The four syntaxes a dashboard body may carry. Group 1 covers `{{name}}` and
// `{{.name}}`, group 2 `[[name]]`, group 3 `$name`. The `$` form takes dotted
// names (`$service.name`) but not a trailing dot, so a sentence-ending period
// stays prose; `$__…` macros are excluded, as in the query reference engine.
const VARIABLE_PATTERN =
/\{\{\s*\.?([\w.-]+)\s*\}\}|\[\[\s*([\w.-]+)\s*\]\]|\$(?!__)([A-Za-z_]\w*(?:\.\w+)*)/g;
// Variable values arrive as scalars or lists of them; anything else is not
// something a reader would want spliced into prose.
function formatValue(value: unknown): string | null {
if (Array.isArray(value)) {
return value.map((entry) => formatValue(entry) ?? '').join(', ');
}
if (typeof value === 'string') {
return value;
}
if (typeof value === 'number' || typeof value === 'boolean') {
return value.toString();
}
return null;
}
/**
* Substitutes dashboard variables into a Markdown body before it is parsed, so an
* injected value becomes Markdown *content* rather than markup — dynamic-variable
* values come from telemetry and are attacker-influenceable.
*
* An undefined variable is left as literal text, matching how queries treat one.
*/
export function interpolateVariables(
text: string,
variables: Querybuildertypesv5QueryRangeRequestDTOVariables | undefined,
): string {
if (!text || !variables) {
return text;
}
return text.replace(
VARIABLE_PATTERN,
(match, braced?: string, bracketed?: string, dollar?: string): string => {
const name = braced ?? bracketed ?? dollar;
if (!name) {
return match;
}
const formatted = formatValue(variables[name]?.value);
return formatted ?? match;
},
);
}

View File

@@ -0,0 +1,17 @@
import type {
DashboardtypesHeaderOptionsDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* Whether the panel opted out of its header strip (`headerOptions.hide`) —
* one localized cast over the plugin-spec union, as `useTextBackground`.
*/
export function isPanelHeaderHidden(spec: DashboardtypesPanelSpecDTO): boolean {
const headerOptions = (
spec.plugin.spec as {
headerOptions?: DashboardtypesHeaderOptionsDTO;
}
).headerOptions;
return headerOptions?.hide === true;
}

View File

@@ -0,0 +1,26 @@
import type {
DashboardtypesPanelSpecDTO,
DashboardtypesTextPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
/**
* The spec with a new authored body. Two localized casts: the plugin-spec union
* can't be narrowed by a dynamic kind, and a bare literal resolves against the
* wrong arm of `plugin` on the way back in.
*/
export function withPanelText(
spec: DashboardtypesPanelSpecDTO,
text: string,
): DashboardtypesPanelSpecDTO {
const pluginSpec: DashboardtypesTextPanelSpecDTO = {
...(spec.plugin.spec as DashboardtypesTextPanelSpecDTO),
text,
};
return {
...spec,
plugin: {
...spec.plugin,
spec: pluginSpec,
} as DashboardtypesPanelSpecDTO['plugin'],
};
}

View File

@@ -1,10 +1,52 @@
.panel {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
background: var(--l2-background);
border: 1px solid var(--l2-border);
background: var(--text-panel-surface, var(--l2-background));
border: 1px solid var(--text-panel-border, var(--l2-border));
border-radius: 4px;
// Inherits into the header and the body, so no descendant names a colour.
color: var(--text-panel-ink, inherit);
overflow: hidden;
}
// `headerOptions.hide`: hover floats in the drag pill + actions menu, so a
// headerless panel can still be moved and edited.
.hiddenHeaderControls {
position: absolute;
inset: 0 0 auto;
z-index: 2;
height: 36px;
display: flex;
align-items: flex-start;
justify-content: center;
padding-top: 8px;
opacity: 0;
pointer-events: none;
transition: opacity 0.15s ease;
}
// The open-menu case: the dropdown is a portal, so hovering it un-hovers the
// panel — without this the trigger fades while its menu stays up.
.panel:hover .hiddenHeaderControls,
.hiddenHeaderControls:focus-within,
.hiddenHeaderControls:has([data-state='open']) {
opacity: 1;
pointer-events: auto;
}
.dragPill {
width: 64px;
height: 6px;
border-radius: 999px;
background: var(--text-panel-grip, var(--l2-border));
cursor: grab;
}
.floatingActions {
position: absolute;
top: 4px;
right: 8px;
}

View File

@@ -1,9 +1,11 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { useTextBackground } from 'pages/DashboardPage/DashboardContainer/Panels/hooks/useTextBackground';
import type { DashboardSection } from '../../utils';
import QueryPanel from './QueryPanel';
import StaticPanel from './StaticPanel';
import QueryPanelContent from './QueryPanelContent';
import StaticPanelContent from './StaticPanelContent';
import styles from './Panel.module.scss';
/**
* Layout context for the panel actions menu — present only in editable mode. No
@@ -24,8 +26,9 @@ interface PanelProps {
}
/**
* A single dashboard panel. Forks on the kind's mode before any query machinery
* exists, so a static kind never mounts a fetch — not even a disabled one.
* A single dashboard panel: the card shell, forking on the kind's mode before any
* query machinery exists, so a static kind never mounts a fetch — not even a
* disabled one.
*/
function Panel({
panel,
@@ -34,27 +37,34 @@ function Panel({
panelActions,
}: PanelProps): JSX.Element {
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
if (panelDefinition.mode === 'static') {
return (
<StaticPanel
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
);
}
const background = useTextBackground(panel.spec);
return (
<QueryPanel
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
<div
className={styles.panel}
style={background.style}
data-panel-visible={isVisible === false ? 'false' : 'true'}
// Stable locator so the "Download as PNG" action can find this node to
// capture, without threading a ref through the header/actions chain.
data-panel-root={panelId}
>
{panelDefinition.mode === 'static' ? (
<StaticPanelContent
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
panelActions={panelActions}
/>
) : (
<QueryPanelContent
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
)}
</div>
);
}

View File

@@ -3,7 +3,7 @@
align-items: center;
justify-content: space-between;
padding: 8px 12px;
border-bottom: 1px solid var(--l2-border);
border-bottom: 1px solid var(--text-panel-border, var(--l2-border));
cursor: grab;
min-height: 32px;
}
@@ -20,6 +20,7 @@
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: var(--text-panel-ink, inherit);
}
.badge {

View File

@@ -11,6 +11,7 @@ import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/quer
import type { PanelActionsConfig } from '../Panel';
import PanelActionsMenu from '../PanelActionsMenu/PanelActionsMenu';
import { EMPTY_PANEL_QUERY_DATA } from '../utils/emptyPanelQueryData';
import PanelHeaderSearch from './PanelHeaderSearch';
import PanelStatusPopover from '../PanelStatus/PanelStatusPopover';
import {
@@ -21,10 +22,22 @@ import {
import styles from './PanelHeader.module.scss';
import { TooltipSimple } from '@signozhq/ui/tooltip';
interface PanelHeaderProps {
interface PanelHeaderBaseProps {
panelId: string;
/** The panel itself — its query seeds the menu's "Create Alerts" action. */
panel: DashboardtypesPanelDTO;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
/**
* Suppress the actions menu entirely — for the editor preview, where
* panel-level actions don't apply (some survive their gates without
* `panelActions`, so omitting it isn't enough).
*/
hideActions?: boolean;
}
interface QueryPanelHeaderProps extends PanelHeaderBaseProps {
mode: 'query';
/** The panel's query response — the menu's source for "Download as CSV". */
data: PanelQueryData;
/** Background refresh in flight — shows a spinner without blinking the chart. */
@@ -35,44 +48,35 @@ interface PanelHeaderProps {
warning?: WarningDTO;
/** Per-panel time-preference label; null when it follows the dashboard window. */
timeLabel?: PanelTimePreferenceLabel | null;
/** Layout context for move/delete — absent outside editable sectioned mode. */
panelActions?: PanelActionsConfig;
/** Kind declares header search — renders the box. */
searchable?: boolean;
/** Current search term; shell owns it, the renderer applies the filter. */
searchTerm?: string;
/** Pushes a new search term up to the shell. */
onSearchChange?: (value: string) => void;
/**
* Suppress the actions menu entirely — for the editor preview, where
* panel-level actions don't apply (some survive their gates without
* `panelActions`, so omitting it isn't enough).
*/
hideActions?: boolean;
}
interface StaticPanelHeaderProps extends PanelHeaderBaseProps {
mode: 'static';
}
type PanelHeaderProps = QueryPanelHeaderProps | StaticPanelHeaderProps;
/** Panel chrome: drag handle, title, refetch + status indicators, actions. */
function PanelHeader({
panelId,
panel,
data,
isFetching,
error,
warning,
timeLabel,
panelActions,
searchable,
searchTerm = '',
onSearchChange,
hideActions,
}: PanelHeaderProps): JSX.Element {
function PanelHeader(props: PanelHeaderProps): JSX.Element {
const { panelId, panel, panelActions, hideActions } = props;
const query = props.mode === 'query' ? props : null;
const name = panel.spec.display.name;
const description = panel.spec.display.description;
const errorDetail = useMemo(() => panelStatusFromError(error), [error]);
const errorDetail = useMemo(
() => panelStatusFromError(query?.error),
[query?.error],
);
const warningDetail = useMemo(
() => panelStatusFromWarning(warning),
[warning],
() => panelStatusFromWarning(query?.warning),
[query?.warning],
);
// Client-derived: warn a Number panel that has more than one enabled query (#9512).
@@ -113,7 +117,7 @@ function PanelHeader({
/>
</TooltipSimple>
)}
{isFetching && (
{query?.isFetching && (
<Loader
size={12}
className={cx('animate-spin', styles.refetchIndicator)}
@@ -124,13 +128,16 @@ function PanelHeader({
{/* `panel-no-drag` opts this region out of the drag handle so clicks hit
the controls instead of starting a panel drag. */}
<div className={cx('panel-no-drag', styles.actions)}>
{searchable && onSearchChange && (
<PanelHeaderSearch value={searchTerm ?? ''} onChange={onSearchChange} />
{query?.searchable && query.onSearchChange && (
<PanelHeaderSearch
value={query.searchTerm ?? ''}
onChange={query.onSearchChange}
/>
)}
{timeLabel && (
<TooltipSimple title={timeLabel.full} arrow>
{query?.timeLabel && (
<TooltipSimple title={query.timeLabel.full} arrow>
<span className={styles.timePill} data-testid="panel-time-preference">
{timeLabel.short}
{query.timeLabel.short}
</span>
</TooltipSimple>
)}
@@ -150,7 +157,7 @@ function PanelHeader({
<PanelActionsMenu
panelId={panelId}
panel={panel}
data={data}
data={query?.data ?? EMPTY_PANEL_QUERY_DATA}
panelActions={panelActions}
/>
)}

View File

@@ -14,9 +14,8 @@ import { useDrilldown } from './hooks/useDrilldown';
import { usePanelInteractions } from './hooks/usePanelInteractions';
import PanelBody from './PanelBody/PanelBody';
import PanelHeader from './PanelHeader/PanelHeader';
import styles from './Panel.module.scss';
interface QueryPanelProps {
interface QueryPanelContentProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The kind's definition, narrowed to the query arm by `Panel`'s fork. */
@@ -28,17 +27,17 @@ interface QueryPanelProps {
}
/**
* A query-backed dashboard panel (header + body). Thin orchestrator: fetching
* lives in `usePanelQuery`, interactions in `usePanelInteractions`, state in
* `PanelBody`.
* The query arm's content (header + body) its own component so these hooks
* never mount for a static kind. Thin orchestrator: fetching lives in
* `usePanelQuery`, interactions in `usePanelInteractions`, state in `PanelBody`.
*/
function QueryPanel({
function QueryPanelContent({
panel,
panelId,
panelDefinition,
isVisible,
panelActions,
}: QueryPanelProps): JSX.Element {
}: QueryPanelContentProps): JSX.Element {
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
const panelKind = panel.spec.plugin.kind;
@@ -67,14 +66,9 @@ function QueryPanel({
const drilldown = useDrilldown(panel, panelId);
return (
<div
className={styles.panel}
data-panel-visible={isOffScreen ? 'false' : 'true'}
// Stable locator so the "Download as PNG" action can find this node to
// capture, without threading a ref through the header/actions chain.
data-panel-root={panelId}
>
<>
<PanelHeader
mode="query"
panelId={panelId}
panel={panel}
data={data}
@@ -105,8 +99,8 @@ function QueryPanel({
enableDrillDown={drilldown.enableDrillDown}
/>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
</>
);
}
export default QueryPanel;
export default QueryPanelContent;

View File

@@ -1,56 +0,0 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import type { PanelActionsConfig } from './Panel';
import PanelHeader from './PanelHeader/PanelHeader';
import StaticPanelBody from './StaticPanelBody/StaticPanelBody';
import styles from './Panel.module.scss';
interface StaticPanelProps {
panel: DashboardtypesPanelDTO;
panelId: string;
panelDefinition: RenderableStaticPanelDefinition;
isVisible?: boolean;
panelActions?: PanelActionsConfig;
}
/**
* A dashboard panel that renders from its own plugin spec: chrome plus the static
* body. No fetch, no status indicators, no time preference, no drilldown — none
* of that exists without a query.
*/
function StaticPanel({
panel,
panelId,
panelDefinition,
isVisible,
panelActions,
}: StaticPanelProps): JSX.Element {
return (
<div
className={styles.panel}
data-panel-visible={isVisible ? 'true' : 'false'}
// Stable locator, as on QueryPanel — actions that capture the panel node
// (and tests) address it the same way for both arms.
data-panel-root={panelId}
>
<PanelHeader
panelId={panelId}
panel={panel}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
timeLabel={null}
panelActions={panelActions}
/>
<StaticPanelBody
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelId}
/>
</div>
);
}
export default StaticPanel;

View File

@@ -12,6 +12,8 @@ interface StaticPanelBodyProps {
panelId: string;
/** Render context — defaults to the dashboard view; the editor preview passes EDIT. */
panelMode?: PanelMode;
/** Saves an edit made from the rendered body; absent leaves it read-only. */
onChangeText?: (text: string) => void;
}
/**
@@ -25,6 +27,7 @@ function StaticPanelBody({
panel,
panelId,
panelMode = PanelMode.DASHBOARD_VIEW,
onChangeText,
}: StaticPanelBodyProps): JSX.Element {
// From the edit context, not props: the editor route seeds it too, so an
// unsaved panel's preview resolves variables the same way the grid does.
@@ -37,6 +40,7 @@ function StaticPanelBody({
panel={panel as PanelOfKind}
panelMode={panelMode}
dashboardId={dashboardId || undefined}
onChangeText={onChangeText}
/>
</div>
);

View File

@@ -0,0 +1,71 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import cx from 'classnames';
import { useUpdatePanelText } from 'pages/DashboardPage/DashboardContainer/Panels/hooks/useUpdatePanelText';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { isPanelHeaderHidden } from 'pages/DashboardPage/DashboardContainer/Panels/utils/isPanelHeaderHidden';
import type { PanelActionsConfig } from './Panel';
import PanelActionsMenu from './PanelActionsMenu/PanelActionsMenu';
import PanelHeader from './PanelHeader/PanelHeader';
import StaticPanelBody from './StaticPanelBody/StaticPanelBody';
import { EMPTY_PANEL_QUERY_DATA } from './utils/emptyPanelQueryData';
import styles from './Panel.module.scss';
interface StaticPanelContentProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The kind's definition, narrowed to the static arm by `Panel`'s fork. */
panelDefinition: RenderableStaticPanelDefinition;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* The static arm's content (header + body) — its own component so none of the
* query machinery is even imported on this path. A hidden header trades the
* chrome for hover-revealed drag and actions controls.
*/
function StaticPanelContent({
panel,
panelId,
panelDefinition,
panelActions,
}: StaticPanelContentProps): JSX.Element {
const onChangeText = useUpdatePanelText(panelId);
return (
<>
{isPanelHeaderHidden(panel.spec) ? (
<div className={styles.hiddenHeaderControls}>
<span
className={cx('panel-drag-handle', styles.dragPill)}
data-testid="hidden-header-drag-handle"
/>
<div className={styles.floatingActions}>
<PanelActionsMenu
panelId={panelId}
panel={panel}
data={EMPTY_PANEL_QUERY_DATA}
panelActions={panelActions}
/>
</div>
</div>
) : (
<PanelHeader
mode="static"
panelId={panelId}
panel={panel}
panelActions={panelActions}
/>
)}
<StaticPanelBody
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelId}
onChangeText={onChangeText}
/>
</>
);
}
export default StaticPanelContent;

View File

@@ -124,7 +124,7 @@ function QueryViewModalBody({
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
void logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
@@ -135,6 +135,7 @@ function QueryViewModalBody({
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
mode="query"
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}
@@ -170,6 +171,7 @@ function QueryViewModalBody({
</div>
<div className={styles.body}>
<PreviewPane
mode="query"
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}

View File

@@ -1,19 +1,16 @@
import { useCallback } from 'react';
import { PenLine } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import cx from 'classnames';
import { PanelMode } from 'lib/visualization/panels/types';
import logEvent from 'api/common/logEvent';
import PanelTypeSwitcher from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/PanelTypeSwitcher';
import PreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import PanelHeader from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/PanelHeader/PanelHeader';
import StaticPanelBody from 'pages/DashboardPage/DashboardContainer/PanelsAndSectionsLayout/Panel/StaticPanelBody/StaticPanelBody';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import { withPanelText } from 'pages/DashboardPage/DashboardContainer/Panels/utils/withPanelText';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import { DashboardEvents } from 'pages/DashboardPage/constants/events';
import { EQueryType } from 'types/common/dashboard';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import styles from './ViewPanelModal.module.scss';
interface StaticViewModalBodyProps {
@@ -24,10 +21,10 @@ interface StaticViewModalBodyProps {
}
/**
* The static-kind View modal body: the panel rendered live over the kind's
* editor pane the same layout idea as the query body, with the time window,
* query builder and drilldown machinery absent because none of it applies.
* Edits are temporary; "Edit panel" hands them to the full editor.
* The static-kind View modal body: the query body's layout with the kind's
* editor pane in the query-builder slot and the live panel below — the time
* window, query builder and drilldown machinery absent because none of it
* applies. Edits are temporary; "Switch to Edit Mode" hands them to the editor.
*/
function StaticViewModalBody({
panelId,
@@ -36,9 +33,15 @@ function StaticViewModalBody({
onChangePanelKind,
}: StaticViewModalBodyProps): JSX.Element {
const { draft, spec, setSpec } = draftApi;
const { EditorPane, Renderer } = panelDefinition;
const { EditorPane } = panelDefinition;
const openPanelEditor = useOpenPanelEditor();
// Temporary, like every edit here: "Switch to Edit Mode" carries it over.
const onChangeText = useCallback(
(text: string): void => setSpec(withPanelText(spec, text)),
[spec, setSpec],
);
const onSwitchToEdit = useCallback((): void => {
void logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, { panelId });
// Carry the in-modal edits so the editor opens on them, not the saved panel.
@@ -49,43 +52,25 @@ function StaticViewModalBody({
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<div className={styles.staticToolbar}>
<PanelTypeSwitcher
panelKind={draft.spec.plugin.kind}
queryType={EQueryType.QUERY_BUILDER}
onChange={onChangePanelKind}
/>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
prefix={<PenLine size={14} />}
onClick={onSwitchToEdit}
data-testid="static-view-switch-to-edit"
>
Edit panel
</Button>
</div>
<div className={styles.staticPreview}>
<PanelHeader
panelId={panelId}
panel={draft}
data={EMPTY_PANEL_QUERY_DATA}
isFetching={false}
error={null}
hideActions
/>
<StaticPanelBody
Renderer={Renderer}
panel={draft}
panelId={panelId}
panelMode={PanelMode.STANDALONE_VIEW}
/>
</div>
<div className={styles.staticEditorPane}>
<ViewPanelModalHeader
mode="static"
panelKind={draft.spec.plugin.kind}
onChangePanelKind={onChangePanelKind}
onSwitchToEdit={onSwitchToEdit}
/>
<div className={cx(styles.queryBuilder, styles.staticEditor)}>
<EditorPane spec={spec} onChangeSpec={setSpec} />
</div>
<div className={styles.body}>
<PreviewPane
mode="static"
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
panelMode={PanelMode.STANDALONE_VIEW}
onChangeText={onChangeText}
/>
</div>
</div>
);
}

View File

@@ -61,28 +61,8 @@
width: 240px;
}
// Static-kind modal body: toolbar, live panel, editor pane.
.staticToolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 0;
}
.staticPreview {
display: flex;
flex-direction: column;
flex: 1 1 55%;
min-height: 0;
border: 1px solid var(--l2-border);
border-radius: 4px;
background: var(--l2-background);
overflow: hidden;
}
.staticEditorPane {
flex: 1 1 45%;
min-height: 0;
margin-top: 12px;
// The markdown editor fills its container (CodeMirror), unlike the
// content-sized query builder — give the static editor slot a fixed share.
.staticEditor {
flex: 0 0 30vh;
}

View File

@@ -10,7 +10,7 @@ import useUrlQuery from 'hooks/useUrlQuery';
import { usePanelEditorDraft } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelTypeSwitch';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
@@ -60,8 +60,7 @@ function ViewPanelModalContent({
spec: buildViewPanelSpec({
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
panelType: urlGraphType ?? toPanelType(baseSpec.plugin.kind),
}),
}
: { ...panel, spec: baseSpec };
@@ -76,7 +75,7 @@ function ViewPanelModalContent({
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[draftKind],
panelType: toPanelType(draftKind),
setSpec: draftApi.setSpec,
});

View File

@@ -10,13 +10,21 @@ import type {
import { usePanelTypeSelectItems } from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/usePanelTypeSelectItems';
import ConfigSelect from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/controls/ConfigSelect/ConfigSelect';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { EQueryType } from 'types/common/dashboard';
import { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
import AuthZTooltip from 'lib/authz/components/AuthZTooltip/AuthZTooltip';
import { useDashboardEditContext } from '../../../hooks/useDashboardEditContext';
interface ViewPanelModalHeaderProps {
interface ViewPanelModalHeaderBaseProps {
onSwitchToEdit: () => void;
/** Draft's current kind (selected value of the panel-type selector). */
panelKind: PanelKind;
onChangePanelKind: (kind: PanelKind) => void;
}
interface QueryViewModalHeaderProps extends ViewPanelModalHeaderBaseProps {
mode: 'query';
selectedInterval: Time | CustomTimeType;
/** Current window bounds (epoch ms) — seed the picker's modal display. */
startMs: number;
@@ -28,9 +36,6 @@ interface ViewPanelModalHeaderProps {
/** Any query in flight — spins the refresh icon and disables it. */
isFetching: boolean;
onRefresh: () => void;
onSwitchToEdit: () => void;
/** Draft's current kind (selected value of the panel-type selector). */
panelKind: PanelKind;
/**
* The active query-builder tab (Query Builder / PromQL / ClickHouse). The type
* selector greys out kinds that can't be authored in it — e.g. List is
@@ -39,30 +44,28 @@ interface ViewPanelModalHeaderProps {
queryType: EQueryType;
/** Current builder datasource — greys out kinds that don't support it (e.g. List needs logs/traces, not metrics). */
signal: TelemetrytypesSignalDTO;
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the saved query + kind (drilldown reset). */
onResetQuery: () => void;
}
interface StaticViewModalHeaderProps extends ViewPanelModalHeaderBaseProps {
mode: 'static';
}
type ViewPanelModalHeaderProps =
| QueryViewModalHeaderProps
| StaticViewModalHeaderProps;
/**
* Toolbar for the View modal: reset the drilldown, open the full editor, switch the
* visualization kind, pick a per-view time window (isolated from the dashboard), and
* refresh. Mirrors V1's FullView header controls.
* refresh. Mirrors V1's FullView header controls. In static mode only the kind
* selector and the edit switch remain — the rest is query machinery.
*/
function ViewPanelModalHeader({
selectedInterval,
startMs,
endMs,
onTimeChange,
isFetching,
onRefresh,
onSwitchToEdit,
panelKind,
queryType,
signal,
onChangePanelKind,
onResetQuery,
}: ViewPanelModalHeaderProps): JSX.Element {
function ViewPanelModalHeader(props: ViewPanelModalHeaderProps): JSX.Element {
const { onSwitchToEdit, panelKind, onChangePanelKind } = props;
const query = props.mode === 'query' ? props : null;
const {
isEditable: canSwitchToEdit,
editChecks,
@@ -70,7 +73,10 @@ function ViewPanelModalHeader({
} = useDashboardEditContext();
// Same capabilities-guarded options as the editor's PanelTypeSwitcher, so the two
// selectors disable the same kinds (e.g. List under PromQL, metrics-only kinds).
const panelTypeItems = usePanelTypeSelectItems({ queryType, signal });
const panelTypeItems = usePanelTypeSelectItems({
queryType: query?.queryType ?? EQueryType.QUERY_BUILDER,
signal: query?.signal,
});
return (
<div className={styles.toolbar}>
@@ -94,38 +100,42 @@ function ViewPanelModalHeader({
Switch to Edit Mode
</Button>
</AuthZTooltip>
<Button
variant="link"
color="primary"
onClick={onResetQuery}
data-testid="view-panel-reset-query"
>
Reset Query
</Button>
<div className={styles.toolbarTime}>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
isModalTimeSelection
disableUrlSync
onTimeChange={onTimeChange}
modalSelectedInterval={selectedInterval as Time}
modalInitialStartTime={startMs}
modalInitialEndTime={endMs}
/>
{query && (
<Button
size="icon"
variant="outlined"
color="secondary"
onClick={onRefresh}
disabled={isFetching}
aria-label="Refresh"
data-testid="view-panel-refresh"
variant="link"
color="primary"
onClick={query.onResetQuery}
data-testid="view-panel-reset-query"
>
<RotateCw className={cx({ 'animate-spin': isFetching })} />
Reset Query
</Button>
</div>
)}
{query && (
<div className={styles.toolbarTime}>
<DateTimeSelectionV2
showAutoRefresh={false}
showRefreshText={false}
hideShareModal
isModalTimeSelection
disableUrlSync
onTimeChange={query.onTimeChange}
modalSelectedInterval={query.selectedInterval as Time}
modalInitialStartTime={query.startMs}
modalInitialEndTime={query.endMs}
/>
<Button
size="icon"
variant="outlined"
color="secondary"
onClick={query.onRefresh}
disabled={query.isFetching}
aria-label="Refresh"
data-testid="view-panel-refresh"
>
<RotateCw className={cx({ 'animate-spin': query.isFetching })} />
</Button>
</div>
)}
</div>
);
}

View File

@@ -10,7 +10,7 @@ import { usePanelEditSession } from 'pages/DashboardPage/DashboardContainer/Pane
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import type { OpenDrilldownView } from 'pages/DashboardPage/DashboardContainer/Panels/types/drilldown';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { toPanelType } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { fromPerses } from 'pages/DashboardPage/DashboardContainer/queryV5/persesQueryAdapters';
@@ -86,11 +86,7 @@ export function useViewPanelMode({
// The query the view opened with, captured once — the Reset target.
const savedQuery = useMemo(
() =>
fromPerses(
panel.spec.queries,
PANEL_KIND_TO_PANEL_TYPE[panel.spec.plugin.kind],
),
() => fromPerses(panel.spec.queries, toPanelType(panel.spec.plugin.kind)),
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only snapshot
[],
);

View File

@@ -1,8 +1,11 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import {
TEXT_BACKGROUND_PAIRS,
TRANSPARENT_BACKGROUND,
} from 'pages/DashboardPage/DashboardContainer/Panels/kinds/TextPanel/background/presets';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import { EMPTY_PANEL_QUERY_DATA } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import Panel from '../Panel';
@@ -26,6 +29,10 @@ jest.mock('../PanelBody/PanelBody', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="query-panel-body" />,
}));
jest.mock('../PanelActionsMenu/PanelActionsMenu', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="panel-actions-menu" />,
}));
jest.mock('../hooks/useDrilldown', () => ({
useDrilldown: (): unknown => ({
onPanelClick: jest.fn(),
@@ -43,6 +50,13 @@ jest.mock('periscope/components/ContextMenu', () => ({
__esModule: true,
default: (): null => null,
}));
// Reaches react-query for the dashboard patch; this file renders without providers.
jest.mock(
'pages/DashboardPage/DashboardContainer/Panels/hooks/useUpdatePanelText',
() => ({
useUpdatePanelText: (): undefined => undefined,
}),
);
const mockUsePanelQuery = usePanelQuery as jest.Mock;
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
@@ -74,7 +88,7 @@ describe('Panel — authoring-mode fork', () => {
beforeEach(() => {
mockUsePanelQuery.mockReset();
mockUsePanelQuery.mockReturnValue({
data: EMPTY_PANEL_QUERY_DATA,
data: { response: undefined, requestPayload: undefined, legendMap: {} },
isFetching: false,
isPreviousData: false,
error: null,
@@ -101,6 +115,27 @@ describe('Panel — authoring-mode fork', () => {
expect(mockUsePanelQuery).not.toHaveBeenCalled();
});
it('swaps a hidden header for the floating drag pill + actions menu', () => {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);
const hiddenHeaderPanel = {
...panel,
spec: {
...panel.spec,
plugin: {
kind: 'signoz/TimeSeriesPanel',
spec: { headerOptions: { hide: true } },
},
},
} as unknown as DashboardtypesPanelDTO;
render(<Panel panel={hiddenHeaderPanel} panelId="p1" />);
expect(screen.queryByTestId('panel-header')).not.toBeInTheDocument();
const dragHandle = screen.getByTestId('hidden-header-drag-handle');
expect(dragHandle).toHaveClass('panel-drag-handle');
expect(screen.getByTestId('panel-actions-menu')).toBeInTheDocument();
});
it('renders the static body in dashboard-view mode with panel chrome', () => {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);
@@ -112,4 +147,61 @@ describe('Panel — authoring-mode fork', () => {
);
expect(screen.getByTestId('panel-header')).toBeInTheDocument();
});
describe('text background', () => {
function textPanelWith(background?: string): DashboardtypesPanelDTO {
return {
...panel,
spec: {
...panel.spec,
plugin: {
kind: 'signoz/TextPanel',
spec: { text: '', presentation: { background } },
},
},
} as unknown as DashboardtypesPanelDTO;
}
function renderPanel(target: DashboardtypesPanelDTO): HTMLElement {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);
const { container } = render(<Panel panel={target} panelId="p1" />);
return container.querySelector('[data-panel-root="p1"]') as HTMLElement;
}
// The header is `headerOptions`' business alone: a transparent panel keeps its
// title bar, and drops the card by painting the surface rather than by class.
it('keeps the title bar for a zero-alpha background', () => {
const root = renderPanel(textPanelWith(TRANSPARENT_BACKGROUND));
expect(screen.getByTestId('panel-header')).toBeInTheDocument();
expect(root.style.getPropertyValue('--text-panel-surface')).toBe(
'transparent',
);
expect(root.style.getPropertyValue('--text-panel-border')).toBe(
'transparent',
);
});
it('keeps the card and hands down the pair for a preset', () => {
const root = renderPanel(
textPanelWith(TEXT_BACKGROUND_PAIRS.forest.dark.surface),
);
expect(root.style.getPropertyValue('--text-panel-surface')).toBe(
TEXT_BACKGROUND_PAIRS.forest.dark.surface,
);
expect(root.style.getPropertyValue('--text-panel-ink')).toBe(
TEXT_BACKGROUND_PAIRS.forest.dark.ink,
);
expect(screen.getByTestId('panel-header')).toBeInTheDocument();
});
// Chart kinds are out of scope: the card keeps its own tokens.
it('sets no custom properties for a panel with no background', () => {
const root = renderPanel(panel);
expect(root.style.getPropertyValue('--text-panel-surface')).toBe('');
expect(screen.getByTestId('panel-header')).toBeInTheDocument();
});
});
});

View File

@@ -76,6 +76,7 @@ function makePanelWithQueries(
}
const baseProps = {
mode: 'query' as const,
panel: makePanel(),
panelId: 'panel-1',
data: {
@@ -243,6 +244,22 @@ describe('PanelHeader actions menu', () => {
});
});
describe('PanelHeader static mode', () => {
it('renders the title and actions menu with no query-status chrome', () => {
renderWithProvider(
<PanelHeader mode="static" panelId="panel-1" panel={makePanel()} />,
);
expect(screen.getByText('My panel')).toBeInTheDocument();
expect(screen.getByTestId('panel-actions-menu')).toBeInTheDocument();
expect(screen.queryByTestId('panel-refetching')).not.toBeInTheDocument();
expect(screen.queryByTestId('panel-status-error')).not.toBeInTheDocument();
expect(
screen.queryByTestId('panel-header-search-trigger'),
).not.toBeInTheDocument();
expect(screen.queryByTestId('panel-time-preference')).not.toBeInTheDocument();
});
});
describe('PanelHeader time-preference pill', () => {
it('shows the pill with the short label when the panel overrides the dashboard time', () => {
renderWithProvider(

View File

@@ -210,6 +210,7 @@ describe('ViewPanelModal', () => {
});
it('mounts the static body — editor pane, no query builder slot — for a static kind', () => {
mockPreviewPaneRender.mockClear();
const actual = jest.requireActual(
'pages/DashboardPage/DashboardContainer/Panels/registry',
);
@@ -219,7 +220,7 @@ describe('ViewPanelModal', () => {
sections: [],
actions: {},
mode: 'static',
Renderer: (): JSX.Element => <div data-testid="fake-static-renderer" />,
Renderer: (): null => null,
EditorPane: (): JSX.Element => <div data-testid="static-editor-pane" />,
};
(getPanelDefinition as jest.Mock).mockImplementation((kind: string) =>
@@ -237,12 +238,15 @@ describe('ViewPanelModal', () => {
/>,
);
expect(screen.getByTestId('view-panel-header')).toBeInTheDocument();
expect(screen.getByTestId('static-editor-pane')).toBeInTheDocument();
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
expect(
screen.queryByTestId('panel-editor-v2-query-builder'),
).not.toBeInTheDocument();
expect(mockPreviewPaneRender).not.toHaveBeenCalled();
// One PreviewPane serves both arms; the static body asks it for the static one.
expect(mockPreviewPaneRender).toHaveBeenLastCalledWith(
expect.objectContaining({ mode: 'static' }),
);
(getPanelDefinition as jest.Mock).mockImplementation(
actual.getPanelDefinition,

Some files were not shown because too many files have changed in this diff Show More