ESLint Telemetry Rule (#153108)

Resolves https://github.com/elastic/kibana/issues/144887

## Summary

This PR adds an ESLint Plugin which checks specific `Eui` elements for
the existence of a `data-test-subj` prop. This rule will make having one
for these elements required.

This rule is currently only enabled for Observability apps (APM, Infra,
Observability, Synthetics, Uptime).

The plugin is also able to generate a suggestion based on the context in
which the element is used. In the IDE this suggestion can be applied by
using the autofix capability (see video below).

When opening a PR, the CI will automatically apply the suggestion to
qualifying Eui elements in the branch.



https://user-images.githubusercontent.com/535564/225449622-bbfccb40-fdd2-4f69-9d5a-7d5a97bf62e6.mov



## Why do this?
There is an increased push to move towards data driven feature
development. In order to facilitate this, we need to have an increased
focus on instrumenting user event generating elements in the Kibana
codebase. This linting rule is an attempt to nudge Kibana engineers to
not forget to add this property when writing frontend code. It also
saves a bit of work for engineers by suggesting a value for the
`data-test-subj` based on the location of the file in the codebase and
any potential default values that might be present in the JSX node tree.
Finally, because the suggestion is always of the same form, it can
increase the consistency in the values given to these elements.

## Shape of the suggestion
The suggestion for the value of data-test-subj is of the form:
`[app][componentName][intent][euiElementName]`.

For example, when working in a component in the location:
`x-pack/plugins/observability/public/pages/overview/containers/overview_page/header_actions.tsx`,
and having the code:

```
function HeaderActions() {
  return (
    <EuiButton>{i18n.translate('id', { defaultMessage: 'Submit Form' })}</EuiButton>
  )
}
```

the suggestion becomes:
`data-test-subj=o11yHeaderActionsSubmitFormButton`.

For elements that don't take a `defaultMessage` prop / translation, the
suggestion takes the form: `[app][componentName][euiElementName]`

## Which elements are checked by the ESLint rule?
In its current iteration the rule checks these Eui elements:

*  `EuiButton`
*  `EuiButtonEmpty`
*  `EuiLink`
*  `EuiFieldText`
*  `EuiFieldSearch`
*  `EuiFieldNumber`
*  `EuiSelect`
*  `EuiRadioGroup`
*  'EuiTextArea`

## What types of prop setting does this rule support?
* `<EuiButton data-test-subj="foo">` (direct prop)
* `<EuiButton {...foo}>` (via spreaded object; rule checks for
`data-test-subj` key in object)

## What types of function declarations does this rule support?
* `function Foo(){}` (Named function)
* `const Foo = () => {}` (Arrow function assigned to variable)
* `const Foo = memo(() => {})` (Arrow function assigned to variable
wrapped in function)
* `const Foo = hoc(uponHoc(uponHoc(() => {})))` (Arrow function assigned
to variable wrapped in infinite levels of functions)

## Things to note
* If an element already has a value for `data-test-subj` the rule will
not kick in as any existing instrumentation might depend on the value.
* the auto suggestion is just a suggestion: the engineer can always
adjust the value for a `data-test-subj` before or after committing. Once
a value is present (autofixed or manually set) the rule will not kick
in.

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
Co-authored-by: Dario Gieselaar <d.gieselaar@gmail.com>
Co-authored-by: Katerina Patticha <kate@kpatticha.com>
Co-authored-by: Tiago Costa <tiago.costa@elastic.co>
This commit is contained in:
Coen Warmer 2023-03-20 14:31:02 +01:00 committed by GitHub
parent 1892bf6535
commit 010ee2e112
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
346 changed files with 2029 additions and 391 deletions

View file

@ -897,6 +897,18 @@ module.exports = {
],
},
},
{
files: [
'x-pack/plugins/apm/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/observability/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/ux/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/synthetics/**/*.{js,mjs,ts,tsx}',
'x-pack/plugins/infra/**/*.{js,mjs,ts,tsx}',
],
rules: {
'@kbn/telemetry/event_generating_elements_should_be_instrumented': 'error',
},
},
{
// require explicit return types in route handlers for performance reasons
files: ['x-pack/plugins/apm/server/**/route.ts'],

1
.github/CODEOWNERS vendored
View file

@ -334,6 +334,7 @@ packages/kbn-eslint-config @elastic/kibana-operations
packages/kbn-eslint-plugin-disable @elastic/kibana-operations
packages/kbn-eslint-plugin-eslint @elastic/kibana-operations
packages/kbn-eslint-plugin-imports @elastic/kibana-operations
packages/kbn-eslint-plugin-telemetry @elastic/actionable-observability
x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin @elastic/kibana-security
src/plugins/event_annotation @elastic/kibana-visualizations
x-pack/test/plugin_api_integration/plugins/event_log @elastic/response-ops

View file

@ -1059,6 +1059,7 @@
"@kbn/eslint-plugin-disable": "link:packages/kbn-eslint-plugin-disable",
"@kbn/eslint-plugin-eslint": "link:packages/kbn-eslint-plugin-eslint",
"@kbn/eslint-plugin-imports": "link:packages/kbn-eslint-plugin-imports",
"@kbn/eslint-plugin-telemetry": "link:packages/kbn-eslint-plugin-telemetry",
"@kbn/expect": "link:packages/kbn-expect",
"@kbn/failed-test-reporter-cli": "link:packages/kbn-failed-test-reporter-cli",
"@kbn/find-used-node-modules": "link:packages/kbn-find-used-node-modules",

View file

@ -1,22 +1,18 @@
const { USES_STYLED_COMPONENTS } = require('@kbn/babel-preset/styled_components_files');
module.exports = {
extends: [
'./javascript.js',
'./typescript.js',
'./jest.js',
'./react.js',
],
extends: ['./javascript.js', './typescript.js', './jest.js', './react.js'],
plugins: [
'@kbn/eslint-plugin-disable',
'@kbn/eslint-plugin-eslint',
'@kbn/eslint-plugin-imports',
'@kbn/eslint-plugin-telemetry',
'prettier',
],
parserOptions: {
ecmaVersion: 2018
ecmaVersion: 2018,
},
env: {
@ -41,7 +37,7 @@ module.exports = {
{
from: 'mkdirp',
to: false,
disallowedMessage: `Don't use 'mkdirp', use the new { recursive: true } option of Fs.mkdir instead`
disallowedMessage: `Don't use 'mkdirp', use the new { recursive: true } option of Fs.mkdir instead`,
},
{
from: 'numeral',
@ -50,7 +46,7 @@ module.exports = {
{
from: '@kbn/elastic-idx',
to: false,
disallowedMessage: `Don't use idx(), use optional chaining syntax instead https://ela.st/optchain`
disallowedMessage: `Don't use idx(), use optional chaining syntax instead https://ela.st/optchain`,
},
{
from: 'x-pack',
@ -67,46 +63,45 @@ module.exports = {
{
from: 'monaco-editor',
to: false,
disallowedMessage: `Don't import monaco directly, use or add exports to @kbn/monaco`
disallowedMessage: `Don't import monaco directly, use or add exports to @kbn/monaco`,
},
{
from: 'tinymath',
to: '@kbn/tinymath',
disallowedMessage: `Don't use 'tinymath', use '@kbn/tinymath'`
disallowedMessage: `Don't use 'tinymath', use '@kbn/tinymath'`,
},
{
from: '@kbn/test/types/ftr',
to: '@kbn/test',
disallowedMessage: `import from the root of @kbn/test instead`
disallowedMessage: `import from the root of @kbn/test instead`,
},
{
from: 'react-intl',
to: '@kbn/i18n-react',
disallowedMessage: `import from @kbn/i18n-react instead`
disallowedMessage: `import from @kbn/i18n-react instead`,
},
{
from: 'styled-components',
to: false,
exclude: USES_STYLED_COMPONENTS,
disallowedMessage: `Prefer using @emotion/react instead. To use styled-components, ensure you plugin is enabled in packages/kbn-babel-preset/styled_components_files.js.`
disallowedMessage: `Prefer using @emotion/react instead. To use styled-components, ensure you plugin is enabled in packages/kbn-babel-preset/styled_components_files.js.`,
},
...[
'@elastic/eui/dist/eui_theme_light.json',
'@elastic/eui/dist/eui_theme_dark.json',
].map(from => ({
from,
to: false,
disallowedMessage: `Use "@kbn/ui-theme" to access theme vars.`
})),
...['@elastic/eui/dist/eui_theme_light.json', '@elastic/eui/dist/eui_theme_dark.json'].map(
(from) => ({
from,
to: false,
disallowedMessage: `Use "@kbn/ui-theme" to access theme vars.`,
})
),
{
from: '@kbn/test/jest',
to: '@kbn/test-jest-helpers',
disallowedMessage: `import from @kbn/test-jest-helpers instead`
disallowedMessage: `import from @kbn/test-jest-helpers instead`,
},
{
from: '@kbn/utility-types/jest',
to: '@kbn/utility-types-jest',
disallowedMessage: `import from @kbn/utility-types-jest instead`
disallowedMessage: `import from @kbn/utility-types-jest instead`,
},
{
from: '@kbn/inspector-plugin',
@ -149,142 +144,126 @@ module.exports = {
* of the file being linted so that we could re-route imports from `plugin-client` types to a different package
* than `plugin-server` types.
*/
'@kbn/imports/exports_moved_packages': ['error', [
{
from: '@kbn/dev-utils',
to: '@kbn/tooling-log',
exportNames: [
'DEFAULT_LOG_LEVEL',
'getLogLevelFlagsHelp',
'LOG_LEVEL_FLAGS',
'LogLevel',
'Message',
'ParsedLogLevel',
'parseLogLevel',
'pickLevelFromFlags',
'ToolingLog',
'ToolingLogCollectingWriter',
'ToolingLogOptions',
'ToolingLogTextWriter',
'ToolingLogTextWriterConfig',
'Writer',
]
},
{
from: '@kbn/dev-utils',
to: '@kbn/ci-stats-reporter',
exportNames: [
'CiStatsMetric',
'CiStatsReporter',
'CiStatsReportTestsOptions',
'CiStatsTestGroupInfo',
'CiStatsTestResult',
'CiStatsTestRun',
'CiStatsTestType',
'CiStatsTiming',
'getTimeReporter',
'MetricsOptions',
'TimingsOptions',
]
},
{
from: '@kbn/dev-utils',
to: '@kbn/ci-stats-core',
exportNames: [
'Config',
]
},
{
from: '@kbn/dev-utils',
to: '@kbn/jest-serializers',
exportNames: [
'createAbsolutePathSerializer',
'createStripAnsiSerializer',
'createRecursiveSerializer',
'createAnyInstanceSerializer',
'createReplaceSerializer',
]
},
{
from: '@kbn/dev-utils',
to: '@kbn/stdio-dev-helpers',
exportNames: [
'observeReadable',
'observeLines',
]
},
{
from: '@kbn/dev-utils',
to: '@kbn/sort-package-json',
exportNames: [
'sortPackageJson',
]
},
{
from: '@kbn/dev-utils',
to: '@kbn/dev-cli-runner',
exportNames: [
'run',
'Command',
'RunWithCommands',
'CleanupTask',
'Command',
'CommandRunFn',
'FlagOptions',
'Flags',
'RunContext',
'RunFn',
'RunOptions',
'RunWithCommands',
'RunWithCommandsOptions',
'getFlags',
'mergeFlagOptions'
]
},
{
from: '@kbn/dev-utils',
to: '@kbn/dev-cli-errors',
exportNames: [
'createFailError',
'createFlagError',
'isFailError',
]
},
{
from: '@kbn/dev-utils',
to: '@kbn/dev-proc-runner',
exportNames: [
'withProcRunner',
'ProcRunner',
]
},
{
from: '@kbn/utils',
to: '@kbn/repo-info',
exportNames: [
'REPO_ROOT',
'UPSTREAM_BRANCH',
'kibanaPackageJson',
'isKibanaDistributable',
'fromRoot',
]
},
{
from: '@kbn/presentation-util-plugin/common',
to: '@kbn/presentation-util-plugin/test_helpers',
exportNames: [
'functionWrapper',
'fontStyle'
]
},
{
from: '@kbn/fleet-plugin/common',
to: '@kbn/fleet-plugin/common/mocks',
exportNames: [
'createFleetAuthzMock'
]
}
]],
'@kbn/imports/exports_moved_packages': [
'error',
[
{
from: '@kbn/dev-utils',
to: '@kbn/tooling-log',
exportNames: [
'DEFAULT_LOG_LEVEL',
'getLogLevelFlagsHelp',
'LOG_LEVEL_FLAGS',
'LogLevel',
'Message',
'ParsedLogLevel',
'parseLogLevel',
'pickLevelFromFlags',
'ToolingLog',
'ToolingLogCollectingWriter',
'ToolingLogOptions',
'ToolingLogTextWriter',
'ToolingLogTextWriterConfig',
'Writer',
],
},
{
from: '@kbn/dev-utils',
to: '@kbn/ci-stats-reporter',
exportNames: [
'CiStatsMetric',
'CiStatsReporter',
'CiStatsReportTestsOptions',
'CiStatsTestGroupInfo',
'CiStatsTestResult',
'CiStatsTestRun',
'CiStatsTestType',
'CiStatsTiming',
'getTimeReporter',
'MetricsOptions',
'TimingsOptions',
],
},
{
from: '@kbn/dev-utils',
to: '@kbn/ci-stats-core',
exportNames: ['Config'],
},
{
from: '@kbn/dev-utils',
to: '@kbn/jest-serializers',
exportNames: [
'createAbsolutePathSerializer',
'createStripAnsiSerializer',
'createRecursiveSerializer',
'createAnyInstanceSerializer',
'createReplaceSerializer',
],
},
{
from: '@kbn/dev-utils',
to: '@kbn/stdio-dev-helpers',
exportNames: ['observeReadable', 'observeLines'],
},
{
from: '@kbn/dev-utils',
to: '@kbn/sort-package-json',
exportNames: ['sortPackageJson'],
},
{
from: '@kbn/dev-utils',
to: '@kbn/dev-cli-runner',
exportNames: [
'run',
'Command',
'RunWithCommands',
'CleanupTask',
'Command',
'CommandRunFn',
'FlagOptions',
'Flags',
'RunContext',
'RunFn',
'RunOptions',
'RunWithCommands',
'RunWithCommandsOptions',
'getFlags',
'mergeFlagOptions',
],
},
{
from: '@kbn/dev-utils',
to: '@kbn/dev-cli-errors',
exportNames: ['createFailError', 'createFlagError', 'isFailError'],
},
{
from: '@kbn/dev-utils',
to: '@kbn/dev-proc-runner',
exportNames: ['withProcRunner', 'ProcRunner'],
},
{
from: '@kbn/utils',
to: '@kbn/repo-info',
exportNames: [
'REPO_ROOT',
'UPSTREAM_BRANCH',
'kibanaPackageJson',
'isKibanaDistributable',
'fromRoot',
],
},
{
from: '@kbn/presentation-util-plugin/common',
to: '@kbn/presentation-util-plugin/test_helpers',
exportNames: ['functionWrapper', 'fontStyle'],
},
{
from: '@kbn/fleet-plugin/common',
to: '@kbn/fleet-plugin/common/mocks',
exportNames: ['createFleetAuthzMock'],
},
],
],
'@kbn/disable/no_protected_eslint_disable': 'error',
'@kbn/disable/no_naked_eslint_disable': 'error',

View file

@ -0,0 +1,13 @@
---
id: kibDevDocsOpsEslintPluginTelemetry
slug: /kibana-dev-docs/ops/kbn-eslint-plugin-telemetry
title: '@kbn/eslint-plugin-telemetry'
description: Custom ESLint rules to support telemetry in the Kibana repository
tags: ['kibana', 'dev', 'contributor', 'operations', 'eslint', 'telemetry']
---
`@kbn/eslint-plugin-telemetry` is an ESLint plugin providing custom rules for validating JSXCode in the Kibana repo to make sure it can be instrumented for the purposes of telemetry.
## `@kbn/telemetry/instrumentable_elements_should_be_instrumented`
This rule warns engineers to add `data-test-subj` to instrumentable components. It currently suggests the most widely used EUI components (`EuiButton`, `EuiFieldText`, etc), but can be expanded with often-used specific components used in the Kibana repo.

View file

@ -0,0 +1,47 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Scope } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/typescript-estree';
export function checkNodeForExistingDataTestSubjProp(
node: TSESTree.JSXOpeningElement,
getScope: () => Scope.Scope
): boolean {
const hasJsxDataTestSubjProp = node.attributes.find(
(attr) => attr.type === AST_NODE_TYPES.JSXAttribute && attr.name.name === 'data-test-subj'
);
if (hasJsxDataTestSubjProp) {
return true;
}
const spreadedVariable = node.attributes.find(
(attr) => attr.type === AST_NODE_TYPES.JSXSpreadAttribute
);
if (
!spreadedVariable ||
!('argument' in spreadedVariable) ||
!('name' in spreadedVariable.argument)
) {
return false;
}
const { name } = spreadedVariable.argument; // The name of the spreaded variable
const variable = getScope().variables.find((v) => v.name === name); // the variable definition of the spreaded variable
return variable && variable.defs.length > 0
? variable.defs[0].node.init.properties.find((property: TSESTree.Property) => {
if ('value' in property.key) {
return property.key.value === 'data-test-subj';
}
return false;
})
: false;
}

View file

@ -0,0 +1,28 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { getAppName } from './get_app_name';
const SYSTEMPATH = 'systemPath';
const testMap = [
['x-pack/plugins/observability/foo/bar/baz/header_actions.tsx', 'o11y'],
['x-pack/plugins/apm/public/components/app/correlations/correlations_table.tsx', 'apm'],
['x-pack/plugins/cases/public/components/foo.tsx', 'cases'],
['packages/kbn-alerts-ui-shared/src/alert_lifecycle_status_badge/index.tsx', 'kbnAlertsUiShared'],
];
describe('Get App Name', () => {
test.each(testMap)(
'should get the responsible app name from a file path',
(path, expectedValue) => {
const appName = getAppName(`${SYSTEMPATH}/${path}`, SYSTEMPATH);
expect(appName).toBe(expectedValue);
}
);
});

View file

@ -0,0 +1,49 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { camelCase } from 'lodash';
import path from 'path';
import { getPkgDirMap } from '@kbn/repo-packages';
import { REPO_ROOT } from '@kbn/repo-info';
export function getAppName(fileName: string, cwd: string) {
const { dir } = path.parse(fileName);
const relativePathToFile = dir.replace(cwd, '');
const packageDirs = Array.from(
Array.from(getPkgDirMap(REPO_ROOT).values()).reduce((acc, currentDir) => {
const topDirectory = currentDir.normalizedRepoRelativeDir.split('/')[0];
if (topDirectory) {
acc.add(topDirectory);
}
return acc;
}, new Set<string>())
);
const relativePathArray = relativePathToFile.split('/');
const appName = camelCase(
packageDirs.reduce((acc, repoPath) => {
if (!relativePathArray[1]) return '';
if (relativePathArray[1] === 'x-pack') {
return relativePathArray[3];
}
if (relativePathArray[1].includes(repoPath)) {
return relativePathArray[2];
}
return acc;
}, '')
);
return appName === 'observability' ? 'o11y' : appName;
}

View file

@ -0,0 +1,34 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/typescript-estree';
export function getFunctionName(func: TSESTree.FunctionDeclaration | TSESTree.Node): string {
if (
'id' in func &&
func.id &&
func.type === AST_NODE_TYPES.FunctionDeclaration &&
func.id.type === AST_NODE_TYPES.Identifier
) {
return func.id.name;
}
if (
func.parent &&
(func.parent.type !== AST_NODE_TYPES.VariableDeclarator ||
func.parent.id.type !== AST_NODE_TYPES.Identifier)
) {
return getFunctionName(func.parent);
}
if (func.parent?.id && 'name' in func.parent.id) {
return func.parent.id.name;
}
return '';
}

View file

@ -0,0 +1,133 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { TSESTree } from '@typescript-eslint/typescript-estree';
import camelCase from 'lodash/camelCase';
/*
Attempts to get a string representation of the intent
out of an array of nodes.
Currently supported node types in the array:
* String literal text (JSXText)
* Translated text via <FormattedMessage> component -> uses prop `defaultMessage`
* Translated text via {i18n.translate} call -> uses passed options object key `defaultMessage`
*/
export function getIntentFromNode(originalNode: TSESTree.JSXOpeningElement): string {
const parent = originalNode.parent as TSESTree.JSXElement;
const node = Array.isArray(parent.children) ? parent.children : [];
if (node.length === 0) {
return '';
}
/*
In order to satisfy TS we need to do quite a bit of defensive programming.
This is my best attempt at providing the minimum amount of typeguards and
keeping the code readable. In the cases where types are explicitly set to
variables, it was done to help the compiler when it couldn't infer the type.
*/
return node.reduce((acc: string, currentNode) => {
switch (currentNode.type) {
case 'JSXText':
// When node is a string primitive
return `${acc}${strip(currentNode.value)}`;
case 'JSXElement':
// Determining whether node is of form `<FormattedMessage defaultMessage="message" />`
const name: TSESTree.JSXTagNameExpression = currentNode.openingElement.name;
const attributes: Array<TSESTree.JSXAttribute | TSESTree.JSXSpreadAttribute> =
currentNode.openingElement.attributes;
if (!('name' in name) || name.name !== 'FormattedMessage') {
return '';
}
const defaultMessageProp = attributes.find(
(attribute) => 'name' in attribute && attribute.name.name === 'defaultMessage'
);
if (
!defaultMessageProp ||
!('value' in defaultMessageProp) ||
!('type' in defaultMessageProp.value!) ||
defaultMessageProp.value.type !== 'Literal' ||
typeof defaultMessageProp.value.value !== 'string'
) {
return '';
}
return `${acc}${strip(defaultMessageProp.value.value)}`;
case 'JSXExpressionContainer':
// Determining whether node is of form `{i18n.translate('foo', { defaultMessage: 'message'})}`
const expression: TSESTree.JSXEmptyExpression | TSESTree.Expression =
currentNode.expression;
if (!('arguments' in expression)) {
return '';
}
const args: TSESTree.CallExpressionArgument[] = expression.arguments;
const callee: TSESTree.LeftHandSideExpression = expression.callee;
if (!('object' in callee)) {
return '';
}
const object: TSESTree.LeftHandSideExpression = callee.object;
const property: TSESTree.Expression | TSESTree.PrivateIdentifier = callee.property;
if (!('name' in object) || !('name' in property)) {
return '';
}
if (object.name !== 'i18n' || property.name !== 'translate') {
return '';
}
const callExpressionArgument: TSESTree.CallExpressionArgument | undefined = args.find(
(arg) => arg.type === 'ObjectExpression'
);
if (!callExpressionArgument || callExpressionArgument.type !== 'ObjectExpression') {
return '';
}
const defaultMessageValue: TSESTree.ObjectLiteralElement | undefined =
callExpressionArgument.properties.find(
(prop) =>
prop.type === 'Property' && 'name' in prop.key && prop.key.name === 'defaultMessage'
);
if (
!defaultMessageValue ||
!('value' in defaultMessageValue) ||
defaultMessageValue.value.type !== 'Literal' ||
typeof defaultMessageValue.value.value !== 'string'
) {
return '';
}
return `${acc}${strip(defaultMessageValue.value.value)}`;
default:
break;
}
return acc;
}, '');
}
function strip(input: string): string {
if (!input) return '';
const cleanedString = camelCase(input);
return `${cleanedString.charAt(0).toUpperCase()}${cleanedString.slice(1)}`;
}

View file

@ -0,0 +1,17 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { EventGeneratingElementsShouldBeInstrumented } from './rules/event_generating_elements_should_be_instrumented';
/**
* Custom ESLint rules, add `'@kbn/eslint-plugin-telemetry'` to your eslint config to use them
* @internal
*/
export const rules = {
event_generating_elements_should_be_instrumented: EventGeneratingElementsShouldBeInstrumented,
};

View file

@ -0,0 +1,13 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test/jest_node',
rootDir: '../..',
roots: ['<rootDir>/packages/kbn-eslint-plugin-telemetry'],
};

View file

@ -0,0 +1,6 @@
{
"type": "shared-common",
"id": "@kbn/eslint-plugin-telemetry",
"owner": "@elastic/actionable-observability",
"devOnly": true
}

View file

@ -0,0 +1,6 @@
{
"name": "@kbn/eslint-plugin-telemetry",
"private": true,
"version": "1.0.0",
"license": "SSPL-1.0 OR Elastic License 2.0"
}

View file

@ -0,0 +1,71 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { RuleTester } from 'eslint';
import {
EventGeneratingElementsShouldBeInstrumented,
EVENT_GENERATING_ELEMENTS,
} from './event_generating_elements_should_be_instrumented';
const tsTester = [
'@typescript-eslint/parser',
new RuleTester({
parser: require.resolve('@typescript-eslint/parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
ecmaFeatures: {
jsx: true,
},
},
}),
] as const;
const babelTester = [
'@babel/eslint-parser',
new RuleTester({
parser: require.resolve('@babel/eslint-parser'),
parserOptions: {
sourceType: 'module',
ecmaVersion: 2018,
requireConfigFile: false,
babelOptions: {
presets: ['@kbn/babel-preset/node_preset'],
},
},
}),
] as const;
for (const [name, tester] of [tsTester, babelTester]) {
describe(name, () => {
tester.run(
'@kbn/event_generating_elements_should_be_instrumented',
EventGeneratingElementsShouldBeInstrumented,
{
valid: EVENT_GENERATING_ELEMENTS.map((element) => ({
filename: 'foo.tsx',
code: `<${element} data-test-subj="foo" />`,
})),
invalid: EVENT_GENERATING_ELEMENTS.map((element) => ({
filename: 'foo.tsx',
code: `<${element}>Value</${element}>`,
errors: [
{
line: 1,
message: `<${element}> should have a \`data-test-subj\` for telemetry purposes. Use the autofix suggestion or add your own.`,
},
],
output: `<${element} data-test-subj="Value${element
.replace('Eui', '')
.replace('Empty', '')}">Value</${element}>`,
})),
}
);
});
}

View file

@ -0,0 +1,92 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { Rule } from 'eslint';
import { AST_NODE_TYPES, TSESTree } from '@typescript-eslint/typescript-estree';
import { checkNodeForExistingDataTestSubjProp } from '../helpers/check_node_for_existing_data_test_subj_prop';
import { getIntentFromNode } from '../helpers/get_intent_from_node';
import { getAppName } from '../helpers/get_app_name';
import { getFunctionName } from '../helpers/get_function_name';
export const EVENT_GENERATING_ELEMENTS = [
'EuiButton',
'EuiButtonEmpty',
'EuiLink',
'EuiFieldText',
'EuiFieldSearch',
'EuiFieldNumber',
'EuiSelect',
'EuiRadioGroup',
'EuiTextArea',
];
export const EventGeneratingElementsShouldBeInstrumented: Rule.RuleModule = {
meta: {
type: 'suggestion',
fixable: 'code',
},
create(context) {
const { getCwd, getFilename, getScope, report } = context;
return {
JSXIdentifier: (node: TSESTree.Node) => {
if (!('name' in node)) {
return;
}
const name = String(node.name);
const range = node.range;
const parent = node.parent;
if (
parent?.type !== AST_NODE_TYPES.JSXOpeningElement ||
!EVENT_GENERATING_ELEMENTS.includes(name)
) {
return;
}
const hasDataTestSubjProp = checkNodeForExistingDataTestSubjProp(parent, getScope);
if (hasDataTestSubjProp) {
// JSXOpeningElement already has a prop for data-test-subj. Bail.
return;
}
// Start building the suggestion.
// 1. The app name
const cwd = getCwd();
const fileName = getFilename();
const appName = getAppName(fileName, cwd);
// 2. Component name
const functionDeclaration = getScope().block as TSESTree.FunctionDeclaration;
const functionName = getFunctionName(functionDeclaration);
const componentName = `${functionName.charAt(0).toUpperCase()}${functionName.slice(1)}`;
// 3. The intention of the element (i.e. "Select date", "Submit", "Cancel")
const intent = getIntentFromNode(parent);
// 4. The element name that generates the events
const element = name.replace('Eui', '').replace('Empty', '');
const suggestion = `${appName}${componentName}${intent}${element}`; // 'o11yHeaderActionsSubmitButton'
// 6. Report feedback to engineer
report({
node: node as any,
message: `<${name}> should have a \`data-test-subj\` for telemetry purposes. Use the autofix suggestion or add your own.`,
fix(fixer) {
return fixer.insertTextAfterRange(range, ` data-test-subj="${suggestion}"`);
},
});
},
} as Rule.RuleListener;
},
};

View file

@ -0,0 +1,11 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"outDir": "target/types",
"types": ["jest", "node"],
"lib": ["es2021"]
},
"include": ["**/*.ts"],
"exclude": ["target/**/*"],
"kbn_references": ["@kbn/repo-packages", "@kbn/repo-info"]
}

View file

@ -662,6 +662,8 @@
"@kbn/eslint-plugin-eslint/*": ["packages/kbn-eslint-plugin-eslint/*"],
"@kbn/eslint-plugin-imports": ["packages/kbn-eslint-plugin-imports"],
"@kbn/eslint-plugin-imports/*": ["packages/kbn-eslint-plugin-imports/*"],
"@kbn/eslint-plugin-telemetry": ["packages/kbn-eslint-plugin-telemetry"],
"@kbn/eslint-plugin-telemetry/*": ["packages/kbn-eslint-plugin-telemetry/*"],
"@kbn/eso-plugin": ["x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin"],
"@kbn/eso-plugin/*": ["x-pack/test/encrypted_saved_objects_api_integration/plugins/api_consumer_plugin/*"],
"@kbn/event-annotation-plugin": ["src/plugins/event_annotation"],

View file

@ -171,6 +171,7 @@ export function TransactionDurationRuleType(props: Props) {
})}
>
<EuiSelect
data-test-subj="apmTransactionDurationRuleTypeSelect"
value={params.aggregationType}
options={map(TRANSACTION_ALERT_AGGREGATION_TYPES, (label, key) => {
return {

View file

@ -156,6 +156,7 @@ export function IsAboveField({
})}
>
<EuiFieldNumber
data-test-subj="apmIsAboveFieldFieldNumber"
min={0}
value={value ?? 0}
onChange={(e) => onChange(parseInt(e.target.value, 10))}

View file

@ -56,7 +56,11 @@ export function CorrelationsProgressControls({
</EuiFlexItem>
<EuiFlexItem grow={false}>
{!isRunning && (
<EuiButton size="s" onClick={onRefresh}>
<EuiButton
data-test-subj="apmCorrelationsProgressControlsRefreshButton"
size="s"
onClick={onRefresh}
>
<FormattedMessage
id="xpack.apm.correlations.refreshButtonTitle"
defaultMessage="Refresh"
@ -64,7 +68,11 @@ export function CorrelationsProgressControls({
</EuiButton>
)}
{isRunning && (
<EuiButton size="s" onClick={onCancel}>
<EuiButton
data-test-subj="apmCorrelationsProgressControlsCancelButton"
size="s"
onClick={onCancel}
>
<FormattedMessage
id="xpack.apm.correlations.cancelButtonTitle"
defaultMessage="Cancel"

View file

@ -21,5 +21,9 @@ export function DependencyOperationDetailLink(query: Query) {
query,
});
return <EuiLink href={link}>{spanName}</EuiLink>;
return (
<EuiLink data-test-subj="apmDependencyOperationDetailLink" href={link}>
{spanName}
</EuiLink>
);
}

View file

@ -26,7 +26,7 @@ export function DetailViewHeader({
return (
<EuiFlexGroup direction="column" gutterSize="s" alignItems="flexStart">
<EuiFlexItem>
<EuiLink href={backHref}>
<EuiLink data-test-subj="apmDetailViewHeaderLink" href={backHref}>
<EuiFlexGroup direction="row" gutterSize="xs">
<EuiFlexItem grow={false}>
<EuiIcon type="arrowLeft" />

View file

@ -187,7 +187,10 @@ export function ErrorSampleDetails({
</EuiFlexItem>
{isTraceExplorerEnabled && (
<EuiFlexItem grow={false}>
<EuiLink href={traceExplorerLink}>
<EuiLink
data-test-subj="apmErrorSampleDetailsLink"
href={traceExplorerLink}
>
<EuiFlexGroup alignItems="center" gutterSize="s">
<EuiFlexItem>
<EuiIcon type="apmTrace" />

View file

@ -37,6 +37,7 @@ export function HelpPopoverButton({
if (buttonTextEnabled) {
return (
<EuiButtonEmpty
data-test-subj="apmHelpPopoverButtonButton"
className="apmHelpPopover__buttonIcon"
size="s"
iconType="help"

View file

@ -98,7 +98,11 @@ export function ServerlessSummary({ serverlessId }: Props) {
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink href="https://ela.st/feedback-aws-lambda" target="_blank">
<EuiLink
data-test-subj="apmServerlessSummaryGiveFeedbackLink"
href="https://ela.st/feedback-aws-lambda"
target="_blank"
>
{i18n.translate('xpack.apm.serverlessMetrics.summary.feedback', {
defaultMessage: 'Give feedback',
})}

View file

@ -127,7 +127,10 @@ export function ServiceNodeMetrics({ serviceNodeName }: Props) {
defaultMessage="We could not identify which JVMs these metrics belong to. This is likely caused by running a version of APM Server that is older than 7.5. Upgrading to APM Server 7.5 or higher should resolve this issue. For more information on upgrading, see the {link}. As an alternative, you can use the Kibana Query bar to filter by hostname, container ID or other fields."
values={{
link: (
<EuiLink href={docLinks.links.apm.upgrading}>
<EuiLink
data-test-subj="apmServiceNodeMetricsDocumentationOfApmServerLink"
href={docLinks.links.apm.upgrading}
>
{i18n.translate(
'xpack.apm.serviceNodeMetrics.unidentifiedServiceNodesWarningDocumentationLink',
{ defaultMessage: 'documentation of APM Server' }

View file

@ -133,6 +133,7 @@ export function MobileFilters() {
style={isLarge ? {} : { width: '225px' }}
>
<EuiSelect
data-test-subj="apmMobileFiltersSelect"
fullWidth={isSmall}
isLoading={status === FETCH_STATUS.LOADING}
prepend={label}

View file

@ -133,7 +133,10 @@ export function MobileServiceOverview() {
preview. You can help us improve the experience by giving feedback. {feedbackLink}."
values={{
feedbackLink: (
<EuiLink href="https://ela.st/feedback-mobile-apm">
<EuiLink
data-test-subj="apmMobileServiceOverviewGiveFeedbackLink"
href="https://ela.st/feedback-mobile-apm"
>
{i18n.translate(
'xpack.apm.serviceOverview.mobileCallOutLink',
{
@ -320,7 +323,10 @@ export function MobileServiceOverview() {
fixedHeight={true}
showPerPageOptions={false}
link={
<EuiLink href={dependenciesLink}>
<EuiLink
data-test-subj="apmMobileServiceOverviewViewDependenciesLink"
href={dependenciesLink}
>
{i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableTabLink',
{ defaultMessage: 'View dependencies' }

View file

@ -32,6 +32,7 @@ export function EditButton({ onClick }: Props) {
)}
>
<EuiButton
data-test-subj="apmEditButtonEditGroupButton"
iconType="pencil"
onClick={() => {
dismissTour();

View file

@ -149,6 +149,7 @@ export function GroupDetails({
}
>
<EuiFieldText
data-test-subj="apmGroupDetailsFieldText"
fullWidth
value={description}
onChange={(e) => {
@ -181,7 +182,11 @@ export function GroupDetails({
</EuiFlexItem>
)}
<EuiFlexItem grow={false} style={{ marginLeft: 'auto' }}>
<EuiButtonEmpty onClick={onCloseModal} isDisabled={isLoading}>
<EuiButtonEmpty
data-test-subj="apmGroupDetailsCancelButton"
onClick={onCloseModal}
isDisabled={isLoading}
>
{i18n.translate(
'xpack.apm.serviceGroups.groupDetailsForm.cancel',
{ defaultMessage: 'Cancel' }
@ -190,6 +195,7 @@ export function GroupDetails({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmGroupDetailsSelectServicesButton"
fill
iconType="sortRight"
iconSide="right"

View file

@ -168,6 +168,7 @@ export function SelectServices({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmSelectServicesButton"
onClick={() => {
setKuery(stagedKuery);
}}
@ -244,6 +245,7 @@ export function SelectServices({
<EuiFlexItem>
<div>
<EuiButton
data-test-subj="apmSelectServicesEditGroupDetailsButton"
color="text"
onClick={onEditGroupDetailsClick}
iconType="sortLeft"
@ -257,7 +259,11 @@ export function SelectServices({
</div>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCloseModal} isDisabled={isLoading}>
<EuiButtonEmpty
data-test-subj="apmSelectServicesCancelButton"
onClick={onCloseModal}
isDisabled={isLoading}
>
{i18n.translate(
'xpack.apm.serviceGroups.selectServicesForm.cancel',
{
@ -268,6 +274,7 @@ export function SelectServices({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmSelectServicesSaveGroupButton"
fill
onClick={() => {
onSaveClick({ ...serviceGroup, kuery });

View file

@ -106,6 +106,7 @@ export function ServiceGroupsList() {
}
>
<EuiFieldText
data-test-subj="apmServiceGroupsListFieldText"
icon="search"
fullWidth
value={filter}
@ -176,6 +177,7 @@ export function ServiceGroupsList() {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink
data-test-subj="apmServiceGroupsListGiveFeedbackLink"
href="https://ela.st/feedback-service-groups"
target="_blank"
>

View file

@ -35,6 +35,7 @@ const options: Array<{
export function Sort({ type, onChange }: Props) {
return (
<EuiSelect
data-test-subj="apmSortSelect"
options={options}
value={type}
onChange={(e) => onChange(e.target.value as ServiceGroupsSortType)}

View file

@ -71,7 +71,12 @@ export function ServiceGroupsTour({
title={title}
anchorPosition={anchorPosition}
footerAction={
<EuiButtonEmpty color="text" size="xs" onClick={dismissTour}>
<EuiButtonEmpty
data-test-subj="apmServiceGroupsTourDismissButton"
color="text"
size="xs"
onClick={dismissTour}
>
{i18n.translate('xpack.apm.serviceGroups.tour.dismiss', {
defaultMessage: 'Dismiss',
})}

View file

@ -67,6 +67,7 @@ export const GenerateMap: Story<{}> = () => {
<EuiFlexGroup>
<EuiFlexItem>
<EuiButton
data-test-subj="apmGenerateMapGenerateServiceMapButton"
onClick={() => {
setElements(
generateServiceMapElements({ size, hasAnomalies: true })
@ -80,6 +81,7 @@ export const GenerateMap: Story<{}> = () => {
<EuiFlexItem>
<EuiToolTip position="right" content="Number of services">
<EuiFieldNumber
data-test-subj="apmGenerateMapFieldNumber"
placeholder="Size"
value={size}
onChange={(e) => setSize(e.target.valueAsNumber)}
@ -88,6 +90,7 @@ export const GenerateMap: Story<{}> = () => {
</EuiFlexItem>
<EuiFlexItem>
<EuiButton
data-test-subj="apmGenerateMapGetJsonButton"
onClick={() => {
setJson(JSON.stringify({ elements }, null, 2));
}}
@ -183,6 +186,7 @@ export const MapFromJSON: Story<{}> = () => {
/>
<EuiSpacer />
<EuiButton
data-test-subj="apmMapFromJSONRenderJsonButton"
onClick={() => {
updateRenderedElements();
}}

View file

@ -68,7 +68,10 @@ export function EmptyBanner() {
defaultMessage:
"We will map out connected services and external requests if we can detect them. Please make sure you're running the latest version of the APM agent.",
})}{' '}
<EuiLink href={docLinks.links.apm.supportedServiceMaps}>
<EuiLink
data-test-subj="apmEmptyBannerLearnMoreInTheDocsLink"
href={docLinks.links.apm.supportedServiceMaps}
>
{i18n.translate('xpack.apm.serviceMap.emptyBanner.docsLink', {
defaultMessage: 'Learn more in the docs',
})}

View file

@ -92,6 +92,7 @@ export function DependencyContents({
<EuiFlexItem>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click*/}
<EuiButton
data-test-subj="apmDependencyContentsDependencyDetailsButton"
href={detailsUrl}
fill={true}
onClick={() => {

View file

@ -67,6 +67,7 @@ export function EdgeContents({ elementData }: ContentsProps) {
<EuiFlexItem>
{/* eslint-disable-next-line @elastic/eui/href-or-on-click*/}
<EuiButton
data-test-subj="apmEdgeContentsExploreTracesButton"
href={url}
fill={true}
onClick={() => {

View file

@ -129,14 +129,23 @@ export function ServiceContents({
</EuiFlexItem>
<EuiSpacer size="s" />
<EuiFlexItem>
<EuiButton href={detailsUrl} fill={true}>
<EuiButton
data-test-subj="apmServiceContentsServiceDetailsButton"
href={detailsUrl}
fill={true}
>
{i18n.translate('xpack.apm.serviceMap.serviceDetailsButtonText', {
defaultMessage: 'Service Details',
})}
</EuiButton>
</EuiFlexItem>
<EuiFlexItem>
<EuiButton color="success" href={focusUrl} onClick={onFocusClick}>
<EuiButton
data-test-subj="apmServiceContentsFocusMapButton"
color="success"
href={focusUrl}
onClick={onFocusClick}
>
{i18n.translate('xpack.apm.serviceMap.focusMapButtonText', {
defaultMessage: 'Focus map',
})}

View file

@ -46,7 +46,10 @@ export function TimeoutPrompt({
function ApmSettingsDocLink() {
const { docLinks } = useApmPluginContext().core;
return (
<EuiLink href={docLinks.links.apm.kibanaSettings}>
<EuiLink
data-test-subj="apmApmSettingsDocLinkLearnMoreAboutApmSettingsInTheDocsLink"
href={docLinks.links.apm.kibanaSettings}
>
{i18n.translate('xpack.apm.serviceMap.timeoutPrompt.docsLink', {
defaultMessage: 'Learn more about APM settings in the docs',
})}

View file

@ -174,7 +174,10 @@ export function ServiceOverview() {
fixedHeight={true}
showPerPageOptions={false}
link={
<EuiLink href={dependenciesLink}>
<EuiLink
data-test-subj="apmServiceOverviewViewDependenciesLink"
href={dependenciesLink}
>
{i18n.translate(
'xpack.apm.serviceOverview.dependenciesTableTabLink',
{ defaultMessage: 'View dependencies' }

View file

@ -188,7 +188,10 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) {
{/* Cancel button */}
<EuiFlexItem grow={false}>
<LegacyAPMLink path="/settings/agent-configuration">
<EuiButtonEmpty color="primary">
<EuiButtonEmpty
data-test-subj="apmServicePageCancelButton"
color="primary"
>
{i18n.translate(
'xpack.apm.agentConfig.servicePage.cancelButton',
{ defaultMessage: 'Cancel' }
@ -200,6 +203,7 @@ export function ServicePage({ newConfig, setNewConfig, onClickNext }: Props) {
{/* Next button */}
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmServicePageNextStepButton"
type="submit"
fill
onClick={onClickNext}

View file

@ -41,6 +41,7 @@ function FormRow({
case 'text': {
return (
<EuiFieldText
data-test-subj="apmFormRowFieldText"
placeholder={setting.placeholder}
value={value || ''}
onChange={(e) => onChange(setting.key, e.target.value)}
@ -51,6 +52,7 @@ function FormRow({
case 'integer': {
return (
<EuiFieldNumber
data-test-subj="apmFormRowFieldNumber"
placeholder={setting.placeholder}
value={(value as any) || ''}
min={setting.min}
@ -93,6 +95,7 @@ function FormRow({
<EuiFlexGroup gutterSize="s">
<EuiFlexItem grow={false}>
<EuiFieldNumber
data-test-subj="apmFormRowFieldNumber"
placeholder={setting.placeholder}
value={amount}
onChange={(e) =>

View file

@ -160,7 +160,11 @@ export function SettingsPage({
</EuiFlexItem>
<EuiFlexItem grow={false}>
{!isEditMode && (
<EuiButton onClick={onClickEdit} iconType="pencil">
<EuiButton
data-test-subj="apmSettingsPageEditButton"
onClick={onClickEdit}
iconType="pencil"
>
{i18n.translate(
'xpack.apm.agentConfig.chooseService.editButton',
{ defaultMessage: 'Edit' }

View file

@ -96,6 +96,7 @@ function CreateConfigurationButton() {
}
>
<EuiButton
data-test-subj="apmAgentConfigurationButtonCreateConfigurationButton"
color="primary"
fill
iconType="plusInCircle"

View file

@ -80,6 +80,7 @@ export function AgentConfigurationList({
}
>
<EuiButton
data-test-subj="apmAgentConfigurationListCreateConfigurationButton"
color="primary"
fill
href={createAgentConfigurationHref}
@ -158,6 +159,7 @@ export function AgentConfigurationList({
sortable: true,
render: (_, config: Config) => (
<EuiButtonEmpty
data-test-subj="apmColumnsButton"
flush="left"
size="s"
color="primary"

View file

@ -79,6 +79,7 @@ export function getInstanceColumns(
values={{
seeDocs: (
<EuiLink
data-test-subj="apmGetInstanceColumnsConfigurationOptionsLink"
href={`${agentDocsPageUrl}${
!isOpenTelemetryAgentName(agentName)
? 'configuration.html#service-node-name'

View file

@ -133,6 +133,7 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
error={formError}
>
<EuiFieldText
data-test-subj="apmCreateAgentKeyFlyoutFieldText"
name="name"
placeholder={i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.namePlaceholder',
@ -208,7 +209,10 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel}>
<EuiButtonEmpty
data-test-subj="apmCreateAgentKeyFlyoutCancelButton"
onClick={onCancel}
>
{i18n.translate(
'xpack.apm.settings.agentKeys.createKeyFlyout.cancelButton',
{
@ -219,6 +223,7 @@ export function CreateAgentKeyFlyout({ onCancel, onSuccess, onError }: Props) {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmCreateAgentKeyFlyoutButton"
fill={true}
onClick={createAgentKey}
type="submit"

View file

@ -44,6 +44,7 @@ export function AgentKeyCallOut({ name, token }: Props) {
)}
</p>
<EuiFieldText
data-test-subj="apmAgentKeyCallOutFieldText"
readOnly
value={token}
aria-label={i18n.translate(

View file

@ -88,6 +88,7 @@ export function AgentKeys() {
{areApiKeysEnabled && canManage && !isEmpty(agentKeys) && (
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmAgentKeysCreateApmAgentKeyButton"
onClick={() => setIsFlyoutVisible(true)}
fill={true}
iconType="plusInCircle"
@ -238,6 +239,7 @@ function AgentKeysContent({
}
actions={
<EuiButton
data-test-subj="apmAgentKeysContentCreateApmAgentKeyButton"
onClick={onCreateAgentClick}
fill={true}
iconType="plusInCircle"

View file

@ -36,6 +36,7 @@ export function ApiKeysNotEnabled() {
values={{
link: (
<EuiLink
data-test-subj="apmApiKeysNotEnabledDocsLink"
href={docLinks?.links.security.apiKeyServiceSettings}
target="_blank"
>

View file

@ -136,7 +136,11 @@ export function AddEnvironments({
</EuiFormRow>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty aria-label="Cancel" onClick={onCancel}>
<EuiButtonEmpty
data-test-subj="apmAddEnvironmentsCancelButton"
aria-label="Cancel"
onClick={onCancel}
>
{i18n.translate(
'xpack.apm.settings.anomalyDetection.addEnvironments.cancelButtonText',
{
@ -147,6 +151,7 @@ export function AddEnvironments({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmAddEnvironmentsCreateJobsButton"
isLoading={isSaving}
isDisabled={isSaving || selectedOptions.length === 0}
fill

View file

@ -231,7 +231,11 @@ export function JobsList({
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton href={mlManageJobsHref} color="primary">
<EuiButton
data-test-subj="apmJobsListManageJobsButton"
href={mlManageJobsHref}
color="primary"
>
{i18n.translate(
'xpack.apm.settings.anomalyDetection.jobList.manageMlJobsButtonText',
{
@ -241,7 +245,12 @@ export function JobsList({
</EuiButton>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill iconType="plusInCircle" onClick={onAddEnvironments}>
<EuiButton
data-test-subj="apmJobsListCreateJobButton"
fill
iconType="plusInCircle"
onClick={onAddEnvironments}
>
{i18n.translate(
'xpack.apm.settings.anomalyDetection.jobList.addEnvironments',
{

View file

@ -242,6 +242,7 @@ export function ApmIndices() {
fullWidth
>
<EuiFieldText
data-test-subj="apmApmIndicesFieldText"
disabled={!canSave}
fullWidth
name={configurationName}
@ -255,7 +256,10 @@ export function ApmIndices() {
<EuiSpacer />
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={refetch}>
<EuiButtonEmpty
data-test-subj="apmApmIndicesCancelButton"
onClick={refetch}
>
{i18n.translate(
'xpack.apm.settings.apmIndices.cancelButton',
{ defaultMessage: 'Cancel' }
@ -276,6 +280,7 @@ export function ApmIndices() {
}
>
<EuiButton
data-test-subj="apmApmIndicesApplyChangesButton"
fill
onClick={handleApplyChangesEvent}
isLoading={isSaving}

View file

@ -53,7 +53,11 @@ export function BottomBarActions({
<EuiFlexItem grow={false}>
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty color="ghost" onClick={onDiscardChanges}>
<EuiButtonEmpty
data-test-subj="apmBottomBarActionsDiscardChangesButton"
color="ghost"
onClick={onDiscardChanges}
>
{i18n.translate(
'xpack.apm.bottomBarActions.discardChangesButton',
{
@ -64,6 +68,7 @@ export function BottomBarActions({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmBottomBarActionsButton"
onClick={onSave}
fill
isLoading={isLoading}

View file

@ -25,6 +25,7 @@ export function DeleteButton({ onDelete, customLinkId }: Props) {
return (
<EuiButtonEmpty
data-test-subj="apmDeleteButtonDeleteButton"
color="danger"
isLoading={isDeleting}
iconSide="right"

View file

@ -15,5 +15,12 @@ interface Props {
export function Documentation({ label }: Props) {
const { docLinks } = useApmPluginContext().core;
return <EuiLink href={docLinks.links.apm.customLinks}>{label}</EuiLink>;
return (
<EuiLink
data-test-subj="apmCustomLinksDocumentationLink"
href={docLinks.links.apm.customLinks}
>
{label}
</EuiLink>
);
}

View file

@ -137,6 +137,7 @@ export function FiltersSection({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="apmCustomLinkFiltersSectionButton"
iconType="trash"
onClick={() => onRemoveFilter(idx)}
disabled={!value && !key && filters.length === 1}
@ -166,6 +167,7 @@ function AddFilterButton({
}) {
return (
<EuiButtonEmpty
data-test-subj="apmCustomLinkAddFilterButtonAddAnotherFilterButton"
iconType="plusInCircle"
onClick={onClick}
disabled={isDisabled}

View file

@ -33,7 +33,12 @@ export function FlyoutFooter({
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty iconType="cross" onClick={onClose} flush="left">
<EuiButtonEmpty
data-test-subj="apmCustomLinkFlyoutFooterCloseButton"
iconType="cross"
onClick={onClose}
flush="left"
>
{i18n.translate('xpack.apm.settings.customLink.flyout.close', {
defaultMessage: 'Close',
})}
@ -44,6 +49,7 @@ export function FlyoutFooter({
<DeleteButton customLinkId={customLinkId} onDelete={onDelete} />
)}
<EuiButton
data-test-subj="apmCustomLinkFlyoutFooterSaveButton"
form="customLink_form"
fill
type="submit"

View file

@ -99,6 +99,7 @@ export function CustomLinkTable({ items = [], onCustomLinkSelected }: Props) {
<>
<EuiSpacer size="m" />
<EuiFieldSearch
data-test-subj="apmCustomLinkTableFieldSearch"
fullWidth
onChange={(e) => setSearchTerm(e.target.value)}
placeholder={i18n.translate(

View file

@ -39,6 +39,7 @@ export function EmptyPrompt({
values={{
customLinkDocLinkText: (
<EuiLink
data-test-subj="apmCustomLinkEmptyPromptDocsLink"
target="_blank"
href={docLinks.links.apm.customLinks}
>

View file

@ -84,6 +84,7 @@ export function GeneralSettings() {
values={{
link: (
<EuiLink
data-test-subj="apmGeneralSettingsKibanaAdvancedSettingsLink"
href={application.getUrlForApp('management', {
path: `/kibana/settings?query=category:(observability)`,
})}

View file

@ -17,7 +17,10 @@ export function CardFooterContent() {
return (
<div>
<EuiButton href={fleetCloudAgentPolicyHref}>
<EuiButton
data-test-subj="apmCardFooterContentViewTheApmIntegrationInFleetButton"
href={fleetCloudAgentPolicyHref}
>
{i18n.translate(
'xpack.apm.settings.schema.success.viewIntegrationInFleet.buttonText',
{ defaultMessage: 'View the APM integration in Fleet' }

View file

@ -35,7 +35,10 @@ export function UpgradeAvailableCard({
defaultMessage="Even though your APM integration is setup, a new version of the APM integration is available for upgrade with your package policy. {upgradePackagePolicyLink} to get the most out of your setup."
values={{
upgradePackagePolicyLink: (
<EuiLink href={upgradeApmPackagePolicyHref}>
<EuiLink
data-test-subj="apmUpgradeAvailableCardUpgradeYourApmIntegrationLink"
href={upgradeApmPackagePolicyHref}
>
{i18n.translate(
'xpack.apm.settings.schema.upgradeAvailable.upgradePackagePolicyLink',
{ defaultMessage: 'Upgrade your APM integration' }

View file

@ -162,6 +162,7 @@ export function SchemaOverview({
})}
>
<EuiButton
data-test-subj="apmSchemaOverviewSwitchToElasticAgentButton"
fill
isLoading={isLoadingConfirmation}
isDisabled={isDisabled}
@ -210,7 +211,11 @@ export function SchemaOverviewHeading() {
</strong>
),
elasticAgentDocLink: (
<EuiLink target="_blank" href={docLinks.links.apm.elasticAgent}>
<EuiLink
data-test-subj="apmSchemaOverviewHeadingElasticAgentLink"
target="_blank"
href={docLinks.links.apm.elasticAgent}
>
{i18n.translate(
'xpack.apm.settings.schema.descriptionText.elasticAgentDocLinkText',
{ defaultMessage: 'Elastic Agent' }

View file

@ -123,7 +123,10 @@ export function StorageExplorer() {
defaultMessage="Enable progressive loading of data and optimized sorting for services list in {kibanaAdvancedSettingsLink}."
values={{
kibanaAdvancedSettingsLink: (
<EuiLink href={getKibanaAdvancedSettingsHref(core)}>
<EuiLink
data-test-subj="apmStorageExplorerKibanaAdvancedSettingsLink"
href={getKibanaAdvancedSettingsHref(core)}
>
{i18n.translate(
'xpack.apm.storageExplorer.longLoadingTimeCalloutLink',
{
@ -136,6 +139,7 @@ export function StorageExplorer() {
/>
</p>
<EuiButton
data-test-subj="apmStorageExplorerButton"
onClick={() =>
setCalloutDismissed({
...calloutDismissed,
@ -171,6 +175,7 @@ export function StorageExplorer() {
)}
</p>
<EuiButton
data-test-subj="apmStorageExplorerButton"
onClick={() =>
setCalloutDismissed({
...calloutDismissed,

View file

@ -170,7 +170,11 @@ export function TipsAndResources() {
title={title}
description={description}
footer={
<EuiButton href={href} target="_blank">
<EuiButton
data-test-subj="apmTipsAndResourcesLearnMoreButton"
href={href}
target="_blank"
>
{i18n.translate(
'xpack.apm.storageExplorer.resources.learnMoreButton',
{

View file

@ -169,7 +169,10 @@ export function StorageDetailsPerService({
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiLink href={serviceOverviewLink}>
<EuiLink
data-test-subj="apmStorageDetailsPerServiceGoToServiceOverviewLink"
href={serviceOverviewLink}
>
{i18n.translate(
'xpack.apm.storageExplorer.serviceDetails.serviceOverviewLink',
{

View file

@ -186,7 +186,10 @@ export function SummaryStats() {
<EuiFlexItem grow={false}>
<EuiFlexGroup direction="column" justifyContent="spaceBetween">
<EuiFlexItem>
<EuiLink href={serviceInventoryLink}>
<EuiLink
data-test-subj="apmSummaryStatsGoToServiceInventoryLink"
href={serviceInventoryLink}
>
{i18n.translate(
'xpack.apm.storageExplorer.summary.serviceInventoryLink',
{
@ -196,7 +199,10 @@ export function SummaryStats() {
</EuiLink>
</EuiFlexItem>
<EuiFlexItem>
<EuiLink href={getIndexManagementHref(core)}>
<EuiLink
data-test-subj="apmSummaryStatsGoToIndexManagementLink"
href={getIndexManagementHref(core)}
>
{i18n.translate(
'xpack.apm.storageExplorer.summary.indexManagementLink',
{

View file

@ -162,6 +162,7 @@ export function TraceSearchBox({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect
data-test-subj="apmTraceSearchBoxSelect"
id="select-query-language"
value={query.type}
onChange={(e) => {
@ -189,6 +190,7 @@ export function TraceSearchBox({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmTraceSearchBoxSearchButton"
isLoading={loading}
onClick={() => {
onQueryCommit();

View file

@ -25,6 +25,7 @@ function FullTraceButton({
}) {
return (
<EuiButton
data-test-subj="apmFullTraceButtonViewFullTraceButton"
fill
iconType="apmTrace"
isLoading={isLoading}

View file

@ -129,6 +129,7 @@ export function Waterfall({
<div>
<div style={{ display: 'flex' }}>
<EuiButtonEmpty
data-test-subj="apmWaterfallButton"
style={{ zIndex: 3, position: 'absolute' }}
iconType={isAccordionOpen ? 'fold' : 'unfold'}
onClick={() => {

View file

@ -48,6 +48,7 @@ export function TruncateHeightSection({ children, previewHeight }: Props) {
{showToggle ? (
<ToggleButtonContainer>
<EuiLink
data-test-subj="apmTruncateHeightSectionLink"
onClick={() => {
setIsOpen(!isOpen);
}}

View file

@ -33,7 +33,10 @@ export function DroppedSpansWarning({
values: { dropped },
}
)}{' '}
<EuiLink href={docLinks.links.apm.droppedTransactionSpans}>
<EuiLink
data-test-subj="apmDroppedSpansWarningLearnMoreAboutDroppedSpansLink"
href={docLinks.links.apm.droppedTransactionSpans}
>
{i18n.translate(
'xpack.apm.transactionDetails.transFlyout.callout.learnMoreAboutDroppedSpansLinkText',
{

View file

@ -64,6 +64,7 @@ export function EditDiscoveryRule({
}}
>
<EuiSelect
data-test-subj="apmEditDiscoveryRuleSelect"
options={operationTypes.map((item) => ({
text: item.operation.label,
value: item.operation.value,
@ -145,6 +146,7 @@ export function EditDiscoveryRule({
)}
>
<EuiFieldText
data-test-subj="apmEditDiscoveryRuleFieldText"
fullWidth
value={probe}
onChange={(e) => onChangeProbe(e.target.value)}
@ -156,10 +158,16 @@ export function EditDiscoveryRule({
)}
<EuiFlexGroup justifyContent="flexEnd">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onCancel}>Cancel</EuiButtonEmpty>
<EuiButtonEmpty
data-test-subj="apmEditDiscoveryRuleCancelButton"
onClick={onCancel}
>
Cancel
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmEditDiscoveryRuleButton"
onClick={onSubmit}
fill
disabled={type === DISCOVERY_RULE_TYPE_ALL ? false : probe === ''}

View file

@ -68,6 +68,7 @@ export function JavaAgentVersionInput({ isValid, version, onChange }: Props) {
values={{
versionLink: (
<EuiLink
data-test-subj="apmJavaAgentVersionInputVersionLink"
href={`${services.docLinks?.ELASTIC_WEBSITE_URL}/guide/en/apm/agent/java/current/release-notes.html`}
target="_blank"
>

View file

@ -147,6 +147,7 @@ export function RuntimeAttachment({
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton
data-test-subj="apmRuntimeAttachmentAddRuleButton"
iconType="plusInCircle"
disabled={editDiscoveryRuleId !== null}
onClick={onAddRule}

View file

@ -32,7 +32,11 @@ function StepComponent() {
</EuiText>
<EuiSpacer size="m" />
<EuiButton fill href={installApmAgentLink}>
<EuiButton
data-test-subj="apmStepComponentInstallApmAgentButton"
fill
href={installApmAgentLink}
>
{i18n.translate(
'xpack.apm.fleetIntegration.enrollmentFlyout.installApmAgentButtonText',
{ defaultMessage: 'Install APM Agent' }

View file

@ -77,7 +77,11 @@ export function getTailSamplingSettings(docsLinks?: string): SettingsRow[] {
defaultMessage="Learn more about tail sampling policies in our {link}."
values={{
link: (
<EuiLink href={docsLinks} target="_blank">
<EuiLink
data-test-subj="apmGetTailSamplingSettingsDocsLink"
href={docsLinks}
target="_blank"
>
{i18n.translate(
'xpack.apm.fleet_integration.settings.tailSamplingDocsHelpTextLink',
{

View file

@ -167,6 +167,7 @@ function AdvancedOptions({ children }: { children: React.ReactNode }) {
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
data-test-subj="apmAdvancedOptionsAdvancedOptionsButton"
iconType={isOpen ? 'arrowDown' : 'arrowRight'}
onClick={() => {
setIsOpen((state) => !state);

View file

@ -27,7 +27,11 @@ export function Labs() {
return (
<>
<EuiButtonEmpty color="text" onClick={toggleFlyoutVisibility}>
<EuiButtonEmpty
data-test-subj="apmLabsLabsButton"
color="text"
onClick={toggleFlyoutVisibility}
>
{i18n.translate('xpack.apm.labs', { defaultMessage: 'Labs' })}
</EuiButtonEmpty>
{isOpen && <LabsFlyout onClose={toggleFlyoutVisibility} />}

View file

@ -133,14 +133,22 @@ export function LabsFlyout({ onClose }: Props) {
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={handelCancel}>
<EuiButtonEmpty
data-test-subj="apmLabsFlyoutCancelButton"
onClick={handelCancel}
>
{i18n.translate('xpack.apm.labs.cancel', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiButton fill isLoading={isSaving} onClick={handleSave}>
<EuiButton
data-test-subj="apmLabsFlyoutReloadToApplyChangesButton"
fill
isLoading={isSaving}
onClick={handleSave}
>
{i18n.translate('xpack.apm.labs.reload', {
defaultMessage: 'Reload to apply changes',
})}

View file

@ -53,7 +53,11 @@ export const storageExplorer = {
</EuiFlexGroup>
),
rightSideItems: [
<EuiLink href={getStorageExplorerFeedbackHref()} target="_blank">
<EuiLink
data-test-subj="apmGiveFeedbackLink"
href={getStorageExplorerFeedbackHref()}
target="_blank"
>
{i18n.translate(
'xpack.apm.views.storageExplorer.giveFeedback',
{

View file

@ -85,7 +85,11 @@ export function AnalyzeDataButton() {
'Explore Data allows you to select and filter result data in any dimension, and look for the cause or impact of performance problems',
})}
>
<EuiButtonEmpty href={href} iconType="visBarVerticalStacked">
<EuiButtonEmpty
data-test-subj="apmAnalyzeDataButtonExploreDataButton"
href={href}
iconType="visBarVerticalStacked"
>
{i18n.translate('xpack.apm.analyzeDataButton.label', {
defaultMessage: 'Explore data',
})}

View file

@ -102,6 +102,7 @@ export function LatencyChart({ height, kuery }: Props) {
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSelect
data-test-subj="apmLatencyChartSelect"
compressed
prepend={i18n.translate(
'xpack.apm.serviceOverview.latencyChartTitle.prepend',

View file

@ -10,7 +10,10 @@ import { EuiLink } from '@elastic/eui';
export function DependenciesTableServiceMapLink({ href }: { href: string }) {
return (
<EuiLink href={href}>
<EuiLink
data-test-subj="apmDependenciesTableServiceMapLinkViewServiceMapLink"
href={href}
>
{i18n.translate('xpack.apm.dependenciesTable.serviceMapLinkText', {
defaultMessage: 'View service map',
})}

View file

@ -49,7 +49,11 @@ export function LicensePrompt({
titleElement="h2"
description={<EuiTextColor color="subdued">{text}</EuiTextColor>}
footer={
<EuiButton fill={true} href={licensePageUrl}>
<EuiButton
data-test-subj="apmLicensePromptStartTrialButton"
fill={true}
href={licensePageUrl}
>
{i18n.translate('xpack.apm.license.button', {
defaultMessage: 'Start trial',
})}

View file

@ -100,5 +100,7 @@ export function LegacyAPMLink({
const href = getLegacyApmHref({ basePath, path, search, query: mergedQuery });
return <EuiLink {...rest} href={href} />;
return (
<EuiLink data-test-subj="apmLegacyAPMLinkLink" {...rest} href={href} />
);
}

View file

@ -27,5 +27,11 @@ export function ErrorOverviewLink({ serviceName, query, ...rest }: Props) {
query,
});
return <EuiLink href={errorOverviewLink} {...rest} />;
return (
<EuiLink
data-test-subj="apmErrorOverviewLinkLink"
href={errorOverviewLink}
{...rest}
/>
);
}

View file

@ -22,5 +22,7 @@ interface ServiceMapLinkProps extends APMLinkExtendProps {
export function ServiceMapLink({ serviceName, ...rest }: ServiceMapLinkProps) {
const href = useServiceMapHref(serviceName);
return <EuiLink href={href} {...rest} />;
return (
<EuiLink data-test-subj="apmServiceMapLinkLink" href={href} {...rest} />
);
}

View file

@ -46,5 +46,11 @@ export function ServiceNodeMetricOverviewLink({
serviceName,
serviceNodeName,
});
return <EuiLink href={href} {...rest} />;
return (
<EuiLink
data-test-subj="apmServiceNodeMetricOverviewLinkLink"
href={href}
{...rest}
/>
);
}

View file

@ -50,5 +50,11 @@ export function ServiceOrTransactionsOverviewLink({
environment,
transactionType,
});
return <EuiLink href={href} {...rest} />;
return (
<EuiLink
data-test-subj="apmServiceOrTransactionsOverviewLinkLink"
href={href}
{...rest}
/>
);
}

View file

@ -81,7 +81,13 @@ export function TransactionDetailLink({
return (
<TruncateWithTooltip
text={transactionName}
content={<EuiLink href={href} {...rest} />}
content={
<EuiLink
data-test-subj="apmTransactionDetailLinkLink"
href={href}
{...rest}
/>
}
/>
);
}

View file

@ -48,5 +48,11 @@ export function TransactionOverviewLink({
latencyAggregationType,
transactionType,
});
return <EuiLink href={href} {...rest} />;
return (
<EuiLink
data-test-subj="apmTransactionOverviewLinkLink"
href={href}
{...rest}
/>
);
}

View file

@ -69,5 +69,5 @@ export function DiscoverLink({ query = {}, ...rest }: Props) {
location,
});
return <EuiLink {...rest} href={href} />;
return <EuiLink data-test-subj="apmDiscoverLinkLink" {...rest} href={href} />;
}

View file

@ -32,7 +32,7 @@ export function ElasticDocsLink({ section, path, children, ...rest }: Props) {
return typeof children === 'function' ? (
children(href)
) : (
<EuiLink href={href} {...rest}>
<EuiLink data-test-subj="apmElasticDocsLinkLink" href={href} {...rest}>
{children}
</EuiLink>
);

View file

@ -48,5 +48,5 @@ export const getInfraHref = ({
export function InfraLink({ app, path, query = {}, ...rest }: Props) {
const { core } = useApmPluginContext();
const href = getInfraHref({ app, basePath: core.http.basePath, query, path });
return <EuiLink {...rest} href={href} />;
return <EuiLink data-test-subj="apmInfraLinkLink" {...rest} href={href} />;
}

View file

@ -23,6 +23,7 @@ export function MLExplorerLink({ jobId, external, children }: Props) {
return (
<EuiLink
data-test-subj="apmMLExplorerLinkLink"
children={children}
href={href}
external={external}

View file

@ -22,6 +22,7 @@ export function MLManageJobsLink({ children, external, jobId }: Props) {
return (
<EuiLink
data-test-subj="apmMLManageJobsLinkLink"
children={children}
href={mlADLink}
external={external}

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