diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_selector_field.scss b/src/plugins/vis_builder/public/application/components/data_tab/field.scss similarity index 83% rename from src/plugins/vis_builder/public/application/components/data_tab/field_selector_field.scss rename to src/plugins/vis_builder/public/application/components/data_tab/field.scss index c129f7a997a..2c4a96c6ec5 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/field_selector_field.scss +++ b/src/plugins/vis_builder/public/application/components/data_tab/field.scss @@ -2,7 +2,7 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ -.vbFieldSelectorField { +.vbFieldButton { @include euiBottomShadowSmall; background-color: $euiColorEmptyShade; @@ -26,3 +26,8 @@ height: 100%; } } + +.vbItem__fieldPopoverPanel { + min-width: 260px; + max-width: 300px; +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field.test.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field.test.tsx new file mode 100644 index 00000000000..6aed9deb159 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/field.test.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen } from '@testing-library/react'; + +import { IndexPatternField } from '../../../../../data/public'; + +import { DraggableFieldButton } from './field'; + +describe('visBuilder field', function () { + describe('DraggableFieldButton', () => { + it('should render normal fields without a dragValue specified', async () => { + const props = { + field: new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ), + }; + render(); + + const button = screen.getByTestId('field-bytes-showDetails'); + + expect(button).toBeDefined(); + }); + + // TODO: it('should allow specified dragValue to override the field name'); + + // TODO: it('should make dots wrappable'); + + // TODO: it('should use a non-scripted FieldIcon by default'); + }); + + // TODO: describe('Field', function () { }); +}); diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field.tsx new file mode 100644 index 00000000000..287c6aed621 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/field.tsx @@ -0,0 +1,113 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState } from 'react'; +import { EuiPopover } from '@elastic/eui'; + +import { IndexPatternField } from '../../../../../data/public'; +import { + FieldButton, + FieldButtonProps, + FieldIcon, +} from '../../../../../opensearch_dashboards_react/public'; + +import { COUNT_FIELD, useDrag } from '../../utils/drag_drop'; +import { FieldDetailsView } from './field_details'; +import { FieldDetails } from './types'; +import './field.scss'; + +export interface FieldProps { + field: IndexPatternField; + getDetails: (field) => FieldDetails; +} + +// TODO: Add field sections (Available fields, popular fields from src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx) +export const Field = ({ field, getDetails }: FieldProps) => { + const [infoIsOpen, setOpen] = useState(false); + + function togglePopover() { + setOpen(!infoIsOpen); + } + + return ( + } + isOpen={infoIsOpen} + closePopover={() => setOpen(false)} + anchorPosition="rightUp" + panelClassName="vbItem__fieldPopoverPanel" + // TODO: make reposition on scroll actually work: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/2782 + repositionOnScroll + data-test-subj="field-popover" + > + {infoIsOpen && } + + ); +}; + +export interface DraggableFieldButtonProps extends Partial { + dragValue?: IndexPatternField['name'] | null | typeof COUNT_FIELD; + field: Partial & Pick; +} + +export const DraggableFieldButton = ({ dragValue, field, ...rest }: DraggableFieldButtonProps) => { + const { name, displayName, type, scripted = false } = field; + const [dragProps] = useDrag({ + namespace: 'field-data', + value: dragValue ?? name, + }); + + function wrapOnDot(str: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str.replace(/\./g, '.\u200B'); + } + + const defaultIcon = ; + + const defaultFieldName = ( + + {wrapOnDot(displayName)} + + ); + + const defaultProps = { + className: 'vbFieldButton', + dataTestSubj: `field-${name}-showDetails`, + fieldIcon: defaultIcon, + fieldName: defaultFieldName, + onClick: () => {}, + }; + + return ; +}; diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_bucket.scss b/src/plugins/vis_builder/public/application/components/data_tab/field_bucket.scss new file mode 100644 index 00000000000..50951d850a6 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/field_bucket.scss @@ -0,0 +1,4 @@ +.vbFieldDetails__barContainer { + // Constrains value to the flex item, and allows for truncation when necessary + min-width: 0; +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_bucket.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field_bucket.tsx new file mode 100644 index 00000000000..1a45857a655 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/field_bucket.tsx @@ -0,0 +1,110 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiText, + EuiButtonIcon, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiProgress, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +import { IndexPatternField } from '../../../../../data/public'; + +import { Bucket } from './types'; +import './field_bucket.scss'; +import { useOnAddFilter } from '../../utils/use'; + +interface FieldBucketProps { + bucket: Bucket; + field: IndexPatternField; +} + +export function FieldBucket({ bucket, field }: FieldBucketProps) { + const { count, display, percent, value } = bucket; + const { filterable: isFilterableField, name: fieldName } = field; + + const onAddFilter = useOnAddFilter(); + + const emptyText = i18n.translate('visBuilder.fieldSelector.detailsView.emptyStringText', { + // We need this to communicate to users when a top value is actually an empty string + defaultMessage: 'Empty string', + }); + const addLabel = i18n.translate( + 'visBuilder.fieldSelector.detailsView.filterValueButtonAriaLabel', + { + defaultMessage: 'Filter for {fieldName}: "{value}"', + values: { fieldName, value }, + } + ); + const removeLabel = i18n.translate( + 'visBuilder.fieldSelector.detailsView.filterOutValueButtonAriaLabel', + { + defaultMessage: 'Filter out {fieldName}: "{value}"', + values: { fieldName, value }, + } + ); + + const displayValue = display || emptyText; + + return ( + <> + + + + + + {displayValue} + + + + + {percent.toFixed(1)}% + + + + + + {/* TODO: Should we have any explanation for non-filterable fields? */} + {isFilterableField && ( + +
+ onAddFilter(field, value, '+')} + aria-label={addLabel} + data-test-subj={`plus-${fieldName}-${value}`} + /> + onAddFilter(field, value, '-')} + aria-label={removeLabel} + data-test-subj={`minus-${fieldName}-${value}`} + /> +
+
+ )} +
+ + + ); +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_details.test.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field_details.test.tsx new file mode 100644 index 00000000000..83a148b2f77 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/field_details.test.tsx @@ -0,0 +1,155 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +// @ts-ignore +import { findTestSubject } from '@elastic/eui/lib/test'; +// @ts-ignore +import stubbedLogstashFields from 'fixtures/logstash_fields'; +// @ts-ignore +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; + +import { IndexPatternField } from '../../../../../data/public'; + +import { FieldDetailsView } from './field_details'; + +const mockUseIndexPatterns = jest.fn(() => ({ selected: 'mockIndexPattern' })); +const mockUseOnAddFilter = jest.fn(); +jest.mock('../../utils/use', () => ({ + useIndexPatterns: jest.fn(() => mockUseIndexPatterns), + useOnAddFilter: jest.fn(() => mockUseOnAddFilter), +})); + +describe('visBuilder field details', function () { + const defaultDetails = { buckets: [], error: '', exists: 1, total: 1 }; + function mountComponent(field: IndexPatternField, props?: Record) { + const compProps = { details: defaultDetails, ...props, field }; + return mountWithIntl(); + } + + it('should render buckets if they exist', async function () { + const field = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const buckets = [1, 2, 3].map((n) => ({ + display: `display-${n}`, + value: `value-${n}`, + percent: 25, + count: 100, + })); + const comp = mountComponent(field, { + details: { ...defaultDetails, buckets }, + }); + expect(findTestSubject(comp, 'fieldDetailsContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldDetailsError').length).toBe(0); + expect(findTestSubject(comp, 'fieldDetailsBucketsContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldDetailsBucketsContainer').children().length).toBe( + buckets.length + ); + expect(findTestSubject(comp, 'fieldDetailsExistsLink').length).toBe(1); + }); + + it('should only render buckets if they exist', async function () { + const field = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(field); + expect(findTestSubject(comp, 'fieldDetailsContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldDetailsError').length).toBe(0); + expect(findTestSubject(comp, 'fieldDetailsBucketsContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldDetailsBucketsContainer').children().length).toBe(0); + expect(findTestSubject(comp, 'fieldDetailsExistsLink').length).toBe(1); + }); + + it('should render a details error', async function () { + const field = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const errText = 'Some error'; + const comp = mountComponent(field, { + details: { ...defaultDetails, error: errText }, + }); + expect(findTestSubject(comp, 'fieldDetailsContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldDetailsBucketsContainer').children().length).toBe(0); + expect(findTestSubject(comp, 'fieldDetailsError').length).toBe(1); + expect(findTestSubject(comp, 'fieldDetailsError').text()).toBe(errText); + expect(findTestSubject(comp, 'fieldDetailsExistsLink').length).toBe(0); + }); + + it('should not render an exists filter link for scripted fields', async function () { + const field = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: true, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(field); + expect(findTestSubject(comp, 'fieldDetailsContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldDetailsError').length).toBe(0); + expect(findTestSubject(comp, 'fieldDetailsExistsLink').length).toBe(0); + }); +}); diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_details.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field_details.tsx new file mode 100644 index 00000000000..cf6f4974bb1 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/field_details.tsx @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiLink, EuiPopoverFooter, EuiPopoverTitle, EuiText } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +import { IndexPatternField } from '../../../../../data/public'; + +import { useIndexPatterns, useOnAddFilter } from '../../utils/use'; +import { FieldBucket } from './field_bucket'; +import { Bucket, FieldDetails } from './types'; + +interface FieldDetailsProps { + field: IndexPatternField; + details: FieldDetails; +} + +export function FieldDetailsView({ field, details }: FieldDetailsProps) { + const { buckets, error, exists, total } = details; + + const onAddFilter = useOnAddFilter(); + const indexPattern = useIndexPatterns().selected; + + const { metaFields = [] } = indexPattern ?? {}; + const isMetaField = metaFields.includes(field.name); + const shouldAllowExistsFilter = !isMetaField && !field.scripted; + + const bucketsTitle = + buckets.length > 1 + ? i18n.translate('visBuilder.fieldSelector.detailsView.fieldTopValuesLabel', { + defaultMessage: 'Top {n} values', + values: { n: buckets.length }, + }) + : i18n.translate('visBuilder.fieldSelector.detailsView.fieldTopValueLabel', { + defaultMessage: 'Top value', + }); + const errorTitle = i18n.translate('visBuilder.fieldSelector.detailsView.fieldNoValuesLabel', { + defaultMessage: 'No values found', + }); + const existsIn = i18n.translate('visBuilder.fieldSelector.detailsView.fieldExistsIn', { + defaultMessage: 'Exists in {exists}', + values: { exists }, + }); + const totalRecords = i18n.translate('visBuilder.fieldSelector.detailsView.fieldTotalRecords', { + defaultMessage: '/ {total} records', + values: { total }, + }); + + const title = buckets.length ? bucketsTitle : errorTitle; + + return ( + <> + {title} +
+ {error ? ( + + {error} + + ) : ( +
+ {buckets.map((bucket: Bucket, idx: number) => ( + + ))} +
+ )} +
+ {!error && ( + + + {shouldAllowExistsFilter ? ( + onAddFilter('_exists_', field.name, '+')} + data-test-subj="fieldDetailsExistsLink" + > + {existsIn} + + ) : ( + <>{exists} + )}{' '} + {totalRecords} + + + )} + + ); +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_selector.test.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.test.tsx new file mode 100644 index 00000000000..980cfb50c66 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.test.tsx @@ -0,0 +1,82 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render, screen } from '@testing-library/react'; +import { FilterManager, IndexPatternField } from '../../../../../data/public'; +import { FieldGroup } from './field_selector'; + +const mockUseIndexPatterns = jest.fn(() => ({ selected: 'mockIndexPattern' })); +const mockUseOnAddFilter = jest.fn(); +jest.mock('../../utils/use', () => ({ + useIndexPatterns: jest.fn(() => mockUseIndexPatterns), + useOnAddFilter: jest.fn(() => mockUseOnAddFilter), +})); + +const mockGetDetailsByField = jest.fn(() => ({ + buckets: [1, 2, 3].map((n) => ({ + display: `display-${n}`, + value: `value-${n}`, + percent: 25, + count: 100, + })), + error: '', + exists: 100, + total: 150, +})); + +const getFields = (name) => { + return new IndexPatternField( + { + name, + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + name + ); +}; + +describe('visBuilder sidebar field selector', function () { + const defaultProps = { + filterManager: {} as FilterManager, + getDetailsByField: mockGetDetailsByField, + header: 'mockHeader', + id: 'mockID', + }; + describe('FieldGroup', () => { + it('renders an empty accordion if no fields specified', async () => { + const { container } = render(); + + expect(container).toHaveTextContent(defaultProps.header); + expect(container).toHaveTextContent('0'); + expect(screen.queryAllByTestId('field-popover').length).toBeFalsy(); + + await fireEvent.click(screen.getByText(defaultProps.header)); + + expect(mockGetDetailsByField).not.toHaveBeenCalled(); + }); + + it('renders an accordion with Fields if fields provided', async () => { + const props = { + ...defaultProps, + fields: ['bytes', 'machine.ram', 'memory', 'phpmemory'].map(getFields), + }; + const { container } = render(); + + expect(container).toHaveTextContent(props.header); + expect(container).toHaveTextContent(props.fields.length.toString()); + expect(screen.queryAllByTestId('field-popover').length).toBe(props.fields.length); + + await fireEvent.click(screen.getByText('memory')); + + expect(mockGetDetailsByField).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_selector.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.tsx index 6d3831363c1..5c82419d553 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/field_selector.tsx +++ b/src/plugins/vis_builder/public/application/components/data_tab/field_selector.tsx @@ -3,21 +3,19 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useState, useEffect, useMemo } from 'react'; +import React, { useState, useEffect, useCallback, useMemo } from 'react'; import { EuiFlexItem, EuiAccordion, EuiNotificationBadge, EuiTitle } from '@elastic/eui'; -import { FieldSearch } from './field_search'; -import { - IndexPatternField, - OPENSEARCH_FIELD_TYPES, - OSD_FIELD_TYPES, -} from '../../../../../data/public'; -import { FieldSelectorField } from './field_selector_field'; +import { IndexPattern, IndexPatternField, OSD_FIELD_TYPES } from '../../../../../data/public'; -import './field_selector.scss'; +import { COUNT_FIELD } from '../../utils/drag_drop'; import { useTypedSelector } from '../../utils/state_management'; -import { useIndexPatterns } from '../../utils/use'; -import { getAvailableFields } from './utils'; +import { useIndexPatterns, useSampleHits } from '../../utils/use'; +import { FieldSearch } from './field_search'; +import { Field, DraggableFieldButton } from './field'; +import { FieldDetails } from './types'; +import { getAvailableFields, getDetails } from './utils'; +import './field_selector.scss'; interface IFieldCategories { categorical: IndexPatternField[]; @@ -25,22 +23,18 @@ interface IFieldCategories { meta: IndexPatternField[]; } -const META_FIELDS: string[] = [ - OPENSEARCH_FIELD_TYPES._ID, - OPENSEARCH_FIELD_TYPES._INDEX, - OPENSEARCH_FIELD_TYPES._SOURCE, - OPENSEARCH_FIELD_TYPES._TYPE, -]; - export const FieldSelector = () => { const indexPattern = useIndexPatterns().selected; const fieldSearchValue = useTypedSelector((state) => state.visualization.searchField); + // TODO: instead of a single fetch of sampled hits for all fields, we should just use the agg service to get top hits or terms per field: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/2780 + const hits = useSampleHits(); const [filteredFields, setFilteredFields] = useState([]); useEffect(() => { - const indexFields = indexPattern?.fields ?? []; + const indexFields = indexPattern?.fields.getAll() ?? []; const filteredSubset = getAvailableFields(indexFields).filter((field) => - field.displayName.includes(fieldSearchValue) + // case-insensitive field search + field.displayName.toLowerCase().includes(fieldSearchValue.toLowerCase()) ); setFilteredFields(filteredSubset); @@ -51,7 +45,7 @@ export const FieldSelector = () => { () => filteredFields?.reduce( (fieldGroups, currentField) => { - const category = getFieldCategory(currentField); + const category = getFieldCategory(currentField, indexPattern); fieldGroups[category].push(currentField); return fieldGroups; @@ -62,7 +56,14 @@ export const FieldSelector = () => { meta: [], } ), - [filteredFields] + [filteredFields, indexPattern] + ); + + const getDetailsByField = useCallback( + (ipField: IndexPatternField) => { + return getDetails(ipField, hits, indexPattern); + }, + [hits, indexPattern] ); return ( @@ -74,20 +75,30 @@ export const FieldSelector = () => {
{/* Count Field */} - + + - -
); @@ -95,37 +106,44 @@ export const FieldSelector = () => { interface FieldGroupProps { fields?: IndexPatternField[]; + getDetailsByField: (ipField: IndexPatternField) => FieldDetails; header: string; id: string; } -const FieldGroup = ({ fields, header, id }: FieldGroupProps) => ( - - {header} - - } - extraAction={ - - {fields?.length || 0} - - } - initialIsOpen - > - {fields?.map((field, i) => ( - - - - ))} - -); +export const FieldGroup = ({ fields, header, id, getDetailsByField }: FieldGroupProps) => { + return ( + + {header} + + } + extraAction={ + + {fields?.length || 0} + + } + initialIsOpen + > + {fields?.map((field, i) => ( + + + + ))} + + ); +}; -function getFieldCategory(field: IndexPatternField): keyof IFieldCategories { - if (META_FIELDS.includes(field.name)) return 'meta'; - if (field.type === OSD_FIELD_TYPES.NUMBER) return 'numerical'; +export const getFieldCategory = ( + { name, type }: IndexPatternField, + indexPattern: IndexPattern | undefined +): keyof IFieldCategories => { + const { metaFields = [] } = indexPattern ?? {}; + if (metaFields.includes(name)) return 'meta'; + if (type === OSD_FIELD_TYPES.NUMBER) return 'numerical'; return 'categorical'; -} +}; diff --git a/src/plugins/vis_builder/public/application/components/data_tab/field_selector_field.tsx b/src/plugins/vis_builder/public/application/components/data_tab/field_selector_field.tsx deleted file mode 100644 index a87e2d184ee..00000000000 --- a/src/plugins/vis_builder/public/application/components/data_tab/field_selector_field.tsx +++ /dev/null @@ -1,86 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { useState } from 'react'; -import { IndexPatternField } from '../../../../../data/public'; -import { FieldButton, FieldIcon } from '../../../../../opensearch_dashboards_react/public'; -import { useDrag } from '../../utils/drag_drop/drag_drop_context'; -import { COUNT_FIELD } from '../../utils/drag_drop/types'; - -import './field_selector_field.scss'; - -export interface FieldSelectorFieldProps { - field: Partial & Pick; -} - -// TODO: -// 1. Add field sections (Available fields, popular fields from src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx) -// 2. Add popover for fields stats from discover as well -export const FieldSelectorField = ({ field }: FieldSelectorFieldProps) => { - const [infoIsOpen, setOpen] = useState(false); - const [dragProps] = useDrag({ - namespace: 'field-data', - value: field.name || COUNT_FIELD, - }); - - function togglePopover() { - setOpen(!infoIsOpen); - } - - function wrapOnDot(str?: string) { - // u200B is a non-width white-space character, which allows - // the browser to efficiently word-wrap right after the dot - // without us having to draw a lot of extra DOM elements, etc - return str ? str.replace(/\./g, '.\u200B') : ''; - } - - const fieldName = ( - - {wrapOnDot(field.displayName)} - - ); - - return ( - } - // fieldAction={actionButton} - fieldName={fieldName} - {...dragProps} - /> - ); -}; diff --git a/src/plugins/vis_builder/public/application/components/data_tab/types.ts b/src/plugins/vis_builder/public/application/components/data_tab/types.ts new file mode 100644 index 00000000000..c7e0327070e --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/types.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface FieldDetails { + buckets: Bucket[]; + error: string; + exists: number; + total: number; +} + +export interface FieldValueCounts extends Partial { + missing?: number; +} + +export interface Bucket { + count: number; + display: string; + percent: number; + value: string; +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/utils/field_calculator.test.ts b/src/plugins/vis_builder/public/application/components/data_tab/utils/field_calculator.test.ts new file mode 100644 index 00000000000..4f1dfd98fc3 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/utils/field_calculator.test.ts @@ -0,0 +1,268 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +// @ts-ignore +import realHits from 'fixtures/real_hits.js'; +// @ts-ignore +import stubbedLogstashFields from 'fixtures/logstash_fields'; +import { coreMock } from '../../../../../../../core/public/mocks'; +import { IndexPattern, IndexPatternField } from '../../../../../../data/public'; +import { getStubIndexPattern } from '../../../../../../data/public/test_utils'; +import { Bucket } from '../types'; +import { + groupValues, + getFieldValues, + getFieldValueCounts, + FieldValueCountsParams, +} from './field_calculator'; + +let indexPattern: IndexPattern; + +describe('field_calculator', function () { + beforeEach(function () { + indexPattern = getStubIndexPattern( + 'logstash-*', + (cfg: any) => cfg, + 'time', + stubbedLogstashFields(), + coreMock.createSetup() + ); + }); + + describe('groupValues', function () { + let groups: Record; + let grouped: boolean; + let values: any[]; + beforeEach(function () { + values = [ + ['foo', 'bar'], + 'foo', + 'foo', + undefined, + ['foo', 'bar'], + 'bar', + 'baz', + null, + null, + null, + 'foo', + undefined, + ]; + groups = groupValues(values, grouped); + }); + + it('should return an object', function () { + expect(groups).toBeInstanceOf(Object); + }); + + it('should throw an error if any value is a plain object', function () { + expect(function () { + groupValues([{}, true, false], grouped); + }).toThrowError(); + }); + + it('should handle values with dots in them', function () { + values = ['0', '0.........', '0.......,.....']; + groups = groupValues(values, grouped); + expect(groups[values[0]].count).toBe(1); + expect(groups[values[1]].count).toBe(1); + expect(groups[values[2]].count).toBe(1); + }); + + it('should have a key for value in the array when not grouping array terms', function () { + expect(_.keys(groups).length).toBe(3); + expect(groups.foo).toBeInstanceOf(Object); + expect(groups.bar).toBeInstanceOf(Object); + expect(groups.baz).toBeInstanceOf(Object); + }); + + it('should count array terms independently', function () { + expect(groups['foo,bar']).toBeUndefined(); + expect(groups.foo.count).toBe(5); + expect(groups.bar.count).toBe(3); + expect(groups.baz.count).toBe(1); + }); + + describe('grouped array terms', function () { + beforeEach(function () { + grouped = true; + groups = groupValues(values, grouped); + }); + + it('should group array terms when grouped is true', function () { + expect(_.keys(groups).length).toBe(4); + expect(groups['foo,bar']).toBeInstanceOf(Object); + }); + + it('should contain the original array as the value', function () { + expect(groups['foo,bar'].value).toEqual(['foo', 'bar']); + }); + + it('should count the pairs separately from the values they contain', function () { + expect(groups['foo,bar'].count).toBe(2); + expect(groups.foo.count).toBe(3); + expect(groups.bar.count).toBe(1); + }); + }); + }); + + describe('getFieldValues', function () { + let hits: any; + + beforeEach(function () { + hits = _.each(_.cloneDeep(realHits), (hit) => indexPattern.flattenHit(hit)); + }); + + it('should return an array of values for _source fields', function () { + const extensions = getFieldValues({ + hits, + field: indexPattern.fields.getByName('extension') as IndexPatternField, + indexPattern, + }); + expect(extensions).toBeInstanceOf(Array); + expect( + _.filter(extensions, function (v) { + return v === 'html'; + }).length + ).toBe(8); + expect(_.uniq(_.clone(extensions)).sort()).toEqual(['gif', 'html', 'php', 'png']); + }); + + it('should return an array of values for core meta fields', function () { + const types = getFieldValues({ + hits, + field: indexPattern.fields.getByName('_type') as IndexPatternField, + indexPattern, + }); + expect(types).toBeInstanceOf(Array); + expect( + _.filter(types, function (v) { + return v === 'apache'; + }).length + ).toBe(18); + expect(_.uniq(_.clone(types)).sort()).toEqual(['apache', 'nginx']); + }); + }); + + describe('getFieldValueCounts', function () { + let params: FieldValueCountsParams; + beforeEach(function () { + params = { + hits: _.cloneDeep(realHits), + field: indexPattern.fields.getByName('extension') as IndexPatternField, + count: 3, + indexPattern, + }; + }); + + it('counts the top 5 values by default', function () { + params.hits = params.hits.map((hit: Record, i) => ({ + ...hit, + _source: { + extension: `${hit._source.extension}-${i}`, + }, + })); + params.count = undefined; + const extensions = getFieldValueCounts(params); + expect(extensions).toBeInstanceOf(Object); + expect(extensions.buckets).toBeInstanceOf(Array); + const buckets = extensions.buckets as Bucket[]; + expect(buckets.length).toBe(5); + expect(extensions.error).toBeUndefined(); + }); + + it('counts only distinct values if less than default', function () { + params.count = undefined; + const extensions = getFieldValueCounts(params); + expect(extensions).toBeInstanceOf(Object); + expect(extensions.buckets).toBeInstanceOf(Array); + const buckets = extensions.buckets as Bucket[]; + expect(buckets.length).toBe(4); + expect(extensions.error).toBeUndefined(); + }); + + it('counts only distinct values if less than specified count', function () { + params.count = 10; + const extensions = getFieldValueCounts(params); + expect(extensions).toBeInstanceOf(Object); + expect(extensions.buckets).toBeInstanceOf(Array); + const buckets = extensions.buckets as Bucket[]; + expect(buckets.length).toBe(4); + expect(extensions.error).toBeUndefined(); + }); + + it('counts the top 3 values', function () { + const extensions = getFieldValueCounts(params); + expect(extensions).toBeInstanceOf(Object); + expect(extensions.buckets).toBeInstanceOf(Array); + const buckets = extensions.buckets as Bucket[]; + expect(buckets.length).toBe(3); + expect(_.map(buckets, 'value')).toEqual(['html', 'gif', 'php']); + expect(extensions.error).toBeUndefined(); + }); + + it('fails to analyze geo and attachment types', function () { + params.field = indexPattern.fields.getByName('point') as IndexPatternField; + expect(getFieldValueCounts(params).error).not.toBeUndefined(); + + params.field = indexPattern.fields.getByName('area') as IndexPatternField; + expect(getFieldValueCounts(params).error).not.toBeUndefined(); + + params.field = indexPattern.fields.getByName('request_body') as IndexPatternField; + expect(getFieldValueCounts(params).error).not.toBeUndefined(); + }); + + it('fails to analyze fields that are in the mapping, but not the hits', function () { + params.field = indexPattern.fields.getByName('ip') as IndexPatternField; + expect(getFieldValueCounts(params).error).not.toBeUndefined(); + }); + + it('counts the total hits', function () { + expect(getFieldValueCounts(params).total).toBe(params.hits.length); + }); + + it('counts the hits the field exists in', function () { + params.field = indexPattern.fields.getByName('phpmemory') as IndexPatternField; + expect(getFieldValueCounts(params).exists).toBe(5); + }); + + it('catches and returns errors', function () { + params.hits = params.hits.map((hit: Record) => ({ + ...hit, + _source: { + extension: { foo: hit._source.extension }, + }, + })); + params.grouped = true; + expect(typeof getFieldValueCounts(params).error).toBe('string'); + }); + }); +}); diff --git a/src/plugins/vis_builder/public/application/components/data_tab/utils/field_calculator.ts b/src/plugins/vis_builder/public/application/components/data_tab/utils/field_calculator.ts new file mode 100644 index 00000000000..bd3cde945d9 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/utils/field_calculator.ts @@ -0,0 +1,124 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { IndexPattern, IndexPatternField } from '../../../../../../data/public'; +import { FieldValueCounts } from '../types'; + +const NO_ANALYSIS_TYPES = ['geo_point', 'geo_shape', 'attachment']; + +interface FieldValuesParams { + hits: Array>; + field: IndexPatternField; + indexPattern: IndexPattern; +} + +interface FieldValueCountsParams extends FieldValuesParams { + count?: number; + grouped?: boolean; +} + +const getFieldValues = ({ hits, field, indexPattern }: FieldValuesParams) => { + // For multi-value fields, we want to flatten based on the parent name instead + const name = field.subType?.multi?.parent ?? field.name; + const flattenHit = indexPattern.flattenHit; + return hits.map((hit) => flattenHit(hit)[name]); +}; + +const getFieldValueCounts = (params: FieldValueCountsParams): FieldValueCounts => { + const { hits, field, indexPattern, count = 5, grouped = false } = params; + const { type: fieldType } = field; + + if (NO_ANALYSIS_TYPES.includes(fieldType)) { + return { + error: i18n.translate( + 'visBuilder.fieldChooser.fieldCalculator.analysisIsNotAvailableForGeoFieldsErrorMessage', + { + defaultMessage: 'Analysis is not available for {fieldType} fields.', + values: { + fieldType, + }, + } + ), + }; + } + + const allValues = getFieldValues({ hits, field, indexPattern }); + const missing = allValues.filter((v) => v === undefined || v === null).length; + + try { + const groups = groupValues(allValues, grouped); + const counts = Object.keys(groups) + .sort((a, b) => groups[b].count - groups[a].count) + .slice(0, count) + .map((key) => ({ + value: groups[key].value, + count: groups[key].count, + percent: (groups[key].count / (hits.length - missing)) * 100, + display: indexPattern.getFormatterForField(field).convert(groups[key].value), + })); + + if (hits.length === missing) { + return { + error: i18n.translate( + 'visBuilder.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage', + { + defaultMessage: + 'This field is present in your OpenSearch mapping but not in the {hitsLength} documents sampled. You may still be able to visualize it.', + values: { + hitsLength: hits.length, + }, + } + ), + }; + } + + return { + total: hits.length, + exists: hits.length - missing, + missing, + buckets: counts, + }; + } catch (e) { + return { + error: e instanceof Error ? e.message : String(e), + }; + } +}; + +const groupValues = ( + allValues: any[], + grouped?: boolean +): Record => { + const values = grouped ? allValues : allValues.flat(); + + return values + .filter((v) => { + if (v instanceof Object && !Array.isArray(v)) { + throw new Error( + i18n.translate( + 'visBuilder.fieldChooser.fieldCalculator.analysisIsNotAvailableForObjectFieldsErrorMessage', + { + defaultMessage: 'Analysis is not available for object fields.', + } + ) + ); + } + return v !== undefined && v !== null; + }) + .reduce((groups, value) => { + if (groups.hasOwnProperty(value)) { + groups[value].count++; + } else { + groups[value] = { + value, + count: 1, + }; + } + return groups; + }, {}); +}; + +export { FieldValueCountsParams, groupValues, getFieldValues, getFieldValueCounts }; diff --git a/src/plugins/vis_builder/public/application/components/data_tab/utils/get_field_details.ts b/src/plugins/vis_builder/public/application/components/data_tab/utils/get_field_details.ts new file mode 100644 index 00000000000..75b8b60c0c6 --- /dev/null +++ b/src/plugins/vis_builder/public/application/components/data_tab/utils/get_field_details.ts @@ -0,0 +1,57 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; + +import { IndexPattern, IndexPatternField } from '../../../../../../data/public'; +import { FieldDetails } from '../types'; + +import { getFieldValueCounts } from './field_calculator'; + +export function getFieldDetails( + field: IndexPatternField, + hits: Array>, + indexPattern?: IndexPattern +): FieldDetails { + const defaultDetails = { + error: '', + exists: 0, + total: 0, + buckets: [], + }; + if (!indexPattern) { + return { + ...defaultDetails, + error: i18n.translate('visBuilder.fieldSelector.noIndexPatternSelectedErrorMessage', { + defaultMessage: 'Index pattern not specified.', + }), + }; + } + if (!hits.length) { + return { + ...defaultDetails, + error: i18n.translate('visBuilder.fieldSelector.noHits', { + defaultMessage: + 'No documents match the selected query and filters. Try increasing time range or removing filters.', + }), + }; + } + const details = { + ...defaultDetails, + ...getFieldValueCounts({ + hits, + field, + indexPattern, + count: 5, + grouped: false, + }), + }; + if (details.buckets) { + for (const bucket of details.buckets) { + bucket.display = indexPattern.getFormatterForField(field).convert(bucket.value); + } + } + return details; +} diff --git a/src/plugins/vis_builder/public/application/components/data_tab/utils/index.ts b/src/plugins/vis_builder/public/application/components/data_tab/utils/index.ts index dd0cdea3e23..2900a66d8f1 100644 --- a/src/plugins/vis_builder/public/application/components/data_tab/utils/index.ts +++ b/src/plugins/vis_builder/public/application/components/data_tab/utils/index.ts @@ -4,3 +4,4 @@ */ export { getAvailableFields } from './get_available_fields'; +export { getFieldDetails as getDetails } from './get_field_details'; diff --git a/src/plugins/vis_builder/public/application/utils/drag_drop/drag_drop_context.tsx b/src/plugins/vis_builder/public/application/utils/drag_drop/drag_drop_context.tsx index c0f8725a501..b5c809c9b35 100644 --- a/src/plugins/vis_builder/public/application/utils/drag_drop/drag_drop_context.tsx +++ b/src/plugins/vis_builder/public/application/utils/drag_drop/drag_drop_context.tsx @@ -14,7 +14,7 @@ import React, { } from 'react'; import { DragDataType } from './types'; -// TODO: Replace any with corret type +// TODO: Replace any with correct type // TODO: Split into separate files interface IDragDropContext { data: DragDataType; diff --git a/src/plugins/vis_builder/public/application/utils/drag_drop/index.ts b/src/plugins/vis_builder/public/application/utils/drag_drop/index.ts index 3799a2eb605..4516a90575a 100644 --- a/src/plugins/vis_builder/public/application/utils/drag_drop/index.ts +++ b/src/plugins/vis_builder/public/application/utils/drag_drop/index.ts @@ -4,3 +4,4 @@ */ export * from './drag_drop_context'; +export * from './types'; diff --git a/src/plugins/vis_builder/public/application/utils/use/index.ts b/src/plugins/vis_builder/public/application/utils/use/index.ts index 3ba3ca35907..1cc0b28dc89 100644 --- a/src/plugins/vis_builder/public/application/utils/use/index.ts +++ b/src/plugins/vis_builder/public/application/utils/use/index.ts @@ -4,6 +4,8 @@ */ export { useAggs } from './use_aggs'; -export { useVisualizationType } from './use_visualization_type'; export { useIndexPatterns } from './use_index_pattern'; +export { useOnAddFilter } from './use_on_add_filter'; +export { useSampleHits } from './use_sample_hits'; export { useSavedVisBuilderVis } from './use_saved_vis_builder_vis'; +export { useVisualizationType } from './use_visualization_type'; diff --git a/src/plugins/vis_builder/public/application/utils/use/use_on_add_filter.ts b/src/plugins/vis_builder/public/application/utils/use/use_on_add_filter.ts new file mode 100644 index 00000000000..791521fccad --- /dev/null +++ b/src/plugins/vis_builder/public/application/utils/use/use_on_add_filter.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback } from 'react'; +import { IndexPatternField, opensearchFilters } from '../../../../../data/public'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { VisBuilderServices } from '../../../types'; +import { useIndexPatterns } from './use_index_pattern'; + +export const useOnAddFilter = () => { + const { + services: { + data: { + query: { filterManager }, + }, + }, + } = useOpenSearchDashboards(); + const indexPattern = useIndexPatterns().selected; + const { id = '' } = indexPattern ?? {}; + return useCallback( + (fieldToFilter: IndexPatternField | string, value: string, operation: '+' | '-') => { + const newFilters = opensearchFilters.generateFilters( + filterManager, + fieldToFilter, + value, + operation, + id + ); + return filterManager.addFilters(newFilters); + }, + [filterManager, id] + ); +}; diff --git a/src/plugins/vis_builder/public/application/utils/use/use_sample_hits.ts b/src/plugins/vis_builder/public/application/utils/use/use_sample_hits.ts new file mode 100644 index 00000000000..f3ed75a4dd6 --- /dev/null +++ b/src/plugins/vis_builder/public/application/utils/use/use_sample_hits.ts @@ -0,0 +1,80 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useLayoutEffect, useState } from 'react'; +import { SortDirection } from '../../../../../data/public'; +import { IExpressionLoaderParams } from '../../../../../expressions/public'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { VisBuilderServices } from '../../../types'; +import { useIndexPatterns } from './use_index_pattern'; + +export const useSampleHits = () => { + const { + services: { + data: { + query: { + filterManager, + queryString, + state$, + timefilter: { timefilter }, + }, + search: { searchSource }, + }, + uiSettings: config, + }, + } = useOpenSearchDashboards(); + const indexPattern = useIndexPatterns().selected; + const [hits, setHits] = useState>>([]); + const [searchContext, setSearchContext] = useState({ + query: queryString.getQuery(), + filters: filterManager.getFilters(), + }); + + useEffect(() => { + async function getData() { + if (indexPattern && searchContext) { + const newSearchSource = await searchSource.create(); + const timeRangeFilter = timefilter.createFilter(indexPattern); + + newSearchSource + .setField('index', indexPattern) + .setField('size', config.get('discover:sampleSize') ?? 500) + .setField('sort', [{ [indexPattern.timeFieldName || '_score']: 'desc' as SortDirection }]) + .setField('filter', [ + ...(searchContext.filters ?? []), + ...(timeRangeFilter ? [timeRangeFilter] : []), + ]); + + if (searchContext.query) { + const contextQuery = + searchContext.query instanceof Array ? searchContext.query[0] : searchContext.query; + + newSearchSource.setField('query', contextQuery); + } + + const searchResponse = await newSearchSource.fetch(); + + setHits(searchResponse.hits.hits); + } + } + + getData(); + }, [config, searchContext, searchSource, indexPattern, timefilter]); + + useLayoutEffect(() => { + const subscription = state$.subscribe(({ state }) => { + setSearchContext({ + query: state.query, + filters: state.filters, + }); + }); + + return () => { + subscription.unsubscribe(); + }; + }, [state$]); + + return hits; +};