[Lens] Create special 'Records' field for count operation (#49376) (#49683)

This commit is contained in:
Chris Davies 2019-10-29 18:21:09 -04:00 committed by GitHub
parent d8a3c53839
commit 14e2de3141
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
24 changed files with 313 additions and 240 deletions

View file

@ -14,6 +14,7 @@ import { IndexPatternPrivateState } from './types';
import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers';
import { ChangeIndexPattern } from './change_indexpattern';
import { EuiProgress } from '@elastic/eui';
import { documentField } from './document_field';
jest.mock('ui/new_platform');
jest.mock('../../../../../../src/legacy/ui/public/registry/field_formats');
@ -121,6 +122,7 @@ const initialState: IndexPatternPrivateState = {
aggregatable: true,
searchable: true,
},
documentField,
],
},
'2': {
@ -174,6 +176,7 @@ const initialState: IndexPatternPrivateState = {
},
},
},
documentField,
],
},
'3': {
@ -199,6 +202,7 @@ const initialState: IndexPatternPrivateState = {
aggregatable: true,
searchable: true,
},
documentField,
],
},
},
@ -565,6 +569,7 @@ describe('IndexPattern Data Panel', () => {
);
expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([
'Records',
'bytes',
'client',
'memory',
@ -630,6 +635,7 @@ describe('IndexPattern Data Panel', () => {
.simulate('click');
expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([
'Records',
'bytes',
'client',
'memory',
@ -698,6 +704,7 @@ describe('IndexPattern Data Panel', () => {
const wrapper = shallowWithIntl(<InnerIndexPatternDataPanel {...props} />);
expect(wrapper.find(FieldItem).map(fieldItem => fieldItem.prop('field').name)).toEqual([
'Records',
'bytes',
'memory',
]);

View file

@ -18,12 +18,13 @@ import {
EuiPopoverTitle,
EuiPopoverFooter,
EuiCallOut,
EuiText,
EuiFormControlLayout,
EuiSwitch,
EuiFacetButton,
EuiIcon,
EuiButton,
EuiButtonEmpty,
EuiSpacer,
EuiFormLabel,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
@ -60,10 +61,11 @@ function sortFields(fieldA: IndexPatternField, fieldB: IndexPatternField) {
return fieldA.name.localeCompare(fieldB.name, undefined, { sensitivity: 'base' });
}
const supportedFieldTypes = new Set(['string', 'number', 'boolean', 'date', 'ip']);
const supportedFieldTypes = new Set(['string', 'number', 'boolean', 'date', 'ip', 'document']);
const PAGINATION_SIZE = 50;
const fieldTypeNames: Record<DataType, string> = {
document: i18n.translate('xpack.lens.datatypes.record', { defaultMessage: 'record' }),
string: i18n.translate('xpack.lens.datatypes.string', { defaultMessage: 'string' }),
number: i18n.translate('xpack.lens.datatypes.number', { defaultMessage: 'number' }),
boolean: i18n.translate('xpack.lens.datatypes.boolean', { defaultMessage: 'boolean' }),
@ -255,7 +257,8 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
if (!showEmptyFields) {
const indexField = currentIndexPattern && fieldByName[field.name];
const exists =
indexField && fieldExists(existingFields, currentIndexPattern.title, indexField.name);
field.type === 'document' ||
(indexField && fieldExists(existingFields, currentIndexPattern.title, indexField.name));
if (localState.typeFilter.length > 0) {
return exists && localState.typeFilter.includes(field.type as DataType);
}
@ -270,7 +273,12 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
return true;
});
const paginatedFields = displayedFields.sort(sortFields).slice(0, pageSize);
const specialFields = displayedFields.filter(f => f.type === 'document');
const paginatedFields = displayedFields
.filter(f => f.type !== 'document')
.sort(sortFields)
.slice(0, pageSize);
const hilight = localState.nameFilter.toLowerCase();
return (
<ChildDragDropProvider {...dragDropContext}>
@ -418,6 +426,31 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
onScroll={lazyScroll}
>
<div className="lnsInnerIndexPatternDataPanel__list">
{specialFields.map(field => (
<FieldItem
core={core}
key={field.name}
indexPattern={currentIndexPattern}
field={field}
highlight={hilight}
exists={paginatedFields.length > 0}
dateRange={dateRange}
query={query}
filters={filters}
hideDetails={true}
/>
))}
{specialFields.length > 0 && (
<>
<EuiSpacer size="s" />
<EuiFormLabel>
{i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', {
defaultMessage: 'Individual fields',
})}
</EuiFormLabel>
<EuiSpacer size="s" />
</>
)}
{paginatedFields.map(field => {
const overallField = fieldByName[field.name];
return (
@ -426,7 +459,7 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
indexPattern={currentIndexPattern}
key={field.name}
field={field}
highlight={localState.nameFilter.toLowerCase()}
highlight={hilight}
exists={
overallField &&
fieldExists(existingFields, currentIndexPattern.title, overallField.name)
@ -439,9 +472,11 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
})}
{paginatedFields.length === 0 && (
<EuiText size="s" color="subdued">
<p>
{showEmptyFields
<EuiCallOut
size="s"
color="warning"
title={
showEmptyFields
? localState.typeFilter.length || localState.nameFilter.length
? i18n.translate('xpack.lens.indexPatterns.noFilteredFieldsLabel', {
defaultMessage:
@ -453,15 +488,17 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
: i18n.translate('xpack.lens.indexPatterns.emptyFieldsWithDataLabel', {
defaultMessage:
'No fields have data with the current filters and time range. Try changing your filters or time range.',
})}
</p>
})
}
>
{(!showEmptyFields ||
localState.typeFilter.length ||
localState.nameFilter.length) && (
<EuiButton
<EuiButtonEmpty
size="xs"
color="primary"
flush="left"
data-test-subj="lnsDataPanelShowAllFields"
size="s"
onClick={() => {
trackUiEvent('indexpattern_show_all_fields_clicked');
clearLocalState();
@ -471,9 +508,9 @@ export const InnerIndexPatternDataPanel = function InnerIndexPatternDataPanel({
{i18n.translate('xpack.lens.indexPatterns.showAllFields.buttonText', {
defaultMessage: 'Show all fields',
})}
</EuiButton>
</EuiButtonEmpty>
)}
</EuiText>
</EuiCallOut>
)}
</div>
</div>

View file

@ -20,11 +20,11 @@ import {
} from 'src/core/public';
import { Storage } from 'ui/storage';
import { IndexPatternPrivateState } from '../types';
import { documentField } from '../document_field';
jest.mock('ui/new_platform');
jest.mock('../loader');
jest.mock('../state_helpers');
jest.mock('../operations');
// Used by indexpattern plugin, which is a dependency of a dependency
jest.mock('ui/chrome');
@ -67,6 +67,7 @@ const expectedIndexPatterns = {
searchable: true,
exists: true,
},
documentField,
],
},
};
@ -200,7 +201,7 @@ describe('IndexPatternDimensionPanel', () => {
expect(options).toHaveLength(2);
expect(options![0].label).toEqual('Document');
expect(options![0].label).toEqual('Records');
expect(options![1].options!.map(({ label }) => label)).toEqual([
'timestamp',
@ -232,7 +233,7 @@ describe('IndexPatternDimensionPanel', () => {
expect(options![1].options!.map(({ label }) => label)).toEqual(['timestamp', 'source']);
});
it('should indicate fields which are imcompatible for the operation of the current column', () => {
it('should indicate fields which are incompatible for the operation of the current column', () => {
wrapper = mount(
<IndexPatternDimensionPanel
{...defaultProps}
@ -263,7 +264,7 @@ describe('IndexPatternDimensionPanel', () => {
const options = wrapper.find(EuiComboBox).prop('options');
expect(options![0]['data-test-subj']).toEqual('lns-documentOptionIncompatible');
expect(options![0]['data-test-subj']).toEqual('lns-fieldOptionIncompatible-Records');
expect(
options![1].options!.filter(({ label }) => label === 'timestamp')[0]['data-test-subj']
@ -659,6 +660,7 @@ describe('IndexPatternDimensionPanel', () => {
isBucketed: false,
label: '',
operationType: 'count',
sourceField: 'Records',
},
},
},
@ -853,6 +855,7 @@ describe('IndexPatternDimensionPanel', () => {
isBucketed: false,
label: '',
operationType: 'count',
sourceField: 'Records',
},
},
},

View file

@ -41,7 +41,6 @@ export type IndexPatternDimensionPanelProps = DatasourceDimensionPanelProps & {
export interface OperationFieldSupportMatrix {
operationByField: Partial<Record<string, OperationType[]>>;
fieldByOperation: Partial<Record<OperationType, string[]>>;
operationByDocument: OperationType[];
}
export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPanel(
@ -57,30 +56,25 @@ export const IndexPatternDimensionPanel = memo(function IndexPatternDimensionPan
const supportedOperationsByField: Partial<Record<string, OperationType[]>> = {};
const supportedFieldsByOperation: Partial<Record<OperationType, string[]>> = {};
const supportedOperationsByDocument: OperationType[] = [];
filteredOperationsByMetadata.forEach(({ operations }) => {
operations.forEach(operation => {
if (operation.type === 'field') {
if (supportedOperationsByField[operation.field]) {
supportedOperationsByField[operation.field]!.push(operation.operationType);
} else {
supportedOperationsByField[operation.field] = [operation.operationType];
}
if (supportedFieldsByOperation[operation.operationType]) {
supportedFieldsByOperation[operation.operationType]!.push(operation.field);
} else {
supportedFieldsByOperation[operation.operationType] = [operation.field];
}
if (supportedOperationsByField[operation.field]) {
supportedOperationsByField[operation.field]!.push(operation.operationType);
} else {
supportedOperationsByDocument.push(operation.operationType);
supportedOperationsByField[operation.field] = [operation.operationType];
}
if (supportedFieldsByOperation[operation.operationType]) {
supportedFieldsByOperation[operation.operationType]!.push(operation.field);
} else {
supportedFieldsByOperation[operation.operationType] = [operation.field];
}
});
});
return {
operationByField: _.mapValues(supportedOperationsByField, _.uniq),
fieldByOperation: _.mapValues(supportedFieldsByOperation, _.uniq),
operationByDocument: _.uniq(supportedOperationsByDocument),
};
}, [currentIndexPattern, props.filterOperations]);

View file

@ -21,9 +21,11 @@ import { IndexPattern, IndexPatternField, IndexPatternPrivateState } from '../ty
import { trackUiEvent } from '../../lens_ui_telemetry';
import { fieldExists } from '../pure_helpers';
export type FieldChoice =
| { type: 'field'; field: string; operationType?: OperationType }
| { type: 'document' };
export interface FieldChoice {
type: 'field';
field: string;
operationType?: OperationType;
}
export interface FieldSelectProps {
currentIndexPattern: IndexPattern;
@ -50,8 +52,7 @@ export function FieldSelect({
onDeleteColumn,
existingFields,
}: FieldSelectProps) {
const { operationByDocument, operationByField } = operationFieldSupportMatrix;
const { operationByField } = operationFieldSupportMatrix;
const memoizedFieldOptions = useMemo(() => {
const fields = Object.keys(operationByField).sort();
@ -65,68 +66,58 @@ export function FieldSelect({
);
}
const isCurrentOperationApplicableWithoutField =
(!selectedColumnOperationType && !incompatibleSelectedOperationType) ||
operationByDocument.includes(
incompatibleSelectedOperationType || selectedColumnOperationType!
);
const [specialFields, normalFields] = _.partition(
fields,
field => fieldMap[field].type === 'document'
);
const fieldOptions = [];
if (operationByDocument.length > 0) {
fieldOptions.push({
label: i18n.translate('xpack.lens.indexPattern.documentField', {
defaultMessage: 'Document',
}),
value: { type: 'document' },
className: classNames({
'lnFieldSelect__option--incompatible': !isCurrentOperationApplicableWithoutField,
}),
'data-test-subj': `lns-documentOption${
isCurrentOperationApplicableWithoutField ? '' : 'Incompatible'
}`,
});
function fieldNamesToOptions(items: string[]) {
return items
.map(field => ({
label: field,
value: {
type: 'field',
field,
dataType: fieldMap[field].type,
operationType:
selectedColumnOperationType && isCompatibleWithCurrentOperation(field)
? selectedColumnOperationType
: undefined,
},
exists:
fieldMap[field].type === 'document' ||
fieldExists(existingFields, currentIndexPattern.title, field),
compatible: isCompatibleWithCurrentOperation(field),
}))
.filter(field => showEmptyFields || field.exists)
.sort((a, b) => {
if (a.compatible && !b.compatible) {
return -1;
}
if (!a.compatible && b.compatible) {
return 1;
}
return 0;
})
.map(({ label, value, compatible, exists }) => ({
label,
value,
className: classNames({
'lnFieldSelect__option--incompatible': !compatible,
'lnFieldSelect__option--nonExistant': !exists,
}),
'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${label}`,
}));
}
const fieldOptions: unknown[] = fieldNamesToOptions(specialFields);
if (fields.length > 0) {
fieldOptions.push({
label: i18n.translate('xpack.lens.indexPattern.individualFieldsLabel', {
defaultMessage: 'Individual fields',
}),
options: fields
.map(field => ({
label: field,
value: {
type: 'field',
field,
dataType: fieldMap[field].type,
operationType:
selectedColumnOperationType && isCompatibleWithCurrentOperation(field)
? selectedColumnOperationType
: undefined,
},
exists: fieldExists(existingFields, currentIndexPattern.title, field),
compatible: isCompatibleWithCurrentOperation(field),
}))
.filter(field => showEmptyFields || field.exists)
.sort((a, b) => {
if (a.compatible && !b.compatible) {
return -1;
}
if (!a.compatible && b.compatible) {
return 1;
}
return 0;
})
.map(({ label, value, compatible, exists }) => ({
label,
value,
className: classNames({
'lnFieldSelect__option--incompatible': !compatible,
'lnFieldSelect__option--nonExistant': !exists,
}),
'data-test-subj': `lns-fieldOption${compatible ? '' : 'Incompatible'}-${label}`,
})),
options: fieldNamesToOptions(normalFields),
});
}

View file

@ -38,10 +38,13 @@ import { trackUiEvent } from '../../lens_ui_telemetry';
const operationPanels = getOperationDisplay();
export function asOperationOptions(
operationTypes: OperationType[],
compatibleWithCurrentField: boolean
) {
export interface PopoverEditorProps extends IndexPatternDimensionPanelProps {
selectedColumn?: IndexPatternColumn;
operationFieldSupportMatrix: OperationFieldSupportMatrix;
currentIndexPattern: IndexPattern;
}
function asOperationOptions(operationTypes: OperationType[], compatibleWithCurrentField: boolean) {
return [...operationTypes]
.sort((opType1, opType2) => {
return operationPanels[opType1].displayName.localeCompare(
@ -54,12 +57,6 @@ export function asOperationOptions(
}));
}
export interface PopoverEditorProps extends IndexPatternDimensionPanelProps {
selectedColumn?: IndexPatternColumn;
operationFieldSupportMatrix: OperationFieldSupportMatrix;
currentIndexPattern: IndexPattern;
}
export function PopoverEditor(props: PopoverEditorProps) {
const {
selectedColumn,
@ -72,7 +69,7 @@ export function PopoverEditor(props: PopoverEditorProps) {
uniqueLabel,
hideGrouping,
} = props;
const { operationByDocument, operationByField, fieldByOperation } = operationFieldSupportMatrix;
const { operationByField, fieldByOperation } = operationFieldSupportMatrix;
const [isPopoverOpen, setPopoverOpen] = useState(false);
const [
incompatibleSelectedOperationType,
@ -87,19 +84,12 @@ export function PopoverEditor(props: PopoverEditorProps) {
currentIndexPattern.fields.forEach(field => {
fields[field.name] = field;
});
return fields;
}, [currentIndexPattern]);
function getOperationTypes() {
const possibleOperationTypes = Object.keys(fieldByOperation).concat(
operationByDocument
) as OperationType[];
const possibleOperationTypes = Object.keys(fieldByOperation) as OperationType[];
const validOperationTypes: OperationType[] = [];
if (!selectedColumn || !hasField(selectedColumn)) {
validOperationTypes.push(...operationByDocument);
}
if (!selectedColumn) {
validOperationTypes.push(...(Object.keys(fieldByOperation) as OperationType[]));
@ -139,12 +129,8 @@ export function PopoverEditor(props: PopoverEditorProps) {
onClick() {
if (!selectedColumn) {
const possibleFields = fieldByOperation[operationType] || [];
const isFieldlessPossible = operationByDocument.includes(operationType);
if (
possibleFields.length === 1 ||
(possibleFields.length === 0 && isFieldlessPossible)
) {
if (possibleFields.length === 1) {
setState(
changeColumn({
state,
@ -156,8 +142,7 @@ export function PopoverEditor(props: PopoverEditorProps) {
layerId: props.layerId,
op: operationType,
indexPattern: currentIndexPattern,
field: possibleFields.length === 1 ? fieldMap[possibleFields[0]] : undefined,
asDocumentOperation: possibleFields.length === 0,
field: fieldMap[possibleFields[0]],
}),
})
);
@ -184,7 +169,7 @@ export function PopoverEditor(props: PopoverEditorProps) {
layerId: props.layerId,
op: operationType,
indexPattern: currentIndexPattern,
field: hasField(selectedColumn) ? fieldMap[selectedColumn.sourceField] : undefined,
field: fieldMap[selectedColumn.sourceField],
});
trackUiEvent(
`indexpattern_dimension_operation_from_${selectedColumn.operationType}_to_${operationType}`
@ -307,12 +292,11 @@ export function PopoverEditor(props: PopoverEditorProps) {
}
column = buildColumn({
columns: props.state.layers[props.layerId].columns,
field: 'field' in choice ? fieldMap[choice.field] : undefined,
field: fieldMap[choice.field],
indexPattern: currentIndexPattern,
layerId: props.layerId,
suggestedPriority: props.suggestedPriority,
op: operation as OperationType,
asDocumentOperation: choice.type === 'document',
});
}

View file

@ -0,0 +1,20 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License;
* you may not use this file except in compliance with the Elastic License.
*/
import { i18n } from '@kbn/i18n';
/**
* This is a special-case field which allows us to perform
* document-level operations such as count.
*/
export const documentField = {
name: i18n.translate('xpack.lens.indexPattern.records', {
defaultMessage: 'Records',
}),
type: 'document',
aggregatable: true,
searchable: true,
};

View file

@ -56,6 +56,7 @@ export interface FieldItemProps {
query: Query;
dateRange: DatasourceDataPanelProps['dateRange'];
filters: Filter[];
hideDetails?: boolean;
}
interface State {
@ -75,7 +76,17 @@ function wrapOnDot(str?: string) {
}
export function FieldItem(props: FieldItemProps) {
const { core, field, indexPattern, highlight, exists, query, dateRange, filters } = props;
const {
core,
field,
indexPattern,
highlight,
exists,
query,
dateRange,
filters,
hideDetails,
} = props;
const [infoIsOpen, setOpen] = useState(false);
@ -140,6 +151,10 @@ export function FieldItem(props: FieldItemProps) {
}
function togglePopover() {
if (hideDetails) {
return;
}
setOpen(!infoIsOpen);
if (!infoIsOpen) {
trackUiEvent('indexpattern_field_info_click');
@ -175,7 +190,7 @@ export function FieldItem(props: FieldItemProps) {
}
}}
aria-label={i18n.translate('xpack.lens.indexPattern.fieldStatsButtonLabel', {
defaultMessage: 'Click for a field preview. Or, drag and drop to visualize.',
defaultMessage: 'Click for a field preview, or drag and drop to visualize.',
})}
>
<LensFieldIcon type={field.type as DataType} />
@ -186,9 +201,15 @@ export function FieldItem(props: FieldItemProps) {
<EuiIconTip
anchorClassName="lnsFieldItem__infoIcon"
content={i18n.translate('xpack.lens.indexPattern.fieldStatsButtonLabel', {
defaultMessage: 'Click for a field preview. Or, drag and drop to visualize.',
})}
content={
hideDetails
? i18n.translate('xpack.lens.indexPattern.fieldItemTooltip', {
defaultMessage: 'Drag and drop to visualize.',
})
: i18n.translate('xpack.lens.indexPattern.fieldStatsButtonLabel', {
defaultMessage: 'Click for a field preview, or drag and drop to visualize.',
})
}
type="iInCircle"
color="subdued"
size="s"
@ -461,7 +482,7 @@ function FieldItemPopoverContents(props: State & FieldItemProps) {
)}
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiText size="s" textAlign="left" color={euiTextColor}>
<EuiText size="xs" textAlign="left" color={euiTextColor}>
{Math.round((topValue.count / props.sampledValues!) * 100)}%
</EuiText>
</EuiFlexItem>

View file

@ -183,6 +183,7 @@ describe('IndexPattern Data Source', () => {
isBucketed: false,
label: 'Foo',
operationType: 'count',
sourceField: 'Records',
};
const map = uniqueLabels({
a: {
@ -243,16 +244,13 @@ describe('IndexPattern Data Source', () => {
label: 'Count of records',
dataType: 'number',
isBucketed: false,
// Private
sourceField: 'Records',
operationType: 'count',
},
col2: {
label: 'Date',
dataType: 'date',
isBucketed: true,
// Private
operationType: 'date_histogram',
sourceField: 'timestamp',
params: {
@ -272,7 +270,7 @@ describe('IndexPattern Data Source', () => {
metricsAtAllLevels=false
partialRows=false
includeFormatHints=true
aggConfigs={lens_auto_date aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]'} | lens_rename_columns idMap='{\\"col-0-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}'"
aggConfigs={lens_auto_date aggConfigs='[{\\"id\\":\\"col1\\",\\"enabled\\":true,\\"type\\":\\"count\\",\\"schema\\":\\"metric\\",\\"params\\":{}},{\\"id\\":\\"col2\\",\\"enabled\\":true,\\"type\\":\\"date_histogram\\",\\"schema\\":\\"segment\\",\\"params\\":{\\"field\\":\\"timestamp\\",\\"useNormalizedEsInterval\\":true,\\"interval\\":\\"1d\\",\\"drop_partials\\":false,\\"min_doc_count\\":0,\\"extended_bounds\\":{}}}]'} | lens_rename_columns idMap='{\\"col-0-col1\\":{\\"label\\":\\"Count of records\\",\\"dataType\\":\\"number\\",\\"isBucketed\\":false,\\"sourceField\\":\\"Records\\",\\"operationType\\":\\"count\\",\\"id\\":\\"col1\\"},\\"col-1-col2\\":{\\"label\\":\\"Date\\",\\"dataType\\":\\"date\\",\\"isBucketed\\":true,\\"operationType\\":\\"date_histogram\\",\\"sourceField\\":\\"timestamp\\",\\"params\\":{\\"interval\\":\\"1d\\"},\\"id\\":\\"col2\\"}}'"
`);
});
});

View file

@ -28,7 +28,7 @@ import {
getDatasourceSuggestionsFromCurrentState,
} from './indexpattern_suggestions';
import { isDraggedField } from './utils';
import { isDraggedField, normalizeOperationDataType } from './utils';
import { LayerPanel } from './layerpanel';
import { IndexPatternColumn } from './operations';
import {
@ -51,7 +51,7 @@ export interface DraggedField {
export function columnToOperation(column: IndexPatternColumn, uniqueLabel?: string): Operation {
const { dataType, label, isBucketed, scale } = column;
return {
dataType,
dataType: normalizeOperationDataType(dataType),
isBucketed,
scale,
label: uniqueLabel || label,

View file

@ -23,6 +23,7 @@ import {
IndexPatternLayer,
IndexPatternField,
} from './types';
import { documentField } from './document_field';
type IndexPatternSugestion = DatasourceSuggestion<IndexPatternPrivateState>;
@ -285,6 +286,7 @@ function createNewLayerWithBucketAggregation(
indexPattern,
layerId,
suggestedPriority: undefined,
field: documentField,
});
const col1 = generateId();
@ -364,6 +366,9 @@ export function getDatasourceSuggestionsFromCurrentState(
columnId =>
layer.columns[columnId].isBucketed && layer.columns[columnId].dataType === 'date'
);
const timeField = indexPattern.fields.find(
({ name }) => name === indexPattern.timeFieldName
);
const suggestions: Array<DatasourceSuggestion<IndexPatternPrivateState>> = [];
if (metrics.length === 0) {
@ -376,9 +381,9 @@ export function getDatasourceSuggestionsFromCurrentState(
})
);
} else if (buckets.length === 0) {
if (indexPattern.timeFieldName) {
if (timeField) {
// suggest current metric over time if there is a default time field
suggestions.push(createSuggestionWithDefaultDateHistogram(state, layerId));
suggestions.push(createSuggestionWithDefaultDateHistogram(state, layerId, timeField));
}
suggestions.push(...createAlternativeMetricSuggestions(indexPattern, layerId, state));
// also suggest simple current state
@ -392,10 +397,10 @@ export function getDatasourceSuggestionsFromCurrentState(
} else {
suggestions.push(...createSimplifiedTableSuggestions(state, layerId));
if (!timeDimension && indexPattern.timeFieldName) {
if (!timeDimension && timeField) {
// suggest current configuration over time if there is a default time field
// and no time dimension yet
suggestions.push(createSuggestionWithDefaultDateHistogram(state, layerId));
suggestions.push(createSuggestionWithDefaultDateHistogram(state, layerId, timeField));
}
if (buckets.length === 2) {
@ -439,20 +444,33 @@ function createMetricSuggestion(
suggestedPriority: 0,
})
)
.filter(op => op.dataType === 'number' && !op.isBucketed);
.filter(op => (op.dataType === 'number' || op.dataType === 'document') && !op.isBucketed);
if (!column) {
return;
}
const newId = generateId();
return buildSuggestion({
layerId,
state,
changeType: 'initial',
updatedLayer: {
...layer,
columns: { [newId]: column },
columns: {
[newId]:
column.dataType !== 'document'
? column
: buildColumn({
op: 'count',
columns: {},
indexPattern,
layerId,
suggestedPriority: undefined,
field: documentField,
}),
},
columnOrder: [newId],
},
});
@ -462,8 +480,8 @@ function getNestedTitle([outerBucket, innerBucket]: IndexPatternColumn[]) {
return i18n.translate('xpack.lens.indexpattern.suggestions.nestingChangeLabel', {
defaultMessage: '{innerOperation} for each {outerOperation}',
values: {
innerOperation: hasField(innerBucket) ? innerBucket.sourceField : innerBucket.label,
outerOperation: hasField(outerBucket) ? outerBucket.sourceField : outerBucket.label,
innerOperation: innerBucket.sourceField,
outerOperation: outerBucket.sourceField,
},
});
}
@ -515,7 +533,8 @@ function createAlternativeMetricSuggestions(
function createSuggestionWithDefaultDateHistogram(
state: IndexPatternPrivateState,
layerId: string
layerId: string,
timeField: IndexPatternField
) {
const layer = state.layers[layerId];
const indexPattern = state.indexPatterns[layer.indexPatternId];
@ -526,7 +545,7 @@ function createSuggestionWithDefaultDateHistogram(
op: 'date_histogram',
indexPattern,
columns: layer.columns,
field: indexPattern.fields.find(({ name }) => name === indexPattern.timeFieldName),
field: timeField,
suggestedPriority: undefined,
});
const updatedLayer = {

View file

@ -8,9 +8,10 @@ import React from 'react';
import { palettes } from '@elastic/eui';
import { FieldIcon, typeToEuiIconMap } from '../../../../../../src/plugins/kibana_react/public';
import { DataType } from '../types';
import { normalizeOperationDataType } from './utils';
export function getColorForDataType(type: string) {
const iconMap = typeToEuiIconMap[type];
const iconMap = typeToEuiIconMap[normalizeOperationDataType(type as DataType)];
if (iconMap) {
return iconMap.color;
}
@ -18,5 +19,12 @@ export function getColorForDataType(type: string) {
}
export function LensFieldIcon({ type }: { type: DataType }) {
return <FieldIcon type={type} className="lnsFieldListPanel__fieldIcon" size="m" useColor />;
return (
<FieldIcon
type={normalizeOperationDataType(type)}
className="lnsFieldListPanel__fieldIcon"
size="m"
useColor
/>
);
}

View file

@ -14,6 +14,7 @@ import {
syncExistingFields,
} from './loader';
import { IndexPatternPersistedState, IndexPatternPrivateState } from './types';
import { documentField } from './document_field';
// TODO: This should not be necessary
jest.mock('ui/new_platform');
@ -63,6 +64,7 @@ const sampleIndexPatterns = {
searchable: true,
esTypes: ['keyword'],
},
documentField,
],
},
b: {
@ -121,6 +123,7 @@ const sampleIndexPatterns = {
},
esTypes: ['keyword'],
},
documentField,
],
},
};
@ -133,7 +136,7 @@ function indexPatternSavedObject({ id }: { id: keyof typeof sampleIndexPatterns
attributes: {
title: pattern.title,
timeFieldName: pattern.timeFieldName,
fields: JSON.stringify(pattern.fields),
fields: JSON.stringify(pattern.fields.filter(f => f.type !== 'document')),
},
};
}

View file

@ -23,6 +23,7 @@ import {
import { updateLayerIndexPattern } from './state_helpers';
import { DateRange, ExistingFields } from '../../common/types';
import { BASE_API_URL } from '../../common';
import { documentField } from './document_field';
interface SavedIndexPatternAttributes extends SavedObjectAttributes {
title: string;
@ -278,10 +279,12 @@ function fromSavedObject(
id,
type,
title: attributes.title,
fields: (JSON.parse(attributes.fields) as IndexPatternField[]).filter(
({ type: fieldType, esTypes }) =>
fieldType !== 'string' || (esTypes && esTypes.includes('keyword'))
),
fields: (JSON.parse(attributes.fields) as IndexPatternField[])
.filter(
({ type: fieldType, esTypes }) =>
fieldType !== 'string' || (esTypes && esTypes.includes('keyword'))
)
.concat(documentField),
typeMeta: attributes.typeMeta
? (JSON.parse(attributes.typeMeta) as SavedRestrictionsInfo)
: undefined,

View file

@ -14,6 +14,7 @@ import { Operation, DimensionPriority } from '../../../types';
export interface BaseIndexPatternColumn extends Operation {
// Private
operationType: string;
sourceField: string;
suggestedPriority?: DimensionPriority;
}
@ -35,6 +36,5 @@ export type ParameterlessIndexPatternColumn<
> = TBase & { operationType: TOperationType };
export interface FieldBasedIndexPatternColumn extends BaseIndexPatternColumn {
sourceField: string;
suggestedPriority?: DimensionPriority;
}

View file

@ -7,6 +7,7 @@
import { i18n } from '@kbn/i18n';
import { OperationDefinition } from '.';
import { ParameterlessIndexPatternColumn, BaseIndexPatternColumn } from './column_types';
import { IndexPatternField } from '../../types';
const countLabel = i18n.translate('xpack.lens.indexPattern.countOf', {
defaultMessage: 'Count of records',
@ -23,14 +24,23 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn> = {
displayName: i18n.translate('xpack.lens.indexPattern.count', {
defaultMessage: 'Count',
}),
getPossibleOperationForDocument: () => {
onFieldChange: (oldColumn, indexPattern, field) => {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
...oldColumn,
label: field.name,
sourceField: field.name,
};
},
buildColumn({ suggestedPriority }) {
getPossibleOperationForField: (field: IndexPatternField) => {
if (field.type === 'document') {
return {
dataType: 'number',
isBucketed: false,
scale: 'ratio',
};
}
},
buildColumn({ suggestedPriority, field }) {
return {
label: countLabel,
dataType: 'number',
@ -38,6 +48,7 @@ export const countOperation: OperationDefinition<CountIndexPatternColumn> = {
suggestedPriority,
isBucketed: false,
scale: 'ratio',
sourceField: field.name,
};
},
toEsAggsConfig: (column, columnId) => ({

View file

@ -16,7 +16,7 @@ import { minOperation, averageOperation, sumOperation, maxOperation } from './me
import { dateHistogramOperation } from './date_histogram';
import { countOperation } from './count';
import { DimensionPriority, StateSetter, OperationMetadata } from '../../../types';
import { BaseIndexPatternColumn, FieldBasedIndexPatternColumn } from './column_types';
import { BaseIndexPatternColumn } from './column_types';
import { IndexPatternPrivateState, IndexPattern, IndexPatternField } from '../../types';
import { DateRange } from '../../../../common';
@ -142,26 +142,14 @@ interface FieldBasedOperationDefinition<C extends BaseIndexPatternColumn>
onFieldChange: (oldColumn: C, indexPattern: IndexPattern, field: IndexPatternField) => C;
}
interface DocumentBasedOperationDefinition<C extends BaseIndexPatternColumn>
extends BaseOperationDefinitionProps<C> {
/**
* Returns the meta data of the operation if applied to documents of the given index pattern.
* Undefined if the operation is not applicable to the index pattern.
*/
getPossibleOperationForDocument: (indexPattern: IndexPattern) => OperationMetadata | undefined;
buildColumn: (arg: BaseBuildColumnArgs) => C;
}
/**
* Shape of an operation definition. If the type parameter of the definition
* indicates a field based column, `getPossibleOperationForField` has to be
* specified, otherwise `getPossibleOperationForDocument` has to be defined.
*/
export type OperationDefinition<
C extends BaseIndexPatternColumn
> = C extends FieldBasedIndexPatternColumn
? FieldBasedOperationDefinition<C>
: DocumentBasedOperationDefinition<C>;
export type OperationDefinition<C extends BaseIndexPatternColumn> = FieldBasedOperationDefinition<
C
>;
// Helper to to infer the column type out of the operation definition.
// This is done to avoid it to have to list out the column types along with
@ -187,9 +175,7 @@ export type OperationType = (typeof internalOperationDefinitions)[number]['type'
* This is an operation definition of an unspecified column out of all possible
* column types. It
*/
export type GenericOperationDefinition =
| FieldBasedOperationDefinition<IndexPatternColumn>
| DocumentBasedOperationDefinition<IndexPatternColumn>;
export type GenericOperationDefinition = FieldBasedOperationDefinition<IndexPatternColumn>;
/**
* List of all available operation definitions

View file

@ -48,8 +48,6 @@ describe('terms', () => {
label: 'Top value of category',
dataType: 'string',
isBucketed: true,
// Private
operationType: 'terms',
params: {
orderBy: { type: 'alphabetical' },
@ -62,8 +60,7 @@ describe('terms', () => {
label: 'Count',
dataType: 'number',
isBucketed: false,
// Private
sourceField: 'Records',
operationType: 'count',
},
},
@ -214,8 +211,7 @@ describe('terms', () => {
label: 'Count',
dataType: 'number',
isBucketed: false,
// Private
sourceField: 'Records',
operationType: 'count',
},
},
@ -255,8 +251,7 @@ describe('terms', () => {
label: 'Count',
dataType: 'number',
isBucketed: false,
// Private
sourceField: 'Records',
operationType: 'count',
},
});

View file

@ -8,6 +8,7 @@ import { getOperationTypesForField, getAvailableOperationsByMetadata, buildColum
import { AvgIndexPatternColumn, MinIndexPatternColumn } from './definitions/metrics';
import { CountIndexPatternColumn } from './definitions/count';
import { IndexPatternPrivateState } from '../types';
import { documentField } from '../document_field';
jest.mock('ui/new_platform');
jest.mock('../loader');
@ -179,6 +180,7 @@ describe('getOperationTypesForField', () => {
columns: state.layers.first.columns,
suggestedPriority: 0,
op: 'count',
field: documentField,
});
expect(column.operationType).toEqual('count');
});
@ -216,7 +218,7 @@ describe('getOperationTypesForField', () => {
indexPattern: expectedIndexPatterns[1],
columns: state.layers.first.columns,
suggestedPriority: 0,
asDocumentOperation: true,
field: documentField,
}) as CountIndexPatternColumn;
expect(column.operationType).toEqual('count');
});
@ -296,10 +298,6 @@ describe('getOperationTypesForField', () => {
"operationType": "sum",
"type": "field",
},
Object {
"operationType": "count",
"type": "document",
},
],
},
]

View file

@ -14,6 +14,7 @@ import {
IndexPatternColumn,
} from './definitions';
import { IndexPattern, IndexPatternField } from '../types';
import { documentField } from '../document_field';
/**
* Returns all available operation types as a list at runtime.
@ -68,9 +69,21 @@ export function getOperationTypesForField(field: IndexPatternField) {
.map(({ type }) => type);
}
type OperationFieldTuple =
| { type: 'field'; operationType: OperationType; field: string }
| { type: 'document'; operationType: OperationType };
let documentOperations: Set<string>;
export function isDocumentOperation(type: string) {
// This can't be done at the root level, because it breaks tests, thanks to mocking oddities
// so we do it here, and cache the result.
documentOperations =
documentOperations || new Set(getOperationTypesForField(documentField) as string[]);
return documentOperations.has(type);
}
interface OperationFieldTuple {
type: 'field';
operationType: OperationType;
field: string;
}
/**
* Returns all possible operations (matches between operations and fields of the index
@ -119,11 +132,6 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) {
};
operationDefinitions.forEach(operationDefinition => {
addToMap(
{ type: 'document', operationType: operationDefinition.type },
getPossibleOperationForDocument(operationDefinition, indexPattern)
);
indexPattern.fields.forEach(field => {
addToMap(
{
@ -139,15 +147,6 @@ export function getAvailableOperationsByMetadata(indexPattern: IndexPattern) {
return Object.values(operationByMetadata);
}
function getPossibleOperationForDocument(
operationDefinition: GenericOperationDefinition,
indexPattern: IndexPattern
): OperationMetadata | undefined {
return 'getPossibleOperationForDocument' in operationDefinition
? operationDefinition.getPossibleOperationForDocument(indexPattern)
: undefined;
}
function getPossibleOperationForField(
operationDefinition: GenericOperationDefinition,
field: IndexPatternField
@ -203,24 +202,18 @@ export function buildColumn({
layerId,
indexPattern,
suggestedPriority,
asDocumentOperation,
}: {
op?: OperationType;
columns: Partial<Record<string, IndexPatternColumn>>;
suggestedPriority: DimensionPriority | undefined;
layerId: string;
indexPattern: IndexPattern;
field?: IndexPatternField;
asDocumentOperation?: boolean;
field: IndexPatternField;
}): IndexPatternColumn {
let operationDefinition: GenericOperationDefinition | undefined;
if (op) {
operationDefinition = operationDefinitionMap[op];
} else if (asDocumentOperation) {
operationDefinition = getDefinition(definition =>
Boolean(getPossibleOperationForDocument(definition, indexPattern))
);
} else if (field) {
operationDefinition = getDefinition(definition =>
Boolean(getPossibleOperationForField(definition, field))
@ -238,19 +231,14 @@ export function buildColumn({
indexPattern,
};
// check for the operation for field getter to determine whether
// this is a field based operation type
if ('getPossibleOperationForField' in operationDefinition) {
if (!field) {
throw new Error(`Invariant error: ${operationDefinition.type} operation requires field`);
}
return operationDefinition.buildColumn({
...baseOptions,
field,
});
} else {
return operationDefinition.buildColumn(baseOptions);
if (!field) {
throw new Error(`Invariant error: ${operationDefinition.type} operation requires field`);
}
return operationDefinition.buildColumn({
...baseOptions,
field,
});
}
export { operationDefinitionMap } from './definitions';

View file

@ -54,8 +54,7 @@ describe('state_helpers', () => {
label: 'Count',
dataType: 'number',
isBucketed: false,
// Private
sourceField: 'Records',
operationType: 'count',
},
},
@ -102,8 +101,7 @@ describe('state_helpers', () => {
label: 'Count',
dataType: 'number',
isBucketed: false,
// Private
sourceField: 'Records',
operationType: 'count',
},
},
@ -328,8 +326,7 @@ describe('state_helpers', () => {
label: 'Count',
dataType: 'number',
isBucketed: false,
// Private
sourceField: 'Records',
operationType: 'count',
},
},

View file

@ -10,6 +10,15 @@ import {
BaseIndexPatternColumn,
FieldBasedIndexPatternColumn,
} from './operations/definitions/column_types';
import { DataType } from '../types';
/**
* Normalizes the specified operation type. (e.g. document operations
* produce 'number')
*/
export function normalizeOperationDataType(type: DataType) {
return type === 'document' ? 'number' : type;
}
export function hasField(column: BaseIndexPatternColumn): column is FieldBasedIndexPatternColumn {
return 'sourceField' in column;

View file

@ -205,7 +205,7 @@ export interface DatasourceLayerPanelProps {
layerId: string;
}
export type DataType = 'string' | 'number' | 'date' | 'boolean' | 'ip';
export type DataType = 'document' | 'string' | 'number' | 'date' | 'boolean' | 'ip';
// An operation represents a column in a table, not any information
// about how the column was created such as whether it is a sum or average.

View file

@ -19,11 +19,12 @@ import { generateId } from '../id_generator';
import { getIconForSeries } from './state_helpers';
const columnSortOrder = {
date: 0,
string: 1,
ip: 2,
boolean: 3,
number: 4,
document: 0,
date: 1,
string: 2,
ip: 3,
boolean: 4,
number: 5,
};
/**