Compare commits

..

7 Commits

Author SHA1 Message Date
Abhi Kumar
dbdb55b434 feat(dashboards): build a series selection from the legend
The row action is now a labelled All/Only toggle, and clicking rows in a
narrowed legend adds to the selection instead of moving the isolation,
which leaves Add with nothing to do. Column floors grew to fit the wider
action.

Assisted-by: Claude Opus 5
2026-09-11 14:17:34 +05:30
Abhi Kumar
d051430206 chore: minor label change 2026-09-11 13:26:24 +05:30
Abhi Kumar
953e69baec fix(dashboards): cap the width of the legend row action tooltips
The isolate tooltip names the series too, so it needs the same wrap as
the label's.
2026-09-11 13:26:24 +05:30
Abhi Kumar
5f5e3dea2a fix(dashboards): wrap long series names in the legend tooltip
Series names have no spaces to break on, so the tooltip needs a width
cap and a break rule to stop it becoming one panel-wide line.
2026-09-11 13:26:24 +05:30
Abhi Kumar
336b9e8d8b style(dashboards): match tooltip series rows to the legend
Same filled rounded marker and mono type, so a series reads the same in
the tooltip as in the legend.
2026-09-11 13:26:24 +05:30
Abhi Kumar
f5083f220a fix(dashboards): reserve exactly the rows the bottom legend shows
The reserved height came from a 40px row estimate while rows are 28px
with a 2px gap, so two rows never quite fit and one row over-reserved.
Two rows that would take half a short panel fall back to one, and a
column can no longer be sized narrower than the row's hover actions.
2026-09-11 13:26:24 +05:30
Abhi Kumar
67293da003 feat(dashboards): rework panel chart legend interaction
What a row click means now depends on how many series are shown: isolate
when all are visible, move the isolation while one is, toggle otherwise.
The marker stays a separate target so a single series can still be
excluded in one click, and at least one series always stays visible.

Only/Add are derived from the shown count rather than a remembered
isolation, so hiding series one at a time behaves the same as isolating.
2026-09-11 13:26:24 +05:30
177 changed files with 2700 additions and 12571 deletions

View File

@@ -296,17 +296,6 @@ components:
- jsmops
- incidentio
type: string
AlertmanagertypesChannelListOrder:
enum:
- asc
- desc
type: string
AlertmanagertypesChannelListSort:
enum:
- updated_at
- created_at
- name
type: string
AlertmanagertypesChannelMSTeamsConfig:
properties:
sendResolved:
@@ -587,43 +576,6 @@ components:
wont_fix_resolution:
type: string
type: object
AlertmanagertypesListableNotificationChannel:
properties:
channels:
items:
$ref: '#/components/schemas/AlertmanagertypesListedNotificationChannel'
type: array
total:
format: int64
type: integer
required:
- channels
- total
type: object
AlertmanagertypesListedNotificationChannel:
properties:
createdAt:
format: date-time
type: string
displayName:
type: string
id:
type: string
kind:
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
name:
type: string
updatedAt:
format: date-time
type: string
required:
- id
- name
- displayName
- kind
- createdAt
- updatedAt
type: object
AlertmanagertypesMaintenanceKind:
enum:
- fixed
@@ -990,20 +942,6 @@ components:
- timezone
- startTime
type: object
AlertmanagertypesTestableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
required:
- config
type: object
AlertmanagertypesUpdatableNotificationChannel:
properties:
config:
$ref: '#/components/schemas/AlertmanagertypesChannelConfig'
required:
- config
type: object
AuthtypesAttributeMapping:
properties:
email:
@@ -7457,7 +7395,10 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
type: array
meta:
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
properties:
unit:
type: string
type: object
predictedSeries:
items:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
@@ -7472,51 +7413,12 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
type: array
type: object
Querybuildertypesv5AggregationMeta:
Querybuildertypesv5Bucket:
properties:
buckets:
items:
format: double
type: number
type: array
unit:
type: string
step:
format: double
type: number
type: object
Querybuildertypesv5BucketOptions:
discriminator:
mapping:
linear: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
log: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
propertyName: kind
oneOf:
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLinear'
- $ref: '#/components/schemas/Querybuildertypesv5BucketOptionsLog'
type: object
Querybuildertypesv5BucketOptionsLinear:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
$ref: '#/components/schemas/Querybuildertypesv5LinearBucketsSpec'
required:
- kind
- spec
type: object
Querybuildertypesv5BucketOptionsLog:
properties:
kind:
$ref: '#/components/schemas/Querybuildertypesv5BucketsKind'
spec:
$ref: '#/components/schemas/Querybuildertypesv5LogBucketsSpec'
required:
- kind
- spec
type: object
Querybuildertypesv5BucketsKind:
enum:
- linear
- log
type: string
Querybuildertypesv5BuilderQuerySpec:
discriminator:
mapping:
@@ -7697,16 +7599,6 @@ components:
value:
type: string
type: object
Querybuildertypesv5LinearBucketsSpec:
properties:
maxValue:
format: double
type: number
numBuckets:
type: integer
required:
- maxValue
type: object
Querybuildertypesv5LogAggregation:
properties:
alias:
@@ -7714,12 +7606,6 @@ components:
expression:
type: string
type: object
Querybuildertypesv5LogBucketsSpec:
properties:
scale:
nullable: true
type: integer
type: object
Querybuildertypesv5MetricAggregation:
properties:
comparisonSpaceAggregationParam:
@@ -7800,8 +7686,6 @@ components:
type: object
Querybuildertypesv5QueryBuilderFormula:
properties:
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
disabled:
type: boolean
expression:
@@ -7832,8 +7716,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
@@ -7895,8 +7777,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
@@ -7958,8 +7838,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
nullable: true
type: array
bucketOptions:
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
cursor:
type: string
disabled:
@@ -8285,7 +8163,6 @@ components:
- raw
- raw_stream
- trace
- heatmap
type: string
Querybuildertypesv5ScalarData:
properties:
@@ -8360,6 +8237,8 @@ components:
type: object
Querybuildertypesv5TimeSeriesValue:
properties:
bucket:
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
partial:
type: boolean
timestamp:
@@ -8470,8 +8349,6 @@ components:
$ref: '#/components/schemas/RuletypesAlertState'
overallStateChanged:
type: boolean
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8515,8 +8392,6 @@ components:
$ref: '#/components/schemas/Querybuildertypesv5Label'
nullable: true
type: array
relatedAITracesLink:
type: string
relatedLogsLink:
type: string
relatedTracesLink:
@@ -8622,7 +8497,6 @@ components:
- TRACES_BASED_ALERT
- LOGS_BASED_ALERT
- EXCEPTIONS_BASED_ALERT
- AI_TRACES_BASED_ALERT
type: string
RuletypesBasicRuleThreshold:
properties:
@@ -19846,86 +19720,6 @@ paths:
tags:
- metrics
/api/v2/notification_channels:
get:
deprecated: false
description: Returns a page of notification channels for the org. Each entry
carries the channel's identity and kind but not its configuration; fetch a
channel by ID for that. Supports a case-insensitive display name search (`query`),
a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`),
and offset-based pagination (`limit`/`offset`).
operationId: ListNotificationChannels
parameters:
- in: query
name: query
schema:
type: string
- in: query
name: kind
schema:
$ref: '#/components/schemas/AlertmanagertypesChannelKind'
- in: query
name: sort
schema:
$ref: '#/components/schemas/AlertmanagertypesChannelListSort'
- in: query
name: order
schema:
$ref: '#/components/schemas/AlertmanagertypesChannelListOrder'
- in: query
name: limit
schema:
type: integer
- in: query
name: offset
schema:
type: integer
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesListableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:list
- tokenizer:
- notification-channel:list
summary: List notification channels
tags:
- channels
post:
deprecated: false
description: This endpoint creates a notification channel
@@ -19988,239 +19782,6 @@ paths:
summary: Create notification channel
tags:
- channels
/api/v2/notification_channels/{id}:
delete:
deprecated: false
description: This endpoint deletes a notification channel by ID
operationId: DeleteNotificationChannel
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:delete
- tokenizer:
- notification-channel:delete
summary: Delete notification channel
tags:
- channels
get:
deprecated: false
description: This endpoint returns a notification channel by ID. A channel written
by the v1 API can carry a configuration this API does not model.
operationId: GetNotificationChannel
parameters:
- in: path
name: id
required: true
schema:
type: string
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:read
- tokenizer:
- notification-channel:read
summary: Get notification channel by ID
tags:
- channels
put:
deprecated: false
description: 'This endpoint replaces a notification channel''s configuration
in full. Neither name is part of the request body: both are immutable. The
kind may change, which replaces the channel''s notifier configuration.'
operationId: UpdateNotificationChannel
parameters:
- in: path
name: id
required: true
schema:
type: string
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesUpdatableNotificationChannel'
responses:
"200":
content:
application/json:
schema:
properties:
data:
$ref: '#/components/schemas/AlertmanagertypesGettableNotificationChannel'
status:
type: string
required:
- status
- data
type: object
description: OK
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"404":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Not Found
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:update
- tokenizer:
- notification-channel:update
summary: Update notification channel
tags:
- channels
/api/v2/notification_channels/test:
post:
deprecated: false
description: This endpoint sends a test notification for the configuration in
the request body. The channel need not exist and nothing is persisted, so
the body carries a configuration only.
operationId: TestNotificationChannel
requestBody:
content:
application/json:
schema:
$ref: '#/components/schemas/AlertmanagertypesTestableNotificationChannel'
responses:
"204":
description: No Content
"400":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Bad Request
"401":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Unauthorized
"403":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Forbidden
"500":
content:
application/json:
schema:
$ref: '#/components/schemas/RenderErrorResponse'
description: Internal Server Error
security:
- api_key:
- notification-channel:create
- tokenizer:
- notification-channel:create
summary: Test notification channel
tags:
- channels
/api/v2/orgs/me:
get:
deprecated: false

View File

@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
transformIgnorePatterns: [
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
],
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
testPathIgnorePatterns: ['/node_modules/', '/public/'],

View File

@@ -21,23 +21,14 @@ import type {
AlertmanagertypesPostableChannelDTO,
AlertmanagertypesPostableNotificationChannelDTO,
AlertmanagertypesReceiverDTO,
AlertmanagertypesTestableNotificationChannelDTO,
AlertmanagertypesUpdatableNotificationChannelDTO,
CreateChannel201,
CreateNotificationChannel201,
DeleteChannelByIDPathParameters,
DeleteNotificationChannelPathParameters,
GetChannelByID200,
GetChannelByIDPathParameters,
GetNotificationChannel200,
GetNotificationChannelPathParameters,
ListChannels200,
ListNotificationChannels200,
ListNotificationChannelsParams,
RenderErrorResponseDTO,
UpdateChannelByIDPathParameters,
UpdateNotificationChannel200,
UpdateNotificationChannelPathParameters,
} from '../sigNoz.schemas';
import { GeneratedAPIInstance } from '../../../generatedAPIInstance';
@@ -658,105 +649,6 @@ export const useTestChannelDeprecated = <
> => {
return useMutation(getTestChannelDeprecatedMutationOptions(options));
};
/**
* Returns a page of notification channels for the org. Each entry carries the channel's identity and kind but not its configuration; fetch a channel by ID for that. Supports a case-insensitive display name search (`query`), a kind filter (`kind`), sort (`updated_at`/`created_at`/`name`), order (`asc`/`desc`), and offset-based pagination (`limit`/`offset`).
* @summary List notification channels
*/
export const listNotificationChannels = (
params?: ListNotificationChannelsParams,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<ListNotificationChannels200>({
url: `/api/v2/notification_channels`,
method: 'GET',
params,
signal,
});
};
export const getListNotificationChannelsQueryKey = (
params?: ListNotificationChannelsParams,
) => {
return [`/api/v2/notification_channels`, ...(params ? [params] : [])] as const;
};
export const getListNotificationChannelsQueryOptions = <
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListNotificationChannelsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listNotificationChannels>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getListNotificationChannelsQueryKey(params);
const queryFn: QueryFunction<
Awaited<ReturnType<typeof listNotificationChannels>>
> = ({ signal }) => listNotificationChannels(params, signal);
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
Awaited<ReturnType<typeof listNotificationChannels>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type ListNotificationChannelsQueryResult = NonNullable<
Awaited<ReturnType<typeof listNotificationChannels>>
>;
export type ListNotificationChannelsQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary List notification channels
*/
export function useListNotificationChannels<
TData = Awaited<ReturnType<typeof listNotificationChannels>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
params?: ListNotificationChannelsParams,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof listNotificationChannels>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getListNotificationChannelsQueryOptions(params, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary List notification channels
*/
export const invalidateListNotificationChannels = async (
queryClient: QueryClient,
params?: ListNotificationChannelsParams,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getListNotificationChannelsQueryKey(params) },
options,
);
return queryClient;
};
/**
* This endpoint creates a notification channel
* @summary Create notification channel
@@ -841,370 +733,3 @@ export const useCreateNotificationChannel = <
> => {
return useMutation(getCreateNotificationChannelMutationOptions(options));
};
/**
* This endpoint deletes a notification channel by ID
* @summary Delete notification channel
*/
export const deleteNotificationChannel = (
{ id }: DeleteNotificationChannelPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/notification_channels/${id}`,
method: 'DELETE',
signal,
});
};
export const getDeleteNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
> => {
const mutationKey = ['deleteNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
{ pathParams: DeleteNotificationChannelPathParameters }
> = (props) => {
const { pathParams } = props ?? {};
return deleteNotificationChannel(pathParams);
};
return { mutationFn, ...mutationOptions };
};
export type DeleteNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof deleteNotificationChannel>>
>;
export type DeleteNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Delete notification channel
*/
export const useDeleteNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof deleteNotificationChannel>>,
TError,
{ pathParams: DeleteNotificationChannelPathParameters },
TContext
> => {
return useMutation(getDeleteNotificationChannelMutationOptions(options));
};
/**
* This endpoint returns a notification channel by ID. A channel written by the v1 API can carry a configuration this API does not model.
* @summary Get notification channel by ID
*/
export const getNotificationChannel = (
{ id }: GetNotificationChannelPathParameters,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<GetNotificationChannel200>({
url: `/api/v2/notification_channels/${id}`,
method: 'GET',
signal,
});
};
export const getGetNotificationChannelQueryKey = ({
id,
}: GetNotificationChannelPathParameters) => {
return [`/api/v2/notification_channels/${id}`] as const;
};
export const getGetNotificationChannelQueryOptions = <
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetNotificationChannelPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
TError,
TData
>;
},
) => {
const { query: queryOptions } = options ?? {};
const queryKey =
queryOptions?.queryKey ?? getGetNotificationChannelQueryKey({ id });
const queryFn: QueryFunction<
Awaited<ReturnType<typeof getNotificationChannel>>
> = ({ signal }) => getNotificationChannel({ id }, signal);
return {
queryKey,
queryFn,
enabled: !!id,
...queryOptions,
} as UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
TError,
TData
> & { queryKey: QueryKey };
};
export type GetNotificationChannelQueryResult = NonNullable<
Awaited<ReturnType<typeof getNotificationChannel>>
>;
export type GetNotificationChannelQueryError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Get notification channel by ID
*/
export function useGetNotificationChannel<
TData = Awaited<ReturnType<typeof getNotificationChannel>>,
TError = ErrorType<RenderErrorResponseDTO>,
>(
{ id }: GetNotificationChannelPathParameters,
options?: {
query?: UseQueryOptions<
Awaited<ReturnType<typeof getNotificationChannel>>,
TError,
TData
>;
},
): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
const queryOptions = getGetNotificationChannelQueryOptions({ id }, options);
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
queryKey: QueryKey;
};
return { ...query, queryKey: queryOptions.queryKey };
}
/**
* @summary Get notification channel by ID
*/
export const invalidateGetNotificationChannel = async (
queryClient: QueryClient,
{ id }: GetNotificationChannelPathParameters,
options?: InvalidateOptions,
): Promise<QueryClient> => {
await queryClient.invalidateQueries(
{ queryKey: getGetNotificationChannelQueryKey({ id }) },
options,
);
return queryClient;
};
/**
* This endpoint replaces a notification channel's configuration in full. Neither name is part of the request body: both are immutable. The kind may change, which replaces the channel's notifier configuration.
* @summary Update notification channel
*/
export const updateNotificationChannel = (
{ id }: UpdateNotificationChannelPathParameters,
alertmanagertypesUpdatableNotificationChannelDTO?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<UpdateNotificationChannel200>({
url: `/api/v2/notification_channels/${id}`,
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesUpdatableNotificationChannelDTO,
signal,
});
};
export const getUpdateNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
> => {
const mutationKey = ['updateNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof updateNotificationChannel>>,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
}
> = (props) => {
const { pathParams, data } = props ?? {};
return updateNotificationChannel(pathParams, data);
};
return { mutationFn, ...mutationOptions };
};
export type UpdateNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof updateNotificationChannel>>
>;
export type UpdateNotificationChannelMutationBody =
| BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>
| undefined;
export type UpdateNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Update notification channel
*/
export const useUpdateNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof updateNotificationChannel>>,
TError,
{
pathParams: UpdateNotificationChannelPathParameters;
data?: BodyType<AlertmanagertypesUpdatableNotificationChannelDTO>;
},
TContext
> => {
return useMutation(getUpdateNotificationChannelMutationOptions(options));
};
/**
* This endpoint sends a test notification for the configuration in the request body. The channel need not exist and nothing is persisted, so the body carries a configuration only.
* @summary Test notification channel
*/
export const testNotificationChannel = (
alertmanagertypesTestableNotificationChannelDTO?: BodyType<AlertmanagertypesTestableNotificationChannelDTO>,
signal?: AbortSignal,
) => {
return GeneratedAPIInstance<void>({
url: `/api/v2/notification_channels/test`,
method: 'POST',
headers: { 'Content-Type': 'application/json' },
data: alertmanagertypesTestableNotificationChannelDTO,
signal,
});
};
export const getTestNotificationChannelMutationOptions = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
>;
}): UseMutationOptions<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
> => {
const mutationKey = ['testNotificationChannel'];
const { mutation: mutationOptions } = options
? options.mutation &&
'mutationKey' in options.mutation &&
options.mutation.mutationKey
? options
: { ...options, mutation: { ...options.mutation, mutationKey } }
: { mutation: { mutationKey } };
const mutationFn: MutationFunction<
Awaited<ReturnType<typeof testNotificationChannel>>,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> }
> = (props) => {
const { data } = props ?? {};
return testNotificationChannel(data);
};
return { mutationFn, ...mutationOptions };
};
export type TestNotificationChannelMutationResult = NonNullable<
Awaited<ReturnType<typeof testNotificationChannel>>
>;
export type TestNotificationChannelMutationBody =
| BodyType<AlertmanagertypesTestableNotificationChannelDTO>
| undefined;
export type TestNotificationChannelMutationError =
ErrorType<RenderErrorResponseDTO>;
/**
* @summary Test notification channel
*/
export const useTestNotificationChannel = <
TError = ErrorType<RenderErrorResponseDTO>,
TContext = unknown,
>(options?: {
mutation?: UseMutationOptions<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
>;
}): UseMutationResult<
Awaited<ReturnType<typeof testNotificationChannel>>,
TError,
{ data?: BodyType<AlertmanagertypesTestableNotificationChannelDTO> },
TContext
> => {
return useMutation(getTestNotificationChannelMutationOptions(options));
};

View File

@@ -507,15 +507,6 @@ export enum AlertmanagertypesChannelKindDTO {
jsmops = 'jsmops',
incidentio = 'incidentio',
}
export enum AlertmanagertypesChannelListOrderDTO {
asc = 'asc',
desc = 'desc',
}
export enum AlertmanagertypesChannelListSortDTO {
updated_at = 'updated_at',
created_at = 'created_at',
name = 'name',
}
export interface ModelLabelSetDTO {
[key: string]: string;
}
@@ -1009,44 +1000,6 @@ export interface AlertmanagertypesJiraReceiverConfigDTO {
wont_fix_resolution?: string;
}
export interface AlertmanagertypesListedNotificationChannelDTO {
/**
* @type string
* @format date-time
*/
createdAt: string;
/**
* @type string
*/
displayName: string;
/**
* @type string
*/
id: string;
kind: AlertmanagertypesChannelKindDTO;
/**
* @type string
*/
name: string;
/**
* @type string
* @format date-time
*/
updatedAt: string;
}
export interface AlertmanagertypesListableNotificationChannelDTO {
/**
* @type array
*/
channels: AlertmanagertypesListedNotificationChannelDTO[];
/**
* @type integer
* @format int64
*/
total: number;
}
export enum AlertmanagertypesMaintenanceKindDTO {
fixed = 'fixed',
recurring = 'recurring',
@@ -2438,14 +2391,6 @@ export interface AlertmanagertypesReceiverDTO {
wechat_configs?: ConfigWechatConfigDTO[];
}
export interface AlertmanagertypesTestableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
}
export interface AlertmanagertypesUpdatableNotificationChannelDTO {
config: AlertmanagertypesChannelConfigDTO;
}
export interface AuthtypesAttributeMappingDTO {
/**
* @type string
@@ -4134,53 +4079,6 @@ export interface Querybuildertypesv5LogAggregationDTO {
expression?: string;
}
export enum Querybuildertypesv5BucketOptionsLinearDTOKind {
linear = 'linear',
}
export interface Querybuildertypesv5LinearBucketsSpecDTO {
/**
* @type number
* @format double
*/
maxValue: number;
/**
* @type integer
*/
numBuckets?: number;
}
export interface Querybuildertypesv5BucketOptionsLinearDTO {
/**
* @type string
* @enum linear
*/
kind: Querybuildertypesv5BucketOptionsLinearDTOKind;
spec: Querybuildertypesv5LinearBucketsSpecDTO;
}
export enum Querybuildertypesv5BucketOptionsLogDTOKind {
log = 'log',
}
export interface Querybuildertypesv5LogBucketsSpecDTO {
/**
* @type integer,null
*/
scale?: number | null;
}
export interface Querybuildertypesv5BucketOptionsLogDTO {
/**
* @type string
* @enum log
*/
kind: Querybuildertypesv5BucketOptionsLogDTOKind;
spec: Querybuildertypesv5LogBucketsSpecDTO;
}
export type Querybuildertypesv5BucketOptionsDTO =
| Querybuildertypesv5BucketOptionsLinearDTO
| Querybuildertypesv5BucketOptionsLogDTO;
export interface Querybuildertypesv5FilterDTO {
/**
* @type string
@@ -4374,7 +4272,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array,null
*/
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
@@ -4502,7 +4399,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array,null
*/
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
@@ -4578,7 +4474,6 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
* @type array,null
*/
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type string
*/
@@ -5034,7 +4929,6 @@ export enum Querybuildertypesv5RequestTypeDTO {
raw = 'raw',
raw_stream = 'raw_stream',
trace = 'trace',
heatmap = 'heatmap',
}
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
@@ -5081,7 +4975,6 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
}
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
/**
* @type boolean
*/
@@ -8649,7 +8542,16 @@ export interface Querybuildertypesv5LabelDTO {
value?: Querybuildertypesv5LabelDTOValue;
}
export interface Querybuildertypesv5BucketDTO {
/**
* @type number
* @format double
*/
step?: number;
}
export interface Querybuildertypesv5TimeSeriesValueDTO {
bucket?: Querybuildertypesv5BucketDTO;
/**
* @type boolean
*/
@@ -9200,16 +9102,12 @@ export interface PromotetypesPromotePathDTO {
promote?: boolean;
}
export interface Querybuildertypesv5AggregationMetaDTO {
/**
* @type array
*/
buckets?: number[];
export type Querybuildertypesv5AggregationBucketDTOMeta = {
/**
* @type string
*/
unit?: string;
}
};
export interface Querybuildertypesv5AggregationBucketDTO {
/**
@@ -9228,7 +9126,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
* @type array
*/
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
meta?: Querybuildertypesv5AggregationMetaDTO;
/**
* @type object
*/
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
/**
* @type array
*/
@@ -9243,10 +9144,6 @@ export interface Querybuildertypesv5AggregationBucketDTO {
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
}
export enum Querybuildertypesv5BucketsKindDTO {
linear = 'linear',
log = 'log',
}
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
/**
* @type string
@@ -9713,10 +9610,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
* @type boolean
*/
overallStateChanged: boolean;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9765,10 +9658,6 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
* @type array,null
*/
labels: Querybuildertypesv5LabelDTO[] | null;
/**
* @type string
*/
relatedAITracesLink?: string;
/**
* @type string
*/
@@ -9864,7 +9753,6 @@ export enum RuletypesAlertTypeDTO {
TRACES_BASED_ALERT = 'TRACES_BASED_ALERT',
LOGS_BASED_ALERT = 'LOGS_BASED_ALERT',
EXCEPTIONS_BASED_ALERT = 'EXCEPTIONS_BASED_ALERT',
AI_TRACES_BASED_ALERT = 'AI_TRACES_BASED_ALERT',
}
export enum RuletypesMatchTypeDTO {
at_least_once = 'at_least_once',
@@ -13274,44 +13162,6 @@ export type GetMetricsTreemap200 = {
status: string;
};
export type ListNotificationChannelsParams = {
/**
* @type string
* @description undefined
*/
query?: string;
/**
* @description undefined
*/
kind?: AlertmanagertypesChannelKindDTO;
/**
* @description undefined
*/
sort?: AlertmanagertypesChannelListSortDTO;
/**
* @description undefined
*/
order?: AlertmanagertypesChannelListOrderDTO;
/**
* @type integer
* @description undefined
*/
limit?: number;
/**
* @type integer
* @description undefined
*/
offset?: number;
};
export type ListNotificationChannels200 = {
data: AlertmanagertypesListableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type CreateNotificationChannel201 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
@@ -13320,31 +13170,6 @@ export type CreateNotificationChannel201 = {
status: string;
};
export type DeleteNotificationChannelPathParameters = {
id: string;
};
export type GetNotificationChannelPathParameters = {
id: string;
};
export type GetNotificationChannel200 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type UpdateNotificationChannelPathParameters = {
id: string;
};
export type UpdateNotificationChannel200 = {
data: AlertmanagertypesGettableNotificationChannelDTO;
/**
* @type string
*/
status: string;
};
export type GetMyOrganization200 = {
data: TypesOrganizationDTO;
/**

View File

@@ -1,45 +0,0 @@
import type { ReactNode } from 'react';
import { Typography } from '@signozhq/ui/typography';
import cx from 'classnames';
import type { CursorPosition } from './types';
import styles from './MarkdownEditor.module.scss';
interface EditorStatusBarProps {
cursor: CursorPosition;
length: number;
maxLength: number;
hint?: ReactNode;
}
function EditorStatusBar({
cursor,
length,
maxLength,
hint,
}: EditorStatusBarProps): JSX.Element {
const isOverLimit = length > maxLength;
return (
<div className={styles.statusBar} data-testid="markdown-editor-status">
<Typography.Text className={styles.statusPosition}>
{`Ln ${cursor.line}, Col ${cursor.column}`}
<span className={styles.statusSeparator}>·</span>
<span
className={cx(styles.statusCount, {
[styles.statusCountOverLimit]: isOverLimit,
})}
data-testid="markdown-editor-char-count"
>
{isOverLimit ? `${length} / ${maxLength} chars` : `${length} chars`}
</span>
</Typography.Text>
{hint && (
<Typography.Text className={styles.statusHint}>{hint}</Typography.Text>
)}
</div>
);
}
export default EditorStatusBar;

View File

@@ -1,93 +0,0 @@
import type { ReactNode } from 'react';
import {
Bold,
CodeXml,
Heading,
Italic,
Link,
List,
ListOrdered,
Table,
Type,
} from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import { Typography } from '@signozhq/ui/typography';
import InsertVariableMenu from './InsertVariableMenu';
import MarkdownHelp from './MarkdownHelp';
import type { EditorCommand, EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
const COMMAND_ICONS: Record<string, ReactNode> = {
heading: <Heading size={14} />,
bold: <Bold size={14} />,
italic: <Italic size={14} />,
'bulleted-list': <List size={14} />,
'numbered-list': <ListOrdered size={14} />,
link: <Link size={14} />,
code: <CodeXml size={14} />,
table: <Table size={14} />,
};
interface EditorToolbarProps {
formatLabel: string;
commands: EditorCommand[];
onRunCommand: (command: EditorCommand) => void;
variables: EditorVariable[];
onInsertVariable: (name: string) => void;
disabled: boolean;
extra?: ReactNode;
}
function EditorToolbar({
formatLabel,
commands,
onRunCommand,
variables,
onInsertVariable,
disabled,
extra,
}: EditorToolbarProps): JSX.Element {
return (
<div className={styles.toolbar} data-testid="markdown-editor-toolbar">
<span className={styles.formatChip}>
<Type size={14} />
<Typography.Text className={styles.formatLabel}>
{formatLabel}
</Typography.Text>
</span>
<span className={styles.toolbarDivider} />
<div className={styles.commands}>
{commands.map((command) => (
<TooltipSimple key={command.id} title={command.label}>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
disabled={disabled}
aria-label={command.label}
data-testid={`markdown-command-${command.id}`}
onClick={(): void => onRunCommand(command)}
>
{COMMAND_ICONS[command.id]}
</Button>
</TooltipSimple>
))}
</div>
<div className={styles.toolbarEnd}>
{extra}
<InsertVariableMenu
variables={variables}
onSelect={onInsertVariable}
disabled={disabled}
/>
<MarkdownHelp />
</div>
</div>
);
}
export default EditorToolbar;

View File

@@ -1,91 +0,0 @@
import { useMemo, useState } from 'react';
import { ChevronDown, DollarSign, Search } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { DropdownMenuSimple, type MenuItem } from '@signozhq/ui/dropdown-menu';
import type { EditorVariable } from './types';
import styles from './MarkdownEditor.module.scss';
interface InsertVariableMenuProps {
variables: EditorVariable[];
/** Receives the variable name; the caller decides the token syntax. */
onSelect: (name: string) => void;
disabled: boolean;
}
function toMenuItems(
variables: EditorVariable[],
onSelect: (name: string) => void,
): MenuItem[] {
return variables.map((variable) => ({
key: variable.name,
label: (
<span
className={styles.variableRow}
data-testid={`markdown-variable-${variable.name}`}
>
<span className={styles.variableName}>{`$${variable.name}`}</span>
{variable.badge && (
<span className={styles.variableBadge}>{variable.badge}</span>
)}
</span>
),
onClick: (): void => onSelect(variable.name),
}));
}
/** Searchable variable picker; hidden entirely when there is nothing to insert. */
function InsertVariableMenu({
variables,
onSelect,
disabled,
}: InsertVariableMenuProps): JSX.Element | null {
const [search, setSearch] = useState('');
const matches = useMemo(() => {
const query = search.trim().toLowerCase();
return query
? variables.filter((variable) => variable.name.toLowerCase().includes(query))
: variables;
}, [variables, search]);
const items = useMemo(
() => toMenuItems(matches, onSelect),
[matches, onSelect],
);
if (variables.length === 0) {
return null;
}
return (
<DropdownMenuSimple
className={styles.variableMenu}
menu={{
items,
search: {
placeholder: 'Search variables',
searchIcon: <Search size={14} />,
onSearchChange: setSearch,
},
}}
>
<Button
type="button"
variant="outlined"
color="secondary"
size="sm"
disabled={disabled}
prefix={<DollarSign size={14} className={styles.insertVariableIcon} />}
suffix={<ChevronDown size={14} />}
className={styles.insertVariable}
data-testid="markdown-insert-variable"
>
Insert variable
</Button>
</DropdownMenuSimple>
);
}
export default InsertVariableMenu;

View File

@@ -1,253 +0,0 @@
@use '../../styles/scrollbar' as *;
.container {
// Read by the decoration theme in `markdownHighlight`, which can't see SCSS.
--md-syntax-heading: var(--text-vanilla-100);
--md-syntax-strong: var(--text-vanilla-100);
--md-syntax-emphasis: var(--text-vanilla-300);
--md-syntax-quote: var(--text-vanilla-400);
--md-syntax-marker: var(--text-robin-300);
--md-syntax-code: var(--text-forest-400);
--md-syntax-link: var(--text-robin-400);
--md-syntax-variable: var(--text-amber-400);
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
background: var(--l1-background);
}
:global(body.lightMode) .container {
--md-syntax-heading: var(--text-ink-400);
--md-syntax-strong: var(--text-ink-400);
--md-syntax-emphasis: var(--text-ink-200);
--md-syntax-quote: var(--text-neutral-light-100);
--md-syntax-marker: var(--text-robin-500);
--md-syntax-code: var(--text-forest-700);
--md-syntax-link: var(--text-robin-500);
--md-syntax-variable: var(--text-sienna-500);
}
.toolbar {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
padding: 8px 12px;
border-bottom: 1px solid var(--l1-border);
}
.formatChip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
border: 1px solid var(--l1-border);
border-radius: 2px;
color: var(--text-sienna-400);
}
.formatLabel {
font-size: 12px;
font-weight: 500;
color: var(--l1-foreground);
}
.toolbarDivider {
width: 1px;
height: 16px;
flex-shrink: 0;
background: var(--l1-border);
}
.commands {
display: flex;
align-items: center;
gap: 2px;
}
.toolbarEnd {
display: flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.insertVariable {
white-space: nowrap;
}
.insertVariableIcon {
color: var(--text-amber-400);
}
// The ui library's dropdown assumes a global border-box reset this app doesn't
// have (`box-sizing` is set on `body` only and doesn't inherit): its items are
// `width: 100%` + padding, so in the portal they lay out content-box and
// overflow the popup by the padding — clipping the flush-right badge.
.variableMenu,
.variableMenu * {
box-sizing: border-box;
}
.variableMenu {
width: 320px;
}
// Shrinkable, so a clamped popup truncates the name instead of clipping the
// badge at the content's `overflow: hidden` edge.
.variableRow {
display: flex;
align-items: center;
gap: 12px;
flex: 1;
min-width: 0;
}
.variableName {
font-family: var(--font-family-sf-mono);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.variableBadge {
flex-shrink: 0;
margin-left: auto;
padding: 2px 6px;
border: 1px solid color-mix(in srgb, var(--text-amber-400) 40%, transparent);
border-radius: 4px;
font-family: var(--font-family-sf-mono);
font-size: 10px;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-amber-400);
}
.editorArea {
flex: 1;
min-height: 0;
overflow: hidden;
}
.codeMirror {
height: 100%;
font-family: var(--font-family-sf-mono);
font-size: 13px;
:global(.cm-editor) {
height: 100%;
background: transparent;
}
:global(.cm-editor.cm-focused) {
outline: none;
}
:global(.cm-gutters) {
background: transparent;
border-right: none;
color: var(--text-neutral-dark-200);
}
:global(.cm-scroller) {
line-height: 20px;
padding: 0 12px;
@include custom-scrollbar;
}
:global(.cm-content) {
padding: 8px 0;
}
}
.statusBar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-shrink: 0;
padding: 6px 12px;
border-top: 1px solid var(--l1-border);
}
.statusPosition {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-family-sf-mono);
font-size: 11px;
color: var(--text-neutral-dark-200);
}
.statusSeparator {
color: var(--l1-border);
}
.statusCount {
color: inherit;
}
.statusCountOverLimit {
color: var(--text-cherry-400);
font-weight: 600;
}
.statusHint {
font-size: 11px;
color: var(--text-neutral-dark-200);
}
.helpContent {
width: 280px;
max-height: 320px;
overflow-y: auto;
@include custom-scrollbar;
}
.helpTitle {
display: block;
margin-bottom: 8px;
font-size: 11px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--text-neutral-dark-200);
}
.helpList {
display: flex;
flex-direction: column;
gap: 6px;
margin: 0;
}
.helpRow {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
dt {
margin: 0;
code {
font-family: var(--font-family-sf-mono);
font-size: 11px;
color: var(--text-forest-400);
}
}
dd {
margin: 0;
font-size: 11px;
color: var(--text-neutral-dark-200);
}
}
// The help popover portals out of `.container`, so it can't inherit its tokens.
:global(body.lightMode) .helpRow dt code {
color: var(--text-forest-700);
}

View File

@@ -1,245 +0,0 @@
import {
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { copilot } from '@uiw/codemirror-theme-copilot';
import { githubLight } from '@uiw/codemirror-theme-github';
import CodeMirror, {
type BasicSetupOptions,
EditorView,
type ViewUpdate,
} from '@uiw/react-codemirror';
import cx from 'classnames';
import { useIsDarkMode } from 'hooks/useDarkMode';
import { formatVariableToken, MARKDOWN_MAX_LENGTH } from './constants';
import EditorStatusBar from './EditorStatusBar';
import EditorToolbar from './EditorToolbar';
import { applyTransform, replaceDocument } from './editorDocument';
import { insertText, MARKDOWN_COMMANDS } from './markdownCommands';
import { markdownHighlight } from './markdownHighlight';
import type {
CursorPosition,
EditorCommand,
EditorTransform,
EditorVariable,
} from './types';
import styles from './MarkdownEditor.module.scss';
/** What the status bar reports. */
type DocumentStatus = CursorPosition & { length: number };
// No language grammar is loaded, so bracket/indent/completion behaviour would only
// get in the way of prose. `indentWithTab` stays off so Tab keeps moving focus.
const BASIC_SETUP: BasicSetupOptions = {
lineNumbers: true,
highlightActiveLine: true,
highlightActiveLineGutter: true,
foldGutter: false,
autocompletion: false,
bracketMatching: false,
closeBrackets: false,
indentOnInput: false,
syntaxHighlighting: false,
highlightSelectionMatches: false,
rectangularSelection: false,
crosshairCursor: false,
searchKeymap: false,
foldKeymap: false,
lintKeymap: false,
completionKeymap: false,
closeBracketsKeymap: false,
};
const EMPTY_VARIABLES: EditorVariable[] = [];
export interface MarkdownEditorProps {
/** Seeds the document; replaced only from outside. See the sync effect. */
value: string;
onChange: (value: string) => void;
/** Offered by the "Insert variable" menu; the button disables when empty. */
variables?: EditorVariable[];
/** What the character counter reports against. */
maxLength?: number;
placeholder?: string;
readOnly?: boolean;
/** Shown on the toolbar chip. */
formatLabel?: string;
/** Rendered before the "Insert variable" menu. */
toolbarExtra?: ReactNode;
/** Right-hand status-bar note, e.g. "Preview updates as you type". */
statusHint?: ReactNode;
autoFocus?: boolean;
className?: string;
testId?: string;
}
/**
* Source editor for Markdown bodies. Source-only: it neither parses nor renders
* the body, so the preview surface and its sanitisation stay the caller's concern.
*/
function MarkdownEditor({
value,
onChange,
variables = EMPTY_VARIABLES,
maxLength = MARKDOWN_MAX_LENGTH,
placeholder = 'Write Markdown…',
readOnly = false,
formatLabel = 'Markdown',
toolbarExtra,
statusHint,
autoFocus = false,
className,
testId = 'markdown-editor',
}: MarkdownEditorProps): JSX.Element {
const isDarkMode = useIsDarkMode();
const viewRef = useRef<EditorView | null>(null);
// Set while a programmatic replacement is in flight, so the caller isn't told
// about a change it asked for. `dispatch` runs listeners synchronously, so the
// window is exactly one call.
const isSyncingRef = useRef(false);
const previousValueRef = useRef(value);
const hasSeededRef = useRef(false);
const [isEditorReady, setIsEditorReady] = useState(false);
const [status, setStatus] = useState<DocumentStatus>(() => ({
line: 1,
column: 1,
length: value.length,
}));
const syncDocument = useCallback((view: EditorView, next: string): void => {
isSyncingRef.current = true;
replaceDocument(view, next);
isSyncingRef.current = false;
}, []);
const onCreateEditor = useCallback((view: EditorView): void => {
viewRef.current = view;
setIsEditorReady(true);
}, []);
/**
* Seeds the document, then applies external replacements — nothing else. Keeping
* keystrokes out of this round-trip is what stops a stale `value` from replacing
* the document and resetting the caret when typing outpaces React.
*
* The seed can't go in `onCreateEditor`: the wrapper defaults its own `value` to
* `''` and reconciles against it once the view exists, wiping anything written
* before that. `isEditorReady` puts this effect after that pass, since a parent's
* effects flush after its children's.
*
* Focus marks ownership: a replacement arriving mid-typing is dropped rather than
* applied over the author.
*/
useEffect(() => {
const view = viewRef.current;
if (!view) {
return;
}
const previous = previousValueRef.current;
previousValueRef.current = value;
const isSeeding = !hasSeededRef.current;
hasSeededRef.current = true;
if (!isSeeding && (value === previous || view.hasFocus)) {
return;
}
if (view.state.doc.toString() !== value) {
syncDocument(view, value);
}
}, [value, isEditorReady, syncDocument]);
const handleChange = useCallback(
(next: string): void => {
if (!isSyncingRef.current) {
onChange(next);
}
},
[onChange],
);
const runTransform = useCallback((transform: EditorTransform): void => {
const view = viewRef.current;
if (view) {
applyTransform(view, transform);
}
}, []);
const onRunCommand = useCallback(
(command: EditorCommand): void => runTransform(command.run),
[runTransform],
);
const onInsertVariable = useCallback(
(name: string): void =>
runTransform((snapshot) => insertText(snapshot, formatVariableToken(name))),
[runTransform],
);
const extensions = useMemo(
() => [markdownHighlight(), EditorView.lineWrapping],
[],
);
// From the document, not `value`: the caller may debounce or drop a change, and
// the counter has to match what the author sees.
const onUpdate = useCallback((update: ViewUpdate): void => {
if (!update.selectionSet && !update.docChanged) {
return;
}
const { head } = update.state.selection.main;
const line = update.state.doc.lineAt(head);
setStatus({
line: line.number,
column: head - line.from + 1,
length: update.state.doc.length,
});
}, []);
return (
<div className={cx(styles.container, className)} data-testid={testId}>
<EditorToolbar
formatLabel={formatLabel}
commands={MARKDOWN_COMMANDS}
onRunCommand={onRunCommand}
variables={variables}
onInsertVariable={onInsertVariable}
disabled={readOnly}
extra={toolbarExtra}
/>
<div className={styles.editorArea}>
<CodeMirror
className={styles.codeMirror}
// No `value`: passing it re-enables the wrapper's own reconciliation,
// and with it the caret reset.
onCreateEditor={onCreateEditor}
onChange={handleChange}
onUpdate={onUpdate}
theme={isDarkMode ? copilot : githubLight}
basicSetup={BASIC_SETUP}
placeholder={placeholder}
editable={!readOnly}
readOnly={readOnly}
indentWithTab={false}
autoFocus={autoFocus}
extensions={extensions}
height="100%"
/>
</div>
<EditorStatusBar
cursor={status}
length={status.length}
maxLength={maxLength}
hint={statusHint}
/>
</div>
);
}
export default MarkdownEditor;

View File

@@ -1,44 +0,0 @@
import { CircleHelp } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
import { Typography } from '@signozhq/ui/typography';
import { MARKDOWN_HELP_ITEMS } from './constants';
import styles from './MarkdownEditor.module.scss';
function MarkdownHelp(): JSX.Element {
return (
<Popover>
<PopoverTrigger asChild>
<Button
type="button"
variant="ghost"
color="secondary"
size="icon"
aria-label="Markdown syntax help"
data-testid="markdown-help-trigger"
>
<CircleHelp size={14} />
</Button>
</PopoverTrigger>
<PopoverContent align="end" className={styles.helpContent}>
<Typography.Text className={styles.helpTitle}>
Markdown syntax
</Typography.Text>
<dl className={styles.helpList}>
{MARKDOWN_HELP_ITEMS.map((item) => (
<div key={item.syntax} className={styles.helpRow}>
<dt>
<code>{item.syntax}</code>
</dt>
<dd>{item.label}</dd>
</div>
))}
</dl>
</PopoverContent>
</Popover>
);
}
export default MarkdownHelp;

View File

@@ -1,270 +0,0 @@
import { useCallback, useRef, useState } from 'react';
import { EditorView } from '@uiw/react-codemirror';
import { mockCodeMirrorDomApis } from 'components/QueryBuilderV2/QueryV2/__tests__/codemirrorDomMocks';
import {
act,
fireEvent,
render,
screen,
userEvent,
waitFor,
} from 'tests/test-utils';
import MarkdownEditor from '../MarkdownEditor';
import type { EditorVariable } from '../types';
beforeAll(() => {
mockCodeMirrorDomApis();
});
jest.mock('hooks/useDarkMode', () => ({
useIsDarkMode: (): boolean => true,
}));
const VARIABLES: EditorVariable[] = [
{ name: 'environment', badge: 'QUERY' },
{ name: 'service', badge: 'CUSTOM' },
];
/** A caller whose state trails the editor by one keystroke. */
function LaggingHarness(): JSX.Element {
const [value, setValue] = useState('');
const previousRef = useRef('');
const onChange = useCallback((next: string): void => {
setValue(previousRef.current);
previousRef.current = next;
}, []);
return <MarkdownEditor value={value} onChange={onChange} />;
}
/** Pushes a replacement in from outside the editor. */
function ExternalHarness(): JSX.Element {
const [value, setValue] = useState('before');
return (
<>
<button type="button" onClick={(): void => setValue('after')}>
push
</button>
<MarkdownEditor value={value} onChange={setValue} />
</>
);
}
function Harness({
initialValue = '',
maxLength,
variables = VARIABLES,
}: {
initialValue?: string;
maxLength?: number;
variables?: EditorVariable[];
}): JSX.Element {
const [value, setValue] = useState(initialValue);
return (
<MarkdownEditor
value={value}
onChange={setValue}
variables={variables}
maxLength={maxLength}
statusHint="Preview updates as you type"
/>
);
}
const getView = (): EditorView => {
const dom = document.querySelector('.cm-editor');
const view = dom ? EditorView.findFromDOM(dom as HTMLElement) : null;
if (!view) {
throw new Error('editor view not mounted');
}
return view;
};
const select = (from: number, to: number): void => {
act(() => {
getView().dispatch({ selection: { anchor: from, head: to } });
});
};
const documentText = (): string => getView().state.doc.toString();
describe('MarkdownEditor', () => {
it('reports the caret position and character count', async () => {
render(<Harness initialValue={'one\ntwo'} />);
select(5, 5);
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-status')).toHaveTextContent(
'Ln 2, Col 2',
);
});
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'7 chars',
);
});
it('flags a body over the character cap', async () => {
render(<Harness initialValue="123456" maxLength={5} />);
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'6 / 5 chars',
);
});
});
it('applies a toolbar command to the selection', async () => {
render(<Harness initialValue="a word b" />);
select(2, 6);
await userEvent.click(screen.getByTestId('markdown-command-bold'));
await waitFor(() => {
expect(documentText()).toBe('a **word** b');
});
});
it('inserts a variable token at the caret', async () => {
render(<Harness initialValue="env: " />);
select(5, 5);
await userEvent.click(screen.getByTestId('markdown-insert-variable'));
// The row shows the name and kind badge.
const row = await screen.findByTestId('markdown-variable-environment');
expect(row).toHaveTextContent('$environment');
expect(row).toHaveTextContent('QUERY');
// fireEvent: userEvent's pointer-down path walks DOM selection APIs the
// CodeMirror mocks stub out.
fireEvent.click(row);
await waitFor(() => {
expect(documentText()).toBe('env: $environment');
});
});
it('colours Markdown syntax and variable tokens in the source', async () => {
render(<Harness initialValue={'## Runbook\nowner {{team}}'} />);
await waitFor(() => {
expect(document.querySelector('.cm-md-heading')).toBeInTheDocument();
});
expect(document.querySelector('.cm-md-variable')).toHaveTextContent(
'{{team}}',
);
});
describe('uncontrolled document', () => {
const type = (at: number, text: string): void => {
act(() => {
getView().dispatch({
changes: { from: at, insert: text },
selection: { anchor: at + text.length },
});
});
};
const focusEditor = (): void => {
act(() => {
getView().focus();
});
};
it('keeps the document and caret while the caller lags behind the typing', () => {
render(<LaggingHarness />);
focusEditor();
type(0, 'a');
type(1, 'b');
type(2, 'c');
expect(documentText()).toBe('abc');
expect(getView().state.selection.main.head).toBe(3);
});
it('reports every keystroke to the caller', () => {
const onChange = jest.fn();
render(<MarkdownEditor value="ab" onChange={onChange} />);
type(2, 'c');
expect(onChange).toHaveBeenLastCalledWith('abc');
});
it('does not report the seed back as a change', () => {
const onChange = jest.fn();
render(<MarkdownEditor value="seeded" onChange={onChange} />);
expect(documentText()).toBe('seeded');
expect(onChange).not.toHaveBeenCalled();
});
it('applies an external replacement while the editor is unfocused', async () => {
render(<ExternalHarness />);
await userEvent.click(screen.getByRole('button', { name: 'push' }));
expect(documentText()).toBe('after');
});
it('ignores a replacement that arrives while the author is still typing', () => {
render(<ExternalHarness />);
focusEditor();
// fireEvent: a real click would blur the editor first. This covers an update
// arriving on its own, while the author is still in the document.
fireEvent.click(screen.getByRole('button', { name: 'push' }));
expect(documentText()).toBe('before');
});
it('counts characters from the document, not from the lagging value', async () => {
render(<MarkdownEditor value="ab" onChange={jest.fn()} />);
type(2, 'cde');
await waitFor(() => {
expect(screen.getByTestId('markdown-editor-char-count')).toHaveTextContent(
'5 chars',
);
});
});
});
it('offers both list kinds in the toolbar', () => {
render(<Harness />);
expect(
screen.getByTestId('markdown-command-bulleted-list'),
).toBeInTheDocument();
expect(
screen.getByTestId('markdown-command-numbered-list'),
).toBeInTheDocument();
});
it('disables authoring affordances when read-only', () => {
render(
<MarkdownEditor
value="body"
onChange={jest.fn()}
variables={VARIABLES}
readOnly
/>,
);
expect(screen.getByTestId('markdown-command-bold')).toBeDisabled();
expect(screen.getByTestId('markdown-insert-variable')).toBeDisabled();
});
it('hides the insert-variable control when none are available', () => {
render(<Harness variables={[]} />);
expect(
screen.queryByTestId('markdown-insert-variable'),
).not.toBeInTheDocument();
});
});

View File

@@ -1,258 +0,0 @@
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
import type { EditorSnapshot, EditorTransform } from '../types';
const commandById = (id: string): EditorTransform => {
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
if (!command) {
throw new Error(`unknown command: ${id}`);
}
return command.run;
};
const heading = commandById('heading');
const bold = commandById('bold');
const italic = commandById('italic');
const bulletedList = commandById('bulleted-list');
const numberedList = commandById('numbered-list');
const link = commandById('link');
const code = commandById('code');
const table = commandById('table');
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
const snapshot = (marked: string): EditorSnapshot => {
if (marked.includes('|')) {
const caret = marked.indexOf('|');
return {
text: marked.replace('|', ''),
selectionStart: caret,
selectionEnd: caret,
};
}
const start = marked.indexOf('[');
const end = marked.indexOf(']') - 1;
return {
text: marked.replace('[', '').replace(']', ''),
selectionStart: start,
selectionEnd: end,
};
};
const selectionOf = (result: EditorSnapshot): string =>
result.text.slice(result.selectionStart, result.selectionEnd);
describe('heading', () => {
it('prefixes the caret line and keeps the caret on the same character', () => {
const result = heading(snapshot('Chec|kout'));
expect(result.text).toBe('## Checkout');
expect(result.selectionStart).toBe(7);
});
it('strips the prefix when every selected line already has one', () => {
const result = heading({
text: '## one\n### two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('one\ntwo');
});
it('adds the prefix when only some selected lines have one', () => {
const result = heading({
text: '## one\ntwo',
selectionStart: 0,
selectionEnd: 10,
});
expect(result.text).toBe('## ## one\n## two');
});
it('does not pull in the line after a selection ending on a line break', () => {
const result = heading({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 4,
});
expect(result.text).toBe('## one\ntwo');
});
});
describe('bulleted list', () => {
it('bullets every line of a multi-line selection', () => {
const result = bulletedList({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 7,
});
expect(result.text).toBe('- one\n- two');
expect(selectionOf(result)).toBe('- one\n- two');
});
it('unbullets a list written with a different marker', () => {
const result = bulletedList({
text: '* one\n+ two',
selectionStart: 0,
selectionEnd: 11,
});
expect(result.text).toBe('one\ntwo');
});
});
describe('numbered list', () => {
it('numbers each line of the selection in order', () => {
const result = numberedList({
text: 'one\ntwo\nthree',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('1. one\n2. two\n3. three');
});
it('unnumbers a list whose numbering is not sequential', () => {
const result = numberedList({
text: '1. one\n5. two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('one\ntwo');
});
});
describe('switching between list kinds', () => {
it('converts bullets to numbers rather than marking them twice', () => {
const result = numberedList({
text: '- one\n- two',
selectionStart: 0,
selectionEnd: 11,
});
expect(result.text).toBe('1. one\n2. two');
});
it('converts numbers to bullets', () => {
const result = bulletedList({
text: '1. one\n2. two',
selectionStart: 0,
selectionEnd: 13,
});
expect(result.text).toBe('- one\n- two');
});
it('keeps indentation so nested items stay nested', () => {
const result = numberedList({
text: 'one\n - nested',
selectionStart: 0,
selectionEnd: 16,
});
expect(result.text).toBe('1. one\n 2. nested');
});
});
describe('bold and italic', () => {
it('wraps the selection and keeps the original text selected', () => {
const result = bold(snapshot('a [word] b'));
expect(result.text).toBe('a **word** b');
expect(selectionOf(result)).toBe('word');
});
it('unwraps when the markers sit inside the selection', () => {
const result = bold({
text: 'a **word** b',
selectionStart: 2,
selectionEnd: 10,
});
expect(result.text).toBe('a word b');
expect(selectionOf(result)).toBe('word');
});
it('unwraps when the markers sit just outside the selection', () => {
const result = bold({
text: 'a **word** b',
selectionStart: 4,
selectionEnd: 8,
});
expect(result.text).toBe('a word b');
expect(selectionOf(result)).toBe('word');
});
it('leaves the caret between the markers when nothing is selected', () => {
const result = italic(snapshot('a |b'));
expect(result.text).toBe('a __b');
expect(result.selectionStart).toBe(3);
expect(result.selectionEnd).toBe(3);
});
it('does not mistake a leading document boundary for a marker', () => {
const result = bold(snapshot('[word] tail'));
expect(result.text).toBe('**word** tail');
});
});
describe('link', () => {
it('selects the url when the label came from the selection', () => {
const result = link(snapshot('see [docs] now'));
expect(result.text).toBe('see [docs](https://) now');
expect(selectionOf(result)).toBe('https://');
});
it('selects the label placeholder when nothing was selected', () => {
const result = link(snapshot('see |'));
expect(result.text).toBe('see [text](https://)');
expect(selectionOf(result)).toBe('text');
});
});
describe('code', () => {
it('uses backticks for a single-line selection', () => {
const result = code(snapshot('run [npm] here'));
expect(result.text).toBe('run `npm` here');
});
it('fences a multi-line selection and selects its content', () => {
const result = code({
text: 'one\ntwo',
selectionStart: 0,
selectionEnd: 7,
});
expect(result.text).toBe('```\none\ntwo\n```');
expect(selectionOf(result)).toBe('one\ntwo');
});
});
describe('table', () => {
it('starts the skeleton on its own line and selects the first header cell', () => {
const result = table(snapshot('intro|'));
expect(result.text).toBe(
'intro\n| Column | Column |\n| --- | --- |\n| | |',
);
expect(selectionOf(result)).toBe('Column');
});
});
describe('insertText', () => {
it('replaces the selection and leaves the caret after the insertion', () => {
const result = insertText(snapshot('env is [old]'), '{{env}}');
expect(result.text).toBe('env is {{env}}');
expect(result.selectionStart).toBe(14);
expect(result.selectionEnd).toBe(14);
});
});

View File

@@ -1,24 +0,0 @@
// The body is persisted inline in the dashboard JSON, so its length is capped.
export const MARKDOWN_MAX_LENGTH = 16000;
/** The canonical syntax; the renderer resolves the other three too. */
export const formatVariableToken = (name: string): string => `$${name}`;
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
// First: consecutive lines joining into one paragraph is the CommonMark rule
// authors trip over before any of the formatting syntax.
{ syntax: 'blank line', label: 'New paragraph' },
{ syntax: '2 spaces + ⏎', label: 'Line break' },
{ syntax: '# Heading', label: 'Heading (16 #)' },
{ syntax: '**bold**', label: 'Bold' },
{ syntax: '_italic_', label: 'Italic' },
{ syntax: '- item', label: 'Bulleted list' },
{ syntax: '1. item', label: 'Numbered list' },
{ syntax: '- [ ] task', label: 'Task list' },
{ syntax: '[label](url)', label: 'Link' },
{ syntax: '![alt](url)', label: 'Image' },
{ syntax: '`code`', label: 'Inline code' },
{ syntax: '```lang', label: 'Code block' },
{ syntax: '> quote', label: 'Blockquote' },
{ syntax: '| a | b |', label: 'Table' },
];

View File

@@ -1,69 +0,0 @@
import { EditorView } from '@uiw/react-codemirror';
import type { EditorSnapshot, EditorTransform } from './types';
// Narrows a whole-document replacement to the range that changed, so a toolbar
// action doesn't invalidate the document's decorations or scroll position.
function toChangeSpec(
previous: string,
next: string,
): { from: number; to: number; insert: string } | null {
if (previous === next) {
return null;
}
const shorter = Math.min(previous.length, next.length);
let start = 0;
while (start < shorter && previous[start] === next[start]) {
start += 1;
}
let previousEnd = previous.length;
let nextEnd = next.length;
while (
previousEnd > start &&
nextEnd > start &&
previous[previousEnd - 1] === next[nextEnd - 1]
) {
previousEnd -= 1;
nextEnd -= 1;
}
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
}
export function readSnapshot(view: EditorView): EditorSnapshot {
const range = view.state.selection.main;
return {
text: view.state.doc.toString(),
selectionStart: range.from,
selectionEnd: range.to,
};
}
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
export function applyTransform(
view: EditorView,
transform: EditorTransform,
): boolean {
if (view.state.readOnly) {
return false;
}
const next = transform(readSnapshot(view));
const changes = toChangeSpec(view.state.doc.toString(), next.text);
view.dispatch({
...(changes ? { changes } : {}),
selection: { anchor: next.selectionStart, head: next.selectionEnd },
scrollIntoView: true,
});
view.focus();
return true;
}
/** Replaces the whole document, for seeding and external replacements. */
export function replaceDocument(view: EditorView, next: string): void {
view.dispatch({
changes: { from: 0, to: view.state.doc.length, insert: next },
});
}

View File

@@ -1,269 +0,0 @@
import type { EditorCommand, EditorSnapshot, EditorTransform } from './types';
const BOLD_MARKER = '**';
const ITALIC_MARKER = '_';
const INLINE_CODE_MARKER = '`';
const CODE_FENCE = '```';
const HEADING_PREFIX = '## ';
const BULLET_MARKER = '- ';
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
const BULLET_LIST_PATTERN = /^[ \t]*[-*+] /;
const ORDERED_LIST_PATTERN = /^[ \t]*\d+\. /;
// Either kind of marker, matched after the indent has been split off.
const LIST_MARKER_PATTERN = /^(?:[-*+]|\d+\.) /;
const INDENT_PATTERN = /^[ \t]*/;
const LINK_LABEL_PLACEHOLDER = 'text';
const LINK_URL_PLACEHOLDER = 'https://';
const TABLE_CELL_PLACEHOLDER = 'Column';
const TABLE_SNIPPET = [
`| ${TABLE_CELL_PLACEHOLDER} | ${TABLE_CELL_PLACEHOLDER} |`,
'| --- | --- |',
'| | |',
].join('\n');
interface LineRange {
start: number;
end: number;
}
// A selection ending exactly on a line break stops there rather than pulling in
// the next line, so "select the line, hit list" doesn't bullet the line below too.
function expandToLines(text: string, from: number, to: number): LineRange {
const end = to > from && text[to - 1] === '\n' ? to - 1 : to;
const breakBefore = from === 0 ? -1 : text.lastIndexOf('\n', from - 1);
const breakAfter = text.indexOf('\n', end);
return {
start: breakBefore + 1,
end: breakAfter === -1 ? text.length : breakAfter,
};
}
/** Pads `block` so it starts and ends on its own line. */
function replaceWithBlock(
snapshot: EditorSnapshot,
block: string,
): { text: string; blockStart: number } {
const { text, selectionStart, selectionEnd } = snapshot;
const before = text.slice(0, selectionStart);
const after = text.slice(selectionEnd);
const lead = before === '' || before.endsWith('\n') ? '' : '\n';
const trail = after === '' || after.startsWith('\n') ? '' : '\n';
return {
text: before + lead + block + trail + after,
blockStart: before.length + lead.length,
};
}
/** Rewrites every line the selection touches. */
function replaceLines(
snapshot: EditorSnapshot,
mapLines: (lines: string[]) => string[],
): EditorSnapshot {
const { text, selectionStart, selectionEnd } = snapshot;
const { start, end } = expandToLines(text, selectionStart, selectionEnd);
const lines = text.slice(start, end).split('\n');
const nextLines = mapLines(lines);
const block = nextLines.join('\n');
const nextText = text.slice(0, start) + block + text.slice(end);
if (selectionStart !== selectionEnd) {
return {
text: nextText,
selectionStart: start,
selectionEnd: start + block.length,
};
}
// Caret-only: the range covers one line, so shift by that line's delta.
const shifted = selectionStart + nextLines[0].length - lines[0].length;
const caret = Math.min(Math.max(shifted, start), start + nextLines[0].length);
return { text: nextText, selectionStart: caret, selectionEnd: caret };
}
/** Strips `prefix` when every selected line already matches `pattern`, else adds it. */
function toggleLinePrefix(prefix: string, pattern: RegExp): EditorTransform {
return (snapshot): EditorSnapshot =>
replaceLines(snapshot, (lines) => {
const isApplied = lines.every((line) => pattern.test(line));
return lines.map((line) =>
isApplied ? line.replace(pattern, '') : `${prefix}${line}`,
);
});
}
/**
* Toggles this kind of list marker. A line carrying the *other* kind is converted
* rather than marked twice, and indentation is preserved so nesting survives.
* `markerAt` takes the line's position, which is what lets an ordered list number.
*/
function toggleList(
pattern: RegExp,
markerAt: (index: number) => string,
): EditorTransform {
return (snapshot): EditorSnapshot =>
replaceLines(snapshot, (lines) => {
const isApplied = lines.every((line) => pattern.test(line));
return lines.map((line, index) => {
const indent = INDENT_PATTERN.exec(line)?.[0] ?? '';
const body = line.slice(indent.length).replace(LIST_MARKER_PATTERN, '');
return isApplied
? `${indent}${body}`
: `${indent}${markerAt(index)}${body}`;
});
});
}
/**
* Unwraps when the markers are already there, whether they sit inside the selection
* (`**bold**` selected whole) or just outside it (only `bold` selected).
*/
function toggleWrap(marker: string): EditorTransform {
return ({ text, selectionStart, selectionEnd }): EditorSnapshot => {
const selected = text.slice(selectionStart, selectionEnd);
const width = marker.length;
if (
selected.length >= width * 2 &&
selected.startsWith(marker) &&
selected.endsWith(marker)
) {
const inner = selected.slice(width, -width);
return {
text: text.slice(0, selectionStart) + inner + text.slice(selectionEnd),
selectionStart,
selectionEnd: selectionStart + inner.length,
};
}
if (
selectionStart >= width &&
text.slice(selectionStart - width, selectionStart) === marker &&
text.slice(selectionEnd, selectionEnd + width) === marker
) {
return {
text:
text.slice(0, selectionStart - width) +
selected +
text.slice(selectionEnd + width),
selectionStart: selectionStart - width,
selectionEnd: selectionStart - width + selected.length,
};
}
return {
text:
text.slice(0, selectionStart) +
marker +
selected +
marker +
text.slice(selectionEnd),
selectionStart: selectionStart + width,
selectionEnd: selectionStart + width + selected.length,
};
};
}
/** Lands the selection on whichever half is still a placeholder. */
const insertLink: EditorTransform = ({
text,
selectionStart,
selectionEnd,
}): EditorSnapshot => {
const selected = text.slice(selectionStart, selectionEnd);
const label = selected || LINK_LABEL_PLACEHOLDER;
const snippet = `[${label}](${LINK_URL_PLACEHOLDER})`;
const nextText =
text.slice(0, selectionStart) + snippet + text.slice(selectionEnd);
// `[` + label + `](` is label.length + 3 characters.
const target = selected
? {
from: selectionStart + label.length + 3,
length: LINK_URL_PLACEHOLDER.length,
}
: { from: selectionStart + 1, length: label.length };
return {
text: nextText,
selectionStart: target.from,
selectionEnd: target.from + target.length,
};
};
/** Backticks for a single-line selection, a fence for a multi-line one. */
const insertCode: EditorTransform = (snapshot): EditorSnapshot => {
const { text, selectionStart, selectionEnd } = snapshot;
const selected = text.slice(selectionStart, selectionEnd);
if (!selected.includes('\n')) {
return toggleWrap(INLINE_CODE_MARKER)(snapshot);
}
const { text: nextText, blockStart } = replaceWithBlock(
snapshot,
`${CODE_FENCE}\n${selected}\n${CODE_FENCE}`,
);
const contentStart = blockStart + CODE_FENCE.length + 1;
return {
text: nextText,
selectionStart: contentStart,
selectionEnd: contentStart + selected.length,
};
};
/** Selects the first header cell, for immediate typing. */
const insertTable: EditorTransform = (snapshot): EditorSnapshot => {
const { text, blockStart } = replaceWithBlock(snapshot, TABLE_SNIPPET);
const firstCell = blockStart + TABLE_SNIPPET.indexOf(TABLE_CELL_PLACEHOLDER);
return {
text,
selectionStart: firstCell,
selectionEnd: firstCell + TABLE_CELL_PLACEHOLDER.length,
};
};
/** Replaces the selection and leaves the caret after the insertion. */
export function insertText(
snapshot: EditorSnapshot,
value: string,
): EditorSnapshot {
const { text, selectionStart, selectionEnd } = snapshot;
const caret = selectionStart + value.length;
return {
text: text.slice(0, selectionStart) + value + text.slice(selectionEnd),
selectionStart: caret,
selectionEnd: caret,
};
}
/** Display order. A new action is an entry here plus an icon in `EditorToolbar`. */
export const MARKDOWN_COMMANDS: EditorCommand[] = [
{
id: 'heading',
label: 'Heading',
run: toggleLinePrefix(HEADING_PREFIX, HEADING_PATTERN),
},
{
id: 'bold',
label: 'Bold',
run: toggleWrap(BOLD_MARKER),
},
{
id: 'italic',
label: 'Italic',
run: toggleWrap(ITALIC_MARKER),
},
{
id: 'bulleted-list',
label: 'Bulleted list',
run: toggleList(BULLET_LIST_PATTERN, () => BULLET_MARKER),
},
{
id: 'numbered-list',
label: 'Numbered list',
run: toggleList(ORDERED_LIST_PATTERN, (index) => `${index + 1}. `),
},
{ id: 'link', label: 'Link', run: insertLink },
{ id: 'code', label: 'Code', run: insertCode },
{ id: 'table', label: 'Table', run: insertTable },
];

View File

@@ -1,149 +0,0 @@
import type { Extension, Line, Range } from '@codemirror/state';
import {
Decoration,
type DecorationSet,
EditorView,
ViewPlugin,
type ViewUpdate,
} from '@codemirror/view';
const FENCE_PATTERN = /^ {0,3}(```|~~~)/;
const HEADING_PATTERN = /^ {0,3}#{1,6} /;
const QUOTE_PATTERN = /^ {0,3}> ?/;
const LIST_MARKER_PATTERN = /^ {0,3}([-*+]|\d+\.) /;
/**
* Convention: capture group 1, when present, is a left guard the token excludes —
* the token runs from the end of that group to the end of the match. Lookbehind is
* avoided for Safari compatibility, so guards are captured rather than asserted.
*/
const INLINE_PATTERNS: { pattern: RegExp; className: string }[] = [
{ pattern: /`[^`\n]+`/g, className: 'cm-md-code' },
{ pattern: /\*\*[^*\n]+\*\*/g, className: 'cm-md-strong' },
{ pattern: /(^|[^\w*_`])_[^_\n]+_(?![\w_])/g, className: 'cm-md-emphasis' },
{ pattern: /!?\[[^\]\n]*\]\([^)\n]*\)/g, className: 'cm-md-link' },
{
// The four variable syntaxes a dashboard body may carry.
pattern:
/\{\{\s*\.?[\w.-]+\s*\}\}|\[\[\s*[\w.-]+\s*\]\]|\$(?!__)[A-Za-z_]\w*(?:\.\w+)*/g,
className: 'cm-md-variable',
},
];
const MARKS = {
heading: Decoration.mark({ class: 'cm-md-heading' }),
quote: Decoration.mark({ class: 'cm-md-quote' }),
listMarker: Decoration.mark({ class: 'cm-md-list-marker' }),
code: Decoration.mark({ class: 'cm-md-code' }),
} as const;
const INLINE_MARKS = INLINE_PATTERNS.map(({ pattern, className }) => ({
pattern,
mark: Decoration.mark({ class: className }),
}));
function pushInlineMarks(
lineText: string,
lineFrom: number,
ranges: Range<Decoration>[],
): void {
INLINE_MARKS.forEach(({ pattern, mark }) => {
pattern.lastIndex = 0;
let match = pattern.exec(lineText);
while (match !== null) {
const guardLength = match[1]?.length ?? 0;
const from = lineFrom + match.index + guardLength;
const to = lineFrom + match.index + match[0].length;
if (to > from) {
ranges.push(mark.range(from, to));
}
match = pattern.exec(lineText);
}
});
}
function pushBlockMark(line: Line, ranges: Range<Decoration>[]): void {
if (HEADING_PATTERN.test(line.text)) {
ranges.push(MARKS.heading.range(line.from, line.to));
return;
}
if (QUOTE_PATTERN.test(line.text)) {
ranges.push(MARKS.quote.range(line.from, line.to));
return;
}
const listMarker = LIST_MARKER_PATTERN.exec(line.text);
if (listMarker) {
ranges.push(
MARKS.listMarker.range(line.from, line.from + listMarker[0].length),
);
}
}
// Scans the whole document rather than the viewport: fenced blocks opening above
// the visible range would otherwise be mis-detected. Bounded by the length cap.
function buildDecorations(view: EditorView): DecorationSet {
const { doc } = view.state;
const ranges: Range<Decoration>[] = [];
let isInsideFence = false;
for (let lineNumber = 1; lineNumber <= doc.lines; lineNumber += 1) {
const line = doc.line(lineNumber);
const isFenceDelimiter = FENCE_PATTERN.test(line.text);
if (isFenceDelimiter || isInsideFence) {
if (line.to > line.from) {
ranges.push(MARKS.code.range(line.from, line.to));
}
isInsideFence = isFenceDelimiter ? !isInsideFence : isInsideFence;
} else {
pushBlockMark(line, ranges);
pushInlineMarks(line.text, line.from, ranges);
}
}
return Decoration.set(ranges, true);
}
// Colours come from custom properties so the SCSS module owns light/dark.
const syntaxTheme = EditorView.theme({
'.cm-md-heading': {
color: 'var(--md-syntax-heading)',
fontWeight: '600',
},
'.cm-md-quote': { color: 'var(--md-syntax-quote)', fontStyle: 'italic' },
'.cm-md-list-marker': { color: 'var(--md-syntax-marker)' },
'.cm-md-code': { color: 'var(--md-syntax-code)' },
'.cm-md-strong': { color: 'var(--md-syntax-strong)', fontWeight: '600' },
'.cm-md-emphasis': {
color: 'var(--md-syntax-emphasis)',
fontStyle: 'italic',
},
'.cm-md-link': { color: 'var(--md-syntax-link)' },
'.cm-md-variable': { color: 'var(--md-syntax-variable)' },
});
const highlightPlugin = ViewPlugin.fromClass(
class {
decorations: DecorationSet;
constructor(view: EditorView) {
this.decorations = buildDecorations(view);
}
update(update: ViewUpdate): void {
if (update.docChanged || update.viewportChanged) {
this.decorations = buildDecorations(update.view);
}
}
},
{ decorations: (plugin): DecorationSet => plugin.decorations },
);
/**
* Decorations rather than a grammar, so the editor stays on the CodeMirror packages
* already bundled — no `@codemirror/lang-markdown` / `@lezer` for what is only a
* colouring pass over a body the renderer parses for real.
*/
export function markdownHighlight(): Extension {
return [highlightPlugin, syntaxTheme];
}

View File

@@ -1,27 +0,0 @@
/** The value every editor command reads and returns. */
export interface EditorSnapshot {
text: string;
selectionStart: number;
selectionEnd: number;
}
export type EditorTransform = (snapshot: EditorSnapshot) => EditorSnapshot;
export interface EditorVariable {
name: string;
/** Short tag for the variable's kind, e.g. "QUERY". */
badge?: string;
}
export interface EditorCommand {
id: string;
/** Accessible name and tooltip for the toolbar button. */
label: string;
run: EditorTransform;
}
/** 1-based, as the status bar reports it. */
export interface CursorPosition {
line: number;
column: number;
}

View File

@@ -0,0 +1,270 @@
@use '../../../../styles/scrollbar' as *;
.container {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
width: 100%;
// Let the flex children shrink below their content height so the series list
// scrolls within the capped legend height instead of overflowing the wrapper
// (the default min-height:auto would block the shrink).
min-height: 0;
}
/* Toolbar */
.toolbar {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: var(--spacing-5, 10px);
padding: 0 var(--spacing-4) var(--spacing-5, 10px);
flex-shrink: 0;
> * {
flex: 0 1 auto;
}
}
.status {
// Wraps rather than losing the count at its end.
min-width: 0;
font-family: var(--font-mono);
font-size: var(--periscope-font-size-small);
color: var(--l3-foreground);
}
.searchContainer {
flex-shrink: 0;
width: 100%;
padding-right: var(--spacing-4);
padding-bottom: var(--spacing-5, 10px);
}
.searchInput {
font-size: var(--font-size-xs);
}
.searchIcon {
color: var(--l3-foreground);
}
/* Series list */
.scroller {
// flex:1 + min-height:0 pins the scroller to the space left after the
// toolbar instead of growing to fit every row.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
padding-right: var(--spacing-2);
overflow-x: hidden;
overscroll-behavior: contain;
@include custom-scrollbar;
}
.gridItem {
// Or the item keeps its content width and the label never ellipsizes.
min-width: 0;
max-width: 100%;
}
.gridList {
min-width: 0;
display: grid;
grid-auto-flow: row;
// min() keeps the column inside a narrow panel, where a wider one would push
// the row's actions out of the clipped area.
grid-template-columns: repeat(
auto-fill,
minmax(min(var(--legend-item-width, 240px), 100%), 1fr)
);
gap: var(--spacing-1) var(--spacing-4);
}
.container.isRight .gridList {
grid-template-columns: 1fr;
}
.emptyState {
padding: var(--spacing-16) 0;
font-size: var(--font-size-xs);
color: var(--l3-foreground);
text-align: center;
}
/* Row */
.row {
// Width of the revealed actions, given up by the label on hover only.
--legend-actions-reserve: 78px;
position: relative;
display: flex;
align-items: center;
gap: var(--spacing-4);
height: 28px;
padding: 0 var(--spacing-3) 0 var(--spacing-4);
box-sizing: border-box;
width: 100%;
max-width: 100%;
min-width: 0;
border-radius: var(--radius);
cursor: pointer;
transition: background 160ms linear;
&:hover,
&:focus-visible {
background: var(--l3-background);
}
}
.isFocused {
background: var(--l3-background);
}
.marker {
// Reads as a checkbox without being one: filled when shown, hollow when
// hidden, deliberately not a check glyph.
flex: 0 0 auto;
box-sizing: border-box;
position: relative;
// Above the actions, so a narrow row's chip never covers the series colour.
z-index: 4;
width: 12px;
height: 12px;
padding: 0;
appearance: none;
border-width: 1.5px;
border-style: solid;
border-radius: var(--radius);
cursor: pointer;
transition:
transform 200ms ease,
box-shadow 200ms ease,
background-color 160ms linear,
opacity 160ms linear;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px
color-mix(in srgb, var(--l1-foreground) 30%, transparent);
}
&:active {
transform: scale(0.9);
}
&:disabled {
cursor: default;
}
&:disabled:hover {
transform: none;
box-shadow: none;
}
}
// Series names run long and have no spaces to break on, so they need both a
// cap and a break rule or the tooltip becomes one panel-wide line.
.rowTooltip {
max-width: 420px;
white-space: normal;
overflow-wrap: anywhere;
}
.label {
flex: 1 1 auto;
box-sizing: border-box;
width: 100%;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
color: var(--l2-foreground);
user-select: none;
}
.isHidden .marker {
opacity: 0.45;
}
.isHidden .label {
color: var(--l3-foreground);
text-decoration: line-through;
text-decoration-thickness: 1px;
}
/* Row actions */
.actions {
position: absolute;
top: var(--spacing-2);
right: var(--spacing-3);
z-index: 3;
display: flex;
align-items: center;
gap: var(--spacing-2);
padding-left: var(--spacing-5, 10px);
// Sits on the row's hover background and masks the label's tail behind it.
background: var(--l3-background);
box-shadow: -8px 0 8px var(--l3-background);
opacity: 0;
transform: translateX(10px);
pointer-events: none;
transition:
opacity 180ms cubic-bezier(0.08, 0.52, 0.52, 1),
transform 180ms cubic-bezier(0.08, 0.52, 0.52, 1);
}
// :focus-visible, not :focus-within — the latter also matches the click that
// just toggled the series, leaving the actions stuck open.
.row:hover .actions,
.row:focus-visible .actions,
.row:has(:focus-visible) .actions {
opacity: 1;
transform: translateX(0);
pointer-events: auto;
}
// The cap spares rows sized to their actions, not their label.
.row:hover .label,
.row:focus-visible .label,
.row:has(:focus-visible) .label {
padding-right: min(var(--legend-actions-reserve), 50%);
}
.actionTrigger {
display: inline-flex;
}
.actionButton {
--button-height: 20px;
--button-width: 20px;
--button-padding: 0;
--button-variant-ghost-color: var(--l3-foreground);
--button-variant-ghost-hover-color: var(--l1-foreground);
flex-shrink: 0;
}
.actionButton.scopeButton {
--button-width: auto;
--button-padding: 0 var(--spacing-4);
--button-font-size: var(--font-size-xs);
--button-border-radius: calc(var(--radius) * 4);
--button-base-border-width: 1px;
// --l2-border is the actions bar's own background: one step up reads.
--button-base-border-color: var(--l3-border);
border-style: solid;
}
.actionButton.scopeButton:hover {
border-color: var(--l2-border);
}

View File

@@ -1,204 +0,0 @@
@use '../../../../styles/scrollbar' as *;
.legend-search-container {
flex-shrink: 0;
width: 100%;
padding-right: 8px;
.legend-search-input {
font-size: 12px;
}
}
.legend-container {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
height: 100%;
width: 100%;
// Allow the flex children to shrink below their content height so the
// virtualized grid scrolls within the capped legend height instead of
// overflowing the wrapper (default min-height:auto would block the shrink).
min-height: 0;
&:has(.legend-item-focused) .legend-item {
opacity: 0.3;
}
&:has(.legend-item-focused) .legend-item.legend-item-focused {
opacity: 1;
}
.legend-empty-state {
font-size: 12px;
color: var(--l2-foreground);
text-align: center;
padding: 12px;
padding: 2rem 0;
}
.legend-virtuoso-container {
// flex:1 + min-height:0 pins the scroller to the space left after the
// search box (RIGHT legend) and lets it scroll instead of growing to fit
// every row — without this the grid overflows a BOTTOM legend's fixed height.
flex: 1;
min-height: 0;
height: 100%;
width: 100%;
.virtuoso-grid-list {
min-width: 0;
display: grid;
grid-auto-flow: row;
grid-template-columns: repeat(
auto-fill,
minmax(var(--legend-average-width, 240px), 1fr)
);
column-gap: 12px;
}
.virtuoso-grid-item {
min-width: 0;
}
&.legend-virtuoso-container-right {
.virtuoso-grid-list {
grid-template-columns: 1fr;
}
}
&.legend-virtuoso-container-single-row {
.virtuoso-grid-list {
grid-template-columns: repeat(
auto-fit,
minmax(var(--legend-average-width, 240px), max-content)
);
justify-content: center;
}
}
@include custom-scrollbar;
}
}
.legend-row {
padding: 4px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 8px 16px;
&.legend-single-row {
justify-content: center;
}
&.legend-row-right {
flex-direction: column;
align-items: flex-start;
justify-content: flex-start;
}
&.legend-row-bottom {
flex-direction: row;
}
}
.legend-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
// Include padding within the width so a full-width row (legend-item-right) fits its
// column instead of overflowing by the 16px horizontal padding — there is no global
// border-box reset, so the default content-box would make it overflow.
box-sizing: border-box;
max-width: 100%;
overflow: hidden;
border-radius: 4px;
cursor: pointer;
&.legend-item-right {
width: 100%;
}
&.legend-item-off {
opacity: 0.3;
text-decoration: line-through;
text-decoration-thickness: 1px;
}
&.legend-item-focused {
opacity: 1;
}
.legend-item-label-trigger {
display: flex;
align-items: center;
gap: 6px;
flex: 1;
min-width: 0;
cursor: pointer;
}
.legend-marker {
border-width: 2px;
border-style: solid;
border-radius: 50%;
min-width: 11px;
min-height: 11px;
width: 11px;
height: 11px;
flex-shrink: 0;
cursor: pointer;
transition: transform 0.2s ease;
position: relative;
&:hover {
transform: scale(1.2);
box-shadow: 0 0 0 2px rgba(255, 255, 255, 0.3);
}
&:active {
transform: scale(0.9);
}
}
.legend-label {
flex: 1;
font-size: 12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
user-select: none;
}
.legend-copy-button {
// Always laid out (space reserved) but transparent, so revealing it on
// hover fades the icon in without reflowing the row / shifting the label.
// Shrink the shared icon Button (defaults to a 2rem square) to the
// compact legend row via its size tokens.
--button-height: auto;
--button-width: auto;
--button-padding: 2px;
opacity: 0;
flex-shrink: 0;
color: var(--l2-foreground);
border-radius: 4px;
transition:
opacity 0.15s ease,
color 0.15s ease;
&:hover {
color: var(--l1-foreground);
}
}
&:hover {
background: var(--l3-background);
.legend-copy-button {
opacity: 1;
}
}
}

View File

@@ -1,139 +1,136 @@
import { useCallback, useMemo, useRef, useState } from 'react';
import { VirtuosoGrid } from 'react-virtuoso';
import { Input } from 'antd';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { useResizeObserver } from 'hooks/useDimensions';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LegendPosition, LegendProps } from '../types';
import './Legend.styles.scss';
import { LEGEND_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
import LegendRow from './LegendRow';
import LegendToolbar from './LegendToolbar';
import { filterLegendItems, getShownSeriesState } from './utils';
export const MAX_LEGEND_WIDTH = 240;
import styles from './Legend.module.scss';
/**
* Presentational legend. Renders the supplied `items` (markers + labels, an
* optional copy button, and a search box for the RIGHT position) and delegates
* all interaction to the container handlers. Source-agnostic — the uPlot
* charts feed it via UPlotLegend; Pie feeds it directly.
* Presentational legend, source-agnostic: the uPlot charts feed it via
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
*/
export default function Legend({
items,
position,
averageLegendWidth = MAX_LEGEND_WIDTH,
focusedSeriesIndex,
onClick,
onMouseMove,
onMouseLeave,
onToggleSeries,
onShowOnlySeries,
onShowAllSeries,
onHoverSeries,
showCopy = true,
}: LegendProps): JSX.Element {
const legendContainerRef = useRef<HTMLDivElement | null>(null);
const [legendSearchQuery, setLegendSearchQuery] = useState('');
const [filterQuery, setFilterQuery] = useState('');
// Search is intrinsic to the right-positioned legend.
const searchEnabled = position === LegendPosition.RIGHT;
const { width: containerWidth } = useResizeObserver(legendContainerRef);
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
const isRightPosition = position === LegendPosition.RIGHT;
const isSingleRow = useMemo(() => {
if (position !== LegendPosition.BOTTOM || containerWidth <= 0) {
return false;
}
const totalLegendWidth = items.length * (averageLegendWidth + 16);
const totalRows = Math.ceil(totalLegendWidth / containerWidth);
return totalRows <= 1;
}, [averageLegendWidth, items.length, position, containerWidth]);
const visibleLegendItems = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return items;
}
const query = legendSearchQuery.trim().toLowerCase();
return items.filter((item) => item.label?.toLowerCase().includes(query));
}, [searchEnabled, legendSearchQuery, items]);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => {
// `color` is uPlot's stroke union (string | fn | gradient); only a string
// is a usable CSS colour for the marker.
const markerColor = typeof item.color === 'string' ? item.color : undefined;
return (
<div
key={item.seriesIndex}
data-legend-item-id={item.seriesIndex}
className={cx('legend-item', `legend-item-${position.toLowerCase()}`, {
'legend-item-off': !item.show,
'legend-item-focused': focusedSeriesIndex === item.seriesIndex,
})}
>
<TooltipSimple title={item.label} arrow side="top" disableHoverableContent>
<div className="legend-item-label-trigger">
<div
className="legend-marker"
style={{ borderColor: markerColor }}
data-is-legend-marker={true}
/>
<span className="legend-label">{item.label}</span>
</div>
</TooltipSimple>
{showCopy && (
<CopyButton
value={item.label ?? ''}
size={12}
className="legend-copy-button"
ariaLabel={`Copy ${item.label}`}
testId="legend-copy"
/>
)}
</div>
);
},
[focusedSeriesIndex, position, showCopy],
const { visibleCount, soleShownSeriesIndex } = useMemo(
() => getShownSeriesState(items),
[items],
);
const isEmptyState = useMemo(() => {
if (!searchEnabled || !legendSearchQuery.trim()) {
return false;
}
return visibleLegendItems.length === 0;
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
// A bottom legend gets two rows; spending one on chrome costs more chart than
// the readout is worth.
const showToolbar = isRightPosition && items.length > 0;
const showFilter = showToolbar;
const effectiveQuery = showFilter ? filterQuery : '';
const visibleLegendItems = useMemo(
() => filterLegendItems(items, effectiveQuery),
[items, effectiveQuery],
);
const isEmptyState =
!!effectiveQuery.trim() && visibleLegendItems.length === 0;
const isAllShown = visibleCount === items.length;
/** Everything showing -> isolate, since there is nothing to exclude yet. */
const handleRowClick = useCallback(
(seriesIndex: number): void => {
if (isAllShown) {
onShowOnlySeries(seriesIndex);
return;
}
onToggleSeries(seriesIndex);
},
[isAllShown, onShowOnlySeries, onToggleSeries],
);
// A row that unmounts under the pointer never fires its own mouseleave.
const handleMouseLeave = useCallback(
(): void => onHoverSeries(null),
[onHoverSeries],
);
const renderLegendItem = useCallback(
(item: LegendItem): JSX.Element => (
<LegendRow
key={item.seriesIndex}
item={item}
isSoleShown={soleShownSeriesIndex === item.seriesIndex}
isAllShown={isAllShown}
isFocused={focusedSeriesIndex === item.seriesIndex}
showCopy={showCopy}
onRowClick={handleRowClick}
onToggleVisibility={onToggleSeries}
onShowOnly={onShowOnlySeries}
onShowAll={onShowAllSeries}
onHover={onHoverSeries}
/>
),
[
soleShownSeriesIndex,
isAllShown,
focusedSeriesIndex,
showCopy,
handleRowClick,
onToggleSeries,
onShowOnlySeries,
onShowAllSeries,
onHoverSeries,
],
);
return (
<div
ref={legendContainerRef}
className="legend-container"
onClick={onClick}
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
style={{
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
}}
className={cx(styles.container, {
[styles.isRight]: isRightPosition,
})}
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
onMouseLeave={handleMouseLeave}
data-testid="legend-container"
>
{searchEnabled && (
<div className="legend-search-container">
<Input
allowClear
placeholder="Search..."
value={legendSearchQuery}
onChange={(e): void => setLegendSearchQuery(e.target.value)}
data-testid="legend-search-input"
className="legend-search-input"
/>
</div>
{showToolbar && (
<LegendToolbar
visibleCount={visibleCount}
totalCount={items.length}
showFilter={showFilter}
filterQuery={filterQuery}
onFilterQueryChange={setFilterQuery}
/>
)}
{isEmptyState ? (
<div className="legend-empty-state">
No series found matching &quot;{legendSearchQuery}&quot;
<div className={styles.emptyState}>
No series found matching &quot;{effectiveQuery}&quot;
</div>
) : (
<VirtuosoGrid
className={cx(
'legend-virtuoso-container',
`legend-virtuoso-container-${position.toLowerCase()}`,
{ 'legend-virtuoso-container-single-row': isSingleRow },
)}
className={styles.scroller}
listClassName={styles.gridList}
itemClassName={styles.gridItem}
data={visibleLegendItems}
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
/>

View File

@@ -0,0 +1,180 @@
import { KeyboardEvent, memo, MouseEvent, useCallback } from 'react';
import { Button } from '@signozhq/ui/button';
import { TooltipSimple } from '@signozhq/ui/tooltip';
import cx from 'classnames';
import { LegendItem } from 'lib/uPlotV2/config/types';
import CopyButton from 'periscope/components/CopyButton/CopyButton';
import { LEGEND_TOOLTIP_DELAY_MS } from './constants';
import styles from './Legend.module.scss';
export interface LegendRowProps {
item: LegendItem;
/** The only series currently shown, so hiding it is refused. */
isSoleShown: boolean;
/** Nothing is hidden, so the row's action can only narrow the selection. */
isAllShown: boolean;
isFocused: boolean;
showCopy: boolean;
/** Row click, whose meaning depends on how many series are shown. */
onRowClick: (seriesIndex: number) => void;
/** Marker click: hide or show just this series. */
onToggleVisibility: (seriesIndex: number) => void;
onShowOnly: (seriesIndex: number) => void;
onShowAll: () => void;
onHover: (seriesIndex: number | null) => void;
}
/**
* One legend row. The marker is its own target for excluding a single series —
* the one thing the row click can't do while everything is showing. The actions
* overlay the label's tail rather than taking layout width, and their reveal is
* pure CSS.
*/
function LegendRow({
item,
isSoleShown,
isAllShown,
isFocused,
showCopy,
onRowClick,
onToggleVisibility,
onShowOnly,
onShowAll,
onHover,
}: LegendRowProps): JSX.Element {
const { seriesIndex, show } = item;
const label = item.label ?? '';
const isShowAllAction = show && !isAllShown;
const scopeActionLabel = isShowAllAction
? 'Show all series'
: 'Show only current series';
// `color` is uPlot's stroke union (string | fn | gradient); only a string is
// a usable CSS colour for the marker.
const seriesColor = typeof item.color === 'string' ? item.color : undefined;
const handleRowClick = useCallback(
(): void => onRowClick(seriesIndex),
[onRowClick, seriesIndex],
);
const handleMarkerClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
onToggleVisibility(seriesIndex);
},
[onToggleVisibility, seriesIndex],
);
const handleKeyDown = useCallback(
(event: KeyboardEvent<HTMLDivElement>): void => {
// Let the row actions handle their own keys.
if (event.target !== event.currentTarget) {
return;
}
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
onRowClick(seriesIndex);
}
},
[onRowClick, seriesIndex],
);
const handleScopeClick = useCallback(
(event: MouseEvent<HTMLButtonElement>): void => {
event.stopPropagation();
if (isShowAllAction) {
onShowAll();
return;
}
onShowOnly(seriesIndex);
},
[isShowAllAction, onShowAll, onShowOnly, seriesIndex],
);
const handleMouseEnter = useCallback(
(): void => onHover(seriesIndex),
[onHover, seriesIndex],
);
const handleMouseLeave = useCallback((): void => onHover(null), [onHover]);
return (
<div
className={cx(styles.row, {
[styles.isHidden]: !show,
[styles.isFocused]: isFocused,
})}
data-legend-item-id={seriesIndex}
data-testid={`legend-item-${seriesIndex}`}
role="switch"
tabIndex={0}
aria-checked={show}
aria-label={label}
onClick={handleRowClick}
onKeyDown={handleKeyDown}
onMouseEnter={handleMouseEnter}
onMouseLeave={handleMouseLeave}
>
<button
type="button"
className={styles.marker}
style={{
borderColor: seriesColor,
backgroundColor: show ? seriesColor : 'transparent',
}}
onClick={handleMarkerClick}
disabled={isSoleShown}
aria-label={`${show ? 'Hide' : 'Show'} ${label}`}
data-is-legend-marker={true}
data-testid={`legend-marker-${seriesIndex}`}
/>
<TooltipSimple
title={label}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
<span className={styles.label}>{label}</span>
</TooltipSimple>
<div className={styles.actions}>
<TooltipSimple
title={scopeActionLabel}
arrow
side="top"
delayDuration={LEGEND_TOOLTIP_DELAY_MS}
disableHoverableContent
tooltipContentProps={{ className: styles.rowTooltip }}
>
{/* Radix's asChild merge strips the button's own data-testid. */}
<span className={styles.actionTrigger}>
<Button
variant="ghost"
color="secondary"
size="sm"
className={cx(styles.actionButton, styles.scopeButton)}
onClick={handleScopeClick}
aria-label={scopeActionLabel}
testId={`legend-scope-${seriesIndex}`}
>
{isShowAllAction ? 'All' : 'Only'}
</Button>
</span>
</TooltipSimple>
{showCopy && (
<CopyButton
value={label}
size={13}
className={styles.actionButton}
ariaLabel={`Copy ${label}`}
testId={`legend-copy-${seriesIndex}`}
/>
)}
</div>
</div>
);
}
export default memo(LegendRow);

View File

@@ -0,0 +1,56 @@
import { ChangeEvent, useCallback } from 'react';
import { Input } from 'antd';
import { Search } from '@signozhq/icons';
import styles from './Legend.module.scss';
export interface LegendToolbarProps {
visibleCount: number;
totalCount: number;
/** Search is intrinsic to the right-positioned legend. */
showFilter: boolean;
filterQuery: string;
onFilterQueryChange: (query: string) => void;
}
/** Legend chrome: the series search box and the "Showing N of M" readout. */
export default function LegendToolbar({
visibleCount,
totalCount,
showFilter,
filterQuery,
onFilterQueryChange,
}: LegendToolbarProps): JSX.Element {
const handleFilterChange = useCallback(
(event: ChangeEvent<HTMLInputElement>): void =>
onFilterQueryChange(event.target.value),
[onFilterQueryChange],
);
return (
<>
{showFilter && (
<div className={styles.searchContainer}>
<Input
allowClear
prefix={<Search size={12} className={styles.searchIcon} />}
placeholder="Search..."
value={filterQuery}
onChange={handleFilterChange}
className={styles.searchInput}
data-testid="legend-search-input"
/>
</div>
)}
<div className={styles.toolbar}>
<span
className={styles.status}
aria-live="polite"
data-testid="legend-status"
>
{`Showing ${visibleCount} of ${totalCount} series`}
</span>
</div>
</>
);
}

View File

@@ -8,8 +8,8 @@ import Legend from './Legend';
/**
* uPlot legend controller. Derives the legend items + focus/visibility state
* from the chart config (useLegendsSync) and the toggle/focus interactions from
* the plot context (useLegendActions), then renders the presentational Legend.
* from the chart config (useLegendsSync) and the series interactions from the
* plot context (useLegendActions), then renders the presentational Legend.
* Must be rendered inside a PlotContextProvider.
*/
export default function UPlotLegend({
@@ -17,13 +17,9 @@ export default function UPlotLegend({
config,
averageLegendWidth,
}: UPlotLegendProps): JSX.Element {
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
useLegendsSync({ config });
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
});
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
const { onToggleSeries, onShowOnlySeries, onShowAllSeries, onHoverSeries } =
useLegendActions();
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
@@ -33,9 +29,10 @@ export default function UPlotLegend({
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
onToggleSeries={onToggleSeries}
onShowOnlySeries={onShowOnlySeries}
onShowAllSeries={onShowAllSeries}
onHoverSeries={onHoverSeries}
/>
);
}

View File

@@ -0,0 +1,45 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import { filterLegendItems, getShownSeriesState } from '../utils';
const items = (shown: boolean[]): LegendItem[] =>
shown.map((show, index) => ({
seriesIndex: index + 1,
label: `series-${index}`,
color: '#000',
show,
}));
describe('getShownSeriesState', () => {
it('counts the shown series', () => {
expect(getShownSeriesState(items([true, false, true]))).toStrictEqual({
visibleCount: 2,
soleShownSeriesIndex: null,
});
});
it('names the series when exactly one is shown', () => {
expect(getShownSeriesState(items([false, true, false]))).toStrictEqual({
visibleCount: 1,
soleShownSeriesIndex: 2,
});
});
it('reports nothing shown', () => {
expect(getShownSeriesState(items([false, false]))).toStrictEqual({
visibleCount: 0,
soleShownSeriesIndex: null,
});
});
});
describe('filterLegendItems', () => {
it('matches case-insensitively on the label', () => {
const filtered = filterLegendItems(items([true, true, true]), 'SERIES-1');
expect(filtered.map((item) => item.label)).toStrictEqual(['series-1']);
});
it('returns every item for a blank query', () => {
expect(filterLegendItems(items([true, true]), ' ')).toHaveLength(2);
});
});

View File

@@ -0,0 +1,20 @@
/** Widest a single legend item is allowed to get when sizing the legend grid. */
export const MAX_LEGEND_WIDTH = 240;
/**
* Enough for a row to contain its own hover actions, which a short label would
* otherwise size a column too narrow for. Little room for the label is intended.
*/
export const MIN_LEGEND_ITEM_WIDTH = 110;
/** Marker + row padding, on top of the estimated label width. */
export const LEGEND_ITEM_EXTRA_WIDTH = 16;
/** Must match `.row`'s height and the grid's row gap, or the reserved
* rectangle clips a row. */
export const LEGEND_ROW_HEIGHT = 28;
export const LEGEND_ROW_GAP = 2;
export const LEGEND_MAX_BOTTOM_ROWS = 2;
/** Hover delay before a row's full-name tooltip opens. */
export const LEGEND_TOOLTIP_DELAY_MS = 500;

View File

@@ -0,0 +1,34 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
export interface ShownSeriesState {
visibleCount: number;
/** The series index when exactly one series is shown, else null. */
soleShownSeriesIndex: number | null;
}
/**
* Driven by what is actually shown, never a remembered isolation: hiding series
* one at a time down to a single one is the same state as "Only".
*/
export function getShownSeriesState(items: LegendItem[]): ShownSeriesState {
const shown = items.filter((item) => item.show);
return {
visibleCount: shown.length,
soleShownSeriesIndex: shown.length === 1 ? shown[0].seriesIndex : null,
};
}
export function filterLegendItems(
items: LegendItem[],
query: string,
): LegendItem[] {
const normalisedQuery = query.trim().toLowerCase();
if (!normalisedQuery) {
return items;
}
return items.filter((item) =>
item.label?.toLowerCase().includes(normalisedQuery),
);
}

View File

@@ -12,10 +12,11 @@
}
}
// Matches the legend row's marker.
.uplotTooltipItemMarker {
border-radius: 50%;
border-radius: var(--radius);
border-style: solid;
border-width: 2px;
border-width: 1.5px;
width: 12px;
height: 12px;
box-sizing: border-box;
@@ -30,11 +31,23 @@
justify-content: space-between;
}
// The legend's mono type; the container's Inter stays for the header.
.uplotTooltipItemLabel,
.uplotTooltipItemValue {
font-family: var(--font-mono);
font-size: var(--font-size-xs);
letter-spacing: -0.01em;
}
.uplotTooltipItemLabel {
white-space: normal;
overflow-wrap: anywhere;
}
.uplotTooltipItemValue {
white-space: nowrap;
}
.uplotTooltipItemContentSeparator {
flex: 1;
border-width: 0.5px;

View File

@@ -25,7 +25,7 @@ export default function TooltipItem({
>
<div
className={Styles.uplotTooltipItemMarker}
style={{ borderColor: item.color }}
style={{ borderColor: item.color, backgroundColor: item.color }}
data-is-legend-marker={true}
data-testid={markerTestId}
/>
@@ -39,7 +39,7 @@ export default function TooltipItem({
className={Styles.uplotTooltipItemContentSeparator}
style={{ borderColor: item.color }}
/>
<span>{item.tooltipValue}</span>
<span className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
</div>
</div>
);

View File

@@ -61,16 +61,16 @@ describe('UPlotLegend', () => {
},
};
let onLegendClick: jest.Mock;
let onLegendMouseMove: jest.Mock;
let onLegendMouseLeave: jest.Mock;
let onFocusSeries: jest.Mock;
let onToggleSeries: jest.Mock;
let onShowOnlySeries: jest.Mock;
let onShowAllSeries: jest.Mock;
let onHoverSeries: jest.Mock;
beforeEach(() => {
onLegendClick = jest.fn();
onLegendMouseMove = jest.fn();
onLegendMouseLeave = jest.fn();
onFocusSeries = jest.fn();
onToggleSeries = jest.fn();
onShowOnlySeries = jest.fn();
onShowAllSeries = jest.fn();
onHoverSeries = jest.fn();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: baseLegendItemsMap,
@@ -79,10 +79,10 @@ describe('UPlotLegend', () => {
});
mockUseLegendActions.mockReturnValue({
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onFocusSeries,
onToggleSeries,
onShowOnlySeries,
onShowAllSeries,
onHoverSeries,
});
});
@@ -102,28 +102,39 @@ describe('UPlotLegend', () => {
);
describe('layout and position', () => {
it('renders search input when legend position is RIGHT', () => {
it('renders the search input on a RIGHT legend', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
});
it('does not render search input when legend position is BOTTOM (default)', () => {
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
renderLegend();
expect(screen.queryByTestId('legend-search-input')).not.toBeInTheDocument();
expect(screen.queryByTestId('legend-status')).not.toBeInTheDocument();
// The row interactions are the same in both placements.
expect(screen.getByTestId('legend-item-0')).toBeInTheDocument();
expect(screen.getByTestId('legend-scope-0')).toBeInTheDocument();
});
it('renders the marker with the correct border color', () => {
it('renders the marker with the series colour, filled only when shown', () => {
renderLegend(LegendPosition.RIGHT);
const legendMarker = document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
) as HTMLElement;
expect(legendMarker).toHaveStyle({
expect(
document.querySelector(
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
),
).toHaveStyle({
'border-color': '#ff0000',
'background-color': '#ff0000',
});
// Hidden series read as an empty checkbox.
expect(
document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
),
).toHaveStyle({ 'background-color': 'transparent' });
});
it('renders all legend items in the grid by default', () => {
@@ -136,25 +147,33 @@ describe('UPlotLegend', () => {
});
});
describe('search behavior (RIGHT position)', () => {
it('filters legend items based on search query (case-insensitive)', async () => {
describe('status readout', () => {
it('reports how many series are showing', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-status')).toHaveTextContent(
'Showing 2 of 3 series',
);
});
});
describe('filter behavior', () => {
it('filters legend items based on the query (case-insensitive)', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'A');
await user.type(screen.getByTestId('legend-search-input'), 'a');
expect(screen.getByText('A')).toBeInTheDocument();
expect(screen.queryByText('B')).not.toBeInTheDocument();
expect(screen.queryByText('C')).not.toBeInTheDocument();
});
it('shows empty state when no legend items match the search query', async () => {
it('shows the empty state when nothing matches', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, 'network');
await user.type(screen.getByTestId('legend-search-input'), 'network');
expect(
screen.getByText(/No series found matching "network"/i),
@@ -162,12 +181,11 @@ describe('UPlotLegend', () => {
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
});
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
it('ignores a whitespace-only query', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const searchInput = screen.getByTestId('legend-search-input');
await user.type(searchInput, ' ');
await user.type(screen.getByTestId('legend-search-input'), ' ');
expect(
screen.queryByText(/No series found matching/i),
@@ -178,39 +196,197 @@ describe('UPlotLegend', () => {
});
});
describe('legend actions', () => {
it('calls onLegendClick when a legend item is clicked', async () => {
describe('row interactions', () => {
const allShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1], show: true },
2: { ...baseLegendItemsMap[2] },
};
const mockAllShown = (): void => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: allShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
};
it('isolates the series when everything is showing', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
// Nothing the user can see is there to exclude, so the click means Only.
expect(onShowOnlySeries).toHaveBeenCalledWith(0);
expect(onToggleSeries).not.toHaveBeenCalled();
});
it('toggles the series once something is already hidden', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByText('A'));
expect(onLegendClick).toHaveBeenCalledTimes(1);
expect(onToggleSeries).toHaveBeenCalledWith(0);
expect(onShowOnlySeries).not.toHaveBeenCalled();
});
it('calls mouseMove when the mouse moves over a legend item', async () => {
it('excludes just that series when its marker is clicked', async () => {
const user = userEvent.setup();
mockAllShown();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-marker-0'));
// The marker is the one way to exclude a single series while
// everything is showing — the row click isolates instead.
expect(onToggleSeries).toHaveBeenCalledWith(0);
expect(onShowOnlySeries).not.toHaveBeenCalled();
});
it('stops the marker offering to hide the last series showing', () => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toBeDisabled();
expect(screen.getByTestId('legend-marker-1')).toBeEnabled();
});
it('labels the marker with what clicking it does', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-marker-0')).toHaveAttribute(
'aria-label',
'Hide A',
);
expect(screen.getByTestId('legend-marker-1')).toHaveAttribute(
'aria-label',
'Show B',
);
});
it('adds the clicked series to the selection while one is alone', async () => {
const user = userEvent.setup();
mockUseLegendsSync.mockReturnValue({
legendItemsMap: {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
},
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
renderLegend(LegendPosition.RIGHT);
// Series 0 is showing alone; clicking another row builds the selection
// up rather than moving the isolation.
await user.click(screen.getByText('B'));
expect(onToggleSeries).toHaveBeenCalledWith(1);
expect(onShowOnlySeries).not.toHaveBeenCalled();
});
it('toggles the series on Enter and Space', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const legendItem = document.querySelector(
'[data-legend-item-id="0"]',
) as HTMLElement;
const row = screen.getByTestId('legend-item-0');
row.focus();
await user.keyboard('{Enter}');
await user.keyboard(' ');
await user.hover(legendItem);
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
expect(onToggleSeries).toHaveBeenCalledTimes(2);
expect(onToggleSeries).toHaveBeenCalledWith(0);
});
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
it('reflects visibility on the row for assistive tech', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-item-0')).toHaveAttribute(
'aria-checked',
'true',
);
expect(screen.getByTestId('legend-item-1')).toHaveAttribute(
'aria-checked',
'false',
);
});
it('restores every series from All without also toggling the row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const container = document.querySelector('.legend-container') as HTMLElement;
// Series 0 is shown while B is hidden, so its action is All.
await user.click(screen.getByTestId('legend-scope-0'));
await user.hover(container);
await user.unhover(container);
expect(onShowAllSeries).toHaveBeenCalled();
expect(onToggleSeries).not.toHaveBeenCalled();
expect(onShowOnlySeries).not.toHaveBeenCalled();
});
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
it('isolates the series from Only on a hidden row', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-1'));
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
expect(onToggleSeries).not.toHaveBeenCalled();
});
it('highlights the hovered series and clears it on leave', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
const row = screen.getByTestId('legend-item-0');
await user.hover(row);
expect(onHoverSeries).toHaveBeenCalledWith(0);
await user.unhover(row);
expect(onHoverSeries).toHaveBeenCalledWith(null);
});
});
describe('one-series state', () => {
const soleShownItemsMap = {
0: { ...baseLegendItemsMap[0] },
1: { ...baseLegendItemsMap[1] },
2: { ...baseLegendItemsMap[2], show: false },
};
beforeEach(() => {
mockUseLegendsSync.mockReturnValue({
legendItemsMap: soleShownItemsMap,
focusedSeriesIndex: null,
setFocusedSeriesIndex: jest.fn(),
});
});
it('offers All on the shown row and Only on the hidden ones', () => {
renderLegend(LegendPosition.RIGHT);
expect(screen.getByTestId('legend-scope-0')).toHaveTextContent('All');
expect(screen.getByTestId('legend-scope-1')).toHaveTextContent('Only');
expect(screen.getByTestId('legend-scope-2')).toHaveTextContent('Only');
});
it('restores everything from All', async () => {
const user = userEvent.setup();
renderLegend(LegendPosition.RIGHT);
await user.click(screen.getByTestId('legend-scope-0'));
expect(onShowAllSeries).toHaveBeenCalled();
});
});
});

View File

@@ -1,4 +1,4 @@
import { MouseEventHandler, ReactNode } from 'react';
import { ReactNode } from 'react';
import { Timezone } from 'components/CustomTimePicker/timezoneUtils';
import { PrecisionOption } from 'components/Graph/types';
import uPlot from 'uplot';
@@ -118,23 +118,23 @@ export interface LegendConfig {
/**
* Presentational legend props. Source-agnostic: it renders whatever `items`
* it's given and delegates interaction to the container handlers, so it serves
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie). The search
* box is intrinsic to the RIGHT position (derived from `position`, not a flag).
* both uPlot charts (via UPlotLegend) and non-uPlot charts (Pie).
*/
export interface LegendProps {
items: LegendItem[];
/** Legend placement; always supplied by the container. */
position: LegendPosition;
averageLegendWidth?: number;
/** Series index to highlight (hovered/focused). */
/** Series index highlighted by the chart cursor. */
focusedSeriesIndex: number | null;
/**
* Container-delegated handlers. Items carry `data-legend-item-id`, so the
* handler reads the target's id rather than binding per item.
*/
onClick: MouseEventHandler<HTMLDivElement>;
onMouseMove: MouseEventHandler<HTMLDivElement>;
onMouseLeave: () => void;
/** Row click / Space / Enter: hide or show that one series. */
onToggleSeries: (seriesIndex: number) => void;
/** "Only": show that series alone. */
onShowOnlySeries: (seriesIndex: number) => void;
/** "All": leave the narrowed selection and show every series. */
onShowAllSeries: () => void;
/** Row hover, for the chart-side highlight; null on leave. */
onHoverSeries: (seriesIndex: number | null) => void;
/** Show the per-item copy button. Default true. */
showCopy?: boolean;
}

View File

@@ -6,6 +6,11 @@ export const DEFAULT_HOVER_PROXIMITY_VALUE = 30; // only snap if within 30px hor
export const DEFAULT_FOCUS_PROXIMITY_VALUE = 1e6;
export const STEP_INTERVAL_MULTIPLIER = 3; // multiply the width computed by STEP_INTERVAL_MULTIPLIER to get the hover prox value
/** Opacity applied to the series that are NOT highlighted while a legend row is hovered. */
export const LEGEND_HIGHLIGHT_DIM_ALPHA = 0.16;
/** Stroke-width multiplier applied to the series highlighted from the legend. */
export const LEGEND_HIGHLIGHT_WIDTH_RATIO = 1.6;
export const DEFAULT_PLOT_CONFIG: Partial<Options> = {
focus: {
alpha: 0.3,

View File

@@ -8,6 +8,10 @@ import {
useMemo,
useRef,
} from 'react';
import {
LEGEND_HIGHLIGHT_DIM_ALPHA,
LEGEND_HIGHLIGHT_WIDTH_RATIO,
} from 'lib/uPlotV2/constants';
import type { SeriesVisibilityItem } from 'lib/visualization/panels/types';
import { updateSeriesVisibilityToLocalStorage } from 'lib/visualization/panels/utils/legendVisibilityUtils';
import type uPlot from 'uplot';
@@ -20,12 +24,26 @@ export interface IPlotContext {
setPlotContextInitialState: (state: PlotContextInitialState) => void;
onToggleSeriesVisibility: (seriesIndex: number) => void;
onToggleSeriesOnOff: (seriesIndex: number) => void;
/** Show this series alone. */
onShowOnlySeries: (seriesIndex: number) => void;
/** Show every series again. */
onShowAllSeries: () => void;
onFocusSeries: (seriesIndex: number | null) => void;
/** Lift one series above the rest (dim + thicken) without changing visibility. */
onHighlightSeries: (seriesIndex: number | null) => void;
syncSeriesVisibilityToLocalStorage: () => void;
}
export const PlotContext = createContext<IPlotContext | null>(null);
/** Data series (index 0 is the x-axis) currently drawn. */
const countShownSeries = (plot: uPlot): number =>
plot.series.reduce(
(count, series, index) =>
index > 0 && series.show !== false ? count + 1 : count,
0,
);
export const PlotContextProvider = ({
children,
}: PropsWithChildren): JSX.Element => {
@@ -33,6 +51,9 @@ export const PlotContextProvider = ({
const activeSeriesIndex = useRef<number | undefined>(undefined);
const idRef = useRef<string | undefined>(undefined);
const shouldSavePreferencesRef = useRef<boolean>(false);
/** Pre-highlight stroke widths, captured on the first highlight so it can be undone. */
const baseSeriesWidthsRef = useRef<Map<number, number | undefined>>(new Map());
const highlightedSeriesIndexRef = useRef<number | null>(null);
const setPlotContextInitialState = useCallback(
({
@@ -43,6 +64,8 @@ export const PlotContextProvider = ({
uPlotInstanceRef.current = uPlotInstance;
idRef.current = id;
activeSeriesIndex.current = undefined;
baseSeriesWidthsRef.current = new Map();
highlightedSeriesIndexRef.current = null;
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
},
[],
@@ -64,6 +87,54 @@ export const PlotContextProvider = ({
updateSeriesVisibilityToLocalStorage(idRef.current, seriesVisibility);
}, []);
const onHighlightSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
highlightedSeriesIndexRef.current = seriesIndex;
plot.series.forEach((series, index) => {
if (index === 0) {
return;
}
if (!baseSeriesWidthsRef.current.has(index)) {
baseSeriesWidthsRef.current.set(index, series.width);
}
const baseWidth = baseSeriesWidthsRef.current.get(index);
const isHighlighted = index === seriesIndex;
/* eslint-disable no-param-reassign */
series.alpha =
seriesIndex === null || isHighlighted ? 1 : LEGEND_HIGHLIGHT_DIM_ALPHA;
series.width =
isHighlighted && baseWidth !== undefined
? baseWidth * LEGEND_HIGHLIGHT_WIDTH_RATIO
: baseWidth;
/* eslint-enable no-param-reassign */
});
// Only the stroke style changed, so the cached paths stay valid.
plot.redraw(false);
}, []);
/**
* Leaving the dim on a hidden series leaves every other one faded, which
* reads as an isolation rather than as one series being excluded.
*/
const clearHighlightIfHidden = useCallback((): void => {
const plot = uPlotInstanceRef.current;
const highlightedIndex = highlightedSeriesIndexRef.current;
if (!plot || highlightedIndex === null) {
return;
}
if (plot.series[highlightedIndex]?.show === false) {
onHighlightSeries(null);
}
}, [onHighlightSeries]);
const onToggleSeriesVisibility = useCallback(
(seriesIndex: number): void => {
const plot = uPlotInstanceRef.current;
@@ -103,14 +174,61 @@ export const PlotContextProvider = ({
if (!series) {
return;
}
// An empty chart is never worth reaching.
const isHiding = series.show !== false;
if (isHiding && countShownSeries(plot) <= 1) {
return;
}
plot.setSeries(seriesIndex, { show: !series.show });
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
clearHighlightIfHidden();
},
[syncSeriesVisibilityToLocalStorage],
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
);
/** Applies `resolveShow` to every data series in one batch, then persists. */
const setSeriesVisibility = useCallback(
(resolveShow: (seriesIndex: number) => boolean): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
return;
}
activeSeriesIndex.current = undefined;
plot.batch(() => {
plot.series.forEach((_, index) => {
if (index === 0) {
return;
}
plot.setSeries(index, { show: resolveShow(index) });
});
if (idRef.current && shouldSavePreferencesRef.current) {
syncSeriesVisibilityToLocalStorage();
}
});
clearHighlightIfHidden();
},
[syncSeriesVisibilityToLocalStorage, clearHighlightIfHidden],
);
const onShowOnlySeries = useCallback(
(seriesIndex: number): void => {
setSeriesVisibility((index) => index === seriesIndex);
},
[setSeriesVisibility],
);
const onShowAllSeries = useCallback((): void => {
setSeriesVisibility(() => true);
}, [setSeriesVisibility]);
const onFocusSeries = useCallback((seriesIndex: number | null): void => {
const plot = uPlotInstanceRef.current;
if (!plot) {
@@ -131,14 +249,20 @@ export const PlotContextProvider = ({
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
syncSeriesVisibilityToLocalStorage,
}),
[
onToggleSeriesVisibility,
setPlotContextInitialState,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
syncSeriesVisibilityToLocalStorage,
],
);

View File

@@ -26,6 +26,7 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
series,
batch: jest.fn((fn: () => void) => fn()),
setSeries: jest.fn(),
redraw: jest.fn(),
}) as unknown as uPlot;
interface TestComponentProps {
@@ -44,7 +45,10 @@ const TestComponent = ({
syncSeriesVisibilityToLocalStorage,
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
} = usePlotContext();
const handleInit = (): void => {
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
@@ -84,6 +88,13 @@ const TestComponent = ({
>
Toggle on/off 1
</button>
<button
type="button"
data-testid="toggle-on-off-2"
onClick={(): void => onToggleSeriesOnOff(2)}
>
Toggle on/off 2
</button>
<button
type="button"
data-testid="toggle-on-off-5"
@@ -98,6 +109,34 @@ const TestComponent = ({
>
Focus series
</button>
<button
type="button"
data-testid="show-only-1"
onClick={(): void => onShowOnlySeries(1)}
>
Show only 1
</button>
<button
type="button"
data-testid="show-all"
onClick={(): void => onShowAllSeries()}
>
Show all
</button>
<button
type="button"
data-testid="highlight-1"
onClick={(): void => onHighlightSeries(1)}
>
Highlight 1
</button>
<button
type="button"
data-testid="clear-highlight"
onClick={(): void => onHighlightSeries(null)}
>
Clear highlight
</button>
</div>
);
};
@@ -273,6 +312,7 @@ describe('PlotContext', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
@@ -324,6 +364,7 @@ describe('PlotContext', () => {
const series: MockSeries[] = [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
];
const plot = createMockPlot(series);
@@ -343,6 +384,48 @@ describe('PlotContext', () => {
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: false });
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('refuses to hide the last series showing', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
// An empty chart is never a state worth reaching.
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
it('still shows a hidden series when only one is left showing', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: false },
{ label: 'Memory', show: true },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('toggle-on-off-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
});
});
describe('onFocusSeries', () => {
@@ -381,4 +464,193 @@ describe('PlotContext', () => {
expect(plot.setSeries).toHaveBeenCalledWith(1, { focus: true }, false);
});
});
describe('onShowOnlySeries', () => {
const renderWithSeries = (
series: MockSeries[],
): { plot: uPlot; user: ReturnType<typeof userEvent.setup> } => {
const user = userEvent.setup();
const plot = createMockPlot(series);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
return { plot, user };
};
it('hides every other series, leaving the x-axis alone', async () => {
const { plot, user } = renderWithSeries([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: true },
]);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-only-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
expect(mockUpdateSeriesVisibilityToLocalStorage).toHaveBeenCalled();
});
it('keeps isolating the series that is already the only one shown', async () => {
const { plot, user } = renderWithSeries([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-only-1'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: false });
});
});
describe('onShowAllSeries', () => {
it('shows every hidden series again', async () => {
const user = userEvent.setup();
const plot = createMockPlot([
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true },
{ label: 'Memory', show: false },
]);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('show-all'));
expect(plot.setSeries).toHaveBeenCalledWith(1, { show: true });
expect(plot.setSeries).toHaveBeenCalledWith(2, { show: true });
expect(plot.setSeries).not.toHaveBeenCalledWith(0, expect.anything());
});
});
describe('onHighlightSeries', () => {
const series = (): MockSeries[] => [
{ label: 'x-axis', show: true },
{ label: 'CPU', show: true, width: 2 },
{ label: 'Memory', show: true, width: 2 },
];
it('dims the other series and thickens the highlighted one', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[1].width).toBe(3.2);
expect(plot.series[2].alpha).toBe(0.16);
expect(plot.series[2].width).toBe(2);
// Only the stroke changed, so the cached paths are reused.
expect(plot.redraw).toHaveBeenCalledWith(false);
});
it('restores every series when the highlight is cleared', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('clear-highlight'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[1].width).toBe(2);
expect(plot.series[2].alpha).toBe(1);
expect(plot.series[2].width).toBe(2);
});
it('drops the dim when the highlighted series is hidden', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
// The mock's setSeries doesn't mutate, so mirror what uPlot would do.
(plot.setSeries as jest.Mock).mockImplementation(
(index: number, opts: { show?: boolean }) => {
if (typeof opts.show === 'boolean') {
(plot.series[index] as MockSeries).show = opts.show;
}
},
);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('toggle-on-off-1'));
// Otherwise every remaining series stays faded and the panel reads as
// an isolation instead of one series being excluded.
expect(plot.series[2].alpha).toBe(1);
expect(plot.series[2].width).toBe(2);
});
it('keeps the dim when a different series is hidden', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
(plot.setSeries as jest.Mock).mockImplementation(
(index: number, opts: { show?: boolean }) => {
if (typeof opts.show === 'boolean') {
(plot.series[index] as MockSeries).show = opts.show;
}
},
);
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
await user.click(screen.getByTestId('toggle-on-off-2'));
expect(plot.series[1].alpha).toBe(1);
expect(plot.series[2].alpha).toBe(0.16);
});
it('leaves visibility untouched', async () => {
const user = userEvent.setup();
const plot = createMockPlot(series());
render(
<PlotContextProvider>
<TestComponent plot={plot} id="widget-123" shouldSaveSelectionPreference />
</PlotContextProvider>,
);
await user.click(screen.getByTestId('init'));
await user.click(screen.getByTestId('highlight-1'));
expect(plot.setSeries).not.toHaveBeenCalled();
expect(mockUpdateSeriesVisibilityToLocalStorage).not.toHaveBeenCalled();
});
});
});

View File

@@ -11,10 +11,12 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
describe('useLegendActions', () => {
let onToggleSeriesVisibility: jest.Mock;
let onToggleSeriesOnOff: jest.Mock;
let onFocusSeriesPlot: jest.Mock;
let onShowOnlySeries: jest.Mock;
let onShowAllSeries: jest.Mock;
let onFocusSeries: jest.Mock;
let onHighlightSeries: jest.Mock;
let setPlotContextInitialState: jest.Mock;
let syncSeriesVisibilityToLocalStorage: jest.Mock;
let setFocusedSeriesIndexMock: jest.Mock;
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
beforeAll(() => {
@@ -37,15 +39,20 @@ describe('useLegendActions', () => {
beforeEach(() => {
onToggleSeriesVisibility = jest.fn();
onToggleSeriesOnOff = jest.fn();
onFocusSeriesPlot = jest.fn();
onShowOnlySeries = jest.fn();
onShowAllSeries = jest.fn();
onFocusSeries = jest.fn();
onHighlightSeries = jest.fn();
setPlotContextInitialState = jest.fn();
syncSeriesVisibilityToLocalStorage = jest.fn();
setFocusedSeriesIndexMock = jest.fn();
mockUsePlotContext.mockReturnValue({
onToggleSeriesVisibility,
onToggleSeriesOnOff,
onFocusSeries: onFocusSeriesPlot,
onShowOnlySeries,
onShowAllSeries,
onFocusSeries,
onHighlightSeries,
setPlotContextInitialState,
syncSeriesVisibilityToLocalStorage,
});
@@ -53,149 +60,65 @@ describe('useLegendActions', () => {
cancelAnimationFrameSpy.mockClear();
});
const createMouseEvent = (options: {
legendItemId?: number;
isMarker?: boolean;
}): any => {
const { legendItemId, isMarker = false } = options;
describe('visibility actions', () => {
it('toggles a single series on row click', () => {
const { result } = renderHook(() => useLegendActions());
return {
target: {
dataset: {
...(isMarker ? { isLegendMarker: 'true' } : {}),
},
closest: jest.fn(() =>
legendItemId !== undefined
? { dataset: { legendItemId: String(legendItemId) } }
: null,
),
},
};
};
result.current.onToggleSeries(2);
describe('onLegendClick', () => {
it('toggles series visibility when clicking on legend label', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(createMouseEvent({ legendItemId: 0 }));
expect(onToggleSeriesVisibility).toHaveBeenCalledTimes(1);
expect(onToggleSeriesVisibility).toHaveBeenCalledWith(0);
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
});
it('toggles series on/off when clicking on marker', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendClick(
createMouseEvent({ legendItemId: 0, isMarker: true }),
);
expect(onToggleSeriesOnOff).toHaveBeenCalledTimes(1);
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(0);
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
// The row must never isolate — that is what "Only" is for.
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
});
it('does nothing when click target is not inside a legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
it('forwards the Only and All actions to the plot', () => {
const { result } = renderHook(() => useLegendActions());
result.current.onLegendClick(createMouseEvent({}));
result.current.onShowOnlySeries(1);
result.current.onShowAllSeries();
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
expect(onShowAllSeries).toHaveBeenCalled();
});
});
describe('onFocusSeries', () => {
it('schedules focus update and calls plot focus handler via mouse move', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
describe('hover highlight', () => {
it('highlights the hovered series', () => {
const { result } = renderHook(() => useLegendActions());
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onHoverSeries(2);
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
expect(onHighlightSeries).toHaveBeenCalledWith(2);
});
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
it('clears the highlight on leave', () => {
const { result } = renderHook(() => useLegendActions());
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
result.current.onHoverSeries(null);
expect(onHighlightSeries).toHaveBeenCalledWith(null);
});
it('coalesces rapid hovers into one frame', () => {
const { result } = renderHook(() => useLegendActions());
result.current.onHoverSeries(1);
result.current.onHoverSeries(2);
// Each new hover cancels the frame the previous one queued.
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
});
});
describe('onLegendMouseMove', () => {
it('focuses new series when hovering over different legend item', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 0,
}),
);
it('cancels a pending highlight frame on unmount', () => {
jest
.spyOn(global, 'requestAnimationFrame')
.mockImplementation((): number => 7);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
const { result, unmount } = renderHook(() => useLegendActions());
result.current.onHoverSeries(1);
unmount();
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(1);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(1);
});
it('does nothing when hovering over already focused series', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: 1,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
expect(setFocusedSeriesIndexMock).not.toHaveBeenCalled();
expect(onFocusSeriesPlot).not.toHaveBeenCalled();
});
});
describe('onLegendMouseLeave', () => {
it('cancels pending animation frame and clears focus state', async () => {
const { result } = renderHook(() =>
useLegendActions({
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
focusedSeriesIndex: null,
}),
);
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
result.current.onLegendMouseLeave();
expect(cancelAnimationFrameSpy).toHaveBeenCalled();
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(null);
expect(onFocusSeriesPlot).toHaveBeenCalledWith(null);
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
});
});
});

View File

@@ -1,117 +1,54 @@
import {
Dispatch,
SetStateAction,
useCallback,
useEffect,
useRef,
} from 'react';
import { useCallback, useEffect, useRef } from 'react';
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
export function useLegendActions({
setFocusedSeriesIndex,
focusedSeriesIndex,
}: {
setFocusedSeriesIndex: Dispatch<SetStateAction<number | null>>;
focusedSeriesIndex: number | null;
}): {
onLegendClick: (e: React.MouseEvent<HTMLDivElement>) => void;
onFocusSeries: (seriesIndex: number | null) => void;
onLegendMouseMove: (e: React.MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
} {
export interface UseLegendActionsResult {
onToggleSeries: (seriesIndex: number) => void;
/** Show this series alone. */
onShowOnlySeries: (seriesIndex: number) => void;
/** Leave the narrowed selection and show every series. */
onShowAllSeries: () => void;
/** null clears the highlight. */
onHoverSeries: (seriesIndex: number | null) => void;
}
/**
* Legend interactions, bound to the plot through PlotContext. Hover is coalesced
* to one chart redraw per frame.
*/
export function useLegendActions(): UseLegendActionsResult {
const {
onFocusSeries: onFocusSeriesPlot,
onToggleSeriesOnOff,
onToggleSeriesVisibility,
onShowOnlySeries,
onShowAllSeries,
onHighlightSeries,
} = usePlotContext();
const rafId = useRef<number | null>(null); // requestAnimationFrame id
const rafIdRef = useRef<number | null>(null);
const getLegendItemIdFromEvent = useCallback(
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
const target = e.target as HTMLElement | null;
if (!target) {
return undefined;
}
const cancelPendingHighlight = useCallback((): void => {
if (rafIdRef.current != null) {
cancelAnimationFrame(rafIdRef.current);
rafIdRef.current = null;
}
}, []);
const legendItemElement = target.closest<HTMLElement>(
'[data-legend-item-id]',
);
return legendItemElement?.dataset.legendItemId;
},
[],
);
const onLegendClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
if (!legendItemId) {
return;
}
const isLegendMarker = (e.target as HTMLElement).dataset.isLegendMarker;
const seriesIndex = Number(legendItemId);
if (isLegendMarker) {
onToggleSeriesOnOff(seriesIndex);
return;
}
onToggleSeriesVisibility(seriesIndex);
},
[onToggleSeriesVisibility, onToggleSeriesOnOff, getLegendItemIdFromEvent],
);
const onFocusSeries = useCallback(
const onHoverSeries = useCallback(
(seriesIndex: number | null): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
rafId.current = requestAnimationFrame(() => {
setFocusedSeriesIndex(seriesIndex);
onFocusSeriesPlot(seriesIndex);
cancelPendingHighlight();
rafIdRef.current = requestAnimationFrame(() => {
rafIdRef.current = null;
onHighlightSeries(seriesIndex);
});
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeriesPlot],
[cancelPendingHighlight, onHighlightSeries],
);
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
const legendItemId = getLegendItemIdFromEvent(e);
const seriesIndex = legendItemId ? Number(legendItemId) : null;
if (seriesIndex === focusedSeriesIndex) {
return;
}
onFocusSeries(seriesIndex);
};
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
const onLegendMouseLeave = useCallback(
(): void => {
// Cancel any pending RAF from handleFocusSeries to prevent race condition
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
rafId.current = null;
}
setFocusedSeriesIndex(null);
onFocusSeries(null);
},
// eslint-disable-next-line react-hooks/exhaustive-deps
[onFocusSeries],
);
// Cleanup pending animation frames on unmount
useEffect(
() => (): void => {
if (rafId.current != null) {
cancelAnimationFrame(rafId.current);
}
},
[],
);
return {
onLegendClick,
onFocusSeries,
onLegendMouseMove,
onLegendMouseLeave,
onToggleSeries: onToggleSeriesOnOff,
onShowOnlySeries,
onShowAllSeries,
onHoverSeries,
};
}

View File

@@ -45,9 +45,10 @@ export default function Pie({
visibleData,
legendItems,
focusedSeriesIndex,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onToggleSeries,
onShowOnlySeries,
onShowAllSeries,
onHoverSeries,
} = usePieInteractions(data, id);
const {
@@ -227,9 +228,10 @@ export default function Pie({
position={position}
averageLegendWidth={averageLegendWidth}
focusedSeriesIndex={focusedSeriesIndex}
onClick={onLegendClick}
onMouseMove={onLegendMouseMove}
onMouseLeave={onLegendMouseLeave}
onToggleSeries={onToggleSeries}
onShowOnlySeries={onShowOnlySeries}
onShowAllSeries={onShowAllSeries}
onHoverSeries={onHoverSeries}
/>
</div>
</div>

View File

@@ -100,17 +100,29 @@ describe('Pie', () => {
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
});
it('hides a slice when its legend marker is clicked', () => {
it('isolates a slice when its legend row is clicked with everything showing', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
expect(svg.querySelectorAll('path')).toHaveLength(3);
const marker = document.querySelector(
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
) as HTMLElement;
fireEvent.click(marker);
fireEvent.click(screen.getByTestId('legend-item-1'));
// Nothing visible to exclude, so the click isolates: one arc left.
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
it('excludes a slice when its legend row is clicked with others already hidden', () => {
renderPie();
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
// Isolate, then add a second slice back, so nothing is isolated any more.
fireEvent.click(screen.getByTestId('legend-item-1'));
fireEvent.click(screen.getByTestId('legend-item-0'));
expect(svg.querySelectorAll('path')).toHaveLength(2);
fireEvent.click(screen.getByTestId('legend-item-0'));
// One slice hidden → one fewer arc drawn.
expect(svg.querySelectorAll('path')).toHaveLength(2);
expect(svg.querySelectorAll('path')).toHaveLength(1);
});
});

View File

@@ -1,6 +1,9 @@
import { LegendPosition } from 'lib/uPlotV2/components/types';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import {
calculateAverageLegendWidth,
calculateChartDimensions,
} from 'lib/visualization/charts/utils';
const labels = (count: number, length = 20): string[] =>
Array.from({ length: count }, (_, i) =>
@@ -49,63 +52,104 @@ describe('calculateChartDimensions', () => {
expect(dims.width).toBe(784);
});
it('RIGHT: never shrinks the column below the 150px floor', () => {
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(3, 3),
});
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(850);
expect(dims.legendWidth).toBe(190);
expect(dims.width).toBe(810);
});
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
const dims = calculateChartDimensions({
containerWidth: 300,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
expect(dims.legendWidth).toBe(120);
expect(dims.width).toBe(180);
// 40% is 120px, too narrow for the column's own toolbar.
expect(dims.legendWidth).toBe(150);
expect(dims.width).toBe(150);
});
it('BOTTOM: a single row of items reserves one legend row', () => {
it('RIGHT: stops widening the column once the panel is narrower than its chrome', () => {
const dims = calculateChartDimensions({
containerWidth: 200,
containerHeight: 400,
legendConfig: { position: LegendPosition.RIGHT },
seriesLabels: labels(10, 40),
});
expect(dims.legendWidth).toBe(100);
expect(dims.width).toBe(100);
});
it('BOTTOM: items that fit one row reserve exactly one row', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(3),
});
// One row = line height (28) + padding (12).
// One 28px row + the wrapper's 12px bottom padding.
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(460);
expect(dims.legendWidth).toBe(1000);
});
it('BOTTOM: many items cap at two rows on a tall container', () => {
it('BOTTOM: more items than one row reserve exactly two rows', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
expect(dims.legendHeight).toBe(68);
expect(dims.height).toBe(432);
// Two 28px rows + the 2px row gap + 12px bottom padding — no room for a
// clipped third row, and none left over.
expect(dims.legendHeight).toBe(70);
expect(dims.height).toBe(430);
});
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
it('BOTTOM: items one past a row still reserve two rows', () => {
// 1000px wide fits 5 of these per row, so 6 items need a second row.
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 160,
containerHeight: 500,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(6),
});
expect(dims.legendHeight).toBe(70);
});
it('BOTTOM: drops to a single row rather than take half a short panel', () => {
const dims = calculateChartDimensions({
containerWidth: 1000,
containerHeight: 120,
legendConfig: { position: LegendPosition.BOTTOM },
seriesLabels: labels(40),
});
// Without the height-relative cap the legend would take 68px of a 160px
// panel and the chart (pie especially) collapses to a sliver.
expect(dims.legendHeight).toBe(48); // 30% of 160
expect(dims.height).toBe(112);
// A whole row goes rather than a clipped one being reserved.
expect(dims.legendHeight).toBe(40);
expect(dims.height).toBe(80);
});
});
describe('calculateAverageLegendWidth', () => {
it('scales with the label length', () => {
// 16px of chrome + 30 chars at 8px.
expect(calculateAverageLegendWidth(labels(4, 30))).toBe(256);
});
it('never drops below what a row needs to contain its hover actions', () => {
// Short or unnamed series would otherwise size a column the actions
// escape, spilling over the item beside it.
expect(calculateAverageLegendWidth(['cpu'])).toBe(110);
expect(calculateAverageLegendWidth([''])).toBe(110);
});
it('keeps the default estimate when there are no labels to measure', () => {
expect(calculateAverageLegendWidth([])).toBe(120);
});
});

View File

@@ -1,4 +1,10 @@
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import {
LEGEND_MAX_BOTTOM_ROWS,
MIN_LEGEND_ITEM_WIDTH,
LEGEND_ROW_GAP,
LEGEND_ROW_HEIGHT,
MAX_LEGEND_WIDTH,
} from 'lib/uPlotV2/components/Legend/constants';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
export interface ChartDimensions {
width: number;
@@ -13,22 +19,31 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
const DEFAULT_AVG_LABEL_LENGTH = 15;
const BASE_LEGEND_WIDTH = 16;
const LEGEND_PADDING = 12;
const LEGEND_LINE_HEIGHT = 28;
// Two rows are worth having, but not at the cost of half the panel.
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
// RIGHT legend is a vertical column with its own width budget (cap protects the donut).
const MAX_RIGHT_LEGEND_WIDTH = 320;
const RIGHT_LEGEND_WIDTH_RATIO = 0.4;
// Column padding + copy button, not covered by the text-length estimate.
const RIGHT_LEGEND_RESERVED_WIDTH = 40;
// Fits the toolbar's "Showing N of M series" readout plus the wrapper padding.
const MIN_RIGHT_LEGEND_WIDTH = 190;
// Past this the split inverts and the chart becomes the smaller half.
const RIGHT_LEGEND_FLOOR_RATIO = 0.5;
/**
* Calculates the average width of the legend items based on the labels of the series.
* Never returns less than a legend row needs to hold its own hover actions.
* @param legends - The labels of the series.
* @returns The average width of the legend items.
*/
export function calculateAverageLegendWidth(legends: string[]): number {
if (legends.length === 0) {
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
return Math.max(
MIN_LEGEND_ITEM_WIDTH,
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
);
}
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
@@ -36,7 +51,10 @@ export function calculateAverageLegendWidth(legends: string[]): number {
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
const percentileLength = lengths[Math.max(0, index)];
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
return Math.max(
MIN_LEGEND_ITEM_WIDTH,
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
);
}
/**
@@ -52,7 +70,9 @@ export function calculateAverageLegendWidth(legends: string[]): number {
* - Chart width is `containerWidth - legendWidth`.
* - BOTTOM legend:
* - Computes how many items fit per row, then uses at most 2 rows.
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
* - `legendHeight` is exactly those rows plus the wrapper's bottom padding, so
* the rectangle never clips a row or reserves space for half of one. Two
* rows that would take half a short panel fall back to one row.
* - Chart height is `containerHeight - legendHeight`, never below 0.
* - `legendsPerSet` is the number of legend items that fit horizontally, based on the same text-width approximation.
*
@@ -100,9 +120,14 @@ export function calculateChartDimensions({
MAX_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_WIDTH_RATIO,
);
// The column's chrome outranks the 40% share on a narrow panel.
const floorWidth = Math.min(
MIN_RIGHT_LEGEND_WIDTH,
containerWidth * RIGHT_LEGEND_FLOOR_RATIO,
);
const rightLegendWidth = Math.min(
Math.max(150, desiredLegendWidth),
maxRightLegendWidth,
Math.max(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
Math.max(floorWidth, maxRightLegendWidth),
);
return {
@@ -115,8 +140,6 @@ export function calculateChartDimensions({
};
}
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
const legendItemWidth = Math.ceil(
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
);
@@ -125,30 +148,30 @@ export function calculateChartDimensions({
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
);
const legendRowCount = Math.min(
2,
Math.ceil(legendItemCount / legendItemsPerRow),
// The wrapper's bottom padding is inside this height (border-box).
const heightForRows = (rowCount: number): number =>
rowCount * LEGEND_ROW_HEIGHT +
(rowCount - 1) * LEGEND_ROW_GAP +
LEGEND_PADDING;
const neededRowCount = Math.max(
1,
Math.min(
LEGEND_MAX_BOTTOM_ROWS,
Math.ceil(legendItemCount / legendItemsPerRow),
),
);
const idealBottomLegendHeight =
legendRowCount > 1
? legendRowCount * legendRowHeight - LEGEND_PADDING
: legendRowHeight;
// Without this, short grid panels hand most of their area to the legend and
// the chart — the pie donut especially — collapses to a sliver. Dropping a
// whole row beats clipping one.
const legendRowCount =
neededRowCount > 1 &&
heightForRows(neededRowCount) > containerHeight * MAX_SHORT_PANEL_LEGEND_RATIO
? 1
: neededRowCount;
// Cap at two rows / 80px, and never more than 30% of the container height
// (the doc above always promised the %-cap; without it, short grid panels
// hand most of their area to the legend and the chart — the pie donut
// especially — collapses to a sliver). 30% mirrors the RIGHT-legend width cap.
const maxAllowedLegendHeight = Math.min(
2 * legendRowHeight,
80,
Math.floor(containerHeight * 0.3),
);
const bottomLegendHeight = Math.min(
idealBottomLegendHeight,
maxAllowedLegendHeight,
);
const bottomLegendHeight = heightForRows(legendRowCount);
return {
width: containerWidth,

View File

@@ -3,7 +3,6 @@ import {
getStoredSeriesVisibility,
updateSeriesVisibilityToLocalStorage,
} from 'lib/visualization/panels/utils/legendVisibilityUtils';
import type { MouseEvent } from 'react';
import { PieSlice } from 'lib/visualization/charts/types';
import { usePieInteractions } from 'lib/visualization/hooks/usePieInteractions';
@@ -24,22 +23,6 @@ const DATA: PieSlice[] = [
{ label: 'checkout', value: 40, color: '#c' },
];
// Builds a fake legend click/move event: `e.target.closest('[data-legend-item-id]')`
// resolves to the item at `index`, and `e.target.dataset.isLegendMarker` flags marker clicks.
function legendEvent(
index: number | null,
isMarker = false,
): MouseEvent<HTMLDivElement> {
const itemEl =
index == null ? null : { dataset: { legendItemId: String(index) } };
return {
target: {
closest: (): unknown => itemEl,
dataset: { isLegendMarker: isMarker ? 'true' : undefined },
},
} as unknown as MouseEvent<HTMLDivElement>;
}
describe('usePieInteractions', () => {
beforeEach(() => {
mockGetStored.mockReturnValue(null);
@@ -59,11 +42,11 @@ describe('usePieInteractions', () => {
expect(result.current.active).toBeNull();
});
describe('marker click (toggle one)', () => {
describe('row toggle', () => {
it('hides then unhides the clicked slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
act(() => result.current.onLegendClick(legendEvent(1, true)));
act(() => result.current.onToggleSeries(1));
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
expect(result.current.legendItems[1].show).toBe(false);
@@ -73,18 +56,30 @@ describe('usePieInteractions', () => {
{ label: 'checkout', show: true },
]);
act(() => result.current.onLegendClick(legendEvent(1, true)));
act(() => result.current.onToggleSeries(1));
expect(result.current.visibleData).toStrictEqual(DATA);
expect(result.current.legendItems[1].show).toBe(true);
});
});
describe('label click (isolate / reset)', () => {
it('isolates the clicked slice, then resets on a second click', () => {
describe('the last slice showing', () => {
it('cannot be hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, false)));
act(() => result.current.onShowOnlySeries(0));
act(() => result.current.onToggleSeries(0));
// An empty donut is never a state worth reaching.
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
});
});
describe('Only', () => {
it('isolates the slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onShowOnlySeries(0));
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
@@ -92,8 +87,24 @@ describe('usePieInteractions', () => {
false,
false,
]);
});
act(() => result.current.onLegendClick(legendEvent(0, false)));
it('switches the isolation to another slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onShowOnlySeries(0));
act(() => result.current.onShowOnlySeries(2));
expect(result.current.visibleData).toStrictEqual([DATA[2]]);
});
});
describe('All', () => {
it('brings every hidden slice back', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onShowOnlySeries(0));
act(() => result.current.onShowAllSeries());
expect(result.current.visibleData).toStrictEqual(DATA);
});
@@ -103,11 +114,23 @@ describe('usePieInteractions', () => {
it('focuses the hovered slice and clears on leave', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendMouseMove(legendEvent(2)));
act(() => result.current.onHoverSeries(2));
expect(result.current.active).toStrictEqual(DATA[2]);
expect(result.current.focusedSeriesIndex).toBe(2);
act(() => result.current.onLegendMouseLeave());
act(() => result.current.onHoverSeries(null));
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
it('drops the focus when the focused slice is hidden', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onHoverSeries(1));
act(() => result.current.onToggleSeries(1));
// Otherwise every remaining arc stays dimmed and the donut reads as an
// isolation instead of one slice being excluded.
expect(result.current.active).toBeNull();
expect(result.current.focusedSeriesIndex).toBeNull();
});
@@ -115,8 +138,8 @@ describe('usePieInteractions', () => {
it('does not focus a hidden slice', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
act(() => result.current.onLegendMouseMove(legendEvent(1)));
act(() => result.current.onToggleSeries(1));
act(() => result.current.onHoverSeries(1));
expect(result.current.active).toBeNull();
});
@@ -125,7 +148,7 @@ describe('usePieInteractions', () => {
describe('persistence', () => {
it('does not write to storage when no id is provided', () => {
const { result } = renderHook(() => usePieInteractions(DATA));
act(() => result.current.onLegendClick(legendEvent(0, true)));
act(() => result.current.onToggleSeries(0));
expect(mockUpdateStored).not.toHaveBeenCalled();
});

View File

@@ -1,6 +1,6 @@
import { LegendItem } from 'lib/uPlotV2/config/types';
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import type { Dispatch, SetStateAction } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import {
getStoredSeriesVisibility,
@@ -18,27 +18,17 @@ export interface UsePieInteractionsResult {
legendItems: LegendItem[];
/** Index of the active slice for the legend's focus highlight, or null. */
focusedSeriesIndex: number | null;
onLegendClick: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseMove: (e: MouseEvent<HTMLDivElement>) => void;
onLegendMouseLeave: () => void;
}
// Reads the slice index off the nearest `[data-legend-item-id]` ancestor of the
// event target (the shared Legend tags each item with its seriesIndex).
function getLegendIndex(e: MouseEvent<HTMLDivElement>): number | null {
const el = (e.target as HTMLElement | null)?.closest<HTMLElement>(
'[data-legend-item-id]',
);
const id = el?.dataset.legendItemId;
return id != null ? Number(id) : null;
onToggleSeries: (sliceIndex: number) => void;
onShowOnlySeries: (sliceIndex: number) => void;
onShowAllSeries: () => void;
onHoverSeries: (sliceIndex: number | null) => void;
}
/**
* Pie interaction + derived state: hover/focus, slice hide/unhide (mirroring the
* uPlot legend — marker toggles one, label isolates), and persistence of the
* hidden set to localStorage (keyed by `id`, matched by label) so it survives
* reloads. Returns the visible slices, legend items, focus index, and the
* legend container handlers.
* Pie interaction + derived state: hover/focus, slice hide/show driven by the
* shared legend's actions, and persistence of the hidden set to localStorage
* (keyed by `id`, matched by label) so it survives reloads. Returns the visible
* slices, legend items, focus index, and the legend handlers.
*/
export function usePieInteractions(
data: PieSlice[],
@@ -48,7 +38,6 @@ export function usePieInteractions(
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
() => new Set(),
);
const isolatedIndexRef = useRef<number | null>(null);
const legendItems = useMemo<LegendItem[]>(
() =>
@@ -104,65 +93,69 @@ export function usePieInteractions(
[id, data],
);
const onLegendMouseMove = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
const onHoverSeries = useCallback(
(sliceIndex: number | null): void => {
// Don't focus/dim for hidden slices — they aren't on the donut.
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
setActive(
sliceIndex != null && !hiddenIndices.has(sliceIndex)
? data[sliceIndex]
: null,
);
},
[data, hiddenIndices],
);
// Marker click toggles just that slice on/off; label click isolates it
// (clicking the isolated one again resets to all) — mirrors the uPlot legend.
const onLegendClick = useCallback(
(e: MouseEvent<HTMLDivElement>): void => {
const index = getLegendIndex(e);
if (index == null) {
return;
}
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
if (isMarker) {
const next = new Set(hiddenIndices);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
const onToggleSeries = useCallback(
(sliceIndex: number): void => {
const next = new Set(hiddenIndices);
if (next.has(sliceIndex)) {
next.delete(sliceIndex);
} else {
// An empty donut is never worth reaching.
if (data.length - next.size <= 1) {
return;
}
applyHidden(next);
return;
next.add(sliceIndex);
}
applyHidden(next);
},
[data.length, hiddenIndices, applyHidden],
);
const isReset = isolatedIndexRef.current === index;
isolatedIndexRef.current = isReset ? null : index;
if (isReset) {
applyHidden(new Set());
return;
}
const onShowOnlySeries = useCallback(
(sliceIndex: number): void => {
const next = new Set<number>();
data.forEach((_, i) => {
if (i !== index) {
next.add(i);
data.forEach((_, index) => {
if (index !== sliceIndex) {
next.add(index);
}
});
applyHidden(next);
},
[data, hiddenIndices, applyHidden],
[data, applyHidden],
);
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
const onShowAllSeries = useCallback(
(): void => applyHidden(new Set()),
[applyHidden],
);
const focusedIndex = active ? data.indexOf(active) : -1;
const activeIndex = active ? data.indexOf(active) : -1;
// Left active, a hidden slice keeps every other arc dimmed, which reads as an
// isolation rather than as one slice being excluded.
const effectiveActive =
activeIndex >= 0 && !hiddenIndices.has(activeIndex) ? active : null;
const focusedIndex = effectiveActive ? activeIndex : -1;
return {
active,
active: effectiveActive,
setActive,
visibleData,
legendItems,
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
onLegendClick,
onLegendMouseMove,
onLegendMouseLeave,
onToggleSeries,
onShowOnlySeries,
onShowAllSeries,
onHoverSeries,
};
}

View File

@@ -29,7 +29,6 @@
box-sizing: border-box;
min-height: 0;
overflow: hidden;
padding-left: 12px;
padding-bottom: 12px;
padding: 0 12px 12px 12px;
}
}

View File

@@ -1,7 +1,7 @@
import { useMemo } from 'react';
import cx from 'classnames';
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/constants';
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';

View File

@@ -5,7 +5,6 @@ import type {
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { resolveSignal } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import type { EQueryType } from 'types/common/dashboard';
@@ -68,7 +67,7 @@ function ConfigPane({
const definition = getPanelDefinition(panelKind);
const sections = definition.sections;
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[0]);
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
// Title/description are just a slice of the spec — edit them through the same
// onChangeSpec path the sections use, so there's a single editing surface.

View File

@@ -44,7 +44,6 @@ describe('PanelTypeSwitcher', () => {
// List supports only logs/traces; every other kind also supports metrics.
// Query-type support comes from SUPPORTED_QUERY_TYPES (all three by default).
mockGetPanelDefinition.mockImplementation((kind: string) => ({
mode: 'query',
supportedSignals:
kind === 'signoz/ListPanel'
? ['logs', 'traces']

View File

@@ -8,7 +8,6 @@ import VisualizationSection from '../VisualizationSection';
// the test doesn't pull the whole panel registry (renderers, chart libs).
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => ({
getPanelDefinition: jest.fn(() => ({
mode: 'query',
supportedSignals: ['metrics', 'logs', 'traces'],
supportedQueryTypes: ['builder', 'clickhouse_sql', 'promql'],
})),

View File

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

View File

@@ -1,116 +0,0 @@
import type { ReactNode } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import layoutStorage from '../layoutStorage';
import styles from '../PanelEditor.module.scss';
/** A resizable pane's bounds, in the percentage strings `ResizablePanel` takes. */
interface PaneSize {
minSize: string;
maxSize: string;
defaultSize: string;
}
/** How the left column divides between the preview and the editor pane. */
export interface PaneSplit {
preview: PaneSize;
editor: PaneSize;
}
/**
* Vertical split per authoring mode. The query builder is a compact form, so the
* preview keeps the room; a static kind's editor pane is the surface being worked
* in, so it gets more and can grow further.
*/
export const PANE_SPLIT = {
query: {
preview: { minSize: '55%', maxSize: '65%', defaultSize: '60%' },
editor: { minSize: '35%', maxSize: '45%', defaultSize: '40%' },
},
static: {
preview: { minSize: '40%', maxSize: '65%', defaultSize: '55%' },
editor: { minSize: '35%', maxSize: '60%', defaultSize: '45%' },
},
} as const satisfies Record<string, PaneSplit>;
interface PanelEditorLayoutProps {
/** Save/close chrome — its affordances differ per authoring mode. */
header: ReactNode;
/** Upper-left: what the panel will look like once saved. */
preview: ReactNode;
/** Lower-left: the kind's `EditorPane` — the query builder, or a static kind's own. */
editor: ReactNode;
/** Right column: the kind's config sections. */
config: ReactNode;
split: PaneSplit;
}
/**
* The panel editor's frame: header, the resizable three-pane arrangement, and the
* persistence of what the user drags. Owned once so both authoring modes cannot
* drift apart on pane bounds or share a layout id by accident — they differ only in
* `split` and in what fills the slots.
*/
function PanelEditorLayout({
header,
preview,
editor,
config,
split,
}: PanelEditorLayoutProps): JSX.Element {
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
return (
<div className={styles.page} data-testid="panel-editor-v2">
{header}
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel {...split.preview}>{preview}</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel {...split.editor}>{editor}</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
{config}
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}
export default PanelEditorLayout;

View File

@@ -21,15 +21,20 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { useIsDarkMode } from 'hooks/useDarkMode';
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 {
getHiddenQueryBuilderFields,
getSupportedQueryTypes,
} from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from '../../Panels/types/panelKind';
import styles from './PanelEditorQueryBuilder.module.scss';
interface PanelEditorQueryBuilderProps {
/** The edited kind's definition — drives supported query types + field visibility. */
panelDefinition: RenderableQueryPanelDefinition;
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
panelKind: PanelKind;
/** The panel's current signal; selects per-signal query-builder field rules. */
signal: TelemetrytypesSignalDTO;
/** Preview fetch in flight — drives the Stage & Run button's loading/cancel state. */
@@ -50,7 +55,7 @@ interface PanelEditorQueryBuilderProps {
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
*/
function PanelEditorQueryBuilder({
panelDefinition,
panelKind,
signal,
isLoadingQueries,
onStageRunQuery,
@@ -60,10 +65,10 @@ 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 = PANEL_KIND_TO_PANEL_TYPE[panelKind];
// 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';
const isListViewPanel = panelKind === 'signoz/ListPanel';
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
const isDarkMode = useIsDarkMode();
@@ -94,12 +99,12 @@ function PanelEditorQueryBuilder({
// Per-kind query-builder field rules from the guard (e.g. List hides step interval
// and having), passed to QueryBuilderV2 as its `filterConfigs`.
const filterConfigs: QueryBuilderProps['filterConfigs'] = useMemo(
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
[panelDefinition.queryBuilderFields, signal],
() => getHiddenQueryBuilderFields(panelKind, signal),
[panelKind, signal],
);
const items = useMemo(() => {
const { supportedQueryTypes } = panelDefinition;
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
const queryTypeComponents = {
[EQueryType.QUERY_BUILDER]: {
@@ -146,7 +151,7 @@ function PanelEditorQueryBuilder({
),
children: queryTypeComponents[queryType].component,
}));
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
return (
<div

View File

@@ -1,28 +0,0 @@
import type { QueryEditorPaneProps } from '../../Panels/types/panelDefinition';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder';
/**
* The default query-kind editor pane: the query-builder tabs with no extras. A
* kind that needs more (e.g. List's columns editor) declares its own wrapper.
*/
function QueryBuilderEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
/>
);
}
export default QueryBuilderEditorPane;

View File

@@ -4,9 +4,6 @@ import { OPERATORS } from 'constants/queryBuilder';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { EQueryType } from 'types/common/dashboard';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder';
// Capture the props the (real-guard-fed) QueryBuilderV2 receives without rendering it.
@@ -51,7 +48,7 @@ function renderBuilder(
): void {
render(
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
panelKind={panelKind as never}
signal={signal}
isLoadingQueries={false}
onStageRunQuery={jest.fn()}

View File

@@ -6,7 +6,7 @@ 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 type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { RenderablePanelDefinition } 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 type {
@@ -20,8 +20,8 @@ import styles from './PreviewPane.module.scss';
interface PreviewPaneProps {
panelId: string;
panel: DashboardtypesPanelDTO;
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
panelDefinition: RenderableQueryPanelDefinition;
/** Resolved definition for the panel kind; */
panelDefinition: RenderablePanelDefinition;
data: PanelQueryData;
/** Any fetch in flight — drives the header spinner and the body's loading state. */
isFetching: boolean;
@@ -107,7 +107,7 @@ function PreviewPane({
hideActions
/>
<PanelBody
Renderer={panelDefinition.Renderer}
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}

View File

@@ -1,332 +0,0 @@
import { useCallback, useMemo } from 'react';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
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 {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface QueryEditorBodyProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
/**
* The persisted panel the dirty check compares against. Distinct from `panel` (the
* seed), which may carry unsaved edits handed off from View mode. Omit for a new panel.
*/
savedPanel?: DashboardtypesPanelDTO;
/** Creating a new panel (seeded default) vs editing an existing one. */
isNew?: boolean;
/** Target section for a new panel; falls back to the last/new section. */
layoutIndex?: number;
/** Leave the editor (navigate back to the dashboard) without saving. */
onClose: () => void;
/** Called after a successful save — navigates back to the dashboard. */
onSaved: () => void;
/** Draft state, owned by the shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
/** The draft kind's definition, narrowed by the shell's fork. */
panelDefinition: RenderableQueryPanelDefinition;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The query-kind editor body: a resizable split with the live preview + the
* kind's editor pane on the left and the config pane on the right. Draft and
* kind-switch state live in the shell; this body owns the query session and the
* save round-trip.
*/
function QueryEditorBody({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: QueryEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
draftApi,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const panelKind = draft.spec.plugin.kind;
// The kind's own lower pane (query builder, plus e.g. List's columns footer).
const { EditorPane } = panelDefinition;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<PanelEditorLayout
split={PANE_SPLIT.query}
header={
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyChecks={editChecks}
readOnlyTooltip={editDisabledTooltip}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
}
preview={
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
}
editor={
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<EditorPane
panelDefinition={panelDefinition}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
spec={spec}
onChangeSpec={setSpec}
/>
</ConfigProvider>
}
config={
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
}
/>
);
}
export default QueryEditorBody;

View File

@@ -1,140 +0,0 @@
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';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import PanelEditorLayout, {
PANE_SPLIT,
} from './PanelEditorLayout/PanelEditorLayout';
import type { PanelEditorContainerProps } from './index';
import type { PanelEditorDraftApi } from './types';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import styles from './PanelEditor.module.scss';
interface StaticEditorBodyProps extends PanelEditorContainerProps {
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* Editor body for a kind that renders from its own plugin spec: the kind's
* editor pane under a live preview of the draft, the config pane on the right.
* No query session, no builder seeding, no staged-run — the preview re-renders
* from the draft spec on every edit.
*/
function StaticEditorBody({
dashboardId,
panelId,
isNew = false,
layoutIndex,
onClose,
onSaved,
draftApi,
panelDefinition,
onChangePanelKind,
}: StaticEditorBodyProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const { draft, spec, setSpec, isSpecDirty } = draftApi;
const { EditorPane, Renderer } = panelDefinition;
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// `queries: []` is the only shape the API accepts for a static kind.
const savedPanelId = await save({ ...draft.spec, queries: [] });
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [isEditable, save, draft.spec, setScrollTargetId, onSaved, showErrorModal]);
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
return (
<PanelEditorLayout
split={PANE_SPLIT.static}
header={
<Header
isDirty={isSpecDirty}
isSaving={isSaving}
showSwitchToView={false}
readOnly={!isEditable}
readOnlyChecks={editChecks}
readOnlyTooltip={editDisabledTooltip}
onSave={onSave}
onClose={onCloseEditor}
/>
}
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>
}
editor={<EditorPane spec={spec} onChangeSpec={setSpec} />}
config={
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={EQueryType.QUERY_BUILDER}
legendSeries={[]}
tableColumns={[]}
/>
}
/>
);
}
export default StaticEditorBody;

View File

@@ -3,7 +3,6 @@ import userEvent from '@testing-library/user-event';
import { toast } from '@signozhq/ui/sonner';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { getSupportedSignals } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import PanelEditorContainer from '../index';
@@ -251,12 +250,7 @@ describe('PanelEditorContainer composition', () => {
}),
);
expect(mockQbProps).toHaveBeenCalledWith(
expect.objectContaining({
panelDefinition: expect.objectContaining({
kind: 'signoz/TimeSeriesPanel',
mode: 'query',
}),
}),
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
);
expect(mockConfigProps).toHaveBeenCalledWith(
expect.objectContaining({
@@ -280,7 +274,7 @@ describe('PanelEditorContainer composition', () => {
setSpec: mockSetSpec,
refetch: mockRefetch,
alwaysSerializeQuery: false,
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
}),
);
expect(mockUseTypeSwitch).toHaveBeenCalledWith(

View File

@@ -18,8 +18,6 @@ import appStore from 'store';
import { useOpenPanelEditor } from '../../hooks/useOpenPanelEditor';
import { usePanelEditorQuerySync } from '../hooks/usePanelEditorQuerySync';
import { requireQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/capabilities';
import PanelEditorQueryBuilder from '../PanelEditorQueryBuilder/PanelEditorQueryBuilder';
// jest.config maps the real hook to a no-op mock; this suite needs real navigation.
@@ -85,7 +83,7 @@ function EditorRoute(): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
panelKind="signoz/TimeSeriesPanel"
signal={TelemetrytypesSignalDTO.metrics}
isLoadingQueries={false}
onStageRunQuery={noop}

View File

@@ -19,10 +19,6 @@ jest.mock('lib/query/panelQuery', () => ({
}));
jest.mock('../../../Panels/capabilities', () => ({
resolveQueryType: jest.fn(),
// Real predicate: these specs use real (query) kinds and the static path is
// exercised through its own cases below.
isStaticPanelKind: jest.requireActual('../../../Panels/capabilities')
.isStaticPanelKind,
}));
jest.mock('../../../queryV5/persesQueryAdapters', () => ({
toPerses: jest.fn(),

View File

@@ -4,19 +4,24 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
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 {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
usePanelQuery,
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { PanelEditorDraftApi } from '../types';
import { usePanelEditorDraft } from './usePanelEditorDraft';
import { usePanelEditorQuerySync } from './usePanelEditorQuerySync';
import { usePanelTypeSwitch } from './usePanelTypeSwitch';
interface UsePanelEditSessionArgs {
panel: DashboardtypesPanelDTO;
@@ -33,12 +38,6 @@ interface UsePanelEditSessionArgs {
alwaysSerializeQuery?: boolean;
/** Seed an empty builder with the kind's default signal (new panels) — off for drilldown. */
seedQuerySignal?: boolean;
/**
* Externally-owned draft. The editor shell hoists it above its mode fork so a
* kind switch across modes survives the branch swap; hosts without a fork (the
* View modal, until it forks) omit it and the session owns the draft.
*/
draftApi?: PanelEditorDraftApi;
}
export interface UsePanelEditSessionReturn {
@@ -51,7 +50,7 @@ export interface UsePanelEditSessionReturn {
reset: () => void;
/** Draft kind → V1 panel type (drives the query builder + preview). */
panelType: PANEL_TYPES;
panelDefinition: RenderableQueryPanelDefinition;
panelDefinition: RenderablePanelDefinition;
/** The kind's first supported signal — seeds new queries/columns. */
defaultSignal: TelemetrytypesSignalDTO;
/** Shared query result for the draft over the resolved time window. */
@@ -63,6 +62,8 @@ export interface UsePanelEditSessionReturn {
buildSaveSpec: (
spec: DashboardtypesPanelSpecDTO,
) => DashboardtypesPanelSpecDTO;
/** Switch the draft's visualization kind in place (reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
@@ -79,17 +80,14 @@ export function usePanelEditSession({
time,
alwaysSerializeQuery = false,
seedQuerySignal = false,
draftApi,
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
// Called unconditionally (hooks rules); unused when a hoisted draft is passed in.
const internalDraftApi = usePanelEditorDraft(panel, savedPanel);
const { draft, spec, setSpec, isSpecDirty, reset } =
draftApi ?? internalDraftApi;
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
panel,
savedPanel,
);
const panelKind = draft.spec.plugin.kind;
// Hosts fork on `definition.mode` before mounting this session (the editor and
// View modal shells) — asserted rather than assumed.
const panelDefinition = requireQueryPanelDefinition(panelKind);
const panelDefinition = getPanelDefinition(panelKind);
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
const defaultSignal = panelDefinition.supportedSignals[0];
@@ -111,6 +109,12 @@ export function usePanelEditSession({
savedQueries: savedPanel?.spec.queries,
});
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draft.spec,
panelType,
setSpec,
});
return {
draft,
spec,
@@ -124,5 +128,6 @@ export function usePanelEditSession({
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
};
}

View File

@@ -18,7 +18,7 @@ import type {
Query,
} from 'types/api/queryBuilder/queryBuilderData';
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
import { resolveQueryType } from '../../Panels/capabilities';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
@@ -128,25 +128,11 @@ export function usePanelTypeSwitch({
queries,
});
// Revisit → restore the stash verbatim (the reversibility path). A static
// kind's stash carries `queries: []` and its builder query is untouched —
// there is no builder to re-seed for it.
// Revisit → restore the stash verbatim (the reversibility path).
const cached = cacheRef.current.get(newKind);
if (cached) {
setSpec(buildSpec(cached.pluginSpec, cached.queries));
if (!isStaticPanelKind(newKind)) {
redirectWithQueryBuilderData(cached.builderQuery);
}
return;
}
// First visit to a static kind → fresh spec from its sections, queries
// emptied (the API accepts nothing else), and the query builder left as-is:
// the stash above keeps the old kind's query for the return trip.
if (isStaticPanelKind(newKind)) {
const signal = getBuilderQueries(currentSpec.queries)[0]
?.signal as TelemetrytypesSignalDTO;
setSpec(buildSpec(getSwitchedPluginSpec(currentSpec, newKind, signal), []));
redirectWithQueryBuilderData(cached.builderQuery);
return;
}

View File

@@ -1,13 +1,57 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import { useCallback, useMemo } from 'react';
import {
ResizableHandle,
ResizablePanel,
ResizablePanelGroup,
useDefaultLayout,
} from '@signozhq/ui/resizable';
import { toast } from '@signozhq/ui/sonner';
import { ConfigProvider } from 'antd';
import {
type DashboardtypesPanelDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import {
type SectionConfig,
type SectionControls,
SectionKind,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/sections';
import { getBuilderQueries } from 'pages/DashboardPage/DashboardContainer/Panels/utils/getBuilderQueries';
import { useErrorModal } from 'providers/ErrorModalProvider';
import QueryEditorBody from './QueryEditorBody';
import StaticEditorBody from './StaticEditorBody';
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
import { useDashboardEditContext } from '../hooks/useDashboardEditContext';
import { getExecStats } from '../queryV5/v5ResponseData';
import { usePanelInteractions } from '../PanelsAndSectionsLayout/Panel/hooks/usePanelInteractions';
import { useScrollIntoViewStore } from '../store/useScrollIntoViewStore';
import ConfigPane from './ConfigPane/ConfigPane';
import Header from './Header/Header';
import layoutStorage from './layoutStorage';
import PanelEditorQueryBuilder from './PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from './PreviewPane/PreviewPane';
import { useLegendSeries } from './hooks/useLegendSeries';
import { usePanelEditSession } from './hooks/usePanelEditSession';
import { usePanelEditorSave } from './hooks/usePanelEditorSave';
import { useSeedMetricUnit } from './hooks/useSeedMetricUnit';
import { useSeedNewListColumns } from './hooks/useSeedNewListColumns';
import { useSwitchColumnsOnSignalChange } from './hooks/useSwitchColumnsOnSignalChange';
import { useSwitchToViewMode } from './hooks/useSwitchToViewMode';
import { useTableColumns } from './hooks/useTableColumns';
import ListColumnsEditor from './ListColumnsEditor/ListColumnsEditor';
export interface PanelEditorContainerProps {
import styles from './PanelEditor.module.scss';
import logEvent from '@/api/common/logEvent';
import { DashboardEvents } from '../../constants/events';
// The query builder sits in an `overflow:hidden` resizable pane, so its Select
// popups (group-by, order-by, having, …) clip when they open into the short pane.
// Portal them to the document body; the query-builder filters honor this via
// `useSelectPopupContainer`. Scoped to the full-page editor — the View modal keeps
// its own `ConfigProvider` so popups stay inside the focus-trapped dialog.
const getBodyPopupContainer = (): HTMLElement => document.body;
interface PanelEditorContainerProps {
dashboardId: string;
panelId: string;
panel: DashboardtypesPanelDTO;
@@ -27,42 +71,297 @@ export interface PanelEditorContainerProps {
}
/**
* V2 panel editor page shell. Owns exactly the state that must survive a switch
* between authoring modes — the draft and the kind-switch cache — and forks on
* the draft kind's `mode`: query kinds get the session-backed body, static kinds
* an editor pane over a live preview with no query machinery at all.
* V2 panel editor page body: a resizable split with the live preview + query
* builder on the left and the config pane on the right. Owns the draft state and
* the save round-trip.
*/
function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
const { panel, savedPanel } = props;
const draftApi = usePanelEditorDraft(panel, savedPanel);
function PanelEditorContainer({
dashboardId,
panelId,
panel,
savedPanel,
isNew = false,
layoutIndex,
onClose,
onSaved,
}: PanelEditorContainerProps): JSX.Element {
// Read here rather than taken as props: this renders inside a loaded dashboard
// subtree, so it resolves the same context every other consumer does.
const { isEditable, editChecks, editDisabledTooltip } =
useDashboardEditContext();
const panelKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
// Shared editing pipeline (draft + query + staged-query sync + kind switch). A new
// panel always serializes its seed query and seeds the builder's default signal.
const {
draft,
spec,
setSpec,
isSpecDirty,
panelDefinition,
query,
runQuery,
isQueryDirty,
buildSaveSpec,
onChangePanelKind,
} = usePanelEditSession({
panel,
panelId,
savedPanel,
alwaysSerializeQuery: isNew,
seedQuerySignal: true,
});
const {
data,
isFetching,
isPreviousData,
error,
cancelQuery,
refetch,
pagination,
} = query;
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
setSpec: draftApi.setSpec,
// Live query type (the selected tab) — the type switcher disables kinds that can't be
// authored in it. Read from the provider, not the spec: a new panel's spec carries no
// query until staged, so the spec would lag the tab.
const { currentQuery } = useQueryBuilder();
const { save, isSaving } = usePanelEditorSave({
dashboardId,
panelId,
isNew,
layoutIndex,
});
const { defaultLayout, onLayoutChanged } = useDefaultLayout({
id: 'panel-editor-v2',
storage: layoutStorage,
});
if (panelDefinition.mode === 'static') {
return (
<StaticEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
const {
defaultLayout: mainDefaultLayout,
onLayoutChanged: onMainLayoutChanged,
} = useDefaultLayout({
id: 'panel-editor-v2-main',
storage: layoutStorage,
});
const panelKind = draft.spec.plugin.kind;
// The current kind's Formatting controls — which unit field (panel-wide `unit` vs
// per-column `columnUnits`) a metric unit may seed into. Same source of truth the
// switch-time seeding in `buildPluginSpec` reads, so the two stay in lockstep.
const formattingControls = useMemo(():
| SectionControls[SectionKind.Formatting]
| undefined => {
const section = panelDefinition.sections.find(
(
candidate,
): candidate is Extract<SectionConfig, { kind: SectionKind.Formatting }> =>
candidate.kind === SectionKind.Formatting,
);
}
return section?.controls;
}, [panelDefinition]);
// Unsaved-edits flag driving the discard confirmation on close (Save is always
// enabled). Read the seed `panel`, not the live `draft` — the staged-query sync
// commits the seed into the draft on open, which would falsely dirty an untouched
// query-less new panel.
const isDirty = useMemo(
() => isSpecDirty || isQueryDirty || (isNew && panel.spec.queries.length > 0),
[isSpecDirty, isQueryDirty, isNew, panel.spec.queries.length],
);
const isListPanel = panelKind === 'signoz/ListPanel';
// The builder-query `signal` literal matches the TelemetrytypesSignalDTO enum
// values; cast at this boundary (as ConfigPane does) so the columns editor's
// field-key lookup is typed.
const listSignal =
(getBuilderQueries(spec.queries)[0]?.signal as TelemetrytypesSignalDTO) ||
TelemetrytypesSignalDTO.logs;
// Swap the List panel's columns to the new signal's defaults on signal change
// (V1 had a per-signal field list; V2 has one `selectFields`).
useSwitchColumnsOnSignalChange({
enabled: isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Seed a new List panel's columns from the query's resolved signal (not the kind's
// default logs signal) so a traces-List export gets traces columns, not logs.
useSeedNewListColumns({
enabled: isNew && isListPanel,
signal: listSignal,
spec,
onChangeSpec: setSpec,
});
// Drag-to-zoom on the preview updates the URL-synced time window, as on the dashboard.
const { onDragSelect } = usePanelInteractions();
const legendSeries = useLegendSeries(draft, data);
const tableColumns = useTableColumns(draft, data);
// Resolves the selected metric's unit and, on a new panel, seeds it into the right
// formatting field for the kind (panel-wide `unit`, or per-column `columnUnits` for
// a Table once results resolve them). `metricUnit` also drives the mismatch warning.
const { metricUnit } = useSeedMetricUnit({
isNewPanel: isNew,
formattingControls,
columns: tableColumns,
spec,
onChangeSpec: setSpec,
});
// Smallest query step interval (seconds) — the floor for the span-gaps
// threshold. Undefined until results carry step metadata.
const stepInterval = useMemo((): number | undefined => {
const intervals = getExecStats(data.response)?.stepIntervals;
const values = intervals ? Object.values(intervals) : [];
return values.length ? Math.min(...values) : undefined;
}, [data.response]);
const onSwitchToView = useSwitchToViewMode({
dashboardId,
panelId,
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
query: currentQuery,
spec: draft.spec,
});
const setScrollTargetId = useScrollIntoViewStore((s) => s.setScrollTargetId);
const { showErrorModal } = useErrorModal();
const onSave = useCallback(async (): Promise<void> => {
if (!isEditable) {
return;
}
try {
// Bake the live query into the spec so unstaged edits are saved too.
const savedPanelId = await save(buildSaveSpec(draft.spec));
// Reveal the saved panel once the dashboard re-renders.
setScrollTargetId(savedPanelId);
toast.success('Panel saved', {
position: 'top-center',
});
onSaved();
} catch (err) {
showErrorModal(err);
}
}, [
isEditable,
save,
buildSaveSpec,
draft.spec,
setScrollTargetId,
onSaved,
showErrorModal,
]);
// Leaving an existing panel's editor (without saving) still returns to it, so
// the dashboard lands on that panel rather than scrolled to the top. A new,
// unsaved panel has no persisted target, so there's nothing to reveal.
const onCloseEditor = useCallback((): void => {
if (!isNew) {
setScrollTargetId(panelId);
}
onClose();
}, [isNew, panelId, setScrollTargetId, onClose]);
const switchToViewMode = useCallback((): void => {
logEvent(DashboardEvents.SWITCH_TO_VIEW_MODE, {
panelId: panelId,
});
onSwitchToView();
}, [onSwitchToView]);
return (
<QueryEditorBody
{...props}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
<div className={styles.page} data-testid="panel-editor-v2">
<Header
isDirty={isDirty}
isSaving={isSaving}
showSwitchToView={!isNew}
readOnly={!isEditable}
readOnlyChecks={editChecks}
readOnlyTooltip={editDisabledTooltip}
onSave={onSave}
onSwitchToView={switchToViewMode}
onClose={onCloseEditor}
/>
<ResizablePanelGroup
id="panel-editor-v2"
orientation="horizontal"
defaultLayout={defaultLayout}
onLayoutChanged={onLayoutChanged}
>
<ResizablePanel minSize="75%" maxSize="80%" defaultSize="80%">
<div className={styles.left}>
<ResizablePanelGroup
id="panel-editor-v2-main"
orientation="vertical"
defaultLayout={mainDefaultLayout}
onLayoutChanged={onMainLayoutChanged}
>
<ResizablePanel minSize="55%" maxSize="65%" defaultSize="60%">
{panelDefinition && (
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
pagination={pagination}
/>
)}
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel minSize="35%" maxSize="45%" defaultSize="40%">
<ConfigProvider getPopupContainer={getBodyPopupContainer}>
<PanelEditorQueryBuilder
panelKind={panelKind}
signal={listSignal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
footer={
isListPanel ? (
<ListColumnsEditor
spec={spec}
onChangeSpec={setSpec}
signal={listSignal}
/>
) : undefined
}
/>
</ConfigProvider>
</ResizablePanel>
</ResizablePanelGroup>
</div>
</ResizablePanel>
<ResizableHandle withHandle className={styles.handle} />
<ResizablePanel
minSize="20%"
maxSize="25%"
defaultSize="20%"
className={styles.right}
>
<ConfigPane
panel={draft}
panelId={panelId}
spec={spec}
onChangeSpec={setSpec}
onChangePanelKind={onChangePanelKind}
queryType={currentQuery.queryType}
legendSeries={legendSeries}
tableColumns={tableColumns}
stepInterval={stepInterval}
metricUnit={metricUnit}
/>
</ResizablePanel>
</ResizablePanelGroup>
</div>
);
}

View File

@@ -11,8 +11,6 @@ import type { PanelQueryCapabilities } from '../types/panelCapabilities';
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
import {
getHiddenQueryBuilderFields,
getQueryPanelDefinition,
requireQueryPanelDefinition,
getSupportedQueryTypes,
getSupportedSignals,
isPanelCombinationValid,
@@ -109,7 +107,7 @@ const ALL_KINDS = Object.keys(EXPECTED_QUERY_TYPES) as PanelKind[];
describe('panel capabilities guard', () => {
describe('query capabilities', () => {
it.each(ALL_KINDS)('declares how %s shapes its request', (kind) => {
expect(getQueryPanelDefinition(kind)?.queryCapabilities).toStrictEqual(
expect(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
EXPECTED_QUERY_CAPABILITIES[kind],
);
});
@@ -151,8 +149,7 @@ describe('panel capabilities guard', () => {
});
it('carries an inert query shape, so a stray request can do no harm', () => {
const queryCapabilities =
requireQueryPanelDefinition(unknownKind).queryCapabilities;
const { queryCapabilities } = getPanelDefinition(unknownKind);
expect(queryCapabilities.requestType).toBe(time_series);
expect(queryCapabilities.serverPaginated).toBe(false);
expect(queryCapabilities.formatTableResultForUI).toBe(false);

View File

@@ -2,11 +2,7 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
import { EQueryType } from 'types/common/dashboard';
import { getPanelDefinition } from './registry';
import {
mergeQueryBuilderFieldRule,
type FilterConfigsPartial,
} from './types/panelCapabilities';
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
import type { FilterConfigsPartial } from './types/panelCapabilities';
import type { PanelKind } from './types/panelKind';
/**
@@ -17,46 +13,11 @@ import type { PanelKind } from './types/panelKind';
* these functions then cover it automatically. Pure and side-effect free.
*/
/** Renders from its own plugin spec — no query surface at all. */
export function isStaticPanelKind(kind: PanelKind): boolean {
return getPanelDefinition(kind).mode === 'static';
}
/**
* The kind's definition narrowed to the query arm, or null for a static kind.
* The null is what hosts fork on; the accessors below fold it into "supports
* nothing" for the guard questions.
*/
export function getQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition | null {
const definition = getPanelDefinition(kind);
return definition.mode === 'query' ? definition : null;
}
/**
* The query arm, asserted present. For call sites that a host mounts only after
* narrowing `mode === 'query'` but that read the definition by kind rather than
* receiving it as a prop — the throw makes that invariant executable instead of
* silently null-tolerant.
*/
export function requireQueryPanelDefinition(
kind: PanelKind,
): RenderableQueryPanelDefinition {
const definition = getQueryPanelDefinition(kind);
if (!definition) {
throw new Error(
`query machinery mounted for query-less panel kind ${kind} — the host must fork on definition.mode before this point`,
);
}
return definition;
}
/** Signals a kind can visualize. */
export function getSupportedSignals(
kind: PanelKind,
): TelemetrytypesSignalDTO[] {
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
return getPanelDefinition(kind).supportedSignals;
}
export function isSignalSupported(
@@ -68,7 +29,7 @@ export function isSignalSupported(
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
return getPanelDefinition(kind).supportedQueryTypes;
}
export function isQueryTypeSupportedByPanelKind(
@@ -92,10 +53,6 @@ export function isPanelCombinationValid({
queryType: EQueryType;
signal?: TelemetrytypesSignalDTO;
}): boolean {
// A query-less kind ignores the query entirely, so it pairs with anything.
if (isStaticPanelKind(kind)) {
return true;
}
if (!isQueryTypeSupportedByPanelKind(kind, queryType)) {
return false;
}
@@ -116,11 +73,7 @@ export function resolveQueryType(
preferred: EQueryType,
): EQueryType {
const supported = getSupportedQueryTypes(kind);
if (supported.includes(preferred)) {
return preferred;
}
// A query-less kind has no supported types; the builder is the neutral answer.
return supported[0] ?? EQueryType.QUERY_BUILDER;
return supported.includes(preferred) ? preferred : supported[0];
}
/**
@@ -132,6 +85,7 @@ export function getHiddenQueryBuilderFields(
kind: PanelKind,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
return mergeQueryBuilderFieldRule(rule, signal);
const rule = getPanelDefinition(kind).queryBuilderFields;
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}

View File

@@ -1,7 +1,6 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/BarChartPanel'> = {
kind: 'signoz/BarChartPanel',
displayName: 'Bar Chart',
mode: 'query',
icon: BarChart,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,7 +1,6 @@
import { BarChart } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/HistogramPanel'> = {
kind: 'signoz/HistogramPanel',
displayName: 'Histogram',
mode: 'query',
icon: BarChart,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,38 +0,0 @@
import type { QueryEditorPaneProps } from '../../types/panelDefinition';
import ListColumnsEditor from '../../../PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from '../../../PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
/**
* List's editor pane: the query builder with the columns editor pinned below it.
* Declared here so no editor host carries a List special case.
*/
function ListEditorPane({
panelDefinition,
signal,
isLoadingQueries,
onStageRunQuery,
onCancelQuery,
stickyHeader,
spec,
onChangeSpec,
}: QueryEditorPaneProps): JSX.Element {
return (
<PanelEditorQueryBuilder
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isLoadingQueries}
onStageRunQuery={onStageRunQuery}
onCancelQuery={onCancelQuery}
stickyHeader={stickyHeader}
footer={
<ListColumnsEditor
spec={spec}
onChangeSpec={onChangeSpec}
signal={signal}
/>
}
/>
);
}
export default ListEditorPane;

View File

@@ -1,7 +1,6 @@
import { List } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import ListEditorPane from './ListEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -14,10 +13,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/ListPanel'> = {
kind: 'signoz/ListPanel',
displayName: 'List',
mode: 'query',
icon: List,
Renderer,
EditorPane: ListEditorPane,
// Raw records come from logs and traces; metrics don't produce row data.
supportedSignals: [
TelemetrytypesSignalDTO.logs,

View File

@@ -1,7 +1,6 @@
import { Hash } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/NumberPanel'> = {
kind: 'signoz/NumberPanel',
displayName: 'Number',
mode: 'query',
icon: Hash,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,7 +1,6 @@
import { ChartPie } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/PieChartPanel'> = {
kind: 'signoz/PieChartPanel',
displayName: 'Pie Chart',
mode: 'query',
icon: ChartPie,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,7 +1,6 @@
import { Table } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TablePanel'> = {
kind: 'signoz/TablePanel',
displayName: 'Table',
mode: 'query',
icon: Table,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -1,25 +0,0 @@
// Tripled on purpose: the markdown reset (`.content.content *`) weighs (0,2,0),
// and a doubled class only ties it — leaving the winner to stylesheet order,
// which reverted `position: relative` and let the copy button anchor to the
// panel instead of the block. (0,3,0) wins regardless of order.
.codeBlock.codeBlock.codeBlock {
position: relative;
}
// The wrapper is a reset-exempt island (see MarkdownContent.module.scss), so
// plain classes style it; the button inside keeps its design-system look.
.copyButton {
position: absolute;
top: 4px;
right: 4px;
border-radius: 3px;
background: var(--l3-background);
opacity: 0;
transition: opacity 0.15s ease;
}
// GitHub-style reveal: hover anywhere on the block, or keyboard focus.
.codeBlock.codeBlock.codeBlock:hover .copyButton,
.codeBlock.codeBlock.codeBlock:focus-within .copyButton {
opacity: 1;
}

View File

@@ -1,69 +0,0 @@
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 styles from './CodeBlock.module.scss';
const LANGUAGE_PATTERN = /language-(\w+)/;
/**
* Fenced blocks are tokenised by Prism but coloured by the SCSS module —
* `useInlineStyles` off swaps the library's own theme for `token …` class names,
* which keeps the palette on design tokens and themed with the rest of the body.
*/
function CodeBlock({ inline, className, children }: CodeProps): JSX.Element {
const fenced = LANGUAGE_PATTERN.exec(className ?? '')?.[1]?.toLowerCase();
const language = fenced ? resolveLanguage(fenced) : null;
const isReady = usePrismLanguage(language);
if (inline) {
return <code className={className}>{children}</code>;
}
// react-markdown hands the block's text through as string children; anything
// else in there is not source and has no place in the highlighter's input —
// and it is exactly what the copy button puts on the clipboard.
const source = (Array.isArray(children) ? children : [children])
.filter((child): child is string => typeof child === 'string')
.join('')
.replace(/\n$/, '');
// Verbatim while the language chunk is still loading, and permanently for one
// Prism doesn't know. The `pre` is supplied here either way, since
// `MarkdownContent` unwraps react-markdown's own.
const block =
!language || !isReady ? (
<pre>
<code className={className}>{children}</code>
</pre>
) : (
<SyntaxHighlighter
language={language}
useInlineStyles={false}
PreTag="pre"
CodeTag="code"
>
{source}
</SyntaxHighlighter>
);
return (
<div className={styles.codeBlock}>
{block}
{/* data-md-ui: exempts the design-system button from the body's style reset. */}
<span data-md-ui className={styles.copyButton}>
<CopyButton
value={source}
size={13}
ariaLabel="Copy code"
testId="text-panel-copy-code"
/>
</span>
</div>
);
}
export default CodeBlock;

View File

@@ -1,328 +0,0 @@
@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
// apply. `all` skips custom properties, so tokens still resolve, and inherited
// properties the caller owns (`text-align`, set by the panel's presentation options)
// still flow in. The class is doubled throughout so a global `.wrapper p` can't tie
// on specificity and win on source order.
// `[data-md-ui]` marks injected UI islands (the code-block copy button) that keep
// their design-system styling: `:where(:not(…))` skips them and their subtrees at
// zero added specificity, so the island escape doesn't out-rank the body rules.
.content.content,
.content.content *:where(:not([data-md-ui], [data-md-ui] *)) {
all: revert;
box-sizing: border-box;
}
.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-code-comment: var(--text-neutral-dark-200);
--md-code-punctuation: var(--text-neutral-dark-100);
--md-code-keyword: var(--text-sakura-400);
--md-code-string: var(--text-forest-400);
--md-code-number: var(--text-amber-400);
--md-code-function: var(--text-robin-300);
--md-code-property: var(--text-aqua-400);
// Fits a two-digit ordered marker (`10.`). Shared by the task-list offset.
--md-list-indent: 24px;
display: block;
font-family: var(--font-family-inter);
font-size: var(--paragraph-base-400-font-size);
font-weight: var(--font-weight-normal);
line-height: var(--paragraph-base-400-line-height);
color: var(--md-foreground);
overflow-wrap: break-word;
}
: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-code-comment: var(--text-neutral-light-100);
--md-code-punctuation: var(--text-neutral-light-100);
--md-code-keyword: var(--text-sakura-600);
--md-code-string: var(--text-forest-700);
--md-code-number: var(--text-amber-800);
--md-code-function: var(--text-robin-600);
--md-code-property: var(--text-aqua-700);
}
.content.content > :first-child {
margin-top: 0;
}
.content.content > :last-child {
margin-bottom: 0;
}
.content.content p {
margin: 0 0 8px;
}
.content.content h1,
.content.content h2,
.content.content h3,
.content.content h4,
.content.content h5,
.content.content h6 {
margin: 16px 0 8px;
font-weight: var(--font-weight-semibold);
line-height: var(--line-height-tight);
color: var(--md-foreground);
}
.content.content h1 {
font-size: var(--font-size-lg);
}
.content.content h2 {
font-size: var(--label-medium-600-font-size);
}
.content.content h3 {
font-size: var(--font-size-sm);
}
.content.content h4,
.content.content h5,
.content.content h6 {
font-size: var(--paragraph-base-600-font-size);
}
.content.content h5,
.content.content h6 {
color: var(--md-muted);
}
.content.content ul,
.content.content ol {
margin: 0 0 8px;
padding-left: var(--md-list-indent);
}
// A nested list belongs to the item above it, so it opens tight.
.content.content li > ul,
.content.content li > ol {
margin: 2px 0 0;
}
// The reset flattens the user-agent's own disc/circle/square progression, so the
// per-depth shapes are restated here.
.content.content ul {
list-style: disc;
}
.content.content ul ul {
list-style: circle;
}
.content.content ul ul ul {
list-style: square;
}
.content.content ol {
list-style: decimal;
}
.content.content ol ol {
list-style: lower-alpha;
}
.content.content ol ol ol {
list-style: lower-roman;
}
.content.content li {
margin: 2px 0;
}
// Markers are structure, not content. Safari below 17 ignores `::marker` colour and
// leaves them in the body colour.
.content.content li::marker {
color: var(--md-muted);
font-variant-numeric: tabular-nums;
}
// Task lists carry their own checkbox, so drop the marker and reclaim the indent.
.content.content li:has(> input[type='checkbox']) {
list-style: none;
margin-left: calc(var(--md-list-indent) * -1);
}
.content.content input[type='checkbox'] {
margin-right: 6px;
accent-color: var(--md-link);
}
.content.content input[type='checkbox']:not(:disabled) {
cursor: pointer;
}
.content.content a {
color: var(--md-link);
text-decoration: none;
&:hover,
&:focus-visible {
text-decoration: underline;
}
}
.content.content strong {
font-weight: var(--font-weight-semibold);
color: var(--md-foreground);
}
.content.content em {
font-style: italic;
}
.content.content del {
text-decoration: line-through;
color: var(--md-muted);
}
.content.content code {
padding: 1px 4px;
border-radius: 2px;
background: var(--md-surface);
font-family: var(--font-family-sf-mono);
font-size: var(--code-small-400-font-size);
color: var(--md-foreground);
}
.content.content pre {
margin: 0 0 8px;
padding: 8px 10px;
border-radius: 3px;
background: var(--md-surface);
overflow-x: auto;
@include custom-scrollbar;
code {
padding: 0;
background: none;
font-size: var(--code-small-400-font-size);
line-height: var(--line-height-18);
color: var(--md-foreground);
}
}
.content.content blockquote {
margin: 0 0 8px;
padding: 2px 0 2px 10px;
border-left: 4px solid var(--md-border);
color: var(--md-muted);
}
.content.content hr {
margin: 12px 0;
border: none;
border-top: 1px solid var(--md-border);
}
.content.content img {
max-width: 100%;
height: auto;
border-radius: 3px;
}
.content.content table {
border-collapse: collapse;
width: auto;
}
.content.content th,
.content.content td {
padding: 4px 10px;
border: 1px solid var(--md-border);
text-align: left;
}
.content.content th {
background: var(--md-surface);
font-weight: var(--font-weight-semibold);
}
// Rendered by the `table` component override.
.content.content .tableScroll {
margin: 0 0 8px;
overflow-x: auto;
@include custom-scrollbar;
}
// Prism runs with `useInlineStyles` off, so it emits `token …` class names. They are
// `:global` because CSS Modules would otherwise hash them and match nothing, and the
// palette lives here on design tokens instead of in a theme object.
.content.content :global(.token.comment),
.content.content :global(.token.prolog),
.content.content :global(.token.doctype),
.content.content :global(.token.cdata) {
color: var(--md-code-comment);
font-style: italic;
}
.content.content :global(.token.punctuation),
.content.content :global(.token.operator),
.content.content :global(.token.entity) {
color: var(--md-code-punctuation);
}
.content.content :global(.token.keyword),
.content.content :global(.token.atrule),
.content.content :global(.token.rule),
.content.content :global(.token.important),
.content.content :global(.token.selector) {
color: var(--md-code-keyword);
}
.content.content :global(.token.string),
.content.content :global(.token.char),
.content.content :global(.token.attr-value),
.content.content :global(.token.regex),
.content.content :global(.token.url) {
color: var(--md-code-string);
}
.content.content :global(.token.number),
.content.content :global(.token.boolean),
.content.content :global(.token.constant),
.content.content :global(.token.symbol) {
color: var(--md-code-number);
}
.content.content :global(.token.function),
.content.content :global(.token.class-name),
.content.content :global(.token.builtin) {
color: var(--md-code-function);
}
.content.content :global(.token.property),
.content.content :global(.token.attr-name),
.content.content :global(.token.variable),
.content.content :global(.token.tag) {
color: var(--md-code-property);
}
.content.content :global(.token.deleted) {
color: var(--text-cherry-400);
}
.content.content :global(.token.inserted) {
color: var(--text-forest-400);
}
.content.content :global(.token.bold) {
font-weight: var(--font-weight-semibold);
}
.content.content :global(.token.italic) {
font-style: italic;
}

View File

@@ -1,148 +0,0 @@
import { type ReactNode, useMemo } from 'react';
import cx from 'classnames';
import ReactMarkdown from 'react-markdown';
import type { Components } from 'react-markdown';
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';
import { TaskItemOffsetContext } from './taskItemOffset';
import styles from './MarkdownContent.module.scss';
/**
* SECURITY — never add `rehype-raw` here. Without it react-markdown renders raw HTML
* as plain text, so there is no `dangerouslySetInnerHTML` on the path and nothing to
* sanitise. The body is user-authored and, on a public dashboard, read anonymously;
* the shared `MarkdownRenderer` enables `rehype-raw` and is safe only for the trusted
* content it was built for. `transformLinkUri` is likewise left at its default.
*/
const REMARK_PLUGINS = [remarkGfm];
// What the default transformer substitutes for a rejected scheme. Inert, but it
// would still put `javascript:` in the DOM, so the anchor is dropped instead.
const REJECTED_HREF = `javascript:${'void(0)'}`;
const READ_ONLY_COMPONENTS: Components = {
a: ({ node: _node, children, href, ...props }): JSX.Element => {
if (!href || href === REJECTED_HREF) {
return <span {...props}>{children}</span>;
}
return (
<a {...props} href={href} target="_blank" rel="noopener noreferrer nofollow">
{children}
</a>
);
},
// Wide tables scroll inside their own box rather than widening the panel.
table: ({ node: _node, children, ...props }): JSX.Element => (
<div className={styles.tableScroll}>
<table {...props}>{children}</table>
</div>
),
code: CodeBlock,
// `CodeBlock` emits its own `pre`, so this one would nest a second one.
pre: ({ children }): JSX.Element => <>{children}</>,
};
/** Absent on a read-only surface, which is what keeps the public view inert. */
export interface MarkdownInteractive {
/** The body before interpolation — what an edit is applied to. */
source: string;
onChangeSource: (next: string) => void;
}
export interface MarkdownContentProps {
/** Variable interpolation happens upstream, before parsing. */
children: string;
interactive?: MarkdownInteractive;
/** Rendered instead of the body when the source is blank. */
emptyState?: ReactNode;
className?: string;
testId?: string;
}
/** CommonMark + GFM, styled in isolation — see the reset in the SCSS module. */
function MarkdownContent({
children,
interactive,
emptyState = null,
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>(() => {
// A const, so the narrowing survives into the handler's closure.
const capability = interactive;
if (!capability) {
return READ_ONLY_COMPONENTS;
}
const edit = <T,>(
construct: EditableConstruct<T>,
renderedOffset: number,
value: T,
): void => {
const next = editRenderedOccurrence(construct, {
source: capability.source,
rendered: children,
renderedOffset,
value,
});
if (next !== null) {
capability.onChangeSource(next);
}
};
return {
...READ_ONLY_COMPONENTS,
li: ({ node, children: items, ...props }): JSX.Element => (
<li {...props}>
<TaskItemOffsetContext.Provider value={node.position?.start.offset}>
{items}
</TaskItemOffsetContext.Provider>
</li>
),
input: ({ checked, type }): JSX.Element | null => {
if (type !== 'checkbox') {
return null;
}
return (
<TaskCheckbox
checked={checked === true}
onChange={(next, offset): void => edit(TASK_LIST, offset, next)}
/>
);
},
};
}, [interactive, children]);
const body = useMemo(
() =>
children.trim() ? (
<ReactMarkdown remarkPlugins={REMARK_PLUGINS} components={components}>
{children}
</ReactMarkdown>
) : null,
[children, components],
);
if (!body) {
return emptyState ? <>{emptyState}</> : null;
}
return (
<div className={cx(styles.content, className)} data-testid={testId}>
{body}
</div>
);
}
export default MarkdownContent;

View File

@@ -1,32 +0,0 @@
import { useTaskItemOffset } from './taskItemOffset';
interface TaskCheckboxProps {
checked: boolean;
/** `offset` locates the item in the rendered body. */
onChange: (checked: boolean, offset: number) => void;
}
/**
* A GFM task-list checkbox that writes its state back to the panel's markdown.
* Disabled without an offset from its item: nothing would locate its marker.
*/
function TaskCheckbox({ checked, onChange }: TaskCheckboxProps): JSX.Element {
const offset = useTaskItemOffset();
return (
<input
type="checkbox"
checked={checked}
disabled={offset === undefined}
aria-label="Toggle task item"
data-testid="markdown-task-checkbox"
onChange={(event): void => {
if (offset !== undefined) {
onChange(event.target.checked, offset);
}
}}
/>
);
}
export default TaskCheckbox;

View File

@@ -1,313 +0,0 @@
import { fireEvent, render, screen, waitFor } from 'tests/test-utils';
import MarkdownContent from '../MarkdownContent';
import { loadLanguage } from '../syntaxLanguages';
describe('MarkdownContent', () => {
describe('security', () => {
it('renders a script tag as literal text, never as an element', () => {
const { container } = render(
<MarkdownContent>{'<script>alert(1)</script>'}</MarkdownContent>,
);
expect(container.querySelector('script')).toBeNull();
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
'<script>alert(1)</script>',
);
});
it('renders raw HTML as text rather than markup', () => {
const { container } = render(
<MarkdownContent>
{'<b>bold</b> and <img src="x" onerror="alert(1)">'}
</MarkdownContent>,
);
expect(container.querySelector('b')).toBeNull();
expect(container.querySelector('img')).toBeNull();
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
'<b>bold</b>',
);
});
it('drops the anchor for a javascript: href, keeping the label as text', () => {
const { container } = render(
<MarkdownContent>{'[x](javascript:alert(1))'}</MarkdownContent>,
);
expect(screen.queryByRole('link')).not.toBeInTheDocument();
expect(container.innerHTML).not.toContain('javascript');
expect(screen.getByTestId('markdown-content')).toHaveTextContent('x');
});
it('opens links in a new tab without handing over the opener', () => {
render(<MarkdownContent>{'[docs](https://signoz.io)'}</MarkdownContent>);
const link = screen.getByRole('link', { name: 'docs' });
expect(link).toHaveAttribute('href', 'https://signoz.io');
expect(link).toHaveAttribute('target', '_blank');
expect(link).toHaveAttribute('rel', 'noopener noreferrer nofollow');
});
});
describe('CommonMark and GFM', () => {
it('renders headings, lists and emphasis', () => {
render(
<MarkdownContent>
{'# Runbook\n\n- **owner** payments\n- _rotation_ weekly'}
</MarkdownContent>,
);
expect(
screen.getByRole('heading', { level: 1, name: 'Runbook' }),
).toBeInTheDocument();
expect(screen.getAllByRole('listitem')).toHaveLength(2);
expect(screen.getByText('owner').tagName).toBe('STRONG');
expect(screen.getByText('rotation').tagName).toBe('EM');
});
it('renders GFM tables, task lists and strikethrough', () => {
const { container } = render(
<MarkdownContent>
{'| a | b |\n| --- | --- |\n| 1 | 2 |\n\n- [x] done\n\n~~gone~~'}
</MarkdownContent>,
);
expect(screen.getByRole('table')).toBeInTheDocument();
expect(screen.getByRole('checkbox')).toBeChecked();
expect(container.querySelector('del')).toHaveTextContent('gone');
});
it('renders fenced code as a preformatted block', () => {
const { container } = render(
<MarkdownContent>{'```sh\nkubectl get pods\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent(
'kubectl get pods',
);
expect(container.querySelectorAll('pre')).toHaveLength(1);
});
it('renders malformed markdown as literal text instead of throwing', () => {
render(<MarkdownContent>{'| broken | table\n**unclosed'}</MarkdownContent>);
expect(screen.getByTestId('markdown-content')).toHaveTextContent(
'**unclosed',
);
});
});
describe('syntax highlighting', () => {
it('tokenises a fenced block once its language has loaded', async () => {
const { container } = render(
<MarkdownContent>{'```js\nconst x = 1; // note\n```'}</MarkdownContent>,
);
await waitFor(() => {
expect(container.querySelector('.token.keyword')).toHaveTextContent(
'const',
);
});
expect(container.querySelector('.token.number')).toHaveTextContent('1');
expect(container.querySelector('.token.comment')).toHaveTextContent(
'// note',
);
});
it('shows the source verbatim while the language is still loading', () => {
const { container } = render(
<MarkdownContent>{'```rust\nfn main() {}\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent(
'fn main() {}',
);
expect(container.querySelector('.token')).toBeNull();
});
it('highlights a language already loaded on the first render', async () => {
await loadLanguage('sql');
const { container } = render(
<MarkdownContent>{'```sql\nSELECT 1\n```'}</MarkdownContent>,
);
expect(container.querySelector('.token.keyword')).toHaveTextContent(
'SELECT',
);
});
it('tags the code element with the language', () => {
const { container } = render(
<MarkdownContent>{'```python\nx = 1\n```'}</MarkdownContent>,
);
expect(container.querySelector('code')).toHaveClass('language-python');
});
it('renders an unknown language verbatim', () => {
const { container } = render(
<MarkdownContent>{'```promql\nrate(foo[5m])\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent(
'rate(foo[5m])',
);
expect(container.querySelector('.token')).toBeNull();
});
it('renders a fence with no language verbatim', () => {
const { container } = render(
<MarkdownContent>{'```\nplain text\n```'}</MarkdownContent>,
);
expect(container.querySelector('pre code')).toHaveTextContent('plain text');
expect(container.querySelector('.token')).toBeNull();
});
it('leaves inline code untokenised', () => {
const { container } = render(
<MarkdownContent>{'use `const` here'}</MarkdownContent>,
);
expect(container.querySelector('pre')).toBeNull();
expect(container.querySelector('.token')).toBeNull();
});
});
describe('empty body', () => {
it('renders nothing when the source is blank', () => {
const { container } = render(<MarkdownContent>{' \n '}</MarkdownContent>);
expect(container).toBeEmptyDOMElement();
});
it('renders the empty state when one is supplied', () => {
render(
<MarkdownContent emptyState={<span>Nothing here yet</span>}>
{''}
</MarkdownContent>,
);
expect(screen.getByText('Nothing here yet')).toBeInTheDocument();
expect(screen.queryByTestId('markdown-content')).not.toBeInTheDocument();
});
});
});
describe('code block copy button', () => {
it('offers the block source, exactly as fenced, to the copy control', () => {
render(<MarkdownContent>{'```sh\nkubectl get pods\n```'}</MarkdownContent>);
const button = screen.getByTestId('text-panel-copy-code');
expect(button).toHaveAccessibleName('Copy code');
});
it('renders no copy control on inline code', () => {
render(<MarkdownContent>{'run `npm i` now'}</MarkdownContent>);
expect(screen.queryByTestId('text-panel-copy-code')).not.toBeInTheDocument();
});
});
describe('MarkdownContent — interactive task lists', () => {
const source = ['- [ ] first', '- [x] second'].join('\n');
it('renders task checkboxes disabled without the capability', () => {
render(<MarkdownContent>{source}</MarkdownContent>);
const boxes = screen.getAllByRole('checkbox');
expect(boxes).toHaveLength(2);
boxes.forEach((box) => expect(box).toBeDisabled());
});
it('renders them enabled, and checked to match the source', () => {
render(
<MarkdownContent interactive={{ source, onChangeSource: jest.fn() }}>
{source}
</MarkdownContent>,
);
const [first, second] = screen.getAllByRole('checkbox');
expect(first).toBeEnabled();
expect(first).not.toBeChecked();
expect(second).toBeChecked();
});
it('checking one rewrites its marker in the source', () => {
const onChangeSource = jest.fn();
render(
<MarkdownContent interactive={{ source, onChangeSource }}>
{source}
</MarkdownContent>,
);
fireEvent.click(screen.getAllByRole('checkbox')[0]);
expect(onChangeSource).toHaveBeenCalledWith(
['- [x] first', '- [x] second'].join('\n'),
);
});
it('unchecking one rewrites only that marker', () => {
const onChangeSource = jest.fn();
render(
<MarkdownContent interactive={{ source, onChangeSource }}>
{source}
</MarkdownContent>,
);
fireEvent.click(screen.getAllByRole('checkbox')[1]);
expect(onChangeSource).toHaveBeenCalledWith(
['- [ ] first', '- [ ] second'].join('\n'),
);
});
it('maps a click back through an expanded variable', () => {
const onChangeSource = jest.fn();
const withVariable = ['- [ ] $env first', '- [ ] second'].join('\n');
render(
<MarkdownContent interactive={{ source: withVariable, onChangeSource }}>
{['- [ ] production first', '- [ ] second'].join('\n')}
</MarkdownContent>,
);
fireEvent.click(screen.getAllByRole('checkbox')[1]);
expect(onChangeSource).toHaveBeenCalledWith(
['- [ ] $env first', '- [x] second'].join('\n'),
);
});
it('saves nothing when a variable injected a marker of its own', () => {
const onChangeSource = jest.fn();
render(
<MarkdownContent interactive={{ source: '- [ ] $tasks', onChangeSource }}>
{['- [ ] one', '- [ ] two'].join('\n')}
</MarkdownContent>,
);
fireEvent.click(screen.getAllByRole('checkbox')[0]);
expect(onChangeSource).not.toHaveBeenCalled();
});
it('ignores a task marker inside a fence', () => {
const onChangeSource = jest.fn();
const fenced = ['```', '- [ ] fenced', '```', '', '- [ ] real'].join('\n');
render(
<MarkdownContent interactive={{ source: fenced, onChangeSource }}>
{fenced}
</MarkdownContent>,
);
expect(screen.getAllByRole('checkbox')).toHaveLength(1);
fireEvent.click(screen.getByRole('checkbox'));
expect(onChangeSource).toHaveBeenCalledWith(
fenced.replace('- [ ] real', '- [x] real'),
);
});
});

View File

@@ -1,89 +0,0 @@
import { PrismLight } from 'react-syntax-highlighter';
type PrismLanguage = Parameters<typeof PrismLight.registerLanguage>[1];
type LanguageLoader = () => Promise<{ default: PrismLanguage }>;
// One dynamic import per language, so each becomes its own chunk and a panel pays
// only for the languages its fences actually name.
const LOADERS: Record<string, LanguageLoader> = {
bash: () => import('react-syntax-highlighter/dist/esm/languages/prism/bash'),
css: () => import('react-syntax-highlighter/dist/esm/languages/prism/css'),
diff: () => import('react-syntax-highlighter/dist/esm/languages/prism/diff'),
docker: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/docker'),
go: () => import('react-syntax-highlighter/dist/esm/languages/prism/go'),
java: () => import('react-syntax-highlighter/dist/esm/languages/prism/java'),
javascript: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/javascript'),
json: () => import('react-syntax-highlighter/dist/esm/languages/prism/json'),
markup: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/markup'),
python: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/python'),
rust: () => import('react-syntax-highlighter/dist/esm/languages/prism/rust'),
sql: () => import('react-syntax-highlighter/dist/esm/languages/prism/sql'),
typescript: () =>
import('react-syntax-highlighter/dist/esm/languages/prism/typescript'),
yaml: () => import('react-syntax-highlighter/dist/esm/languages/prism/yaml'),
};
const ALIASES: Record<string, string> = {
dockerfile: 'docker',
html: 'markup',
js: 'javascript',
py: 'python',
sh: 'bash',
shell: 'bash',
ts: 'typescript',
xml: 'markup',
yml: 'yaml',
};
const registered = new Set<string>();
const inFlight = new Map<string, Promise<boolean>>();
/** The name Prism knows a fence's language by, or null if it knows none. */
export function resolveLanguage(name: string): string | null {
const canonical = ALIASES[name] ?? name;
return canonical in LOADERS ? canonical : null;
}
export function isLanguageRegistered(name: string): boolean {
return registered.has(name);
}
/**
* Resolves to whether `name` is registered and ready to highlight with. Concurrent
* callers share one import, so a dashboard of same-language fences fetches once.
*/
export function loadLanguage(name: string): Promise<boolean> {
if (registered.has(name)) {
return Promise.resolve(true);
}
const pending = inFlight.get(name);
if (pending) {
return pending;
}
const loader = LOADERS[name];
if (!loader) {
return Promise.resolve(false);
}
const request = loader()
.then((module) => {
PrismLight.registerLanguage(name, module.default);
registered.add(name);
return true;
})
.catch(() => false)
.finally(() => {
inFlight.delete(name);
});
inFlight.set(name, request);
return request;
}
export default PrismLight;

View File

@@ -1,22 +0,0 @@
import {
// eslint-disable-next-line no-restricted-imports
createContext,
// eslint-disable-next-line no-restricted-imports
useContext,
} from 'react';
/**
* Offset of the enclosing list item in the rendered body. The checkbox a task list
* renders is synthesised by the AST transform with no position of its own, so its
* item supplies one — after every earlier marker and before its own, which is all
* the ordinal needs.
*
* Context, not a store: one render pass handing a node's position to its child.
*/
export const TaskItemOffsetContext = createContext<number | undefined>(
undefined,
);
export function useTaskItemOffset(): number | undefined {
return useContext(TaskItemOffsetContext);
}

View File

@@ -1,42 +0,0 @@
import { useEffect, useState } from 'react';
import { isLanguageRegistered, loadLanguage } from './syntaxLanguages';
/**
* Registers `language` with Prism on demand, reporting when it is ready to
* highlight with. Already-loaded languages report ready on the first render, so a
* second fence of the same language never flashes unhighlighted.
*/
export function usePrismLanguage(language: string | null): boolean {
const [isReady, setIsReady] = useState(
() => !!language && isLanguageRegistered(language),
);
useEffect(() => {
if (!language) {
setIsReady(false);
return undefined;
}
if (isLanguageRegistered(language)) {
setIsReady(true);
return undefined;
}
setIsReady(false);
let isStale = false;
// `loadLanguage` resolves false rather than rejecting, so there is no failure
// path here beyond leaving the block unhighlighted.
void loadLanguage(language).then((loaded): boolean => {
if (!isStale && loaded) {
setIsReady(true);
}
return loaded;
});
return (): void => {
isStale = true;
};
}, [language]);
return isReady;
}

View File

@@ -1,7 +1,6 @@
import { ChartLine } from '@signozhq/icons';
import type { PanelDefinition } from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
import { sections } from './sections';
import {
@@ -13,10 +12,8 @@ import { EQueryType } from 'types/common/dashboard';
export const definition: PanelDefinition<'signoz/TimeSeriesPanel'> = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Time Series',
mode: 'query',
icon: ChartLine,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections,
supportedSignals: [
TelemetrytypesSignalDTO.metrics,

View File

@@ -5,7 +5,6 @@ import {
NO_PANEL_ACTIONS,
type RenderablePanelDefinition,
} from '../../types/panelDefinition';
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
import Renderer from './Renderer';
/**
@@ -20,10 +19,8 @@ import Renderer from './Renderer';
export const UNSUPPORTED_PANEL: RenderablePanelDefinition = {
kind: '<unsupported>' as RenderablePanelDefinition['kind'],
displayName: 'Unsupported panel',
mode: 'query',
icon: TriangleAlert,
Renderer,
EditorPane: QueryBuilderEditorPane,
sections: [],
supportedSignals: [],
supportedQueryTypes: [],

View File

@@ -22,15 +22,6 @@ export type QueryBuilderFieldRule = {
default?: FilterConfigsPartial;
} & Partial<Record<TelemetrytypesSignalDTO, FilterConfigsPartial>>;
/** The kind's `default` rule with its per-signal overrides merged over it (signal wins). */
export function mergeQueryBuilderFieldRule(
rule: QueryBuilderFieldRule,
signal: TelemetrytypesSignalDTO,
): FilterConfigsPartial {
const perSignal = signal ? rule[signal] : undefined;
return { ...rule.default, ...perSignal };
}
/**
* How a kind's query-range request is shaped. Declared per-kind in
* `kinds/<Kind>/definition.ts` and read through the capabilities guard, so no V2 code

View File

@@ -1,8 +1,5 @@
import type { ComponentType } from 'react';
import {
type DashboardtypesPanelSpecDTO,
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
import type { ChartLine } from '@signozhq/icons';
import type { EQueryType } from 'types/common/dashboard';
@@ -13,11 +10,7 @@ import type {
PanelQueryCapabilities,
QueryBuilderFieldRule,
} from './panelCapabilities';
import type {
BaseRendererProps,
PanelRendererProps,
StaticRendererProps,
} from './rendererProps';
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
/** Export formats offered under the single "Download" action. */
export enum DownloadFormat {
@@ -70,50 +63,12 @@ export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
export type PanelIcon = typeof ChartLine;
export interface PanelDefinitionBase<K extends PanelKind = PanelKind> {
export interface PanelDefinition<K extends PanelKind = PanelKind> {
kind: K;
displayName: string;
icon: PanelIcon;
sections: SectionConfig[];
actions: PanelActionCapabilities;
}
/** Props for a static kind's authoring pane — rendered where the query builder sits. */
export interface StaticEditorPaneProps {
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
}
/**
* Props for a query kind's authoring pane (the editor's lower-left slot). Spec
* read/write is included so a kind's pane can edit its own spec slices (the List
* columns editor) without the host carrying per-kind conditionals.
*/
export interface QueryEditorPaneProps {
/** The kind's definition, narrowed by the host's fork. */
panelDefinition: RenderableQueryPanelDefinition;
signal: TelemetrytypesSignalDTO;
isLoadingQueries: boolean;
onStageRunQuery: () => void;
onCancelQuery: () => void;
/** Pin the tabs row to the pane top; the View modal opts out. */
stickyHeader?: boolean;
spec: DashboardtypesPanelSpecDTO;
onChangeSpec: (spec: DashboardtypesPanelSpecDTO) => void;
}
/**
* A kind that renders from a query. Declares its whole query surface here, so a
* kind without one carries no query declarations at all — no dummy capabilities,
* no empty signal lists standing in for "not applicable".
*/
export interface QueryPanelDefinition<
K extends PanelKind = PanelKind,
> extends PanelDefinitionBase<K> {
mode: 'query';
Renderer: ComponentType<PanelRendererProps<K>>;
/** Lower editor pane — the shared query-builder pane, or a kind wrapper of it. */
EditorPane: ComponentType<QueryEditorPaneProps>;
sections: SectionConfig[];
/** Signals this kind can visualize. */
supportedSignals: TelemetrytypesSignalDTO[];
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
@@ -122,39 +77,16 @@ export interface QueryPanelDefinition<
queryBuilderFields: QueryBuilderFieldRule;
/** How this kind's query-range request is shaped (request type, paging, result formatting). */
queryCapabilities: PanelQueryCapabilities;
actions: PanelActionCapabilities;
}
/**
* A kind that renders from its own plugin spec and saves with `queries: []` (the
* API rejects anything else). Its renderer takes no query data, and its editor
* pane replaces the query builder (TDD D8). No query machinery mounts for it
* anywhere — every host forks on `mode` before touching a query hook.
*/
export interface StaticPanelDefinition<
K extends PanelKind = PanelKind,
> extends PanelDefinitionBase<K> {
mode: 'static';
Renderer: ComponentType<StaticRendererProps<K>>;
EditorPane: ComponentType<StaticEditorPaneProps>;
}
export type PanelDefinition<K extends PanelKind = PanelKind> =
| QueryPanelDefinition<K>
| StaticPanelDefinition<K>;
// Every kind must be registered, so getPanelDefinition never returns undefined.
export type PanelRegistry = { [K in PanelKind]: PanelDefinition<K> };
// The arms with their Renderer widened to the kind-agnostic prop surface. Declared
// explicitly rather than via `Omit` over the union, which collapses to common keys.
export interface RenderableQueryPanelDefinition extends Omit<
QueryPanelDefinition,
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
export interface RenderablePanelDefinition extends Omit<
PanelDefinition,
'Renderer'
> {
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
}
export type RenderableStaticPanelDefinition = StaticPanelDefinition<PanelKind>;
export type RenderablePanelDefinition =
| RenderableQueryPanelDefinition
| RenderableStaticPanelDefinition;

View File

@@ -75,19 +75,6 @@ export type PanelOfKind<K extends PanelKind = PanelKind> = Omit<
};
};
/**
* Props a static (query-less) renderer receives: its panel and render context,
* nothing of the fetch lifecycle. `dashboardId` scopes store reads (resolved
* variables); the editor route seeds the same store, so previews of an unsaved
* panel resolve variables the way the grid does.
*/
export interface StaticRendererProps<K extends PanelKind = PanelKind> {
panelId: string;
panel: PanelOfKind<K>;
panelMode: PanelMode;
dashboardId?: string;
}
// Renderer props for kind K: the base (with `panel` narrowed to K) plus K's
// interaction surface (PanelInteractionMap[K]), so a renderer sees its exact spec
// and only the gestures it supports. The default K = PanelKind is the widest surface.

View File

@@ -1,121 +0,0 @@
import {
editRenderedOccurrence,
maskFencedCode,
matchOffsets,
} from '../markdownSource';
import { TASK_LIST } from '../taskList';
const CHECKLIST = ['- [ ] first', '- [x] second', '- [ ] third'].join('\n');
function toggle(
source: string,
renderedOffset: number,
value: boolean,
rendered = source,
): string | null {
return editRenderedOccurrence(TASK_LIST, {
source,
rendered,
renderedOffset,
value,
});
}
describe('maskFencedCode', () => {
it('blanks a fence without moving anything after it', () => {
const markdown = ['a', '```', '- [ ] not a task', '```', '- [ ] task'].join(
'\n',
);
const masked = maskFencedCode(markdown);
expect(masked).toHaveLength(markdown.length);
expect(masked.indexOf('- [ ] task')).toBe(markdown.indexOf('- [ ] task'));
expect(masked).not.toContain('not a task');
});
it('handles tilde fences and an unterminated one', () => {
expect(maskFencedCode('~~~\n- [ ] x\n~~~')).not.toContain('[ ]');
expect(maskFencedCode('```\n- [ ] x')).not.toContain('[ ]');
});
});
describe('matchOffsets', () => {
it('finds every task marker, in document order', () => {
expect(TASK_LIST.offsets(CHECKLIST)).toStrictEqual([
CHECKLIST.indexOf('[ ]') + 1,
CHECKLIST.indexOf('[x]') + 1,
CHECKLIST.lastIndexOf('[ ]') + 1,
]);
});
it('ignores markers inside a fence', () => {
const markdown = ['- [ ] real', '```', '- [ ] fenced', '```'].join('\n');
expect(TASK_LIST.offsets(markdown)).toHaveLength(1);
});
it('reads ordered lists and the alternate bullets', () => {
const markdown = ['1. [ ] a', '2) [ ] b', '* [ ] c', '+ [ ] d'].join('\n');
expect(TASK_LIST.offsets(markdown)).toHaveLength(4);
});
it('is repeatable', () => {
expect(TASK_LIST.offsets(CHECKLIST)).toStrictEqual(
TASK_LIST.offsets(CHECKLIST),
);
});
it('does not treat a bare bracket as a marker', () => {
expect(TASK_LIST.offsets('- [] no')).toHaveLength(0);
});
it('scans with the pattern it is given', () => {
const offsets = matchOffsets('a1 b2', /\d/g, () => 0);
expect(offsets).toStrictEqual([1, 4]);
});
});
describe('editRenderedOccurrence', () => {
it('checks the marker the clicked checkbox belongs to', () => {
const next = toggle(CHECKLIST, CHECKLIST.lastIndexOf('[ ]'), true);
expect(next).toBe(['- [ ] first', '- [x] second', '- [x] third'].join('\n'));
});
it('unchecks one without touching its neighbours', () => {
const next = toggle(CHECKLIST, CHECKLIST.indexOf('[x]'), false);
expect(next).toBe(['- [ ] first', '- [ ] second', '- [ ] third'].join('\n'));
});
it('leaves the rest of the body byte-identical', () => {
const source = ['# Title', '', '- [ ] one', '', 'Prose after.'].join('\n');
const next = toggle(source, source.indexOf('[ ]'), true);
expect(next).toBe(source.replace('[ ]', '[x]'));
});
it('maps a rendered offset back through an expanded variable', () => {
const source = ['- [ ] $env first', '- [ ] second'].join('\n');
const rendered = ['- [ ] production first', '- [ ] second'].join('\n');
const next = toggle(source, rendered.lastIndexOf('[ ]'), true, rendered);
expect(next).toBe(['- [ ] $env first', '- [x] second'].join('\n'));
});
it('refuses when the two bodies disagree on how many there are', () => {
const source = ['- [ ] $tasks', '- [ ] last'].join('\n');
const rendered = ['- [ ] one', '- [ ] injected', '- [ ] last'].join('\n');
expect(
toggle(source, rendered.lastIndexOf('[ ]'), true, rendered),
).toBeNull();
});
it('refuses when the source has no such occurrence', () => {
expect(toggle('no tasks here', 0, true)).toBeNull();
});
});

View File

@@ -2,7 +2,7 @@ import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schem
import { initialQueriesMap } from 'constants/queryBuilder';
import type { Query } from 'types/api/queryBuilder/queryBuilderData';
import { getQueryPanelDefinition } from '../capabilities';
import { getPanelDefinition } from '../registry';
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
import { fromPerses } from '../../queryV5/persesQueryAdapters';
@@ -10,19 +10,10 @@ import { fromPerses } from '../../queryV5/persesQueryAdapters';
* The panel's saved query as a builder `Query` — what the editor route and the View
* modal put in `compositeQuery` when they open. Matches the seed
* `usePanelEditorQuerySync` computes from the panel.
*
* `null` for a kind that renders from its own spec: there is no query to stage, and
* callers skip the `compositeQuery` param entirely rather than asking about the kind.
*/
export function getPanelBuilderQuery(
panel: DashboardtypesPanelDTO,
): Query | null {
export function getPanelBuilderQuery(panel: DashboardtypesPanelDTO): Query {
const kind = panel.spec.plugin.kind;
const definition = getQueryPanelDefinition(kind);
if (!definition) {
return null;
}
const [defaultSignal] = definition.supportedSignals;
const [defaultSignal] = getPanelDefinition(kind).supportedSignals;
// A query-less panel seeds from the kind's first supported signal — `fromPerses`'s
// metrics default isn't authorable in every kind (e.g. List).
if (panel.spec.queries.length === 0 && defaultSignal) {

View File

@@ -1,73 +0,0 @@
/** Fenced regions, whose markup is source text rather than a rendered construct. */
const FENCE = /^([ \t]*)(`{3,}|~{3,})[^\n]*\n[\s\S]*?(?:\n\1\2[^\n]*|$)/gm;
/**
* Blanks fenced code, preserving length so offsets still line up. A construct
* inside a fence renders as text, so counting it would misalign the rest.
*/
export function maskFencedCode(markdown: string): string {
return markdown.replace(FENCE, (block) => block.replace(/[^\n]/g, ' '));
}
/** Offsets of every match of `pattern` in `markdown`, ignoring fenced code. */
export function matchOffsets(
markdown: string,
pattern: RegExp,
/** Offset within the match of the span the rewrite targets. */
spanOffset: (match: RegExpExecArray) => number,
): number[] {
const masked = maskFencedCode(markdown);
const scan = new RegExp(pattern.source, pattern.flags);
const offsets: number[] = [];
let match = scan.exec(masked);
while (match !== null) {
offsets.push(match.index + spanOffset(match));
match = scan.exec(masked);
}
return offsets;
}
/**
* A markdown construct whose rendered element writes back to the source. Add one
* of these plus an element override in `MarkdownContent` to make another element
* interactive.
*/
export interface EditableConstruct<T> {
/** Offset of each occurrence's editable span, in document order. */
offsets: (markdown: string) => number[];
/** Rewrites the span at `offset` to `value`. */
rewrite: (markdown: string, offset: number, value: T) => string;
}
/**
* Maps a rendered element back to the same ordinal in the source and rewrites it.
*
* Variables are interpolated before parsing, so a rendered offset is not a source
* offset. The ordinal only holds while both bodies carry the same number of the
* construct — a variable value with one of its own shifts everything after it —
* so a mismatch returns `null` rather than editing the wrong occurrence.
*/
export function editRenderedOccurrence<T>(
construct: EditableConstruct<T>,
{
source,
rendered,
renderedOffset,
value,
}: { source: string; rendered: string; renderedOffset: number; value: T },
): string | null {
const sourceOffsets = construct.offsets(source);
const renderedOffsets = construct.offsets(rendered);
if (sourceOffsets.length !== renderedOffsets.length) {
return null;
}
const ordinal = renderedOffsets.filter(
(start) => start < renderedOffset,
).length;
const target = sourceOffsets[ordinal];
if (target === undefined) {
return null;
}
return construct.rewrite(source, target, value);
}

View File

@@ -1,14 +0,0 @@
import { type EditableConstruct, matchOffsets } from './markdownSource';
/** Group 1 runs up to the state character, which is the span a toggle rewrites. */
const TASK_MARKER = /^([ \t]*(?:[-*+]|\d+[.)])[ \t]+\[)[ xX](\])/gm;
/** The checkbox a GFM task list renders. */
export const TASK_LIST: EditableConstruct<boolean> = {
offsets: (markdown) =>
matchOffsets(markdown, TASK_MARKER, (match) => match[1].length),
rewrite: (markdown, offset, checked) =>
markdown.slice(0, offset) +
(checked ? 'x' : ' ') +
markdown.slice(offset + 1),
};

View File

@@ -1,9 +1,22 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import ContextMenu from 'periscope/components/ContextMenu';
import {
getPanelDefinition,
isPanelKindSupported,
} from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
} from 'pages/DashboardPage/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { DashboardSection } from '../../utils';
import QueryPanel from './QueryPanel';
import StaticPanel from './StaticPanel';
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';
/**
* Layout context for the panel actions menu — present only in editable mode. No
@@ -24,8 +37,8 @@ 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 (header + body). Thin orchestrator: fetching lives in
* `usePanelQuery`, interactions in `usePanelInteractions`, state in `PanelBody`.
*/
function Panel({
panel,
@@ -33,28 +46,74 @@ function Panel({
isVisible,
panelActions,
}: PanelProps): JSX.Element {
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
if (panelDefinition.mode === 'static') {
return (
<StaticPanel
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
);
}
const panelKind = panel.spec.plugin.kind;
const panelDefinition = getPanelDefinition(panelKind);
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
// observer reports visibility (the View modal, the editor preview), and those panels
// are on screen by construction.
const isOffScreen = isVisible === false;
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
// Lazy: fetch once on screen, and never for a kind this build can't render —
// the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
const drilldown = useDrilldown(panel, panelId);
return (
<QueryPanel
panel={panel}
panelId={panelId}
panelDefinition={panelDefinition}
isVisible={isVisible}
panelActions={panelActions}
/>
<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
panelId={panelId}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
timeLabel={timeLabel}
panelActions={panelActions}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<PanelBody
panelDefinition={panelDefinition}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}

View File

@@ -1,14 +1,11 @@
import type { ComponentType } from 'react';
import { RotateCw, SquarePlus, TriangleAlert } from '@signozhq/icons';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import PanelLoader from 'pages/DashboardPage/DashboardContainer/Panels/components/PanelLoader/PanelLoader';
import PanelMessage from 'pages/DashboardPage/DashboardContainer/Panels/components/PanelMessage/PanelMessage';
import type { AnyPanelInteractionProps } from 'pages/DashboardPage/DashboardContainer/Panels/types/interactions';
import type {
BaseRendererProps,
DashboardPreference,
} from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { hasRunnableQueries } from 'pages/DashboardPage/DashboardContainer/queryV5/buildQueryRangeRequest';
import { getResponseType } from 'pages/DashboardPage/DashboardContainer/queryV5/v5ResponseData';
import type {
@@ -20,8 +17,8 @@ import { panelStatusFromError } from '../PanelStatus/utils';
import styles from './PanelBody.module.scss';
interface PanelBodyProps {
/** The query arm's renderer — hosts narrow `mode === 'query'` before this mounts. */
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
/** Resolved renderer for the panel kind (`Panel` handles the unsupported case). */
panelDefinition: RenderablePanelDefinition;
panel: DashboardtypesPanelDTO;
panelId: string;
data: PanelQueryData;
@@ -54,7 +51,7 @@ interface PanelBodyProps {
* first-load / renderer. The renderer keeps stale data mounted across refetches.
*/
function PanelBody({
Renderer,
panelDefinition,
panel,
panelId,
data,
@@ -119,7 +116,7 @@ function PanelBody({
return (
<div className={styles.panelContainer}>
<Renderer
<panelDefinition.Renderer
panelId={panelId}
panel={panel}
data={data}

View File

@@ -1,5 +1,6 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelQueryData } from 'pages/DashboardPage/DashboardContainer/queryV5/types';
import PanelBody from '../PanelBody';
@@ -7,6 +8,10 @@ import PanelBody from '../PanelBody';
// Stub the renderer so these tests focus on PanelBody's state machine.
const MockRenderer = (): JSX.Element => <div data-testid="mock-renderer" />;
const panelDefinition = {
Renderer: MockRenderer,
} as unknown as RenderablePanelDefinition;
function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
return {
kind: 'Panel',
@@ -19,7 +24,7 @@ function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
}
const baseProps = {
Renderer: MockRenderer,
panelDefinition,
panelId: 'p1',
data: {} as PanelQueryData,
isFetching: false,

View File

@@ -1,112 +0,0 @@
import { useState } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import ContextMenu from 'periscope/components/ContextMenu';
import { isPanelKindSupported } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
getPanelTimePreference,
panelTimePreferenceLabel,
} from 'pages/DashboardPage/DashboardContainer/hooks/resolvePanelTimeWindow';
import { usePanelQuery } from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import type { PanelActionsConfig } from './Panel';
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 {
panel: DashboardtypesPanelDTO;
panelId: string;
/** The kind's definition, narrowed to the query arm by `Panel`'s fork. */
panelDefinition: RenderableQueryPanelDefinition;
/** True once this panel enters the viewport — gates the fetch (owned by SectionGridItem). */
isVisible?: boolean;
/** Move/delete actions — present only in editable sectioned mode. */
panelActions?: PanelActionsConfig;
}
/**
* A query-backed dashboard panel (header + body). Thin orchestrator: fetching
* lives in `usePanelQuery`, interactions in `usePanelInteractions`, state in
* `PanelBody`.
*/
function QueryPanel({
panel,
panelId,
panelDefinition,
isVisible,
panelActions,
}: QueryPanelProps): JSX.Element {
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
const panelKind = panel.spec.plugin.kind;
// Header search: only kinds that declare it render the box. The term is owned
// here and threaded to both the header (input) and renderer (filter).
const searchable = panelDefinition.actions.search;
const [searchTerm, setSearchTerm] = useState('');
// Only an explicit false defers the fetch: `isVisible` is undefined wherever no
// observer reports visibility (the View modal, the editor preview), and those panels
// are on screen by construction.
const isOffScreen = isVisible === false;
const { data, isFetching, isPreviousData, error, refetch, pagination } =
usePanelQuery({
panel,
panelId,
queryCapabilities: panelDefinition.queryCapabilities,
// Lazy: fetch once on screen, and never for a kind this build can't render —
// the data would have nothing to render into.
enabled: isPanelKindSupported(panelKind) && !isOffScreen,
});
const { onDragSelect, dashboardPreference } = usePanelInteractions();
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
panelId={panelId}
panel={panel}
data={data}
isFetching={isFetching}
error={error}
warning={data.response?.data?.warning}
timeLabel={timeLabel}
panelActions={panelActions}
searchable={searchable}
searchTerm={searchTerm}
onSearchChange={setSearchTerm}
/>
<PanelBody
Renderer={panelDefinition.Renderer}
panel={panel}
panelId={panelId}
data={data}
isFetching={isFetching}
isVisible={isVisible}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={onDragSelect}
dashboardPreference={dashboardPreference}
searchTerm={searchable ? searchTerm : undefined}
pagination={pagination}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
/>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}
export default QueryPanel;

View File

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

View File

@@ -1,5 +0,0 @@
.body {
height: 100%;
min-height: 0;
overflow: hidden;
}

View File

@@ -1,45 +0,0 @@
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import type { RenderableStaticPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import type { PanelOfKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import styles from './StaticPanelBody.module.scss';
interface StaticPanelBodyProps {
Renderer: RenderableStaticPanelDefinition['Renderer'];
panel: DashboardtypesPanelDTO;
panelId: string;
/** Render context — defaults to the dashboard view; the editor preview passes EDIT. */
panelMode?: PanelMode;
}
/**
* Body for a kind that renders from its own plugin spec: the static renderer and
* nothing else — no fetch, no loading or error states. Shared by the dashboard
* grid, the public view and the editor preview, which is what keeps the preview
* live while the draft spec changes.
*/
function StaticPanelBody({
Renderer,
panel,
panelId,
panelMode = PanelMode.DASHBOARD_VIEW,
}: 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.
const dashboardId = useDashboardStore((s) => s.dashboardId);
return (
<div className={styles.body} data-testid="static-panel-body">
<Renderer
panelId={panelId}
panel={panel as PanelOfKind}
panelMode={panelMode}
dashboardId={dashboardId || undefined}
/>
</div>
);
}
export default StaticPanelBody;

View File

@@ -1,196 +0,0 @@
import { useCallback, useEffect, useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ContextMenu from 'periscope/components/ContextMenu';
import PreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import type { PanelKind } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
import type { PanelEditorDraftApi } from 'pages/DashboardPage/DashboardContainer/PanelEditor/types';
import { useViewPanelStore } from 'pages/DashboardPage/DashboardContainer/store/useViewPanelStore';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import { useDrilldown } from '../hooks/useDrilldown';
import { usePanelInteractions } from '../hooks/usePanelInteractions';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelMode } from './useViewPanelMode';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
import logEvent from 'api/common/logEvent';
import {
DashboardDetailEvents,
DashboardEvents,
} from 'pages/DashboardPage/constants/events';
interface QueryViewModalBodyProps {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Close the modal — wired to the graph manager's Save/Cancel. */
onClose: () => void;
/** Draft state, owned by the modal shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
/** Kind switch, owned by the shell (its cache must survive the fork swap). */
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* The query-kind View modal body: a compact drilldown editor. It renders an
* editable draft of the panel (preview) over a per-view time window plus the
* kind's editor pane, so the user can tweak + Stage & Run without touching the
* dashboard. Edits are temporary.
*/
function QueryViewModalBody({
panel,
panelId,
onClose,
draftApi,
onChangePanelKind,
}: QueryViewModalBodyProps): JSX.Element | null {
const {
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
extendWindow,
} = useViewPanelTimeWindow();
const {
draft,
setSpec,
panelDefinition,
signal,
queryType,
query,
runQuery,
resetQuery,
buildSaveSpec,
applyDrilldownQuery,
} = useViewPanelMode({ panel, panelId, time: timeOverride, draftApi });
const {
data,
isFetching,
isPreviousData,
error,
refetch,
cancelQuery,
pagination,
} = query;
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
// so it reflects in-modal edits (and the click's time range follows the per-view window).
const drilldown = useDrilldown(draft, panelId, {
openDrilldownView: applyDrilldownQuery,
});
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
// cursor-sync group so a drag here can't replay onto the grid panels.
const { dashboardPreference } = usePanelInteractions();
const isolatedPreference = useMemo<DashboardPreference>(
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
[dashboardPreference],
);
const openPanelEditor = useOpenPanelEditor();
// Modal drag-to-zoom is its own path (local window, not the grid's) — tag it distinctly.
const handleDragSelect = useCallback(
(start: number, end: number): void => {
if (Math.floor(start) !== Math.floor(end)) {
void logEvent(DashboardDetailEvents.PanelZoomed, {
context: 'viewModal',
panelType: draft.spec.plugin.kind,
panelId,
});
}
onDragSelect(start, end);
},
[onDragSelect, draft.spec.plugin.kind, panelId],
);
// Publish the modal's local extender for the nested no-data state; cleared on close.
const setViewPanelExtendWindow = useViewPanelStore(
(s) => s.setViewPanelExtendWindow,
);
useEffect(() => {
setViewPanelExtendWindow(extendWindow);
return (): void => setViewPanelExtendWindow(null);
}, [extendWindow, setViewPanelExtendWindow]);
// The View action only appears for registered kinds, so this is defensive.
if (!panelDefinition) {
return null;
}
const { EditorPane } = panelDefinition;
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
};
return (
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}
onTimeChange={onTimeChange}
isFetching={isFetching}
onRefresh={(): void => {
// Relative windows re-anchor to now (new key → refetch); a fixed
// custom window just re-runs the same query.
if (selectedInterval === 'custom') {
refetch();
} else {
refreshWindow();
}
}}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}
/>
<div className={styles.queryBuilder}>
<EditorPane
panelDefinition={panelDefinition}
signal={signal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
stickyHeader={false}
spec={draft.spec}
onChangeSpec={setSpec}
/>
</div>
<div className={styles.body}>
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={handleDragSelect}
pagination={pagination}
panelMode={PanelMode.STANDALONE_VIEW}
dashboardPreference={isolatedPreference}
onCloseStandaloneView={onClose}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
hideHeader
/>
</div>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}
export default QueryViewModalBody;

View File

@@ -1,93 +0,0 @@
import { useCallback } from 'react';
import { PenLine } from '@signozhq/icons';
import { Button } from '@signozhq/ui/button';
import { PanelMode } from 'lib/visualization/panels/types';
import logEvent from 'api/common/logEvent';
import PanelTypeSwitcher from 'pages/DashboardPage/DashboardContainer/PanelEditor/ConfigPane/PanelTypeSwitcher/PanelTypeSwitcher';
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 { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import { DashboardEvents } from 'pages/DashboardPage/constants/events';
import { EQueryType } from 'types/common/dashboard';
import styles from './ViewPanelModal.module.scss';
interface StaticViewModalBodyProps {
panelId: string;
draftApi: PanelEditorDraftApi;
panelDefinition: RenderableStaticPanelDefinition;
onChangePanelKind: (kind: PanelKind) => void;
}
/**
* 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.
*/
function StaticViewModalBody({
panelId,
draftApi,
panelDefinition,
onChangePanelKind,
}: StaticViewModalBodyProps): JSX.Element {
const { draft, spec, setSpec } = draftApi;
const { EditorPane, Renderer } = panelDefinition;
const openPanelEditor = useOpenPanelEditor();
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.
openPanelEditor(panelId, {
handoffState: { editSpec: { ...draft.spec, queries: [] } },
});
}, [openPanelEditor, panelId, draft.spec]);
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}>
<EditorPane spec={spec} onChangeSpec={setSpec} />
</div>
</div>
);
}
export default StaticViewModalBody;

View File

@@ -60,29 +60,3 @@
.panelTypeSelector {
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;
}

View File

@@ -1,22 +1,26 @@
import { useMemo } from 'react';
import type {
DashboardtypesPanelDTO,
DashboardtypesPanelSpecDTO,
} from 'api/generated/services/sigNoz.schemas';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { QueryParams } from 'constants/query';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
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 { buildViewPanelSpec } from 'pages/DashboardPage/DashboardContainer/Panels/utils/drilldown/buildViewPanelSpec';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import { useCallback, useEffect, useMemo } from 'react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
import { PanelMode } from 'lib/visualization/panels/types';
import { DashboardCursorSync } from 'lib/uPlotV2/plugins/TooltipPlugin/types';
import ContextMenu from 'periscope/components/ContextMenu';
import ListColumnsEditor from 'pages/DashboardPage/DashboardContainer/PanelEditor/ListColumnsEditor/ListColumnsEditor';
import PanelEditorQueryBuilder from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/PanelEditorQueryBuilder';
import PreviewPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PreviewPane/PreviewPane';
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
import { useViewPanelStore } from 'pages/DashboardPage/DashboardContainer/store/useViewPanelStore';
import { useOpenPanelEditor } from 'pages/DashboardPage/DashboardContainer/hooks/useOpenPanelEditor';
import QueryViewModalBody from './QueryViewModalBody';
import StaticViewModalBody from './StaticViewModalBody';
import { readViewPanelHandoff } from './viewPanelHandoffStore';
import { useDrilldown } from '../hooks/useDrilldown';
import { usePanelInteractions } from '../hooks/usePanelInteractions';
import ViewPanelModalHeader from './ViewPanelModalHeader';
import { useViewPanelMode } from './useViewPanelMode';
import { useViewPanelTimeWindow } from './useViewPanelTimeWindow';
import styles from './ViewPanelModal.module.scss';
import logEvent from 'api/common/logEvent';
import {
DashboardDetailEvents,
DashboardEvents,
} from 'pages/DashboardPage/constants/events';
interface ViewPanelModalContentProps {
panel: DashboardtypesPanelDTO;
@@ -26,79 +30,168 @@ interface ViewPanelModalContentProps {
}
/**
* View-modal shell. Owns the draft and the kind-switch cache — the state that
* must survive a switch between authoring modes — and forks on the draft kind's
* `mode`, so a static kind mounts no time window, query session or drilldown.
* Body of the View modal: a compact drilldown editor. It renders an editable draft of
* the panel (preview) over a per-view time window plus the shared query builder, so the
* user can tweak + Stage & Run without touching the dashboard. Edits are temporary.
*/
function ViewPanelModalContent({
panel,
panelId,
onClose,
}: ViewPanelModalContentProps): JSX.Element {
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the
// query still comes from the URL. Falls back to the saved panel for a plain "View".
const dashboardId = useDashboardStore((s) => s.dashboardId);
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
[],
);
}: ViewPanelModalContentProps): JSX.Element | null {
const {
timeOverride,
selectedInterval,
onTimeChange,
refreshWindow,
onDragSelect,
extendWindow,
} = useViewPanelTimeWindow();
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
const compositeQuery = useGetCompositeQueryParam();
const urlGraphType = useUrlQuery().get(
QueryParams.graphType,
) as PANEL_TYPES | null;
const initialPanel = useMemo<DashboardtypesPanelDTO>(
() => {
// A URL query can only seed a kind that takes one.
const isQuerySeeded =
compositeQuery && getPanelDefinition(baseSpec.plugin.kind).mode === 'query';
return isQuerySeeded
? {
...panel,
spec: buildViewPanelSpec({
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
}),
}
: { ...panel, spec: baseSpec };
},
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
[],
);
const {
draft,
setSpec,
panelDefinition,
signal,
queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
applyDrilldownQuery,
} = useViewPanelMode({ panel, panelId, time: timeOverride });
const {
data,
isFetching,
isPreviousData,
error,
refetch,
cancelQuery,
pagination,
} = query;
const draftApi = usePanelEditorDraft(initialPanel);
const draftKind = draftApi.draft.spec.plugin.kind;
const panelDefinition = getPanelDefinition(draftKind);
const isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
const { onChangePanelKind } = usePanelTypeSwitch({
spec: draftApi.draft.spec,
panelType: PANEL_KIND_TO_PANEL_TYPE[draftKind],
setSpec: draftApi.setSpec,
// Grid drill-down, but filter-by-value / breakout refine this view in place. Drills the draft
// so it reflects in-modal edits (and the click's time range follows the per-view window).
const drilldown = useDrilldown(draft, panelId, {
openDrilldownView: applyDrilldownQuery,
});
if (panelDefinition.mode === 'static') {
return (
<StaticViewModalBody
panelId={panelId}
draftApi={draftApi}
panelDefinition={panelDefinition}
onChangePanelKind={onChangePanelKind}
/>
);
// Drag-to-zoom stays inside the modal; opt the chart out of the dashboard's
// cursor-sync group so a drag here can't replay onto the grid panels.
const { dashboardPreference } = usePanelInteractions();
const isolatedPreference = useMemo<DashboardPreference>(
() => ({ ...dashboardPreference, syncMode: DashboardCursorSync.None }),
[dashboardPreference],
);
const openPanelEditor = useOpenPanelEditor();
// Modal drag-to-zoom is its own path (local window, not the grid's) — tag it distinctly.
const handleDragSelect = useCallback(
(start: number, end: number): void => {
if (Math.floor(start) !== Math.floor(end)) {
void logEvent(DashboardDetailEvents.PanelZoomed, {
context: 'viewModal',
panelType: draft.spec.plugin.kind,
panelId,
});
}
onDragSelect(start, end);
},
[onDragSelect, draft.spec.plugin.kind, panelId],
);
// Publish the modal's local extender for the nested no-data state; cleared on close.
const setViewPanelExtendWindow = useViewPanelStore(
(s) => s.setViewPanelExtendWindow,
);
useEffect(() => {
setViewPanelExtendWindow(extendWindow);
return (): void => setViewPanelExtendWindow(null);
}, [extendWindow, setViewPanelExtendWindow]);
// The View action only appears for registered kinds, so this is defensive.
if (!panelDefinition) {
return null;
}
const onSwitchToEdit = (): void => {
// Carry the drilldown edits so the editor opens on them, not the saved panel.
logEvent(DashboardEvents.SWITCH_TO_EDIT_MODE, {
panelId: panelId,
});
openPanelEditor(panelId, {
handoffState: { editSpec: buildSaveSpec(draft.spec) },
});
};
return (
<QueryViewModalBody
panel={panel}
panelId={panelId}
onClose={onClose}
draftApi={draftApi}
onChangePanelKind={onChangePanelKind}
/>
<div className={styles.content} data-testid="view-panel-modal-content">
<ViewPanelModalHeader
selectedInterval={selectedInterval}
startMs={timeOverride.startMs}
endMs={timeOverride.endMs}
onTimeChange={onTimeChange}
isFetching={isFetching}
onRefresh={(): void => {
// Relative windows re-anchor to now (new key → refetch); a fixed
// custom window just re-runs the same query.
if (selectedInterval === 'custom') {
refetch();
} else {
refreshWindow();
}
}}
onSwitchToEdit={onSwitchToEdit}
panelKind={draft.spec.plugin.kind}
queryType={queryType}
signal={signal}
onChangePanelKind={onChangePanelKind}
onResetQuery={resetQuery}
/>
<div className={styles.queryBuilder}>
<PanelEditorQueryBuilder
panelKind={draft.spec.plugin.kind}
signal={signal}
isLoadingQueries={isFetching}
onStageRunQuery={runQuery}
onCancelQuery={cancelQuery}
stickyHeader={false}
footer={
isListPanel ? (
<ListColumnsEditor
spec={draft.spec}
onChangeSpec={setSpec}
signal={signal}
/>
) : undefined
}
/>
</div>
<div className={styles.body}>
<PreviewPane
panelId={panelId}
panel={draft}
panelDefinition={panelDefinition}
data={data}
isFetching={isFetching}
isPreviousData={isPreviousData}
error={error}
refetch={refetch}
onDragSelect={handleDragSelect}
pagination={pagination}
panelMode={PanelMode.STANDALONE_VIEW}
dashboardPreference={isolatedPreference}
onCloseStandaloneView={onClose}
onClick={drilldown.onPanelClick}
enableDrillDown={drilldown.enableDrillDown}
hideHeader
/>
</div>
<ContextMenu {...drilldown.contextMenuProps} />
</div>
);
}

View File

@@ -5,12 +5,17 @@ import type {
TelemetrytypesSignalDTO,
} from 'api/generated/services/sigNoz.schemas';
import { QueryParams } from 'constants/query';
import { PANEL_TYPES } from 'constants/queryBuilder';
import { useGetCompositeQueryParam } from 'hooks/queryBuilder/useGetCompositeQueryParam';
import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
import useUrlQuery from 'hooks/useUrlQuery';
import { usePanelEditSession } from 'pages/DashboardPage/DashboardContainer/PanelEditor/hooks/usePanelEditSession';
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 type { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
import {
PANEL_KIND_TO_PANEL_TYPE,
type PanelKind,
} 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';
@@ -18,15 +23,16 @@ import {
type PanelQueryTimeOverride,
type UsePanelQueryResult,
} from 'pages/DashboardPage/DashboardContainer/hooks/usePanelQuery';
import { useDashboardStore } from 'pages/DashboardPage/DashboardContainer/store/useDashboardStore';
import type { EQueryType } from 'types/common/dashboard';
import { readViewPanelHandoff } from './viewPanelHandoffStore';
interface UseViewPanelModeArgs {
panel: DashboardtypesPanelDTO;
panelId: string;
/** Per-view time window (epoch ms); isolates the preview from the dashboard. */
time: PanelQueryTimeOverride;
/** Draft state, owned by the modal shell so it survives an authoring-mode switch. */
draftApi: PanelEditorDraftApi;
}
export interface UseViewPanelModeReturn {
@@ -35,7 +41,7 @@ export interface UseViewPanelModeReturn {
/** Update the draft's spec in place (e.g. the List columns editor). */
setSpec: (next: DashboardtypesPanelSpecDTO) => void;
/** Resolved renderer for the draft's current kind (registry always resolves a kind). */
panelDefinition: RenderableQueryPanelDefinition;
panelDefinition: RenderablePanelDefinition;
/**
* Builder datasource driving the query builder and the panel-type selector's
* disabled rule. Resolved from the query, falling back to the kind's default
@@ -48,6 +54,8 @@ export interface UseViewPanelModeReturn {
query: UsePanelQueryResult;
/** Stage & run the live builder query into the draft (drilldown; not persisted). */
runQuery: () => void;
/** Switch the draft's visualization kind (temporary; reversible per session). */
onChangePanelKind: (kind: PanelKind) => void;
/** Restore the query the view opened with, discarding in-modal edits. */
resetQuery: () => void;
/** Bake the live (possibly un-run) query into a spec — used to hand edits to the full editor. */
@@ -69,20 +77,51 @@ export function useViewPanelMode({
panel,
panelId,
time,
draftApi,
}: UseViewPanelModeArgs): UseViewPanelModeReturn {
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
// Config edits from the editor's "Switch to View Mode" arrive via the handoff; the query
// still comes from the URL. Falls back to the saved panel for a plain grid "View".
const dashboardId = useDashboardStore((s) => s.dashboardId);
const baseSpec = useMemo<DashboardtypesPanelSpecDTO>(
() => readViewPanelHandoff(dashboardId, panelId) ?? panel.spec,
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed
[],
);
// Mount-only so a refresh re-seeds and in-modal edits survive (V1 parity).
const compositeQuery = useGetCompositeQueryParam();
const urlGraphType = useUrlQuery().get(
QueryParams.graphType,
) as PANEL_TYPES | null;
const initialPanel = useMemo<DashboardtypesPanelDTO>(
() =>
compositeQuery
? {
...panel,
spec: buildViewPanelSpec({
spec: baseSpec,
query: compositeQuery,
panelType:
urlGraphType ?? PANEL_KIND_TO_PANEL_TYPE[baseSpec.plugin.kind],
}),
}
: { ...panel, spec: baseSpec },
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only seed from the URL
[],
);
const {
draft,
panelDefinition,
defaultSignal,
query,
runQuery,
onChangePanelKind,
buildSaveSpec,
reset,
setSpec,
} = usePanelEditSession({ panel, panelId, time, draftApi });
} = usePanelEditSession({ panel: initialPanel, panelId, time });
// The query the view opened with, captured once — the Reset target.
const savedQuery = useMemo(
@@ -139,6 +178,7 @@ export function useViewPanelMode({
queryType: currentQuery.queryType,
query,
runQuery,
onChangePanelKind,
resetQuery,
buildSaveSpec,
applyDrilldownQuery,

View File

@@ -1,115 +0,0 @@
import { render, screen } from '@testing-library/react';
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
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';
// Real registry by default; the static cases override per render.
jest.mock('pages/DashboardPage/DashboardContainer/Panels/registry', () => {
const actual = jest.requireActual(
'pages/DashboardPage/DashboardContainer/Panels/registry',
);
return { ...actual, getPanelDefinition: jest.fn(actual.getPanelDefinition) };
});
jest.mock('pages/DashboardPage/DashboardContainer/hooks/usePanelQuery', () => ({
usePanelQuery: jest.fn(),
}));
// Chrome + query-path collaborators stubbed: this file tests the mode fork, not them.
jest.mock('../PanelHeader/PanelHeader', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="panel-header" />,
}));
jest.mock('../PanelBody/PanelBody', () => ({
__esModule: true,
default: (): JSX.Element => <div data-testid="query-panel-body" />,
}));
jest.mock('../hooks/useDrilldown', () => ({
useDrilldown: (): unknown => ({
onPanelClick: jest.fn(),
enableDrillDown: false,
contextMenuProps: {},
}),
}));
jest.mock('../hooks/usePanelInteractions', () => ({
usePanelInteractions: (): unknown => ({
onDragSelect: jest.fn(),
dashboardPreference: undefined,
}),
}));
jest.mock('periscope/components/ContextMenu', () => ({
__esModule: true,
default: (): null => null,
}));
const mockUsePanelQuery = usePanelQuery as jest.Mock;
const mockGetPanelDefinition = getPanelDefinition as jest.Mock;
const panel = {
kind: 'Panel',
spec: {
display: { name: 'P' },
plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} },
queries: [],
},
} as unknown as DashboardtypesPanelDTO;
function StaticRenderer(props: { panelMode: string }): JSX.Element {
return <div data-testid="fake-static-renderer" data-mode={props.panelMode} />;
}
const staticDefinition = {
kind: 'signoz/TimeSeriesPanel',
displayName: 'Static',
sections: [],
actions: { search: false },
mode: 'static',
Renderer: StaticRenderer,
EditorPane: StaticRenderer,
};
describe('Panel — authoring-mode fork', () => {
beforeEach(() => {
mockUsePanelQuery.mockReset();
mockUsePanelQuery.mockReturnValue({
data: EMPTY_PANEL_QUERY_DATA,
isFetching: false,
isPreviousData: false,
error: null,
refetch: jest.fn(),
pagination: undefined,
});
});
it('mounts the query body and fetch for a query kind', () => {
render(<Panel panel={panel} panelId="p1" />);
expect(screen.getByTestId('query-panel-body')).toBeInTheDocument();
expect(mockUsePanelQuery).toHaveBeenCalledTimes(1);
});
it('mounts the static renderer for a static kind with no query hook at all', () => {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);
render(<Panel panel={panel} panelId="p1" />);
expect(screen.getByTestId('static-panel-body')).toBeInTheDocument();
expect(screen.getByTestId('fake-static-renderer')).toBeInTheDocument();
expect(screen.queryByTestId('query-panel-body')).not.toBeInTheDocument();
expect(mockUsePanelQuery).not.toHaveBeenCalled();
});
it('renders the static body in dashboard-view mode with panel chrome', () => {
mockGetPanelDefinition.mockReturnValueOnce(staticDefinition);
render(<Panel panel={panel} panelId="p1" />);
expect(screen.getByTestId('fake-static-renderer')).toHaveAttribute(
'data-mode',
'DASHBOARD_VIEW',
);
expect(screen.getByTestId('panel-header')).toBeInTheDocument();
});
});

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