diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts index 9085ae07bbe3e..145901509d1c5 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.test.ts @@ -196,6 +196,24 @@ describe('IndexPattern', () => { }); }); + describe('getFormatterForField', () => { + test('should return the default one for empty objects', () => { + indexPattern.setFieldFormat('scriptedFieldWithEmptyFormatter', {}); + expect( + indexPattern.getFormatterForField({ + name: 'scriptedFieldWithEmptyFormatter', + type: 'number', + esTypes: ['long'], + }) + ).toEqual( + expect.objectContaining({ + convert: expect.any(Function), + getConverterFor: expect.any(Function), + }) + ); + }); + }); + describe('toSpec', () => { test('should match snapshot', () => { const formatter = { diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts index a0f27078543a9..4508d7b1d9082 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_pattern.ts @@ -291,15 +291,15 @@ export class IndexPattern implements IIndexPattern { getFormatterForField( field: IndexPatternField | IndexPatternField['spec'] | IFieldType ): FieldFormat { - const formatSpec = this.fieldFormatMap[field.name]; - if (formatSpec) { - return this.fieldFormats.getInstance(formatSpec.id, formatSpec.params); - } else { - return this.fieldFormats.getDefaultInstance( - field.type as KBN_FIELD_TYPES, - field.esTypes as ES_FIELD_TYPES[] - ); + const fieldFormat = this.getFormatterForFieldNoDefault(field.name); + if (fieldFormat) { + return fieldFormat; } + + return this.fieldFormats.getDefaultInstance( + field.type as KBN_FIELD_TYPES, + field.esTypes as ES_FIELD_TYPES[] + ); } /** @@ -308,7 +308,7 @@ export class IndexPattern implements IIndexPattern { */ getFormatterForFieldNoDefault(fieldname: string) { const formatSpec = this.fieldFormatMap[fieldname]; - if (formatSpec) { + if (formatSpec?.id) { return this.fieldFormats.getInstance(formatSpec.id, formatSpec.params); } } diff --git a/x-pack/examples/alerting_example/public/alert_types/astros.tsx b/x-pack/examples/alerting_example/public/alert_types/astros.tsx index 73c7dfea1263b..54f989b93e22f 100644 --- a/x-pack/examples/alerting_example/public/alert_types/astros.tsx +++ b/x-pack/examples/alerting_example/public/alert_types/astros.tsx @@ -127,9 +127,9 @@ export const PeopleinSpaceExpression: React.FunctionComponent - errs.map((e) => ( -

+ Object.entries(errors).map(([field, errs]: [string, string[]], fieldIndex) => + errs.map((e, index) => ( +

{field}:`: ${errs}`

)) diff --git a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts index bb1cb0d97689b..d02406a23045e 100644 --- a/x-pack/examples/alerting_example/server/alert_types/always_firing.ts +++ b/x-pack/examples/alerting_example/server/alert_types/always_firing.ts @@ -5,25 +5,31 @@ */ import uuid from 'uuid'; -import { range } from 'lodash'; +import { range, random } from 'lodash'; import { AlertType } from '../../../../plugins/alerts/server'; import { DEFAULT_INSTANCES_TO_GENERATE, ALERTING_EXAMPLE_APP_ID } from '../../common/constants'; +const ACTION_GROUPS = [ + { id: 'small', name: 'small' }, + { id: 'medium', name: 'medium' }, + { id: 'large', name: 'large' }, +]; + export const alertType: AlertType = { id: 'example.always-firing', name: 'Always firing', - actionGroups: [{ id: 'default', name: 'default' }], - defaultActionGroupId: 'default', + actionGroups: ACTION_GROUPS, + defaultActionGroupId: 'small', async executor({ services, params: { instances = DEFAULT_INSTANCES_TO_GENERATE }, state }) { const count = (state.count ?? 0) + 1; range(instances) - .map(() => ({ id: uuid.v4() })) - .forEach((instance: { id: string }) => { + .map(() => ({ id: uuid.v4(), tshirtSize: ACTION_GROUPS[random(0, 2)].id! })) + .forEach((instance: { id: string; tshirtSize: string }) => { services .alertInstanceFactory(instance.id) .replaceState({ triggerdOnCycle: count }) - .scheduleActions('default'); + .scheduleActions(instance.tshirtSize); }); return { diff --git a/x-pack/plugins/apm/e2e/cypress/integration/apm.feature b/x-pack/plugins/apm/e2e/cypress/integration/apm.feature index 285615108266b..494a6b5fadb5b 100644 --- a/x-pack/plugins/apm/e2e/cypress/integration/apm.feature +++ b/x-pack/plugins/apm/e2e/cypress/integration/apm.feature @@ -3,5 +3,4 @@ Feature: APM Scenario: Transaction duration charts Given a user browses the APM UI application When the user inspects the opbeans-node service - Then should redirect to correct path with correct params - And should have correct y-axis ticks + Then should redirect to correct path with correct params \ No newline at end of file diff --git a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts index 50c620dca9ddf..42c2bc7ffd318 100644 --- a/x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts +++ b/x-pack/plugins/apm/e2e/cypress/support/step_definitions/apm.ts @@ -29,16 +29,3 @@ Then(`should redirect to correct path with correct params`, () => { cy.url().should('contain', `/app/apm/services/opbeans-node/transactions`); cy.url().should('contain', `transactionType=request`); }); - -Then(`should have correct y-axis ticks`, () => { - const yAxisTick = - '[data-cy=transaction-duration-charts] .rv-xy-plot__axis--vertical .rv-xy-plot__axis__tick__text'; - - // wait for all loading to finish - cy.get('kbnLoadingIndicator').should('not.be.visible'); - - // literal assertions because snapshot() doesn't retry - cy.get(yAxisTick).eq(2).should('have.text', '55 ms'); - cy.get(yAxisTick).eq(1).should('have.text', '28 ms'); - cy.get(yAxisTick).eq(0).should('have.text', '0 ms'); -}); diff --git a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx index e17dd9a9eb038..a17bf7e93e466 100644 --- a/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/ErrorGroupDetails/Distribution/index.tsx @@ -4,31 +4,24 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + Axis, + Chart, + HistogramBarSeries, + niceTimeFormatter, + Position, + ScaleType, + Settings, + SettingsSpec, + TooltipValue, +} from '@elastic/charts'; import { EuiTitle } from '@elastic/eui'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import numeral from '@elastic/numeral'; -import { i18n } from '@kbn/i18n'; import d3 from 'd3'; -import { scaleUtc } from 'd3-scale'; -import { mean } from 'lodash'; import React from 'react'; import { asRelativeDateTimeRange } from '../../../../../common/utils/formatters'; -import { getTimezoneOffsetInMs } from '../../../shared/charts/CustomPlot/getTimezoneOffsetInMs'; -// @ts-expect-error -import Histogram from '../../../shared/charts/Histogram'; -import { EmptyMessage } from '../../../shared/EmptyMessage'; - -interface IBucket { - key: number; - count: number | undefined; -} - -// TODO: cleanup duplication of this in distribution/get_distribution.ts (ErrorDistributionAPIResponse) and transactions/distribution/index.ts (TransactionDistributionAPIResponse) -interface IDistribution { - noHits: boolean; - buckets: IBucket[]; - bucketSize: number; -} +// eslint-disable-next-line @kbn/eslint/no-restricted-paths +import type { ErrorDistributionAPIResponse } from '../../../../../server/lib/errors/distribution/get_distribution'; +import { useTheme } from '../../../../hooks/useTheme'; interface FormattedBucket { x0: number; @@ -37,13 +30,9 @@ interface FormattedBucket { } export function getFormattedBuckets( - buckets: IBucket[], + buckets: ErrorDistributionAPIResponse['buckets'], bucketSize: number -): FormattedBucket[] | null { - if (!buckets) { - return null; - } - +): FormattedBucket[] { return buckets.map(({ count, key }) => { return { x0: key, @@ -54,76 +43,66 @@ export function getFormattedBuckets( } interface Props { - distribution: IDistribution; + distribution: ErrorDistributionAPIResponse; title: React.ReactNode; } -const tooltipHeader = (bucket: FormattedBucket) => - asRelativeDateTimeRange(bucket.x0, bucket.x); - export function ErrorDistribution({ distribution, title }: Props) { + const theme = useTheme(); const buckets = getFormattedBuckets( distribution.buckets, distribution.bucketSize ); - if (!buckets) { - return ( - - ); - } - - const averageValue = mean(buckets.map((bucket) => bucket.y)) || 0; const xMin = d3.min(buckets, (d) => d.x0); - const xMax = d3.max(buckets, (d) => d.x); - const tickFormat = scaleUtc().domain([xMin, xMax]).tickFormat(); + const xMax = d3.max(buckets, (d) => d.x0); + + const xFormatter = niceTimeFormatter([xMin, xMax]); + + const tooltipProps: SettingsSpec['tooltip'] = { + headerFormatter: (tooltip: TooltipValue) => { + const serie = buckets.find((bucket) => bucket.x0 === tooltip.value); + if (serie) { + return asRelativeDateTimeRange(serie.x0, serie.x); + } + return `${tooltip.value}`; + }, + }; return (
{title} - bucket.x} - xType="time-utc" - formatX={(value: Date) => { - const time = value.getTime(); - return tickFormat(new Date(time - getTimezoneOffsetInMs(time))); - }} - buckets={buckets} - bucketSize={distribution.bucketSize} - formatYShort={(value: number) => - i18n.translate('xpack.apm.errorGroupDetails.occurrencesShortLabel', { - defaultMessage: '{occCount} occ.', - values: { occCount: value }, - }) - } - formatYLong={(value: number) => - i18n.translate('xpack.apm.errorGroupDetails.occurrencesLongLabel', { - defaultMessage: - '{occCount} {occCount, plural, one {occurrence} other {occurrences}}', - values: { occCount: value }, - }) - } - legends={[ - { - color: theme.euiColorVis1, - // 0a abbreviates large whole numbers with metric prefixes like: 1000 = 1k, 32000 = 32k, 1000000 = 1m - legendValue: numeral(averageValue).format('0a'), - title: i18n.translate('xpack.apm.errorGroupDetails.avgLabel', { - defaultMessage: 'Avg.', - }), - legendClickDisabled: true, - }, - ]} - /> +
+ + + + + + +
); } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx index 67125d41635a9..bf1bda793179f 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/Distribution/index.tsx @@ -4,22 +4,37 @@ * you may not use this file except in compliance with the Elastic License. */ +import { + Axis, + Chart, + ElementClickListener, + GeometryValue, + HistogramBarSeries, + Position, + RectAnnotation, + ScaleType, + Settings, + SettingsSpec, + TooltipValue, + XYChartSeriesIdentifier, +} from '@elastic/charts'; import { EuiIconTip, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import d3 from 'd3'; import { isEmpty } from 'lodash'; import React, { useCallback } from 'react'; import { ValuesType } from 'utility-types'; +import { useTheme } from '../../../../../../observability/public'; import { getDurationFormatter } from '../../../../../common/utils/formatters'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TransactionDistributionAPIResponse } from '../../../../../server/lib/transactions/distribution'; +import type { TransactionDistributionAPIResponse } from '../../../../../server/lib/transactions/distribution'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { DistributionBucket } from '../../../../../server/lib/transactions/distribution/get_buckets'; +import type { DistributionBucket } from '../../../../../server/lib/transactions/distribution/get_buckets'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; -// @ts-expect-error -import Histogram from '../../../shared/charts/Histogram'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; +import { unit } from '../../../../style/variables'; +import { ChartContainer } from '../../../shared/charts/chart_container'; import { EmptyMessage } from '../../../shared/EmptyMessage'; -import { LoadingStatePrompt } from '../../../shared/LoadingStatePrompt'; interface IChartPoint { x0: number; @@ -31,10 +46,10 @@ interface IChartPoint { } export function getFormattedBuckets( - buckets: DistributionBucket[], - bucketSize: number + buckets?: DistributionBucket[], + bucketSize?: number ) { - if (!buckets) { + if (!buckets || !bucketSize) { return []; } @@ -74,7 +89,7 @@ const getFormatYLong = (transactionType: string | undefined) => (t: number) => { 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.requestTypeUnitLongLabel', { defaultMessage: - '{transCount, plural, =0 {# request} one {# request} other {# requests}}', + '{transCount, plural, =0 {request} one {request} other {requests}}', values: { transCount: t, }, @@ -84,7 +99,7 @@ const getFormatYLong = (transactionType: string | undefined) => (t: number) => { 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.transactionTypeUnitLongLabel', { defaultMessage: - '{transCount, plural, =0 {# transaction} one {# transaction} other {# transactions}}', + '{transCount, plural, =0 {transaction} one {transaction} other {transactions}}', values: { transCount: t, }, @@ -95,21 +110,21 @@ const getFormatYLong = (transactionType: string | undefined) => (t: number) => { interface Props { distribution?: TransactionDistributionAPIResponse; urlParams: IUrlParams; - isLoading: boolean; + fetchStatus: FETCH_STATUS; bucketIndex: number; onBucketClick: ( bucket: ValuesType ) => void; } -export function TransactionDistribution(props: Props) { - const { - distribution, - urlParams: { transactionType }, - isLoading, - bucketIndex, - onBucketClick, - } = props; +export function TransactionDistribution({ + distribution, + urlParams: { transactionType }, + fetchStatus, + bucketIndex, + onBucketClick, +}: Props) { + const theme = useTheme(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ const formatYShort = useCallback(getFormatYShort(transactionType), [ @@ -122,12 +137,10 @@ export function TransactionDistribution(props: Props) { ]); // no data in response - if (!distribution || distribution.noHits) { - // only show loading state if there is no data - else show stale data until new data has loaded - if (isLoading) { - return ; - } - + if ( + (!distribution || distribution.noHits) && + fetchStatus !== FETCH_STATUS.LOADING + ) { return ( { - return bucket.key === chartPoint.x0; - }); - - return clickedBucket; - } - const buckets = getFormattedBuckets( - distribution.buckets, - distribution.bucketSize + distribution?.buckets, + distribution?.bucketSize ); - const xMax = d3.max(buckets, (d) => d.x) || 0; + const xMin = d3.min(buckets, (d) => d.x0) || 0; + const xMax = d3.max(buckets, (d) => d.x0) || 0; const timeFormatter = getDurationFormatter(xMax); + const tooltipProps: SettingsSpec['tooltip'] = { + headerFormatter: (tooltip: TooltipValue) => { + const serie = buckets.find((bucket) => bucket.x0 === tooltip.value); + if (serie) { + const xFormatted = timeFormatter(serie.x); + const x0Formatted = timeFormatter(serie.x0); + return `${x0Formatted.value} - ${xFormatted.value} ${xFormatted.unit}`; + } + return `${timeFormatter(tooltip.value)}`; + }, + }; + + const onBarClick: ElementClickListener = (elements) => { + const chartPoint = elements[0][0] as GeometryValue; + const clickedBucket = distribution?.buckets.find((bucket) => { + return bucket.key === chartPoint.x; + }); + if (clickedBucket) { + onBucketClick(clickedBucket); + } + }; + + const selectedBucket = buckets[bucketIndex]; + return (
@@ -181,42 +211,66 @@ export function TransactionDistribution(props: Props) { /> - - { - const clickedBucket = getBucketFromChartPoint(chartPoint); - - if (clickedBucket) { - onBucketClick(clickedBucket); - } - }} - formatX={(time: number) => timeFormatter(time).formatted} - formatYShort={formatYShort} - formatYLong={formatYLong} - verticalLineHover={(point: IChartPoint) => - isEmpty(getBucketFromChartPoint(point)?.samples) - } - backgroundHover={(point: IChartPoint) => - !isEmpty(getBucketFromChartPoint(point)?.samples) - } - tooltipHeader={(point: IChartPoint) => { - const xFormatted = timeFormatter(point.x); - const x0Formatted = timeFormatter(point.x0); - return `${x0Formatted.value} - ${xFormatted.value} ${xFormatted.unit}`; - }} - tooltipFooter={(point: IChartPoint) => - isEmpty(getBucketFromChartPoint(point)?.samples) && - i18n.translate( - 'xpack.apm.transactionDetails.transactionsDurationDistributionChart.noSampleTooltip', - { - defaultMessage: 'No sample available for this bucket', - } - ) - } - /> + + + + {selectedBucket && ( + + )} + timeFormatter(time).formatted} + /> + formatYShort(value)} + /> + value} + minBarHeight={2} + id="transactionDurationDistribution" + name={(series: XYChartSeriesIdentifier) => { + const bucketCount = series.splitAccessors.get( + series.yAccessor + ) as number; + return formatYLong(bucketCount); + }} + splitSeriesAccessors={['y']} + xScaleType={ScaleType.Linear} + yScaleType={ScaleType.Linear} + xAccessor="x0" + yAccessors={['y']} + data={buckets} + color={theme.eui.euiColorVis1} + /> + +
); } diff --git a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx index efdd7b1f34221..e4c36b028e55c 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionDetails/index.tsx @@ -52,7 +52,11 @@ export function TransactionDetails({ status: distributionStatus, } = useTransactionDistribution(urlParams); - const { data: transactionChartsData } = useTransactionCharts(); + const { + data: transactionChartsData, + status: transactionChartsStatus, + } = useTransactionCharts(); + const { waterfall, exceedsMax, status: waterfallStatus } = useWaterfall( urlParams ); @@ -121,6 +125,7 @@ export function TransactionDetails({ @@ -131,7 +136,7 @@ export function TransactionDetails({ { diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx index b7d1b93600a73..c530a7e1489ad 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/TransactionOverview.test.tsx @@ -4,12 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import { - fireEvent, - getByText, - queryByLabelText, - render, -} from '@testing-library/react'; +import { fireEvent, getByText, queryByLabelText } from '@testing-library/react'; import { createMemoryHistory } from 'history'; import { CoreStart } from 'kibana/public'; import React from 'react'; @@ -20,7 +15,10 @@ import { UrlParamsProvider } from '../../../context/UrlParamsContext'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import * as useFetcherHook from '../../../hooks/useFetcher'; import * as useServiceTransactionTypesHook from '../../../hooks/useServiceTransactionTypes'; -import { disableConsoleWarning } from '../../../utils/testHelpers'; +import { + disableConsoleWarning, + renderWithTheme, +} from '../../../utils/testHelpers'; import { fromQuery } from '../../shared/Links/url_helpers'; import { TransactionOverview } from './'; @@ -54,7 +52,7 @@ function setup({ jest.spyOn(useFetcherHook, 'useFetcher').mockReturnValue({} as any); - return render( + return renderWithTheme( diff --git a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx index 5444d2d521f37..df9e673ed4847 100644 --- a/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/TransactionOverview/index.tsx @@ -22,7 +22,7 @@ import React, { useMemo } from 'react'; import { useLocation } from 'react-router-dom'; import { useTrackPageview } from '../../../../../observability/public'; import { Projection } from '../../../../common/projections'; -import { LegacyChartsSyncContextProvider as ChartsSyncContextProvider } from '../../../context/charts_sync_context'; +import { TRANSACTION_PAGE_LOAD } from '../../../../common/transaction_types'; import { IUrlParams } from '../../../context/UrlParamsContext/types'; import { useServiceTransactionTypes } from '../../../hooks/useServiceTransactionTypes'; import { useTransactionCharts } from '../../../hooks/useTransactionCharts'; @@ -33,11 +33,10 @@ import { ElasticDocsLink } from '../../shared/Links/ElasticDocsLink'; import { fromQuery, toQuery } from '../../shared/Links/url_helpers'; import { LocalUIFilters } from '../../shared/LocalUIFilters'; import { TransactionTypeFilter } from '../../shared/LocalUIFilters/TransactionTypeFilter'; +import { Correlations } from '../Correlations'; import { TransactionList } from './TransactionList'; import { useRedirect } from './useRedirect'; -import { TRANSACTION_PAGE_LOAD } from '../../../../common/transaction_types'; import { UserExperienceCallout } from './user_experience_callout'; -import { Correlations } from '../Correlations'; function getRedirectLocation({ urlParams, @@ -83,7 +82,10 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { }) ); - const { data: transactionCharts } = useTransactionCharts(); + const { + data: transactionCharts, + status: transactionChartsStatus, + } = useTransactionCharts(); useTrackPageview({ app: 'apm', path: 'transaction_overview' }); useTrackPageview({ app: 'apm', path: 'transaction_overview', delay: 15000 }); @@ -135,12 +137,11 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { )} - - - + @@ -190,7 +191,7 @@ export function TransactionOverview({ serviceName }: TransactionOverviewProps) { diff --git a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx index 342152b572f1e..016ee3daf6b51 100644 --- a/x-pack/plugins/apm/public/components/app/service_overview/index.tsx +++ b/x-pack/plugins/apm/public/components/app/service_overview/index.tsx @@ -11,7 +11,7 @@ import styled from 'styled-components'; import { useTrackPageview } from '../../../../../observability/public'; import { isRumAgentName } from '../../../../common/agent_name'; import { ChartsSyncContextProvider } from '../../../context/charts_sync_context'; -import { ErroneousTransactionsRateChart } from '../../shared/charts/erroneous_transactions_rate_chart'; +import { TransactionErrorRateChart } from '../../shared/charts/transaction_error_rate_chart'; import { ErrorOverviewLink } from '../../shared/Links/apm/ErrorOverviewLink'; import { ServiceMapLink } from '../../shared/Links/apm/ServiceMapLink'; import { TransactionOverviewLink } from '../../shared/Links/apm/TransactionOverviewLink'; @@ -125,19 +125,7 @@ export function ServiceOverview({ {!isRumAgentName(agentName) && ( - - -

- {i18n.translate( - 'xpack.apm.serviceOverview.errorRateChartTitle', - { - defaultMessage: 'Error rate', - } - )} -

-
- -
+
)} diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx index b908eb8da4d03..05cae589c19fc 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/TransactionBreakdownGraph/index.tsx @@ -4,62 +4,113 @@ * you may not use this file except in compliance with the Elastic License. */ -import { throttle } from 'lodash'; -import React, { useMemo } from 'react'; +import { + AreaSeries, + Axis, + Chart, + niceTimeFormatter, + Placement, + Position, + ScaleType, + Settings, +} from '@elastic/charts'; +import moment from 'moment'; +import React, { useEffect } from 'react'; +import { useHistory } from 'react-router-dom'; import { asPercent } from '../../../../../common/utils/formatters'; -import { useUiTracker } from '../../../../../../observability/public'; -import { NOT_AVAILABLE_LABEL } from '../../../../../common/i18n'; -import { Maybe } from '../../../../../typings/common'; -import { Coordinate, TimeSeries } from '../../../../../typings/timeseries'; +import { TimeSeries } from '../../../../../typings/timeseries'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { getEmptySeries } from '../../charts/CustomPlot/getEmptySeries'; -import { TransactionLineChart } from '../../charts/TransactionCharts/TransactionLineChart'; +import { useChartsSync as useChartsSync2 } from '../../../../hooks/use_charts_sync'; +import { unit } from '../../../../style/variables'; +import { Annotations } from '../../charts/annotations'; +import { ChartContainer } from '../../charts/chart_container'; +import { onBrushEnd } from '../../charts/helper/helper'; + +const XY_HEIGHT = unit * 16; interface Props { - timeseries: TimeSeries[]; - noHits: boolean; + fetchStatus: FETCH_STATUS; + timeseries?: TimeSeries[]; } -const tickFormatY = (y: Maybe) => { - return asPercent(y ?? 0, 1); -}; +export function TransactionBreakdownGraph({ fetchStatus, timeseries }: Props) { + const history = useHistory(); + const chartRef = React.createRef(); + const { event, setEvent } = useChartsSync2(); + const { urlParams } = useUrlParams(); + const { start, end } = urlParams; -const formatTooltipValue = (coordinate: Coordinate) => { - return isValidCoordinateValue(coordinate.y) - ? asPercent(coordinate.y, 1) - : NOT_AVAILABLE_LABEL; -}; + useEffect(() => { + if (event.chartId !== 'timeSpentBySpan' && chartRef.current) { + chartRef.current.dispatchExternalPointerEvent(event); + } + }, [chartRef, event]); -function TransactionBreakdownGraph({ timeseries, noHits }: Props) { - const { urlParams } = useUrlParams(); - const { rangeFrom, rangeTo } = urlParams; - const trackApmEvent = useUiTracker({ app: 'apm' }); - const handleHover = useMemo( - () => - throttle(() => trackApmEvent({ metric: 'hover_breakdown_chart' }), 60000), - [trackApmEvent] - ); + const min = moment.utc(start).valueOf(); + const max = moment.utc(end).valueOf(); - const emptySeries = - rangeFrom && rangeTo - ? getEmptySeries( - new Date(rangeFrom).getTime(), - new Date(rangeTo).getTime() - ) - : []; + const xFormatter = niceTimeFormatter([min, max]); return ( - + + + onBrushEnd({ x, history })} + showLegend + showLegendExtra + legendPosition={Position.Bottom} + xDomain={{ min, max }} + flatLegend + onPointerUpdate={(currEvent: any) => { + setEvent(currEvent); + }} + externalPointerEvents={{ + tooltip: { visible: true, placement: Placement.Bottom }, + }} + /> + + asPercent(y ?? 0, 1)} + /> + + + + {timeseries?.length ? ( + timeseries.map((serie) => { + return ( + + ); + }) + ) : ( + // When timeseries is empty, loads an AreaSeries chart to show the default empty message. + + )} + + ); } - -export { TransactionBreakdownGraph }; diff --git a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx index 55826497ca385..9b0c041aaf7b5 100644 --- a/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/TransactionBreakdown/index.tsx @@ -5,16 +5,13 @@ */ import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiTitle } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; -import { isEmpty } from 'lodash'; import React from 'react'; -import { FETCH_STATUS } from '../../../hooks/useFetcher'; import { useTransactionBreakdown } from '../../../hooks/useTransactionBreakdown'; import { TransactionBreakdownGraph } from './TransactionBreakdownGraph'; function TransactionBreakdown() { const { data, status } = useTransactionBreakdown(); const { timeseries } = data; - const noHits = isEmpty(timeseries) && status === FETCH_STATUS.SUCCESS; return ( @@ -29,7 +26,10 @@ function TransactionBreakdown() { - +
diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js deleted file mode 100644 index ca85ee961f5d8..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/SingleRect.js +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; -import PropTypes from 'prop-types'; - -function SingleRect({ innerHeight, marginTop, style, x, width }) { - return ( - - ); -} - -SingleRect.requiresSVG = true; -SingleRect.propTypes = { - x: PropTypes.number.isRequired, -}; - -export default SingleRect; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js deleted file mode 100644 index 03fd039a3401e..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/Histogram.test.js +++ /dev/null @@ -1,119 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React from 'react'; - -import d3 from 'd3'; -import { HistogramInner } from '../index'; -import response from './response.json'; -import { - disableConsoleWarning, - toJson, - mountWithTheme, -} from '../../../../../utils/testHelpers'; -import { getFormattedBuckets } from '../../../../app/TransactionDetails/Distribution/index'; -import { - asInteger, - getDurationFormatter, -} from '../../../../../../common/utils/formatters'; - -describe('Histogram', () => { - let mockConsole; - let wrapper; - - const onClick = jest.fn(); - - beforeAll(() => { - mockConsole = disableConsoleWarning('Warning: componentWillReceiveProps'); - }); - - afterAll(() => { - mockConsole.mockRestore(); - }); - - beforeEach(() => { - const buckets = getFormattedBuckets(response.buckets, response.bucketSize); - const xMax = d3.max(buckets, (d) => d.x); - const timeFormatter = getDurationFormatter(xMax); - - wrapper = mountWithTheme( - timeFormatter(time).formatted} - formatYShort={(t) => `${asInteger(t)} occ.`} - formatYLong={(t) => `${asInteger(t)} occurrences`} - tooltipHeader={(bucket) => { - const xFormatted = timeFormatter(bucket.x); - const x0Formatted = timeFormatter(bucket.x0); - return `${x0Formatted.value} - ${xFormatted.value} ${xFormatted.unit}`; - }} - width={800} - /> - ); - }); - - describe('Initially', () => { - it('should have default markup', () => { - expect(toJson(wrapper)).toMatchSnapshot(); - }); - - it('should not show tooltip', () => { - expect(wrapper.find('Tooltip').length).toBe(0); - }); - }); - - describe('when hovering over an empty bucket', () => { - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(2).simulate('mouseOver'); - }); - - it('should not display tooltip', () => { - expect(wrapper.find('Tooltip').length).toBe(0); - }); - }); - - describe('when hovering over a non-empty bucket', () => { - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(7).simulate('mouseOver'); - }); - - it('should display tooltip', () => { - const tooltips = wrapper.find('Tooltip'); - - expect(tooltips.length).toBe(1); - expect(tooltips.prop('header')).toBe('811 - 927 ms'); - expect(tooltips.prop('tooltipPoints')).toEqual([ - { value: '49 occurrences' }, - ]); - expect(tooltips.prop('x')).toEqual(869010); - expect(tooltips.prop('y')).toEqual(27.5); - }); - - it('should have correct markup for tooltip', () => { - const tooltips = wrapper.find('Tooltip'); - expect(toJson(tooltips)).toMatchSnapshot(); - }); - }); - - describe('when clicking on a non-empty bucket', () => { - beforeEach(() => { - wrapper.find('.rv-voronoi__cell').at(7).simulate('click'); - }); - - it('should call onClick with bucket', () => { - expect(onClick).toHaveBeenCalledWith({ - style: { cursor: 'pointer' }, - xCenter: 869010, - x0: 811076, - x: 926944, - y: 49, - }); - }); - }); -}); diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap deleted file mode 100644 index a31b9735628ab..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/__snapshots__/Histogram.test.js.snap +++ /dev/null @@ -1,1504 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Histogram Initially should have default markup 1`] = ` -.c0 { - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; - user-select: none; - position: absolute; - top: 0; - left: 0; -} - -
- -
-
- - - - - - - - - - - - - 0 ms - - - - - - 500 ms - - - - - - 1,000 ms - - - - - - 1,500 ms - - - - - - 2,000 ms - - - - - - 2,500 ms - - - - - - 3,000 ms - - - - - - - - - - 0 occ. - - - - - - 28 occ. - - - - - - 55 occ. - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
-
-
-`; - -exports[`Histogram when hovering over a non-empty bucket should have correct markup for tooltip 1`] = ` -.c0 { - margin: 0 16px; - -webkit-transform: translateY(-50%); - -ms-transform: translateY(-50%); - transform: translateY(-50%); - border: 1px solid #d3dae6; - background: #ffffff; - border-radius: 4px; - font-size: 14px; - color: #000000; -} - -.c1 { - background: #f5f7fa; - border-bottom: 1px solid #d3dae6; - border-radius: 4px 4px 0 0; - padding: 8px; - color: #98a2b3; -} - -.c2 { - margin: 8px; - margin-right: 16px; - font-size: 12px; -} - -.c4 { - color: #98a2b3; - margin: 8px; - font-size: 12px; -} - -.c3 { - color: #69707d; - font-size: 14px; -} - -
- -
- -
- 811 - 927 ms -
-
- -
- -
- 49 occurrences -
-
-
-
- -
- -
-
-
-`; diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json b/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json deleted file mode 100644 index 302e105dfa997..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/__test__/response.json +++ /dev/null @@ -1,106 +0,0 @@ -{ - "buckets": [ - { "key": 0, "count": 0 }, - { "key": 115868, "count": 0 }, - { "key": 231736, "count": 0 }, - { "key": 347604, "count": 0 }, - { "key": 463472, "count": 0 }, - { - "key": 579340, - "count": 8, - "samples": [ - { - "transactionId": "99437ee4-08d4-41f5-9b2b-93cc32ec3dfb" - } - ] - }, - { - "key": 695208, - "count": 23, - "samples": [ - { - "transactionId": "d327611b-e999-4942-a94f-c60208940180" - } - ] - }, - { - "key": 811076, - "count": 49, - "samples": [ - { - "transactionId": "99c50a5b-44b4-4289-a3d1-a2815d128192" - } - ] - }, - { - "key": 926944, - "count": 51, - "transactionId": "9706a1ec-23f5-4ce8-97e8-69ce35fb0a9a" - }, - { - "key": 1042812, - "count": 46, - "transactionId": "f8d360c3-dd5e-47b6-b082-9e0bf821d3b2" - }, - { - "key": 1158680, - "count": 13, - "samples": [ - { - "transactionId": "8486d3e2-7f15-48df-aa37-6ee9955adbd2" - } - ] - }, - { - "key": 1274548, - "count": 7, - "transactionId": "54b4b5a7-f065-4cab-9016-534e58f4fc0a" - }, - { - "key": 1390416, - "count": 4, - "transactionId": "8cfac2a3-38e7-4d3a-9792-d008b4bcb867" - }, - { - "key": 1506284, - "count": 3, - "transactionId": "ce3f3bd3-a37c-419e-bb9c-5db956ded149" - }, - { "key": 1622152, "count": 0 }, - { - "key": 1738020, - "count": 4, - "transactionId": "2300174b-85d8-40ba-a6cb-eeba2a49debf" - }, - { "key": 1853888, "count": 0 }, - { "key": 1969756, "count": 0 }, - { - "key": 2085624, - "count": 1, - "transactionId": "774955a4-2ba3-4461-81a6-65759db4805d" - }, - { "key": 2201492, "count": 0 }, - { "key": 2317360, "count": 0 }, - { "key": 2433228, "count": 0 }, - { "key": 2549096, "count": 0 }, - { "key": 2664964, "count": 0 }, - { - "key": 2780832, - "count": 1, - "transactionId": "035d1b9d-af71-46cf-8910-57bd4faf412d" - }, - { - "key": 2896700, - "count": 1, - "transactionId": "4a845b32-9de4-4796-8ef4-d7bbdedc9099" - }, - { "key": 3012568, "count": 0 }, - { - "key": 3128436, - "count": 1, - "transactionId": "68620ffb-7a1b-4f8e-b9bb-009fa5b092be" - } - ], - "bucketSize": 115868, - "defaultBucketIndex": 12 -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js b/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js deleted file mode 100644 index 3b2109d68c613..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/Histogram/index.js +++ /dev/null @@ -1,319 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { PureComponent } from 'react'; -import d3 from 'd3'; -import { isEmpty } from 'lodash'; -import PropTypes from 'prop-types'; -import { scaleLinear } from 'd3-scale'; -import styled from 'styled-components'; -import SingleRect from './SingleRect'; -import { - XYPlot, - XAxis, - YAxis, - HorizontalGridLines, - VerticalRectSeries, - Voronoi, - makeWidthFlexible, - VerticalGridLines, -} from 'react-vis'; -import { unit } from '../../../../style/variables'; -import Tooltip from '../Tooltip'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { tint } from 'polished'; -import { getTimeTicksTZ, getDomainTZ } from '../helper/timezone'; -import Legends from '../CustomPlot/Legends'; -import StatusText from '../CustomPlot/StatusText'; -import { i18n } from '@kbn/i18n'; -import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; - -const XY_HEIGHT = unit * 10; -const XY_MARGIN = { - top: unit, - left: unit * 5, - right: unit, - bottom: unit * 2, -}; - -const X_TICK_TOTAL = 8; - -// position absolutely to make sure that window resizing/zooming works -const ChartsWrapper = styled.div` - user-select: none; - position: absolute; - top: 0; - left: 0; -`; - -export class HistogramInner extends PureComponent { - constructor(props) { - super(props); - this.state = { - hoveredBucket: {}, - }; - } - - onClick = (bucket) => { - if (this.props.onClick) { - this.props.onClick(bucket); - } - }; - - onHover = (bucket) => { - this.setState({ hoveredBucket: bucket }); - }; - - onBlur = () => { - this.setState({ hoveredBucket: {} }); - }; - - getChartData(items, selectedItem) { - const yMax = d3.max(items, (d) => d.y); - const MINIMUM_BUCKET_SIZE = yMax * 0.02; - - return items.map((item) => { - const padding = (item.x - item.x0) / 20; - return { - ...item, - color: - item === selectedItem - ? theme.euiColorVis1 - : tint(0.5, theme.euiColorVis1), - x0: item.x0 + padding, - x: item.x - padding, - y: item.y > 0 ? Math.max(item.y, MINIMUM_BUCKET_SIZE) : 0, - }; - }); - } - - render() { - const { - backgroundHover, - bucketIndex, - buckets, - bucketSize, - formatX, - formatYShort, - formatYLong, - tooltipFooter, - tooltipHeader, - verticalLineHover, - width: XY_WIDTH, - height, - legends, - } = this.props; - const { hoveredBucket } = this.state; - if (isEmpty(buckets) || XY_WIDTH === 0) { - return null; - } - - const isTimeSeries = - this.props.xType === 'time' || this.props.xType === 'time-utc'; - - const xMin = d3.min(buckets, (d) => d.x0); - const xMax = d3.max(buckets, (d) => d.x); - const yMin = 0; - const yMax = d3.max(buckets, (d) => d.y); - const selectedBucket = buckets[bucketIndex]; - const chartData = this.getChartData(buckets, selectedBucket); - - const x = scaleLinear() - .domain([xMin, xMax]) - .range([XY_MARGIN.left, XY_WIDTH - XY_MARGIN.right]); - - const y = scaleLinear().domain([yMin, yMax]).range([XY_HEIGHT, 0]).nice(); - - const [xMinZone, xMaxZone] = getDomainTZ(xMin, xMax); - const xTickValues = isTimeSeries - ? getTimeTicksTZ({ - domain: [xMinZone, xMaxZone], - totalTicks: X_TICK_TOTAL, - width: XY_WIDTH, - }) - : undefined; - - const xDomain = x.domain(); - const yDomain = y.domain(); - const yTickValues = [0, yDomain[1] / 2, yDomain[1]]; - const shouldShowTooltip = - hoveredBucket.x > 0 && (hoveredBucket.y > 0 || isTimeSeries); - - const showVerticalLineHover = verticalLineHover(hoveredBucket); - const showBackgroundHover = backgroundHover(hoveredBucket); - - const hasValidCoordinates = buckets.some((bucket) => - isValidCoordinateValue(bucket.y) - ); - const noHits = this.props.noHits || !hasValidCoordinates; - - const xyPlotProps = { - dontCheckIfEmpty: true, - xType: this.props.xType, - width: XY_WIDTH, - height: XY_HEIGHT, - margin: XY_MARGIN, - xDomain: xDomain, - yDomain: yDomain, - }; - - const xAxisProps = { - style: { strokeWidth: '1px' }, - marginRight: 10, - tickSize: 0, - tickTotal: X_TICK_TOTAL, - tickFormat: formatX, - tickValues: xTickValues, - }; - - const emptyStateChart = ( - - - - - ); - - return ( -
- - {noHits ? ( - <>{emptyStateChart} - ) : ( - <> - - - - - - {showBackgroundHover && ( - - )} - - {shouldShowTooltip && ( - - )} - - {selectedBucket && ( - - )} - - - - {showVerticalLineHover && hoveredBucket?.x && ( - - )} - - { - return { - ...bucket, - xCenter: (bucket.x0 + bucket.x) / 2, - }; - })} - onClick={this.onClick} - onHover={this.onHover} - onBlur={this.onBlur} - x={(d) => x(d.xCenter)} - y={() => 1} - /> - - - {legends && ( - {}} - truncateLegends={false} - noHits={noHits} - /> - )} - - )} - -
- ); - } -} - -HistogramInner.propTypes = { - backgroundHover: PropTypes.func, - bucketIndex: PropTypes.number, - buckets: PropTypes.array.isRequired, - bucketSize: PropTypes.number.isRequired, - formatX: PropTypes.func, - formatYLong: PropTypes.func, - formatYShort: PropTypes.func, - onClick: PropTypes.func, - tooltipFooter: PropTypes.func, - tooltipHeader: PropTypes.func, - verticalLineHover: PropTypes.func, - width: PropTypes.number.isRequired, - height: PropTypes.number, - xType: PropTypes.string, - legends: PropTypes.array, - noHits: PropTypes.bool, -}; - -HistogramInner.defaultProps = { - backgroundHover: () => null, - formatYLong: (value) => value, - formatYShort: (value) => value, - tooltipFooter: () => null, - tooltipHeader: () => null, - verticalLineHover: () => null, - xType: 'linear', - noHits: false, - height: XY_HEIGHT, -}; - -export default makeWidthFlexible(HistogramInner); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx deleted file mode 100644 index 2e4b51af00d6b..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/TransactionLineChart/index.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import React, { useCallback } from 'react'; -import { Coordinate, TimeSeries } from '../../../../../../typings/timeseries'; -import { useLegacyChartsSync as useChartsSync } from '../../../../../hooks/use_charts_sync'; -// @ts-expect-error -import CustomPlot from '../../CustomPlot'; - -interface Props { - series: TimeSeries[]; - truncateLegends?: boolean; - tickFormatY: (y: number) => React.ReactNode; - formatTooltipValue: (c: Coordinate) => React.ReactNode; - yMax?: string | number; - height?: number; - stacked?: boolean; - onHover?: () => void; - visibleLegendCount?: number; - onToggleLegend?: (disabledSeriesState: boolean[]) => void; -} - -function TransactionLineChart(props: Props) { - const { - series, - tickFormatY, - formatTooltipValue, - yMax = 'max', - height, - truncateLegends, - stacked = false, - onHover, - visibleLegendCount, - onToggleLegend, - } = props; - - const syncedChartsProps = useChartsSync(); - - // combine callback for syncedChartsProps.onHover and props.onHover - const combinedOnHover = useCallback( - (hoverX: number) => { - if (onHover) { - onHover(); - } - return syncedChartsProps.onHover(hoverX); - }, - [syncedChartsProps, onHover] - ); - - return ( - - ); -} - -export { TransactionLineChart }; diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx index b3c0c3b6de857..2a5948d0ebf0b 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/index.tsx @@ -20,104 +20,107 @@ import { TRANSACTION_REQUEST, TRANSACTION_ROUTE_CHANGE, } from '../../../../../common/transaction_types'; +import { asDecimal, tpmUnit } from '../../../../../common/utils/formatters'; import { Coordinate } from '../../../../../typings/timeseries'; +import { ChartsSyncContextProvider } from '../../../../context/charts_sync_context'; import { LicenseContext } from '../../../../context/LicenseContext'; import { IUrlParams } from '../../../../context/UrlParamsContext/types'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { ITransactionChartData } from '../../../../selectors/chartSelectors'; -import { asDecimal, tpmUnit } from '../../../../../common/utils/formatters'; import { isValidCoordinateValue } from '../../../../utils/isValidCoordinateValue'; -import { ErroneousTransactionsRateChart } from '../erroneous_transactions_rate_chart/legacy'; import { TransactionBreakdown } from '../../TransactionBreakdown'; -import { - getResponseTimeTickFormatter, - getResponseTimeTooltipFormatter, -} from './helper'; +import { LineChart } from '../line_chart'; +import { TransactionErrorRateChart } from '../transaction_error_rate_chart/'; +import { getResponseTimeTickFormatter } from './helper'; import { MLHeader } from './ml_header'; -import { TransactionLineChart } from './TransactionLineChart'; import { useFormatter } from './use_formatter'; interface TransactionChartProps { charts: ITransactionChartData; urlParams: IUrlParams; + fetchStatus: FETCH_STATUS; } export function TransactionCharts({ charts, urlParams, + fetchStatus, }: TransactionChartProps) { const getTPMFormatter = (t: number) => { - const unit = tpmUnit(urlParams.transactionType); - return `${asDecimal(t)} ${unit}`; + return `${asDecimal(t)} ${tpmUnit(urlParams.transactionType)}`; }; - const getTPMTooltipFormatter = (p: Coordinate) => { - return isValidCoordinateValue(p.y) - ? getTPMFormatter(p.y) - : NOT_AVAILABLE_LABEL; + const getTPMTooltipFormatter = (y: Coordinate['y']) => { + return isValidCoordinateValue(y) ? getTPMFormatter(y) : NOT_AVAILABLE_LABEL; }; const { transactionType } = urlParams; const { responseTimeSeries, tpmSeries } = charts; - const { formatter, setDisabledSeriesState } = useFormatter( - responseTimeSeries - ); + const { formatter, toggleSerie } = useFormatter(responseTimeSeries); return ( <> - - - - - - - {responseTimeLabel(transactionType)} - - - - {(license) => ( - - )} - - - - - + + + + + + + + {responseTimeLabel(transactionType)} + + + + {(license) => ( + + )} + + + { + if (serie) { + toggleSerie(serie); + } + }} + /> + + - - - - {tpmLabel(transactionType)} - - - - - + + + + {tpmLabel(transactionType)} + + + + + - + - - - - - - - - + + + + + + + + + ); } diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx index fc873cbda7bf2..958a5db6b66c9 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.test.tsx @@ -3,38 +3,17 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React from 'react'; +import { SeriesIdentifier } from '@elastic/charts'; +import { renderHook } from '@testing-library/react-hooks'; +import { act } from 'react-test-renderer'; +import { toMicroseconds } from '../../../../../common/utils/formatters'; import { TimeSeries } from '../../../../../typings/timeseries'; import { useFormatter } from './use_formatter'; -import { render, fireEvent, act } from '@testing-library/react'; -import { toMicroseconds } from '../../../../../common/utils/formatters'; - -function MockComponent({ - timeSeries, - disabledSeries, - value, -}: { - timeSeries: TimeSeries[]; - disabledSeries: boolean[]; - value: number; -}) { - const { formatter, setDisabledSeriesState } = useFormatter(timeSeries); - - const onDisableSeries = () => { - setDisabledSeriesState(disabledSeries); - }; - - return ( -
- - {formatter(value).formatted} -
- ); -} describe('useFormatter', () => { const timeSeries = ([ { + title: 'avg', data: [ { x: 1, y: toMicroseconds(11, 'minutes') }, { x: 2, y: toMicroseconds(1, 'minutes') }, @@ -42,6 +21,7 @@ describe('useFormatter', () => { ], }, { + title: '95th percentile', data: [ { x: 1, y: toMicroseconds(120, 'seconds') }, { x: 2, y: toMicroseconds(1, 'minutes') }, @@ -49,6 +29,7 @@ describe('useFormatter', () => { ], }, { + title: '99th percentile', data: [ { x: 1, y: toMicroseconds(60, 'seconds') }, { x: 2, y: toMicroseconds(5, 'minutes') }, @@ -56,54 +37,47 @@ describe('useFormatter', () => { ], }, ] as unknown) as TimeSeries[]; + it('returns new formatter when disabled series state changes', () => { - const { getByText } = render( - - ); - expect(getByText('2.0 min')).toBeInTheDocument(); + const { result } = renderHook(() => useFormatter(timeSeries)); + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('2.0 min'); + act(() => { - fireEvent.click(getByText('disable series')); + result.current.toggleSerie({ + specId: 'avg', + } as SeriesIdentifier); }); - expect(getByText('120 s')).toBeInTheDocument(); + + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('120 s'); }); + it('falls back to the first formatter when disabled series is empty', () => { - const { getByText } = render( - - ); - expect(getByText('2.0 min')).toBeInTheDocument(); + const { result } = renderHook(() => useFormatter(timeSeries)); + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('2.0 min'); + act(() => { - fireEvent.click(getByText('disable series')); + result.current.toggleSerie({ + specId: 'avg', + } as SeriesIdentifier); }); - expect(getByText('2.0 min')).toBeInTheDocument(); - // const { formatter, setDisabledSeriesState } = useFormatter(timeSeries); - // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); - // setDisabledSeriesState([true, true, false]); - // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); - }); - it('falls back to the first formatter when disabled series is all true', () => { - const { getByText } = render( - - ); - expect(getByText('2.0 min')).toBeInTheDocument(); + + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('120 s'); + act(() => { - fireEvent.click(getByText('disable series')); + result.current.toggleSerie({ + specId: 'avg', + } as SeriesIdentifier); }); - expect(getByText('2.0 min')).toBeInTheDocument(); - // const { formatter, setDisabledSeriesState } = useFormatter(timeSeries); - // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); - // setDisabledSeriesState([true, true, false]); - // expect(formatter(toMicroseconds(120, 'seconds'))).toEqual('2.0 min'); + expect( + result.current.formatter(toMicroseconds(120, 'seconds')).formatted + ).toEqual('2.0 min'); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts index d4694bc3caf1d..1475ec2934e95 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts +++ b/x-pack/plugins/apm/public/components/shared/charts/TransactionCharts/use_formatter.ts @@ -4,8 +4,9 @@ * you may not use this file except in compliance with the Elastic License. */ -import { useState, Dispatch, SetStateAction } from 'react'; -import { isEmpty } from 'lodash'; +import { SeriesIdentifier } from '@elastic/charts'; +import { omit } from 'lodash'; +import { useState } from 'react'; import { getDurationFormatter, TimeFormatter, @@ -14,17 +15,36 @@ import { TimeSeries } from '../../../../../typings/timeseries'; import { getMaxY } from './helper'; export const useFormatter = ( - series: TimeSeries[] + series?: TimeSeries[] ): { formatter: TimeFormatter; - setDisabledSeriesState: Dispatch>; + toggleSerie: (disabledSerie: SeriesIdentifier) => void; } => { - const [disabledSeriesState, setDisabledSeriesState] = useState([]); - const visibleSeries = series.filter( - (serie, index) => disabledSeriesState[index] !== true + const [disabledSeries, setDisabledSeries] = useState< + Record + >({}); + + const visibleSeries = series?.filter( + (serie) => disabledSeries[serie.title] === undefined ); - const maxY = getMaxY(isEmpty(visibleSeries) ? series : visibleSeries); + + const maxY = getMaxY(visibleSeries || series || []); const formatter = getDurationFormatter(maxY); - return { formatter, setDisabledSeriesState }; + const toggleSerie = ({ specId }: SeriesIdentifier) => { + if (disabledSeries[specId] !== undefined) { + setDisabledSeries((prevState) => { + return omit(prevState, specId); + }); + } else { + setDisabledSeries((prevState) => { + return { ...prevState, [specId]: 0 }; + }); + } + }; + + return { + formatter, + toggleSerie, + }; }; diff --git a/x-pack/plugins/apm/public/components/shared/charts/annotations/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/annotations/index.tsx new file mode 100644 index 0000000000000..683c66b2a96fe --- /dev/null +++ b/x-pack/plugins/apm/public/components/shared/charts/annotations/index.tsx @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { + AnnotationDomainTypes, + LineAnnotation, + Position, +} from '@elastic/charts'; +import { EuiIcon } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; +import React from 'react'; +import { asAbsoluteDateTime } from '../../../../../common/utils/formatters'; +import { useTheme } from '../../../../hooks/useTheme'; +import { useAnnotations } from '../../../../hooks/use_annotations'; + +export function Annotations() { + const { annotations } = useAnnotations(); + const theme = useTheme(); + + if (!annotations.length) { + return null; + } + + const color = theme.eui.euiColorSecondary; + + return ( + ({ + dataValue: annotation['@timestamp'], + header: asAbsoluteDateTime(annotation['@timestamp']), + details: `${i18n.translate('xpack.apm.chart.annotation.version', { + defaultMessage: 'Version', + })} ${annotation.text}`, + }))} + style={{ line: { strokeWidth: 1, stroke: color, opacity: 1 } }} + marker={} + markerPosition={Position.Top} + /> + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/chart_container.test.tsx b/x-pack/plugins/apm/public/components/shared/charts/chart_container.test.tsx index 409cb69575ca9..c0e8f869ce647 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/chart_container.test.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/chart_container.test.tsx @@ -5,30 +5,97 @@ */ import { render } from '@testing-library/react'; import React from 'react'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; import { ChartContainer } from './chart_container'; describe('ChartContainer', () => { - describe('when isLoading is true', () => { - it('shows loading the indicator', () => { - const component = render( - + describe('loading indicator', () => { + it('shows loading when status equals to Loading or Pending and has no data', () => { + [FETCH_STATUS.PENDING, FETCH_STATUS.LOADING].map((status) => { + const { queryAllByTestId } = render( + +
My amazing component
+
+ ); + + expect(queryAllByTestId('loading')[0]).toBeInTheDocument(); + }); + }); + it('does not show loading when status equals to Loading or Pending and has data', () => { + [FETCH_STATUS.PENDING, FETCH_STATUS.LOADING].map((status) => { + const { queryAllByText } = render( + +
My amazing component
+
+ ); + expect(queryAllByText('My amazing component')[0]).toBeInTheDocument(); + }); + }); + }); + + describe('failure indicator', () => { + it('shows failure message when status equals to Failure and has data', () => { + const { getByText } = render( +
My amazing component
); - - expect(component.getByTestId('loading')).toBeInTheDocument(); + expect( + getByText( + 'An error happened when trying to fetch data. Please try again' + ) + ).toBeInTheDocument(); + }); + it('shows failure message when status equals to Failure and has no data', () => { + const { getByText } = render( + +
My amazing component
+
+ ); + expect( + getByText( + 'An error happened when trying to fetch data. Please try again' + ) + ).toBeInTheDocument(); }); }); - describe('when isLoading is false', () => { - it('does not show the loading indicator', () => { - const component = render( - + describe('render component', () => { + it('shows children component when status Success and has data', () => { + const { getByText } = render( +
My amazing component
); - - expect(component.queryByTestId('loading')).not.toBeInTheDocument(); + expect(getByText('My amazing component')).toBeInTheDocument(); + }); + it('shows children component when status Success and has no data', () => { + const { getByText } = render( + +
My amazing component
+
+ ); + expect(getByText('My amazing component')).toBeInTheDocument(); }); }); }); diff --git a/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx b/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx index a6f579308597f..b4486f1e9b94a 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/chart_container.tsx @@ -3,27 +3,56 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import { EuiLoadingChart } from '@elastic/eui'; + +import { EuiLoadingChart, EuiText } from '@elastic/eui'; +import { i18n } from '@kbn/i18n'; import React from 'react'; +import { FETCH_STATUS } from '../../../hooks/useFetcher'; interface Props { - isLoading: boolean; + hasData: boolean; + status: FETCH_STATUS; height: number; children: React.ReactNode; } -export function ChartContainer({ isLoading, children, height }: Props) { +export function ChartContainer({ children, height, status, hasData }: Props) { + if ( + !hasData && + (status === FETCH_STATUS.LOADING || status === FETCH_STATUS.PENDING) + ) { + return ; + } + + if (status === FETCH_STATUS.FAILURE) { + return ; + } + + return
{children}
; +} + +function LoadingChartPlaceholder({ height }: { height: number }) { return (
- {isLoading && } - {children} +
); } + +function FailedChartPlaceholder({ height }: { height: number }) { + return ( + + {i18n.translate('xpack.apm.chart.error', { + defaultMessage: + 'An error happened when trying to fetch data. Please try again', + })} + + ); +} diff --git a/x-pack/plugins/apm/public/components/shared/charts/erroneous_transactions_rate_chart/legacy.tsx b/x-pack/plugins/apm/public/components/shared/charts/erroneous_transactions_rate_chart/legacy.tsx deleted file mode 100644 index 29102f606414f..0000000000000 --- a/x-pack/plugins/apm/public/components/shared/charts/erroneous_transactions_rate_chart/legacy.tsx +++ /dev/null @@ -1,112 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import { EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; -import theme from '@elastic/eui/dist/eui_theme_light.json'; -import { i18n } from '@kbn/i18n'; -import { max } from 'lodash'; -import React, { useCallback } from 'react'; -import { useParams } from 'react-router-dom'; -import { asPercent } from '../../../../../common/utils/formatters'; -import { useLegacyChartsSync as useChartsSync } from '../../../../hooks/use_charts_sync'; -import { useFetcher } from '../../../../hooks/useFetcher'; -import { useUrlParams } from '../../../../hooks/useUrlParams'; -import { callApmApi } from '../../../../services/rest/createCallApmApi'; -// @ts-expect-error -import CustomPlot from '../CustomPlot'; - -const tickFormatY = (y?: number | null) => { - return asPercent(y || 0, 1); -}; - -/** - * "Legacy" version of this chart using react-vis charts. See index.tsx for the - * Elastic Charts version. - * - * This will be removed with #70290. - */ -export function ErroneousTransactionsRateChart() { - const { serviceName } = useParams<{ serviceName?: string }>(); - const { urlParams, uiFilters } = useUrlParams(); - const syncedChartsProps = useChartsSync(); - - const { start, end, transactionType, transactionName } = urlParams; - - const { data } = useFetcher(() => { - if (serviceName && start && end) { - return callApmApi({ - pathname: - '/api/apm/services/{serviceName}/transaction_groups/error_rate', - params: { - path: { - serviceName, - }, - query: { - start, - end, - transactionType, - transactionName, - uiFilters: JSON.stringify(uiFilters), - }, - }, - }); - } - }, [serviceName, start, end, uiFilters, transactionType, transactionName]); - - const combinedOnHover = useCallback( - (hoverX: number) => { - return syncedChartsProps.onHover(hoverX); - }, - [syncedChartsProps] - ); - - const errorRates = data?.transactionErrorRate || []; - const maxRate = max(errorRates.map((errorRate) => errorRate.y)); - - return ( - - - - {i18n.translate('xpack.apm.errorRateChart.title', { - defaultMessage: 'Transaction error rate', - })} - - - - - Number.isFinite(y) ? tickFormatY(y) : 'N/A' - } - /> - - ); -} diff --git a/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx b/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx index 3f2a08ecb7641..507acc49d89db 100644 --- a/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx +++ b/x-pack/plugins/apm/public/components/shared/charts/line_chart/index.tsx @@ -20,15 +20,17 @@ import moment from 'moment'; import React, { useEffect } from 'react'; import { useHistory } from 'react-router-dom'; import { TimeSeries } from '../../../../../typings/timeseries'; +import { FETCH_STATUS } from '../../../../hooks/useFetcher'; import { useUrlParams } from '../../../../hooks/useUrlParams'; import { useChartsSync } from '../../../../hooks/use_charts_sync'; import { unit } from '../../../../style/variables'; +import { Annotations } from '../annotations'; import { ChartContainer } from '../chart_container'; import { onBrushEnd } from '../helper/helper'; interface Props { id: string; - isLoading: boolean; + fetchStatus: FETCH_STATUS; onToggleLegend?: LegendItemListener; timeseries: TimeSeries[]; /** @@ -38,18 +40,20 @@ interface Props { /** * Formatter for legend and tooltip values */ - yTickFormat: (y: number) => string; + yTickFormat?: (y: number) => string; + showAnnotations?: boolean; } const XY_HEIGHT = unit * 16; export function LineChart({ id, - isLoading, + fetchStatus, onToggleLegend, timeseries, yLabelFormat, yTickFormat, + showAnnotations = true, }: Props) { const history = useHistory(); const chartRef = React.createRef(); @@ -84,7 +88,7 @@ export function LineChart({ ); return ( - + onBrushEnd({ x, history })} @@ -115,11 +119,13 @@ export function LineChart({ id="y-axis" ticks={3} position={Position.Left} - tickFormat={yTickFormat} + tickFormat={yTickFormat ? yTickFormat : yLabelFormat} labelFormat={yLabelFormat} showGridLines /> + {showAnnotations && } + {timeseries.map((serie) => { return ( (); const { urlParams, uiFilters } = useUrlParams(); @@ -56,25 +61,32 @@ export function ErroneousTransactionsRateChart() { const errorRates = data?.transactionErrorRate || []; return ( - + + +

+ {i18n.translate('xpack.apm.errorRate', { + defaultMessage: 'Error rate', + })} +

+
+ +
); } diff --git a/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts b/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts index 08d2300c3254a..0705383ecb0ca 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionBreakdown.ts @@ -15,7 +15,7 @@ export function useTransactionBreakdown() { uiFilters, } = useUrlParams(); - const { data = { timeseries: [] }, error, status } = useFetcher( + const { data = { timeseries: undefined }, error, status } = useFetcher( (callApmApi) => { if (serviceName && start && end && transactionType) { return callApmApi({ diff --git a/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts b/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts index a5096a314388c..8c76225d03486 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionDistribution.ts @@ -10,7 +10,7 @@ import { IUrlParams } from '../context/UrlParamsContext/types'; import { useFetcher } from './useFetcher'; import { useUiFilters } from '../context/UrlParamsContext'; // eslint-disable-next-line @kbn/eslint/no-restricted-paths -import { TransactionDistributionAPIResponse } from '../../server/lib/transactions/distribution'; +import type { TransactionDistributionAPIResponse } from '../../server/lib/transactions/distribution'; import { toQuery, fromQuery } from '../components/shared/Links/url_helpers'; import { maybe } from '../../common/utils/maybe'; diff --git a/x-pack/plugins/apm/public/hooks/useTransactionList.ts b/x-pack/plugins/apm/public/hooks/useTransactionList.ts index 9c3a18b9c0d0d..b2c2cc30f78ec 100644 --- a/x-pack/plugins/apm/public/hooks/useTransactionList.ts +++ b/x-pack/plugins/apm/public/hooks/useTransactionList.ts @@ -14,8 +14,8 @@ type TransactionsAPIResponse = APIReturnType< '/api/apm/services/{serviceName}/transaction_groups' >; -const DEFAULT_RESPONSE: TransactionsAPIResponse = { - items: [], +const DEFAULT_RESPONSE: Partial = { + items: undefined, isAggregationAccurate: true, bucketSize: 0, }; diff --git a/x-pack/plugins/apm/public/hooks/use_annotations.ts b/x-pack/plugins/apm/public/hooks/use_annotations.ts new file mode 100644 index 0000000000000..2b1c2bec52b3d --- /dev/null +++ b/x-pack/plugins/apm/public/hooks/use_annotations.ts @@ -0,0 +1,38 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ +import { useParams } from 'react-router-dom'; +import { callApmApi } from '../services/rest/createCallApmApi'; +import { useFetcher } from './useFetcher'; +import { useUrlParams } from './useUrlParams'; + +const INITIAL_STATE = { annotations: [] }; + +export function useAnnotations() { + const { serviceName } = useParams<{ serviceName?: string }>(); + const { urlParams, uiFilters } = useUrlParams(); + const { start, end } = urlParams; + const { environment } = uiFilters; + + const { data = INITIAL_STATE } = useFetcher(() => { + if (start && end && serviceName) { + return callApmApi({ + pathname: '/api/apm/services/{serviceName}/annotation/search', + params: { + path: { + serviceName, + }, + query: { + start, + end, + environment, + }, + }, + }); + } + }, [start, end, environment, serviceName]); + + return data; +} diff --git a/x-pack/plugins/apm/public/selectors/chartSelectors.ts b/x-pack/plugins/apm/public/selectors/chartSelectors.ts index 8c6093859f969..450f02f70c6a4 100644 --- a/x-pack/plugins/apm/public/selectors/chartSelectors.ts +++ b/x-pack/plugins/apm/public/selectors/chartSelectors.ts @@ -31,40 +31,37 @@ export interface ITpmBucket { } export interface ITransactionChartData { - tpmSeries: ITpmBucket[]; - responseTimeSeries: TimeSeries[]; + tpmSeries?: ITpmBucket[]; + responseTimeSeries?: TimeSeries[]; mlJobId: string | undefined; } -const INITIAL_DATA = { - apmTimeseries: { - responseTimes: { - avg: [], - p95: [], - p99: [], - }, - tpmBuckets: [], - overallAvgDuration: null, - }, +const INITIAL_DATA: Partial = { + apmTimeseries: undefined, anomalyTimeseries: undefined, }; export function getTransactionCharts( { transactionType }: IUrlParams, - { apmTimeseries, anomalyTimeseries }: TimeSeriesAPIResponse = INITIAL_DATA + charts = INITIAL_DATA ): ITransactionChartData { - const tpmSeries = getTpmSeries(apmTimeseries, transactionType); - - const responseTimeSeries = getResponseTimeSeries({ - apmTimeseries, - anomalyTimeseries, - }); + const { apmTimeseries, anomalyTimeseries } = charts; - return { - tpmSeries, - responseTimeSeries, + const transactionCharts: ITransactionChartData = { + tpmSeries: undefined, + responseTimeSeries: undefined, mlJobId: anomalyTimeseries?.jobId, }; + + if (apmTimeseries) { + transactionCharts.tpmSeries = getTpmSeries(apmTimeseries, transactionType); + + transactionCharts.responseTimeSeries = getResponseTimeSeries({ + apmTimeseries, + anomalyTimeseries, + }); + } + return transactionCharts; } export function getResponseTimeSeries({ diff --git a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts index a42710947a792..b12dd73a20986 100644 --- a/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts +++ b/x-pack/plugins/apm/server/lib/errors/distribution/get_buckets.ts @@ -73,6 +73,6 @@ export async function getBuckets({ return { noHits: resp.hits.total.value === 0, - buckets, + buckets: resp.hits.total.value > 0 ? buckets : [], }; } diff --git a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts index 2bfd3c94ed34c..9020cb1b9953a 100644 --- a/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts +++ b/x-pack/plugins/apm/server/lib/helpers/create_es_client/create_apm_event_client/index.ts @@ -7,14 +7,16 @@ import { ValuesType } from 'utility-types'; import { APMBaseDoc } from '../../../../../typings/es_schemas/raw/apm_base_doc'; import { APMError } from '../../../../../typings/es_schemas/ui/apm_error'; -import { KibanaRequest } from '../../../../../../../../src/core/server'; +import { + KibanaRequest, + LegacyScopedClusterClient, +} from '../../../../../../../../src/core/server'; import { ProcessorEvent } from '../../../../../common/processor_event'; import { ESSearchRequest, ESSearchResponse, } from '../../../../../typings/elasticsearch'; import { ApmIndicesConfig } from '../../../settings/apm_indices/get_apm_indices'; -import { APMRequestHandlerContext } from '../../../../routes/typings'; import { addFilterToExcludeLegacyData } from './add_filter_to_exclude_legacy_data'; import { callClientWithDebug } from '../call_client_with_debug'; import { Transaction } from '../../../../../typings/es_schemas/ui/transaction'; @@ -51,20 +53,23 @@ type TypedSearchResponse< export type APMEventClient = ReturnType; export function createApmEventClient({ - context, + esClient, + debug, request, indices, options: { includeFrozen } = { includeFrozen: false }, }: { - context: APMRequestHandlerContext; + esClient: Pick< + LegacyScopedClusterClient, + 'callAsInternalUser' | 'callAsCurrentUser' + >; + debug: boolean; request: KibanaRequest; indices: ApmIndicesConfig; options: { includeFrozen: boolean; }; }) { - const client = context.core.elasticsearch.legacy.client; - return { search( params: TParams, @@ -77,14 +82,14 @@ export function createApmEventClient({ : withProcessorEventFilter; return callClientWithDebug({ - apiCaller: client.callAsCurrentUser, + apiCaller: esClient.callAsCurrentUser, operationName: 'search', params: { ...withPossibleLegacyDataFilter, ignore_throttled: !includeFrozen, }, request, - debug: context.params.query._debug, + debug, }); }, }; diff --git a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts index 5e75535c678b3..363c4128137e0 100644 --- a/x-pack/plugins/apm/server/lib/helpers/setup_request.ts +++ b/x-pack/plugins/apm/server/lib/helpers/setup_request.ts @@ -88,7 +88,8 @@ export async function setupRequest( const coreSetupRequest = { indices, apmEventClient: createApmEventClient({ - context, + esClient: context.core.elasticsearch.legacy.client, + debug: context.params.query._debug, request, indices, options: { includeFrozen }, diff --git a/x-pack/plugins/apm/server/plugin.ts b/x-pack/plugins/apm/server/plugin.ts index d3341b6c1b163..44269b1775953 100644 --- a/x-pack/plugins/apm/server/plugin.ts +++ b/x-pack/plugins/apm/server/plugin.ts @@ -10,14 +10,17 @@ import { map, take } from 'rxjs/operators'; import { CoreSetup, CoreStart, + KibanaRequest, Logger, Plugin, PluginInitializerContext, + RequestHandlerContext, } from 'src/core/server'; import { APMConfig, APMXPackConfig, mergeConfigs } from '.'; import { APMOSSPluginSetup } from '../../../../src/plugins/apm_oss/server'; import { HomeServerPluginSetup } from '../../../../src/plugins/home/server'; import { UsageCollectionSetup } from '../../../../src/plugins/usage_collection/server'; +import { UI_SETTINGS } from '../../../../src/plugins/data/common'; import { ActionsPlugin } from '../../actions/server'; import { AlertingPlugin } from '../../alerts/server'; import { CloudSetup } from '../../cloud/server'; @@ -30,6 +33,7 @@ import { TaskManagerSetupContract } from '../../task_manager/server'; import { APM_FEATURE, registerFeaturesUsage } from './feature'; import { registerApmAlerts } from './lib/alerts/register_apm_alerts'; import { createApmTelemetry } from './lib/apm_telemetry'; +import { createApmEventClient } from './lib/helpers/create_es_client/create_apm_event_client'; import { getInternalSavedObjectsClient } from './lib/helpers/get_internal_saved_objects_client'; import { createApmAgentConfigurationIndex } from './lib/settings/agent_configuration/create_agent_config_index'; import { getApmIndices } from './lib/settings/apm_indices/get_apm_indices'; @@ -42,6 +46,11 @@ import { uiSettings } from './ui_settings'; export interface APMPluginSetup { config$: Observable; getApmIndices: () => ReturnType; + createApmEventClient: (params: { + debug?: boolean; + request: KibanaRequest; + context: RequestHandlerContext; + }) => Promise>; } export class APMPlugin implements Plugin { @@ -141,13 +150,41 @@ export class APMPlugin implements Plugin { }, }); + const boundGetApmIndices = async () => + getApmIndices({ + savedObjectsClient: await getInternalSavedObjectsClient(core), + config: await mergedConfig$.pipe(take(1)).toPromise(), + }); + return { config$: mergedConfig$, - getApmIndices: async () => - getApmIndices({ - savedObjectsClient: await getInternalSavedObjectsClient(core), - config: await mergedConfig$.pipe(take(1)).toPromise(), - }), + getApmIndices: boundGetApmIndices, + createApmEventClient: async ({ + request, + context, + debug, + }: { + debug?: boolean; + request: KibanaRequest; + context: RequestHandlerContext; + }) => { + const [indices, includeFrozen] = await Promise.all([ + boundGetApmIndices(), + context.core.uiSettings.client.get(UI_SETTINGS.SEARCH_INCLUDE_FROZEN), + ]); + + const esClient = context.core.elasticsearch.legacy.client; + + return createApmEventClient({ + debug: debug ?? false, + esClient, + request, + indices, + options: { + includeFrozen, + }, + }); + }, }; } diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx index 0b9f47e188d15..646978dd68153 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.helpers.tsx @@ -221,6 +221,11 @@ export const setup = async () => { setFreeze, setIndexPriority: setIndexPriority('cold'), }, + delete: { + enable: enable('delete'), + setMinAgeValue: setMinAgeValue('delete'), + setMinAgeUnits: setMinAgeUnits('delete'), + }, }, }; }; diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts index 11fadf51f27f8..4ee67d1ed8a19 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts +++ b/x-pack/plugins/index_lifecycle_management/__jest__/client_integration/edit_policy/edit_policy.test.ts @@ -367,7 +367,6 @@ describe('', () => { expect(testBed.find('snapshotPolicyCombobox').prop('data-currentvalue')).toEqual([ { label: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, - value: DELETE_PHASE_POLICY.policy.phases.delete?.actions.wait_for_snapshot?.policy, }, ]); }); @@ -412,7 +411,7 @@ describe('', () => { test('wait for snapshot field should delete action if field is empty', async () => { const { actions } = testBed; - actions.setWaitForSnapshotPolicy(''); + await actions.setWaitForSnapshotPolicy(''); await actions.savePolicy(); const expected = { diff --git a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx index 4a3fedfb264ac..43910583ceec9 100644 --- a/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx +++ b/x-pack/plugins/index_lifecycle_management/__jest__/components/edit_policy.test.tsx @@ -20,27 +20,27 @@ import { notificationServiceMock, fatalErrorsServiceMock, } from '../../../../../src/core/public/mocks'; + import { usageCollectionPluginMock } from '../../../../../src/plugins/usage_collection/public/mocks'; + import { CloudSetup } from '../../../cloud/public'; import { EditPolicy } from '../../public/application/sections/edit_policy/edit_policy'; +import { + EditPolicyContextProvider, + EditPolicyContextValue, +} from '../../public/application/sections/edit_policy/edit_policy_context'; + import { KibanaContextProvider } from '../../public/shared_imports'; + import { init as initHttp } from '../../public/application/services/http'; import { init as initUiMetric } from '../../public/application/services/ui_metric'; import { init as initNotification } from '../../public/application/services/notification'; import { PolicyFromES } from '../../common/types'; -import { - positiveNumberRequiredMessage, - policyNameRequiredMessage, - policyNameStartsWithUnderscoreErrorMessage, - policyNameContainsCommaErrorMessage, - policyNameContainsSpaceErrorMessage, - policyNameMustBeDifferentErrorMessage, - policyNameAlreadyUsedErrorMessage, -} from '../../public/application/services/policies/policy_validation'; import { i18nTexts } from '../../public/application/sections/edit_policy/i18n_texts'; import { editPolicyHelpers } from './helpers'; +import { defaultPolicy } from '../../public/application/constants'; // @ts-ignore initHttp(axios.create({ adapter: axiosXhrAdapter })); @@ -122,14 +122,11 @@ const noRollover = async (rendered: ReactWrapper) => { const getNodeAttributeSelect = (rendered: ReactWrapper, phase: string) => { return findTestSubject(rendered, `${phase}-selectedNodeAttrs`); }; -const setPolicyName = (rendered: ReactWrapper, policyName: string) => { +const setPolicyName = async (rendered: ReactWrapper, policyName: string) => { const policyNameField = findTestSubject(rendered, 'policyNameField'); - policyNameField.simulate('change', { target: { value: policyName } }); - rendered.update(); -}; -const setPhaseAfterLegacy = (rendered: ReactWrapper, phase: string, after: string | number) => { - const afterInput = rendered.find(`input#${phase}-selectedMinimumAge`); - afterInput.simulate('change', { target: { value: after } }); + await act(async () => { + policyNameField.simulate('change', { target: { value: policyName } }); + }); rendered.update(); }; const setPhaseAfter = async (rendered: ReactWrapper, phase: string, after: string | number) => { @@ -157,6 +154,32 @@ const save = async (rendered: ReactWrapper) => { }); rendered.update(); }; + +const MyComponent = ({ + isCloudEnabled, + isNewPolicy, + policy: _policy, + existingPolicies, + getUrlForApp, + policyName, +}: EditPolicyContextValue & { isCloudEnabled: boolean }) => { + return ( + + + + + + ); +}; + describe('edit policy', () => { beforeAll(() => { jest.useFakeTimers(); @@ -179,14 +202,14 @@ describe('edit policy', () => { beforeEach(() => { component = ( - - - + ); ({ http } = editPolicyHelpers.setup()); @@ -198,62 +221,78 @@ describe('edit policy', () => { test('should show error when trying to save empty form', async () => { const rendered = mountWithIntl(component); await save(rendered); - expectedErrorMessages(rendered, [policyNameRequiredMessage]); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.policyNameRequiredMessage]); }); test('should show error when trying to save policy name with space', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'my policy'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameContainsSpaceErrorMessage]); + await setPolicyName(rendered, 'my policy'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.policyNameContainsInvalidChars]); }); test('should show error when trying to save policy name that is already used', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'testy0'); - rendered.update(); - await save(rendered); - expectedErrorMessages(rendered, [policyNameAlreadyUsedErrorMessage]); + await setPolicyName(rendered, 'testy0'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [ + i18nTexts.editPolicy.errors.policyNameAlreadyUsedErrorMessage, + ]); }); test('should show error when trying to save as new policy but using the same name', async () => { component = ( - ); const rendered = mountWithIntl(component); findTestSubject(rendered, 'saveAsNewSwitch').simulate('click'); rendered.update(); - setPolicyName(rendered, 'testy0'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameMustBeDifferentErrorMessage]); + await setPolicyName(rendered, 'testy0'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [ + i18nTexts.editPolicy.errors.policyNameAlreadyUsedErrorMessage, + ]); }); test('should show error when trying to save policy name with comma', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'my,policy'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameContainsCommaErrorMessage]); + await setPolicyName(rendered, 'my,policy'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.policyNameContainsInvalidChars]); }); test('should show error when trying to save policy name starting with underscore', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, '_mypolicy'); - await save(rendered); - expectedErrorMessages(rendered, [policyNameStartsWithUnderscoreErrorMessage]); + await setPolicyName(rendered, '_mypolicy'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [ + i18nTexts.editPolicy.errors.policyNameStartsWithUnderscoreErrorMessage, + ]); }); test('should show correct json in policy flyout', async () => { - const rendered = mountWithIntl(component); + const rendered = mountWithIntl( + + ); await act(async () => { findTestSubject(rendered, 'requestButton').simulate('click'); }); rendered.update(); + const json = rendered.find(`code`).text(); - const expected = `PUT _ilm/policy/\n${JSON.stringify( + const expected = `PUT _ilm/policy/my-policy\n${JSON.stringify( { policy: { phases: { @@ -282,7 +321,7 @@ describe('edit policy', () => { test('should show errors when trying to save with no max size and no max age', async () => { const rendered = mountWithIntl(component); expect(findTestSubject(rendered, 'rolloverSettingsRequired').exists()).toBeFalsy(); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '' } }); @@ -298,7 +337,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with -1 for max size', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '-1' } }); @@ -309,7 +348,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with 0 for max size', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxSizeInput = findTestSubject(rendered, 'hot-selectedMaxSizeStored'); await act(async () => { maxSizeInput.simulate('change', { target: { value: '-1' } }); @@ -319,7 +358,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with -1 for max age', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxAgeInput = findTestSubject(rendered, 'hot-selectedMaxAge'); await act(async () => { maxAgeInput.simulate('change', { target: { value: '-1' } }); @@ -329,7 +368,7 @@ describe('edit policy', () => { }); test('should show number above 0 required error when trying to save with 0 for max age', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); const maxAgeInput = findTestSubject(rendered, 'hot-selectedMaxAge'); await act(async () => { maxAgeInput.simulate('change', { target: { value: '0' } }); @@ -337,21 +376,21 @@ describe('edit policy', () => { waitForFormLibValidation(rendered); expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.numberGreatThan0Required]); }); - test('should show forcemerge input when rollover enabled', () => { + test('should show forcemerge input when rollover enabled', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); expect(findTestSubject(rendered, 'hot-forceMergeSwitch').exists()).toBeTruthy(); }); test('should hide forcemerge input when rollover is disabled', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await noRollover(rendered); waitForFormLibValidation(rendered); expect(findTestSubject(rendered, 'hot-forceMergeSwitch').exists()).toBeFalsy(); }); test('should show positive number required above zero error when trying to save hot phase with 0 for force merge', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); act(() => { findTestSubject(rendered, 'hot-forceMergeSwitch').simulate('click'); }); @@ -365,7 +404,7 @@ describe('edit policy', () => { }); test('should show positive number above 0 required error when trying to save hot phase with -1 for force merge', async () => { const rendered = mountWithIntl(component); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); findTestSubject(rendered, 'hot-forceMergeSwitch').simulate('click'); rendered.update(); const forcemergeInput = findTestSubject(rendered, 'hot-selectedForceMergeSegments'); @@ -379,7 +418,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save with -1 for index priority', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await setPhaseIndexPriority(rendered, 'hot', '-1'); waitForFormLibValidation(rendered); expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.nonNegativeNumberRequired]); @@ -397,7 +436,7 @@ describe('edit policy', () => { test('should show number required error when trying to save empty warm phase', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', ''); waitForFormLibValidation(rendered); @@ -406,7 +445,7 @@ describe('edit policy', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '0'); waitForFormLibValidation(rendered); @@ -415,7 +454,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save warm phase with -1 for after', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '-1'); waitForFormLibValidation(rendered); @@ -424,7 +463,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save warm phase with -1 for index priority', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); await setPhaseAfter(rendered, 'warm', '-1'); @@ -434,7 +473,7 @@ describe('edit policy', () => { test('should show positive number required above zero error when trying to save warm phase with 0 for shrink', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); act(() => { findTestSubject(rendered, 'shrinkSwitch').simulate('click'); @@ -451,7 +490,7 @@ describe('edit policy', () => { test('should show positive number above 0 required error when trying to save warm phase with -1 for shrink', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); act(() => { @@ -468,7 +507,7 @@ describe('edit policy', () => { test('should show positive number required above zero error when trying to save warm phase with 0 for force merge', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); act(() => { @@ -485,7 +524,7 @@ describe('edit policy', () => { test('should show positive number above 0 required error when trying to save warm phase with -1 for force merge', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); await setPhaseAfter(rendered, 'warm', '1'); await act(async () => { @@ -503,7 +542,7 @@ describe('edit policy', () => { server.respondImmediately = false; const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); @@ -517,7 +556,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'warm'); @@ -527,7 +566,7 @@ describe('edit policy', () => { test('should show node attributes input when attributes exist', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'warm'); @@ -539,7 +578,7 @@ describe('edit policy', () => { test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'warm'); @@ -568,7 +607,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); @@ -581,7 +620,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeTruthy(); @@ -594,7 +633,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeFalsy(); @@ -611,7 +650,7 @@ describe('edit policy', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); await setPhaseAfter(rendered, 'cold', '0'); waitForFormLibValidation(rendered); @@ -621,7 +660,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save cold phase with -1 for after', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); await setPhaseAfter(rendered, 'cold', '-1'); waitForFormLibValidation(rendered); @@ -631,7 +670,7 @@ describe('edit policy', () => { server.respondImmediately = false; const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeTruthy(); expect(rendered.find('.euiCallOut--warning').exists()).toBeFalsy(); @@ -645,7 +684,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'cold'); @@ -655,7 +694,7 @@ describe('edit policy', () => { test('should show node attributes input when attributes exist', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'cold'); @@ -667,7 +706,7 @@ describe('edit policy', () => { test('should show view node attributes link when attribute selected and show flyout when clicked', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); await openNodeAttributesSection(rendered, 'cold'); @@ -689,7 +728,7 @@ describe('edit policy', () => { test('should show positive number required error when trying to save with -1 for index priority', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); await setPhaseAfter(rendered, 'cold', '1'); await setPhaseIndexPriority(rendered, 'cold', '-1'); @@ -704,7 +743,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationWarning').exists()).toBeTruthy(); @@ -717,7 +756,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeTruthy(); @@ -730,7 +769,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'defaultAllocationNotice').exists()).toBeFalsy(); @@ -740,20 +779,20 @@ describe('edit policy', () => { test('should allow 0 for phase timing', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'delete'); - setPhaseAfterLegacy(rendered, 'delete', '0'); - await save(rendered); + await setPhaseAfter(rendered, 'delete', '0'); + waitForFormLibValidation(rendered); expectedErrorMessages(rendered, []); }); test('should show positive number required error when trying to save delete phase with -1 for after', async () => { const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'delete'); - setPhaseAfterLegacy(rendered, 'delete', '-1'); - await save(rendered); - expectedErrorMessages(rendered, [positiveNumberRequiredMessage]); + await setPhaseAfter(rendered, 'delete', '-1'); + waitForFormLibValidation(rendered); + expectedErrorMessages(rendered, [i18nTexts.editPolicy.errors.nonNegativeNumberRequired]); }); }); describe('not on cloud', () => { @@ -768,7 +807,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); @@ -782,14 +821,13 @@ describe('edit policy', () => { describe('on cloud', () => { beforeEach(() => { component = ( - - - + ); ({ http } = editPolicyHelpers.setup()); ({ server, httpRequestsMockHelpers } = http); @@ -808,7 +846,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); @@ -829,7 +867,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'warm'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); @@ -849,7 +887,7 @@ describe('edit policy', () => { }); const rendered = mountWithIntl(component); await noRollover(rendered); - setPolicyName(rendered, 'mypolicy'); + await setPolicyName(rendered, 'mypolicy'); await activatePhase(rendered, 'cold'); expect(rendered.find('.euiLoadingSpinner').exists()).toBeFalsy(); expect(findTestSubject(rendered, 'cloudDataTierCallout').exists()).toBeTruthy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/lib/policies.ts b/x-pack/plugins/index_lifecycle_management/public/application/lib/policies.ts new file mode 100644 index 0000000000000..c4a91978a3765 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/lib/policies.ts @@ -0,0 +1,32 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { PolicyFromES } from '../../../common/types'; + +export const splitSizeAndUnits = (field: string): { size: string; units: string } => { + let size = ''; + let units = ''; + + const result = /(\d+)(\w+)/.exec(field); + if (result) { + size = result[1]; + units = result[2]; + } + + return { + size, + units, + }; +}; + +export const getPolicyByName = ( + policies: PolicyFromES[] | null | undefined, + policyName: string = '' +): PolicyFromES | undefined => { + if (policies && policies.length > 0) { + return policies.find((policy: PolicyFromES) => policy.name === policyName); + } +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts index a04608338718e..326f6ff87dc3b 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/index.ts @@ -7,11 +7,8 @@ export { ActiveBadge } from './active_badge'; export { ErrableFormRow } from './form_errors'; export { LearnMoreLink } from './learn_more_link'; -export { MinAgeInput } from './min_age_input_legacy'; export { OptionalLabel } from './optional_label'; -export { PhaseErrorMessage } from './phase_error_message'; export { PolicyJsonFlyout } from './policy_json_flyout'; -export { SnapshotPolicies } from './snapshot_policies'; export { DescribedFormField } from './described_form_field'; export * from './phases'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx deleted file mode 100644 index 6fcf35b799289..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/min_age_input_legacy.tsx +++ /dev/null @@ -1,263 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { FormattedMessage } from '@kbn/i18n/react'; -import { i18n } from '@kbn/i18n'; - -import { EuiFieldNumber, EuiFlexGroup, EuiFlexItem, EuiFormRow, EuiSelect } from '@elastic/eui'; - -import { LearnMoreLink } from './learn_more_link'; -import { ErrableFormRow } from './form_errors'; -import { PhaseValidationErrors, propertyof } from '../../../services/policies/policy_validation'; -import { PhaseWithMinAge, Phases } from '../../../../../common/types'; - -function getTimingLabelForPhase(phase: keyof Phases) { - // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. - switch (phase) { - case 'warm': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeLabel', { - defaultMessage: 'Timing for warm phase', - }); - - case 'cold': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeLabel', { - defaultMessage: 'Timing for cold phase', - }); - - case 'delete': - return i18n.translate('xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeLabel', { - defaultMessage: 'Timing for delete phase', - }); - } -} - -function getUnitsAriaLabelForPhase(phase: keyof Phases) { - // NOTE: Hot phase isn't necessary, because indices begin in the hot phase. - switch (phase) { - case 'warm': - return i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.phaseWarm.minimumAgeUnitsAriaLabel', - { - defaultMessage: 'Units for timing of warm phase', - } - ); - - case 'cold': - return i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.phaseCold.minimumAgeUnitsAriaLabel', - { - defaultMessage: 'Units for timing of cold phase', - } - ); - - case 'delete': - return i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.phaseDelete.minimumAgeUnitsAriaLabel', - { - defaultMessage: 'Units for timing of delete phase', - } - ); - } -} - -interface Props { - rolloverEnabled: boolean; - errors?: PhaseValidationErrors; - phase: keyof Phases & string; - phaseData: T; - setPhaseData: (dataKey: keyof T & string, value: string) => void; - isShowingErrors: boolean; -} - -export const MinAgeInput = ({ - rolloverEnabled, - errors, - phaseData, - phase, - setPhaseData, - isShowingErrors, -}: React.PropsWithChildren>): React.ReactElement => { - let daysOptionLabel; - let hoursOptionLabel; - let minutesOptionLabel; - let secondsOptionLabel; - let millisecondsOptionLabel; - let microsecondsOptionLabel; - let nanosecondsOptionLabel; - - if (rolloverEnabled) { - daysOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverDaysOptionLabel', - { - defaultMessage: 'days from rollover', - } - ); - - hoursOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverHoursOptionLabel', - { - defaultMessage: 'hours from rollover', - } - ); - minutesOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMinutesOptionLabel', - { - defaultMessage: 'minutes from rollover', - } - ); - - secondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverSecondsOptionLabel', - { - defaultMessage: 'seconds from rollover', - } - ); - millisecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMilliSecondsOptionLabel', - { - defaultMessage: 'milliseconds from rollover', - } - ); - - microsecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverMicroSecondsOptionLabel', - { - defaultMessage: 'microseconds from rollover', - } - ); - - nanosecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.rolloverNanoSecondsOptionLabel', - { - defaultMessage: 'nanoseconds from rollover', - } - ); - } else { - daysOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationDaysOptionLabel', - { - defaultMessage: 'days from index creation', - } - ); - - hoursOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationHoursOptionLabel', - { - defaultMessage: 'hours from index creation', - } - ); - - minutesOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMinutesOptionLabel', - { - defaultMessage: 'minutes from index creation', - } - ); - - secondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationSecondsOptionLabel', - { - defaultMessage: 'seconds from index creation', - } - ); - - millisecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMilliSecondsOptionLabel', - { - defaultMessage: 'milliseconds from index creation', - } - ); - - microsecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationMicroSecondsOptionLabel', - { - defaultMessage: 'microseconds from index creation', - } - ); - - nanosecondsOptionLabel = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.creationNanoSecondsOptionLabel', - { - defaultMessage: 'nanoseconds from index creation', - } - ); - } - - // check that these strings are valid properties - const selectedMinimumAgeProperty = propertyof('selectedMinimumAge'); - const selectedMinimumAgeUnitsProperty = propertyof('selectedMinimumAgeUnits'); - return ( - - - - } - /> - } - > - { - setPhaseData(selectedMinimumAgeProperty, e.target.value); - }} - min={0} - /> - - - - - setPhaseData(selectedMinimumAgeUnitsProperty, e.target.value)} - options={[ - { - value: 'd', - text: daysOptionLabel, - }, - { - value: 'h', - text: hoursOptionLabel, - }, - { - value: 'm', - text: minutesOptionLabel, - }, - { - value: 's', - text: secondsOptionLabel, - }, - { - value: 'ms', - text: millisecondsOptionLabel, - }, - { - value: 'micros', - text: microsecondsOptionLabel, - }, - { - value: 'nanos', - text: nanosecondsOptionLabel, - }, - ]} - /> - - - - ); -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx deleted file mode 100644 index 750f68543f221..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phase_error_message.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -import React from 'react'; -import { EuiBadge } from '@elastic/eui'; -import { FormattedMessage } from '@kbn/i18n/react'; - -export const PhaseErrorMessage = ({ isShowingErrors }: { isShowingErrors: boolean }) => { - return isShowingErrors ? ( - - - - ) : null; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx index 84e955a91ad7c..b87243bd1a9a1 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/cold_phase/cold_phase.tsx @@ -13,19 +13,13 @@ import { EuiDescribedFormGroup, EuiTextColor } from '@elastic/eui'; import { Phases } from '../../../../../../../common/types'; -import { - useFormData, - useFormContext, - UseField, - ToggleField, - NumericField, -} from '../../../../../../shared_imports'; +import { useFormData, UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; import { useEditPolicyContext } from '../../../edit_policy_context'; -import { LearnMoreLink, ActiveBadge, PhaseErrorMessage, DescribedFormField } from '../../'; +import { LearnMoreLink, ActiveBadge, DescribedFormField } from '../../'; -import { MinAgeInputField, DataTierAllocationField, SetPriorityInput } from '../shared'; +import { MinAgeInputField, DataTierAllocationField, SetPriorityInput } from '../shared_fields'; const i18nTexts = { dataTierAllocation: { @@ -43,15 +37,13 @@ const formFieldPaths = { }; export const ColdPhase: FunctionComponent = () => { - const { originalPolicy } = useEditPolicyContext(); - const form = useFormContext(); + const { policy } = useEditPolicyContext(); const [formData] = useFormData({ watch: [formFieldPaths.enabled], }); const enabled = get(formData, formFieldPaths.enabled); - const isShowingErrors = form.isValid === false; return (
@@ -66,8 +58,7 @@ export const ColdPhase: FunctionComponent = () => { defaultMessage="Cold phase" /> {' '} - {enabled && !isShowingErrors ? : null} - + {enabled && }
} titleSize="s" @@ -128,9 +119,7 @@ export const ColdPhase: FunctionComponent = () => { 'xpack.indexLifecycleMgmt.editPolicy.coldPhase.numberOfReplicas.switchLabel', { defaultMessage: 'Set replicas' } ), - initialValue: Boolean( - originalPolicy.phases.cold?.actions?.allocate?.number_of_replicas - ), + initialValue: Boolean(policy.phases.cold?.actions?.allocate?.number_of_replicas), }} fullWidth > diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx similarity index 50% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx index 78ae66327654c..37323b97edc92 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/delete_phase.tsx @@ -7,53 +7,24 @@ import React, { FunctionComponent, Fragment } from 'react'; import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; -import { EuiDescribedFormGroup, EuiSwitch, EuiTextColor, EuiFormRow } from '@elastic/eui'; +import { EuiDescribedFormGroup, EuiTextColor, EuiFormRow } from '@elastic/eui'; -import { DeletePhase as DeletePhaseInterface, Phases } from '../../../../../../common/types'; +import { useFormData, UseField, ToggleField } from '../../../../../../shared_imports'; -import { useFormData } from '../../../../../shared_imports'; +import { ActiveBadge, LearnMoreLink, OptionalLabel } from '../../index'; -import { PhaseValidationErrors } from '../../../../services/policies/policy_validation'; +import { MinAgeInputField, SnapshotPoliciesField } from '../shared_fields'; -import { - ActiveBadge, - LearnMoreLink, - OptionalLabel, - PhaseErrorMessage, - MinAgeInput, - SnapshotPolicies, -} from '../'; -import { useRolloverPath } from './shared'; - -const deleteProperty: keyof Phases = 'delete'; -const phaseProperty = (propertyName: keyof DeletePhaseInterface) => propertyName; - -interface Props { - setPhaseData: (key: keyof DeletePhaseInterface & string, value: string | boolean) => void; - phaseData: DeletePhaseInterface; - isShowingErrors: boolean; - errors?: PhaseValidationErrors; - getUrlForApp: ( - appId: string, - options?: { - path?: string; - absolute?: boolean; - } - ) => string; -} +const formFieldPaths = { + enabled: '_meta.delete.enabled', +}; -export const DeletePhase: FunctionComponent = ({ - setPhaseData, - phaseData, - errors, - isShowingErrors, - getUrlForApp, -}) => { +export const DeletePhase: FunctionComponent = () => { const [formData] = useFormData({ - watch: useRolloverPath, + watch: formFieldPaths.enabled, }); - const hotPhaseRolloverEnabled = get(formData, useRolloverPath); + const enabled = get(formData, formFieldPaths.enabled); return (
@@ -66,8 +37,7 @@ export const DeletePhase: FunctionComponent = ({ defaultMessage="Delete phase" /> {' '} - {phaseData.phaseEnabled && !isShowingErrors ? : null} - + {enabled && }
} titleSize="s" @@ -79,39 +49,23 @@ export const DeletePhase: FunctionComponent = ({ defaultMessage="You no longer need your index. You can define when it is safe to delete it." />

- - } - id={`${deleteProperty}-${phaseProperty('phaseEnabled')}`} - checked={phaseData.phaseEnabled} - onChange={(e) => { - setPhaseData(phaseProperty('phaseEnabled'), e.target.checked); + } fullWidth > - {phaseData.phaseEnabled ? ( - - errors={errors} - phaseData={phaseData} - phase={deleteProperty} - isShowingErrors={isShowingErrors} - setPhaseData={setPhaseData} - rolloverEnabled={hotPhaseRolloverEnabled} - /> - ) : ( -
- )} + {enabled && } - {phaseData.phaseEnabled ? ( + {enabled ? ( @@ -145,11 +99,7 @@ export const DeletePhase: FunctionComponent = ({ } > - setPhaseData(phaseProperty('waitForSnapshotPolicy'), value)} - getUrlForApp={getUrlForApp} - /> + ) : null} diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts new file mode 100644 index 0000000000000..488e4e26cfce0 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/delete_phase/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +export { DeletePhase } from './delete_phase'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx index a184ddf5148b9..629c1388f61fb 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/hot_phase/hot_phase.tsx @@ -19,7 +19,6 @@ import { import { Phases } from '../../../../../../../common/types'; import { - useFormContext, useFormData, UseField, SelectField, @@ -29,26 +28,24 @@ import { import { i18nTexts } from '../../../i18n_texts'; -import { ROLLOVER_EMPTY_VALIDATION } from '../../../form_validations'; +import { ROLLOVER_EMPTY_VALIDATION } from '../../../form'; import { ROLLOVER_FORM_PATHS } from '../../../constants'; -import { LearnMoreLink, ActiveBadge, PhaseErrorMessage } from '../../'; +import { LearnMoreLink, ActiveBadge } from '../../'; -import { Forcemerge, SetPriorityInput, useRolloverPath } from '../shared'; +import { Forcemerge, SetPriorityInput, useRolloverPath } from '../shared_fields'; import { maxSizeStoredUnits, maxAgeUnits } from './constants'; const hotProperty: keyof Phases = 'hot'; export const HotPhase: FunctionComponent = () => { - const form = useFormContext(); const [formData] = useFormData({ watch: useRolloverPath, }); const isRolloverEnabled = get(formData, useRolloverPath); - const isShowingErrors = form.isValid === false; const [showEmptyRolloverFieldsError, setShowEmptyRolloverFieldsError] = useState(false); return ( @@ -62,8 +59,7 @@ export const HotPhase: FunctionComponent = () => { defaultMessage="Hot phase" /> {' '} - {isShowingErrors ? null : } - +
} titleSize="s" diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/cloud_data_tier_callout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/cloud_data_tier_callout.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/cloud_data_tier_callout.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.scss b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.scss similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.scss rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.scss diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/data_tier_allocation.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/data_tier_allocation.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/default_allocation_notice.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/default_allocation_notice.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/default_allocation_notice.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/index.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/no_node_attributes_warning.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/no_node_attributes_warning.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/no_node_attributes_warning.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/no_node_attributes_warning.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_allocation.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx similarity index 90% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_allocation.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx index 407bb9ea92e85..c1676d7074dbc 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_allocation.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_allocation.tsx @@ -10,12 +10,8 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiText, EuiSpacer } from '@elastic/eui'; -import { PhaseWithAllocationAction } from '../../../../../../../../../common/types'; - import { UseField, SelectField, useFormData } from '../../../../../../../../shared_imports'; -import { propertyof } from '../../../../../../../services/policies/policy_validation'; - import { LearnMoreLink } from '../../../../learn_more_link'; import { NodeAttrsDetails } from './node_attrs_details'; @@ -61,9 +57,6 @@ export const NodeAllocation: FunctionComponent = ({ phase, nodes }) nodeOptions.sort((a, b) => a.value.localeCompare(b.value)); - // check that this string is a valid property - const nodeAttrsProperty = propertyof('selectedNodeAttrs'); - return ( <> @@ -100,7 +93,7 @@ export const NodeAllocation: FunctionComponent = ({ phase, nodes }) ) : undefined, euiFieldProps: { - 'data-test-subj': `${phase}-${nodeAttrsProperty}`, + 'data-test-subj': `${phase}-selectedNodeAttrs`, options: [{ text: i18nTexts.doNotModifyAllocationOption, value: '' }].concat( nodeOptions ), diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_attrs_details.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_attrs_details.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_attrs_details.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_attrs_details.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_data_provider.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_data_provider.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/node_data_provider.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/node_data_provider.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/types.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/components/types.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/components/types.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/data_tier_allocation_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/data_tier_allocation_field.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/data_tier_allocation_field.tsx diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/index.ts similarity index 100% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/data_tier_allocation_field/index.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/data_tier_allocation_field/index.ts diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/forcemerge_field.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx similarity index 94% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/forcemerge_field.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx index b410bd0e6b3b0..b05d49be497cd 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared/forcemerge_field.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/forcemerge_field.tsx @@ -21,11 +21,11 @@ interface Props { } export const Forcemerge: React.FunctionComponent = ({ phase }) => { - const { originalPolicy } = useEditPolicyContext(); + const { policy } = useEditPolicyContext(); const initialToggleValue = useMemo(() => { - return Boolean(originalPolicy.phases[phase]?.actions?.forcemerge); - }, [originalPolicy, phase]); + return Boolean(policy.phases[phase]?.actions?.forcemerge); + }, [policy, phase]); return ( = ({ phase }) => { - const phaseIndexPriorityProperty = propertyof('phaseIndexPriority'); return ( = ({ phase }) => { componentProps={{ fullWidth: false, euiFieldProps: { - 'data-test-subj': `${phase}-${phaseIndexPriorityProperty}`, - min: 1, + 'data-test-subj': `${phase}-phaseIndexPriority`, + min: 0, }, }} /> diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx similarity index 68% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies.tsx rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx index cc2849b5c8e9c..e9f9f331e410a 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/snapshot_policies.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/shared_fields/snapshot_policies_field.tsx @@ -4,52 +4,39 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment } from 'react'; - +import React from 'react'; +import { get } from 'lodash'; import { FormattedMessage } from '@kbn/i18n/react'; import { i18n } from '@kbn/i18n'; -import { ApplicationStart } from 'kibana/public'; import { EuiButtonIcon, EuiCallOut, - EuiComboBox, EuiComboBoxOptionOption, EuiLink, EuiSpacer, } from '@elastic/eui'; -import { useLoadSnapshotPolicies } from '../../../services/api'; +import { UseField, ComboBoxField, useFormData } from '../../../../../../shared_imports'; +import { useLoadSnapshotPolicies } from '../../../../../services/api'; +import { useEditPolicyContext } from '../../../edit_policy_context'; + +const waitForSnapshotFormField = 'phases.delete.actions.wait_for_snapshot.policy'; -interface Props { - value: string; - onChange: (value: string) => void; - getUrlForApp: ApplicationStart['getUrlForApp']; -} -export const SnapshotPolicies: React.FunctionComponent = ({ - value, - onChange, - getUrlForApp, -}) => { +export const SnapshotPoliciesField: React.FunctionComponent = () => { + const { getUrlForApp } = useEditPolicyContext(); const { error, isLoading, data, resendRequest } = useLoadSnapshotPolicies(); + const [formData] = useFormData({ + watch: waitForSnapshotFormField, + }); + + const selectedSnapshotPolicy = get(formData, waitForSnapshotFormField); const policies = data.map((name: string) => ({ label: name, value: name, })); - const onComboChange = (options: EuiComboBoxOptionOption[]) => { - if (options.length > 0) { - onChange(options[0].label); - } else { - onChange(''); - } - }; - - const onCreateOption = (newValue: string) => { - onChange(newValue); - }; - const getUrlForSnapshotPolicyWizard = () => { return getUrlForApp('management', { path: `data/snapshot_restore/add_policy`, @@ -59,14 +46,14 @@ export const SnapshotPolicies: React.FunctionComponent = ({ let calloutContent; if (error) { calloutContent = ( - + <> + <> = ({ } )} /> - + } > = ({ defaultMessage="Refresh this field and enter the name of an existing snapshot policy." /> - + ); } else if (data.length === 0) { calloutContent = ( - + <> = ({ }} /> - + ); - } else if (value && !data.includes(value)) { + } else if (selectedSnapshotPolicy && !data.includes(selectedSnapshotPolicy)) { calloutContent = ( - + <> = ({ }} /> - + ); } return ( - - + path={waitForSnapshotFormField}> + {(field) => { + const singleSelectionArray: [selectedSnapshot?: string] = field.value + ? [field.value] + : []; + + return ( + { + field.setValue(newOption); }, - ] - : [] - } - onChange={onComboChange} - noSuggestions={!!(error || data.length === 0)} - /> + onChange: (options: EuiComboBoxOptionOption[]) => { + if (options.length > 0) { + field.setValue(options[0].label); + } else { + field.setValue(''); + } + }, + }} + /> + ); + }} +
{calloutContent} - + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx index 06c16e8bdd5ab..94fd2ee9edaca 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/phases/warm_phase/warm_phase.tsx @@ -17,23 +17,17 @@ import { EuiDescribedFormGroup, } from '@elastic/eui'; -import { - useFormData, - UseField, - ToggleField, - useFormContext, - NumericField, -} from '../../../../../../shared_imports'; +import { useFormData, UseField, ToggleField, NumericField } from '../../../../../../shared_imports'; import { Phases } from '../../../../../../../common/types'; -import { useRolloverPath, MinAgeInputField, Forcemerge, SetPriorityInput } from '../shared'; +import { useRolloverPath, MinAgeInputField, Forcemerge, SetPriorityInput } from '../shared_fields'; import { useEditPolicyContext } from '../../../edit_policy_context'; -import { LearnMoreLink, ActiveBadge, PhaseErrorMessage, DescribedFormField } from '../../'; +import { LearnMoreLink, ActiveBadge, DescribedFormField } from '../../'; -import { DataTierAllocationField } from '../shared'; +import { DataTierAllocationField } from '../shared_fields'; const i18nTexts = { shrinkLabel: i18n.translate('xpack.indexLifecycleMgmt.warmPhase.shrinkIndexLabel', { @@ -54,8 +48,7 @@ const formFieldPaths = { }; export const WarmPhase: FunctionComponent = () => { - const { originalPolicy } = useEditPolicyContext(); - const form = useFormContext(); + const { policy } = useEditPolicyContext(); const [formData] = useFormData({ watch: [useRolloverPath, formFieldPaths.enabled, formFieldPaths.warmPhaseOnRollover], }); @@ -63,7 +56,6 @@ export const WarmPhase: FunctionComponent = () => { const enabled = get(formData, formFieldPaths.enabled); const hotPhaseRolloverEnabled = get(formData, useRolloverPath); const warmPhaseOnRollover = get(formData, formFieldPaths.warmPhaseOnRollover); - const isShowingErrors = form.isValid === false; return (
@@ -77,8 +69,7 @@ export const WarmPhase: FunctionComponent = () => { defaultMessage="Warm phase" /> {' '} - {enabled && !isShowingErrors ? : null} - + {enabled && }
} titleSize="s" @@ -161,9 +152,7 @@ export const WarmPhase: FunctionComponent = () => { 'xpack.indexLifecycleMgmt.editPolicy.warmPhase.numberOfReplicas.switchLabel', { defaultMessage: 'Set replicas' } ), - initialValue: Boolean( - originalPolicy.phases.warm?.actions?.allocate?.number_of_replicas - ), + initialValue: Boolean(policy.phases.warm?.actions?.allocate?.number_of_replicas), }} fullWidth > @@ -203,7 +192,7 @@ export const WarmPhase: FunctionComponent = () => { 'data-test-subj': 'shrinkSwitch', label: i18nTexts.shrinkLabel, 'aria-label': i18nTexts.shrinkLabel, - initialValue: Boolean(originalPolicy.phases.warm?.actions?.shrink), + initialValue: Boolean(policy.phases.warm?.actions?.shrink), }} fullWidth > diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx index 7098b018d6dfd..a8b1680ebde07 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/components/policy_json_flyout.tsx @@ -7,7 +7,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; - import { EuiButtonEmpty, EuiCodeBlock, @@ -25,19 +24,15 @@ import { import { SerializedPolicy } from '../../../../../common/types'; import { useFormContext, useFormData } from '../../../../shared_imports'; + import { FormInternal } from '../types'; interface Props { - legacyPolicy: SerializedPolicy; close: () => void; policyName: string; } -export const PolicyJsonFlyout: React.FunctionComponent = ({ - policyName, - close, - legacyPolicy, -}) => { +export const PolicyJsonFlyout: React.FunctionComponent = ({ policyName, close }) => { /** * policy === undefined: we are checking validity * policy === null: we have determined the policy is invalid @@ -51,20 +46,11 @@ export const PolicyJsonFlyout: React.FunctionComponent = ({ const updatePolicy = useCallback(async () => { setPolicy(undefined); if (await validateForm()) { - const p = getFormData() as SerializedPolicy; - setPolicy({ - ...legacyPolicy, - phases: { - ...legacyPolicy.phases, - hot: p.phases.hot, - warm: p.phases.warm, - cold: p.phases.cold, - }, - }); + setPolicy(getFormData() as SerializedPolicy); } else { setPolicy(null); } - }, [setPolicy, getFormData, legacyPolicy, validateForm]); + }, [setPolicy, getFormData, validateForm]); useEffect(() => { updatePolicy(); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx index c82a420b74857..ebef80871b83d 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.container.tsx @@ -12,8 +12,11 @@ import { FormattedMessage } from '@kbn/i18n/react'; import { useKibana } from '../../../shared_imports'; import { useLoadPoliciesList } from '../../services/api'; +import { getPolicyByName } from '../../lib/policies'; +import { defaultPolicy } from '../../constants'; import { EditPolicy as PresentationComponent } from './edit_policy'; +import { EditPolicyContextProvider } from './edit_policy_context'; interface RouterProps { policyName: string; @@ -44,6 +47,7 @@ export const EditPolicy: React.FunctionComponent { breadcrumbService.setBreadcrumbs('editPolicy'); }, [breadcrumbService]); + if (isLoading) { return ( + + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx index 5397f5da2d6bb..1abbe884c2dc2 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy.tsx @@ -4,7 +4,8 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useEffect, useState, useCallback, useMemo } from 'react'; +import React, { Fragment, useEffect, useState, useMemo } from 'react'; +import { get } from 'lodash'; import { RouteComponentProps } from 'react-router-dom'; @@ -16,7 +17,6 @@ import { EuiButton, EuiButtonEmpty, EuiDescribedFormGroup, - EuiFieldText, EuiFlexGroup, EuiFlexItem, EuiFormRow, @@ -30,31 +30,13 @@ import { EuiTitle, } from '@elastic/eui'; -import { useForm, Form } from '../../../shared_imports'; +import { useForm, Form, UseField, TextField, useFormData } from '../../../shared_imports'; import { toasts } from '../../services/notification'; -import { LegacyPolicy, PolicyFromES, SerializedPolicy } from '../../../../common/types'; - -import { defaultPolicy } from '../../constants'; - -import { - validatePolicy, - ValidationErrors, - findFirstError, -} from '../../services/policies/policy_validation'; - -import { savePolicy } from '../../services/policies/policy_save'; +import { savePolicy } from './save_policy'; import { - deserializePolicy, - getPolicyByName, - initializeNewPolicy, - legacySerializePolicy, -} from '../../services/policies/policy_serialization'; - -import { - ErrableFormRow, LearnMoreLink, PolicyJsonFlyout, ColdPhase, @@ -63,93 +45,66 @@ import { WarmPhase, } from './components'; -import { schema } from './form_schema'; -import { deserializer } from './deserializer'; -import { createSerializer } from './serializer'; +import { schema, deserializer, createSerializer, createPolicyNameValidations } from './form'; -import { EditPolicyContextProvider } from './edit_policy_context'; +import { useEditPolicyContext } from './edit_policy_context'; +import { FormInternal } from './types'; export interface Props { - policies: PolicyFromES[]; - policyName: string; - getUrlForApp: ( - appId: string, - options?: { - path?: string; - absolute?: boolean; - } - ) => string; history: RouteComponentProps['history']; } -const mergeAllSerializedPolicies = ( - serializedPolicy: SerializedPolicy, - legacySerializedPolicy: SerializedPolicy -): SerializedPolicy => { - return { - ...legacySerializedPolicy, - phases: { - ...legacySerializedPolicy.phases, - hot: serializedPolicy.phases.hot, - warm: serializedPolicy.phases.warm, - cold: serializedPolicy.phases.cold, - }, - }; -}; +const policyNamePath = 'name'; -export const EditPolicy: React.FunctionComponent = ({ - policies, - policyName, - history, - getUrlForApp, -}) => { +export const EditPolicy: React.FunctionComponent = ({ history }) => { useEffect(() => { window.scrollTo(0, 0); }, []); - const [isShowingErrors, setIsShowingErrors] = useState(false); - const [errors, setErrors] = useState(); const [isShowingPolicyJsonFlyout, setIsShowingPolicyJsonFlyout] = useState(false); - - const existingPolicy = getPolicyByName(policies, policyName); + const { + isNewPolicy, + policy: currentPolicy, + existingPolicies, + policyName, + } = useEditPolicyContext(); const serializer = useMemo(() => { - return createSerializer(existingPolicy?.policy); - }, [existingPolicy?.policy]); + return createSerializer(isNewPolicy ? undefined : currentPolicy); + }, [isNewPolicy, currentPolicy]); - const originalPolicy = existingPolicy?.policy ?? defaultPolicy; + const [saveAsNew, setSaveAsNew] = useState(isNewPolicy); + const originalPolicyName: string = isNewPolicy ? '' : policyName!; const { form } = useForm({ schema, - defaultValue: originalPolicy, + defaultValue: { + ...currentPolicy, + name: originalPolicyName, + }, deserializer, serializer, }); - const [policy, setPolicy] = useState(() => - existingPolicy ? deserializePolicy(existingPolicy) : initializeNewPolicy(policyName) + const [formData] = useFormData({ form, watch: policyNamePath }); + const currentPolicyName = get(formData, policyNamePath); + + const policyNameValidations = useMemo( + () => + createPolicyNameValidations({ + originalPolicyName, + policies: existingPolicies, + saveAsNewPolicy: saveAsNew, + }), + [originalPolicyName, existingPolicies, saveAsNew] ); - const isNewPolicy: boolean = !Boolean(existingPolicy); - const [saveAsNew, setSaveAsNew] = useState(isNewPolicy); - const originalPolicyName: string = existingPolicy ? existingPolicy.name : ''; - const backToPolicyList = () => { history.push('/policies'); }; const submit = async () => { - setIsShowingErrors(true); - const { data: formLibPolicy, isValid: newIsValid } = await form.submit(); - const [legacyIsValid, validationErrors] = validatePolicy( - saveAsNew, - policy, - policies, - originalPolicyName - ); - setErrors(validationErrors); - - const isValid = legacyIsValid && newIsValid; + const { data: policy, isValid } = await form.submit(); if (!isValid) { toasts.addDanger( @@ -157,22 +112,11 @@ export const EditPolicy: React.FunctionComponent = ({ defaultMessage: 'Please fix the errors on this page.', }) ); - // This functionality will not be required for once form lib is fully adopted for this form - // because errors are reported as fields are edited. - if (!legacyIsValid) { - const firstError = findFirstError(validationErrors); - const errorRowId = `${firstError ? firstError.replace('.', '-') : ''}-row`; - const element = document.getElementById(errorRowId); - if (element) { - element.scrollIntoView({ block: 'center', inline: 'nearest' }); - } - } } else { - const readSerializedPolicy = () => { - const legacySerializedPolicy = legacySerializePolicy(policy, existingPolicy?.policy); - return mergeAllSerializedPolicies(formLibPolicy, legacySerializedPolicy); - }; - const success = await savePolicy(readSerializedPolicy, isNewPolicy || saveAsNew); + const success = await savePolicy( + { ...policy, name: saveAsNew ? currentPolicyName : originalPolicyName }, + isNewPolicy || saveAsNew + ); if (success) { backToPolicyList(); } @@ -183,248 +127,217 @@ export const EditPolicy: React.FunctionComponent = ({ setIsShowingPolicyJsonFlyout(!isShowingPolicyJsonFlyout); }; - const setPhaseData = useCallback( - (phase: keyof LegacyPolicy['phases'], key: string, value: any) => { - setPolicy((nextPolicy) => ({ - ...nextPolicy, - phases: { - ...nextPolicy.phases, - [phase]: { ...nextPolicy.phases[phase], [key]: value }, - }, - })); - }, - [setPolicy] - ); - - const setDeletePhaseData = useCallback( - (key: string, value: any) => setPhaseData('delete', key, value), - [setPhaseData] - ); - return ( - - - - - -

- {isNewPolicy - ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { - defaultMessage: 'Create an index lifecycle policy', - }) - : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { - defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', - values: { originalPolicyName }, - })} -

-
- -
-
- - -

- + +

+ {isNewPolicy + ? i18n.translate('xpack.indexLifecycleMgmt.editPolicy.createPolicyMessage', { + defaultMessage: 'Create an index lifecycle policy', + }) + : i18n.translate('xpack.indexLifecycleMgmt.editPolicy.editPolicyMessage', { + defaultMessage: 'Edit index lifecycle policy {originalPolicyName}', + values: { originalPolicyName }, + })} +

+ + +
+ + + +

+ {' '} - - } - /> -

-
+ />{' '} + + } + /> +

+ - + - {isNewPolicy ? null : ( - - -

- - - - .{' '} + {isNewPolicy ? null : ( + + +

+ + + .{' '} + -

-
- - - - { - setSaveAsNew(e.target.checked); - }} - label={ - - - - } /> - -
- )} - - {saveAsNew || isNewPolicy ? ( - - +

+ + + + + { + setSaveAsNew(e.target.checked); + }} + label={ + -
- } - titleSize="s" - fullWidth - > - + + + )} + + {saveAsNew || isNewPolicy ? ( + + - } - > - { - setPolicy({ ...policy, name: e.target.value }); - }} - /> - - - ) : null} + +
+ } + titleSize="s" + fullWidth + > + + path={policyNamePath} + config={{ + label: i18n.translate('xpack.indexLifecycleMgmt.editPolicy.policyNameLabel', { + defaultMessage: 'Policy name', + }), + helpText: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.validPolicyNameMessage', + { + defaultMessage: + 'A policy name cannot start with an underscore and cannot contain a question mark or a space.', + } + ), + validations: policyNameValidations, + }} + component={TextField} + componentProps={{ + fullWidth: false, + euiFieldProps: { + 'data-test-subj': 'policyNameField', + }, + }} + /> + + ) : null} - + - + - + - + - + - + - + - 0 - } - getUrlForApp={getUrlForApp} - setPhaseData={setDeletePhaseData} - phaseData={policy.phases.delete} - /> + - - - - - - - - {saveAsNew ? ( - - ) : ( - - )} - - - - - + + + + + + + + {saveAsNew ? ( - - - - - - - - {isShowingPolicyJsonFlyout ? ( - - ) : ( + ) : ( + + )} + + + + + - )} - - - - - {isShowingPolicyJsonFlyout ? ( - setIsShowingPolicyJsonFlyout(false)} - /> - ) : null} - -
- - - - + + + + + + + + {isShowingPolicyJsonFlyout ? ( + + ) : ( + + )} + + + + + {isShowingPolicyJsonFlyout ? ( + setIsShowingPolicyJsonFlyout(false)} + /> + ) : null} + + + + + ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx index 4748c26d6cec1..da5f940b1b6c8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/edit_policy_context.tsx @@ -5,10 +5,16 @@ */ import React, { createContext, ReactChild, useContext } from 'react'; -import { SerializedPolicy } from '../../../../common/types'; +import { ApplicationStart } from 'kibana/public'; -interface EditPolicyContextValue { - originalPolicy: SerializedPolicy; +import { PolicyFromES, SerializedPolicy } from '../../../../common/types'; + +export interface EditPolicyContextValue { + isNewPolicy: boolean; + policy: SerializedPolicy; + existingPolicies: PolicyFromES[]; + getUrlForApp: ApplicationStart['getUrlForApp']; + policyName?: string; } const EditPolicyContext = createContext(null as any); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/deserializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts similarity index 82% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/deserializer.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts index f0294a5391d21..5af8807f2dec8 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/deserializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/deserializer.ts @@ -6,17 +6,17 @@ import { produce } from 'immer'; -import { SerializedPolicy } from '../../../../common/types'; +import { SerializedPolicy } from '../../../../../common/types'; -import { splitSizeAndUnits } from '../../services/policies/policy_serialization'; +import { splitSizeAndUnits } from '../../../lib/policies'; -import { determineDataTierAllocationType } from '../../lib'; +import { determineDataTierAllocationType } from '../../../lib'; -import { FormInternal } from './types'; +import { FormInternal } from '../types'; export const deserializer = (policy: SerializedPolicy): FormInternal => { const { - phases: { hot, warm, cold }, + phases: { hot, warm, cold, delete: deletePhase }, } = policy; const _meta: FormInternal['_meta'] = { @@ -37,6 +37,9 @@ export const deserializer = (policy: SerializedPolicy): FormInternal => { dataTierAllocationType: determineDataTierAllocationType(cold?.actions), freezeEnabled: Boolean(cold?.actions?.freeze), }, + delete: { + enabled: Boolean(deletePhase), + }, }; return produce( @@ -86,6 +89,14 @@ export const deserializer = (policy: SerializedPolicy): FormInternal => { draft._meta.cold.minAgeUnit = minAge.units; } } + + if (draft.phases.delete) { + if (draft.phases.delete.min_age) { + const minAge = splitSizeAndUnits(draft.phases.delete.min_age); + draft.phases.delete.min_age = minAge.size; + draft._meta.delete.minAgeUnit = minAge.units; + } + } } ); }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts new file mode 100644 index 0000000000000..82fa478832582 --- /dev/null +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/index.ts @@ -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; + * you may not use this file except in compliance with the Elastic License. + */ + +export { deserializer } from './deserializer'; + +export { createSerializer } from './serializer'; + +export { schema } from './schema'; + +export * from './validations'; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_schema.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts similarity index 90% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_schema.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts index 070f03f74b954..4d20db4018740 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_schema.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/schema.ts @@ -6,18 +6,19 @@ import { i18n } from '@kbn/i18n'; -import { FormSchema, fieldValidators } from '../../../shared_imports'; -import { defaultSetPriority, defaultPhaseIndexPriority } from '../../constants'; +import { FormSchema, fieldValidators } from '../../../../shared_imports'; +import { defaultSetPriority, defaultPhaseIndexPriority } from '../../../constants'; -import { FormInternal } from './types'; +import { FormInternal } from '../types'; import { ifExistsNumberGreaterThanZero, ifExistsNumberNonNegative, rolloverThresholdsValidator, -} from './form_validations'; + minAgeValidator, +} from './validations'; -import { i18nTexts } from './i18n_texts'; +import { i18nTexts } from '../i18n_texts'; const { emptyField, numberGreaterThanField } = fieldValidators; @@ -97,6 +98,18 @@ export const schema: FormSchema = { label: i18nTexts.editPolicy.allocationNodeAttributeFieldLabel, }, }, + delete: { + enabled: { + defaultValue: false, + label: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.deletePhase.activateWarmPhaseSwitchLabel', + { defaultMessage: 'Activate delete phase' } + ), + }, + minAgeUnit: { + defaultValue: 'd', + }, + }, }, phases: { hot: { @@ -177,15 +190,7 @@ export const schema: FormSchema = { defaultValue: '0', validations: [ { - validator: (arg) => - numberGreaterThanField({ - than: 0, - allowEquality: true, - message: i18nTexts.editPolicy.errors.nonNegativeNumberRequired, - })({ - ...arg, - value: arg.value === '' ? -Infinity : parseInt(arg.value, 10), - }), + validator: minAgeValidator, }, ], }, @@ -256,15 +261,7 @@ export const schema: FormSchema = { defaultValue: '0', validations: [ { - validator: (arg) => - numberGreaterThanField({ - than: 0, - allowEquality: true, - message: i18nTexts.editPolicy.errors.nonNegativeNumberRequired, - })({ - ...arg, - value: arg.value === '' ? -Infinity : parseInt(arg.value, 10), - }), + validator: minAgeValidator, }, ], }, @@ -292,5 +289,15 @@ export const schema: FormSchema = { }, }, }, + delete: { + min_age: { + defaultValue: '0', + validations: [ + { + validator: minAgeValidator, + }, + ], + }, + }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/serializer.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer.ts similarity index 90% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/serializer.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer.ts index 564b5a2c4e397..2274efda426ad 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/serializer.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/serializer.ts @@ -4,12 +4,11 @@ * you may not use this file except in compliance with the Elastic License. */ -import { isEmpty } from 'lodash'; +import { isEmpty, isNumber } from 'lodash'; -import { SerializedPolicy, SerializedActionWithAllocation } from '../../../../common/types'; +import { SerializedPolicy, SerializedActionWithAllocation } from '../../../../../common/types'; -import { FormInternal, DataAllocationMetaFields } from './types'; -import { isNumber } from '../../services/policies/policy_serialization'; +import { FormInternal, DataAllocationMetaFields } from '../types'; const serializeAllocateAction = ( { dataTierAllocationType, allocationNodeAttribute }: DataAllocationMetaFields, @@ -165,5 +164,22 @@ export const createSerializer = (originalPolicy?: SerializedPolicy) => ( } } + /** + * DELETE PHASE SERIALIZATION + */ + if (policy.phases.delete) { + if (policy.phases.delete.min_age) { + policy.phases.delete.min_age = `${policy.phases.delete.min_age}${_meta.delete.minAgeUnit}`; + } + + if (originalPolicy?.phases.delete?.actions) { + const { wait_for_snapshot: __, ...rest } = originalPolicy.phases.delete.actions; + policy.phases.delete.actions = { + ...policy.phases.delete.actions, + ...rest, + }; + } + } + return policy; }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_validations.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts similarity index 50% rename from x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_validations.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts index 9c855ccb41624..f2e26a552efc9 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form_validations.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/form/validations.ts @@ -4,13 +4,15 @@ * you may not use this file except in compliance with the Elastic License. */ -import { fieldValidators, ValidationFunc } from '../../../shared_imports'; +import { fieldValidators, ValidationFunc, ValidationConfig } from '../../../../shared_imports'; -import { ROLLOVER_FORM_PATHS } from './constants'; +import { ROLLOVER_FORM_PATHS } from '../constants'; -import { i18nTexts } from './i18n_texts'; +import { i18nTexts } from '../i18n_texts'; +import { PolicyFromES } from '../../../../../common/types'; +import { FormInternal } from '../types'; -const { numberGreaterThanField } = fieldValidators; +const { numberGreaterThanField, containsCharsField, emptyField, startsWithField } = fieldValidators; const createIfNumberExistsValidator = ({ than, @@ -46,7 +48,7 @@ export const ifExistsNumberNonNegative = createIfNumberExistsValidator({ * A special validation type used to keep track of validation errors for * the rollover threshold values not being set (e.g., age and doc count) */ -export const ROLLOVER_EMPTY_VALIDATION = 'EMPTY'; +export const ROLLOVER_EMPTY_VALIDATION = 'ROLLOVER_EMPTY_VALIDATION'; /** * An ILM policy requires that for rollover a value must be set for one of the threshold values. @@ -87,3 +89,68 @@ export const rolloverThresholdsValidator: ValidationFunc = ({ form }) => { fields[ROLLOVER_FORM_PATHS.maxSize].clearErrors(ROLLOVER_EMPTY_VALIDATION); } }; + +export const minAgeValidator: ValidationFunc = (arg) => + numberGreaterThanField({ + than: 0, + allowEquality: true, + message: i18nTexts.editPolicy.errors.nonNegativeNumberRequired, + })({ + ...arg, + value: arg.value === '' ? -Infinity : parseInt(arg.value, 10), + }); + +export const createPolicyNameValidations = ({ + policies, + saveAsNewPolicy, + originalPolicyName, +}: { + policies: PolicyFromES[]; + saveAsNewPolicy: boolean; + originalPolicyName?: string; +}): Array> => { + return [ + { + validator: emptyField(i18nTexts.editPolicy.errors.policyNameRequiredMessage), + }, + { + validator: startsWithField({ + message: i18nTexts.editPolicy.errors.policyNameStartsWithUnderscoreErrorMessage, + char: '_', + }), + }, + { + validator: containsCharsField({ + message: i18nTexts.editPolicy.errors.policyNameContainsInvalidChars, + chars: [',', ' '], + }), + }, + { + validator: (arg) => { + const policyName = arg.value; + if (window.TextEncoder && new window.TextEncoder().encode(policyName).length > 255) { + return { + message: i18nTexts.editPolicy.errors.policyNameTooLongErrorMessage, + }; + } + }, + }, + { + validator: (arg) => { + const policyName = arg.value; + if (saveAsNewPolicy && policyName === originalPolicyName) { + return { + message: i18nTexts.editPolicy.errors.policyNameMustBeDifferentErrorMessage, + }; + } else if (policyName !== originalPolicyName) { + const policyNames = policies.map((existingPolicy) => existingPolicy.name); + if (policyNames.includes(policyName)) { + return { + message: i18nTexts.editPolicy.errors.policyNameAlreadyUsedErrorMessage, + }; + } + } + }, + }, + ]; +}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts index 1fba69b7634ae..ccd5d3a568fe3 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/i18n_texts.ts @@ -98,6 +98,42 @@ export const i18nTexts = { defaultMessage: 'Only non-negative numbers are allowed.', } ), + policyNameContainsInvalidChars: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.errors.policyNameContainsInvalidCharsError', + { + defaultMessage: 'A policy name cannot contain spaces or commas.', + } + ), + policyNameAlreadyUsedErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', + { + defaultMessage: 'That policy name is already used.', + } + ), + policyNameMustBeDifferentErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', + { + defaultMessage: 'The policy name must be different.', + } + ), + policyNameRequiredMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', + { + defaultMessage: 'A policy name is required.', + } + ), + policyNameStartsWithUnderscoreErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', + { + defaultMessage: 'A policy name cannot start with an underscore.', + } + ), + policyNameTooLongErrorMessage: i18n.translate( + 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', + { + defaultMessage: 'A policy name cannot be longer than 255 bytes.', + } + ), }, }, }; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/save_policy.ts similarity index 84% rename from x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts rename to x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/save_policy.ts index 9cf622e830cb2..e2ab6a8817ef6 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_save.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/save_policy.ts @@ -3,23 +3,22 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ - import { i18n } from '@kbn/i18n'; import { METRIC_TYPE } from '@kbn/analytics'; import { SerializedPolicy } from '../../../../common/types'; -import { savePolicy as savePolicyApi } from '../api'; -import { showApiError } from '../api_errors'; -import { getUiMetricsForPhases, trackUiMetric } from '../ui_metric'; + import { UIM_POLICY_CREATE, UIM_POLICY_UPDATE } from '../../constants'; -import { toasts } from '../notification'; + +import { toasts } from '../../services/notification'; +import { savePolicy as savePolicyApi } from '../../services/api'; +import { getUiMetricsForPhases, trackUiMetric } from '../../services/ui_metric'; +import { showApiError } from '../../services/api_errors'; export const savePolicy = async ( - readSerializedPolicy: () => SerializedPolicy, + serializedPolicy: SerializedPolicy, isNew: boolean ): Promise => { - const serializedPolicy = readSerializedPolicy(); - try { await savePolicyApi(serializedPolicy); } catch (err) { diff --git a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts index 1884f8dbc0619..dc3d8a640e682 100644 --- a/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts +++ b/x-pack/plugins/index_lifecycle_management/public/application/sections/edit_policy/types.ts @@ -38,6 +38,10 @@ interface ColdPhaseMetaFields extends DataAllocationMetaFields, MinAgeField { freezeEnabled: boolean; } +interface DeletePhaseMetaFields extends MinAgeField { + enabled: boolean; +} + /** * Describes the shape of data after deserialization. */ @@ -50,5 +54,6 @@ export interface FormInternal extends SerializedPolicy { hot: HotPhaseMetaFields; warm: WarmPhaseMetaFields; cold: ColdPhaseMetaFields; + delete: DeletePhaseMetaFields; }; } diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts deleted file mode 100644 index 6ada039d45cd9..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/delete_phase.ts +++ /dev/null @@ -1,88 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { DeletePhase, SerializedDeletePhase } from '../../../../common/types'; -import { serializedPhaseInitialization } from '../../constants'; -import { isNumber, splitSizeAndUnits } from './policy_serialization'; -import { - numberRequiredMessage, - PhaseValidationErrors, - positiveNumberRequiredMessage, -} from './policy_validation'; - -const deletePhaseInitialization: DeletePhase = { - phaseEnabled: false, - selectedMinimumAge: '0', - selectedMinimumAgeUnits: 'd', - waitForSnapshotPolicy: '', -}; - -export const deletePhaseFromES = (phaseSerialized?: SerializedDeletePhase): DeletePhase => { - const phase = { ...deletePhaseInitialization }; - if (phaseSerialized === undefined || phaseSerialized === null) { - return phase; - } - - phase.phaseEnabled = true; - if (phaseSerialized.min_age) { - const { size: minAge, units: minAgeUnits } = splitSizeAndUnits(phaseSerialized.min_age); - phase.selectedMinimumAge = minAge; - phase.selectedMinimumAgeUnits = minAgeUnits; - } - - if (phaseSerialized.actions) { - const actions = phaseSerialized.actions; - - if (actions.wait_for_snapshot) { - phase.waitForSnapshotPolicy = actions.wait_for_snapshot.policy; - } - } - - return phase; -}; - -export const deletePhaseToES = ( - phase: DeletePhase, - originalEsPhase?: SerializedDeletePhase -): SerializedDeletePhase => { - if (!originalEsPhase) { - originalEsPhase = { ...serializedPhaseInitialization }; - } - const esPhase = { ...originalEsPhase }; - - if (isNumber(phase.selectedMinimumAge)) { - esPhase.min_age = `${phase.selectedMinimumAge}${phase.selectedMinimumAgeUnits}`; - } - - esPhase.actions = esPhase.actions ? { ...esPhase.actions } : {}; - - if (phase.waitForSnapshotPolicy) { - esPhase.actions.wait_for_snapshot = { - policy: phase.waitForSnapshotPolicy, - }; - } else { - delete esPhase.actions.wait_for_snapshot; - } - - return esPhase; -}; - -export const validateDeletePhase = (phase: DeletePhase): PhaseValidationErrors => { - if (!phase.phaseEnabled) { - return {}; - } - - const phaseErrors = {} as PhaseValidationErrors; - - // min age needs to be a positive number - if (!isNumber(phase.selectedMinimumAge)) { - phaseErrors.selectedMinimumAge = [numberRequiredMessage]; - } else if (parseInt(phase.selectedMinimumAge, 10) < 0) { - phaseErrors.selectedMinimumAge = [positiveNumberRequiredMessage]; - } - - return { ...phaseErrors }; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts deleted file mode 100644 index 19481b39a2c80..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.test.ts +++ /dev/null @@ -1,198 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ -// Prefer importing entire lodash library, e.g. import { get } from "lodash" -// eslint-disable-next-line no-restricted-imports -import cloneDeep from 'lodash/cloneDeep'; -import { deserializePolicy, legacySerializePolicy } from './policy_serialization'; -import { defaultNewDeletePhase } from '../../constants'; - -describe('Policy serialization', () => { - test('serialize a policy using "default" data allocation', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); - - test('serialize a policy using "custom" data allocation', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); - - test('serialize a policy using "custom" data allocation with no node attributes', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - // There should be no allocation action in any phases... - name: 'test', - phases: {}, - }); - }); - - test('serialize a policy using "none" data allocation with no node attributes', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - // There should be no allocation action in any phases... - name: 'test', - phases: {}, - }); - }); - - test('serialization does not alter the original policy', () => { - const originalPolicy = { - name: 'test', - phases: {}, - }; - - const originalClone = cloneDeep(originalPolicy); - - const deserializedPolicy = { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }; - - legacySerializePolicy(deserializedPolicy, originalPolicy); - expect(originalPolicy).toEqual(originalClone); - }); - - test('serialize a policy using "best_compression" codec for forcemerge', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: { - hot: { actions: {} }, - }, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); - - test('de-serialize a policy using "best_compression" codec for forcemerge', () => { - expect( - deserializePolicy({ - modified_date: Date.now().toString(), - name: 'test', - version: 1, - policy: { - name: 'test', - phases: { - hot: { - actions: { - rollover: { - max_age: '30d', - max_size: '50gb', - }, - forcemerge: { - max_num_segments: 1, - index_codec: 'best_compression', - }, - set_priority: { - priority: 100, - }, - }, - }, - }, - }, - }) - ).toEqual({ - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }); - }); - - test('delete "best_compression" codec for forcemerge if disabled in UI', () => { - expect( - legacySerializePolicy( - { - name: 'test', - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }, - { - name: 'test', - phases: {}, - } - ) - ).toEqual({ - name: 'test', - phases: {}, - }); - }); -}); diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts deleted file mode 100644 index 55e9d88dcd383..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_serialization.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { LegacyPolicy, PolicyFromES, SerializedPolicy } from '../../../../common/types'; - -import { defaultNewDeletePhase, serializedPhaseInitialization } from '../../constants'; - -import { deletePhaseFromES, deletePhaseToES } from './delete_phase'; - -export const splitSizeAndUnits = (field: string): { size: string; units: string } => { - let size = ''; - let units = ''; - - const result = /(\d+)(\w+)/.exec(field); - if (result) { - size = result[1]; - units = result[2]; - } - - return { - size, - units, - }; -}; - -export const isNumber = (value: any): boolean => value !== '' && value !== null && isFinite(value); - -export const getPolicyByName = ( - policies: PolicyFromES[] | null | undefined, - policyName: string = '' -): PolicyFromES | undefined => { - if (policies && policies.length > 0) { - return policies.find((policy: PolicyFromES) => policy.name === policyName); - } -}; - -export const initializeNewPolicy = (newPolicyName: string = ''): LegacyPolicy => { - return { - name: newPolicyName, - phases: { - delete: { ...defaultNewDeletePhase }, - }, - }; -}; - -export const deserializePolicy = (policy: PolicyFromES): LegacyPolicy => { - const { - name, - policy: { phases }, - } = policy; - - return { - name, - phases: { - delete: deletePhaseFromES(phases.delete), - }, - }; -}; - -export const legacySerializePolicy = ( - policy: LegacyPolicy, - originalEsPolicy: SerializedPolicy = { - name: policy.name, - phases: { hot: { ...serializedPhaseInitialization } }, - } -): SerializedPolicy => { - const serializedPolicy = { - name: policy.name, - phases: {}, - } as SerializedPolicy; - - if (policy.phases.delete.phaseEnabled) { - serializedPolicy.phases.delete = deletePhaseToES( - policy.phases.delete, - originalEsPolicy.phases.delete - ); - } - return serializedPolicy; -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts b/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts deleted file mode 100644 index 79c909c433f33..0000000000000 --- a/x-pack/plugins/index_lifecycle_management/public/application/services/policies/policy_validation.ts +++ /dev/null @@ -1,144 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License; - * you may not use this file except in compliance with the Elastic License. - */ - -import { i18n } from '@kbn/i18n'; -import { DeletePhase, LegacyPolicy, PolicyFromES } from '../../../../common/types'; -import { validateDeletePhase } from './delete_phase'; - -export const propertyof = (propertyName: keyof T & string) => propertyName; - -export const numberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.numberRequiredError', - { - defaultMessage: 'A number is required.', - } -); - -// TODO validation includes 0 -> should be non-negative number? -export const positiveNumberRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberRequiredError', - { - defaultMessage: 'Only positive numbers are allowed.', - } -); - -export const positiveNumbersAboveZeroErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.positiveNumberAboveZeroRequiredError', - { - defaultMessage: 'Only numbers above 0 are allowed.', - } -); - -export const policyNameRequiredMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameRequiredError', - { - defaultMessage: 'A policy name is required.', - } -); - -export const policyNameStartsWithUnderscoreErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameStartsWithUnderscoreError', - { - defaultMessage: 'A policy name cannot start with an underscore.', - } -); -export const policyNameContainsCommaErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsCommaError', - { - defaultMessage: 'A policy name cannot include a comma.', - } -); -export const policyNameContainsSpaceErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameContainsSpaceError', - { - defaultMessage: 'A policy name cannot include a space.', - } -); - -export const policyNameTooLongErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameTooLongError', - { - defaultMessage: 'A policy name cannot be longer than 255 bytes.', - } -); -export const policyNameMustBeDifferentErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.differentPolicyNameRequiredError', - { - defaultMessage: 'The policy name must be different.', - } -); -export const policyNameAlreadyUsedErrorMessage = i18n.translate( - 'xpack.indexLifecycleMgmt.editPolicy.policyNameAlreadyUsedError', - { - defaultMessage: 'That policy name is already used.', - } -); -export type PhaseValidationErrors = { - [P in keyof Partial]: string[]; -}; - -export interface ValidationErrors { - delete: PhaseValidationErrors; - policyName: string[]; -} - -export const validatePolicy = ( - saveAsNew: boolean, - policy: LegacyPolicy, - policies: PolicyFromES[], - originalPolicyName: string -): [boolean, ValidationErrors] => { - const policyNameErrors: string[] = []; - if (!policy.name) { - policyNameErrors.push(policyNameRequiredMessage); - } else { - if (policy.name.startsWith('_')) { - policyNameErrors.push(policyNameStartsWithUnderscoreErrorMessage); - } - if (policy.name.includes(',')) { - policyNameErrors.push(policyNameContainsCommaErrorMessage); - } - if (policy.name.includes(' ')) { - policyNameErrors.push(policyNameContainsSpaceErrorMessage); - } - if (window.TextEncoder && new window.TextEncoder().encode(policy.name).length > 255) { - policyNameErrors.push(policyNameTooLongErrorMessage); - } - - if (saveAsNew && policy.name === originalPolicyName) { - policyNameErrors.push(policyNameMustBeDifferentErrorMessage); - } else if (policy.name !== originalPolicyName) { - const policyNames = policies.map((existingPolicy) => existingPolicy.name); - if (policyNames.includes(policy.name)) { - policyNameErrors.push(policyNameAlreadyUsedErrorMessage); - } - } - } - - const deletePhaseErrors = validateDeletePhase(policy.phases.delete); - const isValid = policyNameErrors.length === 0 && Object.keys(deletePhaseErrors).length === 0; - return [ - isValid, - { - policyName: [...policyNameErrors], - delete: deletePhaseErrors, - }, - ]; -}; - -export const findFirstError = (errors?: ValidationErrors): string | undefined => { - if (!errors) { - return; - } - - if (errors.policyName.length > 0) { - return propertyof('policyName'); - } - - if (Object.keys(errors.delete).length > 0) { - return `${propertyof('delete')}.${Object.keys(errors.delete)[0]}`; - } -}; diff --git a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts index 023aeba57aa7a..a127574d5bad0 100644 --- a/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts +++ b/x-pack/plugins/index_lifecycle_management/public/shared_imports.ts @@ -18,6 +18,7 @@ export { getFieldValidityAndErrorMessage, useFormContext, FormSchema, + ValidationConfig, } from '../../../../src/plugins/es_ui_shared/static/forms/hook_form_lib'; export { fieldValidators } from '../../../../src/plugins/es_ui_shared/static/forms/helpers'; @@ -27,6 +28,8 @@ export { NumericField, SelectField, SuperSelectField, + ComboBoxField, + TextField, } from '../../../../src/plugins/es_ui_shared/static/forms/components'; export { KibanaContextProvider } from '../../../../src/plugins/kibana_react/public'; diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx index c9d99bcfb6d8d..0332f11aa78b3 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/config_panel/layer_panel.tsx @@ -451,6 +451,7 @@ export function LayerPanel( columnId: activeId, filterOperations: activeGroup.filterOperations, suggestedPriority: activeGroup?.suggestedPriority, + dimensionGroups: groups, setState: (newState: unknown) => { props.updateAll( datasourceId, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx index f7a6f0597bf9c..b3ea14efbae80 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/editor_frame.test.tsx @@ -601,7 +601,8 @@ describe('editor_frame', () => { setDatasourceState(updatedState); }); - expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(2); + // validation requires to calls this getConfiguration API + expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(6); expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith( expect.objectContaining({ state: updatedState, @@ -680,7 +681,8 @@ describe('editor_frame', () => { setDatasourceState({}); }); - expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(2); + // validation requires to calls this getConfiguration API + expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(6); expect(mockVisualization.getConfiguration).toHaveBeenLastCalledWith( expect.objectContaining({ frame: expect.objectContaining({ @@ -1193,7 +1195,8 @@ describe('editor_frame', () => { instance.find('[data-test-subj="lnsSuggestion"]').at(2).simulate('click'); }); - expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(1); + // validation requires to calls this getConfiguration API + expect(mockVisualization.getConfiguration).toHaveBeenCalledTimes(4); expect(mockVisualization.getConfiguration).toHaveBeenCalledWith( expect.objectContaining({ state: suggestionVisState, diff --git a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts index 28ad6c531e255..647c0f3ac9cca 100644 --- a/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts +++ b/x-pack/plugins/lens/public/editor_frame_service/editor_frame/state_helpers.ts @@ -6,7 +6,13 @@ import { SavedObjectReference } from 'kibana/public'; import { Ast } from '@kbn/interpreter/common'; -import { Datasource, DatasourcePublicAPI, FramePublicAPI, Visualization } from '../../types'; +import { + Datasource, + DatasourcePublicAPI, + FramePublicAPI, + Visualization, + VisualizationDimensionGroupConfig, +} from '../../types'; import { buildExpression } from './expression_helpers'; import { Document } from '../../persistence/saved_object_store'; import { VisualizeFieldContext } from '../../../../../../src/plugins/ui_actions/public'; @@ -104,8 +110,24 @@ export const validateDatasourceAndVisualization = ( longMessage: string; }> | undefined => { + const layersGroups = + currentVisualizationState && + currentVisualization + ?.getLayerIds(currentVisualizationState) + .reduce>((memo, layerId) => { + const groups = currentVisualization?.getConfiguration({ + frame: frameAPI, + layerId, + state: currentVisualizationState, + }).groups; + if (groups) { + memo[layerId] = groups; + } + return memo; + }, {}); + const datasourceValidationErrors = currentDatasourceState - ? currentDataSource?.getErrorMessages(currentDatasourceState) + ? currentDataSource?.getErrorMessages(currentDatasourceState, layersGroups) : undefined; const visualizationValidationErrors = currentVisualizationState diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx index 829bd333ce2cc..92a4dad14dd25 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/dimension_panel.test.tsx @@ -174,6 +174,7 @@ describe('IndexPatternDimensionEditorPanel', () => { } as unknown) as DataPublicPluginStart['fieldFormats'], } as unknown) as DataPublicPluginStart, core: {} as CoreSetup, + dimensionGroups: [], }; jest.clearAllMocks(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts index bbd1d4e0ae3ab..dd696f8be357f 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts +++ b/x-pack/plugins/lens/public/indexpattern_datasource/dimension_panel/droppable.test.ts @@ -146,6 +146,7 @@ describe('IndexPatternDimensionEditorPanel', () => { } as unknown) as DataPublicPluginStart['fieldFormats'], } as unknown) as DataPublicPluginStart, core: {} as CoreSetup, + dimensionGroups: [], }; jest.clearAllMocks(); diff --git a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx index ecca1b878e9a7..fa106e90d518a 100644 --- a/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx +++ b/x-pack/plugins/lens/public/indexpattern_datasource/indexpattern.tsx @@ -343,7 +343,7 @@ export function getIndexPatternDatasource({ getDatasourceSuggestionsFromCurrentState, getDatasourceSuggestionsForVisualizeField, - getErrorMessages(state) { + getErrorMessages(state, layersGroups) { if (!state) { return; } diff --git a/x-pack/plugins/lens/public/types.ts b/x-pack/plugins/lens/public/types.ts index 3c96579fdc943..4ad849c5d441e 100644 --- a/x-pack/plugins/lens/public/types.ts +++ b/x-pack/plugins/lens/public/types.ts @@ -184,7 +184,10 @@ export interface Datasource { ) => Array>; getPublicAPI: (props: PublicAPIProps) => DatasourcePublicAPI; - getErrorMessages: (state: T) => Array<{ shortMessage: string; longMessage: string }> | undefined; + getErrorMessages: ( + state: T, + layersGroups?: Record + ) => Array<{ shortMessage: string; longMessage: string }> | undefined; /** * uniqueLabels of dimensions exposed for aria-labels of dragged dimensions */ @@ -242,6 +245,7 @@ export type DatasourceDimensionEditorProps = DatasourceDimensionPro setState: StateSetter; core: Pick; dateRange: DateRange; + dimensionGroups: VisualizationDimensionGroupConfig[]; }; export type DatasourceDimensionTriggerProps = DatasourceDimensionProps & { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts index 3f5addb77cb33..48847686828a9 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.test.ts @@ -25,6 +25,7 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 42, + function: 'count > 4', }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( @@ -53,6 +54,7 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 42, + function: 'avg([aggField]) > 4.2', }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( @@ -80,6 +82,7 @@ describe('ActionContext', () => { date: '2020-01-01T00:00:00.000Z', group: '[group]', value: 4, + function: 'count between 4,5', }; const context = addMessages({ name: '[alert-name]' }, base, params); expect(context.title).toMatchInlineSnapshot( diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts index 5135e31e9322c..9bb0df9d07fd4 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/action_context.ts @@ -27,6 +27,8 @@ export interface BaseActionContext extends AlertInstanceContext { date: string; // the value that met the threshold value: number; + // the function that is used + function: string; } export function addMessages( @@ -42,9 +44,6 @@ export function addMessages( }, }); - const agg = params.aggField ? `${params.aggType}(${params.aggField})` : `${params.aggType}`; - const humanFn = `${agg} ${params.thresholdComparator} ${params.threshold.join(',')}`; - const window = `${params.timeWindowSize}${params.timeWindowUnit}`; const message = i18n.translate( 'xpack.stackAlerts.indexThreshold.alertTypeContextMessageDescription', @@ -55,7 +54,7 @@ export function addMessages( name: alertInfo.name, group: baseContext.group, value: baseContext.value, - function: humanFn, + function: baseContext.function, window, date: baseContext.date, }, diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts index 2f0cf3cbbcd16..d75f3af22ab06 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.test.ts @@ -46,6 +46,10 @@ describe('alertType', () => { "description": "The value that exceeded the threshold.", "name": "value", }, + Object { + "description": "A string describing the threshold comparator and threshold", + "name": "function", + }, ], "params": Array [ Object { diff --git a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts index 2a1ed429b7fe1..e0a9cd981dac0 100644 --- a/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts +++ b/x-pack/plugins/stack_alerts/server/alert_types/index_threshold/alert_type.ts @@ -83,6 +83,13 @@ export function getAlertType(service: Service): AlertType { return { @@ -107,6 +114,7 @@ export function getAlertType(service: Service): AlertType void; + setActionGroupIdByIndex?: (group: string, index: number) => void; setAlertProperty: (actions: AlertAction[]) => void; setActionParamsProperty: (key: string, value: any, index: number) => void; http: HttpSetup; - actionTypeRegistry: TypeRegistry; - toastNotifications: Pick< - ToastsApi, - 'get$' | 'add' | 'remove' | 'addSuccess' | 'addWarning' | 'addDanger' | 'addError' - >; + actionTypeRegistry: ActionTypeRegistryContract; + toastNotifications: ToastsSetup; + docLinks: DocLinksStart; actionTypes?: ActionType[]; messageVariables?: ActionVariable[]; defaultActionMessage?: string; - consumer: string; + capabilities: ApplicationStart['capabilities']; } ``` @@ -1339,17 +1339,20 @@ interface ActionAccordionFormProps { |Property|Description| |---|---| |actions|List of actions comes from alert.actions property.| -|defaultActionGroupId|Default action group id to which each new action will belong to.| +|defaultActionGroupId|Default action group id to which each new action will belong by default.| +|actionGroups|Optional. List of action groups to which new action can be assigned. The RunWhen field is only displayed when these action groups are specified| |setActionIdByIndex|Function for changing action 'id' by the proper index in alert.actions array.| +|setActionGroupIdByIndex|Function for changing action 'group' by the proper index in alert.actions array.| |setAlertProperty|Function for changing alert property 'actions'. Used when deleting action from the array to reset it.| |setActionParamsProperty|Function for changing action key/value property by index in alert.actions array.| |http|HttpSetup needed for executing API calls.| |actionTypeRegistry|Registry for action types.| -|toastNotifications|Toast messages.| +|toastNotifications|Toast messages Plugin Setup Contract.| +|docLinks|Documentation links Plugin Start Contract.| |actionTypes|Optional property, which allowes to define a list of available actions specific for a current plugin.| |actionTypes|Optional property, which allowes to define a list of variables for action 'message' property.| |defaultActionMessage|Optional property, which allowes to define a message value for action with 'message' property.| -|consumer|Name of the plugin that creates an action.| +|capabilities|Kibana core's Capabilities ApplicationStart['capabilities'].| AlertsContextProvider value options: diff --git a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts index d105e2c510bcd..a5b2fbb37e838 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts +++ b/x-pack/plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/index.ts @@ -5,7 +5,6 @@ */ import { lazy } from 'react'; import { i18n } from '@kbn/i18n'; - import { AlertTypeModel } from '../../../../types'; import { validateExpression } from './validation'; import { IndexThresholdAlertParams } from './types'; @@ -26,6 +25,12 @@ export function getAlertType(): AlertTypeModel import('./expression')), validate: validateExpression, + defaultActionMessage: i18n.translate( + 'xpack.triggersActionsUI.components.builtinAlertTypes.threshold.alertDefaultActionMessage', + { + defaultMessage: `alert \\{\\{alertName\\}\\} group \\{\\{context.group\\}\\} value \\{\\{context.value\\}\\} exceeded threshold \\{\\{context.function\\}\\} over \\{\\{params.timeWindowSize\\}\\}\\{\\{params.timeWindowUnit\\}\\} on \\{\\{context.date\\}\\}`, + } + ), requiresAppContext: false, }; } diff --git a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss index 24dbb865742d8..bb622829e997a 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss +++ b/x-pack/plugins/triggers_actions_ui/public/application/lib/check_action_type_enabled.scss @@ -3,9 +3,15 @@ } .actAccordionActionForm { - .euiCard { - box-shadow: none; - } + background-color: $euiColorLightestShade; +} + +.actAccordionActionForm .euiCard { + box-shadow: none; +} + +.actAccordionActionForm__button { + padding: $euiSizeM; } .actConnectorsListGrid { diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx index 7c718e8248e41..94452e70e6bfa 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.test.tsx @@ -6,7 +6,6 @@ import React, { Fragment, lazy } from 'react'; import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { coreMock } from '../../../../../../../src/core/public/mocks'; -import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { actionTypeRegistryMock } from '../../action_type_registry.mock'; import { ValidationResult, Alert, AlertAction } from '../../../types'; @@ -112,8 +111,6 @@ describe('action_form', () => { }; describe('action_form in alert', () => { - let wrapper: ReactWrapper; - async function setup(customActions?: AlertAction[]) { const { loadAllActions } = jest.requireMock('../../lib/action_connector_api'); loadAllActions.mockResolvedValueOnce([ @@ -217,7 +214,7 @@ describe('action_form', () => { mutedInstanceIds: [], } as unknown) as Alert; - wrapper = mountWithIntl( + const wrapper = mountWithIntl( { setActionIdByIndex={(id: string, index: number) => { initialAlert.actions[index].id = id; }} + actionGroups={[{ id: 'default', name: 'Default' }]} + setActionGroupIdByIndex={(group: string, index: number) => { + initialAlert.actions[index].group = group; + }} setAlertProperty={(_updatedActions: AlertAction[]) => {}} setActionParamsProperty={(key: string, value: any, index: number) => (initialAlert.actions[index] = { ...initialAlert.actions[index], [key]: value }) @@ -297,10 +298,12 @@ describe('action_form', () => { await nextTick(); wrapper.update(); }); + + return wrapper; } it('renders available action cards', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` ); @@ -314,7 +317,7 @@ describe('action_form', () => { }); it('does not render action types disabled by config', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( '[data-test-subj="disabled-by-config-ActionTypeSelectOption"]' ); @@ -322,52 +325,72 @@ describe('action_form', () => { }); it('render action types which is preconfigured only (disabled by config and with preconfigured connectors)', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); expect(actionOption.exists()).toBeTruthy(); }); + it('renders available action groups for the selected action type', async () => { + const wrapper = await setup(); + const actionOption = wrapper.find( + `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` + ); + actionOption.first().simulate('click'); + const actionGroupsSelect = wrapper.find( + `[data-test-subj="addNewActionConnectorActionGroup-0"]` + ); + expect((actionGroupsSelect.first().props() as any).options).toMatchInlineSnapshot(` + Array [ + Object { + "data-test-subj": "addNewActionConnectorActionGroup-0-option-default", + "inputDisplay": "Default", + "value": "default", + }, + ] + `); + }); + it('renders available connectors for the selected action type', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( `[data-test-subj="${actionType.id}-ActionTypeSelectOption"]` ); actionOption.first().simulate('click'); const combobox = wrapper.find(`[data-test-subj="selectActionConnector-${actionType.id}"]`); expect((combobox.first().props() as any).options).toMatchInlineSnapshot(` - Array [ - Object { - "id": "test", - "key": "test", - "label": "Test connector ", - }, - Object { - "id": "test2", - "key": "test2", - "label": "Test connector 2 (preconfigured)", - }, - ] - `); + Array [ + Object { + "id": "test", + "key": "test", + "label": "Test connector ", + }, + Object { + "id": "test2", + "key": "test2", + "label": "Test connector 2 (preconfigured)", + }, + ] + `); }); it('renders only preconfigured connectors for the selected preconfigured action type', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); actionOption.first().simulate('click'); const combobox = wrapper.find('[data-test-subj="selectActionConnector-preconfigured"]'); expect((combobox.first().props() as any).options).toMatchInlineSnapshot(` - Array [ - Object { - "id": "test3", - "key": "test3", - "label": "Preconfigured Only (preconfigured)", - }, - ] - `); + Array [ + Object { + "id": "test3", + "key": "test3", + "label": "Preconfigured Only (preconfigured)", + }, + ] + `); }); it('does not render "Add connector" button for preconfigured only action type', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find('[data-test-subj="preconfigured-ActionTypeSelectOption"]'); actionOption.first().simulate('click'); const preconfigPannel = wrapper.find('[data-test-subj="alertActionAccordion-default"]'); @@ -378,7 +401,7 @@ describe('action_form', () => { }); it('renders action types disabled by license', async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( '[data-test-subj="disabled-by-license-ActionTypeSelectOption"]' ); @@ -391,7 +414,7 @@ describe('action_form', () => { }); it(`shouldn't render action types without params component`, async () => { - await setup(); + const wrapper = await setup(); const actionOption = wrapper.find( `[data-test-subj="${actionTypeWithoutParams.id}-ActionTypeSelectOption"]` ); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx index 74432157f5659..3a7341afe3e07 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_form.tsx @@ -4,7 +4,7 @@ * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, Suspense, useState, useEffect } from 'react'; +import React, { Fragment, useState, useEffect, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -14,25 +14,13 @@ import { EuiIcon, EuiTitle, EuiSpacer, - EuiFormRow, - EuiComboBox, EuiKeyPadMenuItem, - EuiAccordion, - EuiButtonIcon, - EuiEmptyPrompt, - EuiButtonEmpty, EuiToolTip, - EuiIconTip, EuiLink, - EuiCallOut, - EuiHorizontalRule, - EuiText, - EuiLoadingSpinner, } from '@elastic/eui'; import { HttpSetup, ToastsSetup, ApplicationStart, DocLinksStart } from 'kibana/public'; import { loadActionTypes, loadAllActions as loadConnectors } from '../../lib/action_connector_api'; import { - IErrorObject, ActionTypeModel, ActionTypeRegistryContract, AlertAction, @@ -43,15 +31,19 @@ import { } from '../../../types'; import { SectionLoading } from '../../components/section_loading'; import { ConnectorAddModal } from './connector_add_modal'; +import { ActionTypeForm, ActionTypeFormProps } from './action_type_form'; +import { AddConnectorInline } from './connector_add_inline'; import { actionTypeCompare } from '../../lib/action_type_compare'; import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; import { VIEW_LICENSE_OPTIONS_LINK, DEFAULT_HIDDEN_ACTION_TYPES } from '../../../common/constants'; -import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { ActionGroup } from '../../../../../alerts/common'; -interface ActionAccordionFormProps { +export interface ActionAccordionFormProps { actions: AlertAction[]; defaultActionGroupId: string; + actionGroups?: ActionGroup[]; setActionIdByIndex: (id: string, index: number) => void; + setActionGroupIdByIndex?: (group: string, index: number) => void; setAlertProperty: (actions: AlertAction[]) => void; setActionParamsProperty: (key: string, value: any, index: number) => void; http: HttpSetup; @@ -74,7 +66,9 @@ interface ActiveActionConnectorState { export const ActionForm = ({ actions, defaultActionGroupId, + actionGroups, setActionIdByIndex, + setActionGroupIdByIndex, setAlertProperty, setActionParamsProperty, http, @@ -88,8 +82,6 @@ export const ActionForm = ({ capabilities, docLinks, }: ActionAccordionFormProps) => { - const canSave = hasSaveActionsCapability(capabilities); - const [addModalVisible, setAddModalVisibility] = useState(false); const [activeActionItem, setActiveActionItem] = useState( undefined @@ -101,6 +93,10 @@ export const ActionForm = ({ const [actionTypesIndex, setActionTypesIndex] = useState(undefined); const [emptyActionsIds, setEmptyActionsIds] = useState([]); + const closeAddConnectorModal = useCallback(() => setAddModalVisibility(false), [ + setAddModalVisibility, + ]); + // load action types useEffect(() => { (async () => { @@ -183,359 +179,6 @@ export const ActionForm = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [actions, connectors]); - const preconfiguredMessage = i18n.translate( - 'xpack.triggersActionsUI.sections.actionForm.preconfiguredTitleMessage', - { - defaultMessage: '(preconfigured)', - } - ); - - const getSelectedOptions = (actionItemId: string) => { - const selectedConnector = connectors.find((connector) => connector.id === actionItemId); - if ( - !selectedConnector || - // if selected connector is not preconfigured and action type is for preconfiguration only, - // do not show regular connectors of this type - (actionTypesIndex && - !actionTypesIndex[selectedConnector.actionTypeId].enabledInConfig && - !selectedConnector.isPreconfigured) - ) { - return []; - } - const optionTitle = `${selectedConnector.name} ${ - selectedConnector.isPreconfigured ? preconfiguredMessage : '' - }`; - return [ - { - label: optionTitle, - value: optionTitle, - id: actionItemId, - 'data-test-subj': 'itemActionConnector', - }, - ]; - }; - - const getActionTypeForm = ( - actionItem: AlertAction, - actionConnector: ActionConnector, - actionParamsErrors: { - errors: IErrorObject; - }, - index: number - ) => { - if (!actionTypesIndex) { - return null; - } - - const actionType = actionTypesIndex[actionItem.actionTypeId]; - - const optionsList = connectors - .filter( - (connectorItem) => - connectorItem.actionTypeId === actionItem.actionTypeId && - // include only enabled by config connectors or preconfigured - (actionType.enabledInConfig || connectorItem.isPreconfigured) - ) - .map(({ name, id, isPreconfigured }) => ({ - label: `${name} ${isPreconfigured ? preconfiguredMessage : ''}`, - key: id, - id, - })); - const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); - if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; - const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; - const checkEnabledResult = checkActionFormActionTypeEnabled( - actionTypesIndex[actionConnector.actionTypeId], - connectors.filter((connector) => connector.isPreconfigured) - ); - - const accordionContent = checkEnabledResult.isEnabled ? ( - - - - - } - labelAppend={ - canSave && - actionTypesIndex && - actionTypesIndex[actionConnector.actionTypeId].enabledInConfig ? ( - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - - ) : null - } - > - { - setActionIdByIndex(selectedOptions[0].id ?? '', index); - }} - isClearable={false} - /> - - - - - {ParamsFieldsComponent ? ( - - - - - - } - > - - - ) : null} - - ) : ( - checkEnabledResult.messageCard - ); - - return ( - - - - - - - -
- - - - - - {checkEnabledResult.isEnabled === false && ( - - - - )} - - -
-
-
- - } - extraAction={ - { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setAlertProperty(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === - 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - {accordionContent} -
- -
- ); - }; - - const getAddConnectorsForm = (actionItem: AlertAction, index: number) => { - const actionTypeName = actionTypesIndex - ? actionTypesIndex[actionItem.actionTypeId].name - : actionItem.actionTypeId; - const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); - if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; - - const noConnectorsLabel = ( - - ); - return ( - - - - - - - -
- -
-
-
- - } - extraAction={ - { - const updatedActions = actions.filter( - (_item: AlertAction, i: number) => i !== index - ); - setAlertProperty(updatedActions); - setIsAddActionPanelOpen( - updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === - 0 - ); - setActiveActionItem(undefined); - }} - /> - } - paddingSize="l" - > - {canSave ? ( - actionItem.id === emptyId) ? ( - noConnectorsLabel - ) : ( - - ) - } - actions={[ - { - setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); - setAddModalVisibility(true); - }} - > - - , - ]} - /> - ) : ( - -

- -

-
- )} -
- -
- ); - }; - function addActionType(actionTypeModel: ActionTypeModel) { if (!defaultActionGroupId) { toastNotifications!.addDanger({ @@ -628,116 +271,172 @@ export const ActionForm = ({ }); } - const alertActionsList = actions.map((actionItem: AlertAction, index: number) => { - const actionConnector = connectors.find((field) => field.id === actionItem.id); - // connectors doesn't exists - if (!actionConnector) { - return getAddConnectorsForm(actionItem, index); - } - - const actionErrors: { errors: IErrorObject } = actionTypeRegistry - .get(actionItem.actionTypeId) - ?.validateParams(actionItem.params); - - return getActionTypeForm(actionItem, actionConnector, actionErrors, index); - }); - - return ( + return isLoadingConnectors ? ( + + + + ) : ( - {isLoadingConnectors ? ( - + +

- - ) : ( - - -

- + + + {actionTypesIndex && + actions.map((actionItem: AlertAction, index: number) => { + const actionConnector = connectors.find((field) => field.id === actionItem.id); + // connectors doesn't exists + if (!actionConnector) { + return ( + { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id) + .length === 0 + ); + setActiveActionItem(undefined); + }} + onAddConnector={() => { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} /> -

-
- - {alertActionsList} - {isAddActionPanelOpen === false ? ( -
- - - - setIsAddActionPanelOpen(true)} - > - - - - -
- ) : null} - {isAddActionPanelOpen ? ( - - - - -
+ ); + } + + const actionParamsErrors: ActionTypeFormProps['actionParamsErrors'] = actionTypeRegistry + .get(actionItem.actionTypeId) + ?.validateParams(actionItem.params); + + return ( + { + setActiveActionItem({ actionTypeId: actionItem.actionTypeId, index }); + setAddModalVisibility(true); + }} + onConnectorSelected={(id: string) => { + setActionIdByIndex(id, index); + }} + onDeleteAction={() => { + const updatedActions = actions.filter( + (_item: AlertAction, i: number) => i !== index + ); + setAlertProperty(updatedActions); + setIsAddActionPanelOpen( + updatedActions.filter((item: AlertAction) => item.id !== actionItem.id).length === + 0 + ); + setActiveActionItem(undefined); + }} + /> + ); + })} + + {isAddActionPanelOpen ? ( + + + + +
+ +
+
+
+ {hasDisabledByLicenseActionTypes && ( + + +
+ -
-
-
- {hasDisabledByLicenseActionTypes && ( - - -
- - - -
-
-
- )} -
- - - {isLoadingActionTypes ? ( - - - - ) : ( - actionTypeNodes - )} - -
- ) : null} + +
+
+
+ )} +
+ + + {isLoadingActionTypes ? ( + + + + ) : ( + actionTypeNodes + )} +
+ ) : ( + + + setIsAddActionPanelOpen(true)} + > + + + + )} - {actionTypesIndex && activeActionItem ? ( + {actionTypesIndex && activeActionItem && addModalVisible ? ( { connectors.push(savedAction); setActionIdByIndex(savedAction.id, activeActionItem.index); diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx new file mode 100644 index 0000000000000..38468283b9c19 --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/action_type_form.tsx @@ -0,0 +1,339 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment, Suspense, useState } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiFormRow, + EuiComboBox, + EuiAccordion, + EuiButtonIcon, + EuiButtonEmpty, + EuiIconTip, + EuiText, + EuiFormLabel, + EuiFormControlLayout, + EuiSuperSelect, + EuiLoadingSpinner, + EuiBadge, +} from '@elastic/eui'; +import { IErrorObject, AlertAction, ActionTypeIndex, ActionConnector } from '../../../types'; +import { checkActionFormActionTypeEnabled } from '../../lib/check_action_type_enabled'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { ActionAccordionFormProps } from './action_form'; + +export type ActionTypeFormProps = { + actionItem: AlertAction; + actionConnector: ActionConnector; + actionParamsErrors: { + errors: IErrorObject; + }; + index: number; + onAddConnector: () => void; + onConnectorSelected: (id: string) => void; + onDeleteAction: () => void; + setActionParamsProperty: (key: string, value: any, index: number) => void; + actionTypesIndex: ActionTypeIndex; + connectors: ActionConnector[]; +} & Pick< + ActionAccordionFormProps, + | 'defaultActionGroupId' + | 'actionGroups' + | 'setActionGroupIdByIndex' + | 'setActionParamsProperty' + | 'http' + | 'actionTypeRegistry' + | 'toastNotifications' + | 'docLinks' + | 'messageVariables' + | 'defaultActionMessage' + | 'capabilities' +>; + +const preconfiguredMessage = i18n.translate( + 'xpack.triggersActionsUI.sections.actionForm.preconfiguredTitleMessage', + { + defaultMessage: '(preconfigured)', + } +); + +export const ActionTypeForm = ({ + actionItem, + actionConnector, + actionParamsErrors, + index, + onAddConnector, + onConnectorSelected, + onDeleteAction, + setActionParamsProperty, + actionTypesIndex, + connectors, + http, + toastNotifications, + docLinks, + capabilities, + actionTypeRegistry, + defaultActionGroupId, + defaultActionMessage, + messageVariables, + actionGroups, + setActionGroupIdByIndex, +}: ActionTypeFormProps) => { + const [isOpen, setIsOpen] = useState(true); + + const canSave = hasSaveActionsCapability(capabilities); + const getSelectedOptions = (actionItemId: string) => { + const selectedConnector = connectors.find((connector) => connector.id === actionItemId); + if ( + !selectedConnector || + // if selected connector is not preconfigured and action type is for preconfiguration only, + // do not show regular connectors of this type + (actionTypesIndex && + !actionTypesIndex[selectedConnector.actionTypeId].enabledInConfig && + !selectedConnector.isPreconfigured) + ) { + return []; + } + const optionTitle = `${selectedConnector.name} ${ + selectedConnector.isPreconfigured ? preconfiguredMessage : '' + }`; + return [ + { + label: optionTitle, + value: optionTitle, + id: actionItemId, + 'data-test-subj': 'itemActionConnector', + }, + ]; + }; + + const actionType = actionTypesIndex[actionItem.actionTypeId]; + + const optionsList = connectors + .filter( + (connectorItem) => + connectorItem.actionTypeId === actionItem.actionTypeId && + // include only enabled by config connectors or preconfigured + (actionType.enabledInConfig || connectorItem.isPreconfigured) + ) + .map(({ name, id, isPreconfigured }) => ({ + label: `${name} ${isPreconfigured ? preconfiguredMessage : ''}`, + key: id, + id, + })); + const actionTypeRegistered = actionTypeRegistry.get(actionConnector.actionTypeId); + if (!actionTypeRegistered) return null; + + const ParamsFieldsComponent = actionTypeRegistered.actionParamsFields; + const checkEnabledResult = checkActionFormActionTypeEnabled( + actionTypesIndex[actionConnector.actionTypeId], + connectors.filter((connector) => connector.isPreconfigured) + ); + + const defaultActionGroup = actionGroups?.find(({ id }) => id === defaultActionGroupId); + const selectedActionGroup = + actionGroups?.find(({ id }) => id === actionItem.group) ?? defaultActionGroup; + + const accordionContent = checkEnabledResult.isEnabled ? ( + + {actionGroups && selectedActionGroup && setActionGroupIdByIndex && ( + + + + + + + } + > + ({ + value, + inputDisplay: name, + 'data-test-subj': `addNewActionConnectorActionGroup-${index}-option-${value}`, + }))} + valueOfSelected={selectedActionGroup.id} + onChange={(group) => { + setActionGroupIdByIndex(group, index); + }} + /> + + + + + + )} + + + + } + labelAppend={ + canSave && + actionTypesIndex && + actionTypesIndex[actionConnector.actionTypeId].enabledInConfig ? ( + + + + ) : null + } + > + { + onConnectorSelected(selectedOptions[0].id ?? ''); + }} + isClearable={false} + /> + + + + + {ParamsFieldsComponent ? ( + + + + + + } + > + + + ) : null} + + ) : ( + checkEnabledResult.messageCard + ); + + return ( + + + + + + + +
+ + + + + {selectedActionGroup && !isOpen && ( + + {selectedActionGroup.name} + + )} + + {checkEnabledResult.isEnabled === false && ( + + + + )} + + +
+
+
+ + } + extraAction={ + + } + > + {accordionContent} +
+ +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx new file mode 100644 index 0000000000000..97baf4a36cb4c --- /dev/null +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_inline.tsx @@ -0,0 +1,153 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import React, { Fragment } from 'react'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiSpacer, + EuiAccordion, + EuiButtonIcon, + EuiEmptyPrompt, + EuiCallOut, + EuiText, +} from '@elastic/eui'; +import { AlertAction, ActionTypeIndex } from '../../../types'; +import { hasSaveActionsCapability } from '../../lib/capabilities'; +import { ActionAccordionFormProps } from './action_form'; + +type AddConnectorInFormProps = { + actionTypesIndex: ActionTypeIndex; + actionItem: AlertAction; + index: number; + onAddConnector: () => void; + onDeleteConnector: () => void; + emptyActionsIds: string[]; +} & Pick; + +export const AddConnectorInline = ({ + actionTypesIndex, + actionItem, + index, + onAddConnector, + onDeleteConnector, + actionTypeRegistry, + emptyActionsIds, + defaultActionGroupId, + capabilities, +}: AddConnectorInFormProps) => { + const canSave = hasSaveActionsCapability(capabilities); + + const actionTypeName = actionTypesIndex + ? actionTypesIndex[actionItem.actionTypeId].name + : actionItem.actionTypeId; + const actionTypeRegistered = actionTypeRegistry.get(actionItem.actionTypeId); + if (!actionTypeRegistered || actionItem.group !== defaultActionGroupId) return null; + + const noConnectorsLabel = ( + + ); + return ( + + + + + + + +
+ +
+
+
+ + } + extraAction={ + + } + paddingSize="l" + > + {canSave ? ( + actionItem.id === emptyId) ? ( + noConnectorsLabel + ) : ( + + ) + } + actions={[ + + + , + ]} + /> + ) : ( + +

+ +

+
+ )} +
+ +
+ ); +}; diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx index cba9eea3cf3f7..71a3936ed5055 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.test.tsx @@ -65,8 +65,7 @@ describe('connector_add_modal', () => { const wrapper = mountWithIntl( {}} + onClose={() => {}} actionType={actionType} http={deps!.http} actionTypeRegistry={deps!.actionTypeRegistry} diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx index 13ec8395aa557..de27256bf566c 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/action_connector_form/connector_add_modal.tsx @@ -32,8 +32,7 @@ import { interface ConnectorAddModalProps { actionType: ActionType; - addModalVisible: boolean; - setAddModalVisibility: React.Dispatch>; + onClose: () => void; postSaveEventHandler?: (savedAction: ActionConnector) => void; http: HttpSetup; actionTypeRegistry: ActionTypeRegistryContract; @@ -48,8 +47,7 @@ interface ConnectorAddModalProps { export const ConnectorAddModal = ({ actionType, - addModalVisible, - setAddModalVisibility, + onClose, postSaveEventHandler, http, toastNotifications, @@ -79,14 +77,11 @@ export const ConnectorAddModal = ({ >(undefined); const closeModal = useCallback(() => { - setAddModalVisibility(false); setConnector(initialConnector); setServerError(undefined); - }, [initialConnector, setAddModalVisibility]); + onClose(); + }, [initialConnector, onClose]); - if (!addModalVisible) { - return null; - } const actionTypeModel = actionTypeRegistry.get(actionType.id); const errors = { ...actionTypeModel?.validateConnector(connector).errors, diff --git a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx index 9a637ea750f81..20ad9a8d7c701 100644 --- a/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx +++ b/x-pack/plugins/triggers_actions_ui/public/application/sections/alert_form/alert_form.tsx @@ -3,7 +3,7 @@ * or more contributor license agreements. Licensed under the Elastic License; * you may not use this file except in compliance with the Elastic License. */ -import React, { Fragment, useState, useEffect, Suspense } from 'react'; +import React, { Fragment, useState, useEffect, Suspense, useCallback } from 'react'; import { i18n } from '@kbn/i18n'; import { FormattedMessage } from '@kbn/i18n/react'; import { @@ -153,9 +153,17 @@ export const AlertForm = ({ setAlertTypeModel(alert.alertTypeId ? alertTypeRegistry.get(alert.alertTypeId) : null); }, [alert, alertTypeRegistry]); - const setAlertProperty = (key: string, value: any) => { - dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); - }; + const setAlertProperty = useCallback( + (key: string, value: any) => { + dispatch({ command: { type: 'setProperty' }, payload: { key, value } }); + }, + [dispatch] + ); + + const setActions = useCallback( + (updatedActions: AlertAction[]) => setAlertProperty('actions', updatedActions), + [setAlertProperty] + ); const setAlertParams = (key: string, value: any) => { dispatch({ command: { type: 'setAlertParams' }, payload: { key, value } }); @@ -169,9 +177,12 @@ export const AlertForm = ({ dispatch({ command: { type: 'setAlertActionProperty' }, payload: { key, value, index } }); }; - const setActionParamsProperty = (key: string, value: any, index: number) => { - dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); - }; + const setActionParamsProperty = useCallback( + (key: string, value: any, index: number) => { + dispatch({ command: { type: 'setAlertActionParams' }, payload: { key, value, index } }); + }, + [dispatch] + ); const tagsOptions = alert.tags ? alert.tags.map((label: string) => ({ label })) : []; @@ -202,6 +213,7 @@ export const AlertForm = ({ label={item.name} onClick={() => { setAlertProperty('alertTypeId', item.id); + setActions([]); setAlertTypeModel(item); setAlertProperty('params', {}); if (alertTypesIndex && alertTypesIndex.has(item.id)) { @@ -289,26 +301,25 @@ export const AlertForm = ({ /> ) : null} - {canShowActions && defaultActionGroupId ? ( + {canShowActions && + defaultActionGroupId && + alertTypeModel && + alertTypesIndex?.has(alert.alertTypeId) ? ( - a.name.toUpperCase().localeCompare(b.name.toUpperCase()) - ) - : undefined - } + messageVariables={actionVariablesFromAlertType( + alertTypesIndex.get(alert.alertTypeId)! + ).sort((a, b) => a.name.toUpperCase().localeCompare(b.name.toUpperCase()))} defaultActionGroupId={defaultActionGroupId} + actionGroups={alertTypesIndex.get(alert.alertTypeId)!.actionGroups} setActionIdByIndex={(id: string, index: number) => setActionProperty('id', id, index)} - setAlertProperty={(updatedActions: AlertAction[]) => - setAlertProperty('actions', updatedActions) - } - setActionParamsProperty={(key: string, value: any, index: number) => - setActionParamsProperty(key, value, index) + setActionGroupIdByIndex={(group: string, index: number) => + setActionProperty('group', group, index) } + setAlertProperty={setActions} + setActionParamsProperty={setActionParamsProperty} http={http} actionTypeRegistry={actionTypeRegistry} defaultActionMessage={alertTypeModel?.defaultActionMessage} diff --git a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts index 92db0458c0639..c05fa6cf051ff 100644 --- a/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts +++ b/x-pack/test/alerting_api_integration/spaces_only/tests/alerting/builtin_alert_types/index_threshold/alert.ts @@ -15,6 +15,7 @@ import { ObjectRemover, } from '../../../../../common/lib'; import { createEsDocuments } from './create_test_data'; +import { getAlertType } from '../../../../../../../plugins/triggers_actions_ui/public/application/components/builtin_alert_types/threshold/'; const ALERT_TYPE_ID = '.index-threshold'; const ACTION_TYPE_ID = '.index'; @@ -26,6 +27,8 @@ const ALERT_INTERVALS_TO_WRITE = 5; const ALERT_INTERVAL_SECONDS = 3; const ALERT_INTERVAL_MILLIS = ALERT_INTERVAL_SECONDS * 1000; +const DefaultActionMessage = getAlertType().defaultActionMessage; + // eslint-disable-next-line import/no-default-export export default function alertTests({ getService }: FtrProviderContext) { const supertest = getService('supertest'); @@ -62,6 +65,10 @@ export default function alertTests({ getService }: FtrProviderContext) { await esTestIndexToolOutput.destroy(); }); + it('has a default action message', () => { + expect(DefaultActionMessage).to.be.ok(); + }); + // The tests below create two alerts, one that will fire, one that will // never fire; the tests ensure the ones that should fire, do fire, and // those that shouldn't fire, do not fire. @@ -85,7 +92,7 @@ export default function alertTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(2); for (const doc of docs) { const { group } = doc._source; - const { name, value, title, message } = doc._source.params; + const { name, title, message } = doc._source.params; expect(name).to.be('always fire'); expect(group).to.be('all documents'); @@ -93,9 +100,8 @@ export default function alertTests({ getService }: FtrProviderContext) { // we'll check title and message in this test, but not subsequent ones expect(title).to.be('alert always fire group all documents exceeded threshold'); - const expectedPrefix = `alert always fire group all documents value ${value} exceeded threshold count > -1 over`; - const messagePrefix = message.substr(0, expectedPrefix.length); - expect(messagePrefix).to.be(expectedPrefix); + const messagePattern = /alert always fire group all documents value \d+ exceeded threshold count > -1 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } }); @@ -128,10 +134,13 @@ export default function alertTests({ getService }: FtrProviderContext) { for (const doc of docs) { const { group } = doc._source; - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); if (group === 'group-0') inGroup0++; + + const messagePattern = /alert always fire group group-\d value \d+ exceeded threshold count .+ over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } // there should be 2 docs in group-0, rando split between others @@ -163,9 +172,12 @@ export default function alertTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(2); for (const doc of docs) { - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); + + const messagePattern = /alert always fire group all documents value \d+ exceeded threshold sum\(testedValue\) between 0,1000000 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } }); @@ -195,9 +207,12 @@ export default function alertTests({ getService }: FtrProviderContext) { const docs = await waitForDocs(4); for (const doc of docs) { - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); + + const messagePattern = /alert always fire group all documents value .+ exceeded threshold avg\(testedValue\) .+ 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } }); @@ -232,10 +247,13 @@ export default function alertTests({ getService }: FtrProviderContext) { for (const doc of docs) { const { group } = doc._source; - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); if (group === 'group-2') inGroup2++; + + const messagePattern = /alert always fire group group-. value \d+ exceeded threshold max\(testedValue\) .* 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } // there should be 2 docs in group-2, rando split between others @@ -274,10 +292,13 @@ export default function alertTests({ getService }: FtrProviderContext) { for (const doc of docs) { const { group } = doc._source; - const { name } = doc._source.params; + const { name, message } = doc._source.params; expect(name).to.be('always fire'); if (group === 'group-0') inGroup0++; + + const messagePattern = /alert always fire group group-. value \d+ exceeded threshold min\(testedValue\) .* 0 over 15s on \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z/; + expect(message).to.match(messagePattern); } // there should be 2 docs in group-0, rando split between others @@ -329,7 +350,7 @@ export default function alertTests({ getService }: FtrProviderContext) { name: '{{{alertName}}}', value: '{{{context.value}}}', title: '{{{context.title}}}', - message: '{{{context.message}}}', + message: DefaultActionMessage, }, date: '{{{context.date}}}', // TODO: I wanted to write the alert value here, but how? diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts index 7d99d3635106d..0f6da936f8644 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/alert_create_flyout.ts @@ -55,6 +55,12 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await nameInput.click(); } + async function defineAlwaysFiringAlert(alertName: string) { + await pageObjects.triggersActionsUI.clickCreateAlertButton(); + await testSubjects.setValue('alertNameInput', alertName); + await testSubjects.click('test.always-firing-SelectOption'); + } + describe('create alert', function () { before(async () => { await pageObjects.common.navigateToApp('triggersActions'); @@ -73,10 +79,13 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); const createdConnectorToastTitle = await pageObjects.common.closeToast(); expect(createdConnectorToastTitle).to.eql(`Created '${slackConnectorName}'`); + const messageTextArea = await find.byCssSelector('[data-test-subj="messageTextArea"]'); + expect(await messageTextArea.getAttribute('value')).to.eql( + 'alert {{alertName}} group {{context.group}} value {{context.value}} exceeded threshold {{context.function}} over {{params.timeWindowSize}}{{params.timeWindowUnit}} on {{context.date}}' + ); await testSubjects.setValue('messageTextArea', 'test message '); await testSubjects.click('messageAddVariableButton'); await testSubjects.click('variableMenuButton-0'); - const messageTextArea = await find.byCssSelector('[data-test-subj="messageTextArea"]'); expect(await messageTextArea.getAttribute('value')).to.eql('test message {{alertId}}'); await messageTextArea.type(' some additional text '); @@ -106,6 +115,57 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id)); }); + it('should create an alert with actions in multiple groups', async () => { + const alertName = generateUniqueKey(); + await defineAlwaysFiringAlert(alertName); + + // create Slack connector and attach an action using it + await testSubjects.click('.slack-ActionTypeSelectOption'); + await testSubjects.click('addNewActionConnectorButton-.slack'); + const slackConnectorName = generateUniqueKey(); + await testSubjects.setValue('nameInput', slackConnectorName); + await testSubjects.setValue('slackWebhookUrlInput', 'https://test'); + await find.clickByCssSelector('[data-test-subj="saveActionButtonModal"]:not(disabled)'); + const createdConnectorToastTitle = await pageObjects.common.closeToast(); + expect(createdConnectorToastTitle).to.eql(`Created '${slackConnectorName}'`); + await testSubjects.setValue('messageTextArea', 'test message '); + await ( + await find.byCssSelector( + '[data-test-subj="alertActionAccordion-0"] [data-test-subj="messageTextArea"]' + ) + ).type('some text '); + + await testSubjects.click('addAlertActionButton'); + await testSubjects.click('.slack-ActionTypeSelectOption'); + await testSubjects.setValue('messageTextArea', 'test message '); + await ( + await find.byCssSelector( + '[data-test-subj="alertActionAccordion-1"] [data-test-subj="messageTextArea"]' + ) + ).type('some text '); + + await testSubjects.click('addNewActionConnectorActionGroup-1'); + await testSubjects.click('addNewActionConnectorActionGroup-1-option-other'); + + await testSubjects.click('saveAlertButton'); + const toastTitle = await pageObjects.common.closeToast(); + expect(toastTitle).to.eql(`Created alert "${alertName}"`); + await pageObjects.triggersActionsUI.searchAlerts(alertName); + const searchResultsAfterSave = await pageObjects.triggersActionsUI.getAlertsList(); + expect(searchResultsAfterSave).to.eql([ + { + name: alertName, + tagsText: '', + alertType: 'Always Firing', + interval: '1m', + }, + ]); + + // clean up created alert + const alertsToDelete = await getAlertsByName(alertName); + await deleteAlerts(alertsToDelete.map((alertItem: { id: string }) => alertItem.id)); + }); + it('should show save confirmation before creating alert with no actions', async () => { const alertName = generateUniqueKey(); await defineAlert(alertName); diff --git a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts index 9e4006681dc8d..1d86d95b7a796 100644 --- a/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts +++ b/x-pack/test/functional_with_es_ssl/apps/triggers_actions_ui/details.ts @@ -306,7 +306,8 @@ export default ({ getPageObjects, getService }: FtrProviderContext) => { }); }); - describe('Alert Instances', function () { + // FLAKY: https://github.com/elastic/kibana/issues/57426 + describe.skip('Alert Instances', function () { const testRunUuid = uuid.v4(); let alert: any; diff --git a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts index e3927f6bfffb9..6f9d010378624 100644 --- a/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts +++ b/x-pack/test/functional_with_es_ssl/fixtures/plugins/alerts/server/plugin.ts @@ -78,6 +78,7 @@ function createAlwaysFiringAlertType(alerts: AlertingSetup) { { id: 'default', name: 'Default' }, { id: 'other', name: 'Other' }, ], + defaultActionGroupId: 'default', producer: 'alerts', async executor(alertExecutorOptions: any) { const { services, state, params } = alertExecutorOptions;