mirror of
https://github.com/SigNoz/signoz.git
synced 2026-09-11 14:00:42 +01:00
Compare commits
5 Commits
feat/panel
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4175f84815 | ||
|
|
fd032291f9 | ||
|
|
851abd2c93 | ||
|
|
c41899f2eb | ||
|
|
24b6debd78 |
@@ -296,6 +296,17 @@ components:
|
||||
- jsmops
|
||||
- incidentio
|
||||
type: string
|
||||
AlertmanagertypesChannelListOrder:
|
||||
enum:
|
||||
- asc
|
||||
- desc
|
||||
type: string
|
||||
AlertmanagertypesChannelListSort:
|
||||
enum:
|
||||
- updated_at
|
||||
- created_at
|
||||
- name
|
||||
type: string
|
||||
AlertmanagertypesChannelMSTeamsConfig:
|
||||
properties:
|
||||
sendResolved:
|
||||
@@ -576,6 +587,43 @@ 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
|
||||
@@ -942,6 +990,20 @@ 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:
|
||||
@@ -7395,10 +7457,7 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
meta:
|
||||
properties:
|
||||
unit:
|
||||
type: string
|
||||
type: object
|
||||
$ref: '#/components/schemas/Querybuildertypesv5AggregationMeta'
|
||||
predictedSeries:
|
||||
items:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
@@ -7413,12 +7472,51 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TimeSeries'
|
||||
type: array
|
||||
type: object
|
||||
Querybuildertypesv5Bucket:
|
||||
Querybuildertypesv5AggregationMeta:
|
||||
properties:
|
||||
step:
|
||||
format: double
|
||||
type: number
|
||||
buckets:
|
||||
items:
|
||||
format: double
|
||||
type: number
|
||||
type: array
|
||||
unit:
|
||||
type: string
|
||||
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:
|
||||
@@ -7599,6 +7697,16 @@ components:
|
||||
value:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LinearBucketsSpec:
|
||||
properties:
|
||||
maxValue:
|
||||
format: double
|
||||
type: number
|
||||
numBuckets:
|
||||
type: integer
|
||||
required:
|
||||
- maxValue
|
||||
type: object
|
||||
Querybuildertypesv5LogAggregation:
|
||||
properties:
|
||||
alias:
|
||||
@@ -7606,6 +7714,12 @@ components:
|
||||
expression:
|
||||
type: string
|
||||
type: object
|
||||
Querybuildertypesv5LogBucketsSpec:
|
||||
properties:
|
||||
scale:
|
||||
nullable: true
|
||||
type: integer
|
||||
type: object
|
||||
Querybuildertypesv5MetricAggregation:
|
||||
properties:
|
||||
comparisonSpaceAggregationParam:
|
||||
@@ -7686,6 +7800,8 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5QueryBuilderFormula:
|
||||
properties:
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
disabled:
|
||||
type: boolean
|
||||
expression:
|
||||
@@ -7716,6 +7832,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5LogAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7777,6 +7895,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5MetricAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -7838,6 +7958,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5TraceAggregation'
|
||||
nullable: true
|
||||
type: array
|
||||
bucketOptions:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5BucketOptions'
|
||||
cursor:
|
||||
type: string
|
||||
disabled:
|
||||
@@ -8163,6 +8285,7 @@ components:
|
||||
- raw
|
||||
- raw_stream
|
||||
- trace
|
||||
- heatmap
|
||||
type: string
|
||||
Querybuildertypesv5ScalarData:
|
||||
properties:
|
||||
@@ -8237,8 +8360,6 @@ components:
|
||||
type: object
|
||||
Querybuildertypesv5TimeSeriesValue:
|
||||
properties:
|
||||
bucket:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Bucket'
|
||||
partial:
|
||||
type: boolean
|
||||
timestamp:
|
||||
@@ -8349,6 +8470,8 @@ components:
|
||||
$ref: '#/components/schemas/RuletypesAlertState'
|
||||
overallStateChanged:
|
||||
type: boolean
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8392,6 +8515,8 @@ components:
|
||||
$ref: '#/components/schemas/Querybuildertypesv5Label'
|
||||
nullable: true
|
||||
type: array
|
||||
relatedAITracesLink:
|
||||
type: string
|
||||
relatedLogsLink:
|
||||
type: string
|
||||
relatedTracesLink:
|
||||
@@ -8497,6 +8622,7 @@ components:
|
||||
- TRACES_BASED_ALERT
|
||||
- LOGS_BASED_ALERT
|
||||
- EXCEPTIONS_BASED_ALERT
|
||||
- AI_TRACES_BASED_ALERT
|
||||
type: string
|
||||
RuletypesBasicRuleThreshold:
|
||||
properties:
|
||||
@@ -19720,6 +19846,86 @@ 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
|
||||
@@ -19782,6 +19988,239 @@ 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
|
||||
|
||||
@@ -56,10 +56,10 @@ const config: Config.InitialOptions = {
|
||||
transformIgnorePatterns: [
|
||||
// @chenglou/pretext is ESM-only; @signozhq/ui pulls it in via text-ellipsis.
|
||||
// Pattern 1: allow .pnpm virtual store through (handled by pattern 2), plus root-level ESM packages.
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
'node_modules/(?!(\\.pnpm|react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou/pretext|@signozhq/design-tokens|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|micromark-core-commonmark|micromark-extension-gfm|micromark-extension-gfm-autolink-literal|micromark-extension-gfm-footnote|micromark-extension-gfm-strikethrough|micromark-extension-gfm-table|micromark-extension-gfm-tagfilter|micromark-extension-gfm-task-list-item|micromark-factory-destination|micromark-factory-label|micromark-factory-space|micromark-factory-title|micromark-factory-whitespace|micromark-util-character|micromark-util-chunked|micromark-util-classify-character|micromark-util-combine-extensions|micromark-util-decode-numeric-character-reference|micromark-util-decode-string|micromark-util-encode|micromark-util-html-tag-name|micromark-util-normalize-identifier|micromark-util-resolve-all|micromark-util-sanitize-uri|micromark-util-subtokenize|micromark-util-symbol|micromark-util-types|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)/)',
|
||||
// Pattern 2: pnpm virtual store — ignore everything except ESM-only packages.
|
||||
// pnpm encodes scoped packages as @scope+name@version, so match on scope prefix.
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
'node_modules/\\.pnpm/(?!(react-json-tree|react-base16-styling|lodash-es|react-dnd|core-dnd|@react-dnd|dnd-core|react-dnd-html5-backend|axios|@chenglou|@signozhq|date-fns|d3-interpolate|d3-color|api|@codemirror|@lezer|@marijn|@grafana|nuqs|uuid|copy-text-to-clipboard|react-markdown|vfile|vfile-message|unist-util-stringify-position|unified|bail|is-plain-obj|trough|remark-parse|remark-gfm|mdast-util-gfm|mdast-util-gfm-autolink-literal|mdast-util-gfm-footnote|mdast-util-gfm-strikethrough|mdast-util-gfm-table|mdast-util-gfm-task-list-item|mdast-util-find-and-replace|mdast-util-phrasing|mdast-util-to-markdown|markdown-table|longest-streak|ccount|escape-string-regexp|mdast-util-from-markdown|mdast-util-to-string|micromark|decode-named-character-reference|remark-rehype|mdast-util-to-hast|unist-util-position|trim-lines|unist-util-visit|unist-util-visit-parents|unist-util-is|unist-util-generated|mdast-util-definitions|property-information|hast-util-whitespace|space-separated-tokens|comma-separated-tokens|rehype-raw|hast-util-raw|hast-util-from-parse5|devlop|hastscript|hast-util-parse-selector|vfile-location|web-namespaces|hast-util-to-parse5|zwitch|html-void-elements)[^/]*/node_modules)',
|
||||
],
|
||||
setupFilesAfterEnv: ['<rootDir>/jest.setup.ts'],
|
||||
testPathIgnorePatterns: ['/node_modules/', '/public/'],
|
||||
|
||||
@@ -21,14 +21,23 @@ 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';
|
||||
@@ -649,6 +658,105 @@ 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
|
||||
@@ -733,3 +841,370 @@ 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));
|
||||
};
|
||||
|
||||
@@ -507,6 +507,15 @@ 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;
|
||||
}
|
||||
@@ -1000,6 +1009,44 @@ 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',
|
||||
@@ -2391,6 +2438,14 @@ export interface AlertmanagertypesReceiverDTO {
|
||||
wechat_configs?: ConfigWechatConfigDTO[];
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesTestableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AlertmanagertypesUpdatableNotificationChannelDTO {
|
||||
config: AlertmanagertypesChannelConfigDTO;
|
||||
}
|
||||
|
||||
export interface AuthtypesAttributeMappingDTO {
|
||||
/**
|
||||
* @type string
|
||||
@@ -4079,6 +4134,53 @@ 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
|
||||
@@ -4272,6 +4374,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5LogAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4399,6 +4502,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5MetricAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4474,6 +4578,7 @@ export interface Querybuildertypesv5QueryBuilderQueryGithubComSigNozSignozPkgTyp
|
||||
* @type array,null
|
||||
*/
|
||||
aggregations?: Querybuildertypesv5TraceAggregationDTO[] | null;
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -4929,6 +5034,7 @@ export enum Querybuildertypesv5RequestTypeDTO {
|
||||
raw = 'raw',
|
||||
raw_stream = 'raw_stream',
|
||||
trace = 'trace',
|
||||
heatmap = 'heatmap',
|
||||
}
|
||||
export enum DashboardtypesQueryPluginVariantGithubComSigNozSignozPkgTypesDashboardtypesBuilderQuerySpecDTOKind {
|
||||
'signoz/BuilderQuery' = 'signoz/BuilderQuery',
|
||||
@@ -4975,6 +5081,7 @@ export interface Querybuildertypesv5QueryEnvelopeBuilderAIDTO {
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5QueryBuilderFormulaDTO {
|
||||
bucketOptions?: Querybuildertypesv5BucketOptionsDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -8542,16 +8649,7 @@ export interface Querybuildertypesv5LabelDTO {
|
||||
value?: Querybuildertypesv5LabelDTOValue;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5BucketDTO {
|
||||
/**
|
||||
* @type number
|
||||
* @format double
|
||||
*/
|
||||
step?: number;
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5TimeSeriesValueDTO {
|
||||
bucket?: Querybuildertypesv5BucketDTO;
|
||||
/**
|
||||
* @type boolean
|
||||
*/
|
||||
@@ -9102,12 +9200,16 @@ export interface PromotetypesPromotePathDTO {
|
||||
promote?: boolean;
|
||||
}
|
||||
|
||||
export type Querybuildertypesv5AggregationBucketDTOMeta = {
|
||||
export interface Querybuildertypesv5AggregationMetaDTO {
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
buckets?: number[];
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
unit?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
/**
|
||||
@@ -9126,10 +9228,7 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
* @type array
|
||||
*/
|
||||
lowerBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
/**
|
||||
* @type object
|
||||
*/
|
||||
meta?: Querybuildertypesv5AggregationBucketDTOMeta;
|
||||
meta?: Querybuildertypesv5AggregationMetaDTO;
|
||||
/**
|
||||
* @type array
|
||||
*/
|
||||
@@ -9144,6 +9243,10 @@ export interface Querybuildertypesv5AggregationBucketDTO {
|
||||
upperBoundSeries?: Querybuildertypesv5TimeSeriesDTO[];
|
||||
}
|
||||
|
||||
export enum Querybuildertypesv5BucketsKindDTO {
|
||||
linear = 'linear',
|
||||
log = 'log',
|
||||
}
|
||||
export type Querybuildertypesv5ColumnDescriptorDTOMeta = {
|
||||
/**
|
||||
* @type string
|
||||
@@ -9610,6 +9713,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryDTO {
|
||||
* @type boolean
|
||||
*/
|
||||
overallStateChanged: boolean;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9658,6 +9765,10 @@ export interface RulestatehistorytypesGettableRuleStateHistoryContributorDTO {
|
||||
* @type array,null
|
||||
*/
|
||||
labels: Querybuildertypesv5LabelDTO[] | null;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
relatedAITracesLink?: string;
|
||||
/**
|
||||
* @type string
|
||||
*/
|
||||
@@ -9753,6 +9864,7 @@ 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',
|
||||
@@ -13162,6 +13274,44 @@ 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;
|
||||
/**
|
||||
@@ -13170,6 +13320,31 @@ 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;
|
||||
/**
|
||||
|
||||
45
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
45
frontend/src/components/MarkdownEditor/EditorStatusBar.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
93
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
93
frontend/src/components/MarkdownEditor/EditorToolbar.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
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;
|
||||
@@ -0,0 +1,91 @@
|
||||
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;
|
||||
@@ -0,0 +1,253 @@
|
||||
@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);
|
||||
}
|
||||
245
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
245
frontend/src/components/MarkdownEditor/MarkdownEditor.tsx
Normal file
@@ -0,0 +1,245 @@
|
||||
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;
|
||||
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
44
frontend/src/components/MarkdownEditor/MarkdownHelp.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import { CircleHelp } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@signozhq/ui/popover';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import { MARKDOWN_HELP_ITEMS } from './constants';
|
||||
|
||||
import styles from './MarkdownEditor.module.scss';
|
||||
|
||||
function MarkdownHelp(): JSX.Element {
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label="Markdown syntax help"
|
||||
data-testid="markdown-help-trigger"
|
||||
>
|
||||
<CircleHelp size={14} />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="end" className={styles.helpContent}>
|
||||
<Typography.Text className={styles.helpTitle}>
|
||||
Markdown syntax
|
||||
</Typography.Text>
|
||||
<dl className={styles.helpList}>
|
||||
{MARKDOWN_HELP_ITEMS.map((item) => (
|
||||
<div key={item.syntax} className={styles.helpRow}>
|
||||
<dt>
|
||||
<code>{item.syntax}</code>
|
||||
</dt>
|
||||
<dd>{item.label}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
|
||||
export default MarkdownHelp;
|
||||
@@ -0,0 +1,270 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,258 @@
|
||||
import { insertText, MARKDOWN_COMMANDS } from '../markdownCommands';
|
||||
import type { EditorSnapshot, EditorTransform } from '../types';
|
||||
|
||||
const commandById = (id: string): EditorTransform => {
|
||||
const command = MARKDOWN_COMMANDS.find((entry) => entry.id === id);
|
||||
if (!command) {
|
||||
throw new Error(`unknown command: ${id}`);
|
||||
}
|
||||
return command.run;
|
||||
};
|
||||
|
||||
const heading = commandById('heading');
|
||||
const bold = commandById('bold');
|
||||
const italic = commandById('italic');
|
||||
const bulletedList = commandById('bulleted-list');
|
||||
const numberedList = commandById('numbered-list');
|
||||
const link = commandById('link');
|
||||
const code = commandById('code');
|
||||
const table = commandById('table');
|
||||
|
||||
/** `|` marks a caret, `[...]` a range, so expectations read like the editor looks. */
|
||||
const snapshot = (marked: string): EditorSnapshot => {
|
||||
if (marked.includes('|')) {
|
||||
const caret = marked.indexOf('|');
|
||||
return {
|
||||
text: marked.replace('|', ''),
|
||||
selectionStart: caret,
|
||||
selectionEnd: caret,
|
||||
};
|
||||
}
|
||||
const start = marked.indexOf('[');
|
||||
const end = marked.indexOf(']') - 1;
|
||||
return {
|
||||
text: marked.replace('[', '').replace(']', ''),
|
||||
selectionStart: start,
|
||||
selectionEnd: end,
|
||||
};
|
||||
};
|
||||
|
||||
const selectionOf = (result: EditorSnapshot): string =>
|
||||
result.text.slice(result.selectionStart, result.selectionEnd);
|
||||
|
||||
describe('heading', () => {
|
||||
it('prefixes the caret line and keeps the caret on the same character', () => {
|
||||
const result = heading(snapshot('Chec|kout'));
|
||||
|
||||
expect(result.text).toBe('## Checkout');
|
||||
expect(result.selectionStart).toBe(7);
|
||||
});
|
||||
|
||||
it('strips the prefix when every selected line already has one', () => {
|
||||
const result = heading({
|
||||
text: '## one\n### two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
|
||||
it('adds the prefix when only some selected lines have one', () => {
|
||||
const result = heading({
|
||||
text: '## one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## ## one\n## two');
|
||||
});
|
||||
|
||||
it('does not pull in the line after a selection ending on a line break', () => {
|
||||
const result = heading({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 4,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('## one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bulleted list', () => {
|
||||
it('bullets every line of a multi-line selection', () => {
|
||||
const result = bulletedList({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
expect(selectionOf(result)).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('unbullets a list written with a different marker', () => {
|
||||
const result = bulletedList({
|
||||
text: '* one\n+ two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('numbered list', () => {
|
||||
it('numbers each line of the selection in order', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\ntwo\nthree',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two\n3. three');
|
||||
});
|
||||
|
||||
it('unnumbers a list whose numbering is not sequential', () => {
|
||||
const result = numberedList({
|
||||
text: '1. one\n5. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('switching between list kinds', () => {
|
||||
it('converts bullets to numbers rather than marking them twice', () => {
|
||||
const result = numberedList({
|
||||
text: '- one\n- two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 11,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n2. two');
|
||||
});
|
||||
|
||||
it('converts numbers to bullets', () => {
|
||||
const result = bulletedList({
|
||||
text: '1. one\n2. two',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 13,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('- one\n- two');
|
||||
});
|
||||
|
||||
it('keeps indentation so nested items stay nested', () => {
|
||||
const result = numberedList({
|
||||
text: 'one\n - nested',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 16,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('1. one\n 2. nested');
|
||||
});
|
||||
});
|
||||
|
||||
describe('bold and italic', () => {
|
||||
it('wraps the selection and keeps the original text selected', () => {
|
||||
const result = bold(snapshot('a [word] b'));
|
||||
|
||||
expect(result.text).toBe('a **word** b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit inside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 2,
|
||||
selectionEnd: 10,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('unwraps when the markers sit just outside the selection', () => {
|
||||
const result = bold({
|
||||
text: 'a **word** b',
|
||||
selectionStart: 4,
|
||||
selectionEnd: 8,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('a word b');
|
||||
expect(selectionOf(result)).toBe('word');
|
||||
});
|
||||
|
||||
it('leaves the caret between the markers when nothing is selected', () => {
|
||||
const result = italic(snapshot('a |b'));
|
||||
|
||||
expect(result.text).toBe('a __b');
|
||||
expect(result.selectionStart).toBe(3);
|
||||
expect(result.selectionEnd).toBe(3);
|
||||
});
|
||||
|
||||
it('does not mistake a leading document boundary for a marker', () => {
|
||||
const result = bold(snapshot('[word] tail'));
|
||||
|
||||
expect(result.text).toBe('**word** tail');
|
||||
});
|
||||
});
|
||||
|
||||
describe('link', () => {
|
||||
it('selects the url when the label came from the selection', () => {
|
||||
const result = link(snapshot('see [docs] now'));
|
||||
|
||||
expect(result.text).toBe('see [docs](https://) now');
|
||||
expect(selectionOf(result)).toBe('https://');
|
||||
});
|
||||
|
||||
it('selects the label placeholder when nothing was selected', () => {
|
||||
const result = link(snapshot('see |'));
|
||||
|
||||
expect(result.text).toBe('see [text](https://)');
|
||||
expect(selectionOf(result)).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('code', () => {
|
||||
it('uses backticks for a single-line selection', () => {
|
||||
const result = code(snapshot('run [npm] here'));
|
||||
|
||||
expect(result.text).toBe('run `npm` here');
|
||||
});
|
||||
|
||||
it('fences a multi-line selection and selects its content', () => {
|
||||
const result = code({
|
||||
text: 'one\ntwo',
|
||||
selectionStart: 0,
|
||||
selectionEnd: 7,
|
||||
});
|
||||
|
||||
expect(result.text).toBe('```\none\ntwo\n```');
|
||||
expect(selectionOf(result)).toBe('one\ntwo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('table', () => {
|
||||
it('starts the skeleton on its own line and selects the first header cell', () => {
|
||||
const result = table(snapshot('intro|'));
|
||||
|
||||
expect(result.text).toBe(
|
||||
'intro\n| Column | Column |\n| --- | --- |\n| | |',
|
||||
);
|
||||
expect(selectionOf(result)).toBe('Column');
|
||||
});
|
||||
});
|
||||
|
||||
describe('insertText', () => {
|
||||
it('replaces the selection and leaves the caret after the insertion', () => {
|
||||
const result = insertText(snapshot('env is [old]'), '{{env}}');
|
||||
|
||||
expect(result.text).toBe('env is {{env}}');
|
||||
expect(result.selectionStart).toBe(14);
|
||||
expect(result.selectionEnd).toBe(14);
|
||||
});
|
||||
});
|
||||
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
24
frontend/src/components/MarkdownEditor/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// The body is persisted inline in the dashboard JSON, so its length is capped.
|
||||
export const MARKDOWN_MAX_LENGTH = 16000;
|
||||
|
||||
/** The canonical syntax; the renderer resolves the other three too. */
|
||||
export const formatVariableToken = (name: string): string => `$${name}`;
|
||||
|
||||
export const MARKDOWN_HELP_ITEMS: { syntax: string; label: string }[] = [
|
||||
// First: consecutive lines joining into one paragraph is the CommonMark rule
|
||||
// authors trip over before any of the formatting syntax.
|
||||
{ syntax: 'blank line', label: 'New paragraph' },
|
||||
{ syntax: '2 spaces + ⏎', label: 'Line break' },
|
||||
{ syntax: '# Heading', label: 'Heading (1–6 #)' },
|
||||
{ syntax: '**bold**', label: 'Bold' },
|
||||
{ syntax: '_italic_', label: 'Italic' },
|
||||
{ syntax: '- item', label: 'Bulleted list' },
|
||||
{ syntax: '1. item', label: 'Numbered list' },
|
||||
{ syntax: '- [ ] task', label: 'Task list' },
|
||||
{ syntax: '[label](url)', label: 'Link' },
|
||||
{ syntax: '', label: 'Image' },
|
||||
{ syntax: '`code`', label: 'Inline code' },
|
||||
{ syntax: '```lang', label: 'Code block' },
|
||||
{ syntax: '> quote', label: 'Blockquote' },
|
||||
{ syntax: '| a | b |', label: 'Table' },
|
||||
];
|
||||
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
69
frontend/src/components/MarkdownEditor/editorDocument.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { EditorView } from '@uiw/react-codemirror';
|
||||
|
||||
import type { EditorSnapshot, EditorTransform } from './types';
|
||||
|
||||
// Narrows a whole-document replacement to the range that changed, so a toolbar
|
||||
// action doesn't invalidate the document's decorations or scroll position.
|
||||
function toChangeSpec(
|
||||
previous: string,
|
||||
next: string,
|
||||
): { from: number; to: number; insert: string } | null {
|
||||
if (previous === next) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const shorter = Math.min(previous.length, next.length);
|
||||
let start = 0;
|
||||
while (start < shorter && previous[start] === next[start]) {
|
||||
start += 1;
|
||||
}
|
||||
|
||||
let previousEnd = previous.length;
|
||||
let nextEnd = next.length;
|
||||
while (
|
||||
previousEnd > start &&
|
||||
nextEnd > start &&
|
||||
previous[previousEnd - 1] === next[nextEnd - 1]
|
||||
) {
|
||||
previousEnd -= 1;
|
||||
nextEnd -= 1;
|
||||
}
|
||||
|
||||
return { from: start, to: previousEnd, insert: next.slice(start, nextEnd) };
|
||||
}
|
||||
|
||||
export function readSnapshot(view: EditorView): EditorSnapshot {
|
||||
const range = view.state.selection.main;
|
||||
return {
|
||||
text: view.state.doc.toString(),
|
||||
selectionStart: range.from,
|
||||
selectionEnd: range.to,
|
||||
};
|
||||
}
|
||||
|
||||
/** Returns whether the transform ran, as CodeMirror's keymap contract expects. */
|
||||
export function applyTransform(
|
||||
view: EditorView,
|
||||
transform: EditorTransform,
|
||||
): boolean {
|
||||
if (view.state.readOnly) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const next = transform(readSnapshot(view));
|
||||
const changes = toChangeSpec(view.state.doc.toString(), next.text);
|
||||
view.dispatch({
|
||||
...(changes ? { changes } : {}),
|
||||
selection: { anchor: next.selectionStart, head: next.selectionEnd },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Replaces the whole document, for seeding and external replacements. */
|
||||
export function replaceDocument(view: EditorView, next: string): void {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: next },
|
||||
});
|
||||
}
|
||||
269
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
269
frontend/src/components/MarkdownEditor/markdownCommands.ts
Normal file
@@ -0,0 +1,269 @@
|
||||
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 },
|
||||
];
|
||||
149
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
149
frontend/src/components/MarkdownEditor/markdownHighlight.ts
Normal file
@@ -0,0 +1,149 @@
|
||||
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];
|
||||
}
|
||||
27
frontend/src/components/MarkdownEditor/types.ts
Normal file
27
frontend/src/components/MarkdownEditor/types.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/** 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;
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
@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);
|
||||
}
|
||||
204
frontend/src/lib/uPlotV2/components/Legend/Legend.styles.scss
Normal file
204
frontend/src/lib/uPlotV2/components/Legend/Legend.styles.scss
Normal file
@@ -0,0 +1,204 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,136 +1,139 @@
|
||||
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_ITEM_EXTRA_WIDTH, MAX_LEGEND_WIDTH } from './constants';
|
||||
import LegendRow from './LegendRow';
|
||||
import LegendToolbar from './LegendToolbar';
|
||||
import { filterLegendItems, getShownSeriesState } from './utils';
|
||||
import './Legend.styles.scss';
|
||||
|
||||
import styles from './Legend.module.scss';
|
||||
export const MAX_LEGEND_WIDTH = 240;
|
||||
|
||||
/**
|
||||
* Presentational legend, source-agnostic: the uPlot charts feed it via
|
||||
* UPlotLegend, Pie feeds it directly. Every state change is delegated.
|
||||
* 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.
|
||||
*/
|
||||
export default function Legend({
|
||||
items,
|
||||
position,
|
||||
averageLegendWidth = MAX_LEGEND_WIDTH,
|
||||
focusedSeriesIndex,
|
||||
onToggleSeries,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
onClick,
|
||||
onMouseMove,
|
||||
onMouseLeave,
|
||||
showCopy = true,
|
||||
}: LegendProps): JSX.Element {
|
||||
const legendContainerRef = useRef<HTMLDivElement | null>(null);
|
||||
const [filterQuery, setFilterQuery] = useState('');
|
||||
const [legendSearchQuery, setLegendSearchQuery] = useState('');
|
||||
|
||||
const itemWidth = averageLegendWidth + LEGEND_ITEM_EXTRA_WIDTH;
|
||||
const isRightPosition = position === LegendPosition.RIGHT;
|
||||
// Search is intrinsic to the right-positioned legend.
|
||||
const searchEnabled = position === LegendPosition.RIGHT;
|
||||
const { width: containerWidth } = useResizeObserver(legendContainerRef);
|
||||
|
||||
const { visibleCount, soleShownSeriesIndex } = useMemo(
|
||||
() => getShownSeriesState(items),
|
||||
[items],
|
||||
);
|
||||
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]);
|
||||
|
||||
// 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 visibleLegendItems = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return items;
|
||||
}
|
||||
|
||||
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 query = legendSearchQuery.trim().toLowerCase();
|
||||
return items.filter((item) => item.label?.toLowerCase().includes(query));
|
||||
}, [searchEnabled, legendSearchQuery, items]);
|
||||
|
||||
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,
|
||||
],
|
||||
(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 isEmptyState = useMemo(() => {
|
||||
if (!searchEnabled || !legendSearchQuery.trim()) {
|
||||
return false;
|
||||
}
|
||||
return visibleLegendItems.length === 0;
|
||||
}, [searchEnabled, legendSearchQuery, visibleLegendItems]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={legendContainerRef}
|
||||
className={cx(styles.container, {
|
||||
[styles.isRight]: isRightPosition,
|
||||
})}
|
||||
style={{ ['--legend-item-width' as string]: `${itemWidth}px` }}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
data-testid="legend-container"
|
||||
className="legend-container"
|
||||
onClick={onClick}
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
style={{
|
||||
['--legend-average-width' as string]: `${averageLegendWidth + 16}px`, // 16px is the marker width
|
||||
}}
|
||||
>
|
||||
{showToolbar && (
|
||||
<LegendToolbar
|
||||
visibleCount={visibleCount}
|
||||
totalCount={items.length}
|
||||
showFilter={showFilter}
|
||||
filterQuery={filterQuery}
|
||||
onFilterQueryChange={setFilterQuery}
|
||||
/>
|
||||
{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>
|
||||
)}
|
||||
{isEmptyState ? (
|
||||
<div className={styles.emptyState}>
|
||||
No series found matching "{effectiveQuery}"
|
||||
<div className="legend-empty-state">
|
||||
No series found matching "{legendSearchQuery}"
|
||||
</div>
|
||||
) : (
|
||||
<VirtuosoGrid
|
||||
className={styles.scroller}
|
||||
listClassName={styles.gridList}
|
||||
itemClassName={styles.gridItem}
|
||||
className={cx(
|
||||
'legend-virtuoso-container',
|
||||
`legend-virtuoso-container-${position.toLowerCase()}`,
|
||||
{ 'legend-virtuoso-container-single-row': isSingleRow },
|
||||
)}
|
||||
data={visibleLegendItems}
|
||||
itemContent={(_, item): JSX.Element => renderLegendItem(item)}
|
||||
/>
|
||||
|
||||
@@ -1,180 +0,0 @@
|
||||
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);
|
||||
@@ -1,56 +0,0 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 series interactions from the
|
||||
* plot context (useLegendActions), then renders the presentational Legend.
|
||||
* from the chart config (useLegendsSync) and the toggle/focus interactions from
|
||||
* the plot context (useLegendActions), then renders the presentational Legend.
|
||||
* Must be rendered inside a PlotContextProvider.
|
||||
*/
|
||||
export default function UPlotLegend({
|
||||
@@ -17,9 +17,13 @@ export default function UPlotLegend({
|
||||
config,
|
||||
averageLegendWidth,
|
||||
}: UPlotLegendProps): JSX.Element {
|
||||
const { legendItemsMap, focusedSeriesIndex } = useLegendsSync({ config });
|
||||
const { onToggleSeries, onShowOnlySeries, onShowAllSeries, onHoverSeries } =
|
||||
useLegendActions();
|
||||
const { legendItemsMap, focusedSeriesIndex, setFocusedSeriesIndex } =
|
||||
useLegendsSync({ config });
|
||||
const { onLegendClick, onLegendMouseMove, onLegendMouseLeave } =
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex,
|
||||
focusedSeriesIndex,
|
||||
});
|
||||
|
||||
const items = useMemo(() => Object.values(legendItemsMap), [legendItemsMap]);
|
||||
|
||||
@@ -29,10 +33,9 @@ export default function UPlotLegend({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onToggleSeries={onToggleSeries}
|
||||
onShowOnlySeries={onShowOnlySeries}
|
||||
onShowAllSeries={onShowAllSeries}
|
||||
onHoverSeries={onHoverSeries}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +0,0 @@
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -1,20 +0,0 @@
|
||||
/** 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;
|
||||
@@ -1,34 +0,0 @@
|
||||
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),
|
||||
);
|
||||
}
|
||||
@@ -12,11 +12,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Matches the legend row's marker.
|
||||
.uplotTooltipItemMarker {
|
||||
border-radius: var(--radius);
|
||||
border-radius: 50%;
|
||||
border-style: solid;
|
||||
border-width: 1.5px;
|
||||
border-width: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
box-sizing: border-box;
|
||||
@@ -31,23 +30,11 @@
|
||||
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;
|
||||
|
||||
@@ -25,7 +25,7 @@ export default function TooltipItem({
|
||||
>
|
||||
<div
|
||||
className={Styles.uplotTooltipItemMarker}
|
||||
style={{ borderColor: item.color, backgroundColor: item.color }}
|
||||
style={{ borderColor: 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 className={Styles.uplotTooltipItemValue}>{item.tooltipValue}</span>
|
||||
<span>{item.tooltipValue}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -61,16 +61,16 @@ describe('UPlotLegend', () => {
|
||||
},
|
||||
};
|
||||
|
||||
let onToggleSeries: jest.Mock;
|
||||
let onShowOnlySeries: jest.Mock;
|
||||
let onShowAllSeries: jest.Mock;
|
||||
let onHoverSeries: jest.Mock;
|
||||
let onLegendClick: jest.Mock;
|
||||
let onLegendMouseMove: jest.Mock;
|
||||
let onLegendMouseLeave: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
|
||||
beforeEach(() => {
|
||||
onToggleSeries = jest.fn();
|
||||
onShowOnlySeries = jest.fn();
|
||||
onShowAllSeries = jest.fn();
|
||||
onHoverSeries = jest.fn();
|
||||
onLegendClick = jest.fn();
|
||||
onLegendMouseMove = jest.fn();
|
||||
onLegendMouseLeave = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
|
||||
mockUseLegendsSync.mockReturnValue({
|
||||
legendItemsMap: baseLegendItemsMap,
|
||||
@@ -79,10 +79,10 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
|
||||
mockUseLegendActions.mockReturnValue({
|
||||
onToggleSeries,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
onFocusSeries,
|
||||
});
|
||||
});
|
||||
|
||||
@@ -102,39 +102,28 @@ describe('UPlotLegend', () => {
|
||||
);
|
||||
|
||||
describe('layout and position', () => {
|
||||
it('renders the search input on a RIGHT legend', () => {
|
||||
it('renders search input when legend position is RIGHT', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(screen.getByTestId('legend-search-input')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('keeps a BOTTOM legend bare — its two rows all go to series', () => {
|
||||
it('does not render search input when legend position is BOTTOM (default)', () => {
|
||||
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 series colour, filled only when shown', () => {
|
||||
it('renders the marker with the correct border color', () => {
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
expect(
|
||||
document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
),
|
||||
).toHaveStyle({
|
||||
const legendMarker = document.querySelector(
|
||||
'[data-legend-item-id="0"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(legendMarker).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', () => {
|
||||
@@ -147,33 +136,25 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
describe('search behavior (RIGHT position)', () => {
|
||||
it('filters legend items based on search query (case-insensitive)', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'a');
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'A');
|
||||
|
||||
expect(screen.getByText('A')).toBeInTheDocument();
|
||||
expect(screen.queryByText('B')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('C')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty state when nothing matches', async () => {
|
||||
it('shows empty state when no legend items match the search query', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), 'network');
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, 'network');
|
||||
|
||||
expect(
|
||||
screen.getByText(/No series found matching "network"/i),
|
||||
@@ -181,11 +162,12 @@ describe('UPlotLegend', () => {
|
||||
expect(screen.queryByTestId('virtuoso-grid')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('ignores a whitespace-only query', async () => {
|
||||
it('does not filter or show empty state when search query is empty or only whitespace', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.type(screen.getByTestId('legend-search-input'), ' ');
|
||||
const searchInput = screen.getByTestId('legend-search-input');
|
||||
await user.type(searchInput, ' ');
|
||||
|
||||
expect(
|
||||
screen.queryByText(/No series found matching/i),
|
||||
@@ -196,197 +178,39 @@ describe('UPlotLegend', () => {
|
||||
});
|
||||
});
|
||||
|
||||
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 () => {
|
||||
describe('legend actions', () => {
|
||||
it('calls onLegendClick when a legend item is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
await user.click(screen.getByText('A'));
|
||||
|
||||
expect(onToggleSeries).toHaveBeenCalledWith(0);
|
||||
expect(onShowOnlySeries).not.toHaveBeenCalled();
|
||||
expect(onLegendClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('calls mouseMove when the mouse moves over a legend item', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
const row = screen.getByTestId('legend-item-0');
|
||||
row.focus();
|
||||
await user.keyboard('{Enter}');
|
||||
await user.keyboard(' ');
|
||||
const legendItem = document.querySelector(
|
||||
'[data-legend-item-id="0"]',
|
||||
) as HTMLElement;
|
||||
|
||||
expect(onToggleSeries).toHaveBeenCalledTimes(2);
|
||||
expect(onToggleSeries).toHaveBeenCalledWith(0);
|
||||
await user.hover(legendItem);
|
||||
|
||||
expect(onLegendMouseMove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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 () => {
|
||||
it('calls onLegendMouseLeave when the mouse leaves the legend container', async () => {
|
||||
const user = userEvent.setup();
|
||||
renderLegend(LegendPosition.RIGHT);
|
||||
|
||||
// Series 0 is shown while B is hidden, so its action is All.
|
||||
await user.click(screen.getByTestId('legend-scope-0'));
|
||||
const container = document.querySelector('.legend-container') as HTMLElement;
|
||||
|
||||
expect(onShowAllSeries).toHaveBeenCalled();
|
||||
expect(onToggleSeries).not.toHaveBeenCalled();
|
||||
expect(onShowOnlySeries).not.toHaveBeenCalled();
|
||||
});
|
||||
await user.hover(container);
|
||||
await user.unhover(container);
|
||||
|
||||
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();
|
||||
expect(onLegendMouseLeave).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ReactNode } from 'react';
|
||||
import { MouseEventHandler, 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).
|
||||
* 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).
|
||||
*/
|
||||
export interface LegendProps {
|
||||
items: LegendItem[];
|
||||
/** Legend placement; always supplied by the container. */
|
||||
position: LegendPosition;
|
||||
averageLegendWidth?: number;
|
||||
/** Series index highlighted by the chart cursor. */
|
||||
/** Series index to highlight (hovered/focused). */
|
||||
focusedSeriesIndex: number | null;
|
||||
/** 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;
|
||||
/**
|
||||
* 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;
|
||||
/** Show the per-item copy button. Default true. */
|
||||
showCopy?: boolean;
|
||||
}
|
||||
|
||||
@@ -6,11 +6,6 @@ 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,
|
||||
|
||||
@@ -8,10 +8,6 @@ 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';
|
||||
@@ -24,26 +20,12 @@ 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 => {
|
||||
@@ -51,9 +33,6 @@ 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(
|
||||
({
|
||||
@@ -64,8 +43,6 @@ export const PlotContextProvider = ({
|
||||
uPlotInstanceRef.current = uPlotInstance;
|
||||
idRef.current = id;
|
||||
activeSeriesIndex.current = undefined;
|
||||
baseSeriesWidthsRef.current = new Map();
|
||||
highlightedSeriesIndexRef.current = null;
|
||||
shouldSavePreferencesRef.current = !!shouldSaveSelectionPreference;
|
||||
},
|
||||
[],
|
||||
@@ -87,54 +64,6 @@ 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;
|
||||
@@ -174,61 +103,14 @@ 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, clearHighlightIfHidden],
|
||||
[syncSeriesVisibilityToLocalStorage],
|
||||
);
|
||||
|
||||
/** 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) {
|
||||
@@ -249,20 +131,14 @@ export const PlotContextProvider = ({
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
}),
|
||||
[
|
||||
onToggleSeriesVisibility,
|
||||
setPlotContextInitialState,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
],
|
||||
);
|
||||
|
||||
@@ -26,7 +26,6 @@ const createMockPlot = (series: MockSeries[] = []): uPlot =>
|
||||
series,
|
||||
batch: jest.fn((fn: () => void) => fn()),
|
||||
setSeries: jest.fn(),
|
||||
redraw: jest.fn(),
|
||||
}) as unknown as uPlot;
|
||||
|
||||
interface TestComponentProps {
|
||||
@@ -45,10 +44,7 @@ const TestComponent = ({
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
} = usePlotContext();
|
||||
const handleInit = (): void => {
|
||||
if (!plot || !id || typeof shouldSaveSelectionPreference !== 'boolean') {
|
||||
@@ -88,13 +84,6 @@ 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"
|
||||
@@ -109,34 +98,6 @@ 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>
|
||||
);
|
||||
};
|
||||
@@ -312,7 +273,6 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -364,7 +324,6 @@ describe('PlotContext', () => {
|
||||
const series: MockSeries[] = [
|
||||
{ label: 'x-axis', show: true },
|
||||
{ label: 'CPU', show: true },
|
||||
{ label: 'Memory', show: true },
|
||||
];
|
||||
const plot = createMockPlot(series);
|
||||
|
||||
@@ -384,48 +343,6 @@ 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', () => {
|
||||
@@ -464,193 +381,4 @@ 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -11,12 +11,10 @@ const mockUsePlotContext = usePlotContext as jest.MockedFunction<
|
||||
describe('useLegendActions', () => {
|
||||
let onToggleSeriesVisibility: jest.Mock;
|
||||
let onToggleSeriesOnOff: jest.Mock;
|
||||
let onShowOnlySeries: jest.Mock;
|
||||
let onShowAllSeries: jest.Mock;
|
||||
let onFocusSeries: jest.Mock;
|
||||
let onHighlightSeries: jest.Mock;
|
||||
let onFocusSeriesPlot: jest.Mock;
|
||||
let setPlotContextInitialState: jest.Mock;
|
||||
let syncSeriesVisibilityToLocalStorage: jest.Mock;
|
||||
let setFocusedSeriesIndexMock: jest.Mock;
|
||||
let cancelAnimationFrameSpy: jest.SpyInstance<void, [handle: number]>;
|
||||
|
||||
beforeAll(() => {
|
||||
@@ -39,20 +37,15 @@ describe('useLegendActions', () => {
|
||||
beforeEach(() => {
|
||||
onToggleSeriesVisibility = jest.fn();
|
||||
onToggleSeriesOnOff = jest.fn();
|
||||
onShowOnlySeries = jest.fn();
|
||||
onShowAllSeries = jest.fn();
|
||||
onFocusSeries = jest.fn();
|
||||
onHighlightSeries = jest.fn();
|
||||
onFocusSeriesPlot = jest.fn();
|
||||
setPlotContextInitialState = jest.fn();
|
||||
syncSeriesVisibilityToLocalStorage = jest.fn();
|
||||
setFocusedSeriesIndexMock = jest.fn();
|
||||
|
||||
mockUsePlotContext.mockReturnValue({
|
||||
onToggleSeriesVisibility,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onFocusSeries,
|
||||
onHighlightSeries,
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
setPlotContextInitialState,
|
||||
syncSeriesVisibilityToLocalStorage,
|
||||
});
|
||||
@@ -60,65 +53,149 @@ describe('useLegendActions', () => {
|
||||
cancelAnimationFrameSpy.mockClear();
|
||||
});
|
||||
|
||||
describe('visibility actions', () => {
|
||||
it('toggles a single series on row click', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
const createMouseEvent = (options: {
|
||||
legendItemId?: number;
|
||||
isMarker?: boolean;
|
||||
}): any => {
|
||||
const { legendItemId, isMarker = false } = options;
|
||||
|
||||
result.current.onToggleSeries(2);
|
||||
return {
|
||||
target: {
|
||||
dataset: {
|
||||
...(isMarker ? { isLegendMarker: 'true' } : {}),
|
||||
},
|
||||
closest: jest.fn(() =>
|
||||
legendItemId !== undefined
|
||||
? { dataset: { legendItemId: String(legendItemId) } }
|
||||
: null,
|
||||
),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
expect(onToggleSeriesOnOff).toHaveBeenCalledWith(2);
|
||||
// The row must never isolate — that is what "Only" is for.
|
||||
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(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('forwards the Only and All actions to the plot', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
it('does nothing when click target is not inside a legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onShowOnlySeries(1);
|
||||
result.current.onShowAllSeries();
|
||||
result.current.onLegendClick(createMouseEvent({}));
|
||||
|
||||
expect(onShowOnlySeries).toHaveBeenCalledWith(1);
|
||||
expect(onShowAllSeries).toHaveBeenCalled();
|
||||
expect(onToggleSeriesOnOff).not.toHaveBeenCalled();
|
||||
expect(onToggleSeriesVisibility).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('hover highlight', () => {
|
||||
it('highlights the hovered series', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
describe('onFocusSeries', () => {
|
||||
it('schedules focus update and calls plot focus handler via mouse move', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onHoverSeries(2);
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
|
||||
expect(onHighlightSeries).toHaveBeenCalledWith(2);
|
||||
expect(setFocusedSeriesIndexMock).toHaveBeenCalledWith(0);
|
||||
expect(onFocusSeriesPlot).toHaveBeenCalledWith(0);
|
||||
});
|
||||
|
||||
it('clears the highlight on leave', () => {
|
||||
const { result } = renderHook(() => useLegendActions());
|
||||
it('cancels previous animation frame before scheduling new one on subsequent mouse moves', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: null,
|
||||
}),
|
||||
);
|
||||
|
||||
result.current.onHoverSeries(null);
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 0 }));
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
|
||||
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();
|
||||
});
|
||||
});
|
||||
|
||||
it('cancels a pending highlight frame on unmount', () => {
|
||||
jest
|
||||
.spyOn(global, 'requestAnimationFrame')
|
||||
.mockImplementation((): number => 7);
|
||||
describe('onLegendMouseMove', () => {
|
||||
it('focuses new series when hovering over different legend item', async () => {
|
||||
const { result } = renderHook(() =>
|
||||
useLegendActions({
|
||||
setFocusedSeriesIndex: setFocusedSeriesIndexMock,
|
||||
focusedSeriesIndex: 0,
|
||||
}),
|
||||
);
|
||||
|
||||
const { result, unmount } = renderHook(() => useLegendActions());
|
||||
result.current.onHoverSeries(1);
|
||||
unmount();
|
||||
result.current.onLegendMouseMove(createMouseEvent({ legendItemId: 1 }));
|
||||
|
||||
expect(cancelAnimationFrameSpy).toHaveBeenCalledWith(7);
|
||||
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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,54 +1,117 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import {
|
||||
Dispatch,
|
||||
SetStateAction,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
} from 'react';
|
||||
import { usePlotContext } from 'lib/uPlotV2/context/PlotContext';
|
||||
|
||||
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 {
|
||||
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;
|
||||
} {
|
||||
const {
|
||||
onFocusSeries: onFocusSeriesPlot,
|
||||
onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHighlightSeries,
|
||||
onToggleSeriesVisibility,
|
||||
} = usePlotContext();
|
||||
|
||||
const rafIdRef = useRef<number | null>(null);
|
||||
const rafId = useRef<number | null>(null); // requestAnimationFrame id
|
||||
|
||||
const cancelPendingHighlight = useCallback((): void => {
|
||||
if (rafIdRef.current != null) {
|
||||
cancelAnimationFrame(rafIdRef.current);
|
||||
rafIdRef.current = null;
|
||||
}
|
||||
}, []);
|
||||
const getLegendItemIdFromEvent = useCallback(
|
||||
(e: React.MouseEvent<HTMLDivElement>): string | undefined => {
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const onHoverSeries = useCallback(
|
||||
(seriesIndex: number | null): void => {
|
||||
cancelPendingHighlight();
|
||||
rafIdRef.current = requestAnimationFrame(() => {
|
||||
rafIdRef.current = null;
|
||||
onHighlightSeries(seriesIndex);
|
||||
});
|
||||
const legendItemElement = target.closest<HTMLElement>(
|
||||
'[data-legend-item-id]',
|
||||
);
|
||||
|
||||
return legendItemElement?.dataset.legendItemId;
|
||||
},
|
||||
[cancelPendingHighlight, onHighlightSeries],
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => cancelPendingHighlight, [cancelPendingHighlight]);
|
||||
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(
|
||||
(seriesIndex: number | null): void => {
|
||||
if (rafId.current != null) {
|
||||
cancelAnimationFrame(rafId.current);
|
||||
}
|
||||
rafId.current = requestAnimationFrame(() => {
|
||||
setFocusedSeriesIndex(seriesIndex);
|
||||
onFocusSeriesPlot(seriesIndex);
|
||||
});
|
||||
},
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[onFocusSeriesPlot],
|
||||
);
|
||||
|
||||
const onLegendMouseMove = (e: React.MouseEvent<HTMLDivElement>): void => {
|
||||
const legendItemId = getLegendItemIdFromEvent(e);
|
||||
const seriesIndex = legendItemId ? Number(legendItemId) : null;
|
||||
if (seriesIndex === focusedSeriesIndex) {
|
||||
return;
|
||||
}
|
||||
onFocusSeries(seriesIndex);
|
||||
};
|
||||
|
||||
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 {
|
||||
onToggleSeries: onToggleSeriesOnOff,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
onLegendClick,
|
||||
onFocusSeries,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -45,10 +45,9 @@ export default function Pie({
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex,
|
||||
onToggleSeries,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
} = usePieInteractions(data, id);
|
||||
|
||||
const {
|
||||
@@ -228,10 +227,9 @@ export default function Pie({
|
||||
position={position}
|
||||
averageLegendWidth={averageLegendWidth}
|
||||
focusedSeriesIndex={focusedSeriesIndex}
|
||||
onToggleSeries={onToggleSeries}
|
||||
onShowOnlySeries={onShowOnlySeries}
|
||||
onShowAllSeries={onShowAllSeries}
|
||||
onHoverSeries={onHoverSeries}
|
||||
onClick={onLegendClick}
|
||||
onMouseMove={onLegendMouseMove}
|
||||
onMouseLeave={onLegendMouseLeave}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -100,29 +100,17 @@ describe('Pie', () => {
|
||||
expect(screen.getByTestId('pie')).toHaveStyle({ flexDirection: 'column' });
|
||||
});
|
||||
|
||||
it('isolates a slice when its legend row is clicked with everything showing', () => {
|
||||
it('hides a slice when its legend marker is clicked', () => {
|
||||
renderPie();
|
||||
const svg = screen.getByTestId('pie').querySelector('svg') as SVGElement;
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(3);
|
||||
|
||||
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'));
|
||||
const marker = document.querySelector(
|
||||
'[data-legend-item-id="1"] [data-is-legend-marker="true"]',
|
||||
) as HTMLElement;
|
||||
fireEvent.click(marker);
|
||||
|
||||
// One slice hidden → one fewer arc drawn.
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(1);
|
||||
expect(svg.querySelectorAll('path')).toHaveLength(2);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
import { LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
|
||||
import {
|
||||
calculateAverageLegendWidth,
|
||||
calculateChartDimensions,
|
||||
} from 'lib/visualization/charts/utils';
|
||||
import { calculateChartDimensions } from 'lib/visualization/charts/utils';
|
||||
|
||||
const labels = (count: number, length = 20): string[] =>
|
||||
Array.from({ length: count }, (_, i) =>
|
||||
@@ -52,104 +49,63 @@ describe('calculateChartDimensions', () => {
|
||||
expect(dims.width).toBe(784);
|
||||
});
|
||||
|
||||
it('RIGHT: never shrinks the column below the floor that fits its chrome', () => {
|
||||
it('RIGHT: never shrinks the column below the 150px floor', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(3, 3),
|
||||
});
|
||||
expect(dims.legendWidth).toBe(190);
|
||||
expect(dims.width).toBe(810);
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(850);
|
||||
});
|
||||
|
||||
it('RIGHT: on a narrow container the legend keeps its chrome, up to half the width', () => {
|
||||
it('RIGHT: on a narrow container the legend never takes more than 40% of the width', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 300,
|
||||
containerHeight: 400,
|
||||
legendConfig: { position: LegendPosition.RIGHT },
|
||||
seriesLabels: labels(10, 40),
|
||||
});
|
||||
// 40% is 120px, too narrow for the column's own toolbar.
|
||||
expect(dims.legendWidth).toBe(150);
|
||||
expect(dims.width).toBe(150);
|
||||
expect(dims.legendWidth).toBe(120);
|
||||
expect(dims.width).toBe(180);
|
||||
});
|
||||
|
||||
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', () => {
|
||||
it('BOTTOM: a single row of items reserves one legend row', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(3),
|
||||
});
|
||||
// One 28px row + the wrapper's 12px bottom padding.
|
||||
// One row = line height (28) + padding (12).
|
||||
expect(dims.legendHeight).toBe(40);
|
||||
expect(dims.height).toBe(460);
|
||||
expect(dims.legendWidth).toBe(1000);
|
||||
});
|
||||
|
||||
it('BOTTOM: more items than one row reserve exactly two rows', () => {
|
||||
it('BOTTOM: many items cap at two rows on a tall container', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
containerHeight: 500,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// 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);
|
||||
// Two rows = 2 * 40 - 12 (no trailing padding) = 68, under the 80px cap.
|
||||
expect(dims.legendHeight).toBe(68);
|
||||
expect(dims.height).toBe(432);
|
||||
});
|
||||
|
||||
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.
|
||||
it('BOTTOM: on a short container the legend never takes more than 30% of the height', () => {
|
||||
const dims = calculateChartDimensions({
|
||||
containerWidth: 1000,
|
||||
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,
|
||||
containerHeight: 160,
|
||||
legendConfig: { position: LegendPosition.BOTTOM },
|
||||
seriesLabels: labels(40),
|
||||
});
|
||||
// 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);
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,10 +1,4 @@
|
||||
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 { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
export interface ChartDimensions {
|
||||
width: number;
|
||||
@@ -19,31 +13,22 @@ const LEGEND_WIDTH_PERCENTILE = 0.85;
|
||||
const DEFAULT_AVG_LABEL_LENGTH = 15;
|
||||
const BASE_LEGEND_WIDTH = 16;
|
||||
const LEGEND_PADDING = 12;
|
||||
// Two rows are worth having, but not at the cost of half the panel.
|
||||
const MAX_SHORT_PANEL_LEGEND_RATIO = 0.5;
|
||||
const LEGEND_LINE_HEIGHT = 28;
|
||||
|
||||
// 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 Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH,
|
||||
);
|
||||
return DEFAULT_AVG_LABEL_LENGTH * AVG_CHAR_WIDTH;
|
||||
}
|
||||
|
||||
const lengths = legends.map((l) => l.length).sort((a, b) => a - b);
|
||||
@@ -51,10 +36,7 @@ export function calculateAverageLegendWidth(legends: string[]): number {
|
||||
const index = Math.ceil(LEGEND_WIDTH_PERCENTILE * lengths.length) - 1;
|
||||
const percentileLength = lengths[Math.max(0, index)];
|
||||
|
||||
return Math.max(
|
||||
MIN_LEGEND_ITEM_WIDTH,
|
||||
BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH,
|
||||
);
|
||||
return BASE_LEGEND_WIDTH + percentileLength * AVG_CHAR_WIDTH;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -70,9 +52,7 @@ 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 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.
|
||||
* - `legendHeight` is derived from row count, capped by both a fixed pixel max and a % of container height.
|
||||
* - 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.
|
||||
*
|
||||
@@ -120,14 +100,9 @@ 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(MIN_RIGHT_LEGEND_WIDTH, desiredLegendWidth),
|
||||
Math.max(floorWidth, maxRightLegendWidth),
|
||||
Math.max(150, desiredLegendWidth),
|
||||
maxRightLegendWidth,
|
||||
);
|
||||
|
||||
return {
|
||||
@@ -140,6 +115,8 @@ export function calculateChartDimensions({
|
||||
};
|
||||
}
|
||||
|
||||
const legendRowHeight = LEGEND_LINE_HEIGHT + LEGEND_PADDING;
|
||||
|
||||
const legendItemWidth = Math.ceil(
|
||||
Math.min(approxLegendItemWidth, MAX_LEGEND_WIDTH),
|
||||
);
|
||||
@@ -148,30 +125,30 @@ export function calculateChartDimensions({
|
||||
Math.floor((containerWidth - LEGEND_PADDING * 2) / legendItemWidth),
|
||||
);
|
||||
|
||||
// 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 legendRowCount = Math.min(
|
||||
2,
|
||||
Math.ceil(legendItemCount / legendItemsPerRow),
|
||||
);
|
||||
|
||||
// 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;
|
||||
const idealBottomLegendHeight =
|
||||
legendRowCount > 1
|
||||
? legendRowCount * legendRowHeight - LEGEND_PADDING
|
||||
: legendRowHeight;
|
||||
|
||||
const bottomLegendHeight = heightForRows(legendRowCount);
|
||||
// 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,
|
||||
);
|
||||
|
||||
return {
|
||||
width: containerWidth,
|
||||
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -23,6 +24,22 @@ 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);
|
||||
@@ -42,11 +59,11 @@ describe('usePieInteractions', () => {
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
|
||||
describe('row toggle', () => {
|
||||
describe('marker click (toggle one)', () => {
|
||||
it('hides then unhides the clicked slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA, 'panel-1'));
|
||||
|
||||
act(() => result.current.onToggleSeries(1));
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0], DATA[2]]);
|
||||
expect(result.current.legendItems[1].show).toBe(false);
|
||||
@@ -56,30 +73,18 @@ describe('usePieInteractions', () => {
|
||||
{ label: 'checkout', show: true },
|
||||
]);
|
||||
|
||||
act(() => result.current.onToggleSeries(1));
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
expect(result.current.legendItems[1].show).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('the last slice showing', () => {
|
||||
it('cannot be hidden', () => {
|
||||
describe('label click (isolate / reset)', () => {
|
||||
it('isolates the clicked slice, then resets on a second click', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
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));
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual([DATA[0]]);
|
||||
expect(result.current.legendItems.map((i) => i.show)).toStrictEqual([
|
||||
@@ -87,24 +92,8 @@ describe('usePieInteractions', () => {
|
||||
false,
|
||||
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());
|
||||
act(() => result.current.onLegendClick(legendEvent(0, false)));
|
||||
|
||||
expect(result.current.visibleData).toStrictEqual(DATA);
|
||||
});
|
||||
@@ -114,23 +103,11 @@ describe('usePieInteractions', () => {
|
||||
it('focuses the hovered slice and clears on leave', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onHoverSeries(2));
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(2)));
|
||||
expect(result.current.active).toStrictEqual(DATA[2]);
|
||||
expect(result.current.focusedSeriesIndex).toBe(2);
|
||||
|
||||
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.
|
||||
act(() => result.current.onLegendMouseLeave());
|
||||
expect(result.current.active).toBeNull();
|
||||
expect(result.current.focusedSeriesIndex).toBeNull();
|
||||
});
|
||||
@@ -138,8 +115,8 @@ describe('usePieInteractions', () => {
|
||||
it('does not focus a hidden slice', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
|
||||
act(() => result.current.onToggleSeries(1));
|
||||
act(() => result.current.onHoverSeries(1));
|
||||
act(() => result.current.onLegendClick(legendEvent(1, true))); // hide cart
|
||||
act(() => result.current.onLegendMouseMove(legendEvent(1)));
|
||||
|
||||
expect(result.current.active).toBeNull();
|
||||
});
|
||||
@@ -148,7 +125,7 @@ describe('usePieInteractions', () => {
|
||||
describe('persistence', () => {
|
||||
it('does not write to storage when no id is provided', () => {
|
||||
const { result } = renderHook(() => usePieInteractions(DATA));
|
||||
act(() => result.current.onToggleSeries(0));
|
||||
act(() => result.current.onLegendClick(legendEvent(0, true)));
|
||||
expect(mockUpdateStored).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { LegendItem } from 'lib/uPlotV2/config/types';
|
||||
import type { Dispatch, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import type { Dispatch, MouseEvent, SetStateAction } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
|
||||
import {
|
||||
getStoredSeriesVisibility,
|
||||
@@ -18,17 +18,27 @@ export interface UsePieInteractionsResult {
|
||||
legendItems: LegendItem[];
|
||||
/** Index of the active slice for the legend's focus highlight, or null. */
|
||||
focusedSeriesIndex: number | null;
|
||||
onToggleSeries: (sliceIndex: number) => void;
|
||||
onShowOnlySeries: (sliceIndex: number) => void;
|
||||
onShowAllSeries: () => void;
|
||||
onHoverSeries: (sliceIndex: number | null) => void;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function usePieInteractions(
|
||||
data: PieSlice[],
|
||||
@@ -38,6 +48,7 @@ export function usePieInteractions(
|
||||
const [hiddenIndices, setHiddenIndices] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
);
|
||||
const isolatedIndexRef = useRef<number | null>(null);
|
||||
|
||||
const legendItems = useMemo<LegendItem[]>(
|
||||
() =>
|
||||
@@ -93,69 +104,65 @@ export function usePieInteractions(
|
||||
[id, data],
|
||||
);
|
||||
|
||||
const onHoverSeries = useCallback(
|
||||
(sliceIndex: number | null): void => {
|
||||
const onLegendMouseMove = useCallback(
|
||||
(e: MouseEvent<HTMLDivElement>): void => {
|
||||
const index = getLegendIndex(e);
|
||||
// Don't focus/dim for hidden slices — they aren't on the donut.
|
||||
setActive(
|
||||
sliceIndex != null && !hiddenIndices.has(sliceIndex)
|
||||
? data[sliceIndex]
|
||||
: null,
|
||||
);
|
||||
setActive(index != null && !hiddenIndices.has(index) ? data[index] : null);
|
||||
},
|
||||
[data, hiddenIndices],
|
||||
);
|
||||
|
||||
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;
|
||||
}
|
||||
next.add(sliceIndex);
|
||||
// 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;
|
||||
}
|
||||
applyHidden(next);
|
||||
},
|
||||
[data.length, hiddenIndices, applyHidden],
|
||||
);
|
||||
const isMarker = (e.target as HTMLElement).dataset.isLegendMarker;
|
||||
|
||||
const onShowOnlySeries = useCallback(
|
||||
(sliceIndex: number): void => {
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, index) => {
|
||||
if (index !== sliceIndex) {
|
||||
if (isMarker) {
|
||||
const next = new Set(hiddenIndices);
|
||||
if (next.has(index)) {
|
||||
next.delete(index);
|
||||
} else {
|
||||
next.add(index);
|
||||
}
|
||||
applyHidden(next);
|
||||
return;
|
||||
}
|
||||
|
||||
const isReset = isolatedIndexRef.current === index;
|
||||
isolatedIndexRef.current = isReset ? null : index;
|
||||
if (isReset) {
|
||||
applyHidden(new Set());
|
||||
return;
|
||||
}
|
||||
const next = new Set<number>();
|
||||
data.forEach((_, i) => {
|
||||
if (i !== index) {
|
||||
next.add(i);
|
||||
}
|
||||
});
|
||||
applyHidden(next);
|
||||
},
|
||||
[data, applyHidden],
|
||||
[data, hiddenIndices, applyHidden],
|
||||
);
|
||||
|
||||
const onShowAllSeries = useCallback(
|
||||
(): void => applyHidden(new Set()),
|
||||
[applyHidden],
|
||||
);
|
||||
const onLegendMouseLeave = useCallback((): void => setActive(null), []);
|
||||
|
||||
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;
|
||||
const focusedIndex = active ? data.indexOf(active) : -1;
|
||||
|
||||
return {
|
||||
active: effectiveActive,
|
||||
active,
|
||||
setActive,
|
||||
visibleData,
|
||||
legendItems,
|
||||
focusedSeriesIndex: focusedIndex >= 0 ? focusedIndex : null,
|
||||
onToggleSeries,
|
||||
onShowOnlySeries,
|
||||
onShowAllSeries,
|
||||
onHoverSeries,
|
||||
onLegendClick,
|
||||
onLegendMouseMove,
|
||||
onLegendMouseLeave,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
box-sizing: border-box;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 12px 12px 12px;
|
||||
padding-left: 12px;
|
||||
padding-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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/constants';
|
||||
import { MAX_LEGEND_WIDTH } from 'lib/uPlotV2/components/Legend/Legend';
|
||||
import { LegendConfig, LegendPosition } from 'lib/uPlotV2/components/types';
|
||||
import { UPlotConfigBuilder } from 'lib/uPlotV2/config/UPlotConfigBuilder';
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ 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';
|
||||
|
||||
@@ -67,7 +68,7 @@ function ConfigPane({
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition.sections;
|
||||
|
||||
const signal = resolveSignal(spec.queries, definition.supportedSignals[0]);
|
||||
const signal = resolveSignal(spec.queries, getSupportedSignals(panelKind)[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.
|
||||
|
||||
@@ -44,6 +44,7 @@ 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']
|
||||
|
||||
@@ -8,6 +8,7 @@ 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'],
|
||||
})),
|
||||
|
||||
@@ -26,3 +26,16 @@
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
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;
|
||||
@@ -21,20 +21,15 @@ import { useQueryBuilder } from 'hooks/queryBuilder/useQueryBuilder';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getSupportedQueryTypes,
|
||||
} from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from '../../Panels/types/panelKind';
|
||||
import { mergeQueryBuilderFieldRule } from '../../Panels/types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from '../../Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../../Panels/types/panelKind';
|
||||
|
||||
import styles from './PanelEditorQueryBuilder.module.scss';
|
||||
|
||||
interface PanelEditorQueryBuilderProps {
|
||||
/** The edited panel's visualization kind — drives supported query types + field visibility via the capabilities guard. */
|
||||
panelKind: PanelKind;
|
||||
/** The edited kind's definition — drives supported query types + field visibility. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** 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. */
|
||||
@@ -55,7 +50,7 @@ interface PanelEditorQueryBuilderProps {
|
||||
* `QueryBuilderProvider`. `usePanelEditorQuerySync` owns the panel↔provider sync.
|
||||
*/
|
||||
function PanelEditorQueryBuilder({
|
||||
panelKind,
|
||||
panelDefinition,
|
||||
signal,
|
||||
isLoadingQueries,
|
||||
onStageRunQuery,
|
||||
@@ -65,10 +60,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[panelKind];
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelDefinition.kind];
|
||||
// Raw rows: the builder drops its aggregation controls, and with them the trace
|
||||
// operator that combines aggregated trace queries (V1 parity).
|
||||
const isListViewPanel = panelKind === 'signoz/ListPanel';
|
||||
const isListViewPanel = panelDefinition.kind === 'signoz/ListPanel';
|
||||
const { currentQuery, redirectWithQueryBuilderData } = useQueryBuilder();
|
||||
const isDarkMode = useIsDarkMode();
|
||||
|
||||
@@ -99,12 +94,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(
|
||||
() => getHiddenQueryBuilderFields(panelKind, signal),
|
||||
[panelKind, signal],
|
||||
() => mergeQueryBuilderFieldRule(panelDefinition.queryBuilderFields, signal),
|
||||
[panelDefinition.queryBuilderFields, signal],
|
||||
);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const supportedQueryTypes = getSupportedQueryTypes(panelKind);
|
||||
const { supportedQueryTypes } = panelDefinition;
|
||||
|
||||
const queryTypeComponents = {
|
||||
[EQueryType.QUERY_BUILDER]: {
|
||||
@@ -151,7 +146,7 @@ function PanelEditorQueryBuilder({
|
||||
),
|
||||
children: queryTypeComponents[queryType].component,
|
||||
}));
|
||||
}, [panelKind, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
}, [panelDefinition, panelType, filterConfigs, isDarkMode, isListViewPanel]);
|
||||
|
||||
return (
|
||||
<div
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
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;
|
||||
@@ -4,6 +4,9 @@ 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.
|
||||
@@ -48,7 +51,7 @@ function renderBuilder(
|
||||
): void {
|
||||
render(
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind={panelKind as never}
|
||||
panelDefinition={requireQueryPanelDefinition(panelKind as PanelKind)}
|
||||
signal={signal}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={jest.fn()}
|
||||
|
||||
@@ -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 { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { RenderableQueryPanelDefinition } 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;
|
||||
/** Resolved definition for the panel kind; */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** The kind's definition, narrowed to the query arm — this preview is the query render path. */
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
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
|
||||
panelDefinition={panelDefinition}
|
||||
Renderer={panelDefinition.Renderer}
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
data={data}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
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;
|
||||
@@ -0,0 +1,140 @@
|
||||
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;
|
||||
@@ -3,6 +3,7 @@ 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';
|
||||
@@ -250,7 +251,12 @@ describe('PanelEditorContainer composition', () => {
|
||||
}),
|
||||
);
|
||||
expect(mockQbProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ panelKind: 'signoz/TimeSeriesPanel' }),
|
||||
expect.objectContaining({
|
||||
panelDefinition: expect.objectContaining({
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
mode: 'query',
|
||||
}),
|
||||
}),
|
||||
);
|
||||
expect(mockConfigProps).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
@@ -274,7 +280,7 @@ describe('PanelEditorContainer composition', () => {
|
||||
setSpec: mockSetSpec,
|
||||
refetch: mockRefetch,
|
||||
alwaysSerializeQuery: false,
|
||||
signal: getPanelDefinition('signoz/TimeSeriesPanel').supportedSignals[0],
|
||||
signal: getSupportedSignals('signoz/TimeSeriesPanel')[0],
|
||||
}),
|
||||
);
|
||||
expect(mockUseTypeSwitch).toHaveBeenCalledWith(
|
||||
|
||||
@@ -18,6 +18,8 @@ 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.
|
||||
@@ -83,7 +85,7 @@ function EditorRoute(): JSX.Element {
|
||||
|
||||
return (
|
||||
<PanelEditorQueryBuilder
|
||||
panelKind="signoz/TimeSeriesPanel"
|
||||
panelDefinition={requireQueryPanelDefinition('signoz/TimeSeriesPanel')}
|
||||
signal={TelemetrytypesSignalDTO.metrics}
|
||||
isLoadingQueries={false}
|
||||
onStageRunQuery={noop}
|
||||
|
||||
@@ -19,6 +19,10 @@ 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(),
|
||||
|
||||
@@ -4,24 +4,19 @@ import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { PANEL_TYPES } from 'constants/queryBuilder';
|
||||
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 { 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 {
|
||||
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;
|
||||
@@ -38,6 +33,12 @@ 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 {
|
||||
@@ -50,7 +51,7 @@ export interface UsePanelEditSessionReturn {
|
||||
reset: () => void;
|
||||
/** Draft kind → V1 panel type (drives the query builder + preview). */
|
||||
panelType: PANEL_TYPES;
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/** The kind's first supported signal — seeds new queries/columns. */
|
||||
defaultSignal: TelemetrytypesSignalDTO;
|
||||
/** Shared query result for the draft over the resolved time window. */
|
||||
@@ -62,8 +63,6 @@ export interface UsePanelEditSessionReturn {
|
||||
buildSaveSpec: (
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
) => DashboardtypesPanelSpecDTO;
|
||||
/** Switch the draft's visualization kind in place (reversible per session). */
|
||||
onChangePanelKind: (kind: PanelKind) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -80,14 +79,17 @@ export function usePanelEditSession({
|
||||
time,
|
||||
alwaysSerializeQuery = false,
|
||||
seedQuerySignal = false,
|
||||
draftApi,
|
||||
}: UsePanelEditSessionArgs): UsePanelEditSessionReturn {
|
||||
const { draft, spec, setSpec, isSpecDirty, reset } = usePanelEditorDraft(
|
||||
panel,
|
||||
savedPanel,
|
||||
);
|
||||
// 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 panelKind = draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
// Hosts fork on `definition.mode` before mounting this session (the editor and
|
||||
// View modal shells) — asserted rather than assumed.
|
||||
const panelDefinition = requireQueryPanelDefinition(panelKind);
|
||||
const panelType = PANEL_KIND_TO_PANEL_TYPE[panelKind];
|
||||
const defaultSignal = panelDefinition.supportedSignals[0];
|
||||
|
||||
@@ -109,12 +111,6 @@ export function usePanelEditSession({
|
||||
savedQueries: savedPanel?.spec.queries,
|
||||
});
|
||||
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draft.spec,
|
||||
panelType,
|
||||
setSpec,
|
||||
});
|
||||
|
||||
return {
|
||||
draft,
|
||||
spec,
|
||||
@@ -128,6 +124,5 @@ export function usePanelEditSession({
|
||||
runQuery,
|
||||
isQueryDirty,
|
||||
buildSaveSpec,
|
||||
onChangePanelKind,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import type {
|
||||
Query,
|
||||
} from 'types/api/queryBuilder/queryBuilderData';
|
||||
|
||||
import { resolveQueryType } from '../../Panels/capabilities';
|
||||
import { isStaticPanelKind, resolveQueryType } from '../../Panels/capabilities';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
@@ -128,11 +128,25 @@ export function usePanelTypeSwitch({
|
||||
queries,
|
||||
});
|
||||
|
||||
// Revisit → restore the stash verbatim (the reversibility path).
|
||||
// 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.
|
||||
const cached = cacheRef.current.get(newKind);
|
||||
if (cached) {
|
||||
setSpec(buildSpec(cached.pluginSpec, cached.queries));
|
||||
redirectWithQueryBuilderData(cached.builderQuery);
|
||||
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), []));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,57 +1,13 @@
|
||||
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 type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import {
|
||||
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 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';
|
||||
import QueryEditorBody from './QueryEditorBody';
|
||||
import StaticEditorBody from './StaticEditorBody';
|
||||
import { usePanelEditorDraft } from './hooks/usePanelEditorDraft';
|
||||
import { usePanelTypeSwitch } from './hooks/usePanelTypeSwitch';
|
||||
|
||||
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 {
|
||||
export interface PanelEditorContainerProps {
|
||||
dashboardId: string;
|
||||
panelId: string;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -71,297 +27,42 @@ interface PanelEditorContainerProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
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();
|
||||
function PanelEditorContainer(props: PanelEditorContainerProps): JSX.Element {
|
||||
const { panel, savedPanel } = props;
|
||||
const draftApi = usePanelEditorDraft(panel, savedPanel);
|
||||
|
||||
// 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 panelKind = draftApi.draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(panelKind);
|
||||
|
||||
// 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,
|
||||
});
|
||||
|
||||
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,
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[panelKind],
|
||||
query: currentQuery,
|
||||
spec: draft.spec,
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
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]);
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
<StaticEditorBody
|
||||
{...props}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<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>
|
||||
<QueryEditorBody
|
||||
{...props}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,8 @@ import type { PanelQueryCapabilities } from '../types/panelCapabilities';
|
||||
import { NO_PANEL_ACTIONS } from '../types/panelDefinition';
|
||||
import {
|
||||
getHiddenQueryBuilderFields,
|
||||
getQueryPanelDefinition,
|
||||
requireQueryPanelDefinition,
|
||||
getSupportedQueryTypes,
|
||||
getSupportedSignals,
|
||||
isPanelCombinationValid,
|
||||
@@ -107,7 +109,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(getPanelDefinition(kind).queryCapabilities).toStrictEqual(
|
||||
expect(getQueryPanelDefinition(kind)?.queryCapabilities).toStrictEqual(
|
||||
EXPECTED_QUERY_CAPABILITIES[kind],
|
||||
);
|
||||
});
|
||||
@@ -149,7 +151,8 @@ describe('panel capabilities guard', () => {
|
||||
});
|
||||
|
||||
it('carries an inert query shape, so a stray request can do no harm', () => {
|
||||
const { queryCapabilities } = getPanelDefinition(unknownKind);
|
||||
const queryCapabilities =
|
||||
requireQueryPanelDefinition(unknownKind).queryCapabilities;
|
||||
expect(queryCapabilities.requestType).toBe(time_series);
|
||||
expect(queryCapabilities.serverPaginated).toBe(false);
|
||||
expect(queryCapabilities.formatTableResultForUI).toBe(false);
|
||||
|
||||
@@ -2,7 +2,11 @@ import type { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.sche
|
||||
import { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
import { getPanelDefinition } from './registry';
|
||||
import type { FilterConfigsPartial } from './types/panelCapabilities';
|
||||
import {
|
||||
mergeQueryBuilderFieldRule,
|
||||
type FilterConfigsPartial,
|
||||
} from './types/panelCapabilities';
|
||||
import type { RenderableQueryPanelDefinition } from './types/panelDefinition';
|
||||
import type { PanelKind } from './types/panelKind';
|
||||
|
||||
/**
|
||||
@@ -13,11 +17,46 @@ 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 getPanelDefinition(kind).supportedSignals;
|
||||
return getQueryPanelDefinition(kind)?.supportedSignals ?? [];
|
||||
}
|
||||
|
||||
export function isSignalSupported(
|
||||
@@ -29,7 +68,7 @@ export function isSignalSupported(
|
||||
|
||||
/** Query languages a kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
export function getSupportedQueryTypes(kind: PanelKind): EQueryType[] {
|
||||
return getPanelDefinition(kind).supportedQueryTypes;
|
||||
return getQueryPanelDefinition(kind)?.supportedQueryTypes ?? [];
|
||||
}
|
||||
|
||||
export function isQueryTypeSupportedByPanelKind(
|
||||
@@ -53,6 +92,10 @@ 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;
|
||||
}
|
||||
@@ -73,7 +116,11 @@ export function resolveQueryType(
|
||||
preferred: EQueryType,
|
||||
): EQueryType {
|
||||
const supported = getSupportedQueryTypes(kind);
|
||||
return supported.includes(preferred) ? preferred : supported[0];
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +132,6 @@ export function getHiddenQueryBuilderFields(
|
||||
kind: PanelKind,
|
||||
signal: TelemetrytypesSignalDTO,
|
||||
): FilterConfigsPartial {
|
||||
const rule = getPanelDefinition(kind).queryBuilderFields;
|
||||
const perSignal = signal ? rule[signal] : undefined;
|
||||
return { ...rule.default, ...perSignal };
|
||||
const rule = getQueryPanelDefinition(kind)?.queryBuilderFields ?? {};
|
||||
return mergeQueryBuilderFieldRule(rule, signal);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -12,8 +13,10 @@ 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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -12,8 +13,10 @@ 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,
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
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;
|
||||
@@ -1,6 +1,7 @@
|
||||
import { List } from '@signozhq/icons';
|
||||
|
||||
import type { PanelDefinition } from '../../types/panelDefinition';
|
||||
import ListEditorPane from './ListEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
import { sections } from './sections';
|
||||
import {
|
||||
@@ -13,8 +14,10 @@ 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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -12,8 +13,10 @@ 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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -12,8 +13,10 @@ 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,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -12,8 +13,10 @@ 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,
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
// 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;
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
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;
|
||||
@@ -0,0 +1,328 @@
|
||||
@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;
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
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;
|
||||
@@ -0,0 +1,32 @@
|
||||
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;
|
||||
@@ -0,0 +1,313 @@
|
||||
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'),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
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;
|
||||
@@ -0,0 +1,22 @@
|
||||
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);
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
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;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
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 {
|
||||
@@ -12,8 +13,10 @@ 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,
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
NO_PANEL_ACTIONS,
|
||||
type RenderablePanelDefinition,
|
||||
} from '../../types/panelDefinition';
|
||||
import QueryBuilderEditorPane from 'pages/DashboardPage/DashboardContainer/PanelEditor/PanelEditorQueryBuilder/QueryBuilderEditorPane';
|
||||
import Renderer from './Renderer';
|
||||
|
||||
/**
|
||||
@@ -19,8 +20,10 @@ 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: [],
|
||||
|
||||
@@ -22,6 +22,15 @@ 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
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { ChartLine } from '@signozhq/icons';
|
||||
import type { EQueryType } from 'types/common/dashboard';
|
||||
|
||||
@@ -10,7 +13,11 @@ import type {
|
||||
PanelQueryCapabilities,
|
||||
QueryBuilderFieldRule,
|
||||
} from './panelCapabilities';
|
||||
import type { BaseRendererProps, PanelRendererProps } from './rendererProps';
|
||||
import type {
|
||||
BaseRendererProps,
|
||||
PanelRendererProps,
|
||||
StaticRendererProps,
|
||||
} from './rendererProps';
|
||||
|
||||
/** Export formats offered under the single "Download" action. */
|
||||
export enum DownloadFormat {
|
||||
@@ -63,12 +70,50 @@ export const NO_PANEL_ACTIONS: PanelActionCapabilities = {
|
||||
|
||||
export type PanelIcon = typeof ChartLine;
|
||||
|
||||
export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
export interface PanelDefinitionBase<K extends PanelKind = PanelKind> {
|
||||
kind: K;
|
||||
displayName: string;
|
||||
icon: PanelIcon;
|
||||
Renderer: ComponentType<PanelRendererProps<K>>;
|
||||
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>;
|
||||
/** Signals this kind can visualize. */
|
||||
supportedSignals: TelemetrytypesSignalDTO[];
|
||||
/** Query languages this kind supports (Query Builder / ClickHouse / PromQL). */
|
||||
@@ -77,16 +122,39 @@ export interface PanelDefinition<K extends PanelKind = PanelKind> {
|
||||
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> };
|
||||
|
||||
// PanelDefinition with its Renderer widened to the kind-agnostic prop surface.
|
||||
export interface RenderablePanelDefinition extends Omit<
|
||||
PanelDefinition,
|
||||
// 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,
|
||||
'Renderer'
|
||||
> {
|
||||
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
|
||||
}
|
||||
export type RenderableStaticPanelDefinition = StaticPanelDefinition<PanelKind>;
|
||||
|
||||
export type RenderablePanelDefinition =
|
||||
| RenderableQueryPanelDefinition
|
||||
| RenderableStaticPanelDefinition;
|
||||
|
||||
@@ -75,6 +75,19 @@ 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.
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
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();
|
||||
});
|
||||
});
|
||||
@@ -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 { getPanelDefinition } from '../registry';
|
||||
import { getQueryPanelDefinition } from '../capabilities';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } from '../types/panelKind';
|
||||
import { fromPerses } from '../../queryV5/persesQueryAdapters';
|
||||
|
||||
@@ -10,10 +10,19 @@ 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 {
|
||||
export function getPanelBuilderQuery(
|
||||
panel: DashboardtypesPanelDTO,
|
||||
): Query | null {
|
||||
const kind = panel.spec.plugin.kind;
|
||||
const [defaultSignal] = getPanelDefinition(kind).supportedSignals;
|
||||
const definition = getQueryPanelDefinition(kind);
|
||||
if (!definition) {
|
||||
return null;
|
||||
}
|
||||
const [defaultSignal] = definition.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) {
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/** 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);
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
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),
|
||||
};
|
||||
@@ -1,22 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
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 { getPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/registry';
|
||||
|
||||
import type { DashboardSection } from '../../utils';
|
||||
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';
|
||||
import QueryPanel from './QueryPanel';
|
||||
import StaticPanel from './StaticPanel';
|
||||
|
||||
/**
|
||||
* Layout context for the panel actions menu — present only in editable mode. No
|
||||
@@ -37,8 +24,8 @@ interface PanelProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* A single dashboard panel (header + body). Thin orchestrator: fetching lives in
|
||||
* `usePanelQuery`, interactions in `usePanelInteractions`, state in `PanelBody`.
|
||||
* 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.
|
||||
*/
|
||||
function Panel({
|
||||
panel,
|
||||
@@ -46,74 +33,28 @@ function Panel({
|
||||
isVisible,
|
||||
panelActions,
|
||||
}: PanelProps): JSX.Element {
|
||||
const timeLabel = panelTimePreferenceLabel(getPanelTimePreference(panel));
|
||||
const panelDefinition = getPanelDefinition(panel.spec.plugin.kind);
|
||||
|
||||
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);
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
<StaticPanel
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
panelDefinition={panelDefinition}
|
||||
isVisible={isVisible}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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
|
||||
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>
|
||||
<QueryPanel
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
panelDefinition={panelDefinition}
|
||||
isVisible={isVisible}
|
||||
panelActions={panelActions}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
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 { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import type { DashboardPreference } from 'pages/DashboardPage/DashboardContainer/Panels/types/rendererProps';
|
||||
import type {
|
||||
BaseRendererProps,
|
||||
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 {
|
||||
@@ -17,8 +20,8 @@ import { panelStatusFromError } from '../PanelStatus/utils';
|
||||
import styles from './PanelBody.module.scss';
|
||||
|
||||
interface PanelBodyProps {
|
||||
/** Resolved renderer for the panel kind (`Panel` handles the unsupported case). */
|
||||
panelDefinition: RenderablePanelDefinition;
|
||||
/** The query arm's renderer — hosts narrow `mode === 'query'` before this mounts. */
|
||||
Renderer: ComponentType<BaseRendererProps & AnyPanelInteractionProps>;
|
||||
panel: DashboardtypesPanelDTO;
|
||||
panelId: string;
|
||||
data: PanelQueryData;
|
||||
@@ -51,7 +54,7 @@ interface PanelBodyProps {
|
||||
* first-load / renderer. The renderer keeps stale data mounted across refetches.
|
||||
*/
|
||||
function PanelBody({
|
||||
panelDefinition,
|
||||
Renderer,
|
||||
panel,
|
||||
panelId,
|
||||
data,
|
||||
@@ -116,7 +119,7 @@ function PanelBody({
|
||||
|
||||
return (
|
||||
<div className={styles.panelContainer}>
|
||||
<panelDefinition.Renderer
|
||||
<Renderer
|
||||
panelId={panelId}
|
||||
panel={panel}
|
||||
data={data}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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';
|
||||
@@ -8,10 +7,6 @@ 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',
|
||||
@@ -24,7 +19,7 @@ function panelWith(queries: unknown[]): DashboardtypesPanelDTO {
|
||||
}
|
||||
|
||||
const baseProps = {
|
||||
panelDefinition,
|
||||
Renderer: MockRenderer,
|
||||
panelId: 'p1',
|
||||
data: {} as PanelQueryData,
|
||||
isFetching: false,
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
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;
|
||||
@@ -0,0 +1,56 @@
|
||||
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;
|
||||
@@ -0,0 +1,5 @@
|
||||
.body {
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
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;
|
||||
@@ -0,0 +1,196 @@
|
||||
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;
|
||||
@@ -0,0 +1,93 @@
|
||||
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;
|
||||
@@ -60,3 +60,29 @@
|
||||
.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;
|
||||
}
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
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 { 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 { 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';
|
||||
import QueryViewModalBody from './QueryViewModalBody';
|
||||
import StaticViewModalBody from './StaticViewModalBody';
|
||||
import { readViewPanelHandoff } from './viewPanelHandoffStore';
|
||||
|
||||
interface ViewPanelModalContentProps {
|
||||
panel: DashboardtypesPanelDTO;
|
||||
@@ -30,168 +26,79 @@ interface ViewPanelModalContentProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
function ViewPanelModalContent({
|
||||
panel,
|
||||
panelId,
|
||||
onClose,
|
||||
}: ViewPanelModalContentProps): JSX.Element | null {
|
||||
const {
|
||||
timeOverride,
|
||||
selectedInterval,
|
||||
onTimeChange,
|
||||
refreshWindow,
|
||||
onDragSelect,
|
||||
extendWindow,
|
||||
} = useViewPanelTimeWindow();
|
||||
}: 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
|
||||
[],
|
||||
);
|
||||
|
||||
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;
|
||||
// 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 isListPanel = draft.spec.plugin.kind === 'signoz/ListPanel';
|
||||
const draftApi = usePanelEditorDraft(initialPanel);
|
||||
const draftKind = draftApi.draft.spec.plugin.kind;
|
||||
const panelDefinition = getPanelDefinition(draftKind);
|
||||
|
||||
// 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,
|
||||
const { onChangePanelKind } = usePanelTypeSwitch({
|
||||
spec: draftApi.draft.spec,
|
||||
panelType: PANEL_KIND_TO_PANEL_TYPE[draftKind],
|
||||
setSpec: draftApi.setSpec,
|
||||
});
|
||||
|
||||
// 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;
|
||||
if (panelDefinition.mode === 'static') {
|
||||
return (
|
||||
<StaticViewModalBody
|
||||
panelId={panelId}
|
||||
draftApi={draftApi}
|
||||
panelDefinition={panelDefinition}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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}>
|
||||
<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>
|
||||
<QueryViewModalBody
|
||||
panel={panel}
|
||||
panelId={panelId}
|
||||
onClose={onClose}
|
||||
draftApi={draftApi}
|
||||
onChangePanelKind={onChangePanelKind}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -5,17 +5,12 @@ 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 { RenderablePanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import {
|
||||
PANEL_KIND_TO_PANEL_TYPE,
|
||||
type PanelKind,
|
||||
} from 'pages/DashboardPage/DashboardContainer/Panels/types/panelKind';
|
||||
import type { RenderableQueryPanelDefinition } from 'pages/DashboardPage/DashboardContainer/Panels/types/panelDefinition';
|
||||
import { PANEL_KIND_TO_PANEL_TYPE } 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';
|
||||
@@ -23,16 +18,15 @@ 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 {
|
||||
@@ -41,7 +35,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: RenderablePanelDefinition;
|
||||
panelDefinition: RenderableQueryPanelDefinition;
|
||||
/**
|
||||
* 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
|
||||
@@ -54,8 +48,6 @@ 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. */
|
||||
@@ -77,51 +69,20 @@ 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: initialPanel, panelId, time });
|
||||
} = usePanelEditSession({ panel, panelId, time, draftApi });
|
||||
|
||||
// The query the view opened with, captured once — the Reset target.
|
||||
const savedQuery = useMemo(
|
||||
@@ -178,7 +139,6 @@ export function useViewPanelMode({
|
||||
queryType: currentQuery.queryType,
|
||||
query,
|
||||
runQuery,
|
||||
onChangePanelKind,
|
||||
resetQuery,
|
||||
buildSaveSpec,
|
||||
applyDrilldownQuery,
|
||||
|
||||
@@ -0,0 +1,115 @@
|
||||
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
Reference in New Issue
Block a user