diff --git a/package.json b/package.json index da506ddc..4bb29f82 100644 --- a/package.json +++ b/package.json @@ -27,12 +27,15 @@ }, "devDependencies": { "@testing-library/user-event": "^13.1.9", + "@types/enzyme-adapter-react-16": "^1.0.6", + "@types/react-test-renderer": "^16.9.1", "cypress": "^5.0.0", "eslint": "^6.8.0", "husky": "^4.2.5", "jest-raw-loader": "^1.0.1", "lint-staged": "^10.2.0", "mutationobserver-shim": "^0.3.3", + "jest-dom": "^4.0.0", "ts-jest": "^29.1.0" }, "resolutions": { diff --git a/public/ace-themes/sql_console.js b/public/ace-themes/sql_console.js index c841db28..b0d4898c 100644 --- a/public/ace-themes/sql_console.js +++ b/public/ace-themes/sql_console.js @@ -3,10 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ - import * as ace from 'brace'; -ace.define('ace/theme/sql_console', ['require', 'exports', 'module', 'ace/lib/dom'], function (acequire, exports, module) { +ace.define('ace/theme/sql_console', ['require', 'exports', 'module', 'ace/lib/dom'], function ( + acequire, + exports, + module +) { exports.isDark = false; exports.cssClass = 'ace-sql-console'; exports.cssText = require('../index.scss'); diff --git a/public/components/acceleration/create/__tests__/__snapshots__/caution_banner_callout.test.tsx.snap b/public/components/acceleration/create/__tests__/__snapshots__/caution_banner_callout.test.tsx.snap new file mode 100644 index 00000000..891e2965 --- /dev/null +++ b/public/components/acceleration/create/__tests__/__snapshots__/caution_banner_callout.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Acceleration callout renders acceleration flyout callout 1`] = `ReactWrapper {}`; diff --git a/public/components/acceleration/create/__tests__/__snapshots__/create_acceleration.test.tsx.snap b/public/components/acceleration/create/__tests__/__snapshots__/create_acceleration.test.tsx.snap new file mode 100644 index 00000000..32dc90f7 --- /dev/null +++ b/public/components/acceleration/create/__tests__/__snapshots__/create_acceleration.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Create acceleration flyout components renders acceleration flyout component with default options 1`] = `ReactWrapper {}`; diff --git a/public/components/acceleration/create/__tests__/__snapshots__/create_acceleration_header.test.tsx.snap b/public/components/acceleration/create/__tests__/__snapshots__/create_acceleration_header.test.tsx.snap new file mode 100644 index 00000000..4b3b9f03 --- /dev/null +++ b/public/components/acceleration/create/__tests__/__snapshots__/create_acceleration_header.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Acceleration header renders acceleration flyout header 1`] = `ReactWrapper {}`; diff --git a/public/components/acceleration/create/__tests__/caution_banner_callout.test.tsx b/public/components/acceleration/create/__tests__/caution_banner_callout.test.tsx new file mode 100644 index 00000000..f3888a2b --- /dev/null +++ b/public/components/acceleration/create/__tests__/caution_banner_callout.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { CautionBannerCallout } from '../caution_banner_callout'; + +describe('Acceleration callout', () => { + configure({ adapter: new Adapter() }); + + it('renders acceleration flyout callout', async () => { + const wrapper = mount(() as React.ReactElement); + wrapper.update(); + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/acceleration/create/__tests__/create_acceleration.test.tsx b/public/components/acceleration/create/__tests__/create_acceleration.test.tsx new file mode 100644 index 00000000..1261e060 --- /dev/null +++ b/public/components/acceleration/create/__tests__/create_acceleration.test.tsx @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { CreateAcceleration } from '../create_acceleration'; + +describe('Create acceleration flyout components', () => { + configure({ adapter: new Adapter() }); + + it('renders acceleration flyout component with default options', async () => { + const dataSource = ''; + const resetFlyout = jest.fn(); + const updateQueries = jest.fn(); + const wrapper = mount( + ( + + ) as React.ReactElement + ); + wrapper.update(); + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/acceleration/create/__tests__/create_acceleration_header.test.tsx b/public/components/acceleration/create/__tests__/create_acceleration_header.test.tsx new file mode 100644 index 00000000..fe33575c --- /dev/null +++ b/public/components/acceleration/create/__tests__/create_acceleration_header.test.tsx @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { waitFor } from '@testing-library/dom'; +import { configure, mount } from 'enzyme'; +import Adapter from 'enzyme-adapter-react-16'; +import React from 'react'; +import { CreateAccelerationHeader } from '../create_acceleration_header'; + +describe('Acceleration header', () => { + configure({ adapter: new Adapter() }); + + it('renders acceleration flyout header', async () => { + const wrapper = mount(() as React.ReactElement); + wrapper.update(); + await waitFor(() => { + expect(wrapper).toMatchSnapshot(); + }); + }); +}); diff --git a/public/components/acceleration/create/__tests__/utils.test.tsx b/public/components/acceleration/create/__tests__/utils.test.tsx new file mode 100644 index 00000000..060d6385 --- /dev/null +++ b/public/components/acceleration/create/__tests__/utils.test.tsx @@ -0,0 +1,262 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ACCELERATION_INDEX_NAME_REGEX, + ACCELERATION_S3_URL_REGEX, +} from '../../../../../common/constants'; +import { + coveringIndexDataMock, + materializedViewEmptyDataMock, + materializedViewEmptyTumbleDataMock, + materializedViewStaleDataMock, + materializedViewValidDataMock, + skippingIndexDataMock, +} from '../../../../../test/mocks/accelerationMock'; +import { + pluralizeTime, + validateCheckpointLocation, + validateCoveringIndexData, + validateDataSource, + validateDataTable, + validateDatabase, + validateIndexName, + validateMaterializedViewData, + validatePrimaryShardCount, + validateRefreshInterval, + validateReplicaCount, + validateSkippingIndexData, +} from '../utils'; + +describe('pluralizeTime', () => { + it('should return "s" for a time window greater than 1', () => { + expect(pluralizeTime(2)).toBe('s'); + expect(pluralizeTime(10)).toBe('s'); + expect(pluralizeTime(100)).toBe('s'); + }); + + it('should return an empty string for a time window of 1/0', () => { + expect(pluralizeTime(1)).toBe(''); + expect(pluralizeTime(0)).toBe(''); // form throws validation error, doesn't allow user to proceed + }); +}); + +describe('validateDataSource', () => { + it('should return an array with an error message when the dataSource is empty', () => { + expect(validateDataSource('')).toEqual(['Select a valid data source']); + expect(validateDataSource(' ')).toEqual(['Select a valid data source']); + }); + + it('should return an empty array when the dataSource is not empty', () => { + expect(validateDataSource('Some_valid_data_source')).toEqual([]); + expect(validateDataSource(' Some_valid_data_source ')).toEqual([]); + }); +}); + +describe('validateDatabase', () => { + it('should return an array with an error message when the database is empty', () => { + expect(validateDatabase('')).toEqual(['Select a valid database']); + expect(validateDatabase(' ')).toEqual(['Select a valid database']); + }); + + it('should return an empty array when the database is not empty', () => { + expect(validateDatabase('Some_valid_database')).toEqual([]); + expect(validateDatabase(' Some_valid_database ')).toEqual([]); + }); +}); + +describe('validateDataTable', () => { + it('should return an array with an error message when the dataTable is empty', () => { + expect(validateDataTable('')).toEqual(['Select a valid table']); + expect(validateDataTable(' ')).toEqual(['Select a valid table']); + }); + + it('should return an empty array when the dataTable is not empty', () => { + expect(validateDataTable('Some_valid_table')).toEqual([]); + expect(validateDataTable(' Some_valid_table ')).toEqual([]); + }); +}); + +describe('validatePrimaryShardCount', () => { + it('should return an array with an error message when primaryShardCount is less than 1', () => { + expect(validatePrimaryShardCount(0)).toEqual(['Primary shards count should be greater than 0']); + expect(validatePrimaryShardCount(-1)).toEqual([ + 'Primary shards count should be greater than 0', + ]); // form throws validation error, doesn't allow user to proceed + }); + + it('should return an empty array when primaryShardCount is greater than or equal to 1', () => { + expect(validatePrimaryShardCount(1)).toEqual([]); + expect(validatePrimaryShardCount(5)).toEqual([]); + expect(validatePrimaryShardCount(100)).toEqual([]); + }); +}); + +describe('validateReplicaCount', () => { + it('should return an array with an error message when replicaCount is less than 1', () => { + expect(validateReplicaCount(0)).toEqual(['Replica count should be greater than 0']); + expect(validateReplicaCount(-1)).toEqual(['Replica count should be greater than 0']); // form throws validation error, doesn't allow user to proceed + }); + + it('should return an empty array when replicaCount is greater than or equal to 1', () => { + expect(validateReplicaCount(1)).toEqual([]); + expect(validateReplicaCount(5)).toEqual([]); + expect(validateReplicaCount(100)).toEqual([]); + }); +}); + +describe('validateRefreshInterval', () => { + it('should return an array with an error message when refreshType is "interval" and refreshWindow is less than 1', () => { + expect(validateRefreshInterval('interval', 0)).toEqual([ + 'refresh window should be greater than 0', + ]); + expect(validateRefreshInterval('interval', -1)).toEqual([ + 'refresh window should be greater than 0', + ]); + expect(validateRefreshInterval('interval', -10)).toEqual([ + 'refresh window should be greater than 0', + ]); + }); + + it('should return an empty array when refreshType is not "interval" or when refreshWindow is greater than or equal to 1', () => { + expect(validateRefreshInterval('auto', 0)).toEqual([]); + expect(validateRefreshInterval('auto', 1)).toEqual([]); + expect(validateRefreshInterval('interval', 1)).toEqual([]); + expect(validateRefreshInterval('auto', 5)).toEqual([]); + }); +}); + +describe('validateIndexName', () => { + it('should return an array with an error message when the index name is invalid', () => { + expect(validateIndexName('_invalid')).toEqual(['Enter a valid index name']); + expect(validateIndexName('-invalid')).toEqual(['Enter a valid index name']); + expect(validateIndexName('InVal1d')).toEqual(['Enter a valid index name']); + expect(validateIndexName('invalid_with spaces')).toEqual(['Enter a valid index name']); + }); + + it('should return an empty array when the index name is valid', () => { + expect(validateIndexName('valid')).toEqual([]); + expect(validateIndexName('valid_name')).toEqual([]); + expect(validateIndexName('another-valid-name')).toEqual([]); + }); + + it('should use the ACCELERATION_INDEX_NAME_REGEX pattern to validate the index name', () => { + expect(ACCELERATION_INDEX_NAME_REGEX.test('valid_name')).toBe(true); + expect(ACCELERATION_INDEX_NAME_REGEX.test('invalid name')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('-invalid')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('_invalid')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('invalid.')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('invalid<')).toBe(false); + expect(ACCELERATION_INDEX_NAME_REGEX.test('invalid*')).toBe(false); + }); +}); + +describe('validateCheckpointLocation', () => { + it('should return an array with an error message when creating a materialized view without a checkpoint location', () => { + const materializedError = validateCheckpointLocation('materialized', undefined); + expect(materializedError).toEqual([ + 'Checkpoint location is mandatory for materialized view creation', + ]); + }); + + it('should return an array with an error message when creating a materialized view without a checkpoint location', () => { + const materializedError = validateCheckpointLocation('materialized', ''); + expect(materializedError).toEqual([ + 'Checkpoint location is mandatory for materialized view creation', + ]); + }); + + it('should return an array with an error message when the checkpoint location is not a valid S3 URL', () => { + const invalidCheckpoint = validateCheckpointLocation('skipping', 'not_a_valid_s3_url'); + expect(invalidCheckpoint).toEqual(['Enter a valid checkpoint location']); + }); + + it('should return an empty array when the checkpoint location is a valid S3 URL', () => { + const validCheckpoint = validateCheckpointLocation( + 'covering', + 's3://valid-s3-bucket/path/to/checkpoint' + ); + expect(validCheckpoint).toEqual([]); + }); + + it('should return an empty array when the checkpoint location is a valid S3A URL', () => { + const validCheckpoint = validateCheckpointLocation( + 'skipping', + 's3a://valid-s3-bucket/path/to/checkpoint' + ); + expect(validCheckpoint).toEqual([]); + }); + + it('should return an empty array when creating a materialized view with a valid checkpoint location', () => { + const validMaterializedCheckpoint = validateCheckpointLocation( + 'materialized', + 's3://valid-s3-bucket/path/to/checkpoint' + ); + expect(validMaterializedCheckpoint).toEqual([]); + }); + + it('should use the ACCELERATION_S3_URL_REGEX pattern to validate the checkpoint location', () => { + expect(ACCELERATION_S3_URL_REGEX.test('s3://valid-s3-bucket/path/to/checkpoint')).toBe(true); + expect(ACCELERATION_S3_URL_REGEX.test('s3a://valid-s3-bucket/path/to/checkpoint')).toBe(true); + expect(ACCELERATION_S3_URL_REGEX.test('https://amazon.com')).toBe(false); + expect(ACCELERATION_S3_URL_REGEX.test('http://www.amazon.com')).toBe(false); + }); +}); + +describe('validateSkippingIndexData', () => { + it('should return an array with an error message when accelerationIndexType is "skipping" and no skipping index data is provided', () => { + const error = validateSkippingIndexData('skipping', []); + expect(error).toEqual(['Add fields to the skipping index definition']); + }); + + it('should return an empty array when accelerationIndexType is not "skipping"', () => { + const noError = validateSkippingIndexData('covering', []); + expect(noError).toEqual([]); + }); + + it('should return an empty array when accelerationIndexType is "skipping" and skipping index data is provided', () => { + const noError = validateSkippingIndexData('skipping', skippingIndexDataMock); + expect(noError).toEqual([]); + }); +}); + +describe('validateCoveringIndexData', () => { + it('should return an array with an error message when accelerationIndexType is "covering" and no covering index data is provided', () => { + const error = validateCoveringIndexData('covering', []); + expect(error).toEqual(['Add fields to covering index definition']); + }); + + it('should return an empty array when accelerationIndexType is not "covering"', () => { + const noError = validateCoveringIndexData('skipping', []); + expect(noError).toEqual([]); + }); + + it('should return an empty array when accelerationIndexType is "covering" and covering index data is provided', () => { + const noError = validateCoveringIndexData('covering', coveringIndexDataMock); + expect(noError).toEqual([]); + }); +}); + +describe('validateMaterializedViewData', () => { + it('should return an array with an error message when accelerationIndexType is "materialized" and no materialized view data is provided', () => { + const error = validateMaterializedViewData('materialized', materializedViewEmptyDataMock); + expect(error).toEqual(['Add columns to materialized view definition']); + }); + + it('should return an array with an error message when accelerationIndexType is "materialized" and groupByTumbleValue is incomplete', () => { + const error = validateMaterializedViewData('materialized', materializedViewEmptyTumbleDataMock); + expect(error).toEqual(['Add a time field to tumble function in materialized view definition']); + }); + + it('should return an empty array when accelerationIndexType is not "materialized"', () => { + const noError = validateMaterializedViewData('covering', materializedViewStaleDataMock); + expect(noError).toEqual([]); + }); + + it('should return an empty array when accelerationIndexType is "materialized" and materialized view data is complete', () => { + const noError = validateMaterializedViewData('materialized', materializedViewValidDataMock); + expect(noError).toEqual([]); + }); +}); diff --git a/public/components/acceleration/create/create_acceleration.tsx b/public/components/acceleration/create/create_acceleration.tsx index f667661c..8dae138b 100644 --- a/public/components/acceleration/create/create_acceleration.tsx +++ b/public/components/acceleration/create/create_acceleration.tsx @@ -103,7 +103,7 @@ export const CreateAcceleration = ({ 0) + if (materializedViewQueryData.groupByTumbleValue.tumbleWindow < 1) return ['Add a valid time window to tumble function in materialized view definition']; return []; }; diff --git a/public/components/acceleration/visual_editors/covering_index/covering_index_builder.tsx b/public/components/acceleration/visual_editors/covering_index/covering_index_builder.tsx index 0335f498..5968ca01 100644 --- a/public/components/acceleration/visual_editors/covering_index/covering_index_builder.tsx +++ b/public/components/acceleration/visual_editors/covering_index/covering_index_builder.tsx @@ -17,6 +17,7 @@ import { import React, { useState } from 'react'; import { ACCELERATION_ADD_FIELDS_TEXT } from '../../../../../common/constants'; import { CreateAccelerationForm } from '../../../../../common/types'; +import { hasError } from '../../create/utils'; interface CoveringIndexBuilderProps { accelerationFormData: CreateAccelerationForm; @@ -74,7 +75,10 @@ export const CoveringIndexBuilder = ({ value={columnsValue} isActive={isPopOverOpen} onClick={() => setIsPopOverOpen(true)} - isInvalid={columnsValue === ACCELERATION_ADD_FIELDS_TEXT} + isInvalid={ + hasError(accelerationFormData.formErrors, 'coveringIndexError') && + columnsValue === ACCELERATION_ADD_FIELDS_TEXT + } /> } isOpen={isPopOverOpen} diff --git a/public/components/acceleration/visual_editors/materialized_view/add_column_popover.tsx b/public/components/acceleration/visual_editors/materialized_view/add_column_popover.tsx index 2eb28d1d..df6b276f 100644 --- a/public/components/acceleration/visual_editors/materialized_view/add_column_popover.tsx +++ b/public/components/acceleration/visual_editors/materialized_view/add_column_popover.tsx @@ -18,6 +18,7 @@ import { htmlIdGenerator, } from '@elastic/eui'; import { EuiComboBoxOptionOption } from '@opensearch-project/oui'; +import producer from 'immer'; import React, { ChangeEvent, useEffect, useState } from 'react'; import { ACCELERATION_AGGREGRATION_FUNCTIONS } from '../../../../../common/constants'; import { @@ -25,6 +26,7 @@ import { CreateAccelerationForm, MaterializedViewColumn, } from '../../../../../common/types'; +import { validateMaterializedViewData } from '../../create/utils'; interface AddColumnPopOverProps { isColumnPopOverOpen: boolean; @@ -32,6 +34,7 @@ interface AddColumnPopOverProps { columnExpressionValues: MaterializedViewColumn[]; setColumnExpressionValues: React.Dispatch>; accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; } export const AddColumnPopOver = ({ @@ -40,6 +43,7 @@ export const AddColumnPopOver = ({ columnExpressionValues, setColumnExpressionValues, accelerationFormData, + setAccelerationFormData, }: AddColumnPopOverProps) => { const [selectedFunction, setSelectedFunction] = useState([ ACCELERATION_AGGREGRATION_FUNCTIONS[0], @@ -64,6 +68,31 @@ export const AddColumnPopOver = ({ setSeletedAlias(e.target.value); }; + const onAddExpression = () => { + const newColumnExpresionValue = [ + ...columnExpressionValues, + { + id: htmlIdGenerator()(), + functionName: selectedFunction[0].label as AggregationFunctionType, + functionParam: selectedField[0].label, + fieldAlias: selectedAlias, + }, + ]; + + setAccelerationFormData( + producer((accData) => { + accData.materializedViewQueryData.columnsValues = newColumnExpresionValue; + accData.formErrors.materializedViewError = validateMaterializedViewData( + accData.accelerationIndexType, + accData.materializedViewQueryData + ); + }) + ); + + setColumnExpressionValues(newColumnExpresionValue); + setIsColumnPopOverOpen(false); + }; + useEffect(() => { resetSelectedField(); }, []); @@ -97,6 +126,7 @@ export const AddColumnPopOver = ({ options={ACCELERATION_AGGREGRATION_FUNCTIONS} selectedOptions={selectedFunction} onChange={setSelectedFunction} + isClearable={false} /> @@ -110,6 +140,7 @@ export const AddColumnPopOver = ({ ]} selectedOptions={selectedField} onChange={setSelectedField} + isClearable={false} /> @@ -120,22 +151,7 @@ export const AddColumnPopOver = ({ - { - setColumnExpressionValues([ - ...columnExpressionValues, - { - id: htmlIdGenerator()(), - functionName: selectedFunction[0].label as AggregationFunctionType, - functionParam: selectedField[0].label, - fieldAlias: selectedAlias, - }, - ]); - setIsColumnPopOverOpen(false); - }} - > + Add diff --git a/public/components/acceleration/visual_editors/materialized_view/column_expression.tsx b/public/components/acceleration/visual_editors/materialized_view/column_expression.tsx index 0da3d65b..5f9a0909 100644 --- a/public/components/acceleration/visual_editors/materialized_view/column_expression.tsx +++ b/public/components/acceleration/visual_editors/materialized_view/column_expression.tsx @@ -13,6 +13,7 @@ import { EuiFormRow, EuiPopover, } from '@elastic/eui'; +import producer from 'immer'; import _ from 'lodash'; import React, { useState } from 'react'; import { ACCELERATION_AGGREGRATION_FUNCTIONS } from '../../../../../common/constants'; @@ -21,6 +22,7 @@ import { CreateAccelerationForm, MaterializedViewColumn, } from '../../../../../common/types'; +import { validateMaterializedViewData } from '../../create/utils'; interface ColumnExpressionProps { index: number; @@ -28,6 +30,7 @@ interface ColumnExpressionProps { columnExpressionValues: MaterializedViewColumn[]; setColumnExpressionValues: React.Dispatch>; accelerationFormData: CreateAccelerationForm; + setAccelerationFormData: React.Dispatch>; } export const ColumnExpression = ({ @@ -36,6 +39,7 @@ export const ColumnExpression = ({ columnExpressionValues, setColumnExpressionValues, accelerationFormData, + setAccelerationFormData, }: ColumnExpressionProps) => { const [isFunctionPopOverOpen, setIsFunctionPopOverOpen] = useState(false); const [isAliasPopOverOpen, setIsAliasPopOverOpen] = useState(false); @@ -46,6 +50,22 @@ export const ColumnExpression = ({ setColumnExpressionValues(updatedArray); }; + const onDeleteColumnExpression = () => { + const newColumnExpresionValue = [ + ..._.filter(columnExpressionValues, (o) => o.id !== currentColumnExpressionValue.id), + ]; + setAccelerationFormData( + producer((accData) => { + accData.materializedViewQueryData.columnsValues = newColumnExpresionValue; + accData.formErrors.materializedViewError = validateMaterializedViewData( + accData.accelerationIndexType, + accData.materializedViewQueryData + ); + }) + ); + setColumnExpressionValues(newColumnExpresionValue); + }; + return ( @@ -89,6 +109,7 @@ export const ColumnExpression = ({ index ) } + isClearable={false} /> @@ -116,6 +137,7 @@ export const ColumnExpression = ({ index ) } + isClearable={false} /> @@ -162,14 +184,7 @@ export const ColumnExpression = ({ { - setColumnExpressionValues([ - ..._.filter( - columnExpressionValues, - (o) => o.id !== currentColumnExpressionValue.id - ), - ]); - }} + onClick={onDeleteColumnExpression} iconType="trash" aria-label="delete-column-expression" /> diff --git a/public/components/acceleration/visual_editors/materialized_view/group_by_tumble_expression.tsx b/public/components/acceleration/visual_editors/materialized_view/group_by_tumble_expression.tsx index dc853414..e1e25339 100644 --- a/public/components/acceleration/visual_editors/materialized_view/group_by_tumble_expression.tsx +++ b/public/components/acceleration/visual_editors/materialized_view/group_by_tumble_expression.tsx @@ -14,10 +14,11 @@ import { EuiPopover, EuiSelect, } from '@elastic/eui'; -import React, { useEffect, useState } from 'react'; +import producer from 'immer'; +import React, { useState } from 'react'; import { ACCELERATION_TIME_INTERVAL } from '../../../../../common/constants'; import { CreateAccelerationForm, GroupByTumbleType } from '../../../../../common/types'; -import { pluralizeTime } from '../../create/utils'; +import { hasError, pluralizeTime, validateMaterializedViewData } from '../../create/utils'; interface GroupByTumbleExpressionProps { accelerationFormData: CreateAccelerationForm; @@ -35,29 +36,36 @@ export const GroupByTumbleExpression = ({ tumbleInterval: ACCELERATION_TIME_INTERVAL[0].value, }); + const updateGroupByStates = (newGroupByValue: GroupByTumbleType) => { + setGroupByValues(newGroupByValue); + setAccelerationFormData( + producer((accData) => { + accData.materializedViewQueryData.groupByTumbleValue = newGroupByValue; + accData.formErrors.materializedViewError = validateMaterializedViewData( + accData.accelerationIndexType, + accData.materializedViewQueryData + ); + }) + ); + }; + const onChangeTumbleWindow = (e: React.ChangeEvent) => { - setGroupByValues({ ...groupbyValues, tumbleWindow: +e.target.value }); + const newGroupByValue = { ...groupbyValues, tumbleWindow: +e.target.value }; + updateGroupByStates(newGroupByValue); }; const onChangeTumbleInterval = (e: React.ChangeEvent) => { - setGroupByValues({ ...groupbyValues, tumbleInterval: e.target.value }); + const newGroupByValue = { ...groupbyValues, tumbleInterval: e.target.value }; + updateGroupByStates(newGroupByValue); }; const onChangeTimeField = (selectedOptions: EuiComboBoxOptionOption[]) => { - if (selectedOptions.length > 0) - setGroupByValues({ ...groupbyValues, timeField: selectedOptions[0].label }); + if (selectedOptions.length > 0) { + const newGroupByValue = { ...groupbyValues, timeField: selectedOptions[0].label }; + updateGroupByStates(newGroupByValue); + } }; - useEffect(() => { - setAccelerationFormData({ - ...accelerationFormData, - materializedViewQueryData: { - ...accelerationFormData.materializedViewQueryData, - groupByTumbleValue: groupbyValues, - }, - }); - }, [groupbyValues]); - return ( setIsGroupPopOverOpen(true)} - isInvalid={groupbyValues.timeField === ''} + isInvalid={ + hasError(accelerationFormData.formErrors, 'materializedViewError') && + groupbyValues.timeField === '' + } /> } isOpen={IsGroupPopOverOpen} @@ -89,6 +100,7 @@ export const GroupByTumbleExpression = ({ .map((value) => ({ label: value.fieldName }))} selectedOptions={[{ label: groupbyValues.timeField }]} onChange={onChangeTimeField} + isClearable={false} /> diff --git a/public/components/acceleration/visual_editors/materialized_view/materialized_view_builder.tsx b/public/components/acceleration/visual_editors/materialized_view/materialized_view_builder.tsx index 0f3bdeea..124b451c 100644 --- a/public/components/acceleration/visual_editors/materialized_view/materialized_view_builder.tsx +++ b/public/components/acceleration/visual_editors/materialized_view/materialized_view_builder.tsx @@ -11,9 +11,15 @@ import { EuiText, htmlIdGenerator, } from '@elastic/eui'; +import producer from 'immer'; import _ from 'lodash'; import React, { useEffect, useState } from 'react'; -import { CreateAccelerationForm, MaterializedViewColumn } from '../../../../../common/types'; +import { + AggregationFunctionType, + CreateAccelerationForm, + MaterializedViewColumn, +} from '../../../../../common/types'; +import { hasError, validateMaterializedViewData } from '../../create/utils'; import { AddColumnPopOver } from './add_column_popover'; import { ColumnExpression } from './column_expression'; import { GroupByTumbleExpression } from './group_by_tumble_expression'; @@ -36,27 +42,27 @@ export const MaterializedViewBuilder = ({ useEffect(() => { if (accelerationFormData.dataTableFields.length > 0) { - setColumnExpressionValues([ + const newColumnExpresionValue = [ { id: newColumnExpressionId, - functionName: 'count', + functionName: 'count' as AggregationFunctionType, functionParam: accelerationFormData.dataTableFields[0].fieldName, fieldAlias: 'counter1', }, - ]); + ]; + setAccelerationFormData( + producer((accData) => { + accData.materializedViewQueryData.columnsValues = newColumnExpresionValue; + accData.formErrors.materializedViewError = validateMaterializedViewData( + accData.accelerationIndexType, + accData.materializedViewQueryData + ); + }) + ); + setColumnExpressionValues(newColumnExpresionValue); } }, [accelerationFormData.dataTableFields]); - useEffect(() => { - setAccelerationFormData({ - ...accelerationFormData, - materializedViewQueryData: { - ...accelerationFormData.materializedViewQueryData, - columnsValues: columnExpressionValues, - }, - }); - }, [columnExpressionValues]); - return ( <> @@ -74,7 +80,15 @@ export const MaterializedViewBuilder = ({ - + @@ -95,6 +110,7 @@ export const MaterializedViewBuilder = ({ columnExpressionValues={columnExpressionValues} setColumnExpressionValues={setColumnExpressionValues} accelerationFormData={accelerationFormData} + setAccelerationFormData={setAccelerationFormData} /> ); })} diff --git a/public/plugin.ts b/public/plugin.ts index 10836ae1..213f4142 100644 --- a/public/plugin.ts +++ b/public/plugin.ts @@ -4,8 +4,8 @@ */ import { AppMountParameters, CoreSetup, CoreStart, Plugin } from '../../../src/core/public'; -import { WorkbenchPluginSetup, WorkbenchPluginStart, AppPluginStartDependencies } from './types'; import { PLUGIN_NAME } from '../common/constants'; +import { AppPluginStartDependencies, WorkbenchPluginSetup, WorkbenchPluginStart } from './types'; export class WorkbenchPlugin implements Plugin { public setup(core: CoreSetup): WorkbenchPluginSetup { diff --git a/test/mocks/accelerationMock.ts b/test/mocks/accelerationMock.ts new file mode 100644 index 00000000..5356d610 --- /dev/null +++ b/test/mocks/accelerationMock.ts @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SkippingIndexRowType, materializedViewQueryType } from '../../common/types'; + +export const skippingIndexDataMock: SkippingIndexRowType[] = [ + { + id: '1', + fieldName: 'field1', + dataType: 'string', + accelerationMethod: 'PARTITION', + }, + { + id: '2', + fieldName: 'field2', + dataType: 'number', + accelerationMethod: 'VALUE_SET', + }, +]; + +export const coveringIndexDataMock: string[] = ['field1', 'field2', 'field3']; + +export const materializedViewEmptyDataMock = { + columnsValues: [], + groupByTumbleValue: { + timeField: '', + tumbleWindow: 0, + tumbleInterval: '', + }, +}; + +export const materializedViewEmptyTumbleDataMock: materializedViewQueryType = { + columnsValues: [ + { + id: '1', + functionName: 'count', + functionParam: 'field1', + }, + ], + groupByTumbleValue: { + timeField: '', + tumbleWindow: 0, + tumbleInterval: 'second', + }, +}; + +export const materializedViewStaleDataMock: materializedViewQueryType = { + columnsValues: [], + groupByTumbleValue: { + timeField: 'timestamp', + tumbleWindow: 10, + tumbleInterval: 'hour', + }, +}; + +export const materializedViewValidDataMock: materializedViewQueryType = { + columnsValues: [ + { + id: '1', + functionName: 'count', + functionParam: 'field1', + }, + { + id: '2', + functionName: 'sum', + functionParam: 'field2', + }, + ], + groupByTumbleValue: { + timeField: 'timestamp', + tumbleWindow: 5, + tumbleInterval: 'hour', + }, +};