diff --git a/x-pack/legacy/plugins/rollup/__jest__/client_integration/helpers/job_create.helpers.js b/x-pack/legacy/plugins/rollup/__jest__/client_integration/helpers/job_create.helpers.js index 57b551cad595b..e97f060306e52 100644 --- a/x-pack/legacy/plugins/rollup/__jest__/client_integration/helpers/job_create.helpers.js +++ b/x-pack/legacy/plugins/rollup/__jest__/client_integration/helpers/job_create.helpers.js @@ -14,7 +14,7 @@ const initTestBed = registerTestBed(JobCreate, { store: rollupJobsStore }); export const setup = (props) => { const testBed = initTestBed(props); - const { component, form } = testBed; + const { component, form, table } = testBed; // User actions const clickNextStep = () => { @@ -68,6 +68,30 @@ export const setup = (props) => { } }; + // Helpers for the metrics step in job creation. + // Mostly placed here for now to make test file a bit smaller and specific + // to tests. + const getFieldListTableRows = () => { + const { rows } = table.getMetaData('rollupJobMetricsFieldList'); + return rows; + }; + + const getFieldListTableRow = (row) => { + const rows = getFieldListTableRows(); + return rows[row]; + }; + + const getFieldChooserColumnForRow = (row) => { + const selectedRow = getFieldListTableRow(row); + const [,, fieldChooserColumn] = selectedRow.columns; + return fieldChooserColumn; + }; + + const getSelectAllInputForRow = (row) => { + const fieldChooser = getFieldChooserColumnForRow(row); + return fieldChooser.reactWrapper.find('input').first(); + }; + // Misc const getEuiStepsHorizontalActive = () => component.find('.euiStepHorizontal-isSelected').text(); @@ -84,5 +108,11 @@ export const setup = (props) => { ...testBed.form, fillFormFields, }, + metrics: { + getFieldListTableRows, + getFieldListTableRow, + getFieldChooserColumnForRow, + getSelectAllInputForRow, + } }; }; diff --git a/x-pack/legacy/plugins/rollup/__jest__/client_integration/job_create_metrics.test.js b/x-pack/legacy/plugins/rollup/__jest__/client_integration/job_create_metrics.test.js index f54ba7f85ae99..cfd4901e3a8be 100644 --- a/x-pack/legacy/plugins/rollup/__jest__/client_integration/job_create_metrics.test.js +++ b/x-pack/legacy/plugins/rollup/__jest__/client_integration/job_create_metrics.test.js @@ -36,6 +36,7 @@ describe('Create Rollup Job, step 5: Metrics', () => { let getEuiStepsHorizontalActive; let goToStep; let table; + let metrics; beforeAll(() => { ({ server, httpRequestsMockHelpers } = setupEnvironment()); @@ -56,6 +57,7 @@ describe('Create Rollup Job, step 5: Metrics', () => { getEuiStepsHorizontalActive, goToStep, table, + metrics, } = setup()); }); @@ -175,6 +177,9 @@ describe('Create Rollup Job, step 5: Metrics', () => { } }; + const numericTypeMetrics = ['avg', 'max', 'min', 'sum', 'value_count']; + const dateTypeMetrics = ['max', 'min', 'value_count']; + it('should have an empty field list', async () => { await goToStep(5); @@ -189,7 +194,6 @@ describe('Create Rollup Job, step 5: Metrics', () => { }); it('should have "avg", "max", "min", "sum" & "value count" metrics for *numeric* fields', () => { - const numericTypeMetrics = ['avg', 'max', 'min', 'sum', 'value_count']; addFieldToList('numeric'); numericTypeMetrics.forEach(type => { try { @@ -203,11 +207,10 @@ describe('Create Rollup Job, step 5: Metrics', () => { const { rows: [firstRow] } = table.getMetaData('rollupJobMetricsFieldList'); const columnWithMetricsCheckboxes = 2; const metricsCheckboxes = firstRow.columns[columnWithMetricsCheckboxes].reactWrapper.find('input'); - expect(metricsCheckboxes.length).toBe(numericTypeMetrics.length); + expect(metricsCheckboxes.length).toBe(numericTypeMetrics.length + 1 /* add one for select all */); }); it('should have "max", "min", & "value count" metrics for *date* fields', () => { - const dateTypeMetrics = ['max', 'min', 'value_count']; addFieldToList('date'); dateTypeMetrics.forEach(type => { @@ -222,7 +225,7 @@ describe('Create Rollup Job, step 5: Metrics', () => { const { rows: [firstRow] } = table.getMetaData('rollupJobMetricsFieldList'); const columnWithMetricsCheckboxes = 2; const metricsCheckboxes = firstRow.columns[columnWithMetricsCheckboxes].reactWrapper.find('input'); - expect(metricsCheckboxes.length).toBe(dateTypeMetrics.length); + expect(metricsCheckboxes.length).toBe(dateTypeMetrics.length + 1 /* add one for select all */); }); it('should not allow to go to the next step if at least one metric type is not selected', () => { @@ -247,12 +250,162 @@ describe('Create Rollup Job, step 5: Metrics', () => { const columnsFirstRow = fieldListRows[0].columns; // The last column is the eui "actions" column - const deleteButton = columnsFirstRow[columnsFirstRow.length - 1].reactWrapper.find('button'); + const deleteButton = columnsFirstRow[columnsFirstRow.length - 1].reactWrapper.find('button').last(); deleteButton.simulate('click'); ({ rows: fieldListRows } = table.getMetaData('rollupJobMetricsFieldList')); expect(fieldListRows[0].columns[0].value).toEqual('No metrics fields added'); }); }); + + describe('when using multi-selectors', () => { + let getSelectAllInputForRow; + let getFieldChooserColumnForRow; + let getFieldListTableRows; + + beforeEach(async () => { + httpRequestsMockHelpers.setIndexPatternValidityResponse({ numericFields, dateFields }); + await goToStep(5); + await addFieldToList('numeric'); + await addFieldToList('date'); + find('rollupJobSelectAllMetricsPopoverButton').simulate('click'); + ({ getSelectAllInputForRow, getFieldChooserColumnForRow, getFieldListTableRows } = metrics); + }); + + const expectAllFieldChooserInputs = (fieldChooserColumn, expected) => { + const inputs = fieldChooserColumn.reactWrapper.find('input'); + inputs.forEach((input) => { + expect(input.props().checked).toBe(expected); + }); + }; + + it('should select all of the fields in a row', async () => { + // The last column is the eui "actions" column + const selectAllCheckbox = getSelectAllInputForRow(0); + selectAllCheckbox.simulate('change', { checked: true }); + const fieldChooserColumn = getFieldChooserColumnForRow(0); + expectAllFieldChooserInputs(fieldChooserColumn, true); + }); + + it('should deselect all of the fields in a row ', async () => { + const selectAllCheckbox = getSelectAllInputForRow(0); + selectAllCheckbox.simulate('change', { checked: true }); + + let fieldChooserColumn = getFieldChooserColumnForRow(0); + expectAllFieldChooserInputs(fieldChooserColumn, true); + + selectAllCheckbox.simulate('change', { checked: false }); + fieldChooserColumn = getFieldChooserColumnForRow(0); + expectAllFieldChooserInputs(fieldChooserColumn, false); + }); + + it('should select all of the metric types across rows (column-wise)', () => { + const selectAllAvgCheckbox = find('rollupJobMetricsSelectAllCheckbox-avg'); + selectAllAvgCheckbox.first().simulate('change', { checked: true }); + + const rows = getFieldListTableRows(); + + rows + .filter((row) => { + const [, metricTypeCol ] = row.columns; + return metricTypeCol.value === 'numeric'; + }) + .forEach((row, idx) => { + const fieldChooser = getFieldChooserColumnForRow(idx); + fieldChooser.reactWrapper.find('input').forEach(input => { + const props = input.props(); + if (props['data-test-subj'].endsWith('avg')) { + expect(props.checked).toBe(true); + } else { + expect(props.checked).toBe(false); + } + }); + }); + }); + + it('should deselect all of the metric types across rows (column-wise)', () => { + const selectAllAvgCheckbox = find('rollupJobMetricsSelectAllCheckbox-avg'); + + // Select first, which adds metrics column-wise, and then de-select column-wise to ensure + // that we correctly remove/undo our adding action. + selectAllAvgCheckbox.last().simulate('change', { checked: true }); + selectAllAvgCheckbox.last().simulate('change', { checked: false }); + + const rows = getFieldListTableRows(); + + rows.forEach((row, idx) => { + const [, metricTypeCol ] = row.columns; + if (metricTypeCol.value !== 'numeric') { + return; + } + const fieldChooser = getFieldChooserColumnForRow(idx); + fieldChooser.reactWrapper.find('input').forEach(input => { + expect(input.props().checked).toBe(false); + }); + }); + }); + + it('should correctly select across rows and columns', async () => { + /** + * Tricky test case where we want to determine that the column-wise and row-wise + * selections are interacting correctly with each other. + * + * We will select avg (numeric) and max (numeric + date) to ensure that we are + * testing across metrics types too. The plan is: + * + * 1. Select all avg column-wise + * 2. Select all max column-wise + * 3. Select and deselect row-wise the first numeric metric row. Because some items will + * have been selected by the previous column-wise selection we want to test that row-wise + * select all followed by de-select can effectively undo the column-wise selections. + * 4. Expect the avg and max select all checkboxes to be unchecked + * 5. Select all on the last date metric row-wise + * 6. Select then deselect all max column-wise + * 7. Expect everything but all and max to be selected on the last date metric row + * + * Let's a go! + */ + + // 1. + find('rollupJobMetricsSelectAllCheckbox-avg').first().simulate('change', { checked: true }); + // 2. + find('rollupJobMetricsSelectAllCheckbox-max').first().simulate('change', { checked: true }); + + const selectAllCheckbox = getSelectAllInputForRow(0); + + // 3. + // Select all (which should check all checkboxes) + selectAllCheckbox.simulate('change', { checked: true }); + // Deselect all (which should deselect all checkboxes) + selectAllCheckbox.simulate('change', { checked: false }); + + // 4. + expect(find('rollupJobMetricsSelectAllCheckbox-avg').first().props().checked).toBe(false); + expect(find('rollupJobMetricsSelectAllCheckbox-max').first().props().checked).toBe(false); + + let rows = getFieldListTableRows(); + // 5. + getSelectAllInputForRow(rows.length - 1).simulate('change', { checked: true }); + + // 6. + find('rollupJobMetricsSelectAllCheckbox-max').first().simulate('change', { checked: true }); + find('rollupJobMetricsSelectAllCheckbox-max').first().simulate('change', { checked: false }); + + rows = getFieldListTableRows(); + const lastRowFieldChooserColumn = getFieldChooserColumnForRow(rows.length - 1); + // 7. + lastRowFieldChooserColumn.reactWrapper.find('input').forEach((input) => { + const props = input.props(); + if ( + props['data-test-subj'].endsWith('max') || + props['data-test-subj'].endsWith('All') + ) { + expect(props.checked).toBe(false); + } else { + expect(props.checked).toBe(true); + } + }); + }); + }); }); }); diff --git a/x-pack/legacy/plugins/rollup/public/crud_app/constants/index.js b/x-pack/legacy/plugins/rollup/public/crud_app/constants/index.js index f71725d23b4e0..9b2b10d3ae742 100644 --- a/x-pack/legacy/plugins/rollup/public/crud_app/constants/index.js +++ b/x-pack/legacy/plugins/rollup/public/crud_app/constants/index.js @@ -7,3 +7,7 @@ export { CRUD_APP_BASE_PATH, } from './paths'; + +export { + METRICS_CONFIG +} from './metrics_config'; diff --git a/x-pack/legacy/plugins/rollup/public/crud_app/constants/metrics_config.js b/x-pack/legacy/plugins/rollup/public/crud_app/constants/metrics_config.js new file mode 100644 index 0000000000000..f0931252a2d1e --- /dev/null +++ b/x-pack/legacy/plugins/rollup/public/crud_app/constants/metrics_config.js @@ -0,0 +1,45 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License; + * you may not use this file except in compliance with the Elastic License. + */ + +import { i18n } from '@kbn/i18n'; + +export const METRICS_CONFIG = [ + { + type: 'avg', + label: i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.checkboxAverageLabel', + { defaultMessage: 'Average' }, + ), + }, + { + type: 'max', + label: i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.checkboxMaxLabel', + { defaultMessage: 'Maximum' }, + ), + }, + { + type: 'min', + label: i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.checkboxMinLabel', + { defaultMessage: 'Minimum' }, + ), + }, + { + type: 'sum', + label: i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.checkboxSumLabel', + { defaultMessage: 'Sum' }, + ), + }, + { + type: 'value_count', + label: i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.checkboxValueCountLabel', + { defaultMessage: 'Value count' }, + ), + }, +]; diff --git a/x-pack/legacy/plugins/rollup/public/crud_app/sections/components/field_list/field_list.js b/x-pack/legacy/plugins/rollup/public/crud_app/sections/components/field_list/field_list.js index 5734cdb1ec0a6..6f3834971917f 100644 --- a/x-pack/legacy/plugins/rollup/public/crud_app/sections/components/field_list/field_list.js +++ b/x-pack/legacy/plugins/rollup/public/crud_app/sections/components/field_list/field_list.js @@ -45,7 +45,7 @@ export const FieldList = ({ type: 'icon', color: 'danger', onClick: (field) => onRemoveField(field), - }] + }], }); } else { extendedColumns = columns; diff --git a/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps/components/field_chooser.js b/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps/components/field_chooser.js index 6f56b9a204588..6d6b77ab2e047 100644 --- a/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps/components/field_chooser.js +++ b/x-pack/legacy/plugins/rollup/public/crud_app/sections/job_create/steps/components/field_chooser.js @@ -25,7 +25,6 @@ export class FieldChooser extends Component { fields: PropTypes.array.isRequired, selectedFields: PropTypes.array.isRequired, onSelectField: PropTypes.func.isRequired, - columns: PropTypes.array.isRequired, prompt: PropTypes.string, dataTestSubj: PropTypes.string, } @@ -136,7 +135,6 @@ export class FieldChooser extends Component { ); }; - return ( { + return !!get(whiteListedMetricByFieldType, [fieldType, metricType]); +}; + +// We use an IFFE to associate metricType configs with their +// associated field types. After processing each of these +// objects should have a fieldTypes: { date: true, numeric: true } +// like object. +const metricTypesConfig = (function () { + return METRICS_CONFIG.map(config => { + const fieldTypes = {}; + for (const [fieldType, metrics] of Object.entries(whiteListedMetricByFieldType)) { + fieldTypes[fieldType] = !!metrics[config.type]; + } + return { + ...config, + fieldTypes, + }; + }); +}()); + export class StepMetricsUi extends Component { static propTypes = { fields: PropTypes.object.isRequired, @@ -54,99 +72,218 @@ export class StepMetricsUi extends Component { fieldErrors: PropTypes.object.isRequired, areStepErrorsVisible: PropTypes.bool.isRequired, metricsFields: PropTypes.array.isRequired, - } + }; constructor(props) { super(props); - this.chooserColumns = [{ - field: 'name', - name: 'Field', - sortable: true, - width: '240px', - }, { - field: 'type', - name: 'Type', - truncateText: true, - sortable: true, - width: '100px', - }]; - - const metricTypesConfig = [ - { - type: 'avg', - label: ( - - ), - }, { - type: 'max', - label: ( - - ), - }, { - type: 'min', - label: ( - - ), - }, { - type: 'sum', - label: ( - - ), - }, { - type: 'value_count', - label: ( - - ), - }, - ]; + this.state = { + metricsPopoverOpen: false, + listColumns: [], + selectedMetricsMap: [], + }; + } - this.listColumns = this.chooserColumns.concat({ - type: 'metrics', - name: 'Metrics', - render: ({ name: fieldName, type: fieldType, types }) => { - const checkboxes = metricTypesConfig.map(({ type, label }) => { - const isAllowed = whiteListedMetricByFieldType[fieldType][type]; + openMetricsPopover = () => { + this.setState({ + metricsPopoverOpen: true, + }); + }; + + closeMetricsPopover = () => { + this.setState({ + metricsPopoverOpen: false, + }); + }; - if (!isAllowed) { - return; + renderMetricsSelectAllCheckboxes() { + const { + fields: { metrics }, + onFieldsChange, + } = this.props; + + let disabledCheckboxesCount = 0; + + /** + * Look at all the metric configs and include the special "All" checkbox which adds the ability + * to select all the checkboxes across columns and rows. + */ + const checkboxElements = [{ + label: i18n.translate('xpack.rollupJobs.create.stepMetrics.allCheckbox', { defaultMessage: 'All' }), + type: 'all', + }].concat(metricTypesConfig).map(({ label, type: metricType, fieldTypes }, idx) => { + const isAllMetricTypes = metricType === 'all'; + // For this config we are either considering all user selected metrics or a subset. + const applicableMetrics = isAllMetricTypes + ? metrics + : metrics + .filter(({ type }) => { + return fieldTypes[type]; + }); + + let checkedCount = 0; + let isChecked = false; + let isDisabled = false; + + if (isAllMetricTypes) { + applicableMetrics.forEach(({ types, type }) => { + const whiteListedSubset = Object.keys(whiteListedMetricByFieldType[type]); + if (whiteListedSubset.every(metricName => types.some(type => type === metricName))) { + ++checkedCount; } + }); + + isDisabled = metrics.length === 0; + } else { + applicableMetrics.forEach(({ types }) => { + const metricSelected = types.some(type => type === metricType); + if (metricSelected) { + ++checkedCount; + } + }); + + isDisabled = !metrics.some(({ type: fieldType }) => + checkWhiteListedMetricByFieldType(fieldType, metricType), + ); + } + + // Determine if a select all checkbox is checked. + isChecked = checkedCount === applicableMetrics.length; + + if (isDisabled) ++disabledCheckboxesCount; + + return ( + { + if (isAllMetricTypes) { + const newMetrics = metricTypesConfig + .reduce((acc, { type }) => this.setMetrics(type, !isChecked), null); + onFieldsChange({ metrics: newMetrics }); + } else { + onFieldsChange({ metrics: this.setMetrics(metricType, !isChecked) }); + } + }} + /> + ); + }); - const isSelected = types.includes(type); + return { + checkboxElements, + allCheckboxesDisabled: checkboxElements.length === disabledCheckboxesCount, + }; + } + + getMetricsSelectAllMenu() { + const { + checkboxElements, + allCheckboxesDisabled + } = this.renderMetricsSelectAllCheckboxes(); - return ( - + - this.setMetric(fieldName, type, !isSelected)} - /> - - ); - }).filter(checkbox => checkbox !== undefined); + { + i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.selectAllPopoverButtonLabel', + { defaultMessage: 'Select metrics' }) + } + + } + > + + { + checkboxElements + .map((item, idx) => {item}) + } + + + + ); + } + + renderRowSelectAll({ fieldName, fieldType, types }) { + const { onFieldsChange } = this.props; + const hasSelectedItems = Boolean(types.length); + const maxItemsToBeSelected = Object.keys(whiteListedMetricByFieldType[fieldType]).length; + const allSelected = maxItemsToBeSelected === types.length; + + const label = i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.selectAllRowLabel', + { defaultMessage: 'All' }, + ); + + const onChange = () => { + const isSelected = hasSelectedItems ? types.length !== maxItemsToBeSelected : true; + const newMetrics = metricTypesConfig + .filter(config => config.fieldTypes[fieldType]) + .reduce((acc, { type: typeConfig }) => { + return this.setMetric(fieldName, typeConfig, isSelected); + }, null); + onFieldsChange({ metric: newMetrics }); + }; + + return ( + + ); + } + + getListColumns() { + return StepMetricsUi.chooserColumns.concat({ + type: 'metrics', + name: i18n.translate('xpack.rollupJobs.create.stepMetrics.metricsColumnHeader', { defaultMessage: 'Metrics' }), + render: ({ name: fieldName, type: fieldType, types }) => { + const { onFieldsChange } = this.props; + const checkboxes = metricTypesConfig + .map(({ type, label }) => { + const isAllowed = checkWhiteListedMetricByFieldType(fieldType, type); + + if (!isAllowed) { + return; + } + + const isSelected = types.includes(type); + + return ( + + + onFieldsChange({ metrics: this.setMetric(fieldName, type, !isSelected) }) + } + /> + + ); + }) + .filter(checkbox => checkbox !== undefined); return ( + + {this.renderRowSelectAll({ fieldName, fieldType, types })} + {checkboxes} ); @@ -154,7 +291,7 @@ export class StepMetricsUi extends Component { }); } - onSelectField = (field) => { + onSelectField = field => { const { fields: { metrics }, onFieldsChange, @@ -168,7 +305,7 @@ export class StepMetricsUi extends Component { onFieldsChange({ metrics: newMetrics }); }; - onRemoveField = (field) => { + onRemoveField = field => { const { fields: { metrics }, onFieldsChange, @@ -179,33 +316,42 @@ export class StepMetricsUi extends Component { onFieldsChange({ metrics: newMetrics }); }; + setMetrics(metricType, isSelected) { + const { + fields: { metrics: fields }, + } = this.props; + + return fields + .filter(field => checkWhiteListedMetricByFieldType(field.type, metricType)) + .reduce((acc, metric) => { + return this.setMetric(metric.name, metricType, isSelected); + }, []); + } + setMetric = (fieldName, metricType, isSelected) => { const { fields: { metrics }, - onFieldsChange, } = this.props; - const newMetrics = [ ...metrics ]; - const { types: updatedTypes } = newMetrics.find(({ name }) => name === fieldName); + const newMetrics = [...metrics]; + const newMetric = newMetrics.find(({ name }) => name === fieldName); + // Update copied object by reference if (isSelected) { - updatedTypes.push(metricType); + // Don't add duplicates. + if (newMetric.types.indexOf(metricType) === -1) { + newMetric.types.push(metricType); + } } else { - updatedTypes.splice(updatedTypes.indexOf(metricType), 1); + newMetric.types.splice(newMetric.types.indexOf(metricType), 1); } - - onFieldsChange({ metrics: newMetrics }); + return newMetrics; }; render() { - const { - fields, - metricsFields, - } = this.props; + const { fields, metricsFields } = this.props; - const { - metrics, - } = fields; + const { metrics } = fields; return ( @@ -220,7 +366,7 @@ export class StepMetricsUi extends Component { - +

@@ -250,28 +396,51 @@ export class StepMetricsUi extends Component { - + No metrics fields added

} - addButton={( - + { + i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.emptyListLabel', + { defaultMessage: 'No metrics fields added' }, + ) + } +

+ } + + addButton={ + + + + } + columns={StepMetricsUi.chooserColumns} + fields={metricsFields} + selectedFields={metrics} + onSelectField={this.onSelectField} + dataTestSubj="rollupJobMetricsFieldChooser" /> - )} - columns={this.chooserColumns} - fields={metricsFields} - selectedFields={metrics} - onSelectField={this.onSelectField} - dataTestSubj="rollupJobMetricsFieldChooser" - /> - )} + + + {this.getMetricsSelectAllMenu()} + + + } + dataTestSubj="rollupJobMetricsFieldList" /> @@ -290,8 +459,30 @@ export class StepMetricsUi extends Component { return null; } - return ; - } + return ; + }; + + static chooserColumns = [ + { + field: 'name', + name: i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.fieldColumnLabel', + { defaultMessage: 'Field' } + ), + sortable: true, + width: '240px', + }, + { + field: 'type', + name: i18n.translate( + 'xpack.rollupJobs.create.stepMetrics.typeColumnLabel', + { defaultMessage: 'Type' } + ), + truncateText: true, + sortable: true, + width: '100px', + }, + ]; } export const StepMetrics = injectI18n(StepMetricsUi);