mirror of
https://github.com/SigNoz/signoz.git
synced 2026-06-23 16:40:35 +01:00
Compare commits
1 Commits
main
...
feat/gener
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db934678bb |
17
.github/workflows/goci.yaml
vendored
17
.github/workflows/goci.yaml
vendored
@@ -140,3 +140,20 @@ jobs:
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config web-settings
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in web settings schema. Run go run cmd/enterprise/*.go generate config web-settings locally and commit."; exit 1)
|
||||
transaction-groups:
|
||||
if: |
|
||||
github.event_name == 'merge_group' ||
|
||||
(github.event_name == 'pull_request' && ! github.event.pull_request.head.repo.fork && github.event.pull_request.user.login != 'dependabot[bot]' && ! contains(github.event.pull_request.labels.*.name, 'safe-to-test')) ||
|
||||
(github.event_name == 'pull_request_target' && contains(github.event.pull_request.labels.*.name, 'safe-to-test'))
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: self-checkout
|
||||
uses: actions/checkout@v4
|
||||
- name: go-install
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
- name: generate-transaction-groups
|
||||
run: |
|
||||
go run cmd/enterprise/*.go generate config transaction-groups
|
||||
git diff --compact-summary --exit-code || (echo; echo "Unexpected difference in transaction groups schema. Run go run cmd/enterprise/*.go generate config transaction-groups locally and commit."; exit 1)
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/SigNoz/signoz/pkg/types/authtypes"
|
||||
"github.com/SigNoz/signoz/pkg/web"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/swaggest/jsonschema-go"
|
||||
@@ -13,6 +14,8 @@ import (
|
||||
|
||||
const webSettingsSchemaPath = "docs/config/web-settings.json"
|
||||
|
||||
const transactionGroupsSchemaPath = "frontend/src/api/generated/transactionGroups.schema.json"
|
||||
|
||||
func registerGenerateConfig(parentCmd *cobra.Command) {
|
||||
configCmd := &cobra.Command{
|
||||
Use: "config",
|
||||
@@ -27,6 +30,14 @@ func registerGenerateConfig(parentCmd *cobra.Command) {
|
||||
},
|
||||
})
|
||||
|
||||
configCmd.AddCommand(&cobra.Command{
|
||||
Use: "transaction-groups",
|
||||
Short: "Generate JSON Schema for transaction groups",
|
||||
RunE: func(currCmd *cobra.Command, args []string) error {
|
||||
return generateTransactionGroups()
|
||||
},
|
||||
})
|
||||
|
||||
parentCmd.AddCommand(configCmd)
|
||||
}
|
||||
|
||||
@@ -59,3 +70,30 @@ func generateWebSettings() error {
|
||||
|
||||
return os.WriteFile(webSettingsSchemaPath, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
func generateTransactionGroups() error {
|
||||
falseVal := false
|
||||
noAdditional := jsonschema.SchemaOrBool{TypeBoolean: &falseVal}
|
||||
|
||||
reflector := jsonschema.Reflector{}
|
||||
reflector.DefaultOptions = append(reflector.DefaultOptions,
|
||||
jsonschema.InterceptSchema(func(params jsonschema.InterceptSchemaParams) (bool, error) {
|
||||
if params.Value.Kind() == reflect.Struct {
|
||||
params.Schema.AdditionalProperties = &noAdditional
|
||||
}
|
||||
return false, nil
|
||||
}),
|
||||
)
|
||||
|
||||
schema, err := reflector.Reflect(authtypes.TransactionGroups{})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
data, err := json.MarshalIndent(schema, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return os.WriteFile(transactionGroupsSchemaPath, append(data, '\n'), 0o600)
|
||||
}
|
||||
|
||||
93
frontend/src/api/generated/transactionGroups.schema.json
Normal file
93
frontend/src/api/generated/transactionGroups.schema.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"items": {
|
||||
"$ref": "#/definitions/AuthtypesTransactionGroup"
|
||||
},
|
||||
"definitions": {
|
||||
"AuthtypesRelation": {
|
||||
"additionalProperties": false,
|
||||
"enum": [
|
||||
"create",
|
||||
"read",
|
||||
"update",
|
||||
"delete",
|
||||
"list",
|
||||
"assignee",
|
||||
"attach",
|
||||
"detach"
|
||||
],
|
||||
"type": "string"
|
||||
},
|
||||
"AuthtypesTransactionGroup": {
|
||||
"required": [
|
||||
"relation",
|
||||
"objectGroup"
|
||||
],
|
||||
"properties": {
|
||||
"objectGroup": {
|
||||
"$ref": "#/definitions/CoretypesObjectGroup"
|
||||
},
|
||||
"relation": {
|
||||
"$ref": "#/definitions/AuthtypesRelation"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CoretypesKind": {
|
||||
"additionalProperties": false,
|
||||
"type": "string"
|
||||
},
|
||||
"CoretypesObjectGroup": {
|
||||
"required": [
|
||||
"resource",
|
||||
"selectors"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"resource": {
|
||||
"$ref": "#/definitions/CoretypesResourceRef"
|
||||
},
|
||||
"selectors": {
|
||||
"items": {
|
||||
"$ref": "#/definitions/CoretypesSelector"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CoretypesResourceRef": {
|
||||
"required": [
|
||||
"type",
|
||||
"kind"
|
||||
],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"kind": {
|
||||
"$ref": "#/definitions/CoretypesKind"
|
||||
},
|
||||
"type": {
|
||||
"$ref": "#/definitions/CoretypesType"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"CoretypesSelector": {
|
||||
"additionalProperties": false,
|
||||
"type": "string"
|
||||
},
|
||||
"CoretypesType": {
|
||||
"additionalProperties": false,
|
||||
"enum": [
|
||||
"user",
|
||||
"serviceaccount",
|
||||
"anonymous",
|
||||
"role",
|
||||
"organization",
|
||||
"metaresource",
|
||||
"telemetryresource"
|
||||
],
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
@@ -44,5 +44,4 @@ export enum LOCALSTORAGE {
|
||||
ACTIVE_SIGNOZ_INSTANCE_URL = 'ACTIVE_SIGNOZ_INSTANCE_URL',
|
||||
DASHBOARDS_LIST_VISIBLE_COLUMNS = 'DASHBOARDS_LIST_VISIBLE_COLUMNS',
|
||||
DASHBOARDS_LIST_VIEWS = 'DASHBOARDS_LIST_VIEWS',
|
||||
DASHBOARD_V2_PANEL_COLUMN_WIDTHS = 'DASHBOARD_V2_PANEL_COLUMN_WIDTHS',
|
||||
}
|
||||
|
||||
@@ -1,34 +1,24 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Info } from '@signozhq/icons';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- fixed-option signal picker
|
||||
// eslint-disable-next-line signoz/no-antd-components -- searchable async select: no @signozhq/ui equivalent
|
||||
import { Select } from 'antd';
|
||||
import { CustomSelect } from 'components/NewSelect';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
import { useGetFieldKeys } from 'hooks/dynamicVariables/useGetFieldKeys';
|
||||
import { useGetFieldValues } from 'hooks/dynamicVariables/useGetFieldValues';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
import { isRetryableError } from 'utils/errorUtils';
|
||||
|
||||
import {
|
||||
DYNAMIC_SIGNAL_LABEL,
|
||||
DYNAMIC_SIGNALS,
|
||||
type DynamicSignalOption,
|
||||
signalForApi,
|
||||
} from '../variableFormModel';
|
||||
import { TELEMETRY_SIGNALS, type TelemetrySignal } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface DynamicVariableFieldsProps {
|
||||
attribute: string;
|
||||
signal: DynamicSignalOption;
|
||||
signal: TelemetrySignal;
|
||||
onChange: (patch: {
|
||||
dynamicAttribute?: string;
|
||||
dynamicSignal?: DynamicSignalOption;
|
||||
dynamicSignal?: TelemetrySignal;
|
||||
}) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
/** Inline error shown under the attribute field (e.g. duplicate attribute). */
|
||||
attributeError?: string;
|
||||
}
|
||||
|
||||
/** Dynamic-variable body: telemetry signal + field, whose live values preview. */
|
||||
@@ -37,24 +27,18 @@ function DynamicVariableFields({
|
||||
signal,
|
||||
onChange,
|
||||
onPreview,
|
||||
attributeError,
|
||||
}: DynamicVariableFieldsProps): JSX.Element {
|
||||
const [search, setSearch] = useState('');
|
||||
const debouncedSearch = useDebounce(search, 300);
|
||||
const apiSignal = signalForApi(signal);
|
||||
|
||||
const {
|
||||
data: keyData,
|
||||
isLoading,
|
||||
error,
|
||||
refetch,
|
||||
} = useGetFieldKeys({
|
||||
signal: apiSignal,
|
||||
const { data: keyData, isLoading } = useGetFieldKeys({
|
||||
signal,
|
||||
name: debouncedSearch || undefined,
|
||||
});
|
||||
|
||||
// `keys` is a Record keyed BY field name; the field names are the map keys.
|
||||
// CustomSelect filters the supplied options locally as the user types.
|
||||
// When the API reports the list is `complete`, search filters locally.
|
||||
const isComplete = keyData?.data?.complete === true;
|
||||
const options = useMemo(
|
||||
() =>
|
||||
Object.keys(keyData?.data?.keys ?? {}).map((name) => ({
|
||||
@@ -65,7 +49,7 @@ function DynamicVariableFields({
|
||||
);
|
||||
|
||||
const { data: valueData } = useGetFieldValues({
|
||||
signal: apiSignal,
|
||||
signal,
|
||||
name: attribute,
|
||||
enabled: !!attribute,
|
||||
});
|
||||
@@ -78,60 +62,40 @@ function DynamicVariableFields({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [valueData]);
|
||||
|
||||
const errorMessage = error ? (error as Error).message || null : null;
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={cx(styles.labelContainer, styles.sourceLabel)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Source</Typography.Text>
|
||||
<TextToolTip
|
||||
text="By default, this searches across logs, traces, and metrics, which can be slow. Selecting a single source improves performance. Many fields share the same values across different signals (for example, `k8s.pod.name` is identical in logs, traces and metrics) making one source enough. Only use `All telemetry` when you need fields that have different values in different signal types."
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<Select
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={signal}
|
||||
options={DYNAMIC_SIGNALS.map((s) => ({
|
||||
label: DYNAMIC_SIGNAL_LABEL[s],
|
||||
value: s,
|
||||
}))}
|
||||
items={TELEMETRY_SIGNALS.map((s) => ({ label: s, value: s }))}
|
||||
onChange={(value): void =>
|
||||
onChange({ dynamicSignal: value as DynamicSignalOption })
|
||||
onChange({ dynamicSignal: value as TelemetrySignal })
|
||||
}
|
||||
data-testid="variable-signal-select"
|
||||
testId="variable-signal-select"
|
||||
/>
|
||||
</div>
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Attribute</Typography.Text>
|
||||
</div>
|
||||
<CustomSelect
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
value={attribute || undefined}
|
||||
placeholder="Select a telemetry field"
|
||||
loading={isLoading}
|
||||
options={options}
|
||||
filterOption={isComplete}
|
||||
onSearch={setSearch}
|
||||
onChange={(value): void => onChange({ dynamicAttribute: value as string })}
|
||||
noDataMessage="No fields found"
|
||||
errorMessage={errorMessage}
|
||||
onRetry={(): void => {
|
||||
void refetch();
|
||||
}}
|
||||
showRetryButton={error ? isRetryableError(error) : true}
|
||||
options={options}
|
||||
notFoundContent={isLoading ? 'Loading…' : 'No fields found'}
|
||||
data-testid="variable-field-select"
|
||||
/>
|
||||
</div>
|
||||
{attributeError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{attributeError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- fixed-option sort picker
|
||||
import { Select } from 'antd';
|
||||
import { CustomSelect } from 'components/NewSelect';
|
||||
|
||||
import {
|
||||
VARIABLE_SORT_LABEL,
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from '../variableFormModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface ListVariableFieldsProps {
|
||||
model: VariableFormModel;
|
||||
onChange: (patch: Partial<VariableFormModel>) => void;
|
||||
previewValues: (string | number)[];
|
||||
previewError: string | null;
|
||||
defaultValue: string;
|
||||
onDefaultValueChange: (value: string) => void;
|
||||
/** Whether the "ALL values" toggle applies to this type (QUERY / CUSTOM). */
|
||||
showAllOptionField: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rows shared by the list-style variables (Query / Custom / Dynamic): the value
|
||||
* preview, sort, multi-select / ALL toggles and the default-value picker.
|
||||
*/
|
||||
function ListVariableFields({
|
||||
model,
|
||||
onChange,
|
||||
previewValues,
|
||||
previewError,
|
||||
defaultValue,
|
||||
onDefaultValueChange,
|
||||
showAllOptionField,
|
||||
}: ListVariableFieldsProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.previewSection)}>
|
||||
<Typography.Text className={styles.previewLabel}>
|
||||
Preview of Values
|
||||
</Typography.Text>
|
||||
<div className={styles.previewValues}>
|
||||
{previewError ? (
|
||||
<Typography.Text className={styles.previewError}>
|
||||
{previewError}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
previewValues.map((value, idx) => (
|
||||
<Badge
|
||||
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
|
||||
key={`${value}-${idx}`}
|
||||
color="vanilla"
|
||||
>
|
||||
{value.toString()}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
|
||||
</div>
|
||||
<Select
|
||||
className={styles.sortSelect}
|
||||
popupMatchSelectWidth={false}
|
||||
value={model.sort}
|
||||
options={VARIABLE_SORTS.map((sort) => ({
|
||||
label: VARIABLE_SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => onChange({ sort: value as VariableSort })}
|
||||
data-testid="variable-sort-select"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.multiSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Enable multiple values to be checked
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.multiSelect}
|
||||
onChange={(checked): void =>
|
||||
onChange({
|
||||
multiSelect: checked,
|
||||
showAllOption: checked ? model.showAllOption : false,
|
||||
})
|
||||
}
|
||||
testId="variable-multi-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.multiSelect && showAllOptionField ? (
|
||||
<div className={cx(styles.row, styles.allOptionSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Include an option for ALL values
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.showAllOption}
|
||||
onChange={(checked): void => onChange({ showAllOption: checked })}
|
||||
testId="variable-all-switch"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cx(styles.row, styles.defaultValueSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Default Value</Typography.Text>
|
||||
<Typography.Text className={styles.defaultValueDesc}>
|
||||
{model.type === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<CustomSelect
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue || undefined}
|
||||
onChange={(value): void => onDefaultValueChange((value as string) ?? '')}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value.toString(),
|
||||
value: value.toString(),
|
||||
}))}
|
||||
data-testid="variable-default-select"
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListVariableFields;
|
||||
@@ -3,14 +3,14 @@ import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import dashboardVariablesQuery from 'api/dashboard/variables/dashboardVariablesQuery';
|
||||
import Editor from 'components/Editor';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableSort } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface QueryVariableFieldsProps {
|
||||
queryValue: string;
|
||||
/** Sibling variable selections, so dependent `$vars` in the query resolve. */
|
||||
variables: PayloadVariables;
|
||||
sort: VariableSort;
|
||||
onChange: (queryValue: string) => void;
|
||||
onPreview: (values: (string | number)[]) => void;
|
||||
onError: (message: string | null) => void;
|
||||
@@ -19,7 +19,7 @@ interface QueryVariableFieldsProps {
|
||||
/** Query-variable body: SQL editor + "Test Run Query" that previews the values. */
|
||||
function QueryVariableFields({
|
||||
queryValue,
|
||||
variables,
|
||||
sort,
|
||||
onChange,
|
||||
onPreview,
|
||||
onError,
|
||||
@@ -30,21 +30,20 @@ function QueryVariableFields({
|
||||
setIsRunning(true);
|
||||
onError(null);
|
||||
try {
|
||||
const res = await dashboardVariablesQuery({ query: queryValue, variables });
|
||||
const res = await dashboardVariablesQuery({
|
||||
query: queryValue,
|
||||
variables: {},
|
||||
});
|
||||
if (res.statusCode === 200 && res.payload) {
|
||||
onPreview(res.payload.variableValues ?? []);
|
||||
onPreview(
|
||||
sortValues(res.payload.variableValues ?? [], sort) as (string | number)[],
|
||||
);
|
||||
} else {
|
||||
onError(res.error || 'Failed to run query');
|
||||
onPreview([]);
|
||||
}
|
||||
} catch (err) {
|
||||
// `dashboardVariablesQuery` throws `{ message, details: { error } }`.
|
||||
const detail = (err as { details?: { error?: string } }).details?.error;
|
||||
const message =
|
||||
detail && detail.includes('Syntax error:')
|
||||
? 'Please make sure query is valid and dependent variables are selected'
|
||||
: detail || (err as Error).message || 'Failed to run query';
|
||||
onError(message);
|
||||
onError((err as Error).message || 'Failed to run query');
|
||||
onPreview([]);
|
||||
} finally {
|
||||
setIsRunning(false);
|
||||
|
||||
@@ -5,8 +5,22 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
|
||||
.allVariables {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 16px;
|
||||
border-bottom: 1px solid var(--l1-border);
|
||||
}
|
||||
|
||||
.allVariablesBtn {
|
||||
--button-height: 24px;
|
||||
--button-padding: 0;
|
||||
color: var(--muted-foreground);
|
||||
}
|
||||
|
||||
.content {
|
||||
@@ -28,12 +42,6 @@
|
||||
width: 200px;
|
||||
}
|
||||
|
||||
.sourceLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
@@ -51,7 +59,7 @@
|
||||
.textarea,
|
||||
.defaultInput {
|
||||
padding: 6px 6px 6px 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l3-background);
|
||||
}
|
||||
@@ -70,89 +78,48 @@
|
||||
color: var(--bg-amber-500);
|
||||
}
|
||||
|
||||
/* Variable type — Tabs root composing the picker row + per-type body panels. */
|
||||
/* Variable type segmented group */
|
||||
.typeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
margin-top: 40px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Picker row (label left, tabs right); the bottom divider separates type from
|
||||
config. Single line — the tab row scrolls (never wraps) when narrow. */
|
||||
.typePicker {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--l2-border);
|
||||
|
||||
@media (max-width: 1440px) {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
|
||||
/* Active tab panel — reset the Tabs default padding; body rows handle spacing. */
|
||||
.typePanel {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.typeContent {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.typeLabelContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
white-space: nowrap;
|
||||
width: auto;
|
||||
}
|
||||
|
||||
/* Horizontal scroll so the tab row never wraps to a second line. The scrollbar
|
||||
is hidden — the row stays a single crisp line and scrolls only when narrow. */
|
||||
.typeTabsScroll {
|
||||
justify-self: flex-end;
|
||||
--tab-list-wrapper-secondary-padding-left: 0;
|
||||
}
|
||||
|
||||
/* Connected segmented control, mirroring Overview's SegmentedControl: no outer
|
||||
padding, segments divided by 1px borders, active segment filled + bold. */
|
||||
.typeTabs {
|
||||
display: inline-flex;
|
||||
flex-wrap: nowrap;
|
||||
width: max-content;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.typeTab {
|
||||
display: inline-flex;
|
||||
.typeBtnGroup {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, max-content);
|
||||
height: 32px;
|
||||
flex-shrink: 0;
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
background: var(--l2-background);
|
||||
box-shadow: 0 0 8px 0 rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.typeBtn {
|
||||
--button-height: 32px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 24px;
|
||||
padding: 6px 14px;
|
||||
white-space: nowrap;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
min-width: 114px;
|
||||
border-radius: 0;
|
||||
color: var(--l2-foreground);
|
||||
|
||||
&:not(:last-child) {
|
||||
border-right: 1px solid var(--l2-border);
|
||||
& + & {
|
||||
border-left: 1px solid var(--l1-border);
|
||||
}
|
||||
}
|
||||
|
||||
&[data-state='active'] {
|
||||
color: var(--l1-foreground);
|
||||
font-weight: 500;
|
||||
// override the Tabs component's default (transparent) active background.
|
||||
background: var(--l3-background) !important;
|
||||
}
|
||||
.typeBtnSelected {
|
||||
background: var(--l1-border);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.betaTag {
|
||||
@@ -171,7 +138,7 @@
|
||||
.editorWrap {
|
||||
height: 240px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--l2-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 2px;
|
||||
}
|
||||
|
||||
@@ -187,7 +154,7 @@
|
||||
|
||||
.customSection :global(.custom-collapse) {
|
||||
width: 100%;
|
||||
border: 1px solid var(--l2-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px 3px 0 0;
|
||||
|
||||
:global(.ant-collapse-item) {
|
||||
@@ -241,7 +208,7 @@
|
||||
min-height: 88px;
|
||||
margin-bottom: 0;
|
||||
padding-bottom: 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border: 1px solid var(--l1-border);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
@@ -304,9 +271,13 @@
|
||||
letter-spacing: -0.07px;
|
||||
}
|
||||
|
||||
.sortSelect {
|
||||
width: 192px;
|
||||
}
|
||||
|
||||
.defaultValueSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 1rem;
|
||||
align-items: center;
|
||||
margin-bottom: 0;
|
||||
@@ -326,21 +297,14 @@
|
||||
letter-spacing: -0.06px;
|
||||
}
|
||||
|
||||
/* All variable selects (Source / Attribute / Sort / Default Value) share width
|
||||
and a consistent --l2-border outline. */
|
||||
.sortSelect,
|
||||
.searchSelect {
|
||||
width: 240px;
|
||||
flex-shrink: 0;
|
||||
|
||||
:global(.ant-select-selector) {
|
||||
border-color: var(--l2-border) !important;
|
||||
}
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.actionButtons {
|
||||
width: 100%;
|
||||
/* Footer */
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 1rem;
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
@@ -1,199 +1,350 @@
|
||||
import { Check, X } from '@signozhq/icons';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { ArrowLeft, Check, X } from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { TabsContent, TabsRoot } from '@signozhq/ui/tabs';
|
||||
import { SelectSimple } from '@signozhq/ui/select';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput } from 'antd';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- TextArea/Collapse/searchable Select: no @signozhq/ui equivalent
|
||||
import { Collapse, Input as AntdInput, Select } from 'antd';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import sortValues from 'lib/dashboardVariables/sortVariableValues';
|
||||
|
||||
import type { VariableType } from '../variableFormModel';
|
||||
import {
|
||||
VARIABLE_SORTS,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
type VariableType,
|
||||
} from '../variableModel';
|
||||
import DynamicVariableFields from './DynamicVariableFields';
|
||||
import ListVariableFields from './ListVariableFields';
|
||||
import QueryVariableFields from './QueryVariableFields';
|
||||
import { useVariableForm } from './useVariableForm';
|
||||
import VariableTypeTabs from './VariableTypeTabs';
|
||||
import VariableTypeSelector from './VariableTypeSelector';
|
||||
import styles from './VariableForm.module.scss';
|
||||
import BackToAllVariables from '../components/BackToAllVariables/BackToAllVariables';
|
||||
import { VariableFormProps } from '../types';
|
||||
import VariableInfoForm from '../components/VariableInfoForm/VariableInfoForm';
|
||||
|
||||
const SORT_LABEL: Record<VariableSort, string> = {
|
||||
DISABLED: 'Disabled',
|
||||
ASC: 'Ascending',
|
||||
DESC: 'Descending',
|
||||
};
|
||||
|
||||
function getNameError(name: string, existingNames: string[]): string | null {
|
||||
if (name === '') {
|
||||
return 'Variable name is required';
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return 'Variable name cannot contain whitespaces';
|
||||
}
|
||||
if (existingNames.includes(name)) {
|
||||
return 'Variable name already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** Names of the other variables, for uniqueness validation. */
|
||||
existingNames: string[];
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* In-drawer variable editor reproducing the V1 VariableItem layout, built on
|
||||
* @signozhq components (antd kept only for the monaco editor, TextArea, Collapse
|
||||
* and searchable selects). Master→detail: renders in place of the list. Form
|
||||
* state/handlers live in {@link useVariableForm}; the shared list-type rows in
|
||||
* {@link ListVariableFields}.
|
||||
* and searchable selects). Master→detail: renders in place of the list.
|
||||
*/
|
||||
function VariableForm({
|
||||
initial,
|
||||
siblings,
|
||||
isNew,
|
||||
existingNames,
|
||||
isSaving,
|
||||
onClose,
|
||||
onSave,
|
||||
}: VariableFormProps): JSX.Element {
|
||||
const {
|
||||
model,
|
||||
set,
|
||||
onNameChange,
|
||||
selectType,
|
||||
onCustomChange,
|
||||
onDynamicChange,
|
||||
setRawPreview,
|
||||
previewValues,
|
||||
previewError,
|
||||
setPreviewError,
|
||||
defaultValue,
|
||||
setDefaultValue,
|
||||
visibleNameError,
|
||||
nameError,
|
||||
attributeError,
|
||||
cycleError,
|
||||
isListType,
|
||||
showAllOptionField,
|
||||
payloadVariables,
|
||||
handleSave,
|
||||
} = useVariableForm({ initial, siblings, isNew, onSave });
|
||||
const [model, setModel] = useState<VariableFormModel>(initial);
|
||||
const [previewValues, setPreviewValues] = useState<(string | number)[]>([]);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [defaultValue, setDefaultValue] = useState<string>(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
|
||||
// Shared list rows (preview/sort/multi/default) for the list-type variables;
|
||||
// rendered as a sibling inside each list-type panel. Only the active panel
|
||||
// mounts (Tabs unmounts the rest), so reusing one element is safe.
|
||||
const listFields = isListType ? (
|
||||
<ListVariableFields
|
||||
model={model}
|
||||
onChange={set}
|
||||
previewValues={previewValues}
|
||||
previewError={previewError}
|
||||
defaultValue={defaultValue}
|
||||
onDefaultValueChange={setDefaultValue}
|
||||
showAllOptionField={showAllOptionField}
|
||||
/>
|
||||
) : null;
|
||||
useEffect(() => {
|
||||
setModel(initial);
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
setDefaultValue(
|
||||
((initial.defaultValue as { value?: string })?.value ?? '') as string,
|
||||
);
|
||||
}, [initial]);
|
||||
|
||||
const set = (patch: Partial<VariableFormModel>): void =>
|
||||
setModel((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const selectType = (type: VariableType): void => {
|
||||
set({ type });
|
||||
setPreviewValues([]);
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setPreviewValues(
|
||||
sortValues(commaValuesParser(value), model.sort) as (string | number)[],
|
||||
);
|
||||
};
|
||||
|
||||
const trimmedName = model.name.trim();
|
||||
const nameError = getNameError(trimmedName, existingNames);
|
||||
|
||||
const isListType =
|
||||
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
|
||||
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
|
||||
|
||||
const handleSave = (): void => {
|
||||
onSave({
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<BackToAllVariables onClose={onClose} />
|
||||
<>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.allVariables}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.allVariablesBtn}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onClose}
|
||||
testId="variable-form-back"
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className={styles.content}>
|
||||
<VariableInfoForm
|
||||
title={model.name}
|
||||
description={model.description}
|
||||
onTitleChange={onNameChange}
|
||||
onDescriptionChange={(value): void => set({ description: value })}
|
||||
visibleNameError={visibleNameError}
|
||||
/>
|
||||
<div className={styles.content}>
|
||||
{/* Name */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Name</Typography.Text>
|
||||
<Input
|
||||
className={styles.input}
|
||||
value={model.name}
|
||||
placeholder="Unique name of the variable"
|
||||
onChange={(e): void => set({ name: e.target.value })}
|
||||
testId="variable-name-input"
|
||||
/>
|
||||
{nameError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{nameError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<TabsRoot
|
||||
className={styles.typeSection}
|
||||
value={model.type}
|
||||
onValueChange={(next): void => selectType(next as VariableType)}
|
||||
>
|
||||
<VariableTypeTabs />
|
||||
{/* Description */}
|
||||
<div className={cx(styles.row, styles.column)}>
|
||||
<Typography.Text className={styles.label}>Description</Typography.Text>
|
||||
<AntdInput.TextArea
|
||||
className={styles.textarea}
|
||||
value={model.description}
|
||||
placeholder="Enter a description for the variable"
|
||||
rows={3}
|
||||
onChange={(e): void => set({ description: e.target.value })}
|
||||
data-testid="variable-description-input"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<TabsContent value="DYNAMIC" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<DynamicVariableFields
|
||||
attribute={model.dynamicAttribute}
|
||||
signal={model.dynamicSignal}
|
||||
onChange={onDynamicChange}
|
||||
onPreview={setRawPreview}
|
||||
attributeError={attributeError}
|
||||
{/* Variable Type */}
|
||||
<VariableTypeSelector value={model.type} onChange={selectType} />
|
||||
|
||||
{/* Type-specific body */}
|
||||
{model.type === 'DYNAMIC' ? (
|
||||
<DynamicVariableFields
|
||||
attribute={model.dynamicAttribute}
|
||||
signal={model.dynamicSignal}
|
||||
onChange={(patch): void => set(patch)}
|
||||
onPreview={setPreviewValues}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'QUERY' ? (
|
||||
<QueryVariableFields
|
||||
queryValue={model.queryValue}
|
||||
sort={model.sort}
|
||||
onChange={(queryValue): void => set({ queryValue })}
|
||||
onPreview={setPreviewValues}
|
||||
onError={setPreviewError}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{model.type === 'CUSTOM' ? (
|
||||
<div className={cx(styles.row, styles.customSection)}>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<AntdInput.TextArea
|
||||
value={model.customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onCustomChange(e.target.value)}
|
||||
data-testid="variable-custom-input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
{listFields}
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="QUERY" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<QueryVariableFields
|
||||
queryValue={model.queryValue}
|
||||
variables={payloadVariables}
|
||||
onChange={(queryValue): void => set({ queryValue })}
|
||||
onPreview={setRawPreview}
|
||||
onError={setPreviewError}
|
||||
{model.type === 'TEXT' ? (
|
||||
<div className={cx(styles.row, styles.textboxSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.defaultInput}
|
||||
value={model.textValue}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
onChange={(e): void => set({ textValue: e.target.value })}
|
||||
testId="variable-text-input"
|
||||
/>
|
||||
{listFields}
|
||||
</div>
|
||||
</TabsContent>
|
||||
) : null}
|
||||
|
||||
<TabsContent value="CUSTOM" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<div className={cx(styles.row, styles.customSection)}>
|
||||
<Collapse
|
||||
collapsible="header"
|
||||
rootClassName="custom-collapse"
|
||||
defaultActiveKey={['1']}
|
||||
items={[
|
||||
{
|
||||
key: '1',
|
||||
label: 'Options',
|
||||
children: (
|
||||
<AntdInput.TextArea
|
||||
value={model.customValue}
|
||||
placeholder="Enter options separated by commas."
|
||||
rootClassName="comma-input"
|
||||
onChange={(e): void => onCustomChange(e.target.value)}
|
||||
data-testid="variable-custom-input"
|
||||
/>
|
||||
),
|
||||
},
|
||||
]}
|
||||
{/* Shared rows for list-type variables */}
|
||||
{isListType ? (
|
||||
<>
|
||||
<div className={cx(styles.row, styles.previewSection)}>
|
||||
<Typography.Text className={styles.previewLabel}>
|
||||
Preview of Values
|
||||
</Typography.Text>
|
||||
<div className={styles.previewValues}>
|
||||
{previewError ? (
|
||||
<Typography.Text className={styles.previewError}>
|
||||
{previewError}
|
||||
</Typography.Text>
|
||||
) : (
|
||||
previewValues.map((value, idx) => (
|
||||
<Badge
|
||||
// eslint-disable-next-line react/no-array-index-key -- preview values are display-only and may contain duplicates
|
||||
key={`${value}-${idx}`}
|
||||
color="vanilla"
|
||||
>
|
||||
{value.toString()}
|
||||
</Badge>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={cx(styles.row, styles.sortSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>Sort Values</Typography.Text>
|
||||
</div>
|
||||
<SelectSimple
|
||||
className={styles.sortSelect}
|
||||
value={model.sort}
|
||||
items={VARIABLE_SORTS.map((sort) => ({
|
||||
label: SORT_LABEL[sort],
|
||||
value: sort,
|
||||
}))}
|
||||
onChange={(value): void => set({ sort: value as VariableSort })}
|
||||
testId="variable-sort-select"
|
||||
/>
|
||||
</div>
|
||||
{listFields}
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="TEXT" className={styles.typePanel}>
|
||||
<div className={styles.typeContent}>
|
||||
<div className={cx(styles.row, styles.textboxSection)}>
|
||||
<div className={cx(styles.row, styles.multiSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Enable multiple values to be checked
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.multiSelect}
|
||||
onChange={(checked): void => {
|
||||
set({
|
||||
multiSelect: checked,
|
||||
showAllOption: checked ? model.showAllOption : false,
|
||||
});
|
||||
}}
|
||||
testId="variable-multi-switch"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{model.multiSelect && showAllOptionField ? (
|
||||
<div className={cx(styles.row, styles.allOptionSection)}>
|
||||
<Typography.Text className={styles.rowLabel}>
|
||||
Include an option for ALL values
|
||||
</Typography.Text>
|
||||
<Switch
|
||||
value={model.showAllOption}
|
||||
onChange={(checked): void => set({ showAllOption: checked })}
|
||||
testId="variable-all-switch"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className={cx(styles.row, styles.defaultValueSection)}>
|
||||
<div className={styles.labelContainer}>
|
||||
<Typography.Text className={styles.label}>
|
||||
Default Value
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.defaultValueDesc}>
|
||||
{model.type === 'QUERY'
|
||||
? 'Click Test Run Query to see the values or add custom value'
|
||||
: 'Select a value from the preview values or add custom value'}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Input
|
||||
className={styles.defaultInput}
|
||||
value={model.textValue}
|
||||
placeholder="Enter a default value (if any)..."
|
||||
onChange={(e): void => set({ textValue: e.target.value })}
|
||||
testId="variable-text-input"
|
||||
<Select
|
||||
className={styles.searchSelect}
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="Select a default value"
|
||||
value={defaultValue || undefined}
|
||||
onChange={(value): void => setDefaultValue(value ?? '')}
|
||||
options={previewValues.map((value) => ({
|
||||
label: value.toString(),
|
||||
value: value.toString(),
|
||||
}))}
|
||||
data-testid="variable-default-select"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</TabsRoot>
|
||||
|
||||
{cycleError ? (
|
||||
<Typography.Text className={styles.errorText}>
|
||||
{cycleError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
|
||||
<div className={styles.actionButtons}>
|
||||
<Button
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError || !!attributeError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.footer}>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
onClick={onClose}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
disabled={!!nameError}
|
||||
loading={isSaving}
|
||||
onClick={handleSave}
|
||||
testId="variable-save"
|
||||
>
|
||||
Save Variable
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import type { VariableType } from '../variableModel';
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
interface VariableTypeSelectorProps {
|
||||
value: VariableType;
|
||||
onChange: (type: VariableType) => void;
|
||||
}
|
||||
|
||||
/** The segmented Dynamic / Textbox / Custom / Query type picker. */
|
||||
function VariableTypeSelector({
|
||||
value,
|
||||
onChange,
|
||||
}: VariableTypeSelectorProps): JSX.Element {
|
||||
return (
|
||||
<div className={cx(styles.row, styles.typeSection)}>
|
||||
<div className={styles.typeLabelContainer}>
|
||||
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.typeBtnGroup}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<Pyramid size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'DYNAMIC',
|
||||
})}
|
||||
onClick={(): void => onChange('DYNAMIC')}
|
||||
testId="variable-type-dynamic"
|
||||
>
|
||||
Dynamic
|
||||
<Badge color="robin" className={styles.betaTag}>
|
||||
Beta
|
||||
</Badge>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<ClipboardType size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'TEXT',
|
||||
})}
|
||||
onClick={(): void => onChange('TEXT')}
|
||||
testId="variable-type-textbox"
|
||||
>
|
||||
Textbox
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<LayoutList size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'CUSTOM',
|
||||
})}
|
||||
onClick={(): void => onChange('CUSTOM')}
|
||||
testId="variable-type-custom"
|
||||
>
|
||||
Custom
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
prefix={<DatabaseZap size={14} />}
|
||||
className={cx(styles.typeBtn, {
|
||||
[styles.typeBtnSelected]: value === 'QUERY',
|
||||
})}
|
||||
onClick={(): void => onChange('QUERY')}
|
||||
testId="variable-type-query"
|
||||
>
|
||||
Query
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTypeSelector;
|
||||
@@ -1,93 +0,0 @@
|
||||
import {
|
||||
ClipboardType,
|
||||
DatabaseZap,
|
||||
Info,
|
||||
LayoutList,
|
||||
Pyramid,
|
||||
} from '@signozhq/icons';
|
||||
import { Badge } from '@signozhq/ui/badge';
|
||||
import { TabsList, TabsTrigger } from '@signozhq/ui/tabs';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import TextToolTip from 'components/TextToolTip';
|
||||
|
||||
import styles from './VariableForm.module.scss';
|
||||
|
||||
/**
|
||||
* Presentational trigger row for the variable-type tabs (label + segmented
|
||||
* triggers). Must render inside a `TabsRoot`, which owns the active state and
|
||||
* change handling; the matching `TabsContent` panels are siblings in the root.
|
||||
*/
|
||||
function VariableTypeTabs(): JSX.Element {
|
||||
return (
|
||||
<div className={styles.typePicker}>
|
||||
<div className={styles.typeLabelContainer}>
|
||||
<Typography.Text className={styles.label}>Variable Type</Typography.Text>
|
||||
<TextToolTip
|
||||
text="Learn more about supported variable types"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#supported-variable-types"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.typeTabsScroll}>
|
||||
<TabsList variant="secondary" className={styles.typeTabs}>
|
||||
<TabsTrigger
|
||||
value="DYNAMIC"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-dynamic"
|
||||
>
|
||||
<Pyramid size={14} />
|
||||
Dynamic
|
||||
<Badge color="robin" className={styles.betaTag}>
|
||||
Beta
|
||||
</Badge>
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="TEXT"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-textbox"
|
||||
>
|
||||
<ClipboardType size={14} />
|
||||
Textbox
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="CUSTOM"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-custom"
|
||||
>
|
||||
<LayoutList size={14} />
|
||||
Custom
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value="QUERY"
|
||||
className={styles.typeTab}
|
||||
testId="variable-type-query"
|
||||
>
|
||||
<DatabaseZap size={14} />
|
||||
Query
|
||||
<Badge color="amber" className={styles.betaTag}>
|
||||
Not Recommended
|
||||
</Badge>
|
||||
<span
|
||||
className={styles.betaTag}
|
||||
onClick={(e): void => e.stopPropagation()}
|
||||
role="presentation"
|
||||
>
|
||||
<TextToolTip
|
||||
text="Learn why we don't recommend"
|
||||
url="https://signoz.io/docs/userguide/manage-variables/#why-avoid-clickhouse-query-variables"
|
||||
urlText="here"
|
||||
useFilledIcon={false}
|
||||
outlinedIcon={<Info size={14} />}
|
||||
/>
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableTypeTabs;
|
||||
@@ -1,191 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { commaValuesParser } from 'lib/dashboardVariables/customCommaValuesParser';
|
||||
import type { PayloadVariables } from 'types/api/dashboard/variables/query';
|
||||
|
||||
import type { VariableSelectionMap } from '../../../VariablesBar/selectionTypes';
|
||||
import { useDashboardStore } from '../../../store/useDashboardStore';
|
||||
import { detectVariableCycle } from '../variableDependencies';
|
||||
import {
|
||||
sortValuesByOrder,
|
||||
type VariableFormModel,
|
||||
type VariableType,
|
||||
} from '../variableFormModel';
|
||||
import { getAttributeError, getNameError } from './variableValidation';
|
||||
|
||||
// Stable reference so the zustand selector never returns a fresh object (which
|
||||
// would make useSyncExternalStore loop) when this dashboard has no selections.
|
||||
const EMPTY_SELECTIONS: VariableSelectionMap = {};
|
||||
|
||||
interface UseVariableFormArgs {
|
||||
initial: VariableFormModel;
|
||||
siblings: VariableFormModel[];
|
||||
isNew: boolean;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
|
||||
export interface UseVariableForm {
|
||||
model: VariableFormModel;
|
||||
set: (patch: Partial<VariableFormModel>) => void;
|
||||
onNameChange: (value: string) => void;
|
||||
selectType: (type: VariableType) => void;
|
||||
onCustomChange: (value: string) => void;
|
||||
onDynamicChange: (patch: Partial<VariableFormModel>) => void;
|
||||
setRawPreview: (values: (string | number)[]) => void;
|
||||
previewValues: (string | number)[];
|
||||
previewError: string | null;
|
||||
setPreviewError: (message: string | null) => void;
|
||||
defaultValue: string;
|
||||
setDefaultValue: (value: string) => void;
|
||||
visibleNameError: string | null;
|
||||
nameError: string | null;
|
||||
attributeError: string | undefined;
|
||||
cycleError: string | null;
|
||||
isListType: boolean;
|
||||
showAllOptionField: boolean;
|
||||
payloadVariables: PayloadVariables;
|
||||
handleSave: () => void;
|
||||
}
|
||||
|
||||
const readDefaultValue = (model: VariableFormModel): string =>
|
||||
((model.defaultValue as { value?: string })?.value ?? '') as string;
|
||||
|
||||
/** Form state, derivations and handlers for the variable editor. */
|
||||
export function useVariableForm({
|
||||
initial,
|
||||
siblings,
|
||||
isNew,
|
||||
onSave,
|
||||
}: UseVariableFormArgs): UseVariableForm {
|
||||
const [model, setModel] = useState<VariableFormModel>(initial);
|
||||
// Raw, unsorted preview; `previewValues` applies the chosen sort so a shown
|
||||
// preview re-sorts when Sort changes.
|
||||
const [rawPreview, setRawPreview] = useState<(string | number)[]>([]);
|
||||
const [previewError, setPreviewError] = useState<string | null>(null);
|
||||
const [cycleError, setCycleError] = useState<string | null>(null);
|
||||
// In add mode, mirror the chosen attribute into the name until the user types.
|
||||
const [nameTouched, setNameTouched] = useState(false);
|
||||
const [defaultValue, setDefaultValue] = useState<string>(
|
||||
readDefaultValue(initial),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setModel(initial);
|
||||
setRawPreview([]);
|
||||
setPreviewError(null);
|
||||
setCycleError(null);
|
||||
setNameTouched(false);
|
||||
setDefaultValue(readDefaultValue(initial));
|
||||
}, [initial]);
|
||||
|
||||
const set = (patch: Partial<VariableFormModel>): void =>
|
||||
setModel((prev) => ({ ...prev, ...patch }));
|
||||
|
||||
const previewValues = useMemo(
|
||||
() => sortValuesByOrder(rawPreview, model.sort) as (string | number)[],
|
||||
[rawPreview, model.sort],
|
||||
);
|
||||
|
||||
const existingNames = useMemo(() => siblings.map((v) => v.name), [siblings]);
|
||||
|
||||
const existingDynamicAttributes = useMemo(
|
||||
() =>
|
||||
siblings
|
||||
.filter((v) => v.type === 'DYNAMIC' && v.dynamicAttribute)
|
||||
.map((v) => v.dynamicAttribute),
|
||||
[siblings],
|
||||
);
|
||||
|
||||
// Sibling selections feed the Query "Test Run" so dependent `$vars` resolve.
|
||||
const dashboardId = useDashboardStore((s) => s.dashboardId);
|
||||
const selections = useDashboardStore(
|
||||
(s) => s.variableValues[dashboardId ?? ''] ?? EMPTY_SELECTIONS,
|
||||
);
|
||||
const payloadVariables = useMemo<PayloadVariables>(() => {
|
||||
const out: PayloadVariables = {};
|
||||
siblings.forEach((v) => {
|
||||
if (v.name) {
|
||||
out[v.name] = selections[v.name]?.value ?? null;
|
||||
}
|
||||
});
|
||||
return out;
|
||||
}, [siblings, selections]);
|
||||
|
||||
const trimmedName = model.name.trim();
|
||||
const nameError = getNameError(trimmedName, existingNames, initial.name);
|
||||
// Surface the message only once the field is dirty; Save stays disabled regardless.
|
||||
const visibleNameError = nameTouched ? nameError : null;
|
||||
const attributeError = getAttributeError(model, existingDynamicAttributes);
|
||||
|
||||
const isListType =
|
||||
model.type === 'QUERY' || model.type === 'CUSTOM' || model.type === 'DYNAMIC';
|
||||
const showAllOptionField = model.type === 'QUERY' || model.type === 'CUSTOM';
|
||||
|
||||
const onNameChange = (value: string): void => {
|
||||
setNameTouched(true);
|
||||
set({ name: value });
|
||||
};
|
||||
|
||||
const selectType = (type: VariableType): void => {
|
||||
set({ type });
|
||||
setRawPreview([]);
|
||||
setPreviewError(null);
|
||||
};
|
||||
|
||||
const onCustomChange = (value: string): void => {
|
||||
set({ customValue: value });
|
||||
setRawPreview(commaValuesParser(value));
|
||||
};
|
||||
|
||||
// In add mode, mirror the selected attribute into the name until the user
|
||||
// edits the name themselves (matches the V1 dynamic-variable behaviour).
|
||||
const onDynamicChange = (patch: Partial<VariableFormModel>): void => {
|
||||
if (isNew && !nameTouched && patch.dynamicAttribute) {
|
||||
set({ ...patch, name: patch.dynamicAttribute });
|
||||
} else {
|
||||
set(patch);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = (): void => {
|
||||
const next: VariableFormModel = {
|
||||
...model,
|
||||
name: trimmedName,
|
||||
defaultValue: defaultValue ? { value: defaultValue } : undefined,
|
||||
};
|
||||
|
||||
const cycle = detectVariableCycle([...siblings, next]);
|
||||
if (cycle) {
|
||||
setCycleError(
|
||||
`Cannot save: circular dependency detected between variables: ${cycle.join(
|
||||
' → ',
|
||||
)}`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
setCycleError(null);
|
||||
onSave(next);
|
||||
};
|
||||
|
||||
return {
|
||||
model,
|
||||
set,
|
||||
onNameChange,
|
||||
selectType,
|
||||
onCustomChange,
|
||||
onDynamicChange,
|
||||
setRawPreview,
|
||||
previewValues,
|
||||
previewError,
|
||||
setPreviewError,
|
||||
defaultValue,
|
||||
setDefaultValue,
|
||||
visibleNameError,
|
||||
nameError,
|
||||
attributeError,
|
||||
cycleError,
|
||||
isListType,
|
||||
showAllOptionField,
|
||||
payloadVariables,
|
||||
handleSave,
|
||||
};
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
import type { VariableFormModel } from '../variableFormModel';
|
||||
|
||||
/**
|
||||
* Name validation, mirroring V1: empty / whitespace are rejected, and the name
|
||||
* set includes self, but keeping your own (original) name is always allowed.
|
||||
*/
|
||||
export function getNameError(
|
||||
name: string,
|
||||
existingNames: string[],
|
||||
originalName: string,
|
||||
): string | null {
|
||||
if (name === '') {
|
||||
return 'Variable name is required';
|
||||
}
|
||||
if (/\s/.test(name)) {
|
||||
return 'Variable name cannot contain whitespaces';
|
||||
}
|
||||
if (name !== originalName && existingNames.includes(name)) {
|
||||
return 'Variable name already exists';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Rejects a dynamic variable reusing an attribute already bound elsewhere. */
|
||||
export function getAttributeError(
|
||||
model: VariableFormModel,
|
||||
existingDynamicAttributes: string[],
|
||||
): string | undefined {
|
||||
if (
|
||||
model.type === 'DYNAMIC' &&
|
||||
model.dynamicAttribute &&
|
||||
existingDynamicAttributes.includes(model.dynamicAttribute)
|
||||
) {
|
||||
return 'A variable with this attribute key already exists';
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
@@ -1,140 +0,0 @@
|
||||
import type { CSSProperties } from 'react';
|
||||
import { Check, GripVertical, PenLine, Trash2, X } from '@signozhq/icons';
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariableRowProps {
|
||||
variable: VariableFormModel;
|
||||
index: number;
|
||||
canEdit: boolean;
|
||||
/** True when this row's delete is awaiting inline confirmation. */
|
||||
isConfirmingDelete: boolean;
|
||||
onEdit: (index: number) => void;
|
||||
onRequestDelete: (index: number) => void;
|
||||
onConfirmDelete: (index: number) => void;
|
||||
onCancelDelete: () => void;
|
||||
}
|
||||
|
||||
/** A single draggable variable row (drag handle + meta + inline actions). */
|
||||
function VariableRow({
|
||||
variable,
|
||||
index,
|
||||
canEdit,
|
||||
isConfirmingDelete,
|
||||
onEdit,
|
||||
onRequestDelete,
|
||||
onConfirmDelete,
|
||||
onCancelDelete,
|
||||
}: VariableRowProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
setActivatorNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id: variable.name });
|
||||
|
||||
const style: CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
...(isDragging ? { position: 'relative', zIndex: 1, opacity: 0.8 } : {}),
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={styles.row}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
{canEdit ? (
|
||||
<span
|
||||
ref={setActivatorNodeRef}
|
||||
className={styles.dragHandle}
|
||||
aria-label="Reorder variable"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={14} />
|
||||
</span>
|
||||
) : null}
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && !isConfirmingDelete ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableRow;
|
||||
@@ -2,11 +2,13 @@
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 20px 16px;
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@@ -28,6 +30,14 @@
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.empty {
|
||||
padding: 32px;
|
||||
text-align: center;
|
||||
border: 1px dashed var(--l1-border);
|
||||
border-radius: 4px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -52,15 +62,6 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
display: flex;
|
||||
flex-shrink: 0;
|
||||
align-items: center;
|
||||
color: var(--l3-foreground);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.varName {
|
||||
font-weight: 500;
|
||||
color: var(--l1-foreground);
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
import type { DragEndEvent } from '@dnd-kit/core';
|
||||
import {
|
||||
DndContext,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
|
||||
import {
|
||||
SortableContext,
|
||||
verticalListSortingStrategy,
|
||||
} from '@dnd-kit/sortable';
|
||||
Check,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
PenLine,
|
||||
Trash2,
|
||||
X,
|
||||
} from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import VariableRow from './VariableRow';
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import styles from './Variables.module.scss';
|
||||
|
||||
const TYPE_LABEL: Record<VariableFormModel['type'], string> = {
|
||||
QUERY: 'Query',
|
||||
CUSTOM: 'Custom',
|
||||
TEXT: 'Text',
|
||||
DYNAMIC: 'Dynamic',
|
||||
};
|
||||
|
||||
interface VariablesListProps {
|
||||
variables: VariableFormModel[];
|
||||
canEdit: boolean;
|
||||
@@ -37,48 +41,98 @@ function VariablesList({
|
||||
onCancelDelete,
|
||||
onMove,
|
||||
}: VariablesListProps): JSX.Element {
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 1 } }),
|
||||
);
|
||||
|
||||
const handleDragEnd = ({ active, over }: DragEndEvent): void => {
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const from = variables.findIndex((v) => v.name === active.id);
|
||||
const to = variables.findIndex((v) => v.name === over.id);
|
||||
if (from !== -1 && to !== -1) {
|
||||
onMove(from, to);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
items={variables.map((v) => v.name)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<VariableRow
|
||||
key={variable.name || `variable-${index}`}
|
||||
variable={variable}
|
||||
index={index}
|
||||
canEdit={canEdit}
|
||||
isConfirmingDelete={confirmingIndex === index}
|
||||
onEdit={onEdit}
|
||||
onRequestDelete={onRequestDelete}
|
||||
onConfirmDelete={onConfirmDelete}
|
||||
onCancelDelete={onCancelDelete}
|
||||
/>
|
||||
))}
|
||||
<div className={styles.list} data-testid="variables-list">
|
||||
{variables.map((variable, index) => (
|
||||
<div
|
||||
className={styles.row}
|
||||
key={variable.name || `variable-${index}`}
|
||||
data-testid={`variable-row-${variable.name}`}
|
||||
>
|
||||
<div className={styles.rowMain}>
|
||||
<Typography.Text className={styles.varName}>
|
||||
${variable.name}
|
||||
</Typography.Text>
|
||||
<span className={styles.typeTag}>{TYPE_LABEL[variable.type]}</span>
|
||||
{variable.description ? (
|
||||
<Typography.Text className={styles.varDesc}>
|
||||
{variable.description}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{canEdit && confirmingIndex === index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Typography.Text className={styles.confirmText}>Delete?</Typography.Text>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
onClick={(): void => onConfirmDelete(index)}
|
||||
aria-label="Confirm delete"
|
||||
testId={`variable-delete-confirm-${variable.name}`}
|
||||
>
|
||||
<Check size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={onCancelDelete}
|
||||
aria-label="Cancel delete"
|
||||
>
|
||||
<X size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{canEdit && confirmingIndex !== index ? (
|
||||
<div className={styles.rowActions}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === 0}
|
||||
onClick={(): void => onMove(index, index - 1)}
|
||||
aria-label="Move up"
|
||||
>
|
||||
<ChevronUp size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
disabled={index === variables.length - 1}
|
||||
onClick={(): void => onMove(index, index + 1)}
|
||||
aria-label="Move down"
|
||||
>
|
||||
<ChevronDown size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onEdit(index)}
|
||||
aria-label="Edit variable"
|
||||
testId={`variable-edit-${variable.name}`}
|
||||
>
|
||||
<PenLine size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
onClick={(): void => onRequestDelete(index)}
|
||||
aria-label="Delete variable"
|
||||
testId={`variable-delete-${variable.name}`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,26 +0,0 @@
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
const AddVariableButton = ({
|
||||
isEditable,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
setIsEditing: (state: { type: 'new' }) => void;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
size="md"
|
||||
onClick={(): void => setIsEditing({ type: 'new' })}
|
||||
testId="add-variable"
|
||||
disabled={!isEditable}
|
||||
>
|
||||
Add variable
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export default AddVariableButton;
|
||||
@@ -1,11 +0,0 @@
|
||||
.backToAllVariables {
|
||||
gap: 8px;
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--l3-border);
|
||||
}
|
||||
|
||||
.backToAllVariablesButton {
|
||||
--button-font-size: 14px;
|
||||
--button-padding: var(--spacing-5) var(--spacing-3);
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { ArrowLeft } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import styles from './BackToAllVariables.module.scss';
|
||||
import { VariableFormProps } from '../../types';
|
||||
|
||||
const BackToAllVariables = ({
|
||||
onClose,
|
||||
}: {
|
||||
onClose: VariableFormProps['onClose'];
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.backToAllVariables}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
className={styles.backToAllVariablesButton}
|
||||
prefix={<ArrowLeft size={14} />}
|
||||
onClick={onClose}
|
||||
testId="variable-form-back"
|
||||
size="md"
|
||||
>
|
||||
All variables
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default BackToAllVariables;
|
||||
@@ -1,25 +0,0 @@
|
||||
.noVariablesCard {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.noVariablesCopy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.noVariablesTitle {
|
||||
color: var(--l1-foreground);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
.noVariablesInfo {
|
||||
color: var(--l3-foreground);
|
||||
font-size: 13px;
|
||||
line-height: 18px;
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import AddVariableButton from '../AddVariableButton';
|
||||
import { EditingState } from '../../types';
|
||||
import styles from './NoVariables.module.scss';
|
||||
|
||||
const NoVariablesCard = ({
|
||||
isEditable,
|
||||
setIsEditing,
|
||||
}: {
|
||||
isEditable: boolean;
|
||||
setIsEditing: React.Dispatch<React.SetStateAction<EditingState | null>>;
|
||||
}): JSX.Element => {
|
||||
return (
|
||||
<div className={styles.noVariablesCard}>
|
||||
<div className={styles.noVariablesCopy}>
|
||||
<Typography.Text className={styles.noVariablesTitle}>
|
||||
No variables yet
|
||||
</Typography.Text>
|
||||
<Typography.Text className={styles.noVariablesInfo}>
|
||||
Create a variable to parameterize your panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default NoVariablesCard;
|
||||
@@ -1,25 +0,0 @@
|
||||
.infoItemContainer {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.infoTitle {
|
||||
color: var(--l2-foreground);
|
||||
font-family: Inter;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.variableNameInput {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
}
|
||||
|
||||
.descriptionTextArea {
|
||||
border-radius: 2px;
|
||||
border: 1px solid var(--l2-border);
|
||||
}
|
||||
@@ -1,60 +0,0 @@
|
||||
import { Input } from '@signozhq/ui/input';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
// eslint-disable-next-line signoz/no-antd-components -- multiline TextArea has no @signozhq/ui equivalent yet
|
||||
import { Input as AntdInput } from 'antd';
|
||||
|
||||
import styles from './VariableInfoForm.module.scss';
|
||||
import variableFormStyles from '../../VariableForm/VariableForm.module.scss';
|
||||
|
||||
interface VariableInfoFormProps {
|
||||
title: string;
|
||||
description: string;
|
||||
onTitleChange: (value: string) => void;
|
||||
onDescriptionChange: (value: string) => void;
|
||||
visibleNameError: string | null;
|
||||
}
|
||||
|
||||
function VariableInfoForm({
|
||||
title,
|
||||
description,
|
||||
onTitleChange,
|
||||
onDescriptionChange,
|
||||
visibleNameError,
|
||||
}: VariableInfoFormProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Name</Typography>
|
||||
|
||||
<Input
|
||||
testId="variable-name"
|
||||
className={styles.variableNameInput}
|
||||
value={title}
|
||||
onChange={(e): void => onTitleChange(e.target.value)}
|
||||
placeholder="Unique name of the variable"
|
||||
/>
|
||||
|
||||
{visibleNameError ? (
|
||||
<Typography.Text className={variableFormStyles.errorText}>
|
||||
<sup>*</sup>
|
||||
{visibleNameError}
|
||||
</Typography.Text>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className={styles.infoItemContainer}>
|
||||
<Typography className={styles.infoTitle}>Description</Typography>
|
||||
<AntdInput.TextArea
|
||||
className={styles.descriptionTextArea}
|
||||
value={description}
|
||||
placeholder="Enter a description for the variable"
|
||||
data-testid="dashboard-desc"
|
||||
rows={3}
|
||||
onChange={(e): void => onDescriptionChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VariableInfoForm;
|
||||
@@ -1,75 +1,75 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type { DashboardtypesGettableDashboardV2DTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import cx from 'classnames';
|
||||
|
||||
import settingsStyles from '../DashboardSettings.module.scss';
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { useSaveVariables } from './useSaveVariables';
|
||||
import { dtoToFormModel } from './variableAdapters';
|
||||
import {
|
||||
emptyVariableFormModel,
|
||||
type VariableFormModel,
|
||||
} from './variableFormModel';
|
||||
} from './variableModel';
|
||||
import VariableForm from './VariableForm/VariableForm';
|
||||
import VariablesList from './VariablesList';
|
||||
import styles from './Variables.module.scss';
|
||||
import AddVariableButton from './components/AddVariableButton';
|
||||
import NoVariablesCard from './components/NoVariablesCard/NoVariablesCard';
|
||||
import { EditingState } from './types';
|
||||
|
||||
interface VariablesSettingsProps {
|
||||
dashboard: DashboardtypesGettableDashboardV2DTO;
|
||||
}
|
||||
|
||||
/** `null` index = adding a new variable; a number = editing that row. */
|
||||
type EditingState = { index: number | null } | null;
|
||||
|
||||
function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
const isEditable = useDashboardStore((s) => s.isEditable);
|
||||
const { save, isSaving } = useSaveVariables();
|
||||
|
||||
const initialFormModels = useMemo(
|
||||
() => dashboard.spec.variables.map(dtoToFormModel),
|
||||
[dashboard.spec.variables],
|
||||
const initialModels = useMemo(
|
||||
() => (dashboard.spec?.variables ?? []).map(dtoToFormModel),
|
||||
[dashboard.spec?.variables],
|
||||
);
|
||||
const [variables, setVariables] =
|
||||
useState<VariableFormModel[]>(initialFormModels);
|
||||
const [variables, setVariables] = useState<VariableFormModel[]>(initialModels);
|
||||
|
||||
// Resync from the dashboard after a save round-trips (refetch bumps updatedAt).
|
||||
useEffect(() => {
|
||||
setVariables(initialFormModels);
|
||||
setVariables(initialModels);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [dashboard.updatedAt]);
|
||||
|
||||
const [isEditing, setIsEditing] = useState<EditingState>(null);
|
||||
const [editing, setEditing] = useState<EditingState>(null);
|
||||
const [confirmDeleteIndex, setConfirmDeleteIndex] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const editingFormModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!isEditing) {
|
||||
const editingModel: VariableFormModel | null = useMemo(() => {
|
||||
if (!editing) {
|
||||
return null;
|
||||
}
|
||||
return isEditing.type === 'new'
|
||||
return editing.index === null
|
||||
? emptyVariableFormModel()
|
||||
: variables[isEditing.index];
|
||||
}, [isEditing, variables]);
|
||||
: variables[editing.index];
|
||||
}, [editing, variables]);
|
||||
|
||||
const siblings = useMemo(() => {
|
||||
const self = isEditing?.type === 'edit' ? isEditing.index : null;
|
||||
return variables.filter((_, i) => i !== self);
|
||||
}, [variables, isEditing]);
|
||||
const existingNames = useMemo(() => {
|
||||
const self = editing?.index ?? null;
|
||||
return variables.filter((_, i) => i !== self).map((v) => v.name);
|
||||
}, [variables, editing]);
|
||||
|
||||
const persist = (next: VariableFormModel[]): void => {
|
||||
setVariables(next);
|
||||
void save(next);
|
||||
};
|
||||
|
||||
const handleFormSave = (Formmodel: VariableFormModel): void => {
|
||||
const handleFormSave = (model: VariableFormModel): void => {
|
||||
const next = [...variables];
|
||||
if (isEditing?.type === 'new') {
|
||||
next.push(Formmodel);
|
||||
} else if (isEditing?.type === 'edit') {
|
||||
next[isEditing.index] = Formmodel;
|
||||
if (editing?.index == null) {
|
||||
next.push(model);
|
||||
} else {
|
||||
next[editing.index] = model;
|
||||
}
|
||||
setIsEditing(null);
|
||||
setEditing(null);
|
||||
persist(next);
|
||||
};
|
||||
|
||||
@@ -88,14 +88,14 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
setConfirmDeleteIndex(null);
|
||||
};
|
||||
|
||||
if (editingFormModel) {
|
||||
// Detail view — edit/new form replaces the list in place (no modal).
|
||||
if (editingModel) {
|
||||
return (
|
||||
<VariableForm
|
||||
initial={editingFormModel}
|
||||
siblings={siblings}
|
||||
isNew={isEditing?.type === 'new'}
|
||||
initial={editingModel}
|
||||
existingNames={existingNames}
|
||||
isSaving={isSaving}
|
||||
onClose={(): void => setIsEditing(null)}
|
||||
onClose={(): void => setEditing(null)}
|
||||
onSave={handleFormSave}
|
||||
/>
|
||||
);
|
||||
@@ -103,25 +103,42 @@ function VariablesSettings({ dashboard }: VariablesSettingsProps): JSX.Element {
|
||||
|
||||
// Master view — the variables list.
|
||||
return (
|
||||
<div className={cx(styles.container, settingsStyles.settingsCard)}>
|
||||
<div className={styles.container}>
|
||||
<div className={styles.header}>
|
||||
<div className={styles.titleRow}>
|
||||
<Typography.Text className={styles.title}>Variables</Typography.Text>
|
||||
<Typography.Text className={styles.subtitle}>
|
||||
Define variables to parameterize panel queries.
|
||||
</Typography.Text>
|
||||
</div>
|
||||
{isEditable ? (
|
||||
<Button
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Plus size={14} />}
|
||||
onClick={(): void => setEditing({ index: null })}
|
||||
testId="add-variable"
|
||||
>
|
||||
New variable
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{variables.length === 0 ? (
|
||||
<NoVariablesCard isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
<div className={styles.empty}>
|
||||
<Typography.Text>No variables defined yet.</Typography.Text>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={styles.header}>
|
||||
<AddVariableButton isEditable={isEditable} setIsEditing={setIsEditing} />
|
||||
</div>
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setIsEditing({ type: 'edit', index })}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
</>
|
||||
<VariablesList
|
||||
variables={variables}
|
||||
canEdit={isEditable}
|
||||
confirmingIndex={confirmDeleteIndex}
|
||||
onEdit={(index): void => setEditing({ index })}
|
||||
onRequestDelete={(index): void => setConfirmDeleteIndex(index)}
|
||||
onConfirmDelete={handleConfirmDelete}
|
||||
onCancelDelete={(): void => setConfirmDeleteIndex(null)}
|
||||
onMove={handleMove}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
import { VariableFormModel } from './variableFormModel';
|
||||
|
||||
/** `null` index = adding a new variable; a number = editing that row. */
|
||||
export type EditingState =
|
||||
| { type: 'new' }
|
||||
| { type: 'edit'; index: number }
|
||||
| null;
|
||||
|
||||
export interface VariableFormProps {
|
||||
initial: VariableFormModel;
|
||||
/** The other variables (excluding this one), for uniqueness & cycle checks. */
|
||||
siblings: VariableFormModel[];
|
||||
/** True when adding a new variable (enables auto-naming from the attribute). */
|
||||
isNew: boolean;
|
||||
isSaving: boolean;
|
||||
onClose: () => void;
|
||||
onSave: (model: VariableFormModel) => void;
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import APIError from 'types/api/error';
|
||||
|
||||
import { useDashboardStore } from '../../store/useDashboardStore';
|
||||
import { formModelToDto } from './variableAdapters';
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
import type { VariableFormModel } from './variableModel';
|
||||
import { buildVariablesPatch } from './variablePatchOps';
|
||||
|
||||
interface UseSaveVariables {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesCustomVariableSpecDTOKind as CustomPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesDynamicVariableSpecDTOKind as DynamicPluginKind,
|
||||
DashboardtypesVariablePluginVariantGithubComSigNozSignozPkgTypesDashboardtypesQueryVariableSpecDTOKind as QueryPluginKind,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
DashboardtypesListVariableSpecDTO,
|
||||
@@ -13,24 +14,21 @@ import type {
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
type DynamicSignalOption,
|
||||
emptyVariableFormModel,
|
||||
signalForApi,
|
||||
VARIABLE_SORT_DISABLED,
|
||||
PLUGIN_KIND,
|
||||
type TelemetrySignal,
|
||||
type VariableFormModel,
|
||||
type VariableSort,
|
||||
} from './variableFormModel';
|
||||
} from './variableModel';
|
||||
|
||||
/** DTO envelope → flat form model (for display / editing). */
|
||||
export function dtoToFormModel(
|
||||
dto: DashboardtypesVariableDTO,
|
||||
): VariableFormModel {
|
||||
const base = emptyVariableFormModel();
|
||||
const display = dto.spec.display;
|
||||
const display = dto.spec?.display;
|
||||
const common: VariableFormModel = {
|
||||
...base,
|
||||
// TODO
|
||||
name: dto.spec?.name ?? display?.name ?? '',
|
||||
description: display?.description ?? '',
|
||||
};
|
||||
@@ -52,7 +50,7 @@ export function dtoToFormModel(
|
||||
...common,
|
||||
multiSelect: spec.allowMultiple ?? false,
|
||||
showAllOption: spec.allowAllValue ?? false,
|
||||
sort: (spec.sort as VariableSort) ?? VARIABLE_SORT_DISABLED,
|
||||
sort: (spec.sort as VariableSort) ?? 'DISABLED',
|
||||
defaultValue: spec.defaultValue,
|
||||
};
|
||||
const plugin = spec.plugin;
|
||||
@@ -69,9 +67,7 @@ export function dtoToFormModel(
|
||||
...listCommon,
|
||||
type: 'DYNAMIC',
|
||||
dynamicAttribute: plugin.spec.name ?? '',
|
||||
// An omitted wire signal means "all telemetry".
|
||||
dynamicSignal:
|
||||
(plugin.spec.signal as DynamicSignalOption) ?? DYNAMIC_SIGNAL_ALL,
|
||||
dynamicSignal: (plugin.spec.signal as TelemetrySignal) ?? 'traces',
|
||||
};
|
||||
}
|
||||
// Default to Query (also covers a query plugin or a missing/unknown plugin).
|
||||
@@ -99,7 +95,7 @@ function buildPlugin(
|
||||
kind: DynamicPluginKind['signoz/DynamicVariable'],
|
||||
spec: {
|
||||
name: model.dynamicAttribute,
|
||||
signal: signalForApi(model.dynamicSignal),
|
||||
signal: model.dynamicSignal as TelemetrytypesSignalDTO,
|
||||
},
|
||||
};
|
||||
case 'QUERY':
|
||||
@@ -118,6 +114,7 @@ export function formModelToDto(
|
||||
const display = {
|
||||
name: model.name,
|
||||
description: model.description,
|
||||
hidden: model.hidden,
|
||||
};
|
||||
|
||||
if (model.type === 'TEXT') {
|
||||
@@ -138,10 +135,7 @@ export function formModelToDto(
|
||||
name: model.name,
|
||||
display,
|
||||
allowMultiple: model.multiSelect,
|
||||
// Dynamic variables always expose the aggregate "ALL" entry (matches V1,
|
||||
// which forced showALLOption true on save); other types respect the toggle.
|
||||
allowAllValue: model.type === 'DYNAMIC' ? true : model.showAllOption,
|
||||
// model.sort is already a Perses sort token (`none` / `alphabetical-*`).
|
||||
allowAllValue: model.showAllOption,
|
||||
sort: model.sort,
|
||||
defaultValue: model.defaultValue,
|
||||
plugin: buildPlugin(model),
|
||||
@@ -155,3 +149,5 @@ export function variableTypeOf(
|
||||
): VariableFormModel['type'] {
|
||||
return dtoToFormModel(dto).type;
|
||||
}
|
||||
|
||||
export { PLUGIN_KIND };
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
import {
|
||||
buildDependencies,
|
||||
buildDependencyGraph,
|
||||
} from 'container/DashboardContainer/DashboardVariablesSelection/util';
|
||||
import type { IDashboardVariable } from 'types/api/dashboard/getAll';
|
||||
|
||||
import type { VariableFormModel } from './variableFormModel';
|
||||
|
||||
/**
|
||||
* Detects a circular reference among QUERY variables (a query referencing
|
||||
* another that, transitively, references it back). Reuses the V1 dependency
|
||||
* graph helpers, which key off `name` / `type` / `queryValue` only.
|
||||
*
|
||||
* Returns the names forming the cycle, or `null` when the set is acyclic.
|
||||
*/
|
||||
export function detectVariableCycle(
|
||||
variables: VariableFormModel[],
|
||||
): string[] | null {
|
||||
const asDbVariables = variables
|
||||
.filter((variable) => variable.name)
|
||||
.map(
|
||||
(variable) =>
|
||||
({
|
||||
name: variable.name,
|
||||
type: variable.type,
|
||||
queryValue: variable.queryValue,
|
||||
}) as IDashboardVariable,
|
||||
);
|
||||
|
||||
const { hasCycle, cycleNodes } = buildDependencyGraph(
|
||||
buildDependencies(asDbVariables),
|
||||
);
|
||||
|
||||
return hasCycle ? (cycleNodes ?? []) : null;
|
||||
}
|
||||
@@ -1,154 +0,0 @@
|
||||
import { TelemetrytypesSignalDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { sortBy } from 'lodash-es';
|
||||
|
||||
/**
|
||||
* The four variable types the editor exposes. No generated enum exists for this
|
||||
* — it's a UI grouping over the wire's envelope + plugin kinds: the TextVariable
|
||||
* envelope → `TEXT`, and a ListVariable's `DashboardtypesVariablePluginKindDTO`
|
||||
* (`signoz/QueryVariable` | `signoz/CustomVariable` | `signoz/DynamicVariable`)
|
||||
* → `QUERY` | `CUSTOM` | `DYNAMIC`. Replace with a generated enum if the backend
|
||||
* ever exposes a single variable-kind type.
|
||||
*/
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
/** Telemetry signal — the generated enum (traces / logs / metrics). */
|
||||
export type TelemetrySignal = TelemetrytypesSignalDTO;
|
||||
|
||||
/**
|
||||
* Signal selected in the dynamic-variable editor. `'all'` is UI-only (the
|
||||
* generated `TelemetrytypesSignalDTO` has no "all") — it searches across every
|
||||
* signal and maps to an omitted `signal` on the wire (see {@link signalForApi}).
|
||||
*/
|
||||
export const DYNAMIC_SIGNAL_ALL = 'all' as const;
|
||||
export type DynamicSignalOption = TelemetrySignal | typeof DYNAMIC_SIGNAL_ALL;
|
||||
|
||||
/**
|
||||
* Sort order for list-variable values. The wire (Perses) validates `sort`
|
||||
* against a fixed method set. There is no generated TS enum for it
|
||||
* (`DashboardtypesListOrderDTO` is the query-builder order, a different field),
|
||||
* so we mirror the Perses `Sort` tokens here.
|
||||
*/
|
||||
export const VARIABLE_SORT = {
|
||||
DISABLED: 'none',
|
||||
ASC: 'alphabetical-asc',
|
||||
DESC: 'alphabetical-desc',
|
||||
NUMERICAL_ASC: 'numerical-asc',
|
||||
NUMERICAL_DESC: 'numerical-desc',
|
||||
CI_ASC: 'alphabetical-ci-asc',
|
||||
CI_DESC: 'alphabetical-ci-desc',
|
||||
} as const;
|
||||
|
||||
export type VariableSort = (typeof VARIABLE_SORT)[keyof typeof VARIABLE_SORT];
|
||||
|
||||
/** Persisted "no sort" value (Perses `none`). */
|
||||
export const VARIABLE_SORT_DISABLED: VariableSort = VARIABLE_SORT.DISABLED;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSort[] = [
|
||||
VARIABLE_SORT.DISABLED,
|
||||
VARIABLE_SORT.ASC,
|
||||
VARIABLE_SORT.DESC,
|
||||
VARIABLE_SORT.NUMERICAL_ASC,
|
||||
VARIABLE_SORT.NUMERICAL_DESC,
|
||||
VARIABLE_SORT.CI_ASC,
|
||||
VARIABLE_SORT.CI_DESC,
|
||||
];
|
||||
|
||||
export const VARIABLE_SORT_LABEL: Record<VariableSort, string> = {
|
||||
[VARIABLE_SORT.DISABLED]: 'Disabled',
|
||||
[VARIABLE_SORT.ASC]: 'Alphabetical (ascending)',
|
||||
[VARIABLE_SORT.DESC]: 'Alphabetical (descending)',
|
||||
[VARIABLE_SORT.NUMERICAL_ASC]: 'Numerical (ascending)',
|
||||
[VARIABLE_SORT.NUMERICAL_DESC]: 'Numerical (descending)',
|
||||
[VARIABLE_SORT.CI_ASC]: 'Alphabetical, case-insensitive (ascending)',
|
||||
[VARIABLE_SORT.CI_DESC]: 'Alphabetical, case-insensitive (descending)',
|
||||
};
|
||||
|
||||
export const DYNAMIC_SIGNALS: DynamicSignalOption[] = [
|
||||
DYNAMIC_SIGNAL_ALL,
|
||||
TelemetrytypesSignalDTO.traces,
|
||||
TelemetrytypesSignalDTO.logs,
|
||||
TelemetrytypesSignalDTO.metrics,
|
||||
];
|
||||
|
||||
export const DYNAMIC_SIGNAL_LABEL: Record<DynamicSignalOption, string> = {
|
||||
[DYNAMIC_SIGNAL_ALL]: 'All telemetry',
|
||||
[TelemetrytypesSignalDTO.traces]: 'Traces',
|
||||
[TelemetrytypesSignalDTO.logs]: 'Logs',
|
||||
[TelemetrytypesSignalDTO.metrics]: 'Metrics',
|
||||
};
|
||||
|
||||
/** Maps the editor's signal selection to the wire value (`'all'` → omitted). */
|
||||
export function signalForApi(
|
||||
signal: DynamicSignalOption,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal === DYNAMIC_SIGNAL_ALL ? undefined : signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts preview / option values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
switch (sort) {
|
||||
case VARIABLE_SORT.ASC:
|
||||
return sortBy(values);
|
||||
case VARIABLE_SORT.DESC:
|
||||
return sortBy(values).reverse();
|
||||
case VARIABLE_SORT.NUMERICAL_ASC:
|
||||
return sortBy(values, (value) => Number(value));
|
||||
case VARIABLE_SORT.NUMERICAL_DESC:
|
||||
return sortBy(values, (value) => Number(value)).reverse();
|
||||
case VARIABLE_SORT.CI_ASC:
|
||||
return sortBy(values, (value) => String(value).toLowerCase());
|
||||
case VARIABLE_SORT.CI_DESC:
|
||||
return sortBy(values, (value) => String(value).toLowerCase()).reverse();
|
||||
default:
|
||||
return values;
|
||||
}
|
||||
}
|
||||
|
||||
export interface VariableFormModel {
|
||||
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
|
||||
name: string;
|
||||
description: string;
|
||||
type: VariableType;
|
||||
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
customValue: string; // CUSTOM
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: DynamicSignalOption; // DYNAMIC — the telemetry signal
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'DYNAMIC',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: VARIABLE_SORT_DISABLED,
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: DYNAMIC_SIGNAL_ALL,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { sortBy } from 'lodash-es';
|
||||
import type { VariableDefaultValueDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* Flat, UI-friendly representation of a V2 dashboard variable. The wire format
|
||||
* (`DashboardtypesVariableDTO`) is a nested envelope/plugin union that is awkward
|
||||
* to bind a form to; `variableAdapters` converts between this model and the DTO.
|
||||
*/
|
||||
|
||||
export type VariableType = 'QUERY' | 'CUSTOM' | 'TEXT' | 'DYNAMIC';
|
||||
|
||||
export type VariableSort = 'DISABLED' | 'ASC' | 'DESC';
|
||||
|
||||
export type TelemetrySignal = 'traces' | 'logs' | 'metrics';
|
||||
|
||||
/** Wire `kind` discriminators (string values of the generated enums). */
|
||||
export const ENVELOPE_KIND = {
|
||||
LIST: 'ListVariable',
|
||||
TEXT: 'TextVariable',
|
||||
} as const;
|
||||
|
||||
export const PLUGIN_KIND = {
|
||||
QUERY: 'signoz/QueryVariable',
|
||||
CUSTOM: 'signoz/CustomVariable',
|
||||
DYNAMIC: 'signoz/DynamicVariable',
|
||||
} as const;
|
||||
|
||||
export const VARIABLE_SORTS: VariableSort[] = ['DISABLED', 'ASC', 'DESC'];
|
||||
|
||||
export const TELEMETRY_SIGNALS: TelemetrySignal[] = [
|
||||
'traces',
|
||||
'logs',
|
||||
'metrics',
|
||||
];
|
||||
|
||||
export interface VariableFormModel {
|
||||
/** Stable identifier, referenced in queries (e.g. `$name`); must be unique. */
|
||||
name: string;
|
||||
description: string;
|
||||
hidden: boolean;
|
||||
type: VariableType;
|
||||
|
||||
// List-variable common fields (Query / Custom / Dynamic).
|
||||
multiSelect: boolean;
|
||||
showAllOption: boolean;
|
||||
sort: VariableSort;
|
||||
|
||||
// Type-specific.
|
||||
queryValue: string; // QUERY
|
||||
customValue: string; // CUSTOM
|
||||
textValue: string; // TEXT
|
||||
textConstant: boolean; // TEXT
|
||||
dynamicAttribute: string; // DYNAMIC — the telemetry field name
|
||||
dynamicSignal: TelemetrySignal; // DYNAMIC — the telemetry signal
|
||||
|
||||
/**
|
||||
* Runtime-selected default, not editable in the management tab yet; carried
|
||||
* through edits so saving a definition doesn't clobber it.
|
||||
*/
|
||||
defaultValue?: VariableDefaultValueDTO;
|
||||
}
|
||||
|
||||
export function emptyVariableFormModel(): VariableFormModel {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
hidden: false,
|
||||
type: 'QUERY',
|
||||
multiSelect: false,
|
||||
showAllOption: false,
|
||||
sort: 'DISABLED',
|
||||
queryValue: '',
|
||||
customValue: '',
|
||||
textValue: '',
|
||||
textConstant: false,
|
||||
dynamicAttribute: '',
|
||||
dynamicSignal: 'traces',
|
||||
};
|
||||
}
|
||||
|
||||
/** Maps the dynamic-variable signal to the field-values API signal. */
|
||||
export function signalForApi(
|
||||
signal: TelemetrySignal,
|
||||
): TelemetrySignal | undefined {
|
||||
return signal;
|
||||
}
|
||||
|
||||
type SortableValues = (string | number | boolean)[];
|
||||
|
||||
/** Sorts option/preview values by the variable's chosen order (no-op when disabled). */
|
||||
export function sortValuesByOrder(
|
||||
values: SortableValues,
|
||||
sort: VariableSort,
|
||||
): SortableValues {
|
||||
if (sort === 'ASC') {
|
||||
return sortBy(values);
|
||||
}
|
||||
if (sort === 'DESC') {
|
||||
return sortBy(values).reverse();
|
||||
}
|
||||
return values;
|
||||
}
|
||||
@@ -1,90 +0,0 @@
|
||||
.config {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
// padding: 18px 18px 44px;
|
||||
background-color: var(--l1-background);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
|
||||
//TODO: replace this with custom-scrollbar mixin
|
||||
// Thin, unobtrusive scrollbar (replaces the chunky native bar).
|
||||
$thumb: color-mix(in srgb, var(--bg-vanilla-100) 16%, transparent);
|
||||
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: $thumb transparent;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
&::-webkit-scrollbar-thumb {
|
||||
background: $thumb;
|
||||
border-radius: 999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
||||
.heading {
|
||||
margin-bottom: 18px;
|
||||
padding: 16px 16px 0 16px;
|
||||
}
|
||||
|
||||
.title {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.eyebrow {
|
||||
display: block;
|
||||
margin: 0 2px 10px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.divider {
|
||||
height: 1px;
|
||||
background: var(--l2-border);
|
||||
margin: 18px 0;
|
||||
}
|
||||
|
||||
.sectionsContainer {
|
||||
padding: 0 16px;
|
||||
}
|
||||
|
||||
.sections {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
& > * + * {
|
||||
border-top: 1px solid var(--l2-border);
|
||||
}
|
||||
}
|
||||
@@ -1,108 +0,0 @@
|
||||
import { Input } from 'antd';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { getPanelDefinition } from 'pages/DashboardPageV2/DashboardContainer/Panels/registry';
|
||||
import { getBuilderQueries } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries';
|
||||
|
||||
import type { LegendSeries } from '../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../hooks/useTableColumns';
|
||||
import SectionSlot from './SectionSlot/SectionSlot';
|
||||
|
||||
import styles from './ConfigPane.module.scss';
|
||||
import { PanelKind } from '../../Panels/types/panelKind';
|
||||
|
||||
interface ConfigPaneProps {
|
||||
/** Full plugin kind (e.g. `signoz/TimeSeriesPanel`); drives which sections show. */
|
||||
panelKind: PanelKind;
|
||||
/** The panel spec — the single editing surface (title/description + section slices). */
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Panel's resolved series, provided to sections that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
tableColumns: TableColumnOption[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Right-hand configuration pane. Renders the always-present general fields (title +
|
||||
* description) followed by the panel kind's configuration sections (Formatting, Axes,
|
||||
* …). The section list is declared per kind (`PanelDefinition.sections`) and rendered
|
||||
* generically via the section registry — only sections with a built editor appear.
|
||||
*/
|
||||
function ConfigPane({
|
||||
panelKind,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
}: ConfigPaneProps): JSX.Element {
|
||||
const definition = getPanelDefinition(panelKind);
|
||||
const sections = definition?.sections ?? [];
|
||||
|
||||
const signal = getBuilderQueries(spec.queries)[0]?.signal as
|
||||
| TelemetrytypesSignalDTO
|
||||
| undefined;
|
||||
|
||||
// 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.
|
||||
const setDisplayField = (field: 'name' | 'description', value: string): void =>
|
||||
onChangeSpec({ ...spec, display: { ...spec.display, [field]: value } });
|
||||
|
||||
return (
|
||||
<div className={styles.config}>
|
||||
<header className={styles.heading}>
|
||||
<Typography.Text>Panel settings</Typography.Text>
|
||||
</header>
|
||||
|
||||
<div className={styles.group}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Title</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-title"
|
||||
value={spec.display.name}
|
||||
placeholder="Panel title"
|
||||
onChange={(e): void => setDisplayField('name', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Description</Typography.Text>
|
||||
<Input.TextArea
|
||||
data-testid="panel-editor-v2-description"
|
||||
value={spec.display.description ?? ''}
|
||||
placeholder="Add a description"
|
||||
rows={3}
|
||||
onChange={(e): void => setDisplayField('description', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{sections.length > 0 && (
|
||||
<>
|
||||
<div className={styles.divider} />
|
||||
<div className={styles.sectionsContainer}>
|
||||
<span className={styles.eyebrow}>Display</span>
|
||||
<div className={styles.sections}>
|
||||
{sections.map((config) => (
|
||||
<SectionSlot
|
||||
key={config.kind}
|
||||
config={config}
|
||||
spec={spec}
|
||||
onChangeSpec={onChangeSpec}
|
||||
legendSeries={legendSeries}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigPane;
|
||||
@@ -1,77 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import {
|
||||
type PanelFormattingSlice,
|
||||
SECTION_METADATA,
|
||||
type SectionConfig,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { LegendSeries } from '../../hooks/useLegendSeries';
|
||||
import type { TableColumnOption } from '../../hooks/useTableColumns';
|
||||
import { resolveSectionEditor } from '../sectionRegistry';
|
||||
import SettingsSection from '../SettingsSection/SettingsSection';
|
||||
|
||||
interface SectionSlotProps {
|
||||
config: SectionConfig;
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Resolved series, forwarded to editors that need them (legend colors). */
|
||||
legendSeries: LegendSeries[];
|
||||
/** Table panel's resolved value columns, for the table-only editors. */
|
||||
tableColumns: TableColumnOption[];
|
||||
/** Panel's telemetry signal, for editors that fetch field suggestions (List columns). */
|
||||
signal?: TelemetrytypesSignalDTO;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders one configuration section: its collapsible wrapper plus the registered editor
|
||||
* for `config.kind`, wired through the registry's spec lens. Renders nothing when the
|
||||
* kind has no editor yet (sections roll out incrementally), so a kind can declare a
|
||||
* section before its editor exists.
|
||||
*/
|
||||
function SectionSlot({
|
||||
config,
|
||||
spec,
|
||||
onChangeSpec,
|
||||
legendSeries,
|
||||
tableColumns,
|
||||
signal,
|
||||
}: SectionSlotProps): JSX.Element | null {
|
||||
// A kind can hide a section based on current spec state (e.g. Histogram legend once
|
||||
// queries are merged) — skip it before resolving the editor.
|
||||
if (config.isHidden?.(spec)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const editor = resolveSectionEditor(config.kind);
|
||||
if (!editor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { title, icon: Icon } = SECTION_METADATA[config.kind];
|
||||
const { Component, get, update } = editor;
|
||||
// Atomic sections carry no `controls`; controlled ones do.
|
||||
const controls = 'controls' in config ? config.controls : undefined;
|
||||
// The panel's formatting unit, forwarded to editors that scope to it (thresholds
|
||||
// restrict their unit picker to this unit's category, as in V1).
|
||||
const yAxisUnit = (spec.plugin.spec as { formatting?: PanelFormattingSlice })
|
||||
.formatting?.unit;
|
||||
|
||||
return (
|
||||
<SettingsSection title={title} icon={<Icon size={15} />}>
|
||||
<Component
|
||||
value={get(spec)}
|
||||
controls={controls}
|
||||
onChange={(next): void => onChangeSpec(update(spec, next))}
|
||||
legendSeries={legendSeries}
|
||||
yAxisUnit={yAxisUnit}
|
||||
tableColumns={tableColumns}
|
||||
signal={signal}
|
||||
/>
|
||||
</SettingsSection>
|
||||
);
|
||||
}
|
||||
|
||||
export default SectionSlot;
|
||||
@@ -1,54 +0,0 @@
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 11px;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
padding: 0 4px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
color: var(--text-vanilla-100);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.iconTile {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 27px;
|
||||
height: 27px;
|
||||
flex: none;
|
||||
border-radius: 3px;
|
||||
background: var(--l3-background);
|
||||
color: var(--l3-foreground);
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
.iconTileOpen {
|
||||
background: color-mix(in srgb, var(--bg-robin-400) 14%, transparent);
|
||||
color: var(--bg-robin-400);
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--l2-border);
|
||||
transition: transform 0.15s ease;
|
||||
|
||||
&.open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 2px 0 18px;
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
import { type ReactNode, useState } from 'react';
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import cx from 'classnames';
|
||||
|
||||
import styles from './SettingsSection.module.scss';
|
||||
|
||||
interface SettingsSectionProps {
|
||||
title: string;
|
||||
icon?: ReactNode;
|
||||
defaultOpen?: boolean;
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapsible container for one configuration section in the V2 panel editor's
|
||||
* ConfigPane. Header shows an icon tile (accented when expanded), the title, and a
|
||||
* rotating chevron; sections are separated by hairline dividers (no surrounding boxes),
|
||||
* matching the Configure-panel design.
|
||||
*/
|
||||
function SettingsSection({
|
||||
title,
|
||||
icon,
|
||||
defaultOpen = false,
|
||||
children,
|
||||
}: SettingsSectionProps): JSX.Element {
|
||||
const [isOpen, setIsOpen] = useState(defaultOpen);
|
||||
|
||||
return (
|
||||
<section className={styles.section}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.header}
|
||||
aria-expanded={isOpen}
|
||||
data-testid={`config-section-${title}`}
|
||||
onClick={(): void => setIsOpen((prev) => !prev)}
|
||||
>
|
||||
{icon && (
|
||||
<span className={cx(styles.iconTile, { [styles.iconTileOpen]: isOpen })}>
|
||||
{icon}
|
||||
</span>
|
||||
)}
|
||||
<Typography.Text className={styles.title}>{title}</Typography.Text>
|
||||
<ChevronDown
|
||||
size={15}
|
||||
className={cx(styles.chevron, { [styles.open]: isOpen })}
|
||||
/>
|
||||
</button>
|
||||
{isOpen && <div className={styles.body}>{children}</div>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
export default SettingsSection;
|
||||
@@ -1,69 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelSpecDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ConfigPane from '../ConfigPane';
|
||||
import { PanelKind } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/panelKind';
|
||||
|
||||
function spec(unit?: string): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
display: { name: 'CPU', description: 'usage' },
|
||||
plugin: {
|
||||
kind: 'signoz/TimeSeriesPanel',
|
||||
spec: unit ? { formatting: { unit } } : {},
|
||||
},
|
||||
queries: [],
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
function renderConfigPane(
|
||||
overrides: Partial<React.ComponentProps<typeof ConfigPane>> = {},
|
||||
): React.ComponentProps<typeof ConfigPane> {
|
||||
const props: React.ComponentProps<typeof ConfigPane> = {
|
||||
panelKind: 'signoz/TimeSeriesPanel',
|
||||
spec: spec(),
|
||||
onChangeSpec: jest.fn(),
|
||||
legendSeries: [],
|
||||
tableColumns: [],
|
||||
...overrides,
|
||||
};
|
||||
render(<ConfigPane {...props} />);
|
||||
return props;
|
||||
}
|
||||
|
||||
describe('ConfigPane', () => {
|
||||
it('renders the seeded title and description', () => {
|
||||
renderConfigPane();
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-title')).toHaveValue('CPU');
|
||||
expect(screen.getByTestId('panel-editor-v2-description')).toHaveValue(
|
||||
'usage',
|
||||
);
|
||||
});
|
||||
|
||||
it('reports title edits through onChangeSpec (into spec.display)', () => {
|
||||
const { onChangeSpec } = renderConfigPane();
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-title'), {
|
||||
target: { value: 'Memory' },
|
||||
});
|
||||
|
||||
expect(onChangeSpec).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
display: { name: 'Memory', description: 'usage' },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('renders the Formatting section for a kind that declares it', () => {
|
||||
renderConfigPane();
|
||||
// The TimeSeries kind declares a Formatting section; its collapsible header shows.
|
||||
expect(screen.getByTestId('config-section-Formatting')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('omits the Formatting section for an unknown kind', () => {
|
||||
renderConfigPane({ panelKind: 'signoz/UnknownPanel' as PanelKind });
|
||||
expect(
|
||||
screen.queryByTestId('config-section-Formatting'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
.group {
|
||||
width: min(350px, 100%);
|
||||
}
|
||||
|
||||
.segment {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
import { ToggleGroupSimple } from '@signozhq/ui/toggle-group';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
|
||||
import styles from './ConfigSegmented.module.scss';
|
||||
|
||||
export interface ConfigSegmentedItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: SegmentIconName;
|
||||
}
|
||||
|
||||
interface ConfigSegmentedProps {
|
||||
testId: string;
|
||||
value: string | undefined;
|
||||
items: ConfigSegmentedItem[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inline segmented control for short option sets in the config pane (line style, fill
|
||||
* mode, axis scale, legend position). Each segment carries an optional muted glyph that
|
||||
* brightens with the selected state (it inherits the toggle's `currentColor`). Built on
|
||||
* the Periscope ToggleGroup so it stays theme-faithful.
|
||||
*/
|
||||
function ConfigSegmented({
|
||||
testId,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
}: ConfigSegmentedProps): JSX.Element {
|
||||
return (
|
||||
<ToggleGroupSimple
|
||||
type="single"
|
||||
testId={testId}
|
||||
className={styles.group}
|
||||
value={value}
|
||||
items={items.map((item) => ({
|
||||
value: item.value,
|
||||
'aria-label': item.label,
|
||||
label: (
|
||||
<span className={styles.segment}>
|
||||
{item.icon && <SegmentIcon name={item.icon} />}
|
||||
{item.label}
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
// Single toggle-groups emit '' when the active segment is re-clicked; ignore that
|
||||
// so a required choice (e.g. scale, position) can't be cleared to an empty value.
|
||||
onChange={(next: string): void => {
|
||||
if (next) {
|
||||
onChange(next);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSegmented;
|
||||
@@ -1,10 +0,0 @@
|
||||
// Fill the section field so the select lines up with the other full-width controls.
|
||||
.select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import { Select } from 'antd';
|
||||
|
||||
import { SegmentIcon, type SegmentIconName } from '../segmentIcons';
|
||||
|
||||
import styles from './ConfigSelect.module.scss';
|
||||
|
||||
export interface ConfigSelectItem {
|
||||
value: string;
|
||||
label: string;
|
||||
icon?: SegmentIconName;
|
||||
}
|
||||
|
||||
interface ConfigSelectProps {
|
||||
testId: string;
|
||||
value: string | undefined;
|
||||
placeholder?: string;
|
||||
items: ConfigSelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Single-select dropdown for the panel editor's config sections. Built on antd's
|
||||
* `Select` so it matches the rest of the editor's antd controls; the menu portals to
|
||||
* `document.body` (antd default) so the surrounding `overflow:auto` pane can't clip it.
|
||||
*/
|
||||
function ConfigSelect({
|
||||
testId,
|
||||
value,
|
||||
placeholder,
|
||||
items,
|
||||
onChange,
|
||||
}: ConfigSelectProps): JSX.Element {
|
||||
return (
|
||||
<Select<string>
|
||||
className={styles.select}
|
||||
data-testid={testId}
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={onChange}
|
||||
virtual={false}
|
||||
options={items.map((item) => ({
|
||||
value: item.value,
|
||||
label: item.icon ? (
|
||||
<span className={styles.item}>
|
||||
<SegmentIcon name={item.icon} />
|
||||
{item.label}
|
||||
</span>
|
||||
) : (
|
||||
item.label
|
||||
),
|
||||
}))}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSelect;
|
||||
@@ -1,30 +0,0 @@
|
||||
.card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
background: var(--l2-background-60);
|
||||
}
|
||||
|
||||
.text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.06em;
|
||||
text-transform: uppercase;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.description {
|
||||
font-size: 12px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -1,43 +0,0 @@
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './ConfigSwitch.module.scss';
|
||||
|
||||
interface ConfigSwitchProps {
|
||||
testId: string;
|
||||
/** Shown uppercased as the card title. */
|
||||
title: string;
|
||||
/** Optional helper line under the title. */
|
||||
description?: string;
|
||||
value: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Boolean toggle rendered as a bordered card: an uppercase title with an optional
|
||||
* description on the left and a Switch on the right. The standard presentation for
|
||||
* on/off panel-config controls (e.g. "Show points").
|
||||
*/
|
||||
function ConfigSwitch({
|
||||
testId,
|
||||
title,
|
||||
description,
|
||||
value,
|
||||
onChange,
|
||||
}: ConfigSwitchProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.card}>
|
||||
<div className={styles.text}>
|
||||
<span className={styles.title}>{title}</span>
|
||||
{description && (
|
||||
<Typography.Text className={styles.description}>
|
||||
{description}
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
<Switch testId={testId} value={value} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ConfigSwitch;
|
||||
@@ -1,64 +0,0 @@
|
||||
import { ColorPicker } from 'antd';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import styles from './LegendColors.module.scss';
|
||||
|
||||
interface LegendColorRowProps {
|
||||
label: string;
|
||||
/** Effective color shown in the swatch (override or auto). */
|
||||
color: string;
|
||||
/** True when the series has an explicit override (enables Reset). */
|
||||
isOverridden: boolean;
|
||||
onChange: (hex: string) => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* One series row in the legend-colors list: an antd ColorPicker swatch trigger, the
|
||||
* series label, and a Reset action shown only when the color is overridden. `onChange`
|
||||
* fires on commit (`onChangeComplete`) so dragging the picker doesn't churn the spec.
|
||||
*/
|
||||
function LegendColorRow({
|
||||
label,
|
||||
color,
|
||||
isOverridden,
|
||||
onChange,
|
||||
onReset,
|
||||
}: LegendColorRowProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.row}>
|
||||
<ColorPicker
|
||||
value={color}
|
||||
size="small"
|
||||
showText={false}
|
||||
trigger="click"
|
||||
onChangeComplete={(next): void => onChange(next.toHexString())}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.trigger}
|
||||
data-testid={`legend-color-${label}`}
|
||||
>
|
||||
<span className={styles.swatch} style={{ backgroundColor: color }} />
|
||||
<Typography.Text className={styles.label} title={label}>
|
||||
{label}
|
||||
</Typography.Text>
|
||||
</button>
|
||||
</ColorPicker>
|
||||
{isOverridden && (
|
||||
<Button
|
||||
size="sm"
|
||||
color="secondary"
|
||||
variant="ghost"
|
||||
onClick={onReset}
|
||||
testId={`legend-color-reset-${label}`}
|
||||
>
|
||||
Reset
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegendColorRow;
|
||||
@@ -1,51 +0,0 @@
|
||||
.container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
height: 34px;
|
||||
}
|
||||
|
||||
.trigger {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.swatch {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.label {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.empty {
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
@@ -1,83 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Search } from '@signozhq/icons';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
import LegendColorRow from './LegendColorRow';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
resolveSeriesColor,
|
||||
setSeriesColor,
|
||||
} from './legendColors.utils';
|
||||
|
||||
import styles from './LegendColors.module.scss';
|
||||
|
||||
interface LegendColorsProps {
|
||||
/** Panel's resolved series (from the shared preview query). */
|
||||
series: LegendSeries[];
|
||||
value: DashboardtypesLegendDTOCustomColors | undefined;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-series color overrides for the legend: a searchable, virtualized list of the
|
||||
* panel's resolved series, each with an antd ColorPicker swatch. Picking a color writes
|
||||
* `{ [seriesLabel]: hex }` into `legend.customColors` — the same label the chart keys its
|
||||
* color lookup on; Reset drops the override. Virtualized so panels with hundreds of
|
||||
* series stay responsive. Until the query produces series, shows a hint.
|
||||
*/
|
||||
function LegendColors({
|
||||
series,
|
||||
value,
|
||||
onChange,
|
||||
}: LegendColorsProps): JSX.Element {
|
||||
const [query, setQuery] = useState('');
|
||||
|
||||
if (series.length === 0) {
|
||||
return (
|
||||
<Typography.Text className={styles.empty}>
|
||||
Run the panel to customise series colors.
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const filtered = filterLegendSeries(series, query);
|
||||
|
||||
return (
|
||||
<div className={styles.container} data-testid="panel-editor-v2-legend-colors">
|
||||
<Input
|
||||
data-testid="panel-editor-v2-legend-search"
|
||||
placeholder="Search series…"
|
||||
value={query}
|
||||
prefix={<Search size={14} />}
|
||||
onChange={(e): void => setQuery(e.target.value)}
|
||||
/>
|
||||
{filtered.length === 0 ? (
|
||||
<Typography.Text className={styles.empty}>
|
||||
No series match “{query}”.
|
||||
</Typography.Text>
|
||||
) : (
|
||||
<Virtuoso
|
||||
className={styles.list}
|
||||
style={{ height: Math.min(filtered.length * 34, 240) }}
|
||||
data={filtered}
|
||||
itemContent={(_, s): JSX.Element => (
|
||||
<LegendColorRow
|
||||
label={s.label}
|
||||
color={resolveSeriesColor(value, s.label, s.defaultColor)}
|
||||
isOverridden={value?.[s.label] !== undefined}
|
||||
onChange={(hex): void => onChange(setSeriesColor(value, s.label, hex))}
|
||||
onReset={(): void => onChange(clearSeriesColor(value, s.label))}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegendColors;
|
||||
@@ -1,42 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import LegendColors from '../LegendColors';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
{ label: 'frontend', defaultColor: '#ff0000' },
|
||||
{ label: 'cartservice', defaultColor: '#00ff00' },
|
||||
];
|
||||
|
||||
describe('LegendColors', () => {
|
||||
it('shows a hint when there are no resolved series', () => {
|
||||
render(<LegendColors series={[]} value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-legend-colors'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByText(/run the panel/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the search box once series are present', () => {
|
||||
render(
|
||||
<LegendColors series={SERIES} value={undefined} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-legend-search'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows a no-match message when the search filters everything out', () => {
|
||||
render(
|
||||
<LegendColors series={SERIES} value={undefined} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-legend-search'), {
|
||||
target: { value: 'zzz' },
|
||||
});
|
||||
|
||||
expect(screen.getByText(/no series match/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,63 +0,0 @@
|
||||
import type { LegendSeries } from '../../../../hooks/useLegendSeries';
|
||||
import {
|
||||
clearSeriesColor,
|
||||
filterLegendSeries,
|
||||
resolveSeriesColor,
|
||||
setSeriesColor,
|
||||
} from '../legendColors.utils';
|
||||
|
||||
const SERIES: LegendSeries[] = [
|
||||
{ label: 'frontend', defaultColor: '#ff0000' },
|
||||
{ label: 'cartservice', defaultColor: '#00ff00' },
|
||||
{ label: 'frontendproxy', defaultColor: '#0000ff' },
|
||||
];
|
||||
|
||||
describe('legendColors.utils', () => {
|
||||
describe('filterLegendSeries', () => {
|
||||
it('returns all series for an empty/whitespace query', () => {
|
||||
expect(filterLegendSeries(SERIES, '')).toHaveLength(3);
|
||||
expect(filterLegendSeries(SERIES, ' ')).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('matches case-insensitive substrings', () => {
|
||||
expect(
|
||||
filterLegendSeries(SERIES, 'FRONT').map((s) => s.label),
|
||||
).toStrictEqual(['frontend', 'frontendproxy']);
|
||||
expect(filterLegendSeries(SERIES, 'cart')).toHaveLength(1);
|
||||
expect(filterLegendSeries(SERIES, 'zzz')).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveSeriesColor', () => {
|
||||
it('prefers the override, falling back to the default', () => {
|
||||
expect(resolveSeriesColor({ frontend: '#111' }, 'frontend', '#ff0000')).toBe(
|
||||
'#111',
|
||||
);
|
||||
expect(resolveSeriesColor(undefined, 'frontend', '#ff0000')).toBe('#ff0000');
|
||||
expect(resolveSeriesColor(null, 'frontend', '#ff0000')).toBe('#ff0000');
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSeriesColor', () => {
|
||||
it('adds/overwrites a label without mutating the input', () => {
|
||||
const value = { frontend: '#111' };
|
||||
const next = setSeriesColor(value, 'cartservice', '#222');
|
||||
expect(next).toStrictEqual({ frontend: '#111', cartservice: '#222' });
|
||||
expect(value).toStrictEqual({ frontend: '#111' });
|
||||
});
|
||||
|
||||
it('handles null/undefined base', () => {
|
||||
expect(setSeriesColor(undefined, 'a', '#1')).toStrictEqual({ a: '#1' });
|
||||
expect(setSeriesColor(null, 'a', '#1')).toStrictEqual({ a: '#1' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearSeriesColor', () => {
|
||||
it('removes a label without mutating the input', () => {
|
||||
const value = { frontend: '#111', cartservice: '#222' };
|
||||
const next = clearSeriesColor(value, 'frontend');
|
||||
expect(next).toStrictEqual({ cartservice: '#222' });
|
||||
expect(value).toStrictEqual({ frontend: '#111', cartservice: '#222' });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,43 +0,0 @@
|
||||
import type { DashboardtypesLegendDTOCustomColors } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
|
||||
/** Case-insensitive substring filter over series labels. Empty query → all series. */
|
||||
export function filterLegendSeries(
|
||||
series: LegendSeries[],
|
||||
query: string,
|
||||
): LegendSeries[] {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) {
|
||||
return series;
|
||||
}
|
||||
return series.filter((s) => s.label.toLowerCase().includes(q));
|
||||
}
|
||||
|
||||
/** The effective color for a series: the override if set, else its auto color. */
|
||||
export function resolveSeriesColor(
|
||||
value: DashboardtypesLegendDTOCustomColors | undefined,
|
||||
label: string,
|
||||
defaultColor: string,
|
||||
): string {
|
||||
return value?.[label] ?? defaultColor;
|
||||
}
|
||||
|
||||
/** Set an override for `label`, returning a new customColors record. */
|
||||
export function setSeriesColor(
|
||||
value: DashboardtypesLegendDTOCustomColors | undefined,
|
||||
label: string,
|
||||
hex: string,
|
||||
): Record<string, string> {
|
||||
return { ...value, [label]: hex };
|
||||
}
|
||||
|
||||
/** Drop the override for `label` (revert to the auto color), returning a new record. */
|
||||
export function clearSeriesColor(
|
||||
value: DashboardtypesLegendDTOCustomColors | undefined,
|
||||
label: string,
|
||||
): Record<string, string> {
|
||||
const next = { ...value };
|
||||
delete next[label];
|
||||
return next;
|
||||
}
|
||||
@@ -1,145 +0,0 @@
|
||||
/**
|
||||
* Small glyph icons for the panel-editor segmented/select controls, ported from the
|
||||
* Configure-panel design. They render at 14px and inherit `currentColor` so the
|
||||
* surrounding control can dim them when unselected and brighten them when active.
|
||||
*/
|
||||
export type SegmentIconName =
|
||||
| 'solid-line'
|
||||
| 'dashed-line'
|
||||
| 'fill-none'
|
||||
| 'fill-solid'
|
||||
| 'fill-gradient'
|
||||
| 'pos-bottom'
|
||||
| 'pos-right'
|
||||
| 'scale-linear'
|
||||
| 'scale-log'
|
||||
| 'interp-linear'
|
||||
| 'interp-spline'
|
||||
| 'interp-step-before'
|
||||
| 'interp-step-after';
|
||||
|
||||
function Svg({ children }: { children: React.ReactNode }): JSX.Element {
|
||||
return (
|
||||
<svg
|
||||
width={14}
|
||||
height={14}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.5}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
style={{ flex: 'none' }}
|
||||
aria-hidden
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const FILLED = { fill: 'currentColor', stroke: 'none' } as const;
|
||||
|
||||
export function SegmentIcon({
|
||||
name,
|
||||
}: {
|
||||
name: SegmentIconName;
|
||||
}): JSX.Element | null {
|
||||
switch (name) {
|
||||
case 'solid-line':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 8 H14" />
|
||||
</Svg>
|
||||
);
|
||||
case 'dashed-line':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 8 H4.5" />
|
||||
<path d="M6.75 8 H9.25" />
|
||||
<path d="M11.5 8 H14" />
|
||||
</Svg>
|
||||
);
|
||||
case 'fill-none':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 11 L6 6 L10 9 L14 5" />
|
||||
</Svg>
|
||||
);
|
||||
case 'fill-solid':
|
||||
return (
|
||||
<Svg>
|
||||
<path
|
||||
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
|
||||
fill="currentColor"
|
||||
fillOpacity={0.85}
|
||||
stroke="none"
|
||||
/>
|
||||
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
|
||||
</Svg>
|
||||
);
|
||||
case 'fill-gradient':
|
||||
return (
|
||||
<Svg>
|
||||
<path
|
||||
d="M2 10.5 L6 5.5 L10 8.5 L14 4.5 V13.5 H2 Z"
|
||||
fill="currentColor"
|
||||
fillOpacity={0.3}
|
||||
stroke="none"
|
||||
/>
|
||||
<path d="M2 10.5 L6 5.5 L10 8.5 L14 4.5" />
|
||||
</Svg>
|
||||
);
|
||||
case 'pos-bottom':
|
||||
return (
|
||||
<Svg>
|
||||
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
|
||||
<rect x={2} y={9} width={12} height={2.5} {...FILLED} />
|
||||
</Svg>
|
||||
);
|
||||
case 'pos-right':
|
||||
return (
|
||||
<Svg>
|
||||
<rect x={2} y={2.5} width={12} height={9} rx={1.2} />
|
||||
<rect x={10.5} y={2.5} width={3.5} height={9} {...FILLED} />
|
||||
</Svg>
|
||||
);
|
||||
case 'scale-linear':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2.5 13 L13.5 3" />
|
||||
</Svg>
|
||||
);
|
||||
case 'scale-log':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2.5 13 C5 13, 8 4.5, 13.5 3" />
|
||||
</Svg>
|
||||
);
|
||||
case 'interp-linear':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 12 L6 5 L10 9 L14 4" />
|
||||
</Svg>
|
||||
);
|
||||
case 'interp-spline':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 12 C5 3, 9 3, 14 8" />
|
||||
</Svg>
|
||||
);
|
||||
case 'interp-step-before':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 6 H6 V10 H10 V4.5 H14" />
|
||||
</Svg>
|
||||
);
|
||||
case 'interp-step-after':
|
||||
return (
|
||||
<Svg>
|
||||
<path d="M2 10 H6 V5 H10 V9.5 H14" />
|
||||
</Svg>
|
||||
);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -1,171 +0,0 @@
|
||||
import type { ComponentType } from 'react';
|
||||
import type {
|
||||
DashboardLinkDTO,
|
||||
DashboardtypesAxesDTO,
|
||||
DashboardtypesBarChartVisualizationDTO,
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
DashboardtypesLegendDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
DashboardtypesTimeSeriesChartAppearanceDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
AnyThreshold,
|
||||
PanelFormattingSlice,
|
||||
SectionEditorProps,
|
||||
SectionKind,
|
||||
SectionSpecMap,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import AxesSection from './sections/AxesSection/AxesSection';
|
||||
import BucketsSection from './sections/BucketsSection/BucketsSection';
|
||||
import ChartAppearanceSection from './sections/ChartAppearanceSection/ChartAppearanceSection';
|
||||
import ContextLinksSection from './sections/ContextLinksSection/ContextLinksSection';
|
||||
import FormattingSection from './sections/FormattingSection/FormattingSection';
|
||||
import LegendSection from './sections/LegendSection/LegendSection';
|
||||
import ThresholdsSection from './sections/ThresholdsSection/ThresholdsSection';
|
||||
import VisualizationSection from './sections/VisualizationSection/VisualizationSection';
|
||||
|
||||
type PanelSpec = DashboardtypesPanelSpecDTO;
|
||||
|
||||
/**
|
||||
* Pairs a section kind with its editor component and a typed lens into the panel spec.
|
||||
* The lens reads/writes over the WHOLE panel spec, so a section can target either the
|
||||
* plugin spec (`spec.plugin.spec.<key>`) or a panel-level field (e.g. `spec.links`).
|
||||
*/
|
||||
export interface SectionDescriptor<K extends SectionKind> {
|
||||
Component: ComponentType<SectionEditorProps<K>>;
|
||||
get: (spec: PanelSpec) => SectionSpecMap[K] | undefined;
|
||||
update: (spec: PanelSpec, value: SectionSpecMap[K]) => PanelSpec;
|
||||
}
|
||||
|
||||
// The plugin spec is a discriminated union over panel kinds; reading/writing a shared
|
||||
// slice (formatting, axes, …) by key is the one place the union must be narrowed. The
|
||||
// helper concentrates that cast so the registry entries stay declarative.
|
||||
type PluginSpecSlice = Partial<Record<string, unknown>>;
|
||||
|
||||
function getPluginSlice<T>(spec: PanelSpec, key: string): T | undefined {
|
||||
return (spec.plugin.spec as PluginSpecSlice)[key] as T | undefined;
|
||||
}
|
||||
|
||||
function updatePluginSlice(
|
||||
spec: PanelSpec,
|
||||
key: string,
|
||||
value: unknown,
|
||||
): PanelSpec {
|
||||
return {
|
||||
...spec,
|
||||
plugin: {
|
||||
...spec.plugin,
|
||||
spec: { ...(spec.plugin.spec as PluginSpecSlice), [key]: value },
|
||||
},
|
||||
} as PanelSpec;
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry of section editors. Partial by design: only sections with a built editor
|
||||
* appear here, so ConfigPane renders exactly those and silently skips the rest. Adding
|
||||
* a section editor = one entry here + one component file.
|
||||
*/
|
||||
export const SECTION_REGISTRY: {
|
||||
[K in SectionKind]?: SectionDescriptor<K>;
|
||||
} = {
|
||||
formatting: {
|
||||
Component: FormattingSection,
|
||||
get: (spec): PanelFormattingSlice | undefined =>
|
||||
getPluginSlice<PanelFormattingSlice>(spec, 'formatting'),
|
||||
update: (spec, formatting): PanelSpec =>
|
||||
updatePluginSlice(spec, 'formatting', formatting),
|
||||
},
|
||||
axes: {
|
||||
Component: AxesSection,
|
||||
get: (spec): DashboardtypesAxesDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesAxesDTO>(spec, 'axes'),
|
||||
update: (spec, axes): PanelSpec => updatePluginSlice(spec, 'axes', axes),
|
||||
},
|
||||
legend: {
|
||||
Component: LegendSection,
|
||||
get: (spec): DashboardtypesLegendDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesLegendDTO>(spec, 'legend'),
|
||||
update: (spec, legend): PanelSpec =>
|
||||
updatePluginSlice(spec, 'legend', legend),
|
||||
},
|
||||
chartAppearance: {
|
||||
Component: ChartAppearanceSection,
|
||||
get: (spec): DashboardtypesTimeSeriesChartAppearanceDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesTimeSeriesChartAppearanceDTO>(
|
||||
spec,
|
||||
'chartAppearance',
|
||||
),
|
||||
update: (spec, chartAppearance): PanelSpec =>
|
||||
updatePluginSlice(spec, 'chartAppearance', chartAppearance),
|
||||
},
|
||||
visualization: {
|
||||
Component: VisualizationSection,
|
||||
get: (spec): DashboardtypesBarChartVisualizationDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesBarChartVisualizationDTO>(
|
||||
spec,
|
||||
'visualization',
|
||||
),
|
||||
update: (spec, visualization): PanelSpec =>
|
||||
updatePluginSlice(spec, 'visualization', visualization),
|
||||
},
|
||||
buckets: {
|
||||
Component: BucketsSection,
|
||||
get: (spec): DashboardtypesHistogramBucketsDTO | undefined =>
|
||||
getPluginSlice<DashboardtypesHistogramBucketsDTO>(spec, 'histogramBuckets'),
|
||||
update: (spec, buckets): PanelSpec =>
|
||||
updatePluginSlice(spec, 'histogramBuckets', buckets),
|
||||
},
|
||||
contextLinks: {
|
||||
Component: ContextLinksSection,
|
||||
// Panel-level slice (spec.links), not under the plugin spec — no cast needed.
|
||||
get: (spec): DashboardLinkDTO[] | undefined => spec.links,
|
||||
update: (spec, links): PanelSpec => ({ ...spec, links }),
|
||||
},
|
||||
// One editor for every threshold variant (label / comparison / table); the kind's
|
||||
// `controls.variant` picks the row editor + element shape. All persist to the same
|
||||
// plugin.spec.thresholds key.
|
||||
thresholds: {
|
||||
Component: ThresholdsSection,
|
||||
get: (spec): AnyThreshold[] | undefined =>
|
||||
getPluginSlice<AnyThreshold[]>(spec, 'thresholds'),
|
||||
update: (spec, thresholds): PanelSpec =>
|
||||
updatePluginSlice(spec, 'thresholds', thresholds),
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* A section descriptor with the kind correlation erased. `SECTION_REGISTRY[kind]` and a
|
||||
* `SectionConfig` are both unions keyed by the same `kind`, but TS can't prove the lookup
|
||||
* and the config refer to the same member — the classic correlated-union limitation. The
|
||||
* resolver below narrows once here (the single localized cast), so render sites compose
|
||||
* `get` → `Component` → `update` without any further casts.
|
||||
*/
|
||||
export interface ErasedSectionDescriptor {
|
||||
Component: ComponentType<{
|
||||
value: unknown;
|
||||
controls?: unknown;
|
||||
onChange: (next: unknown) => void;
|
||||
// Forwarded to every editor; only sections that need the panel's resolved series
|
||||
// (legend colors) read it. Optional so editors can ignore it.
|
||||
legendSeries?: unknown;
|
||||
// The panel's formatting unit; read by editors that scope to it (thresholds).
|
||||
yAxisUnit?: unknown;
|
||||
// The Table panel's resolved value columns; read by the table-only editors
|
||||
// (column units, per-column thresholds) to offer real columns.
|
||||
tableColumns?: unknown;
|
||||
// The panel's telemetry signal; read by editors that fetch field-key
|
||||
// suggestions scoped to it (List column picker).
|
||||
signal?: unknown;
|
||||
}>;
|
||||
get: (spec: PanelSpec) => unknown;
|
||||
update: (spec: PanelSpec, value: unknown) => PanelSpec;
|
||||
}
|
||||
|
||||
export function resolveSectionEditor(
|
||||
kind: SectionKind,
|
||||
): ErasedSectionDescriptor | undefined {
|
||||
return SECTION_REGISTRY[kind] as unknown as
|
||||
| ErasedSectionDescriptor
|
||||
| undefined;
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
.bounds {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
|
||||
import styles from './AxesSection.module.scss';
|
||||
|
||||
type SoftBound = 'softMin' | 'softMax';
|
||||
|
||||
const SCALE_OPTIONS = [
|
||||
{ value: 'linear', label: 'Linear', icon: 'scale-linear' as const },
|
||||
{ value: 'log', label: 'Log', icon: 'scale-log' as const },
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `axes` slice of a panel spec: soft Y-axis min/max bounds and the
|
||||
* linear/logarithmic scale toggle. Each control is gated by its `controls` flag.
|
||||
*/
|
||||
function AxesSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'axes'>): JSX.Element {
|
||||
// An empty field clears the bound (null); otherwise parse to a number, ignoring
|
||||
// transient non-numeric input (e.g. a lone "-") by leaving the bound unset.
|
||||
const handleBound =
|
||||
(bound: SoftBound) =>
|
||||
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = e.target.value;
|
||||
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
|
||||
onChange({ ...value, [bound]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.minMax && (
|
||||
<div className={styles.bounds}>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Soft min</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-soft-min"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.softMin ?? ''}
|
||||
onChange={handleBound('softMin')}
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Soft max</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-soft-max"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.softMax ?? ''}
|
||||
onChange={handleBound('softMax')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.logScale && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Y-axis scale</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-log-scale"
|
||||
value={value?.isLogScale ? 'log' : 'linear'}
|
||||
items={SCALE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, isLogScale: next === 'log' })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default AxesSection;
|
||||
@@ -1,82 +0,0 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import AxesSection from '../AxesSection';
|
||||
|
||||
describe('AxesSection', () => {
|
||||
it('renders soft bounds and the log-scale switch when both controls are enabled', () => {
|
||||
render(
|
||||
<AxesSection
|
||||
value={undefined}
|
||||
controls={{ minMax: true, logScale: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-soft-min')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-soft-max')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-log-scale')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides the soft bounds when minMax is off', () => {
|
||||
render(
|
||||
<AxesSection
|
||||
value={undefined}
|
||||
controls={{ logScale: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-soft-min'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-log-scale')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes a numeric soft min through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<AxesSection
|
||||
value={undefined}
|
||||
controls={{ minMax: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByTestId('panel-editor-v2-soft-min'), '5');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ softMin: 5 });
|
||||
});
|
||||
|
||||
it('clears a soft bound to null when the field is emptied', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<AxesSection
|
||||
value={{ softMax: 100 }}
|
||||
controls={{ minMax: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.clear(screen.getByTestId('panel-editor-v2-soft-max'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ softMax: null });
|
||||
});
|
||||
|
||||
it('toggles the logarithmic scale through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<AxesSection
|
||||
value={{ isLogScale: false }}
|
||||
controls={{ logScale: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByText('Log'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ isLogScale: true });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,80 +0,0 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardtypesHistogramBucketsDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
|
||||
import styles from './BucketsSection.module.scss';
|
||||
|
||||
// The two numeric bounds of the histogram-buckets spec (derived from the BE DTO).
|
||||
type NumericBound = keyof Pick<
|
||||
DashboardtypesHistogramBucketsDTO,
|
||||
'bucketCount' | 'bucketWidth'
|
||||
>;
|
||||
|
||||
/**
|
||||
* Edits the `histogramBuckets` slice of a Histogram panel spec: bucket count / width
|
||||
* and whether to merge all active queries into one set of buckets. Each control is gated
|
||||
* by its `controls` flag.
|
||||
*/
|
||||
function BucketsSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'buckets'>): JSX.Element {
|
||||
// Empty clears the bound to null (chart auto-sizes); otherwise parse to a number,
|
||||
// ignoring transient non-numeric input by leaving it unset.
|
||||
const handleNumber =
|
||||
(bound: NumericBound) =>
|
||||
(e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = e.target.value;
|
||||
const next = raw === '' || Number.isNaN(Number(raw)) ? null : Number(raw);
|
||||
onChange({ ...value, [bound]: next });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{controls.count && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Bucket count</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-bucket-count"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.bucketCount ?? ''}
|
||||
onChange={handleNumber('bucketCount')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.width && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Bucket width</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-bucket-width"
|
||||
type="number"
|
||||
placeholder="Auto"
|
||||
value={value?.bucketWidth ?? ''}
|
||||
onChange={handleNumber('bucketWidth')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.mergeQueries && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-merge-queries"
|
||||
title="Merge active queries"
|
||||
description="Bucket all active queries together into one distribution"
|
||||
value={value?.mergeAllActiveQueries ?? false}
|
||||
onChange={(checked): void =>
|
||||
onChange({ ...value, mergeAllActiveQueries: checked })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default BucketsSection;
|
||||
@@ -1,41 +0,0 @@
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import BucketsSection from '../BucketsSection';
|
||||
|
||||
describe('BucketsSection', () => {
|
||||
it('renders only the controls whose flag is set', () => {
|
||||
render(
|
||||
<BucketsSection
|
||||
value={undefined}
|
||||
controls={{ count: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-bucket-count'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-bucket-width'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-merge-queries'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('toggles merge-active-queries through onChange', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<BucketsSection
|
||||
value={{ mergeAllActiveQueries: false }}
|
||||
controls={{ mergeQueries: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('panel-editor-v2-merge-queries'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ mergeAllActiveQueries: true });
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,164 +0,0 @@
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import {
|
||||
DashboardtypesFillModeDTO,
|
||||
DashboardtypesLineInterpolationDTO,
|
||||
DashboardtypesLineStyleDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
|
||||
import styles from './ChartAppearanceSection.module.scss';
|
||||
|
||||
const LINE_STYLE_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.solid,
|
||||
label: 'Solid',
|
||||
icon: 'solid-line' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineStyleDTO.dashed,
|
||||
label: 'Dashed',
|
||||
icon: 'dashed-line' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const LINE_INTERPOLATION_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.linear,
|
||||
label: 'Linear',
|
||||
icon: 'interp-linear' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.spline,
|
||||
label: 'Spline',
|
||||
icon: 'interp-spline' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_before,
|
||||
label: 'Step before',
|
||||
icon: 'interp-step-before' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLineInterpolationDTO.step_after,
|
||||
label: 'Step after',
|
||||
icon: 'interp-step-after' as const,
|
||||
},
|
||||
];
|
||||
|
||||
const FILL_MODE_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.none,
|
||||
label: 'None',
|
||||
icon: 'fill-none' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.solid,
|
||||
label: 'Solid',
|
||||
icon: 'fill-solid' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesFillModeDTO.gradient,
|
||||
label: 'Gradient',
|
||||
icon: 'fill-gradient' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `chartAppearance` slice of a TimeSeries panel spec: line style /
|
||||
* interpolation, fill mode, point markers, and the connect-null-gaps threshold. Each
|
||||
* control is gated by its `controls` flag.
|
||||
*/
|
||||
function ChartAppearanceSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'chartAppearance'>): JSX.Element {
|
||||
// `spanGaps.fillLessThan` is a stringified seconds threshold: empty means "connect
|
||||
// every gap" (the chart default), a number means "only bridge gaps shorter than this".
|
||||
const handleSpanGaps = (e: ChangeEvent<HTMLInputElement>): void => {
|
||||
const raw = e.target.value;
|
||||
onChange({
|
||||
...value,
|
||||
spanGaps: raw === '' ? undefined : { ...value?.spanGaps, fillLessThan: raw },
|
||||
});
|
||||
};
|
||||
return (
|
||||
<>
|
||||
{controls.lineStyle && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Line style</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-line-style"
|
||||
value={value?.lineStyle}
|
||||
items={LINE_STYLE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, lineStyle: next as DashboardtypesLineStyleDTO })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.lineInterpolation && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Line interpolation</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-line-interpolation"
|
||||
placeholder="Select interpolation…"
|
||||
value={value?.lineInterpolation}
|
||||
items={LINE_INTERPOLATION_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
lineInterpolation: next as DashboardtypesLineInterpolationDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.fillMode && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Fill mode</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-fill-mode"
|
||||
value={value?.fillMode}
|
||||
items={FILL_MODE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({ ...value, fillMode: next as DashboardtypesFillModeDTO })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.showPoints && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-show-points"
|
||||
title="Show points"
|
||||
description="Display individual data points on the chart"
|
||||
value={value?.showPoints ?? false}
|
||||
onChange={(checked): void => onChange({ ...value, showPoints: checked })}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.spanGaps && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Connect gaps shorter than (s)</Typography.Text>
|
||||
<Input
|
||||
data-testid="panel-editor-v2-span-gaps"
|
||||
type="number"
|
||||
placeholder="All gaps"
|
||||
value={value?.spanGaps?.fillLessThan ?? ''}
|
||||
onChange={handleSpanGaps}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default ChartAppearanceSection;
|
||||
@@ -1,140 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DashboardtypesLineStyleDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ChartAppearanceSection from '../ChartAppearanceSection';
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label. The
|
||||
// line-style and fill-mode controls are ConfigSegmented (buttons), so this helper is
|
||||
// only used for the line-interpolation ConfigSelect.
|
||||
async function pickOption(triggerTestId: string, label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId(triggerTestId);
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
await user.click(await screen.findByRole('option', { name: label }));
|
||||
}
|
||||
|
||||
const ALL_CONTROLS = {
|
||||
lineStyle: true,
|
||||
lineInterpolation: true,
|
||||
fillMode: true,
|
||||
showPoints: true,
|
||||
spanGaps: true,
|
||||
};
|
||||
|
||||
describe('ChartAppearanceSection', () => {
|
||||
it('renders every control that is enabled', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={ALL_CONTROLS}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-line-style')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-line-interpolation'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-fill-mode')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-show-points')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-span-gaps')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only the controls whose flag is set', () => {
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ lineStyle: true, fillMode: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-line-style')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-fill-mode')).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-line-interpolation'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-show-points'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen fill mode through the segmented control', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ lineStyle: DashboardtypesLineStyleDTO.solid }}
|
||||
controls={{ fillMode: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Gradient'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
lineStyle: 'solid',
|
||||
fillMode: 'gradient',
|
||||
});
|
||||
});
|
||||
|
||||
it('writes the chosen line interpolation through the dropdown', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ lineInterpolation: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await pickOption('panel-editor-v2-line-interpolation', 'Spline');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ lineInterpolation: 'spline' });
|
||||
});
|
||||
|
||||
it('toggles show points through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ChartAppearanceSection
|
||||
value={{ showPoints: false }}
|
||||
controls={{ showPoints: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-show-points'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ showPoints: true });
|
||||
});
|
||||
|
||||
it('writes a span-gaps threshold and clears it when emptied', () => {
|
||||
const onChange = jest.fn();
|
||||
const { rerender } = render(
|
||||
<ChartAppearanceSection
|
||||
value={undefined}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
|
||||
target: { value: '60' },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({
|
||||
spanGaps: { fillLessThan: '60' },
|
||||
});
|
||||
|
||||
rerender(
|
||||
<ChartAppearanceSection
|
||||
value={{ spanGaps: { fillLessThan: '60' } }}
|
||||
controls={{ spanGaps: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
fireEvent.change(screen.getByTestId('panel-editor-v2-span-gaps'), {
|
||||
target: { value: '' },
|
||||
});
|
||||
expect(onChange).toHaveBeenLastCalledWith({ spanGaps: undefined });
|
||||
});
|
||||
});
|
||||
@@ -1,32 +0,0 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.rowFooter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.newTab {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.newTabLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
@@ -1,94 +0,0 @@
|
||||
import { Plus, Trash2 } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { Switch } from '@signozhq/ui/switch';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import styles from './ContextLinksSection.module.scss';
|
||||
|
||||
/**
|
||||
* Edits the panel's context links (`spec.links`): a list of label + URL rows with an
|
||||
* "open in new tab" toggle, plus add/remove. Atomic section — no per-kind sub-controls.
|
||||
* URLs may reference dashboard/query variables; that interpolation is resolved at render
|
||||
* time, so this editor just captures the raw strings.
|
||||
*/
|
||||
function ContextLinksSection({
|
||||
value,
|
||||
onChange,
|
||||
}: SectionEditorProps<'contextLinks'>): JSX.Element {
|
||||
const links = value ?? [];
|
||||
|
||||
const updateAt = (index: number, patch: Partial<DashboardLinkDTO>): void =>
|
||||
onChange(
|
||||
links.map((link, i) => (i === index ? { ...link, ...patch } : link)),
|
||||
);
|
||||
|
||||
const addLink = (): void =>
|
||||
onChange([...links, { name: '', url: '', targetBlank: true }]);
|
||||
|
||||
const removeAt = (index: number): void =>
|
||||
onChange(links.filter((_, i) => i !== index));
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{links.map((link, index) => (
|
||||
// Links have no stable id on the wire; index is the row identity here.
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
<div className={styles.row} key={index}>
|
||||
<Input
|
||||
data-testid={`context-link-label-${index}`}
|
||||
placeholder="Label"
|
||||
value={link.name ?? ''}
|
||||
onChange={(e): void => updateAt(index, { name: e.target.value })}
|
||||
/>
|
||||
<Input
|
||||
data-testid={`context-link-url-${index}`}
|
||||
placeholder="https://… or /path?var=$variable"
|
||||
value={link.url ?? ''}
|
||||
onChange={(e): void => updateAt(index, { url: e.target.value })}
|
||||
/>
|
||||
<div className={styles.rowFooter}>
|
||||
<div className={styles.newTab}>
|
||||
<Switch
|
||||
testId={`context-link-newtab-${index}`}
|
||||
value={link.targetBlank ?? false}
|
||||
onChange={(checked: boolean): void =>
|
||||
updateAt(index, { targetBlank: checked })
|
||||
}
|
||||
/>
|
||||
<Typography.Text className={styles.newTabLabel}>
|
||||
Open in new tab
|
||||
</Typography.Text>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label={`Remove link ${index + 1}`}
|
||||
data-testid={`context-link-remove-${index}`}
|
||||
onClick={(): void => removeAt(index)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
data-testid="panel-editor-v2-add-link"
|
||||
onClick={addLink}
|
||||
>
|
||||
Add link
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ContextLinksSection;
|
||||
@@ -1,54 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardLinkDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ContextLinksSection from '../ContextLinksSection';
|
||||
|
||||
const LINKS: DashboardLinkDTO[] = [
|
||||
{ name: 'Docs', url: 'https://signoz.io', targetBlank: true },
|
||||
];
|
||||
|
||||
describe('ContextLinksSection', () => {
|
||||
it('renders only the add button when there are no links', () => {
|
||||
render(<ContextLinksSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-add-link')).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('context-link-label-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('appends a blank link (open-in-new-tab on) when Add link is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={[]} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-link'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ name: '', url: '', targetBlank: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('renders existing links and edits a label through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
|
||||
|
||||
expect(screen.getByTestId('context-link-label-0')).toHaveValue('Docs');
|
||||
expect(screen.getByTestId('context-link-url-0')).toHaveValue(
|
||||
'https://signoz.io',
|
||||
);
|
||||
|
||||
fireEvent.change(screen.getByTestId('context-link-label-0'), {
|
||||
target: { value: 'Runbook' },
|
||||
});
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ name: 'Runbook', url: 'https://signoz.io', targetBlank: true },
|
||||
]);
|
||||
});
|
||||
|
||||
it('removes a link through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ContextLinksSection value={LINKS} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('context-link-remove-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
@@ -1,65 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
|
||||
import type { TableColumnOption } from '../../../hooks/useTableColumns';
|
||||
|
||||
import styles from './FormattingSection.module.scss';
|
||||
|
||||
interface ColumnUnitsProps {
|
||||
/** Resolved value columns of the panel's current table result. */
|
||||
columns: TableColumnOption[];
|
||||
/** Current per-column unit map (`formatting.columnUnits`), keyed by column key. */
|
||||
value: Record<string, string>;
|
||||
onChange: (next: Record<string, string>) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-column unit picker for Table panels: one unit selector per resolved value
|
||||
* column, writing `{ [columnKey]: unitId }` keyed by the query identifier (V1
|
||||
* parity). Clearing a column's unit drops its entry. Until the panel produces
|
||||
* columns, shows a hint.
|
||||
*/
|
||||
function ColumnUnits({
|
||||
columns,
|
||||
value,
|
||||
onChange,
|
||||
}: ColumnUnitsProps): JSX.Element {
|
||||
if (columns.length === 0) {
|
||||
return (
|
||||
<Typography.Text className={styles.columnUnitsHint}>
|
||||
Run the panel to set per-column units.
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const setUnit = (columnKey: string, unit: string | undefined): void => {
|
||||
const next = { ...value };
|
||||
if (unit) {
|
||||
next[columnKey] = unit;
|
||||
} else {
|
||||
delete next[columnKey];
|
||||
}
|
||||
onChange(next);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.columnUnits}>
|
||||
{columns.map((column) => (
|
||||
<div className={styles.columnField} key={column.key}>
|
||||
<Typography.Text>{column.label}</Typography.Text>
|
||||
<YAxisUnitSelector
|
||||
data-testid={`panel-editor-v2-column-unit-${column.key}`}
|
||||
placeholder="Select unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
value={value[column.key]}
|
||||
containerClassName={styles.columnUnitSelector}
|
||||
onChange={(unit): void => setUnit(column.key, unit)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ColumnUnits;
|
||||
@@ -1,37 +0,0 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.unitSelector {
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
// Stacked per-column unit pickers; each column keeps the standard field layout.
|
||||
.columnUnits {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.columnUnitsHint {
|
||||
font-size: 12px;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.columnField {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 16px;
|
||||
}
|
||||
.columnUnitSelector {
|
||||
flex: 1;
|
||||
}
|
||||
@@ -1,89 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesPrecisionOptionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { TableColumnOption } from '../../../hooks/useTableColumns';
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ColumnUnits from './ColumnUnits';
|
||||
|
||||
import styles from './FormattingSection.module.scss';
|
||||
|
||||
type FormattingSectionProps = SectionEditorProps<'formatting'> & {
|
||||
/** Table panel's resolved value columns; required for the column-units editor. */
|
||||
tableColumns?: TableColumnOption[];
|
||||
};
|
||||
|
||||
// `full` means "show the raw value, no rounding"; the digits round to that many places.
|
||||
const DECIMAL_OPTIONS: {
|
||||
value: DashboardtypesPrecisionOptionDTO;
|
||||
label: string;
|
||||
}[] = [
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_0, label: '0 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_1, label: '1 decimal' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_2, label: '2 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_3, label: '3 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.NUMBER_4, label: '4 decimals' },
|
||||
{ value: DashboardtypesPrecisionOptionDTO.full, label: 'Full' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `formatting` slice of a panel spec (unit + decimal precision). Which
|
||||
* controls show is driven by the per-kind `controls` flags; the spec slice itself
|
||||
* is uniform across every kind that declares the Formatting section.
|
||||
*/
|
||||
function FormattingSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
tableColumns = [],
|
||||
}: FormattingSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.unit && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Unit</Typography.Text>
|
||||
<YAxisUnitSelector
|
||||
containerClassName={styles.unitSelector}
|
||||
data-testid="panel-editor-v2-unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
value={value?.unit}
|
||||
onChange={(unit): void => onChange({ ...value, unit })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.decimals && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Decimals</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-decimals"
|
||||
placeholder="Select decimals…"
|
||||
value={value?.decimalPrecision}
|
||||
items={DECIMAL_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
decimalPrecision: next as DashboardtypesPrecisionOptionDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.columnUnits && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Column units</Typography.Text>
|
||||
<ColumnUnits
|
||||
columns={tableColumns}
|
||||
value={value?.columnUnits ?? {}}
|
||||
onChange={(columnUnits): void => onChange({ ...value, columnUnits })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default FormattingSection;
|
||||
@@ -1,74 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
|
||||
import FormattingSection from '../FormattingSection';
|
||||
|
||||
// Open the Decimals select (clicking its antd selector) and pick the option with the
|
||||
// given visible label.
|
||||
async function pickDecimal(label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId('panel-editor-v2-decimals');
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
await user.click(await screen.findByRole('option', { name: label }));
|
||||
}
|
||||
|
||||
describe('FormattingSection', () => {
|
||||
it('renders Unit and Decimals when both controls are enabled', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={undefined}
|
||||
controls={{ unit: true, decimals: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('panel-editor-v2-unit')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-decimals')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('hides a control when its flag is off', () => {
|
||||
render(
|
||||
<FormattingSection
|
||||
value={undefined}
|
||||
controls={{ decimals: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.queryByTestId('panel-editor-v2-unit')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-decimals')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen decimal precision through onChange', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<FormattingSection
|
||||
value={undefined}
|
||||
controls={{ decimals: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await pickDecimal('Full');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ decimalPrecision: 'full' });
|
||||
});
|
||||
|
||||
it('merges the edit into the existing formatting slice', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<FormattingSection
|
||||
value={{ unit: 'bytes' }}
|
||||
controls={{ decimals: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await pickDecimal('2 decimals');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
unit: 'bytes',
|
||||
decimalPrecision: '2',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,5 +0,0 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSegmented from '../../controls/ConfigSegmented/ConfigSegmented';
|
||||
import LegendColors from '../../controls/LegendColors/LegendColors';
|
||||
import type { LegendSeries } from '../../../hooks/useLegendSeries';
|
||||
|
||||
import styles from './LegendSection.module.scss';
|
||||
|
||||
type LegendSectionProps = SectionEditorProps<'legend'> & {
|
||||
/** Panel's resolved series, forwarded by SectionSlot for the colors control. */
|
||||
legendSeries?: LegendSeries[];
|
||||
};
|
||||
|
||||
const POSITION_OPTIONS = [
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.bottom,
|
||||
label: 'Bottom',
|
||||
icon: 'pos-bottom' as const,
|
||||
},
|
||||
{
|
||||
value: DashboardtypesLegendPositionDTO.right,
|
||||
label: 'Right',
|
||||
icon: 'pos-right' as const,
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Edits the `legend` slice of a panel spec: legend position and per-series color
|
||||
* overrides. The colors control reads the panel's resolved series from context (the
|
||||
* shared preview query) and writes `customColors` keyed by series label.
|
||||
*/
|
||||
function LegendSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
legendSeries,
|
||||
}: LegendSectionProps): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.position && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Position</Typography.Text>
|
||||
<ConfigSegmented
|
||||
testId="panel-editor-v2-legend-position"
|
||||
items={POSITION_OPTIONS}
|
||||
value={value?.position}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
position: next as DashboardtypesLegendPositionDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.colors && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Series colors</Typography.Text>
|
||||
<LegendColors
|
||||
series={legendSeries ?? []}
|
||||
value={value?.customColors}
|
||||
onChange={(customColors): void => onChange({ ...value, customColors })}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default LegendSection;
|
||||
@@ -1,68 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import { DashboardtypesLegendPositionDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import LegendSection from '../LegendSection';
|
||||
|
||||
describe('LegendSection', () => {
|
||||
it('renders the position toggle with both options when position is enabled', () => {
|
||||
render(
|
||||
<LegendSection
|
||||
value={undefined}
|
||||
controls={{ position: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-legend-position'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByText('Bottom')).toBeInTheDocument();
|
||||
expect(screen.getByText('Right')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders nothing when position is not enabled', () => {
|
||||
render(
|
||||
<LegendSection value={undefined} controls={{}} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-legend-position'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen position through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<LegendSection
|
||||
value={{ position: undefined }}
|
||||
controls={{ position: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Right'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ position: 'right' });
|
||||
});
|
||||
|
||||
it('preserves other legend fields when changing position', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<LegendSection
|
||||
value={{
|
||||
position: DashboardtypesLegendPositionDTO.bottom,
|
||||
customColors: { a: '#fff' },
|
||||
}}
|
||||
controls={{ position: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByText('Right'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
position: 'right',
|
||||
customColors: { a: '#fff' },
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,50 +0,0 @@
|
||||
import { ChevronDown } from '@signozhq/icons';
|
||||
import { ColorPicker } from 'antd';
|
||||
|
||||
import styles from './ThresholdsSection.module.scss';
|
||||
|
||||
interface ThresholdColorSelectProps {
|
||||
value: string;
|
||||
testId?: string;
|
||||
onChange: (hex: string) => void;
|
||||
}
|
||||
|
||||
// Named presets from the SigNoz palette (cherry / amber / forest / robin). They surface
|
||||
// as quick swatches in the picker; the full picker below covers any custom color.
|
||||
const PRESETS: { label: string; value: string }[] = [
|
||||
{ label: 'Red', value: '#F1575F' },
|
||||
{ label: 'Orange', value: '#F5B225' },
|
||||
{ label: 'Green', value: '#2BB673' },
|
||||
{ label: 'Blue', value: '#4E74F8' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Threshold color control: an antd ColorPicker with the palette presets plus a full
|
||||
* custom picker, in a single popover (so moving from the trigger into the picker never
|
||||
* dismisses it). The trigger shows the current swatch and its preset name, or "Custom".
|
||||
*/
|
||||
function ThresholdColorSelect({
|
||||
value,
|
||||
testId,
|
||||
onChange,
|
||||
}: ThresholdColorSelectProps): JSX.Element {
|
||||
const current = PRESETS.find(
|
||||
(p) => p.value.toLowerCase() === value?.toLowerCase(),
|
||||
);
|
||||
|
||||
return (
|
||||
<ColorPicker
|
||||
value={value}
|
||||
onChangeComplete={(c): void => onChange(c.toHexString())}
|
||||
presets={[{ label: 'Defaults', colors: PRESETS.map((p) => p.value) }]}
|
||||
>
|
||||
<button type="button" className={styles.colorTrigger} data-testid={testId}>
|
||||
<span className={styles.dot} style={{ backgroundColor: value }} />
|
||||
<span className={styles.colorLabel}>{current?.label ?? 'Custom'}</span>
|
||||
<ChevronDown size={13} />
|
||||
</button>
|
||||
</ColorPicker>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThresholdColorSelect;
|
||||
@@ -1,104 +0,0 @@
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// ── View mode: compact summary row ──────────────────────────────────────────
|
||||
.viewRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
height: 40px;
|
||||
padding: 0 4px 0 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
background-color: var(--l2-background);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.viewValue {
|
||||
flex: none;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--text-vanilla-100);
|
||||
}
|
||||
|
||||
.viewLabel {
|
||||
overflow: hidden;
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.spacer {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
// ── Edit mode: labelled form ────────────────────────────────────────────────
|
||||
.editRow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
background-color: var(--l2-background);
|
||||
border: 1px solid var(--bg-robin-400);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.fieldLabel {
|
||||
font-size: 12px;
|
||||
color: var(--text-vanilla-400);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
// ── Shared ──────────────────────────────────────────────────────────────────
|
||||
.dot {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
flex: none;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.colorTrigger {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
height: 36px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--text-vanilla-100);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.colorLabel {
|
||||
flex: 1;
|
||||
font-size: 13px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
// Match Formatting: make the YAxisUnitSelector fill the row width.
|
||||
.unitSelector {
|
||||
:global(.ant-select) {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.invalidUnit {
|
||||
font-size: 11px;
|
||||
color: var(--bg-cherry-400);
|
||||
}
|
||||
@@ -1,181 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesComparisonThresholdDTO,
|
||||
type DashboardtypesTableThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
type DashboardtypesThresholdWithLabelDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type {
|
||||
AnyThreshold,
|
||||
ThresholdVariant,
|
||||
} from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import type { TableColumnOption } from '../../../hooks/useTableColumns';
|
||||
import ComparisonThresholdRow from './rows/ComparisonThresholdRow';
|
||||
import LabelThresholdRow from './rows/LabelThresholdRow';
|
||||
import TableThresholdRow from './rows/TableThresholdRow';
|
||||
|
||||
import styles from './ThresholdsSection.module.scss';
|
||||
|
||||
// New thresholds default to red (the first palette preset); the user recolors per rule.
|
||||
const DEFAULT_THRESHOLD_COLOR = '#F1575F';
|
||||
|
||||
// Add-button testId per variant — kept stable so existing E2E/unit selectors hold.
|
||||
const ADD_TESTID: Record<ThresholdVariant, string> = {
|
||||
label: 'panel-editor-v2-add-threshold',
|
||||
comparison: 'panel-editor-v2-add-comparison-threshold',
|
||||
table: 'panel-editor-v2-add-table-threshold',
|
||||
};
|
||||
|
||||
// Seed for a freshly-added row, in the shape the variant's editor + spec expect.
|
||||
function defaultThreshold(
|
||||
variant: ThresholdVariant,
|
||||
tableColumns: TableColumnOption[],
|
||||
): AnyThreshold {
|
||||
switch (variant) {
|
||||
case 'comparison':
|
||||
return {
|
||||
value: 0,
|
||||
color: DEFAULT_THRESHOLD_COLOR,
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
format: DashboardtypesThresholdFormatDTO.text,
|
||||
};
|
||||
case 'table':
|
||||
return {
|
||||
columnName: tableColumns[0]?.key ?? '',
|
||||
value: 0,
|
||||
color: DEFAULT_THRESHOLD_COLOR,
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
};
|
||||
default:
|
||||
return { value: 0, color: DEFAULT_THRESHOLD_COLOR, label: '' };
|
||||
}
|
||||
}
|
||||
|
||||
type ThresholdsSectionProps = {
|
||||
value: AnyThreshold[] | undefined;
|
||||
/** `variant` picks the row editor + element shape; defaults to `label`. */
|
||||
controls?: { variant?: ThresholdVariant };
|
||||
onChange: (next: AnyThreshold[]) => void;
|
||||
/** Panel formatting unit; scopes each row's unit picker to its category (V1 parity). */
|
||||
yAxisUnit?: string;
|
||||
/** Table panel's resolved value columns (table variant only). */
|
||||
tableColumns?: TableColumnOption[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Edits the `thresholds` slice for every panel kind. All variants share the same
|
||||
* list mechanics (one row edits at a time; a freshly-added row opens in edit mode and
|
||||
* is removed if discarded before saving) and differ only in the row editor, picked by
|
||||
* `controls.variant`: `label` (TimeSeries/Bar), `comparison` (Number), `table` (Table).
|
||||
*/
|
||||
function ThresholdsSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
yAxisUnit,
|
||||
tableColumns = [],
|
||||
}: ThresholdsSectionProps): JSX.Element {
|
||||
const variant = controls?.variant ?? 'label';
|
||||
const thresholds = value ?? [];
|
||||
// Which row is being edited, and whether it was just added (so Discard removes it).
|
||||
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
||||
const [unsavedIndex, setUnsavedIndex] = useState<number | null>(null);
|
||||
|
||||
const addThreshold = (): void => {
|
||||
const nextIndex = thresholds.length;
|
||||
onChange([...thresholds, defaultThreshold(variant, tableColumns)]);
|
||||
setEditingIndex(nextIndex);
|
||||
setUnsavedIndex(nextIndex);
|
||||
};
|
||||
|
||||
const saveAt =
|
||||
(index: number) =>
|
||||
(next: AnyThreshold): void => {
|
||||
onChange(thresholds.map((t, i) => (i === index ? next : t)));
|
||||
setEditingIndex(null);
|
||||
setUnsavedIndex(null);
|
||||
};
|
||||
|
||||
const removeAt = (index: number): void => {
|
||||
onChange(thresholds.filter((_, i) => i !== index));
|
||||
setEditingIndex(null);
|
||||
setUnsavedIndex(null);
|
||||
};
|
||||
|
||||
const discardAt = (index: number) => (): void => {
|
||||
// Discarding a row that was never saved removes it; otherwise just exit edit.
|
||||
if (index === unsavedIndex) {
|
||||
removeAt(index);
|
||||
return;
|
||||
}
|
||||
setEditingIndex(null);
|
||||
};
|
||||
|
||||
const renderRow = (threshold: AnyThreshold, index: number): JSX.Element => {
|
||||
// Shared row controls; the threshold value is narrowed per variant at this
|
||||
// branch boundary — the slice only ever holds the active variant's shape.
|
||||
const common = {
|
||||
index,
|
||||
yAxisUnit,
|
||||
isEditing: editingIndex === index,
|
||||
onEdit: (): void => setEditingIndex(index),
|
||||
onSave: saveAt(index),
|
||||
onDiscard: discardAt(index),
|
||||
onRemove: (): void => removeAt(index),
|
||||
};
|
||||
|
||||
if (variant === 'comparison') {
|
||||
return (
|
||||
<ComparisonThresholdRow
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
threshold={threshold as DashboardtypesComparisonThresholdDTO}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (variant === 'table') {
|
||||
return (
|
||||
<TableThresholdRow
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
threshold={threshold as DashboardtypesTableThresholdDTO}
|
||||
tableColumns={tableColumns}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<LabelThresholdRow
|
||||
// eslint-disable-next-line react/no-array-index-key
|
||||
key={index}
|
||||
threshold={threshold as DashboardtypesThresholdWithLabelDTO}
|
||||
{...common}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.list}>
|
||||
{thresholds.map(renderRow)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="dashed"
|
||||
color="secondary"
|
||||
prefix={<Plus size={14} />}
|
||||
data-testid={ADD_TESTID[variant]}
|
||||
onClick={addThreshold}
|
||||
>
|
||||
Add threshold
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThresholdsSection;
|
||||
@@ -1,202 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesComparisonThresholdDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
import { render, screen, userEvent } from 'tests/test-utils';
|
||||
|
||||
import UnifiedThresholdsSection from '../ThresholdsSection';
|
||||
|
||||
// The comparison editor is the unified ThresholdsSection in its `comparison` variant;
|
||||
// this wrapper pins the variant so the suite reads as the comparison editor's spec.
|
||||
function ComparisonThresholdsSection(props: {
|
||||
value: DashboardtypesComparisonThresholdDTO[] | undefined;
|
||||
onChange: (next: DashboardtypesComparisonThresholdDTO[]) => void;
|
||||
yAxisUnit?: string;
|
||||
}): JSX.Element {
|
||||
return (
|
||||
<UnifiedThresholdsSection
|
||||
value={props.value}
|
||||
onChange={props.onChange as (next: AnyThreshold[]) => void}
|
||||
yAxisUnit={props.yAxisUnit}
|
||||
controls={{ variant: 'comparison' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const THRESHOLDS: DashboardtypesComparisonThresholdDTO[] = [
|
||||
{
|
||||
value: 80,
|
||||
color: '#F5B225',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
unit: 'percent',
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
];
|
||||
|
||||
// Stateful harness for flows that depend on the value updating (add/discard).
|
||||
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
|
||||
const [value, setValue] = useState<DashboardtypesComparisonThresholdDTO[]>([]);
|
||||
return (
|
||||
<ComparisonThresholdsSection
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
yAxisUnit={yAxisUnit}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
describe('ComparisonThresholdsSection', () => {
|
||||
it('renders only the add button when there are no thresholds', () => {
|
||||
render(
|
||||
<ComparisonThresholdsSection value={undefined} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-add-comparison-threshold'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('comparison-threshold-edit-0'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an existing threshold in view mode (no form until Edit)', () => {
|
||||
render(
|
||||
<ComparisonThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />,
|
||||
);
|
||||
|
||||
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
|
||||
// Operator symbol + value render in the summary.
|
||||
expect(screen.getByText(/> 80/)).toBeInTheDocument();
|
||||
// The editable fields are hidden until the row is edited.
|
||||
expect(
|
||||
screen.queryByTestId('comparison-threshold-value-0'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('formats the view-mode value through its unit (e.g. currency symbol)', () => {
|
||||
render(
|
||||
<ComparisonThresholdsSection
|
||||
value={[
|
||||
{
|
||||
value: 3100,
|
||||
color: '#F5B225',
|
||||
operator: DashboardtypesComparisonOperatorDTO.below,
|
||||
unit: 'currencyUSD',
|
||||
},
|
||||
]}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
const row = screen.getByTestId('comparison-threshold-edit-0').closest('div');
|
||||
// Unit-aware: shows the currency symbol, never the raw unit id.
|
||||
expect(row).toHaveTextContent('$');
|
||||
expect(row).not.toHaveTextContent('currencyUSD');
|
||||
});
|
||||
|
||||
it('edits a threshold value and commits it on Save', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
|
||||
const valueInput = screen.getByTestId('comparison-threshold-value-0');
|
||||
expect(valueInput).toHaveValue(80);
|
||||
|
||||
await user.clear(valueInput);
|
||||
await user.type(valueInput, '90');
|
||||
await user.click(screen.getByTestId('comparison-threshold-save-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{
|
||||
value: 90,
|
||||
color: '#F5B225',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
unit: 'percent',
|
||||
format: DashboardtypesThresholdFormatDTO.background,
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not commit edits when Discard is clicked', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
|
||||
await user.clear(screen.getByTestId('comparison-threshold-value-0'));
|
||||
await user.type(screen.getByTestId('comparison-threshold-value-0'), '90');
|
||||
await user.click(screen.getByTestId('comparison-threshold-discard-0'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
// Back to view mode.
|
||||
expect(
|
||||
screen.queryByTestId('comparison-threshold-value-0'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('comparison-threshold-edit-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes a threshold from view mode', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<ComparisonThresholdsSection value={THRESHOLDS} onChange={onChange} />,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('comparison-threshold-remove-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('adds a threshold that opens in edit mode, and discards it away', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<Harness />);
|
||||
|
||||
await user.click(
|
||||
screen.getByTestId('panel-editor-v2-add-comparison-threshold'),
|
||||
);
|
||||
// New row opens in edit mode.
|
||||
expect(
|
||||
screen.getByTestId('comparison-threshold-value-0'),
|
||||
).toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByTestId('comparison-threshold-discard-0'));
|
||||
// Discarding a never-saved row removes it entirely.
|
||||
expect(
|
||||
screen.queryByTestId('comparison-threshold-value-0'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('comparison-threshold-edit-0'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('flags a threshold unit in a different category than the y-axis unit', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(
|
||||
<ComparisonThresholdsSection
|
||||
value={[
|
||||
{
|
||||
value: 80,
|
||||
color: '#F5B225',
|
||||
operator: DashboardtypesComparisonOperatorDTO.above,
|
||||
unit: 'ms',
|
||||
},
|
||||
]}
|
||||
yAxisUnit="bytes"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('comparison-threshold-edit-0'));
|
||||
expect(
|
||||
screen.getByTestId('comparison-threshold-unit-invalid-0'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { AnyThreshold } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ThresholdsSection from '../ThresholdsSection';
|
||||
|
||||
const THRESHOLDS: DashboardtypesThresholdWithLabelDTO[] = [
|
||||
{ value: 80, color: '#F5B225', label: 'High', unit: 'percent' },
|
||||
];
|
||||
|
||||
// Stateful harness for flows that depend on the value updating (add/discard). No
|
||||
// `controls` is passed, exercising the default `label` variant.
|
||||
function Harness({ yAxisUnit }: { yAxisUnit?: string }): JSX.Element {
|
||||
const [value, setValue] = useState<AnyThreshold[]>([]);
|
||||
return (
|
||||
<ThresholdsSection value={value} onChange={setValue} yAxisUnit={yAxisUnit} />
|
||||
);
|
||||
}
|
||||
|
||||
describe('ThresholdsSection', () => {
|
||||
it('renders only the add button when there are no thresholds', () => {
|
||||
render(<ThresholdsSection value={undefined} onChange={jest.fn()} />);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-add-threshold'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows an existing threshold in view mode (no form until Edit)', () => {
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={jest.fn()} />);
|
||||
|
||||
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
|
||||
expect(screen.getByText('High')).toBeInTheDocument();
|
||||
// The editable fields are hidden until the row is edited.
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('edits a threshold value and commits it on Save', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
expect(screen.getByTestId('threshold-value-0')).toHaveValue(80);
|
||||
|
||||
fireEvent.change(screen.getByTestId('threshold-value-0'), {
|
||||
target: { value: '90' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('threshold-save-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([
|
||||
{ value: 90, color: '#F5B225', label: 'High', unit: 'percent' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not commit edits when Discard is clicked', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
fireEvent.change(screen.getByTestId('threshold-value-0'), {
|
||||
target: { value: '90' },
|
||||
});
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
// Back to view mode.
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
expect(screen.getByTestId('threshold-edit-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('removes a threshold from view mode', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ThresholdsSection value={THRESHOLDS} onChange={onChange} />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-remove-0'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it('adds a threshold that opens in edit mode, and discards it away', () => {
|
||||
render(<Harness />);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-add-threshold'));
|
||||
// New row opens in edit mode.
|
||||
expect(screen.getByTestId('threshold-value-0')).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-discard-0'));
|
||||
// Discarding a never-saved row removes it entirely.
|
||||
expect(screen.queryByTestId('threshold-value-0')).not.toBeInTheDocument();
|
||||
expect(screen.queryByTestId('threshold-edit-0')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('flags a threshold unit in a different category than the y-axis unit', () => {
|
||||
render(
|
||||
<ThresholdsSection
|
||||
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
|
||||
yAxisUnit="bytes"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
expect(screen.getByTestId('threshold-unit-invalid-0')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not flag a threshold unit in the same category as the y-axis unit', () => {
|
||||
render(
|
||||
<ThresholdsSection
|
||||
value={[{ value: 80, color: '#F5B225', label: '', unit: 'ms' }]}
|
||||
yAxisUnit="s"
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('threshold-edit-0'));
|
||||
expect(
|
||||
screen.queryByTestId('threshold-unit-invalid-0'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -1,117 +0,0 @@
|
||||
import {
|
||||
type DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesComparisonThresholdDTO,
|
||||
type DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
|
||||
|
||||
import {
|
||||
FORMAT_OPTIONS,
|
||||
OPERATOR_OPTIONS,
|
||||
OPERATOR_SYMBOL,
|
||||
} from '../thresholdOptions';
|
||||
import ThresholdColorField from './shared/ThresholdColorField';
|
||||
import ThresholdRowShell from './shared/ThresholdRowShell';
|
||||
import ThresholdSelectField from './shared/ThresholdSelectField';
|
||||
import ThresholdUnitField from './shared/ThresholdUnitField';
|
||||
import { useThresholdDraft } from './shared/useThresholdDraft';
|
||||
import ThresholdValueField from './shared/ThresholdValueField';
|
||||
|
||||
import styles from '../ThresholdsSection.module.scss';
|
||||
|
||||
interface ComparisonThresholdRowProps {
|
||||
index: number;
|
||||
threshold: DashboardtypesComparisonThresholdDTO;
|
||||
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
|
||||
yAxisUnit?: string;
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onSave: (next: DashboardtypesComparisonThresholdDTO) => void;
|
||||
onDiscard: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Comparison threshold (Number): value crosses an operator → recolor. Edit form is
|
||||
* condition (operator), value, unit, color, display format.
|
||||
*/
|
||||
function ComparisonThresholdRow({
|
||||
index,
|
||||
threshold,
|
||||
yAxisUnit,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onSave,
|
||||
onDiscard,
|
||||
onRemove,
|
||||
}: ComparisonThresholdRowProps): JSX.Element {
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
|
||||
|
||||
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
|
||||
const summary = (
|
||||
<span className={styles.viewValue}>
|
||||
{symbol} {formatPanelValue(threshold.value, threshold.unit)}
|
||||
</span>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThresholdRowShell
|
||||
index={index}
|
||||
testIdPrefix="comparison-threshold"
|
||||
color={threshold.color}
|
||||
isEditing={isEditing}
|
||||
summary={summary}
|
||||
onEdit={onEdit}
|
||||
onSave={(): void => onSave(draft)}
|
||||
onDiscard={onDiscard}
|
||||
onRemove={onRemove}
|
||||
>
|
||||
<ThresholdSelectField
|
||||
label="If value is"
|
||||
testId={`comparison-threshold-operator-${index}`}
|
||||
placeholder="Select condition"
|
||||
value={draft.operator}
|
||||
items={OPERATOR_OPTIONS}
|
||||
onChange={(operator): void =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
operator: operator as DashboardtypesComparisonOperatorDTO,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<ThresholdValueField
|
||||
testId={`comparison-threshold-value-${index}`}
|
||||
value={draft.value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<ThresholdUnitField
|
||||
testId={`comparison-threshold-unit-${index}`}
|
||||
invalidTestId={`comparison-threshold-unit-invalid-${index}`}
|
||||
value={draft.unit}
|
||||
scopeUnit={yAxisUnit}
|
||||
scopeLabel="y-axis unit"
|
||||
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
|
||||
/>
|
||||
<ThresholdColorField
|
||||
testId={`comparison-threshold-color-${index}`}
|
||||
value={draft.color}
|
||||
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
|
||||
/>
|
||||
<ThresholdSelectField
|
||||
label="Display"
|
||||
testId={`comparison-threshold-format-${index}`}
|
||||
placeholder="Select display"
|
||||
value={draft.format}
|
||||
items={FORMAT_OPTIONS}
|
||||
onChange={(format): void =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
format: format as DashboardtypesThresholdFormatDTO,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ThresholdRowShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default ComparisonThresholdRow;
|
||||
@@ -1,96 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
import type { DashboardtypesThresholdWithLabelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
|
||||
|
||||
import ThresholdColorField from './shared/ThresholdColorField';
|
||||
import ThresholdRowShell from './shared/ThresholdRowShell';
|
||||
import ThresholdUnitField from './shared/ThresholdUnitField';
|
||||
import { useThresholdDraft } from './shared/useThresholdDraft';
|
||||
import ThresholdValueField from './shared/ThresholdValueField';
|
||||
|
||||
import styles from '../ThresholdsSection.module.scss';
|
||||
|
||||
interface LabelThresholdRowProps {
|
||||
index: number;
|
||||
threshold: DashboardtypesThresholdWithLabelDTO;
|
||||
/** Panel formatting unit — scopes the unit picker to its category (V1 parity). */
|
||||
yAxisUnit?: string;
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onSave: (next: DashboardtypesThresholdWithLabelDTO) => void;
|
||||
onDiscard: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Value + color + label threshold (TimeSeries / Bar): a line drawn on the chart. Edit
|
||||
* form is color, value, unit, label.
|
||||
*/
|
||||
function LabelThresholdRow({
|
||||
index,
|
||||
threshold,
|
||||
yAxisUnit,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onSave,
|
||||
onDiscard,
|
||||
onRemove,
|
||||
}: LabelThresholdRowProps): JSX.Element {
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
|
||||
|
||||
const summary = (
|
||||
<>
|
||||
<span className={styles.viewValue}>
|
||||
{formatPanelValue(threshold.value, threshold.unit)}
|
||||
</span>
|
||||
{threshold.label && (
|
||||
<span className={styles.viewLabel}>{threshold.label}</span>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThresholdRowShell
|
||||
index={index}
|
||||
testIdPrefix="threshold"
|
||||
color={threshold.color}
|
||||
isEditing={isEditing}
|
||||
summary={summary}
|
||||
onEdit={onEdit}
|
||||
onSave={(): void => onSave(draft)}
|
||||
onDiscard={onDiscard}
|
||||
onRemove={onRemove}
|
||||
>
|
||||
<ThresholdColorField
|
||||
testId={`threshold-color-${index}`}
|
||||
value={draft.color}
|
||||
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
|
||||
/>
|
||||
<ThresholdValueField
|
||||
testId={`threshold-value-${index}`}
|
||||
value={draft.value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<ThresholdUnitField
|
||||
testId={`threshold-unit-${index}`}
|
||||
invalidTestId={`threshold-unit-invalid-${index}`}
|
||||
value={draft.unit}
|
||||
scopeUnit={yAxisUnit}
|
||||
scopeLabel="y-axis unit"
|
||||
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
|
||||
/>
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.fieldLabel}>Label</Typography.Text>
|
||||
<Input
|
||||
data-testid={`threshold-label-${index}`}
|
||||
placeholder="Optional"
|
||||
value={draft.label ?? ''}
|
||||
onChange={(e): void => setDraft((d) => ({ ...d, label: e.target.value }))}
|
||||
/>
|
||||
</div>
|
||||
</ThresholdRowShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default LabelThresholdRow;
|
||||
@@ -1,141 +0,0 @@
|
||||
import {
|
||||
type DashboardtypesComparisonOperatorDTO,
|
||||
type DashboardtypesTableThresholdDTO,
|
||||
type DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { formatPanelValue } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/formatPanelValue';
|
||||
|
||||
import type { TableColumnOption } from '../../../../hooks/useTableColumns';
|
||||
import {
|
||||
FORMAT_OPTIONS,
|
||||
OPERATOR_OPTIONS,
|
||||
OPERATOR_SYMBOL,
|
||||
} from '../thresholdOptions';
|
||||
import ThresholdColorField from './shared/ThresholdColorField';
|
||||
import ThresholdRowShell from './shared/ThresholdRowShell';
|
||||
import ThresholdSelectField from './shared/ThresholdSelectField';
|
||||
import ThresholdUnitField from './shared/ThresholdUnitField';
|
||||
import { useThresholdDraft } from './shared/useThresholdDraft';
|
||||
import ThresholdValueField from './shared/ThresholdValueField';
|
||||
|
||||
import styles from '../ThresholdsSection.module.scss';
|
||||
|
||||
interface TableThresholdRowProps {
|
||||
index: number;
|
||||
threshold: DashboardtypesTableThresholdDTO;
|
||||
/** Resolved value columns (with their configured units); the rule targets one. */
|
||||
tableColumns: TableColumnOption[];
|
||||
isEditing: boolean;
|
||||
onEdit: () => void;
|
||||
onSave: (next: DashboardtypesTableThresholdDTO) => void;
|
||||
onDiscard: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-column comparison threshold (Table): value in a column crosses an operator →
|
||||
* recolor that column's cells. Edit form is column, condition (operator), value, unit,
|
||||
* color, display format. The unit picker scopes to the selected column's unit (Table
|
||||
* panels have no single panel-wide unit — V1 parity).
|
||||
*/
|
||||
function TableThresholdRow({
|
||||
index,
|
||||
threshold,
|
||||
tableColumns,
|
||||
isEditing,
|
||||
onEdit,
|
||||
onSave,
|
||||
onDiscard,
|
||||
onRemove,
|
||||
}: TableThresholdRowProps): JSX.Element {
|
||||
const { draft, setDraft, setValue } = useThresholdDraft(threshold, isEditing);
|
||||
|
||||
// Stored columnName is the query key; resolve its label + configured unit.
|
||||
const columnUnit = tableColumns.find((c) => c.key === draft.columnName)?.unit;
|
||||
const columnLabel =
|
||||
tableColumns.find((c) => c.key === threshold.columnName)?.label ??
|
||||
threshold.columnName;
|
||||
const columnItems = tableColumns.map((column) => ({
|
||||
value: column.key,
|
||||
label: column.label,
|
||||
}));
|
||||
|
||||
const symbol = threshold.operator ? OPERATOR_SYMBOL[threshold.operator] : '';
|
||||
const summary = (
|
||||
<>
|
||||
<span className={styles.viewLabel}>{columnLabel}</span>
|
||||
<span className={styles.viewValue}>
|
||||
{symbol} {formatPanelValue(threshold.value, threshold.unit)}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<ThresholdRowShell
|
||||
index={index}
|
||||
testIdPrefix="table-threshold"
|
||||
color={threshold.color}
|
||||
isEditing={isEditing}
|
||||
summary={summary}
|
||||
onEdit={onEdit}
|
||||
onSave={(): void => onSave(draft)}
|
||||
onDiscard={onDiscard}
|
||||
onRemove={onRemove}
|
||||
>
|
||||
<ThresholdSelectField
|
||||
label="Column"
|
||||
testId={`table-threshold-column-${index}`}
|
||||
placeholder="Select column"
|
||||
value={draft.columnName || undefined}
|
||||
items={columnItems}
|
||||
onChange={(columnName): void => setDraft((d) => ({ ...d, columnName }))}
|
||||
/>
|
||||
<ThresholdSelectField
|
||||
label="If value is"
|
||||
testId={`table-threshold-operator-${index}`}
|
||||
placeholder="Select condition"
|
||||
value={draft.operator}
|
||||
items={OPERATOR_OPTIONS}
|
||||
onChange={(operator): void =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
operator: operator as DashboardtypesComparisonOperatorDTO,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<ThresholdValueField
|
||||
testId={`table-threshold-value-${index}`}
|
||||
value={draft.value}
|
||||
onChange={setValue}
|
||||
/>
|
||||
<ThresholdUnitField
|
||||
testId={`table-threshold-unit-${index}`}
|
||||
invalidTestId={`table-threshold-unit-invalid-${index}`}
|
||||
value={draft.unit}
|
||||
scopeUnit={columnUnit}
|
||||
scopeLabel="column unit"
|
||||
onChange={(unit): void => setDraft((d) => ({ ...d, unit }))}
|
||||
/>
|
||||
<ThresholdColorField
|
||||
testId={`table-threshold-color-${index}`}
|
||||
value={draft.color}
|
||||
onChange={(color): void => setDraft((d) => ({ ...d, color }))}
|
||||
/>
|
||||
<ThresholdSelectField
|
||||
label="Display"
|
||||
testId={`table-threshold-format-${index}`}
|
||||
placeholder="Select display"
|
||||
value={draft.format}
|
||||
items={FORMAT_OPTIONS}
|
||||
onChange={(format): void =>
|
||||
setDraft((d) => ({
|
||||
...d,
|
||||
format: format as DashboardtypesThresholdFormatDTO,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</ThresholdRowShell>
|
||||
);
|
||||
}
|
||||
|
||||
export default TableThresholdRow;
|
||||
@@ -1,27 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ThresholdColorSelect from '../../ThresholdColorSelect';
|
||||
|
||||
import styles from '../../ThresholdsSection.module.scss';
|
||||
|
||||
interface ThresholdColorFieldProps {
|
||||
testId: string;
|
||||
value: string;
|
||||
onChange: (hex: string) => void;
|
||||
}
|
||||
|
||||
/** Labelled color picker, shared by every threshold variant. */
|
||||
function ThresholdColorField({
|
||||
testId,
|
||||
value,
|
||||
onChange,
|
||||
}: ThresholdColorFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.fieldLabel}>Color</Typography.Text>
|
||||
<ThresholdColorSelect value={value} testId={testId} onChange={onChange} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThresholdColorField;
|
||||
@@ -1,103 +0,0 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { Check, Pencil, Trash2, X } from '@signozhq/icons';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
|
||||
import styles from '../../ThresholdsSection.module.scss';
|
||||
|
||||
interface ThresholdRowShellProps {
|
||||
index: number;
|
||||
/** testId prefix per variant: `threshold` | `comparison-threshold` | `table-threshold`. */
|
||||
testIdPrefix: string;
|
||||
/** Swatch color shown in view mode. */
|
||||
color: string;
|
||||
isEditing: boolean;
|
||||
/** Compact view-mode summary, rendered between the color dot and the actions. */
|
||||
summary: ReactNode;
|
||||
/** Edit-mode fields. */
|
||||
children: ReactNode;
|
||||
onEdit: () => void;
|
||||
onSave: () => void;
|
||||
onDiscard: () => void;
|
||||
onRemove: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shared chrome for a threshold row's V1-style view/edit modes: the view summary with
|
||||
* Edit/Delete, and the edit form's Discard/Save actions. Each variant supplies its own
|
||||
* `summary` and field `children`; everything else (layout, buttons, testIds) is shared.
|
||||
*/
|
||||
function ThresholdRowShell({
|
||||
index,
|
||||
testIdPrefix,
|
||||
color,
|
||||
isEditing,
|
||||
summary,
|
||||
children,
|
||||
onEdit,
|
||||
onSave,
|
||||
onDiscard,
|
||||
onRemove,
|
||||
}: ThresholdRowShellProps): JSX.Element {
|
||||
if (!isEditing) {
|
||||
return (
|
||||
<div className={styles.viewRow}>
|
||||
<span className={styles.dot} style={{ backgroundColor: color }} />
|
||||
{summary}
|
||||
<div className={styles.spacer} />
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
aria-label={`Edit threshold ${index + 1}`}
|
||||
data-testid={`${testIdPrefix}-edit-${index}`}
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="destructive"
|
||||
size="icon"
|
||||
aria-label={`Remove threshold ${index + 1}`}
|
||||
data-testid={`${testIdPrefix}-remove-${index}`}
|
||||
onClick={onRemove}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.editRow}>
|
||||
{children}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
prefix={<X size={14} />}
|
||||
data-testid={`${testIdPrefix}-discard-${index}`}
|
||||
onClick={onDiscard}
|
||||
>
|
||||
Discard
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="solid"
|
||||
color="primary"
|
||||
prefix={<Check size={14} />}
|
||||
data-testid={`${testIdPrefix}-save-${index}`}
|
||||
onClick={onSave}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThresholdRowShell;
|
||||
@@ -1,44 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
|
||||
import ConfigSelect, {
|
||||
type ConfigSelectItem,
|
||||
} from '../../../../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
import styles from '../../ThresholdsSection.module.scss';
|
||||
|
||||
interface ThresholdSelectFieldProps {
|
||||
label: string;
|
||||
testId: string;
|
||||
placeholder?: string;
|
||||
value: string | undefined;
|
||||
items: ConfigSelectItem[];
|
||||
onChange: (value: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Labelled single-select, shared by the threshold variants' enum fields
|
||||
* (operator / display format / column).
|
||||
*/
|
||||
function ThresholdSelectField({
|
||||
label,
|
||||
testId,
|
||||
placeholder,
|
||||
value,
|
||||
items,
|
||||
onChange,
|
||||
}: ThresholdSelectFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.fieldLabel}>{label}</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId={testId}
|
||||
placeholder={placeholder}
|
||||
value={value}
|
||||
items={items}
|
||||
onChange={onChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThresholdSelectField;
|
||||
@@ -1,57 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import YAxisUnitSelector from 'components/YAxisUnitSelector';
|
||||
import { YAxisSource } from 'components/YAxisUnitSelector/types';
|
||||
|
||||
import {
|
||||
isThresholdUnitIncompatible,
|
||||
thresholdUnitCategories,
|
||||
} from '../../thresholdUnitCategories';
|
||||
|
||||
import styles from '../../ThresholdsSection.module.scss';
|
||||
|
||||
interface ThresholdUnitFieldProps {
|
||||
testId: string;
|
||||
invalidTestId: string;
|
||||
value: string | undefined;
|
||||
/** Unit whose category scopes the picker (panel y-axis unit, or the column's unit). */
|
||||
scopeUnit: string | undefined;
|
||||
/** How the scope reads in the mismatch message, e.g. "y-axis unit" / "column unit". */
|
||||
scopeLabel: string;
|
||||
onChange: (unit: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Labelled unit picker, scoped to `scopeUnit`'s category (V1 parity) and flagging a
|
||||
* threshold unit that resolves to a different category. Shared by every variant; only
|
||||
* the scope source and its wording differ.
|
||||
*/
|
||||
function ThresholdUnitField({
|
||||
testId,
|
||||
invalidTestId,
|
||||
value,
|
||||
scopeUnit,
|
||||
scopeLabel,
|
||||
onChange,
|
||||
}: ThresholdUnitFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.fieldLabel}>Unit</Typography.Text>
|
||||
<YAxisUnitSelector
|
||||
containerClassName={styles.unitSelector}
|
||||
data-testid={testId}
|
||||
placeholder="Select unit"
|
||||
source={YAxisSource.DASHBOARDS}
|
||||
categoriesOverride={thresholdUnitCategories(scopeUnit)}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
/>
|
||||
{isThresholdUnitIncompatible(value, scopeUnit) && (
|
||||
<Typography.Text className={styles.invalidUnit} data-testid={invalidTestId}>
|
||||
Threshold unit ({value}) is not valid with the {scopeLabel} ({scopeUnit})
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThresholdUnitField;
|
||||
@@ -1,33 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { Input } from 'antd';
|
||||
|
||||
import styles from '../../ThresholdsSection.module.scss';
|
||||
|
||||
interface ThresholdValueFieldProps {
|
||||
testId: string;
|
||||
value: number;
|
||||
/** Receives the raw input string; the draft hook parses it. */
|
||||
onChange: (raw: string) => void;
|
||||
}
|
||||
|
||||
/** Labelled numeric "Value" input, shared by every threshold variant. */
|
||||
function ThresholdValueField({
|
||||
testId,
|
||||
value,
|
||||
onChange,
|
||||
}: ThresholdValueFieldProps): JSX.Element {
|
||||
return (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text className={styles.fieldLabel}>Value</Typography.Text>
|
||||
<Input
|
||||
data-testid={testId}
|
||||
type="number"
|
||||
placeholder="Value"
|
||||
value={value}
|
||||
onChange={(e): void => onChange(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ThresholdValueField;
|
||||
@@ -1,34 +0,0 @@
|
||||
import { type Dispatch, type SetStateAction, useEffect, useState } from 'react';
|
||||
|
||||
interface ThresholdDraft<T> {
|
||||
draft: T;
|
||||
setDraft: Dispatch<SetStateAction<T>>;
|
||||
/** Parse a raw input string into `value`, ignoring transient non-numeric input. */
|
||||
setValue: (raw: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Local draft for a threshold row, shared by every variant. Snapshots the saved
|
||||
* threshold on each entry into edit mode (so Discard simply drops the draft and the
|
||||
* next edit starts clean) and exposes the numeric `value` setter all variants use.
|
||||
*/
|
||||
export function useThresholdDraft<T extends { value: number }>(
|
||||
threshold: T,
|
||||
isEditing: boolean,
|
||||
): ThresholdDraft<T> {
|
||||
const [draft, setDraft] = useState<T>(threshold);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) {
|
||||
setDraft(threshold);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- snapshot only on edit entry
|
||||
}, [isEditing]);
|
||||
|
||||
const setValue = (raw: string): void => {
|
||||
const next = Number(raw);
|
||||
setDraft((d) => ({ ...d, value: Number.isNaN(next) ? d.value : next }));
|
||||
};
|
||||
|
||||
return { draft, setDraft, setValue };
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
import {
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
DashboardtypesThresholdFormatDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
// Comparison operators offered in the "If value is" condition picker. Labels pair a
|
||||
// word with its math symbol so the dropdown reads clearly while the view row can show
|
||||
// the compact symbol (OPERATOR_SYMBOL below).
|
||||
export const OPERATOR_OPTIONS: ConfigSelectItem[] = [
|
||||
{ value: DashboardtypesComparisonOperatorDTO.above, label: 'Above (>)' },
|
||||
{
|
||||
value: DashboardtypesComparisonOperatorDTO.above_or_equal,
|
||||
label: 'Above or equal (≥)',
|
||||
},
|
||||
{ value: DashboardtypesComparisonOperatorDTO.below, label: 'Below (<)' },
|
||||
{
|
||||
value: DashboardtypesComparisonOperatorDTO.below_or_equal,
|
||||
label: 'Below or equal (≤)',
|
||||
},
|
||||
{ value: DashboardtypesComparisonOperatorDTO.equal, label: 'Equal (=)' },
|
||||
{
|
||||
value: DashboardtypesComparisonOperatorDTO.not_equal,
|
||||
label: 'Not equal (≠)',
|
||||
},
|
||||
];
|
||||
|
||||
// Compact symbol shown in the collapsed (view-mode) summary row.
|
||||
export const OPERATOR_SYMBOL: Record<
|
||||
DashboardtypesComparisonOperatorDTO,
|
||||
string
|
||||
> = {
|
||||
[DashboardtypesComparisonOperatorDTO.above]: '>',
|
||||
[DashboardtypesComparisonOperatorDTO.above_or_equal]: '≥',
|
||||
[DashboardtypesComparisonOperatorDTO.below]: '<',
|
||||
[DashboardtypesComparisonOperatorDTO.below_or_equal]: '≤',
|
||||
[DashboardtypesComparisonOperatorDTO.equal]: '=',
|
||||
[DashboardtypesComparisonOperatorDTO.not_equal]: '≠',
|
||||
};
|
||||
|
||||
// How the threshold recolors the panel: just the number ("text") or the whole tile
|
||||
// ("background").
|
||||
export const FORMAT_OPTIONS: ConfigSelectItem[] = [
|
||||
{ value: DashboardtypesThresholdFormatDTO.background, label: 'Background' },
|
||||
{ value: DashboardtypesThresholdFormatDTO.text, label: 'Text' },
|
||||
];
|
||||
@@ -1,54 +0,0 @@
|
||||
import {
|
||||
type YAxisCategory,
|
||||
YAxisSource,
|
||||
} from 'components/YAxisUnitSelector/types';
|
||||
import {
|
||||
getYAxisCategories,
|
||||
mapMetricUnitToUniversalUnit,
|
||||
} from 'components/YAxisUnitSelector/utils';
|
||||
|
||||
// The unit category (Time, Data, …) a unit belongs to, or undefined if unrecognized.
|
||||
function categoryForUnit(unit: string): YAxisCategory | undefined {
|
||||
const universal = mapMetricUnitToUniversalUnit(unit);
|
||||
return getYAxisCategories(YAxisSource.DASHBOARDS).find((c) =>
|
||||
c.units.some((u) => u.id === universal),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restricts the threshold unit picker to the panel's y-axis unit family, mirroring V1:
|
||||
* a threshold is only meaningfully comparable to the axis when it shares its category
|
||||
* (e.g. an `ms` axis → only Time units). Returns the single matching category, or
|
||||
* `undefined` (all categories) when the panel has no unit set or it can't be mapped.
|
||||
*/
|
||||
export function thresholdUnitCategories(
|
||||
yAxisUnit: string | undefined,
|
||||
): YAxisCategory[] | undefined {
|
||||
if (!yAxisUnit) {
|
||||
return undefined;
|
||||
}
|
||||
const category = categoryForUnit(yAxisUnit);
|
||||
return category ? [category] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a threshold's unit belongs to a different category than the panel's y-axis
|
||||
* unit (so the values can't be compared) — drives the V1-style mismatch message. Only
|
||||
* flags when both units are set and resolve to distinct categories (e.g. a stale `ms`
|
||||
* threshold left over after the axis unit was changed to bytes).
|
||||
*/
|
||||
export function isThresholdUnitIncompatible(
|
||||
thresholdUnit: string | undefined,
|
||||
yAxisUnit: string | undefined,
|
||||
): boolean {
|
||||
if (!thresholdUnit || !yAxisUnit) {
|
||||
return false;
|
||||
}
|
||||
const thresholdCategory = categoryForUnit(thresholdUnit);
|
||||
const axisCategory = categoryForUnit(yAxisUnit);
|
||||
return Boolean(
|
||||
thresholdCategory &&
|
||||
axisCategory &&
|
||||
thresholdCategory.name !== axisCategory.name,
|
||||
);
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
@@ -1,67 +0,0 @@
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import type { SectionEditorProps } from 'pages/DashboardPageV2/DashboardContainer/Panels/types/sections';
|
||||
|
||||
import ConfigSelect from '../../controls/ConfigSelect/ConfigSelect';
|
||||
import ConfigSwitch from '../../controls/ConfigSwitch/ConfigSwitch';
|
||||
import { TIME_PREFERENCE_OPTIONS } from './timePreferenceOptions';
|
||||
|
||||
import styles from './VisualizationSection.module.scss';
|
||||
|
||||
/**
|
||||
* Edits the `visualization` slice: the per-panel time preference (all kinds), bar
|
||||
* stacking (`stackedBarChart`, Bar only), and gap filling (`fillSpans`, TimeSeries
|
||||
* only). Each control is gated by its `controls` flag, so a kind only renders — and only
|
||||
* writes — the visualization fields its spec actually supports.
|
||||
*/
|
||||
function VisualizationSection({
|
||||
value,
|
||||
controls,
|
||||
onChange,
|
||||
}: SectionEditorProps<'visualization'>): JSX.Element {
|
||||
return (
|
||||
<>
|
||||
{controls.timePreference && (
|
||||
<div className={styles.field}>
|
||||
<Typography.Text>Panel time preference</Typography.Text>
|
||||
<ConfigSelect
|
||||
testId="panel-editor-v2-time-preference"
|
||||
placeholder="Select time scope…"
|
||||
value={value?.timePreference}
|
||||
items={TIME_PREFERENCE_OPTIONS}
|
||||
onChange={(next): void =>
|
||||
onChange({
|
||||
...value,
|
||||
timePreference: next as DashboardtypesTimePreferenceDTO,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{controls.stacking && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-stacked-bar-chart"
|
||||
title="Stack series"
|
||||
description="Stack bars from all series on top of each other"
|
||||
value={value?.stackedBarChart ?? false}
|
||||
onChange={(checked): void =>
|
||||
onChange({ ...value, stackedBarChart: checked })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{controls.fillSpans && (
|
||||
<ConfigSwitch
|
||||
testId="panel-editor-v2-fill-spans"
|
||||
title="Fill gaps"
|
||||
description="Fill gaps in data with 0 for continuity"
|
||||
value={value?.fillSpans ?? false}
|
||||
onChange={(checked): void => onChange({ ...value, fillSpans: checked })}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default VisualizationSection;
|
||||
@@ -1,104 +0,0 @@
|
||||
import { fireEvent, render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import VisualizationSection from '../VisualizationSection';
|
||||
|
||||
// Open the antd Select by clicking its selector, then pick the option by label.
|
||||
async function pickOption(triggerTestId: string, label: string): Promise<void> {
|
||||
const user = userEvent.setup();
|
||||
const trigger = screen.getByTestId(triggerTestId);
|
||||
await user.click(trigger.querySelector('.ant-select-selector') as HTMLElement);
|
||||
await user.click(await screen.findByRole('option', { name: label }));
|
||||
}
|
||||
|
||||
describe('VisualizationSection', () => {
|
||||
it('renders every control that is enabled', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true, stacking: true, fillSpans: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-time-preference'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-stacked-bar-chart'),
|
||||
).toBeInTheDocument();
|
||||
expect(screen.getByTestId('panel-editor-v2-fill-spans')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders only the controls whose flag is set', () => {
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
onChange={jest.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByTestId('panel-editor-v2-time-preference'),
|
||||
).toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-stacked-bar-chart'),
|
||||
).not.toBeInTheDocument();
|
||||
expect(
|
||||
screen.queryByTestId('panel-editor-v2-fill-spans'),
|
||||
).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('writes the chosen time preference through the dropdown', async () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={undefined}
|
||||
controls={{ timePreference: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
await pickOption('panel-editor-v2-time-preference', 'Last 1 hr');
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ timePreference: 'last_1_hr' });
|
||||
});
|
||||
|
||||
it('toggles bar stacking through onChange, preserving other fields', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{
|
||||
timePreference: DashboardtypesTimePreferenceDTO.global_time,
|
||||
stackedBarChart: false,
|
||||
}}
|
||||
controls={{ stacking: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-stacked-bar-chart'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({
|
||||
timePreference: 'global_time',
|
||||
stackedBarChart: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('toggles fill spans through onChange', () => {
|
||||
const onChange = jest.fn();
|
||||
render(
|
||||
<VisualizationSection
|
||||
value={{ fillSpans: false }}
|
||||
controls={{ fillSpans: true }}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
fireEvent.click(screen.getByTestId('panel-editor-v2-fill-spans'));
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith({ fillSpans: true });
|
||||
});
|
||||
});
|
||||
@@ -1,18 +0,0 @@
|
||||
import { DashboardtypesTimePreferenceDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import type { ConfigSelectItem } from '../../controls/ConfigSelect/ConfigSelect';
|
||||
|
||||
// Per-panel time scope. "Global Time" follows the dashboard's time picker; the rest pin
|
||||
// the panel to a fixed relative window regardless of the dashboard range (V1 parity).
|
||||
export const TIME_PREFERENCE_OPTIONS: ConfigSelectItem[] = [
|
||||
{ value: DashboardtypesTimePreferenceDTO.global_time, label: 'Global Time' },
|
||||
{ value: DashboardtypesTimePreferenceDTO.last_5_min, label: 'Last 5 min' },
|
||||
{ value: DashboardtypesTimePreferenceDTO.last_15_min, label: 'Last 15 min' },
|
||||
{ value: DashboardtypesTimePreferenceDTO.last_30_min, label: 'Last 30 min' },
|
||||
{ value: DashboardtypesTimePreferenceDTO.last_1_hr, label: 'Last 1 hr' },
|
||||
{ value: DashboardtypesTimePreferenceDTO.last_6_hr, label: 'Last 6 hr' },
|
||||
{ value: DashboardtypesTimePreferenceDTO.last_1_day, label: 'Last 1 day' },
|
||||
{ value: DashboardtypesTimePreferenceDTO.last_3_days, label: 'Last 3 days' },
|
||||
{ value: DashboardtypesTimePreferenceDTO.last_1_week, label: 'Last 1 week' },
|
||||
{ value: DashboardtypesTimePreferenceDTO.last_1_month, label: 'Last 1 month' },
|
||||
];
|
||||
@@ -1,38 +0,0 @@
|
||||
.editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border-top: 1px solid var(--l2-border);
|
||||
background-color: var(--l1-background);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--l2-foreground);
|
||||
}
|
||||
|
||||
.body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.hint {
|
||||
font-size: 11px;
|
||||
color: var(--l3-foreground);
|
||||
}
|
||||
@@ -1,122 +0,0 @@
|
||||
import { useMemo } from 'react';
|
||||
import {
|
||||
closestCenter,
|
||||
DndContext,
|
||||
type DragEndEvent,
|
||||
KeyboardSensor,
|
||||
PointerSensor,
|
||||
useSensor,
|
||||
useSensors,
|
||||
} from '@dnd-kit/core';
|
||||
import {
|
||||
arrayMove,
|
||||
horizontalListSortingStrategy,
|
||||
SortableContext,
|
||||
sortableKeyboardCoordinates,
|
||||
} from '@dnd-kit/sortable';
|
||||
import { Typography } from '@signozhq/ui/typography';
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import AddColumnDropdown from './components/AddColumnDropdown/AddColumnDropdown';
|
||||
import SortableColumnChip from './components/SortableColumnChip/SortableColumnChip';
|
||||
import { readSelectFields, writeSelectFields } from './selectFields';
|
||||
import styles from './ListColumnsEditor.module.scss';
|
||||
|
||||
interface ListColumnsEditorProps {
|
||||
spec: DashboardtypesPanelSpecDTO;
|
||||
onChangeSpec: (next: DashboardtypesPanelSpecDTO) => void;
|
||||
/** Committed query's signal — scopes the add-dropdown's field suggestions. */
|
||||
signal: TelemetrytypesSignalDTO | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The List panel's columns control, rendered below the query builder (V1 parity).
|
||||
* Columns are `spec.plugin.spec.selectFields`, shown as draggable chips that can
|
||||
* be reordered, removed, or added from a field-search dropdown. Empty means the
|
||||
* renderer shows every field the query returns.
|
||||
*/
|
||||
function ListColumnsEditor({
|
||||
spec,
|
||||
onChangeSpec,
|
||||
signal,
|
||||
}: ListColumnsEditorProps): JSX.Element {
|
||||
const fields = useMemo(() => readSelectFields(spec), [spec]);
|
||||
const names = useMemo(() => fields.map((field) => field.name), [fields]);
|
||||
const selectedNames = useMemo(() => new Set(names), [names]);
|
||||
|
||||
const sensors = useSensors(
|
||||
useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),
|
||||
useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),
|
||||
);
|
||||
|
||||
const commit = (next: TelemetrytypesTelemetryFieldKeyDTO[]): void =>
|
||||
onChangeSpec(writeSelectFields(spec, next));
|
||||
|
||||
const handleDragEnd = (event: DragEndEvent): void => {
|
||||
const { active, over } = event;
|
||||
if (!over || active.id === over.id) {
|
||||
return;
|
||||
}
|
||||
const oldIndex = names.indexOf(active.id as string);
|
||||
const newIndex = names.indexOf(over.id as string);
|
||||
if (oldIndex === -1 || newIndex === -1) {
|
||||
return;
|
||||
}
|
||||
commit(arrayMove(fields, oldIndex, newIndex));
|
||||
};
|
||||
|
||||
const handleRemove = (name: string): void =>
|
||||
commit(fields.filter((field) => field.name !== name));
|
||||
|
||||
const handleToggle = (field: TelemetrytypesTelemetryFieldKeyDTO): void => {
|
||||
if (selectedNames.has(field.name)) {
|
||||
handleRemove(field.name);
|
||||
return;
|
||||
}
|
||||
commit([...fields, field]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.editor} data-testid="list-columns-editor">
|
||||
<div className={styles.header}>
|
||||
<Typography.Text className={styles.title}>Columns</Typography.Text>
|
||||
</div>
|
||||
<div className={styles.body}>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext items={names} strategy={horizontalListSortingStrategy}>
|
||||
<div className={styles.chips}>
|
||||
{fields.map((field) => (
|
||||
<SortableColumnChip
|
||||
key={field.name}
|
||||
id={field.name}
|
||||
name={field.name}
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
<AddColumnDropdown
|
||||
signal={signal}
|
||||
selectedNames={selectedNames}
|
||||
onToggle={handleToggle}
|
||||
/>
|
||||
</div>
|
||||
{fields.length === 0 && (
|
||||
<Typography.Text className={styles.hint}>
|
||||
Leave empty to show all fields returned by the query.
|
||||
</Typography.Text>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ListColumnsEditor;
|
||||
@@ -1,134 +0,0 @@
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import {
|
||||
TelemetrytypesSignalDTO,
|
||||
type DashboardtypesPanelSpecDTO,
|
||||
type TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import ListColumnsEditor from '../ListColumnsEditor';
|
||||
import { readSelectFields } from '../selectFields';
|
||||
|
||||
// The add-dropdown fetches field-key suggestions; stub the generated hook so the
|
||||
// editor renders without a query client. Each test can override the return value.
|
||||
const mockUseGetFieldsKeys = jest.fn(
|
||||
(..._args: unknown[]): { data: unknown; isFetching: boolean } => ({
|
||||
data: undefined,
|
||||
isFetching: false,
|
||||
}),
|
||||
);
|
||||
jest.mock('api/generated/services/fields', () => ({
|
||||
useGetFieldsKeys: (
|
||||
...args: unknown[]
|
||||
): { data: unknown; isFetching: boolean } => mockUseGetFieldsKeys(...args),
|
||||
}));
|
||||
|
||||
const FIELDS = [
|
||||
{ name: 'body', fieldContext: 'attribute' },
|
||||
{ name: 'level' },
|
||||
] as TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
|
||||
function specWith(
|
||||
selectFields: TelemetrytypesTelemetryFieldKeyDTO[] | undefined,
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
return {
|
||||
plugin: { kind: 'signoz/ListPanel', spec: { selectFields } },
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
|
||||
describe('ListColumnsEditor', () => {
|
||||
beforeEach(() => {
|
||||
mockUseGetFieldsKeys.mockClear();
|
||||
mockUseGetFieldsKeys.mockReturnValue({ data: undefined, isFetching: false });
|
||||
});
|
||||
|
||||
it('renders the selected columns as chips', () => {
|
||||
render(
|
||||
<ListColumnsEditor
|
||||
spec={specWith(FIELDS)}
|
||||
onChangeSpec={jest.fn()}
|
||||
signal={TelemetrytypesSignalDTO.logs}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('body')).toBeInTheDocument();
|
||||
expect(screen.getByText('level')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('shows the empty-state hint when no columns are selected', () => {
|
||||
render(
|
||||
<ListColumnsEditor
|
||||
spec={specWith([])}
|
||||
onChangeSpec={jest.fn()}
|
||||
signal={TelemetrytypesSignalDTO.logs}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByText(/Leave empty to show all fields/),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('scopes the field-key suggestions to the panel signal', () => {
|
||||
render(
|
||||
<ListColumnsEditor
|
||||
spec={specWith(FIELDS)}
|
||||
onChangeSpec={jest.fn()}
|
||||
signal={TelemetrytypesSignalDTO.traces}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(mockUseGetFieldsKeys).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ signal: TelemetrytypesSignalDTO.traces }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it('removing a chip writes the spec without that column', async () => {
|
||||
const user = userEvent.setup();
|
||||
const onChangeSpec = jest.fn();
|
||||
render(
|
||||
<ListColumnsEditor
|
||||
spec={specWith(FIELDS)}
|
||||
onChangeSpec={onChangeSpec}
|
||||
signal={TelemetrytypesSignalDTO.logs}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByLabelText('Remove body'));
|
||||
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
const nextSpec = onChangeSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
|
||||
const fields = readSelectFields(nextSpec);
|
||||
expect(fields.map((field) => field.name)).toStrictEqual(['level']);
|
||||
});
|
||||
|
||||
it('adds a suggestion picked from the dropdown', async () => {
|
||||
const user = userEvent.setup();
|
||||
mockUseGetFieldsKeys.mockReturnValue({
|
||||
data: { data: { keys: { group: [{ name: 'status' }] } } },
|
||||
isFetching: false,
|
||||
});
|
||||
const onChangeSpec = jest.fn();
|
||||
render(
|
||||
<ListColumnsEditor
|
||||
spec={specWith(FIELDS)}
|
||||
onChangeSpec={onChangeSpec}
|
||||
signal={TelemetrytypesSignalDTO.logs}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.click(screen.getByTestId('list-columns-add'));
|
||||
const suggestion = await screen.findByText('status');
|
||||
await user.click(suggestion);
|
||||
|
||||
expect(onChangeSpec).toHaveBeenCalledTimes(1);
|
||||
const nextSpec = onChangeSpec.mock.calls[0][0] as DashboardtypesPanelSpecDTO;
|
||||
const fields = readSelectFields(nextSpec);
|
||||
expect(fields.map((field) => field.name)).toStrictEqual([
|
||||
'body',
|
||||
'level',
|
||||
'status',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -1,58 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import {
|
||||
readSelectFields,
|
||||
sanitizeSelectFields,
|
||||
writeSelectFields,
|
||||
} from '../selectFields';
|
||||
|
||||
// The fields API and default-column constants carry extra runtime keys (e.g.
|
||||
// `isIndexed`) the save contract rejects; only the DTO keys may be persisted.
|
||||
const DIRTY_FIELD = {
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
isIndexed: false,
|
||||
} as unknown as TelemetrytypesTelemetryFieldKeyDTO;
|
||||
|
||||
describe('selectFields', () => {
|
||||
describe('sanitizeSelectFields', () => {
|
||||
it('drops keys outside the field-key DTO (isIndexed)', () => {
|
||||
const [cleaned] = sanitizeSelectFields([DIRTY_FIELD]);
|
||||
|
||||
expect('isIndexed' in cleaned).toBe(false);
|
||||
expect(cleaned).toStrictEqual({
|
||||
name: 'body',
|
||||
signal: 'logs',
|
||||
fieldContext: 'log',
|
||||
fieldDataType: '',
|
||||
});
|
||||
});
|
||||
|
||||
it('omits absent optional keys rather than emitting undefined', () => {
|
||||
const [cleaned] = sanitizeSelectFields([
|
||||
{ name: 'level' } as TelemetrytypesTelemetryFieldKeyDTO,
|
||||
]);
|
||||
|
||||
expect(cleaned).toStrictEqual({ name: 'level' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('writeSelectFields', () => {
|
||||
it('sanitizes columns as it writes them onto the spec', () => {
|
||||
const spec = {
|
||||
plugin: { kind: 'signoz/ListPanel', spec: {} },
|
||||
} as unknown as DashboardtypesPanelSpecDTO;
|
||||
|
||||
const next = writeSelectFields(spec, [DIRTY_FIELD]);
|
||||
|
||||
expect(readSelectFields(next)).toStrictEqual([
|
||||
{ name: 'body', signal: 'logs', fieldContext: 'log', fieldDataType: '' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,10 +0,0 @@
|
||||
// Shrink the icon Button from its 2rem default to the editor's 28px control row.
|
||||
.addBtn {
|
||||
--button-height: 28px;
|
||||
--button-width: 28px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.dropdown {
|
||||
width: 260px;
|
||||
}
|
||||
@@ -1,106 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import {
|
||||
Combobox,
|
||||
ComboboxCommand,
|
||||
ComboboxContent,
|
||||
ComboboxEmpty,
|
||||
ComboboxInput,
|
||||
ComboboxItem,
|
||||
ComboboxList,
|
||||
ComboboxLoading,
|
||||
ComboboxTrigger,
|
||||
} from '@signozhq/ui/combobox';
|
||||
import { Plus } from '@signozhq/icons';
|
||||
import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
import { useListColumnSuggestions } from '../../hooks/useListColumnSuggestions';
|
||||
import styles from './AddColumnDropdown.module.scss';
|
||||
|
||||
interface AddColumnDropdownProps {
|
||||
signal: TelemetrytypesSignalDTO | undefined;
|
||||
/** Names already chosen — drives the checked state + toggle behavior. */
|
||||
selectedNames: Set<string>;
|
||||
onToggle: (field: TelemetrytypesTelemetryFieldKeyDTO) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* The "+" affordance for the List columns editor: a searchable combobox of
|
||||
* field-key suggestions. Picking a suggestion toggles it (checkmark = selected);
|
||||
* a non-matching search term can be added verbatim so not-yet-indexed fields are
|
||||
* still selectable. Search is server-side, so cmdk filtering is disabled.
|
||||
*/
|
||||
function AddColumnDropdown({
|
||||
signal,
|
||||
selectedNames,
|
||||
onToggle,
|
||||
}: AddColumnDropdownProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const { searchText, setSearchText, suggestions, isFetching } =
|
||||
useListColumnSuggestions(signal);
|
||||
|
||||
const trimmed = searchText.trim();
|
||||
const hasExactMatch = suggestions.some((field) => field.name === trimmed);
|
||||
const showCustomAdd = trimmed.length > 0 && !hasExactMatch;
|
||||
|
||||
return (
|
||||
<Combobox open={open} onOpenChange={setOpen}>
|
||||
<ComboboxTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outlined"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.addBtn}
|
||||
aria-label="Add column"
|
||||
// `data-testid` (not the `testId` prop) survives the trigger's
|
||||
// `asChild` Slot merge, which otherwise resets it to undefined.
|
||||
data-testid="list-columns-add"
|
||||
>
|
||||
<Plus size={16} />
|
||||
</Button>
|
||||
</ComboboxTrigger>
|
||||
<ComboboxContent arrow side="top" align="end" className={styles.dropdown}>
|
||||
<ComboboxCommand shouldFilter={false}>
|
||||
<ComboboxInput
|
||||
value={searchText}
|
||||
onValueChange={setSearchText}
|
||||
placeholder="Search fields"
|
||||
testId="list-columns-search"
|
||||
/>
|
||||
<ComboboxList>
|
||||
{showCustomAdd && (
|
||||
<ComboboxItem
|
||||
value={trimmed}
|
||||
onSelect={(): void => onToggle({ name: trimmed })}
|
||||
data-testid="list-columns-add-custom"
|
||||
>
|
||||
Add "{trimmed}"
|
||||
</ComboboxItem>
|
||||
)}
|
||||
{suggestions.map((field) => (
|
||||
<ComboboxItem
|
||||
key={field.name}
|
||||
value={field.name}
|
||||
isSelected={selectedNames.has(field.name)}
|
||||
onSelect={(): void => onToggle(field)}
|
||||
data-testid="list-columns-suggestion"
|
||||
>
|
||||
{field.name}
|
||||
</ComboboxItem>
|
||||
))}
|
||||
{isFetching && <ComboboxLoading>Loading…</ComboboxLoading>}
|
||||
{!isFetching && !showCustomAdd && suggestions.length === 0 && (
|
||||
<ComboboxEmpty>No fields found</ComboboxEmpty>
|
||||
)}
|
||||
</ComboboxList>
|
||||
</ComboboxCommand>
|
||||
</ComboboxContent>
|
||||
</Combobox>
|
||||
);
|
||||
}
|
||||
|
||||
export default AddColumnDropdown;
|
||||
@@ -1,41 +0,0 @@
|
||||
.chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 28px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid var(--l2-border);
|
||||
border-radius: 4px;
|
||||
background-color: var(--l2-background);
|
||||
}
|
||||
|
||||
// Shrink the icon Button to the chip's compact footprint (defaults are 2rem
|
||||
// square with 0.5rem padding) via the component's CSS-custom-property hooks.
|
||||
.grip {
|
||||
--button-height: 16px;
|
||||
--button-width: 16px;
|
||||
--button-padding: 0;
|
||||
cursor: grab;
|
||||
|
||||
&:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
}
|
||||
|
||||
.chipName {
|
||||
max-width: 180px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 12px;
|
||||
color: var(--l1-foreground);
|
||||
}
|
||||
|
||||
.remove {
|
||||
--button-height: 16px;
|
||||
--button-width: 16px;
|
||||
--button-padding: 0;
|
||||
// Keep the V1 affordance: neutral by default, cherry-red on hover.
|
||||
--button-variant-ghost-hover-color: var(--bg-cherry-400);
|
||||
--button-variant-ghost-hover-background-color: transparent;
|
||||
}
|
||||
@@ -1,70 +0,0 @@
|
||||
import { useSortable } from '@dnd-kit/sortable';
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import { Button } from '@signozhq/ui/button';
|
||||
import { GripVertical, X } from '@signozhq/icons';
|
||||
|
||||
import styles from './SortableColumnChip.module.scss';
|
||||
|
||||
interface SortableColumnChipProps {
|
||||
/** Stable sortable id (the column name). */
|
||||
id: string;
|
||||
name: string;
|
||||
onRemove: (name: string) => void;
|
||||
}
|
||||
|
||||
/** A single draggable column chip: grip handle, field name, remove button. */
|
||||
function SortableColumnChip({
|
||||
id,
|
||||
name,
|
||||
onRemove,
|
||||
}: SortableColumnChipProps): JSX.Element {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
isDragging,
|
||||
} = useSortable({ id });
|
||||
|
||||
// dnd-kit drives the drag transform per-frame, so this must be inline.
|
||||
const style: React.CSSProperties = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : undefined,
|
||||
};
|
||||
|
||||
return (
|
||||
<div ref={setNodeRef} style={style} className={styles.chip}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.grip}
|
||||
aria-label={`Reorder ${name}`}
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
>
|
||||
<GripVertical size={12} />
|
||||
</Button>
|
||||
<span className={styles.chipName} title={name}>
|
||||
{name}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
color="secondary"
|
||||
size="icon"
|
||||
className={styles.remove}
|
||||
aria-label={`Remove ${name}`}
|
||||
testId="list-column-remove"
|
||||
onClick={(): void => onRemove(name)}
|
||||
>
|
||||
<X size={12} />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default SortableColumnChip;
|
||||
@@ -1,50 +0,0 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import type {
|
||||
TelemetrytypesSignalDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
import { useGetFieldsKeys } from 'api/generated/services/fields';
|
||||
import useDebounce from 'hooks/useDebounce';
|
||||
|
||||
interface UseListColumnSuggestions {
|
||||
searchText: string;
|
||||
setSearchText: (value: string) => void;
|
||||
suggestions: TelemetrytypesTelemetryFieldKeyDTO[];
|
||||
/** Suggestion field keyed by name — enriches a freshly-picked column with its metadata. */
|
||||
suggestionByName: Map<string, TelemetrytypesTelemetryFieldKeyDTO>;
|
||||
isFetching: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Server-side telemetry field-key search scoped to the panel's signal, shared by
|
||||
* the List columns editor's add-dropdown. Suggestions arrive grouped by name; we
|
||||
* flatten them and index by name so picks can carry their context/data-type.
|
||||
*/
|
||||
export function useListColumnSuggestions(
|
||||
signal: TelemetrytypesSignalDTO | undefined,
|
||||
): UseListColumnSuggestions {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const debouncedSearch = useDebounce(searchText, 300);
|
||||
|
||||
const { data, isFetching } = useGetFieldsKeys(
|
||||
{ signal, searchText: debouncedSearch },
|
||||
{ query: { enabled: !!signal } },
|
||||
);
|
||||
|
||||
const suggestions = useMemo(
|
||||
() => Object.values(data?.data.keys ?? {}).flat(),
|
||||
[data],
|
||||
);
|
||||
const suggestionByName = useMemo(
|
||||
() => new Map(suggestions.map((field) => [field.name, field])),
|
||||
[suggestions],
|
||||
);
|
||||
|
||||
return {
|
||||
searchText,
|
||||
setSearchText,
|
||||
suggestions,
|
||||
suggestionByName,
|
||||
isFetching,
|
||||
};
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
import type {
|
||||
DashboardtypesListPanelSpecDTO,
|
||||
DashboardtypesPanelSpecDTO,
|
||||
TelemetrytypesTelemetryFieldKeyDTO,
|
||||
} from 'api/generated/services/sigNoz.schemas';
|
||||
|
||||
/**
|
||||
* The field-key suggestions API and the default-column constants carry extra
|
||||
* runtime fields (e.g. `isIndexed`) that the save contract rejects. Reduce each
|
||||
* column to the `TelemetrytypesTelemetryFieldKeyDTO` shape so persisted
|
||||
* `selectFields` only contain backend-known keys.
|
||||
*/
|
||||
function toFieldKeyDTO(
|
||||
field: TelemetrytypesTelemetryFieldKeyDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO {
|
||||
const { name, description, fieldContext, fieldDataType, signal, unit } = field;
|
||||
return {
|
||||
name,
|
||||
...(description !== undefined && { description }),
|
||||
...(fieldContext !== undefined && { fieldContext }),
|
||||
...(fieldDataType !== undefined && { fieldDataType }),
|
||||
...(signal !== undefined && { signal }),
|
||||
...(unit !== undefined && { unit }),
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeSelectFields(
|
||||
fields: TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
return fields.map(toFieldKeyDTO);
|
||||
}
|
||||
|
||||
/**
|
||||
* `spec.plugin.spec` is a discriminated union over every panel kind; these helpers
|
||||
* run only for the List panel, so it's narrowed to the List variant with a single
|
||||
* localized cast at the boundary.
|
||||
*/
|
||||
export function readSelectFields(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
): TelemetrytypesTelemetryFieldKeyDTO[] {
|
||||
return (spec.plugin.spec as DashboardtypesListPanelSpecDTO).selectFields ?? [];
|
||||
}
|
||||
|
||||
export function writeSelectFields(
|
||||
spec: DashboardtypesPanelSpecDTO,
|
||||
selectFields: TelemetrytypesTelemetryFieldKeyDTO[],
|
||||
): DashboardtypesPanelSpecDTO {
|
||||
const listSpec: DashboardtypesListPanelSpecDTO = {
|
||||
...(spec.plugin.spec as DashboardtypesListPanelSpecDTO),
|
||||
selectFields: sanitizeSelectFields(selectFields),
|
||||
};
|
||||
return {
|
||||
...spec,
|
||||
plugin: { ...spec.plugin, spec: listSpec },
|
||||
} as DashboardtypesPanelSpecDTO;
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import type { DashboardtypesPanelDTO } from 'api/generated/services/sigNoz.schemas';
|
||||
import { useIsDarkMode } from 'hooks/useDarkMode';
|
||||
import { generateColor } from 'lib/uPlotLib/utils/generateColor';
|
||||
import { resolveSeriesLabelV5 } from 'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel';
|
||||
import type { PanelQueryData } from 'pages/DashboardPageV2/DashboardContainer/queryV5/types';
|
||||
import { flattenTimeSeries } from 'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData';
|
||||
|
||||
import { useLegendSeries } from '../useLegendSeries';
|
||||
|
||||
jest.mock('hooks/useDarkMode', () => ({ useIsDarkMode: jest.fn() }));
|
||||
jest.mock('lib/getLabelName', () => jest.fn(() => 'base'));
|
||||
jest.mock('lib/uPlotLib/utils/generateColor', () => ({
|
||||
generateColor: jest.fn((label: string) => `color:${label}`),
|
||||
}));
|
||||
jest.mock('constants/theme', () => ({
|
||||
themeColors: { chartcolors: ['dark'], lightModeColor: ['light'] },
|
||||
}));
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/utils/getBuilderQueries',
|
||||
() => ({ getBuilderQueries: jest.fn(() => []) }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/Panels/utils/resolveSeriesLabel',
|
||||
() => ({ resolveSeriesLabelV5: jest.fn() }),
|
||||
);
|
||||
jest.mock(
|
||||
'pages/DashboardPageV2/DashboardContainer/queryV5/v5ResponseData',
|
||||
() => ({
|
||||
flattenTimeSeries: jest.fn(),
|
||||
getTimeSeriesResults: jest.fn(() => []),
|
||||
}),
|
||||
);
|
||||
|
||||
const mockUseIsDarkMode = useIsDarkMode as unknown as jest.Mock;
|
||||
const mockFlatten = flattenTimeSeries as unknown as jest.Mock;
|
||||
const mockResolveLabel = resolveSeriesLabelV5 as unknown as jest.Mock;
|
||||
const mockGenerateColor = generateColor as unknown as jest.Mock;
|
||||
|
||||
const PANEL = {
|
||||
kind: 'Panel',
|
||||
spec: { plugin: { kind: 'signoz/TimeSeriesPanel', spec: {} }, queries: [] },
|
||||
} as unknown as DashboardtypesPanelDTO;
|
||||
const DATA = { response: {}, legendMap: {} } as unknown as PanelQueryData;
|
||||
|
||||
// Each flattened series carries the label resolveSeriesLabelV5 should report.
|
||||
function seriesWithLabels(labels: string[]): { __label: string }[] {
|
||||
return labels.map((__label) => ({ __label }));
|
||||
}
|
||||
|
||||
describe('useLegendSeries', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
mockUseIsDarkMode.mockReturnValue(true);
|
||||
mockResolveLabel.mockImplementation((s: { __label: string }) => s.__label);
|
||||
});
|
||||
|
||||
it('is empty when there are no series', () => {
|
||||
mockFlatten.mockReturnValue([]);
|
||||
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it('maps each series to a { label, defaultColor } pair', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b']));
|
||||
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'a', defaultColor: 'color:a' },
|
||||
{ label: 'b', defaultColor: 'color:b' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('dedupes by label, keeping first-seen order', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a', 'b', 'a', 'c']));
|
||||
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
|
||||
expect(result.current.map((s) => s.label)).toStrictEqual(['a', 'b', 'c']);
|
||||
// The duplicate 'a' must not generate a second color.
|
||||
expect(
|
||||
mockGenerateColor.mock.calls.filter(([label]) => label === 'a'),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('skips series that resolve to an empty label', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['', 'a', '']));
|
||||
const { result } = renderHook(() => useLegendSeries(PANEL, DATA));
|
||||
expect(result.current).toStrictEqual([
|
||||
{ label: 'a', defaultColor: 'color:a' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the dark palette in dark mode and the light palette otherwise', () => {
|
||||
mockFlatten.mockReturnValue(seriesWithLabels(['a']));
|
||||
|
||||
const dark = renderHook(() => useLegendSeries(PANEL, DATA));
|
||||
expect(mockGenerateColor).toHaveBeenLastCalledWith('a', ['dark']);
|
||||
dark.unmount();
|
||||
|
||||
mockUseIsDarkMode.mockReturnValue(false);
|
||||
renderHook(() => useLegendSeries(PANEL, DATA));
|
||||
expect(mockGenerateColor).toHaveBeenLastCalledWith('a', ['light']);
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user