diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx index f713af9768229..aca29c4723688 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/field_value_selection.tsx @@ -71,6 +71,7 @@ export function FieldValueSelection({ selectedValue, excludedValue, compressed = true, + allowExclusions = true, onChange: onSelectionChange, }: FieldValueSelectionProps) { const [options, setOptions] = useState(() => @@ -142,7 +143,7 @@ export function FieldValueSelection({ .filter((opt) => opt?.checked === 'off') .map(({ label: labelN }) => labelN); - return isEqual(selectedValue ?? [], currSelected) && isEqual(excludedValue, currExcluded); + return isEqual(selectedValue ?? [], currSelected) && isEqual(excludedValue ?? [], currExcluded); }; return ( @@ -174,7 +175,7 @@ export function FieldValueSelection({ options={options} onChange={onChange} isLoading={loading && !query && options.length === 0} - allowExclusions={true} + allowExclusions={allowExclusions} > {(list, search) => (
@@ -190,6 +191,13 @@ export function FieldValueSelection({ )} ); } diff --git a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts index d857b39b074ac..046f98748cdf2 100644 --- a/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts +++ b/x-pack/plugins/observability/public/components/shared/field_value_suggestions/types.ts @@ -24,6 +24,9 @@ interface CommonProps { asFilterButton?: boolean; showCount?: boolean; allowAllValuesSelection?: boolean; + cardinalityField?: string; + required?: boolean; + allowExclusions?: boolean; } export type FieldValueSuggestionsProps = CommonProps & { diff --git a/x-pack/plugins/observability/public/hooks/use_values_list.ts b/x-pack/plugins/observability/public/hooks/use_values_list.ts index 46d89a062f072..5aa7dd672cfda 100644 --- a/x-pack/plugins/observability/public/hooks/use_values_list.ts +++ b/x-pack/plugins/observability/public/hooks/use_values_list.ts @@ -18,6 +18,7 @@ export interface Props { filters?: ESFilter[]; time?: { from: string; to: string }; keepHistory?: boolean; + cardinalityField?: string; } export interface ListItem { @@ -32,6 +33,7 @@ export const useValuesList = ({ filters, time, keepHistory, + cardinalityField, }: Props): { values: ListItem[]; loading?: boolean } => { const [debouncedQuery, setDebounceQuery] = useState(query); const [values, setValues] = useState([]); @@ -93,9 +95,20 @@ export const useValuesList = ({ values: { terms: { field: sourceField, - size: 100, + size: 50, ...(query ? { include: includeClause } : {}), }, + ...(cardinalityField + ? { + aggs: { + count: { + cardinality: { + field: cardinalityField, + }, + }, + }, + } + : {}), }, }, }, @@ -105,10 +118,20 @@ export const useValuesList = ({ useEffect(() => { const newValues = - data?.aggregations?.values.buckets.map(({ key: value, doc_count: count }) => ({ - count, - label: String(value), - })) ?? []; + data?.aggregations?.values.buckets.map( + ({ key: value, doc_count: count, count: aggsCount }) => { + if (aggsCount) { + return { + count: aggsCount.value, + label: String(value), + }; + } + return { + count, + label: String(value), + }; + } + ) ?? []; if (keepHistory && query) { setValues((prevState) => { diff --git a/x-pack/plugins/translations/translations/ja-JP.json b/x-pack/plugins/translations/translations/ja-JP.json index e3fef367766b5..def2158f3dd78 100644 --- a/x-pack/plugins/translations/translations/ja-JP.json +++ b/x-pack/plugins/translations/translations/ja-JP.json @@ -26159,10 +26159,6 @@ "xpack.uptime.filterBar.options.portLabel": "ポート", "xpack.uptime.filterBar.options.schemeLabel": "スキーム", "xpack.uptime.filterBar.options.tagsLabel": "タグ", - "xpack.uptime.filterPopout.loadingMessage": "読み込み中...", - "xpack.uptime.filterPopout.searchMessage": "{title} の検索", - "xpack.uptime.filterPopout.searchMessage.ariaLabel": "{title} を検索", - "xpack.uptime.filterPopover.filterItem.label": "{title} {item}でフィルタリングします。", "xpack.uptime.fleetIntegration.assets.description": "アップタイムでモニターを表示", "xpack.uptime.fleetIntegration.assets.name": "監視", "xpack.uptime.integrationLink.missingDataMessage": "この統合に必要なデータが見つかりませんでした。", @@ -26313,7 +26309,6 @@ "xpack.uptime.overview.alerts.enabled.failed": "ルールを有効にできません。", "xpack.uptime.overview.alerts.enabled.success": "ルールが正常に有効にされました。 ", "xpack.uptime.overview.alerts.enabled.success.description": "この監視が停止しているときには、メッセージが {actionConnectors} に送信されます。", - "xpack.uptime.overview.filterButton.label": "{title}フィルターのフィルターグループを展開", "xpack.uptime.overview.heading": "監視", "xpack.uptime.overview.pageHeader.syntheticsCallout.announcementLink": "お知らせを読む", "xpack.uptime.overview.pageHeader.syntheticsCallout.content": "アップタイムは、スクリプト化された複数ステップの可用性チェックのサポートをプレビューしています。つまり、単に単一のページのアップ/ダウンのチェックだけではなく、Webページの要素を操作したり、全体的な可用性を確認したりできます(購入やシステムへのサインインなど)。詳細については以下をクリックしてください。これらの機能を先駆けて使用したい場合は、プレビュー合成エージェントをダウンロードし、アップタイムでチェックを表示できます。", diff --git a/x-pack/plugins/translations/translations/zh-CN.json b/x-pack/plugins/translations/translations/zh-CN.json index 786eb5bef3286..cc2bbcfbe811f 100644 --- a/x-pack/plugins/translations/translations/zh-CN.json +++ b/x-pack/plugins/translations/translations/zh-CN.json @@ -26595,10 +26595,6 @@ "xpack.uptime.filterBar.options.portLabel": "端口", "xpack.uptime.filterBar.options.schemeLabel": "方案", "xpack.uptime.filterBar.options.tagsLabel": "标签", - "xpack.uptime.filterPopout.loadingMessage": "正在加载……", - "xpack.uptime.filterPopout.searchMessage": "搜索 {title}", - "xpack.uptime.filterPopout.searchMessage.ariaLabel": "搜索 {title}", - "xpack.uptime.filterPopover.filterItem.label": "按 {title} {item} 筛选。", "xpack.uptime.fleetIntegration.assets.description": "在 Uptime 中查看监测", "xpack.uptime.fleetIntegration.assets.name": "监测", "xpack.uptime.integrationLink.missingDataMessage": "未找到此集成的所需数据。", @@ -26749,7 +26745,6 @@ "xpack.uptime.overview.alerts.enabled.failed": "无法启用规则!", "xpack.uptime.overview.alerts.enabled.success": "已成功启用规则 ", "xpack.uptime.overview.alerts.enabled.success.description": "此监测关闭时,将有消息发送到 {actionConnectors}。", - "xpack.uptime.overview.filterButton.label": "展开筛选 {title} 的筛选组", "xpack.uptime.overview.heading": "监测", "xpack.uptime.overview.pageHeader.syntheticsCallout.announcementLink": "阅读公告", "xpack.uptime.overview.pageHeader.syntheticsCallout.content": "Uptime 现在正在预览对脚本化多步骤可用性检查的支持。这意味着您可以与网页元素进行交互,并检查整个过程(例如购买或登录系统)的可用性,而不仅仅是简单的单个页面启动/关闭检查。请单击下面的内容以了解详情,如果您想率先使用这些功能,则可以下载我们的预览组合代理,并在 Uptime 中查看组合检查。", diff --git a/x-pack/plugins/uptime/common/constants/rest_api.ts b/x-pack/plugins/uptime/common/constants/rest_api.ts index 655f9629b848b..52b0620586eb4 100644 --- a/x-pack/plugins/uptime/common/constants/rest_api.ts +++ b/x-pack/plugins/uptime/common/constants/rest_api.ts @@ -6,7 +6,6 @@ */ export enum API_URLS { - INDEX_PATTERN = `/api/uptime/index_pattern`, INDEX_STATUS = '/api/uptime/index_status', MONITOR_LIST = `/api/uptime/monitor/list`, MONITOR_LOCATIONS = `/api/uptime/monitor/locations`, @@ -16,7 +15,6 @@ export enum API_URLS { PINGS = '/api/uptime/pings', PING_HISTOGRAM = `/api/uptime/ping/histogram`, SNAPSHOT_COUNT = `/api/uptime/snapshot/count`, - FILTERS = `/api/uptime/filters`, LOG_PAGE_VIEW = `/api/uptime/log_page_view`, ML_MODULE_JOBS = `/api/ml/modules/jobs_exist/`, diff --git a/x-pack/plugins/uptime/common/constants/ui.ts b/x-pack/plugins/uptime/common/constants/ui.ts index dcaf4bb310ad7..29df2614d0617 100644 --- a/x-pack/plugins/uptime/common/constants/ui.ts +++ b/x-pack/plugins/uptime/common/constants/ui.ts @@ -67,3 +67,10 @@ export enum CERT_STATUS { } export const KQL_SYNTAX_LOCAL_STORAGE = 'xpack.uptime.kql.syntax'; + +export const FILTER_FIELDS = { + TAGS: 'tags', + PORT: 'url.port', + LOCATION: 'observer.geo.name', + TYPE: 'monitor.type', +}; diff --git a/x-pack/plugins/uptime/common/runtime_types/index.ts b/x-pack/plugins/uptime/common/runtime_types/index.ts index 51dacd2f4e9b6..1c1b05cddcd44 100644 --- a/x-pack/plugins/uptime/common/runtime_types/index.ts +++ b/x-pack/plugins/uptime/common/runtime_types/index.ts @@ -10,7 +10,6 @@ export * from './certs'; export * from './common'; export * from './dynamic_settings'; export * from './monitor'; -export * from './overview_filters'; export * from './ping'; export * from './snapshot'; export * from './network_events'; diff --git a/x-pack/plugins/uptime/common/runtime_types/overview_filters/index.ts b/x-pack/plugins/uptime/common/runtime_types/overview_filters/index.ts deleted file mode 100644 index ed97b8902c879..0000000000000 --- a/x-pack/plugins/uptime/common/runtime_types/overview_filters/index.ts +++ /dev/null @@ -1,8 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { OverviewFiltersType, OverviewFilters } from './overview_filters'; diff --git a/x-pack/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts b/x-pack/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts deleted file mode 100644 index e3610a98f5ceb..0000000000000 --- a/x-pack/plugins/uptime/common/runtime_types/overview_filters/overview_filters.ts +++ /dev/null @@ -1,17 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import * as t from 'io-ts'; - -export const OverviewFiltersType = t.partial({ - locations: t.array(t.string), - ports: t.array(t.number), - schemes: t.array(t.string), - tags: t.array(t.string), -}); - -export type OverviewFilters = t.TypeOf; diff --git a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts index d6875840a138c..d71e720f50e6e 100644 --- a/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts +++ b/x-pack/plugins/uptime/common/runtime_types/ping/ping.ts @@ -304,6 +304,7 @@ export const GetPingsParamsType = t.intersection([ dateRange: DateRangeType, }), t.partial({ + excludedLocations: t.string, index: t.number, size: t.number, locations: t.string, diff --git a/x-pack/plugins/uptime/public/apps/uptime_app.tsx b/x-pack/plugins/uptime/public/apps/uptime_app.tsx index b31dd068ebb08..f82a312ef91f5 100644 --- a/x-pack/plugins/uptime/public/apps/uptime_app.tsx +++ b/x-pack/plugins/uptime/public/apps/uptime_app.tsx @@ -32,6 +32,7 @@ import { kibanaService } from '../state/kibana_service'; import { ActionMenu } from '../components/common/header/action_menu'; import { EuiThemeProvider } from '../../../../../src/plugins/kibana_react/common'; import { Storage } from '../../../../../src/plugins/kibana_utils/public'; +import { UptimeIndexPatternContextProvider } from '../contexts/uptime_index_pattern_context'; export interface UptimeAppColors { danger: string; @@ -119,16 +120,20 @@ const Application = (props: UptimeAppProps) => { -
- - - - - -
+ +
+ +
+ + + +
+
+
+
diff --git a/x-pack/plugins/uptime/public/components/common/monitor_tags.tsx b/x-pack/plugins/uptime/public/components/common/monitor_tags.tsx index 892271f9ef8c5..793c27a031546 100644 --- a/x-pack/plugins/uptime/public/components/common/monitor_tags.tsx +++ b/x-pack/plugins/uptime/public/components/common/monitor_tags.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useMemo, useState } from 'react'; import { EuiBadge, EuiBadgeGroup, EuiLink } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { useHistory } from 'react-router-dom'; @@ -69,9 +69,14 @@ export const MonitorTags = ({ ping, summary }: Props) => { const currFilters = parseCurrentFilters(params.filters); - const [filterType, setFilterType] = useState(currFilters.get('tags') ?? []); + const [tagFilters, setTagFilters] = useState(currFilters.get('tags') ?? []); - useFilterUpdate('tags', filterType); + const excludedTagFilters = useMemo(() => { + const currExcludedFilters = parseCurrentFilters(params.excludedFilters); + return currExcludedFilters.get('tags') ?? []; + }, [params.excludedFilters]); + + useFilterUpdate('tags', tagFilters, excludedTagFilters); if (tags.length === 0) { return summary ? null : ( @@ -93,7 +98,7 @@ export const MonitorTags = ({ ping, summary }: Props) => { key={tag} title={getFilterLabel(tag)} onClick={() => { - setFilterType([tag]); + setTagFilters([tag]); }} onClickAriaLabel={getFilterLabel(tag)} color="hollow" diff --git a/x-pack/plugins/uptime/public/components/common/uptime_date_picker.test.tsx b/x-pack/plugins/uptime/public/components/common/uptime_date_picker.test.tsx index 4bfe7de33cba5..51909527c51e2 100644 --- a/x-pack/plugins/uptime/public/components/common/uptime_date_picker.test.tsx +++ b/x-pack/plugins/uptime/public/components/common/uptime_date_picker.test.tsx @@ -56,7 +56,7 @@ describe('UptimeDatePicker component', () => { expect(customHistory.push).toHaveBeenCalledWith({ pathname: '/', - search: 'dateRangeStart=now-30m&dateRangeEnd=now-15m', + search: 'dateRangeEnd=now-15m&dateRangeStart=now-30m', }); }); diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_header.tsx b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_header.tsx index 8e599cba6e97e..0284211d6259c 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_header.tsx +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/ping_list_header.tsx @@ -9,7 +9,7 @@ import React from 'react'; import { FormattedMessage } from '@kbn/i18n/react'; import { EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import { StatusFilter } from '../../overview/monitor_list/status_filter'; -import { FilterGroup } from '../../overview/filter_group'; +import { FilterGroup } from '../../overview/filter_group/filter_group'; export const PingListHeader = () => { return ( @@ -27,7 +27,7 @@ export const PingListHeader = () => { - + diff --git a/x-pack/plugins/uptime/public/components/monitor/ping_list/use_pings.ts b/x-pack/plugins/uptime/public/components/monitor/ping_list/use_pings.ts index 9a8b1aa0e46f4..7e62b087ae671 100644 --- a/x-pack/plugins/uptime/public/components/monitor/ping_list/use_pings.ts +++ b/x-pack/plugins/uptime/public/components/monitor/ping_list/use_pings.ts @@ -35,7 +35,7 @@ export const usePingsList = ({ pageSize, pageIndex }: Props) => { const { statusFilter } = useGetUrlParams(); - const { selectedLocations } = useSelectedFilters(); + const selectedFilters = useSelectedFilters(); const dispatch = useDispatch(); @@ -45,6 +45,9 @@ export const usePingsList = ({ pageSize, pageIndex }: Props) => { dispatch, ]); + const locations = JSON.stringify(selectedFilters.selectedLocations); + const excludedLocations = JSON.stringify(selectedFilters.excludedLocations); + useEffect(() => { getPings({ monitorId, @@ -52,7 +55,8 @@ export const usePingsList = ({ pageSize, pageIndex }: Props) => { from, to, }, - locations: JSON.stringify(selectedLocations), + excludedLocations, + locations, index: pageIndex, size: pageSize, status: statusFilter !== 'all' ? statusFilter : '', @@ -66,7 +70,8 @@ export const usePingsList = ({ pageSize, pageIndex }: Props) => { pageIndex, pageSize, statusFilter, - selectedLocations, + locations, + excludedLocations, ]); const { data } = useFetcher(() => { diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/alert_query_bar/query_bar.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/alert_query_bar/query_bar.tsx index 0a0bbadb6216f..4c6072a018642 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/alert_query_bar/query_bar.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/alert_query_bar/query_bar.tsx @@ -9,9 +9,9 @@ import React, { useEffect, useState } from 'react'; import { EuiFlexItem } from '@elastic/eui'; import { i18n } from '@kbn/i18n'; import { QueryStringInput } from '../../../../../../../../src/plugins/data/public'; -import { useIndexPattern } from '../../query_bar/use_index_pattern'; import { isValidKuery } from '../../query_bar/query_bar'; import * as labels from '../translations'; +import { useIndexPattern } from '../../../../hooks'; interface Props { query: string; @@ -19,7 +19,7 @@ interface Props { } export const AlertQueryBar = ({ query = '', onChange }: Props) => { - const { index_pattern: indexPattern } = useIndexPattern(); + const indexPattern = useIndexPattern(); const [inputVal, setInputVal] = useState(query); diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/alerts_containers/alert_monitor_status.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/alerts_containers/alert_monitor_status.tsx index ff2ef4d2359a8..39d19464caa2e 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/alerts_containers/alert_monitor_status.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/alerts_containers/alert_monitor_status.tsx @@ -8,15 +8,18 @@ import React, { useEffect } from 'react'; import { useSelector, useDispatch } from 'react-redux'; import { isRight } from 'fp-ts/lib/Either'; -import { overviewFiltersSelector, selectedFiltersSelector } from '../../../../state/selectors'; +import { selectedFiltersSelector } from '../../../../state/selectors'; import { AlertMonitorStatusComponent } from '../monitor_status_alert/alert_monitor_status'; -import { fetchOverviewFilters, setSearchTextAction } from '../../../../state/actions'; +import { setSearchTextAction } from '../../../../state/actions'; import { AtomicStatusCheckParamsType, GetMonitorAvailabilityParamsType, } from '../../../../../common/runtime_types'; import { useSnapShotCount } from './use_snap_shot'; +import { FILTER_FIELDS } from '../../../../../common/constants'; + +const { TYPE, TAGS, LOCATION, PORT } = FILTER_FIELDS; interface Props { alertParams: { [key: string]: any }; @@ -37,23 +40,6 @@ export const AlertMonitorStatus: React.FC = ({ alertParams, }) => { const dispatch = useDispatch(); - useEffect(() => { - if (!window.location.pathname.includes('/app/uptime')) { - // filters inside uptime app already loaded - dispatch( - fetchOverviewFilters({ - dateRangeStart: 'now-24h', - dateRangeEnd: 'now', - locations: alertParams.filters?.['observer.geo.name'] ?? [], - ports: alertParams.filters?.['url.port'] ?? [], - tags: alertParams.filters?.tags ?? [], - schemes: alertParams.filters?.['monitor.type'] ?? [], - }) - ); - } - }, [alertParams, dispatch]); - - const overviewFilters = useSelector(overviewFiltersSelector); useEffect(() => { if (alertParams.search) { @@ -78,14 +64,10 @@ export const AlertMonitorStatus: React.FC = ({ useEffect(() => { if (!alertParams.filters && selectedFilters !== null) { setAlertParams('filters', { - // @ts-ignore - 'url.port': selectedFilters?.ports ?? [], - // @ts-ignore - 'observer.geo.name': selectedFilters?.locations ?? [], - // @ts-ignore - 'monitor.type': selectedFilters?.schemes ?? [], - // @ts-ignore - tags: selectedFilters?.tags ?? [], + [PORT]: selectedFilters?.ports ?? [], + [LOCATION]: selectedFilters?.locations ?? [], + [TYPE]: selectedFilters?.schemes ?? [], + [TAGS]: selectedFilters?.tags ?? [], }); } }, [alertParams, setAlertParams, selectedFilters]); @@ -94,7 +76,6 @@ export const AlertMonitorStatus: React.FC = ({ void; + hasFilters: boolean; } const TimeRangeOptions: TimeRangeOption[] = [ @@ -55,6 +56,7 @@ export const AvailabilityExpressionSelect: React.FC = ({ alertParams, isOldAlert, setAlertParams, + hasFilters, }) => { const [range, setRange] = useState(alertParams?.availability?.range ?? DEFAULT_RANGE); const [rangeUnit, setRangeUnit] = useState( @@ -114,7 +116,11 @@ export const AvailabilityExpressionSelect: React.FC = ({ /> } data-test-subj="xpack.uptime.alerts.monitorStatus.availability.threshold" - description={labels.ENTER_AVAILABILITY_THRESHOLD_DESCRIPTION} + description={ + hasFilters + ? labels.ENTER_AVAILABILITY_THRESHOLD_DESCRIPTION + : labels.ENTER_ANY_AVAILABILITY_THRESHOLD_DESCRIPTION + } id="threshold" isEnabled={isEnabled} isInvalid={thresholdIsInvalid} diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select.test.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select.test.tsx index c0bf73d6c5308..6aa829adc4544 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select.test.tsx @@ -6,12 +6,11 @@ */ import React from 'react'; -import { shallowWithIntl } from '@kbn/test/jest'; import { fireEvent, waitFor } from '@testing-library/react'; import { FiltersExpressionsSelect } from './filters_expression_select'; import { render } from '../../../../lib/helper/rtl_helpers'; import { filterAriaLabels as aria } from './translations'; -import { filterLabels } from '../../filter_group/translations'; +import * as Hooks from '../../../../../../observability/public/hooks/use_values_list'; describe('FiltersExpressionSelect', () => { const LOCATION_FIELD_NAME = 'observer.geo.name'; @@ -19,23 +18,22 @@ describe('FiltersExpressionSelect', () => { const SCHEME_FIELD_NAME = 'monitor.type'; const TAG_FIELD_NAME = 'tags'; - it('is empty when no filters available', () => { - const component = shallowWithIntl( + it('is empty when no filters available', async () => { + const { queryByLabelText } = render( ); - expect(component).toMatchInlineSnapshot(``); + + await waitFor(() => { + for (const label of Object.values(aria)) { + expect(queryByLabelText(label)).toBeNull(); + } + }); }); it.each([ @@ -51,24 +49,20 @@ describe('FiltersExpressionSelect', () => { [aria.LOCATION, aria.TAG], ], [[TAG_FIELD_NAME], [aria.TAG], [aria.LOCATION, aria.PORT, aria.SCHEME]], - ])('contains provided new filter values', (newFilters, expectedLabels, absentLabels) => { + ])('contains provided new filter values', async (newFilters, expectedLabels, absentLabels) => { const { getByLabelText, queryByLabelText } = render( ); - expectedLabels.forEach((label) => expect(getByLabelText(label))); - absentLabels.forEach((label) => expect(queryByLabelText(label)).toBeNull()); + await waitFor(() => { + expectedLabels.forEach((label) => expect(getByLabelText(label))); + absentLabels.forEach((label) => expect(queryByLabelText(label)).toBeNull()); + }); }); it.each([ @@ -84,12 +78,6 @@ describe('FiltersExpressionSelect', () => { alertParams={{}} newFilters={[LOCATION_FIELD_NAME, SCHEME_FIELD_NAME, PORT_FIELD_NAME, TAG_FIELD_NAME]} onRemoveFilter={onRemoveFilterMock} - filters={{ - tags: ['prod'], - ports: [5601], - schemes: ['http'], - locations: ['nyc'], - }} setAlertParams={setAlertParamsMock} shouldUpdateUrl={false} /> @@ -108,60 +96,11 @@ describe('FiltersExpressionSelect', () => { }); }); - const TEST_TAGS = ['foo', 'bar']; - const TEST_PORTS = [5601, 9200]; - const TEST_SCHEMES = ['http', 'tcp']; - const TEST_LOCATIONS = ['nyc', 'fairbanks']; - it.each([ - [ - { - tags: TEST_TAGS, - ports: [5601, 9200], - schemes: ['http', 'tcp'], - locations: ['nyc', 'fairbanks'], - }, - [TAG_FIELD_NAME], - aria.TAG, - filterLabels.TAG, - TEST_TAGS, - ], - [ - { - tags: [], - ports: TEST_PORTS, - schemes: [], - locations: [], - }, - [PORT_FIELD_NAME], - aria.PORT, - filterLabels.PORT, - TEST_PORTS, - ], - [ - { - tags: [], - ports: [], - schemes: TEST_SCHEMES, - locations: [], - }, - [SCHEME_FIELD_NAME], - aria.SCHEME, - filterLabels.SCHEME, - TEST_SCHEMES, - ], - [ - { - tags: [], - ports: [], - schemes: [], - locations: TEST_LOCATIONS, - }, - [LOCATION_FIELD_NAME], - aria.LOCATION, - filterLabels.LOCATION, - TEST_LOCATIONS, - ], + [[TAG_FIELD_NAME], aria.TAG], + [[PORT_FIELD_NAME], aria.PORT], + [[SCHEME_FIELD_NAME], aria.SCHEME], + [[LOCATION_FIELD_NAME], aria.LOCATION], ])( 'applies accessible label to filter expressions, and contains selected filters', /** @@ -171,19 +110,14 @@ describe('FiltersExpressionSelect', () => { * @param filterLabel the name of the filter label expected in each item's aria-label * @param expectedFilterItems the set of filter options the component should render */ - async ( - filters, - newFilters, - expectedFilterButtonAriaLabel, - filterLabel, - expectedFilterItems - ) => { - const { getByLabelText } = render( + async (newFilters, expectedFilterButtonAriaLabel) => { + const spy = jest.spyOn(Hooks, 'useValuesList'); + spy.mockReturnValue({ loading: false, values: [{ label: 'test-label', count: 3 }] }); + const { getByLabelText, getByText } = render( @@ -194,9 +128,7 @@ describe('FiltersExpressionSelect', () => { fireEvent.click(filterButton); await waitFor(() => { - expectedFilterItems.forEach((filterItem: string | number) => - expect(getByLabelText(`Filter by ${filterLabel} ${filterItem}.`)) - ); + expect(getByText('Apply')); }); } ); diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select.tsx index b09d44488e803..cd0a78a42c5f7 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select.tsx @@ -7,31 +7,38 @@ import React, { useState } from 'react'; import { EuiButtonIcon, EuiExpression, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; -import { FilterPopover } from '../../filter_group/filter_popover'; import { filterLabels } from '../../filter_group/translations'; import { alertFilterLabels, filterAriaLabels } from './translations'; -import { FilterExpressionsSelectProps } from './filters_expression_select_container'; -import { OverviewFiltersState } from '../../../../state/reducers/overview_filters'; +import { FieldValueSuggestions } from '../../../../../../observability/public'; +import { useIndexPattern } from '../../../../contexts/uptime_index_pattern_context'; +import { FILTER_FIELDS } from '../../../../../common/constants'; +import { useGetUrlParams } from '../../../../hooks'; -type Props = FilterExpressionsSelectProps & Pick; +export interface FilterExpressionsSelectProps { + alertParams: { [key: string]: any }; + newFilters: string[]; + onRemoveFilter: (val: string) => void; + setAlertParams: (key: string, value: any) => void; + shouldUpdateUrl: boolean; +} -export const FiltersExpressionsSelect: React.FC = ({ +const { TYPE, TAGS, LOCATION, PORT } = FILTER_FIELDS; + +export const FiltersExpressionsSelect: React.FC = ({ alertParams, - filters: overviewFilters, newFilters, onRemoveFilter, setAlertParams, }) => { - const { tags, ports, schemes, locations } = overviewFilters; - const alertFilters = alertParams?.filters; - const selectedPorts = alertFilters?.['url.port'] ?? []; - const selectedLocations = alertFilters?.['observer.geo.name'] ?? []; - const selectedSchemes = alertFilters?.['monitor.type'] ?? []; - const selectedTags = alertFilters?.tags ?? []; + const selectedPorts = alertFilters?.[PORT] ?? []; + const selectedLocations = alertFilters?.[LOCATION] ?? []; + const selectedSchemes = alertFilters?.[TYPE] ?? []; + const selectedTags = alertFilters?.[TAGS] ?? []; - const onFilterFieldChange = (fieldName: string, values: string[]) => { + const { dateRangeStart: from, dateRangeEnd: to } = useGetUrlParams(); + const onFilterFieldChange = (fieldName: string, values?: string[]) => { // the `filters` field is no longer a string if (alertParams.filters && typeof alertParams.filters !== 'string') { setAlertParams('filters', { ...alertParams.filters, [fieldName]: values }); @@ -41,12 +48,12 @@ export const FiltersExpressionsSelect: React.FC = ({ Object.assign( {}, { - tags: [], - 'url.port': [], - 'observer.geo.name': [], - 'monitor.type': [], + [TAGS]: [], + [PORT]: [], + [LOCATION]: [], + [TYPE]: [], }, - { [fieldName]: values } + { [fieldName]: values ?? [] } ) ); } @@ -54,13 +61,11 @@ export const FiltersExpressionsSelect: React.FC = ({ const monitorFilters = [ { - 'aria-label': filterAriaLabels.PORT, + ariaLabel: filterAriaLabels.PORT, onFilterFieldChange, loading: false, fieldName: 'url.port', id: 'filter_port', - disabled: ports?.length === 0, - items: ports?.map((p: number) => p.toString()) ?? [], selectedItems: selectedPorts, title: filterLabels.PORT, description: @@ -68,39 +73,33 @@ export const FiltersExpressionsSelect: React.FC = ({ value: selectedPorts.length === 0 ? alertFilterLabels.ANY_PORT : selectedPorts?.join(','), }, { - 'aria-label': filterAriaLabels.TAG, + ariaLabel: filterAriaLabels.TAG, onFilterFieldChange, loading: false, fieldName: 'tags', id: 'filter_tags', - disabled: tags?.length === 0, - items: tags ?? [], selectedItems: selectedTags, title: filterLabels.TAG, description: selectedTags.length === 0 ? alertFilterLabels.WITH : alertFilterLabels.WITH_TAG, value: selectedTags.length === 0 ? alertFilterLabels.ANY_TAG : selectedTags?.join(','), }, { - 'aria-label': filterAriaLabels.SCHEME, + ariaLabel: filterAriaLabels.SCHEME, onFilterFieldChange, loading: false, fieldName: 'monitor.type', id: 'filter_scheme', - disabled: schemes?.length === 0, - items: schemes ?? [], selectedItems: selectedSchemes, title: filterLabels.SCHEME, description: selectedSchemes.length === 0 ? alertFilterLabels.OF : alertFilterLabels.OF_TYPE, value: selectedSchemes.length === 0 ? alertFilterLabels.ANY_TYPE : selectedSchemes?.join(','), }, { - 'aria-label': filterAriaLabels.LOCATION, + ariaLabel: filterAriaLabels.LOCATION, onFilterFieldChange, loading: false, fieldName: 'observer.geo.name', id: 'filter_location', - disabled: locations?.length === 0, - items: locations ?? [], selectedItems: selectedLocations, title: filterLabels.LOCATION, description: @@ -123,43 +122,61 @@ export const FiltersExpressionsSelect: React.FC = ({ (curr) => curr.selectedItems.length > 0 || newFilters?.includes(curr.fieldName) ); + const indexPattern = useIndexPattern(); + return ( <> - {filtersToDisplay.map(({ description, value, ...item }) => ( - - - setIsOpen({ ...isOpen, [item.id]: !isOpen[item.id] })} + {filtersToDisplay.map( + ({ description, id, title, value, fieldName, ariaLabel, selectedItems }) => ( + + + {indexPattern && ( + { + onFilterFieldChange(fieldName, vals); + }} + selectedValue={selectedItems} + button={ + setIsOpen({ ...isOpen, [id]: !isOpen[id] })} + /> + } + forceOpen={isOpen[id]} + setForceOpen={() => { + setIsOpen({ ...isOpen, [id]: !isOpen[id] }); + }} + asCombobox={false} + cardinalityField="monitor.id" + time={{ from, to }} + allowExclusions={false} /> - } - forceOpen={isOpen[item.id]} - setForceOpen={() => { - setIsOpen({ ...isOpen, [item.id]: !isOpen[item.id] }); - }} - /> - - - { - onRemoveFilter(item.fieldName); - onFilterFieldChange(item.fieldName, []); - }} - /> - - - - ))} + )} + + + { + onRemoveFilter(fieldName); + onFilterFieldChange(fieldName, []); + }} + /> + + + + ) + )} ); }; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select_container.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select_container.tsx deleted file mode 100644 index 0c03d55ba38f5..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/filters_expression_select_container.tsx +++ /dev/null @@ -1,25 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { useSelector } from 'react-redux'; -import { FiltersExpressionsSelect } from './filters_expression_select'; -import { overviewFiltersSelector } from '../../../../state/selectors'; - -export interface FilterExpressionsSelectProps { - alertParams: { [key: string]: any }; - newFilters: string[]; - onRemoveFilter: (val: string) => void; - setAlertParams: (key: string, value: any) => void; - shouldUpdateUrl: boolean; -} - -export const FiltersExpressionSelectContainer: React.FC = (props) => { - const overviewFilters = useSelector(overviewFiltersSelector); - - return ; -}; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/index.ts b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/index.ts index 85d0e82471e5c..6797517116ccd 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/index.ts +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_expressions/index.ts @@ -7,6 +7,5 @@ export { DownNoExpressionSelect } from './down_number_select'; export { FiltersExpressionsSelect } from './filters_expression_select'; -export { FiltersExpressionSelectContainer } from './filters_expression_select_container'; export { TimeExpressionSelect } from './time_expression_select'; export { StatusExpressionSelect } from './status_expression_select'; diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_status_alert/alert_monitor_status.test.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_status_alert/alert_monitor_status.test.tsx index e161727b46b1b..b339410ef2409 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_status_alert/alert_monitor_status.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_status_alert/alert_monitor_status.test.tsx @@ -7,10 +7,45 @@ import React from 'react'; import { screen } from '@testing-library/dom'; -import { AlertMonitorStatusComponent, AlertMonitorStatusProps } from './alert_monitor_status'; +import { + AlertMonitorStatusComponent, + AlertMonitorStatusProps, + hasFilters, +} from './alert_monitor_status'; import { render } from '../../../../lib/helper/rtl_helpers'; describe('alert monitor status component', () => { + describe('hasFilters', () => { + const EMPTY_FILTERS = { + tags: [], + 'url.port': [], + 'observer.geo.name': [], + 'monitor.type': [], + }; + + it('returns false when filters are empty', () => { + expect(hasFilters({})).toBe(false); + }); + + it('returns false when all fields are empty', () => { + expect(hasFilters(EMPTY_FILTERS)).toBe(false); + }); + + it.each([ + { tags: ['prod'] }, + { 'url.port': ['5678'] }, + { 'observer.geo.name': ['Fairbanks'] }, + { 'monitor.type': ['HTTP'] }, + ])('returns true if a filter has a field', (testObj) => { + expect( + hasFilters({ + ...EMPTY_FILTERS, + ...testObj, + }) + ).toBe(true); + }); + }); + describe('AlertMonitorStatus', () => { const defaultProps: AlertMonitorStatusProps = { alertParams: { @@ -20,7 +55,6 @@ describe('alert monitor status component', () => { timerangeCount: 21, }, enabled: true, - hasFilters: false, isOldAlert: true, snapshotCount: 0, snapshotLoading: false, @@ -38,7 +72,7 @@ describe('alert monitor status component', () => { expect(await screen.findByText('Add filter')).toBeInTheDocument(); expect(await screen.findByText('Availability')).toBeInTheDocument(); expect(await screen.findByText('Status check')).toBeInTheDocument(); - expect(await screen.findByText('matching monitors are up in')).toBeInTheDocument(); + expect(await screen.findByText('any monitor is up in')).toBeInTheDocument(); expect(await screen.findByText('days')).toBeInTheDocument(); expect(await screen.findByText('hours')).toBeInTheDocument(); expect(await screen.findByText('within the last')).toBeInTheDocument(); diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_status_alert/alert_monitor_status.tsx b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_status_alert/alert_monitor_status.tsx index eaae1650b02ed..8ed4b8f7a0032 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/monitor_status_alert/alert_monitor_status.tsx +++ b/x-pack/plugins/uptime/public/components/overview/alerts/monitor_status_alert/alert_monitor_status.tsx @@ -8,17 +8,17 @@ import React, { useCallback, useEffect, useState } from 'react'; import { EuiCallOut, EuiSpacer, EuiHorizontalRule, EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@kbn/i18n/react'; -import { FiltersExpressionSelectContainer, StatusExpressionSelect } from '../monitor_expressions'; +import { FiltersExpressionsSelect, StatusExpressionSelect } from '../monitor_expressions'; import { AddFilterButton } from './add_filter_btn'; import { OldAlertCallOut } from './old_alert_call_out'; import { AvailabilityExpressionSelect } from '../monitor_expressions/availability_expression_select'; import { AlertQueryBar } from '../alert_query_bar/query_bar'; import { useGetUrlParams } from '../../../../hooks'; +import { FILTER_FIELDS } from '../../../../../common/constants'; export interface AlertMonitorStatusProps { alertParams: { [key: string]: any }; enabled: boolean; - hasFilters: boolean; isOldAlert: boolean; snapshotCount: number; snapshotLoading?: boolean; @@ -30,15 +30,16 @@ export interface AlertMonitorStatusProps { }; } +export const hasFilters = (filters?: { [key: string]: string[] }) => { + if (!filters || Object.keys(filters).length === 0) { + return false; + } + + return Object.values(FILTER_FIELDS).some((f) => filters[f].length); +}; + export const AlertMonitorStatusComponent: React.FC = (props) => { - const { - alertParams, - hasFilters, - isOldAlert, - setAlertParams, - snapshotCount, - snapshotLoading, - } = props; + const { alertParams, isOldAlert, setAlertParams, snapshotCount, snapshotLoading } = props; const alertFilters = alertParams?.filters ?? {}; const [newFilters, setNewFilters] = useState( @@ -94,7 +95,7 @@ export const AlertMonitorStatusComponent: React.FC = (p }} /> - { @@ -110,8 +111,8 @@ export const AlertMonitorStatusComponent: React.FC = (p @@ -120,6 +121,7 @@ export const AlertMonitorStatusComponent: React.FC = (p alertParams={alertParams} isOldAlert={isOldAlert} setAlertParams={setAlertParams} + hasFilters={hasFilters(alertParams?.filters)} /> diff --git a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts index 7cfcdabe5562b..76d8ebaea3719 100644 --- a/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts +++ b/x-pack/plugins/uptime/public/components/overview/alerts/translations.ts @@ -197,6 +197,15 @@ export const ENTER_AVAILABILITY_THRESHOLD_DESCRIPTION = i18n.translate( } ); +export const ENTER_ANY_AVAILABILITY_THRESHOLD_DESCRIPTION = i18n.translate( + 'xpack.uptime.alerts.monitorStatus.availability.threshold.anyMonitorDescription', + { + defaultMessage: 'any monitor is up in', + description: + 'This fragment explains that an alert will fire for monitors matching user-specified criteria', + } +); + export const ENTER_AVAILABILITY_THRESHOLD_VALUE = (value: string) => i18n.translate('xpack.uptime.alerts.monitorStatus.availability.threshold.value', { defaultMessage: '< {value}% of checks', diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/__snapshots__/parse_filter_map.test.ts.snap b/x-pack/plugins/uptime/public/components/overview/filter_group/__snapshots__/parse_filter_map.test.ts.snap deleted file mode 100644 index cabad2818fa90..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/__snapshots__/parse_filter_map.test.ts.snap +++ /dev/null @@ -1,20 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`parseFiltersMap provides values from valid filter string 1`] = ` -Object { - "locations": Array [ - "us-east-2", - ], - "ports": Array [ - "5601", - "80", - ], - "schemes": Array [ - "http", - "tcp", - ], - "tags": Array [], -} -`; - -exports[`parseFiltersMap returns an empty object for invalid filter 1`] = `"Unable to parse invalid filter string"`; diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.test.tsx b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.test.tsx index 807f95c22bc61..cdc399a750756 100644 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.test.tsx +++ b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.test.tsx @@ -8,67 +8,103 @@ import React from 'react'; import { fireEvent, waitFor } from '@testing-library/react'; import { render } from '../../../lib/helper/rtl_helpers'; -import { FilterGroupComponent } from './filter_group'; +import { FilterGroup } from './filter_group'; +import * as Hooks from '../../../../../observability/public/hooks/use_values_list'; -describe('FilterGroupComponent', () => { - const overviewFilters = { - locations: ['nyc', 'fairbanks'], - ports: [5601, 9200], - schemes: ['http', 'tcp'], - tags: ['prod', 'dev'], - }; +describe('FilterGroup', () => { it.each([ - ['expands filter group for Location filter', 'Search for location'], - ['expands filter group for Port filter', 'Search for port'], - ['expands filter group for Scheme filter', 'Search for scheme'], - ['expands filter group for Tag filter', 'Search for tag'], - ])('handles loading', async (popoverButtonLabel, searchInputLabel) => { - const { getByLabelText } = render( - - ); + ['expands filter group for Location filter'], + ['expands filter group for Port filter'], + ['expands filter group for Scheme filter'], + ['expands filter group for Tag filter'], + ])('handles loading', async (popoverButtonLabel) => { + jest.spyOn(Hooks, 'useValuesList').mockReturnValue({ + values: [], + loading: true, + }); + const { getByLabelText, getByText } = render(); - const popoverButton = getByLabelText(popoverButtonLabel); - fireEvent.click(popoverButton); await waitFor(() => { - const searchInput = getByLabelText(searchInputLabel); - expect(searchInput).toHaveAttribute('placeholder', 'Loading...'); + const popoverButton = getByLabelText(popoverButtonLabel); + fireEvent.click(popoverButton); + }); + await waitFor(() => { + expect(getByText('Loading options')); }); }); it.each([ [ 'expands filter group for Location filter', - 'Search for location', - ['Filter by Location nyc.', 'Filter by Location fairbanks.'], + [ + [ + { + label: 'Fairbanks', + count: 10, + }, + { + label: 'NYC', + count: 2, + }, + ], + [], + [], + [], + ], ], [ 'expands filter group for Port filter', - 'Search for port', - ['Filter by Port 5601.', 'Filter by Port 9200.'], + [ + [], + [ + { label: '80', count: 12 }, + { label: '443', count: 8 }, + ], + [], + [], + ], ], [ 'expands filter group for Scheme filter', - 'Search for scheme', - ['Filter by Scheme http.', 'Filter by Scheme tcp.'], + [ + [], + [], + [ + { label: 'HTTP', count: 15 }, + { label: 'TCP', count: 10 }, + ], + [], + ], ], [ 'expands filter group for Tag filter', - 'Search for tag', - ['Filter by Tag prod.', 'Filter by Tag dev.'], + [ + [], + [], + [], + [ + { label: 'test', count: 23 }, + { label: 'prod', count: 10 }, + ], + ], ], - ])( - 'displays filter items when clicked', - async (popoverButtonLabel, searchInputLabel, filterItemButtonLabels) => { - const { getByLabelText } = render( - - ); + ])('displays filter item counts when clicked', async (popoverButtonLabel, values) => { + const spy = jest.spyOn(Hooks, 'useValuesList'); + for (let i = 0; i < 4; i++) { + spy.mockReturnValueOnce({ + values: values[i], + loading: false, + }); + } + const { getByLabelText, getAllByLabelText } = render(); + + await waitFor(() => { const popoverButton = getByLabelText(popoverButtonLabel); fireEvent.click(popoverButton); - await waitFor(() => { - expect(getByLabelText(searchInputLabel)); - filterItemButtonLabels.forEach((itemLabel) => expect(getByLabelText(itemLabel))); - }); - } - ); + }); + + expect(getByLabelText('2 available filters')); + expect(getAllByLabelText('0 available filters')).toHaveLength(3); + }); }); diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx index b60d2b3050f5c..3980b4bf9d3da 100644 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx +++ b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group.tsx @@ -5,100 +5,72 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useCallback, useState } from 'react'; import { EuiFilterGroup } from '@elastic/eui'; import styled from 'styled-components'; -import { useRouteMatch } from 'react-router-dom'; -import { FilterPopoverProps, FilterPopover } from './filter_popover'; -import { OverviewFilters } from '../../../../common/runtime_types/overview_filters'; -import { filterLabels } from './translations'; import { useFilterUpdate } from '../../../hooks/use_filter_update'; -import { MONITOR_ROUTE } from '../../../../common/constants'; import { useSelectedFilters } from '../../../hooks/use_selected_filters'; - -interface Props { - loading: boolean; - overviewFilters: OverviewFilters; -} +import { FieldValueSuggestions } from '../../../../../observability/public'; +import { SelectedFilters } from './selected_filters'; +import { useIndexPattern } from '../../../contexts/uptime_index_pattern_context'; +import { useGetUrlParams } from '../../../hooks'; const Container = styled(EuiFilterGroup)` margin-bottom: 10px; `; -function isDisabled(array?: T[]) { - return array ? array.length === 0 : true; -} - -export const FilterGroupComponent: React.FC = ({ overviewFilters, loading }) => { - const { locations, ports, schemes, tags } = overviewFilters; - +export const FilterGroup = () => { const [updatedFieldValues, setUpdatedFieldValues] = useState<{ fieldName: string; values: string[]; - }>({ fieldName: '', values: [] }); + notValues: string[]; + }>({ fieldName: '', values: [], notValues: [] }); - useFilterUpdate(updatedFieldValues.fieldName, updatedFieldValues.values); + useFilterUpdate( + updatedFieldValues.fieldName, + updatedFieldValues.values, + updatedFieldValues.notValues + ); - const { selectedLocations, selectedPorts, selectedSchemes, selectedTags } = useSelectedFilters(); + const { dateRangeStart, dateRangeEnd } = useGetUrlParams(); - const onFilterFieldChange = (fieldName: string, values: string[]) => { - setUpdatedFieldValues({ fieldName, values }); - }; + const { filtersList } = useSelectedFilters(); - const isMonitorPage = useRouteMatch(MONITOR_ROUTE); + const indexPattern = useIndexPattern(); - const filterPopoverProps: FilterPopoverProps[] = [ - { - loading, - onFilterFieldChange, - fieldName: 'observer.geo.name', - id: 'location', - items: locations || [], - selectedItems: selectedLocations, - title: filterLabels.LOCATION, + const onFilterFieldChange = useCallback( + (fieldName: string, values: string[], notValues: string[]) => { + setUpdatedFieldValues({ fieldName, values, notValues }); }, - // on monitor page we only display location filter in ping list - ...(!isMonitorPage - ? [ - { - loading, - onFilterFieldChange, - fieldName: 'url.port', - id: 'port', - disabled: isDisabled(ports), - items: ports?.map((p: number) => p.toString()) ?? [], - selectedItems: selectedPorts, - title: filterLabels.PORT, - }, - { - loading, - onFilterFieldChange, - fieldName: 'monitor.type', - id: 'scheme', - disabled: isDisabled(schemes), - items: schemes ?? [], - selectedItems: selectedSchemes, - title: filterLabels.SCHEME, - }, - { - loading, - onFilterFieldChange, - fieldName: 'tags', - id: 'tags', - disabled: isDisabled(tags), - items: tags ?? [], - selectedItems: selectedTags, - title: filterLabels.TAG, - }, - ] - : []), - ]; + [] + ); return ( - - {filterPopoverProps.map((item) => ( - - ))} - + <> + + {indexPattern && + filtersList.map(({ field, label, selectedItems, excludedItems }) => ( + + onFilterFieldChange(field, values ?? [], notValues ?? []) + } + asCombobox={false} + asFilterButton={true} + forceOpen={false} + filters={[]} + cardinalityField="monitor.id" + time={{ from: dateRangeStart, to: dateRangeEnd }} + /> + ))} + + + ); }; diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group_container.tsx b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group_container.tsx deleted file mode 100644 index db1892526a1e6..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_group_container.tsx +++ /dev/null @@ -1,57 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React, { useContext, useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { useGetUrlParams } from '../../../hooks'; -import { parseFiltersMap } from './parse_filter_map'; -import { fetchOverviewFilters } from '../../../state/actions'; -import { FilterGroupComponent } from './index'; -import { UptimeRefreshContext } from '../../../contexts'; -import { esKuerySelector, overviewFiltersSelector } from '../../../state/selectors'; - -interface Props { - esFilters?: string; -} - -export const FilterGroup: React.FC = ({ esFilters }: Props) => { - const { lastRefresh } = useContext(UptimeRefreshContext); - - const { filters: overviewFilters, loading } = useSelector(overviewFiltersSelector); - const esKuery = useSelector(esKuerySelector); - - const { dateRangeStart, dateRangeEnd, statusFilter, filters: urlFilters } = useGetUrlParams(); - - const dispatch = useDispatch(); - - useEffect(() => { - const filterSelections = parseFiltersMap(urlFilters); - dispatch( - fetchOverviewFilters({ - dateRangeStart, - dateRangeEnd, - locations: filterSelections.locations ?? [], - ports: filterSelections.ports ?? [], - schemes: filterSelections.schemes ?? [], - search: esKuery, - statusFilter, - tags: filterSelections.tags ?? [], - }) - ); - }, [ - lastRefresh, - dateRangeStart, - dateRangeEnd, - esKuery, - esFilters, - statusFilter, - urlFilters, - dispatch, - ]); - - return ; -}; diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_popover.test.tsx b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_popover.test.tsx deleted file mode 100644 index bccebb21718bf..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_popover.test.tsx +++ /dev/null @@ -1,96 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import React from 'react'; -import { fireEvent, waitFor, waitForElementToBeRemoved } from '@testing-library/react'; -import { FilterPopoverProps, FilterPopover } from './filter_popover'; -import { render } from '../../../lib/helper/rtl_helpers'; - -describe('FilterPopover component', () => { - let props: FilterPopoverProps; - - beforeEach(() => { - props = { - fieldName: 'test-fieldName', - id: 'test', - loading: false, - items: ['first', 'second', 'third', 'fourth'], - onFilterFieldChange: jest.fn(), - selectedItems: ['first', 'third'], - title: 'test-title', - }; - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('expands on button click', () => { - const { getByRole, getByLabelText, getByText, queryByLabelText, queryByText } = render( - - ); - - const screenReaderOnlyText = 'You are in a dialog. To close this dialog, hit escape.'; - - expect(queryByText(screenReaderOnlyText)).toBeNull(); - expect(queryByLabelText('Filter by bar fourth.')).toBeNull(); - - fireEvent.click(getByRole('button')); - - expect(getByText(screenReaderOnlyText)); - expect(getByLabelText('Filter by test-title fourth.')); - }); - - it('does not show item list when loading, and displays placeholder', async () => { - props.loading = true; - const { getByRole, queryByText, getByLabelText } = render(); - - fireEvent.click(getByRole('button')); - - await waitFor(() => { - const search = getByLabelText('Search for test-title'); - expect(search).toHaveAttribute('placeholder', 'Loading...'); - }); - - expect(queryByText('Filter by test-title second.')).toBeNull(); - }); - - it.each([ - [[], ['third'], ['third']], - [['first', 'third'], ['first'], ['third']], - [['fourth'], ['first', 'second'], ['first', 'second', 'fourth']], - ])( - 'returns selected items on popover close', - async (selectedPropsItems, expectedSelections, itemsToClick) => { - if (itemsToClick.length < 1) { - throw new Error('This test assumes at least one item will be clicked'); - } - props.selectedItems = selectedPropsItems; - - const { getByLabelText, queryByLabelText } = render(); - - const uptimeFilterButton = getByLabelText(`expands filter group for ${props.title} filter`); - - fireEvent.click(uptimeFilterButton); - - const generateLabelText = (item: string) => `Filter by ${props.title} ${item}.`; - - itemsToClick.forEach((item) => { - const optionButtonLabelText = generateLabelText(item); - const optionButton = getByLabelText(optionButtonLabelText); - fireEvent.click(optionButton); - }); - - fireEvent.click(uptimeFilterButton); - - await waitForElementToBeRemoved(() => queryByLabelText(generateLabelText(itemsToClick[0]))); - - expect(props.onFilterFieldChange).toHaveBeenCalledTimes(1); - expect(props.onFilterFieldChange).toHaveBeenCalledWith(props.fieldName, expectedSelections); - } - ); -}); diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_popover.tsx b/x-pack/plugins/uptime/public/components/overview/filter_group/filter_popover.tsx deleted file mode 100644 index 23e17802a6835..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/filter_popover.tsx +++ /dev/null @@ -1,146 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFieldSearch, EuiFilterSelectItem, EuiPopover, EuiPopoverTitle } from '@elastic/eui'; -import React, { useState, useEffect } from 'react'; -import { i18n } from '@kbn/i18n'; -import { UptimeFilterButton } from './uptime_filter_button'; -import { toggleSelectedItems } from './toggle_selected_item'; -import { LocationLink } from '../monitor_list'; - -export interface FilterPopoverProps { - fieldName: string; - id: string; - loading: boolean; - disabled?: boolean; - items: string[]; - onFilterFieldChange: (fieldName: string, values: string[]) => void; - selectedItems: string[]; - title: string; - btnContent?: JSX.Element; - forceOpen?: boolean; - setForceOpen?: (val: boolean) => void; -} - -const isItemSelected = (selectedItems: string[], item: string): 'on' | undefined => - selectedItems.find((selected) => selected === item) ? 'on' : undefined; - -export const FilterPopover = ({ - fieldName, - id, - disabled, - loading, - items: allItems, - onFilterFieldChange, - selectedItems, - title, - btnContent, - forceOpen, - setForceOpen, -}: FilterPopoverProps) => { - const [isOpen, setIsOpen] = useState(false); - const [itemsToDisplay, setItemsToDisplay] = useState([]); - const [searchQuery, setSearchQuery] = useState(''); - const [tempSelectedItems, setTempSelectedItems] = useState(selectedItems); - - const [items, setItems] = useState([]); - - useEffect(() => { - // Merge incoming items with selected items, to enable deselection - - const mItems = selectedItems.concat(allItems ?? []); - const newItems = mItems.filter((item, index) => mItems.indexOf(item) === index); - setItems(newItems); - setTempSelectedItems(selectedItems); - }, [allItems, selectedItems]); - - useEffect(() => { - if (searchQuery !== '') { - const toDisplay = items.filter((item) => item.indexOf(searchQuery) >= 0); - setItemsToDisplay(toDisplay); - } else { - setItemsToDisplay(items); - } - }, [searchQuery, items]); - - return ( - 0} - numFilters={items.length} - numActiveFilters={isOpen ? tempSelectedItems.length : selectedItems.length} - onClick={() => { - if (isOpen) { - // only update these values on close - onFilterFieldChange(fieldName, tempSelectedItems); - } - setIsOpen(!isOpen); - }} - title={title} - /> - ) - } - closePopover={() => { - setIsOpen(false); - onFilterFieldChange(fieldName, tempSelectedItems); - if (setForceOpen) { - setForceOpen(false); - } - }} - data-test-subj={`filter-popover_${id}`} - id={id} - isOpen={isOpen || forceOpen} - ownFocus={true} - zIndex={10000} - > - - setSearchQuery(query)} - aria-label={i18n.translate('xpack.uptime.filterPopout.searchMessage.ariaLabel', { - defaultMessage: 'Search for {title}', - values: { - title: title.toLowerCase(), - }, - })} - placeholder={ - loading - ? i18n.translate('xpack.uptime.filterPopout.loadingMessage', { - defaultMessage: 'Loading...', - }) - : i18n.translate('xpack.uptime.filterPopout.searchMessage', { - defaultMessage: 'Search {title}', - values: { - title: title.toLowerCase(), - }, - }) - } - /> - - {!loading && - itemsToDisplay.map((item) => ( - toggleSelectedItems(item, tempSelectedItems, setTempSelectedItems)} - > - {item} - - ))} - {id === 'location' && items.length === 0 && } - - ); -}; diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/index.ts b/x-pack/plugins/uptime/public/components/overview/filter_group/index.ts deleted file mode 100644 index befe65560dfd6..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/index.ts +++ /dev/null @@ -1,9 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -export { FilterGroupComponent } from './filter_group'; -export { FilterGroup } from './filter_group_container'; diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.test.ts b/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.test.ts deleted file mode 100644 index d06af65cee73d..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.test.ts +++ /dev/null @@ -1,22 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { parseFiltersMap } from './parse_filter_map'; - -describe('parseFiltersMap', () => { - it('provides values from valid filter string', () => { - expect( - parseFiltersMap( - '[["url.port",["5601","80"]],["observer.geo.name",["us-east-2"]],["monitor.type",["http","tcp"]]]' - ) - ).toMatchSnapshot(); - }); - - it('returns an empty object for invalid filter', () => { - expect(() => parseFiltersMap('some invalid string')).toThrowErrorMatchingSnapshot(); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.ts b/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.ts deleted file mode 100644 index 5d08847b6b713..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/parse_filter_map.ts +++ /dev/null @@ -1,39 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -interface FilterField { - name: string; - fieldName: string; -} - -/** - * These are the only filter fields we are looking to catch at the moment. - * If your code needs to support custom fields, introduce a second parameter to - * `parseFiltersMap` to take a list of FilterField objects. - */ -const filterAllowList: FilterField[] = [ - { name: 'ports', fieldName: 'url.port' }, - { name: 'locations', fieldName: 'observer.geo.name' }, - { name: 'tags', fieldName: 'tags' }, - { name: 'schemes', fieldName: 'monitor.type' }, -]; - -export const parseFiltersMap = (filterMapString: string) => { - if (!filterMapString) { - return {}; - } - const filterSlices: { [key: string]: any } = {}; - try { - const map = new Map(JSON.parse(filterMapString)); - filterAllowList.forEach(({ name, fieldName }) => { - filterSlices[name] = map.get(fieldName) ?? []; - }); - return filterSlices; - } catch { - throw new Error('Unable to parse invalid filter string'); - } -}; diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/selected_filters.tsx b/x-pack/plugins/uptime/public/components/overview/filter_group/selected_filters.tsx new file mode 100644 index 0000000000000..7e70673016d2c --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/filter_group/selected_filters.tsx @@ -0,0 +1,79 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { FilterValueLabel } from '../../../../../observability/public'; +import { useIndexPattern } from '../../../contexts/uptime_index_pattern_context'; +import { useSelectedFilters } from '../../../hooks/use_selected_filters'; + +interface Props { + onChange: (fieldName: string, values: string[], notValues: string[]) => void; +} +export const SelectedFilters = ({ onChange }: Props) => { + const indexPattern = useIndexPattern(); + const { filtersList } = useSelectedFilters(); + + if (!indexPattern) return null; + + return ( + + {filtersList.map(({ field, selectedItems, excludedItems, label }) => [ + ...selectedItems.map((value) => ( + + { + onChange( + field, + selectedItems.filter((valT) => valT !== value), + excludedItems + ); + }} + invertFilter={(val) => { + onChange( + field, + selectedItems.filter((valT) => valT !== value), + [...excludedItems, value] + ); + }} + field={field} + value={value} + negate={false} + label={label} + /> + + )), + ...excludedItems.map((value) => ( + + { + onChange( + field, + selectedItems, + excludedItems.filter((valT) => valT !== value) + ); + }} + invertFilter={(val) => { + onChange( + field, + [...selectedItems, value], + excludedItems.filter((valT) => valT !== value) + ); + }} + field={field} + value={value} + negate={true} + label={label} + /> + + )), + ])} + + ); +}; diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/toggle_selected_item.test.ts b/x-pack/plugins/uptime/public/components/overview/filter_group/toggle_selected_item.test.ts deleted file mode 100644 index 0a80f2062320d..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/toggle_selected_item.test.ts +++ /dev/null @@ -1,24 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { toggleSelectedItems } from './toggle_selected_item'; - -describe('toggleSelectedItems', () => { - it(`adds the item if it's not in the list`, () => { - const mock = jest.fn(); - toggleSelectedItems('abc', ['aba', 'abd'], mock); - expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith(['aba', 'abd', 'abc']); - }); - - it(`removes the item if it's already in the list`, () => { - const mock = jest.fn(); - toggleSelectedItems('abc', ['aba', 'abc', 'abd'], mock); - expect(mock).toHaveBeenCalledTimes(1); - expect(mock).toHaveBeenCalledWith(['aba', 'abd']); - }); -}); diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/toggle_selected_item.ts b/x-pack/plugins/uptime/public/components/overview/filter_group/toggle_selected_item.ts deleted file mode 100644 index 08b031f936dc5..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/toggle_selected_item.ts +++ /dev/null @@ -1,23 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { Dispatch, SetStateAction } from 'react'; - -export const toggleSelectedItems = ( - item: string, - tempSelectedItems: string[], - setTempSelectedItems: Dispatch> -) => { - const index = tempSelectedItems.indexOf(item); - const nextSelectedItems = [...tempSelectedItems]; - if (index >= 0) { - nextSelectedItems.splice(index, 1); - } else { - nextSelectedItems.push(item); - } - setTempSelectedItems(nextSelectedItems); -}; diff --git a/x-pack/plugins/uptime/public/components/overview/filter_group/uptime_filter_button.tsx b/x-pack/plugins/uptime/public/components/overview/filter_group/uptime_filter_button.tsx deleted file mode 100644 index 326ad7a292455..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/filter_group/uptime_filter_button.tsx +++ /dev/null @@ -1,44 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { EuiFilterButton } from '@elastic/eui'; -import { i18n } from '@kbn/i18n'; -import React from 'react'; - -interface UptimeFilterButtonProps { - isDisabled?: boolean; - isSelected: boolean; - numFilters: number; - numActiveFilters: number; - onClick: () => void; - title: string; -} - -export const UptimeFilterButton = ({ - isDisabled, - isSelected, - numFilters, - numActiveFilters, - onClick, - title, -}: UptimeFilterButtonProps) => ( - - {title} - -); diff --git a/x-pack/plugins/uptime/public/components/overview/index.ts b/x-pack/plugins/uptime/public/components/overview/index.ts index 729db44aaa964..d647c38cee1ca 100644 --- a/x-pack/plugins/uptime/public/components/overview/index.ts +++ b/x-pack/plugins/uptime/public/components/overview/index.ts @@ -7,6 +7,5 @@ export * from './monitor_list'; export * from './empty_state'; -export * from './filter_group'; export * from './alerts'; export * from './snapshot'; diff --git a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_name_col.tsx b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_name_col.tsx index 7adf248d37fc5..65bbaf45b63a2 100644 --- a/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_name_col.tsx +++ b/x-pack/plugins/uptime/public/components/overview/monitor_list/columns/monitor_name_col.tsx @@ -5,7 +5,7 @@ * 2.0. */ -import React, { useState } from 'react'; +import React, { useState, useMemo } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiButtonEmpty, EuiText } from '@elastic/eui'; import { MonitorPageLink } from '../../../common/monitor_page_link'; @@ -44,7 +44,12 @@ export const MonitorNameColumn = ({ summary }: Props) => { const [filterType, setFilterType] = useState(currFilters.get('monitor.type') ?? []); - useFilterUpdate('monitor.type', filterType); + const excludedTypeFilters = useMemo(() => { + const currExcludedFilters = parseCurrentFilters(params.excludedFilters); + return currExcludedFilters.get('monitor.type') ?? []; + }, [params.excludedFilters]); + + useFilterUpdate('monitor.type', filterType, excludedTypeFilters); const filterLabel = i18n.translate('xpack.uptime.monitorList.monitorType.filter', { defaultMessage: 'Filter all monitors with type {type}', diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx b/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx index 9436f420f7740..3c8c0599efa69 100644 --- a/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx +++ b/x-pack/plugins/uptime/public/components/overview/query_bar/query_bar.tsx @@ -9,10 +9,9 @@ import React, { useState } from 'react'; import { i18n } from '@kbn/i18n'; import { EuiFlexItem } from '@elastic/eui'; import { QueryStringInput } from '../../../../../../../src/plugins/data/public/'; -import { useIndexPattern } from './use_index_pattern'; import { SyntaxType, useQueryBar } from './use_query_bar'; import { KQL_PLACE_HOLDER, SIMPLE_SEARCH_PLACEHOLDER } from './translations'; -import { useGetUrlParams } from '../../../hooks'; +import { useGetUrlParams, useIndexPattern } from '../../../hooks'; const SYNTAX_STORAGE = 'uptime:queryBarSyntax'; @@ -36,7 +35,7 @@ export const QueryBar = () => { const { query, setQuery, submitImmediately } = useQueryBar(); - const { index_pattern: indexPattern } = useIndexPattern(); + const indexPattern = useIndexPattern(); const [inputVal, setInputVal] = useState(query.query as string); diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts b/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts deleted file mode 100644 index b0e567c40ed73..0000000000000 --- a/x-pack/plugins/uptime/public/components/overview/query_bar/use_index_pattern.ts +++ /dev/null @@ -1,25 +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 - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { useEffect } from 'react'; -import { useDispatch, useSelector } from 'react-redux'; -import { getIndexPattern } from '../../../state/actions'; -import { selectIndexPattern } from '../../../state/selectors'; - -export const useIndexPattern = () => { - const dispatch = useDispatch(); - const indexPattern = useSelector(selectIndexPattern); - - useEffect(() => { - // we only use index pattern for kql queries - if (!indexPattern.index_pattern) { - dispatch(getIndexPattern()); - } - }, [indexPattern.index_pattern, dispatch]); - - return indexPattern; -}; diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.test.tsx b/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.test.tsx new file mode 100644 index 0000000000000..e4c57dab0ffcf --- /dev/null +++ b/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.test.tsx @@ -0,0 +1,126 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import { waitFor } from '@testing-library/react'; +import { act, renderHook } from '@testing-library/react-hooks'; +import { MockRouter, MockKibanaProvider } from '../../../lib/helper/rtl_helpers'; +import { SyntaxType, useQueryBar, DEBOUNCE_INTERVAL } from './use_query_bar'; +import { MountWithReduxProvider } from '../../../lib'; +import * as URL from '../../../hooks/use_url_params'; +import * as ES_FILTERS from '../../../hooks/update_kuery_string'; +import { UptimeUrlParams } from '../../../lib/helper/url_params'; + +const SAMPLE_ES_FILTERS = `{"bool":{"should":[{"match_phrase":{"monitor.id":"NodeServer"}}],"minimum_should_match":1}}`; + +describe('useQueryBar', () => { + let DEFAULT_URL_PARAMS: UptimeUrlParams; + let wrapper: any; + let useUrlParamsSpy: jest.SpyInstance<[URL.GetUrlParams, URL.UpdateUrlParams]>; + let useGetUrlParamsSpy: jest.SpyInstance; + let updateUrlParamsMock: jest.Mock; + let useUpdateKueryStringSpy: jest.SpyInstance; + + beforeEach(() => { + DEFAULT_URL_PARAMS = { + absoluteDateRangeStart: 100, + absoluteDateRangeEnd: 200, + autorefreshInterval: 10000, + autorefreshIsPaused: true, + dateRangeStart: 'now-15m', + dateRangeEnd: 'now', + excludedFilters: '', + filters: '', + query: '', + search: 'monitor.id: "My-Monitor"', + statusFilter: '', + }; + wrapper = ({ children }: any) => ( + + + {children} + + + ); + useUrlParamsSpy = jest.spyOn(URL, 'useUrlParams'); + useGetUrlParamsSpy = jest.spyOn(URL, 'useGetUrlParams'); + useUpdateKueryStringSpy = jest.spyOn(ES_FILTERS, 'useUpdateKueryString'); + updateUrlParamsMock = jest.fn(); + + useUrlParamsSpy.mockImplementation(() => [jest.fn(), updateUrlParamsMock]); + useGetUrlParamsSpy.mockReturnValue(DEFAULT_URL_PARAMS); + useUpdateKueryStringSpy.mockReturnValue([SAMPLE_ES_FILTERS]); + }); + + it.each([ + [SyntaxType.text, undefined, SAMPLE_ES_FILTERS, '', 'monitor.id: "My-Other-Monitor"', false, 0], + [ + SyntaxType.kuery, + new Error('there was a problem'), + SAMPLE_ES_FILTERS, + '', + 'monitor.id: "My-Other-Monitor"', + false, + 0, + ], + [SyntaxType.kuery, undefined, undefined, '', 'monitor.id: "My-Other-Monitor"', false, 0], + [SyntaxType.text, undefined, undefined, '', 'monitor.id: "My-Other-Monitor"', false, 0], + [SyntaxType.text, undefined, undefined, 'my-search', 'monitor.id: "My-Other-Monitor"', true, 1], + [SyntaxType.kuery, undefined, undefined, 'my-search', '', true, 1], + [ + SyntaxType.kuery, + undefined, + SAMPLE_ES_FILTERS, + 'my-search', + 'monitor.id: "My-Monitor"', + true, + 1, + ], + ])( + 'updates URL only when conditions are appropriate', + /** + * This test is designed to prevent massive duplication of boilerplate; each set of parameters should trigger + * a different response from the hook. At the end, we wait for the debounce interval to elapse and then check + * whether the URL was updated. + * + * @param language the query syntax + * @param error an error resulting from parsing es filters + * @param esFilters the AST string generated from parsing kuery syntax + * @param search the simple text search + * @param query the new kuery entered by the user + * @param shouldExpectCall boolean denoting whether or not the test should expect the url to be updated + * @param calledTimes the number of times the test should expect the url to be updated + */ + async (language, error, esFilters, search, query, shouldExpectCall, calledTimes) => { + const { + result: { current }, + } = renderHook(() => useQueryBar(), { wrapper }); + + useUpdateKueryStringSpy.mockReturnValue([esFilters, error]); + useGetUrlParamsSpy.mockReturnValue({ + ...DEFAULT_URL_PARAMS, + search, + }); + + act(() => { + current.setQuery({ + query, + language, + }); + }); + + await waitFor(async () => { + await new Promise((r) => setInterval(r, DEBOUNCE_INTERVAL + 50)); + if (shouldExpectCall) { + expect(updateUrlParamsMock).toHaveBeenCalledTimes(calledTimes); + } else { + expect(updateUrlParamsMock).not.toHaveBeenCalled(); + } + }); + } + ); +}); diff --git a/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts b/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts index 2f2d8bf092ddf..4dd431e9617a3 100644 --- a/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts +++ b/x-pack/plugins/uptime/public/components/overview/query_bar/use_query_bar.ts @@ -9,9 +9,13 @@ import React, { useCallback, useState } from 'react'; import useDebounce from 'react-use/lib/useDebounce'; import { useDispatch } from 'react-redux'; import { Query } from 'src/plugins/data/common'; -import { useGetUrlParams, useUpdateKueryString, useUrlParams } from '../../../hooks'; +import { + useGetUrlParams, + useIndexPattern, + useUpdateKueryString, + useUrlParams, +} from '../../../hooks'; import { setEsKueryString } from '../../../state/actions'; -import { useIndexPattern } from './use_index_pattern'; import { useKibana } from '../../../../../../../src/plugins/kibana_react/public'; import { UptimePluginServices } from '../../../apps/plugin'; @@ -35,6 +39,8 @@ interface UseQueryBarUtils { submitImmediately: () => void; } +export const DEBOUNCE_INTERVAL = 250; + /** * Provides state management and automatic dispatching of a Query object. * @@ -44,7 +50,7 @@ export const useQueryBar = (): UseQueryBarUtils => { const dispatch = useDispatch(); const { absoluteDateRangeStart, absoluteDateRangeEnd, ...params } = useGetUrlParams(); - const { search, query: queryParam, filters: paramFilters } = params; + const { search, query: queryParam, filters: paramFilters, excludedFilters } = params; const { services: { storage }, @@ -64,14 +70,15 @@ export const useQueryBar = (): UseQueryBarUtils => { } ); - const { index_pattern: indexPattern } = useIndexPattern(); + const indexPattern = useIndexPattern(); - const updateUrlParams = useUrlParams()[1]; + const [, updateUrlParams] = useUrlParams(); const [esFilters, error] = useUpdateKueryString( indexPattern, query.language === SyntaxType.kuery ? (query.query as string) : undefined, - paramFilters + paramFilters, + excludedFilters ); const setEsKueryFilters = useCallback( @@ -92,7 +99,7 @@ export const useQueryBar = (): UseQueryBarUtils => { if (query.language === SyntaxType.text && queryParam !== query.query) { updateUrlParams({ query: query.query as string }); } - if (query.language === SyntaxType.kuery) { + if (query.language === SyntaxType.kuery && queryParam !== '') { updateUrlParams({ query: '' }); } }, [query.language, query.query, queryParam, updateUrlParams]); @@ -112,17 +119,18 @@ export const useQueryBar = (): UseQueryBarUtils => { useDebounce( () => { - if (query.language === SyntaxType.kuery && !error && esFilters) { + if (query.language === SyntaxType.kuery && !error && esFilters && search !== query.query) { updateUrlParams({ search: query.query as string }); } - if (query.language === SyntaxType.text) { + if (query.language === SyntaxType.text && search !== '') { updateUrlParams({ search: '' }); } - if (query.language === SyntaxType.kuery && query.query === '') { + // this calls when it probably doesn't need to + if (query.language === SyntaxType.kuery && query.query === '' && search !== '') { updateUrlParams({ search: '' }); } }, - 250, + DEBOUNCE_INTERVAL, [esFilters, error] ); diff --git a/x-pack/plugins/uptime/public/contexts/uptime_index_pattern_context.tsx b/x-pack/plugins/uptime/public/contexts/uptime_index_pattern_context.tsx new file mode 100644 index 0000000000000..580160bac4012 --- /dev/null +++ b/x-pack/plugins/uptime/public/contexts/uptime_index_pattern_context.tsx @@ -0,0 +1,42 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React, { createContext, useContext, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useFetcher } from '../../../observability/public'; +import { DataPublicPluginStart, IndexPattern } from '../../../../../src/plugins/data/public'; +import { selectDynamicSettings } from '../state/selectors'; +import { getDynamicSettings } from '../state/actions/dynamic_settings'; + +export const UptimeIndexPatternContext = createContext({} as IndexPattern); + +export const UptimeIndexPatternContextProvider: React.FC<{ data: DataPublicPluginStart }> = ({ + children, + data: { indexPatterns }, +}) => { + const { settings } = useSelector(selectDynamicSettings); + const dispatch = useDispatch(); + + useEffect(() => { + if (typeof settings === 'undefined') { + dispatch(getDynamicSettings()); + } + }, [dispatch, settings]); + + const heartbeatIndices = settings?.heartbeatIndices || ''; + + const { data } = useFetcher>(async () => { + if (heartbeatIndices) { + // this only creates an index pattern in memory, not as saved object + return indexPatterns.create({ title: heartbeatIndices }); + } + }, [heartbeatIndices]); + + return ; +}; + +export const useIndexPattern = () => useContext(UptimeIndexPatternContext); diff --git a/x-pack/plugins/uptime/public/hooks/__snapshots__/use_url_params.test.tsx.snap b/x-pack/plugins/uptime/public/hooks/__snapshots__/use_url_params.test.tsx.snap index 31b78a8f810f0..d8b148675dc62 100644 --- a/x-pack/plugins/uptime/public/hooks/__snapshots__/use_url_params.test.tsx.snap +++ b/x-pack/plugins/uptime/public/hooks/__snapshots__/use_url_params.test.tsx.snap @@ -210,7 +210,7 @@ exports[`useUrlParams deletes keys that do not have truthy values 1`] = ` } >
- {"pagination":"foo","absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","search":"","statusFilter":"","focusConnectorField":false,"query":""} + {"pagination":"foo","absoluteDateRangeStart":20,"absoluteDateRangeEnd":20,"autorefreshInterval":60000,"autorefreshIsPaused":false,"dateRangeStart":"now-12","dateRangeEnd":"now","filters":"","excludedFilters":"","search":"","statusFilter":"","focusConnectorField":false,"query":""}