Skip to content

Commit

Permalink
[Vis Builder] Add field summary popovers
Browse files Browse the repository at this point in the history
Much of the functionality was ported from `Discover`, but
largely refactored.

* Add utilities to get sampled hit summaries by field
* Add popover summaries
* Slight refactor of special `Count` pseudofield
* Use observable subscription to update sampled hits

Fixes #950

Signed-off-by: Josh Romero <rmerqg@amazon.com>
  • Loading branch information
joshuarrrr committed Nov 1, 2022
1 parent 0c9ca96 commit e2a6b42
Show file tree
Hide file tree
Showing 16 changed files with 1,109 additions and 77 deletions.
3 changes: 2 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [Vis Builder] Rename wizard on save modal and visualization table ([#2645](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2645))
- Change save object type, wizard id and name to visBuilder #2673 ([#2673](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2673))
- Add extension point in saved object management to register namespaces and show filter ([#2656](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2656))
- [Vis Builder] Add field summary popovers ([#2682](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2682))

### 🐛 Bug Fixes

Expand All @@ -49,7 +50,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/)
- [Multi DataSource] Address UX comments on index pattern management stack ([#2611](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2611))
- [Multi DataSource] Apply get indices error handling in step index pattern ([#2652](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2652))
- [Vis Builder] Last Updated Timestamp for visbuilder savedobject is getting Generated ([#2628](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2628))
- Removed Leftover X Pack references ([#2638](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2638))
- Removed Leftover X Pack references ([#2638](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2638))

### 🚞 Infrastructure

Expand Down
1 change: 1 addition & 0 deletions src/core/public/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ function createCoreSetupMock({
uiSettings: uiSettingsServiceMock.createSetupContract(),
injectedMetadata: {
getInjectedVar: injectedMetadataServiceMock.createSetupContract().getInjectedVar,
getBranding: injectedMetadataServiceMock.createSetupContract().getBranding,
},
};

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.vbFieldDetails__barContainer {
// Constrains value to the flex item, and allows for truncation when necessary
min-width: 0;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
/*
* 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';

interface Props {
bucket: Bucket;
field: IndexPatternField;
onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void;
}

export function VisBuilderFieldBucket({ bucket, field, onAddFilter }: Props) {
const { count, display, percent, value } = bucket;
const { filterable: isFilterableField, name: fieldName } = field;

const emptyTxt = i18n.translate('visBuilder.fieldChooser.detailViews.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.fieldChooser.detailViews.filterValueButtonAriaLabel',
{
defaultMessage: 'Filter for {fieldName}: "{value}"',
values: { fieldName, value },
}
);
const removeLabel = i18n.translate(
'visBuilder.fieldChooser.detailViews.filterOutValueButtonAriaLabel',
{
defaultMessage: 'Filter out {fieldName}: "{value}"',
values: { fieldName, value },
}
);

const displayValue = display || emptyTxt;

return (
<>
<EuiFlexGroup justifyContent="spaceBetween" responsive={false} gutterSize="s">
<EuiFlexItem className="vbFieldDetails__barContainer" grow={1}>
<EuiFlexGroup justifyContent="spaceBetween" gutterSize="xs" responsive={false}>
<EuiFlexItem grow={1} className="eui-textTruncate">
<EuiText
title={`${displayValue}: ${count} (${percent.toFixed(1)}%)`}
size="xs"
className="eui-textTruncate"
>
{displayValue}
</EuiText>
</EuiFlexItem>
<EuiFlexItem grow={false} className="eui-textTruncate">
<EuiText color="secondary" size="xs" className="eui-textTruncate">
{percent.toFixed(1)}%
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<StringFieldProgressBar value={value} percent={percent} count={count} />
</EuiFlexItem>
{/* TODO: Should we have any explanation for non-filterable fields? */}
{isFilterableField && (
<EuiFlexItem grow={false}>
<div>
<EuiButtonIcon
className="vbFieldDetails__filterButton"
iconSize="s"
iconType="plusInCircle"
onClick={() => onAddFilter(field, value, '+')}
aria-label={addLabel}
data-test-subj={`plus-${fieldName}-${value}`}
/>
<EuiButtonIcon
className="vbFieldDetails__filterButton"
iconSize="s"
iconType="minusInCircle"
onClick={() => onAddFilter(field, value, '-')}
aria-label={removeLabel}
data-test-subj={`minus-${fieldName}-${value}`}
/>
</div>
</EuiFlexItem>
)}
</EuiFlexGroup>
<EuiSpacer size="s" />
</>
);
}

export function StringFieldProgressBar({
value,
percent,
count,
}: Pick<Bucket, 'count' | 'percent' | 'value'>) {
const ariaLabel = `${value}: ${count} (${percent}%)`;

return (
<EuiProgress value={percent} max={100} color="secondary" aria-label={ariaLabel} size="s" />
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
/*
* 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 { VisBuilderFieldDetails } from './field_details';

const mockOnAddFilter = jest.fn();

describe('visBuilder sidebar field details', function () {
const defaultProps = {
isMetaField: false,
details: { buckets: [], error: '', exists: 1, total: 1, columns: [] },
onAddFilter: mockOnAddFilter,
};

function mountComponent(field: IndexPatternField, props?: Record<string, any>) {
const compProps = { ...defaultProps, ...props, field };
return mountWithIntl(<VisBuilderFieldDetails {...compProps} />);
}

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: { ...defaultProps.details, 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: { ...defaultProps.details, 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);
});

it('should not render an exists filter link for meta 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, {
...defaultProps,
isMetaField: true,
});
expect(findTestSubject(comp, 'fieldDetailsContainer').length).toBe(1);
expect(findTestSubject(comp, 'fieldDetailsError').length).toBe(0);
expect(findTestSubject(comp, 'fieldDetailsExistsLink').length).toBe(0);
});
});
Loading

0 comments on commit e2a6b42

Please sign in to comment.