mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 22:10:42 +01:00
Compare commits
1 Commits
chore/sept
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c8e9e362f7 |
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
|
||||
@@ -376,6 +376,7 @@ export enum PANEL_TYPES {
|
||||
BAR = 'bar',
|
||||
PIE = 'pie',
|
||||
HISTOGRAM = 'histogram',
|
||||
TEXT = 'text',
|
||||
EMPTY_WIDGET = 'EMPTY_WIDGET',
|
||||
}
|
||||
|
||||
|
||||
@@ -29,5 +29,6 @@ export const PANEL_TYPES_VS_FULL_VIEW_TABLE: PanelTypeAndGraphManagerVisibilityP
|
||||
BAR: true,
|
||||
PIE: false,
|
||||
HISTOGRAM: false,
|
||||
TEXT: false,
|
||||
EMPTY_WIDGET: false,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -19,6 +19,7 @@ const KIND_LABEL: Record<VariableUsage['kind'], string> = {
|
||||
promql: 'PromQL',
|
||||
clickhouse: 'ClickHouse',
|
||||
variable: 'Variable',
|
||||
text: 'Markdown body',
|
||||
};
|
||||
|
||||
interface VariableImpactDialogProps {
|
||||
|
||||
@@ -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}}',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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`;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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']));
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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={
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {} });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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
|
||||
@@ -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]);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
]),
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
@@ -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() ? (
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,4 @@
|
||||
.pane {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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 },
|
||||
];
|
||||
@@ -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<
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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>',
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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: (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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'],
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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;
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
[],
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user