diff --git a/.node-version b/.node-version index 6d80269a4f04..a9d087399d71 100644 --- a/.node-version +++ b/.node-version @@ -1 +1 @@ -18.16.0 +18.19.0 diff --git a/.nvmrc b/.nvmrc index 6d80269a4f04..a9d087399d71 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.16.0 +18.19.0 diff --git a/CHANGELOG.md b/CHANGELOG.md index 34bd61150acc..73853de5bc75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Chrome] Introduce registerCollapsibleNavHeader to allow plugins to customize the rendering of nav menu header ([#5244](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5244)) - [PM] Enhance single version requirements imposed during bootstrapping ([#5675](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5675)) - [Custom Branding] Relative URL should be allowed for logos ([#5572](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5572)) +- Revert to legacy discover table and add toggle to new discover table ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) +- [Discover] Add collapsible and resizeable sidebar ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) - [Discover] Enhanced the data source selector with added sorting functionality ([#5609](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5609)) - [Multiple Datasource] Add datasource picker component and use it in devtools and tutorial page when multiple datasource is enabled ([#5756](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5756)) - [Multiple Datasource] Add datasource picker to import saved object flyout when multiple data source is enabled ([#5781](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5781)) @@ -55,8 +57,15 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [BUG][Discover] Fix advanced setting `discover:modifyColumnsOnSwitch` ([#5508](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5508)) - [BUG][Discover] Show 0 filters when there are no active filters ([#5508](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5508)) - [Discover] Fix missing index pattern field from breaking Discover [#5626](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5626) +- [BUG][Discover] Fix Discover table panel not adjusting its size automatically when the time range changes ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) +- [BUG] Fix issue where changing from a search with few results to a search with more results keeps the number of rows from the previous search ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) +- [BUG] Fix copying data from columns in Discover including extra data ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) +- [BUG] Fix no line wrapping when displaying fields in Discover datagrid ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) +- [BUG] Fix 'truncate:maxHeight' not working in Discover since 2.10.0 ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) +- [BUG] Fix UI glitch when mouseover Discover datagrid element ([#5789](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5789)) - [BUG] Remove duplicate sample data as id 90943e30-9a47-11e8-b64d-95841ca0b247 ([5668](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5668)) - [BUG][Multiple Datasource] Fix datasource testing connection unexpectedly passed with wrong endpoint [#5663](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5663) +- [Table Visualization] Fix filter action buttons for split table aggregations ([#5619](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5619)) - [BUG][Multiple Datasource] Datasource id is required if multiple datasource is enabled and no default cluster supported [#5751](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5751) ### 🚞 Infrastructure @@ -235,6 +244,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Bump OpenSearch-Dashboards 2.10.0 to use nodejs 18.16.0 version ([#4948](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4948)) - Bump `oui` to `1.3.0` ([#4941](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4941)) - Add @curq as maintainer ([#4760](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4760)) +- Bump OpenSearch Dashboards to use nodejs v18.19.0 ([#4948](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5830)) ### 🪛 Refactoring @@ -347,6 +357,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Dashboard De-Angular] Add more unit tests for utils folder ([#4641](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4641)) - [Dashboard De-Angular] Add unit tests for dashboard_listing and dashboard_top_nav ([#4640](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4640)) - Optimize `augment-vis` saved obj searching by adding arg to saved obj client ([#4595](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4595)) +- Change SavedObjects' Import API to allow selecting a data source when uploading files ([#5777](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5777)) ### 🐛 Bug Fixes diff --git a/Dockerfile b/Dockerfile index 215f77d888b4..960321ef1051 100755 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -ARG NODE_VERSION=18.16.0 +ARG NODE_VERSION=18.19.0 FROM node:${NODE_VERSION} AS base ENV HOME '.' diff --git a/TESTING.md b/TESTING.md index 95dc9495309a..64f10127538b 100644 --- a/TESTING.md +++ b/TESTING.md @@ -24,7 +24,7 @@ In general, we recommend four tiers of tests: # Requirements * Install the latest NodeJS, [NPM](https://www.npmjs.com/get-npm) and [Yarn](https://classic.yarnpkg.com/en/docs/install/#mac-stable) - * `nvm install v14.20.1` + * `nvm install v18.19.0` * `npm install -g yarn` # Running tests diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 189344ff1481..e1adad20eb33 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -274,4 +274,4 @@ # opensearchDashboards.survey.url: "https://survey.opensearch.org" # Set the value of this setting to true to enable plugin augmentation on Dashboard -# vis_augmenter.pluginAugmentationEnabled: true +# vis_augmenter.pluginAugmentationEnabled: true \ No newline at end of file diff --git a/packages/osd-stylelint-config/config/global_selectors.json b/packages/osd-stylelint-config/config/global_selectors.json index ef2ec5f9252b..285f11f40a2b 100644 --- a/packages/osd-stylelint-config/config/global_selectors.json +++ b/packages/osd-stylelint-config/config/global_selectors.json @@ -24,7 +24,8 @@ "src/plugins/vis_builder/public/application/components/side_nav.scss", "packages/osd-ui-framework/src/components/button/button_group/_button_group.scss", "src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.scss", - "src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss" + "src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss", + "src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss" ] } } \ No newline at end of file diff --git a/packages/osd-ui-shared-deps/package.json b/packages/osd-ui-shared-deps/package.json index 417766aa2340..d2c9c5f020be 100644 --- a/packages/osd-ui-shared-deps/package.json +++ b/packages/osd-ui-shared-deps/package.json @@ -52,4 +52,3 @@ "webpack": "npm:@amoo-miki/webpack@4.46.0-rc.2" } } - diff --git a/src/core/server/core_app/assets/legacy_dark_theme.css b/src/core/server/core_app/assets/legacy_dark_theme.css index 4ef4c726e414..b56eeed717a1 100644 --- a/src/core/server/core_app/assets/legacy_dark_theme.css +++ b/src/core/server/core_app/assets/legacy_dark_theme.css @@ -802,7 +802,7 @@ width: 100%; max-width: 100%; margin-bottom: 20px; - font-size: 14px; + font-size: 12px; } .table thead { font-size: 12px; diff --git a/src/core/server/core_app/assets/legacy_light_theme.css b/src/core/server/core_app/assets/legacy_light_theme.css index 9f9a0dc118d1..d4f6d10e7022 100644 --- a/src/core/server/core_app/assets/legacy_light_theme.css +++ b/src/core/server/core_app/assets/legacy_light_theme.css @@ -802,7 +802,7 @@ width: 100%; max-width: 100%; margin-bottom: 20px; - font-size: 14px; + font-size: 12px; } .table thead { font-size: 12px; diff --git a/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts b/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts new file mode 100644 index 000000000000..4bf3a8110454 --- /dev/null +++ b/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts @@ -0,0 +1,150 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { mockUuidv4 } from './__mocks__'; +import { SavedObjectReference, SavedObjectsImportRetry } from 'opensearch-dashboards/public'; +import { SavedObject } from '../types'; +import { SavedObjectsErrorHelpers } from '..'; +import { + checkConflictsForDataSource, + ConflictsForDataSourceParams, +} from './check_conflict_for_data_source'; + +type SavedObjectType = SavedObject<{ title?: string }>; + +/** + * Function to create a realistic-looking import object given a type and ID + */ +const createObject = (type: string, id: string): SavedObjectType => ({ + type, + id, + attributes: { title: 'some-title' }, + references: (Symbol() as unknown) as SavedObjectReference[], +}); + +const getResultMock = { + conflict: (type: string, id: string) => { + const error = SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; + return { type, id, error }; + }, + unresolvableConflict: (type: string, id: string) => { + const conflictMock = getResultMock.conflict(type, id); + const metadata = { isNotOverwritable: true }; + return { ...conflictMock, error: { ...conflictMock.error, metadata } }; + }, + invalidType: (type: string, id: string) => { + const error = SavedObjectsErrorHelpers.createUnsupportedTypeError(type).output.payload; + return { type, id, error }; + }, +}; + +/** + * Create a variety of different objects to exercise different import / result scenarios + */ +const dataSourceObj = createObject('data-source', 'data-source-id-1'); // -> data-source type, no need to add in the filteredObjects +const dataSourceObj1 = createObject('type-1', 'ds_id-1'); // -> object with data source id +const dataSourceObj2 = createObject('type-2', 'ds_id-2'); // -> object with data source id +const objectsWithDataSource = [dataSourceObj, dataSourceObj1, dataSourceObj2]; +const dataSourceObj1Error = getResultMock.conflict(dataSourceObj1.type, dataSourceObj1.id); + +describe('#checkConflictsForDataSource', () => { + const setupParams = (partial: { + objects: SavedObjectType[]; + ignoreRegularConflicts?: boolean; + retries?: SavedObjectsImportRetry[]; + createNewCopies?: boolean; + dataSourceId?: string; + }): ConflictsForDataSourceParams => { + return { ...partial }; + }; + + beforeEach(() => { + mockUuidv4.mockReset(); + mockUuidv4.mockReturnValueOnce(`new-object-id`); + }); + + it('exits early if there are no objects to check', async () => { + const params = setupParams({ objects: [] }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + expect(checkConflictsForDataSourceResult).toEqual({ + filteredObjects: [], + errors: [], + importIdMap: new Map(), + pendingOverwrites: new Set(), + }); + }); + + it('return obj if it is not data source obj and there is no conflict of the data source id', async () => { + const params = setupParams({ objects: objectsWithDataSource, dataSourceId: 'ds' }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + expect(checkConflictsForDataSourceResult).toEqual({ + filteredObjects: [dataSourceObj1, dataSourceObj2], + errors: [], + importIdMap: new Map(), + pendingOverwrites: new Set(), + }); + }); + + it('can resolve the data source id conflict when the ds it not match when ignoreRegularConflicts=true', async () => { + const params = setupParams({ + objects: objectsWithDataSource, + ignoreRegularConflicts: true, + dataSourceId: 'currentDsId', + }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + + expect(checkConflictsForDataSourceResult).toEqual( + expect.objectContaining({ + filteredObjects: [ + { + ...dataSourceObj1, + id: 'currentDsId_id-1', + }, + { + ...dataSourceObj2, + id: 'currentDsId_id-2', + }, + ], + errors: [], + importIdMap: new Map([ + [ + `${dataSourceObj1.type}:${dataSourceObj1.id}`, + { id: 'currentDsId_id-1', omitOriginId: true }, + ], + [ + `${dataSourceObj2.type}:${dataSourceObj2.id}`, + { id: 'currentDsId_id-2', omitOriginId: true }, + ], + ]), + pendingOverwrites: new Set([ + `${dataSourceObj1.type}:${dataSourceObj1.id}`, + `${dataSourceObj2.type}:${dataSourceObj2.id}`, + ]), + }) + ); + }); + + it('can push error when do not override with data source conflict', async () => { + const params = setupParams({ + objects: [dataSourceObj1], + ignoreRegularConflicts: false, + dataSourceId: 'currentDs', + }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + expect(checkConflictsForDataSourceResult).toEqual({ + filteredObjects: [], + errors: [ + { + ...dataSourceObj1Error, + title: dataSourceObj1.attributes.title, + meta: { title: dataSourceObj1.attributes.title }, + error: { type: 'conflict' }, + }, + ], + importIdMap: new Map(), + pendingOverwrites: new Set(), + }); + }); +}); diff --git a/src/core/server/saved_objects/import/check_conflict_for_data_source.ts b/src/core/server/saved_objects/import/check_conflict_for_data_source.ts new file mode 100644 index 000000000000..50005777aba0 --- /dev/null +++ b/src/core/server/saved_objects/import/check_conflict_for_data_source.ts @@ -0,0 +1,85 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObject, SavedObjectsImportError, SavedObjectsImportRetry } from '../types'; + +export interface ConflictsForDataSourceParams { + objects: Array>; + ignoreRegularConflicts?: boolean; + retries?: SavedObjectsImportRetry[]; + dataSourceId?: string; +} + +interface ImportIdMapEntry { + id?: string; + omitOriginId?: boolean; +} + +/** + * function to check the conflict when multiple data sources are enabled. + * the purpose of this function is to check the conflict of the imported saved objects and data source + * @param objects, this the array of saved objects to be verified whether contains the data source conflict + * @param ignoreRegularConflicts whether to override + * @param retries import operations list + * @param dataSourceId the id to identify the data source + * @returns {filteredObjects, errors, importIdMap, pendingOverwrites } + */ +export async function checkConflictsForDataSource({ + objects, + ignoreRegularConflicts, + retries = [], + dataSourceId, +}: ConflictsForDataSourceParams) { + const filteredObjects: Array> = []; + const errors: SavedObjectsImportError[] = []; + const importIdMap = new Map(); + const pendingOverwrites = new Set(); + + // exit early if there are no objects to check + if (objects.length === 0) { + return { filteredObjects, errors, importIdMap, pendingOverwrites }; + } + const retryMap = retries.reduce( + (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), + new Map() + ); + objects.forEach((object) => { + const { + type, + id, + attributes: { title }, + } = object; + const { destinationId } = retryMap.get(`${type}:${id}`) || {}; + + if (object.type !== 'data-source') { + const parts = id.split('_'); // this is the array to host the split results of the id + const previoudDataSourceId = parts.length > 1 ? parts[0] : undefined; + const rawId = previoudDataSourceId ? parts[1] : parts[0]; + + /** + * for import saved object from osd exported + * when the imported saved objects with the different dataSourceId comparing to the current dataSourceId + */ + + if ( + previoudDataSourceId && + previoudDataSourceId !== dataSourceId && + !ignoreRegularConflicts + ) { + const error = { type: 'conflict' as 'conflict', ...(destinationId && { destinationId }) }; + errors.push({ type, id, title, meta: { title }, error }); + } else if (previoudDataSourceId && previoudDataSourceId === dataSourceId) { + filteredObjects.push(object); + } else { + const omitOriginId = ignoreRegularConflicts; + importIdMap.set(`${type}:${id}`, { id: `${dataSourceId}_${rawId}`, omitOriginId }); + pendingOverwrites.add(`${type}:${id}`); + filteredObjects.push({ ...object, id: `${dataSourceId}_${rawId}` }); + } + } + }); + + return { filteredObjects, errors, importIdMap, pendingOverwrites }; +} diff --git a/src/core/server/saved_objects/import/check_origin_conflicts.ts b/src/core/server/saved_objects/import/check_origin_conflicts.ts index 7ef98982e149..5010c04f337a 100644 --- a/src/core/server/saved_objects/import/check_origin_conflicts.ts +++ b/src/core/server/saved_objects/import/check_origin_conflicts.ts @@ -45,6 +45,7 @@ interface CheckOriginConflictsParams { namespace?: string; ignoreRegularConflicts?: boolean; importIdMap: Map; + dataSourceId?: string; } type CheckOriginConflictParams = Omit & { diff --git a/src/core/server/saved_objects/import/collect_saved_objects.ts b/src/core/server/saved_objects/import/collect_saved_objects.ts index 8fd015c44991..747c384862e0 100644 --- a/src/core/server/saved_objects/import/collect_saved_objects.ts +++ b/src/core/server/saved_objects/import/collect_saved_objects.ts @@ -46,6 +46,7 @@ interface CollectSavedObjectsOptions { objectLimit: number; filter?: (obj: SavedObject) => boolean; supportedTypes: string[]; + dataSourceId?: string; } export async function collectSavedObjects({ @@ -53,6 +54,7 @@ export async function collectSavedObjects({ objectLimit, filter, supportedTypes, + dataSourceId, }: CollectSavedObjectsOptions) { const errors: SavedObjectsImportError[] = []; const entries: Array<{ type: string; id: string }> = []; diff --git a/src/core/server/saved_objects/import/create_saved_objects.test.ts b/src/core/server/saved_objects/import/create_saved_objects.test.ts index 9c40124c34cd..f1118842c967 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.test.ts @@ -53,6 +53,8 @@ const createObject = (type: string, id: string, originId?: string): SavedObject const MULTI_NS_TYPE = 'multi'; const OTHER_TYPE = 'other'; +const DATA_SOURCE = 'data-source'; + /** * Create a variety of different objects to exercise different import / result scenarios */ @@ -69,11 +71,56 @@ const obj10 = createObject(OTHER_TYPE, 'id-10', 'originId-f'); // -> success const obj11 = createObject(OTHER_TYPE, 'id-11', 'originId-g'); // -> conflict const obj12 = createObject(OTHER_TYPE, 'id-12'); // -> success const obj13 = createObject(OTHER_TYPE, 'id-13'); // -> conflict +// data source object +const dataSourceObj1 = createObject(DATA_SOURCE, 'ds-id1'); // -> success +const dataSourceObj2 = createObject(DATA_SOURCE, 'ds-id2'); // -> conflict +const dashboardObjWithDataSource = createObject('dashboard', 'ds_dashboard-id1'); // -> success +const visualizationObjWithDataSource = createObject('visualization', 'ds_visualization-id1'); // -> success +const searchObjWithDataSource = createObject('search', 'ds_search-id1'); // -> success + +// objs without data source id, used to test can get saved object with data source id +const searchObj = { + id: '6aea5700-ac94-11e8-a651-614b2788174a', + type: 'search', + attributes: { + title: 'some-title', + }, + references: [], + source: { + title: 'mysavedsearch', + kibanaSavedObjectMeta: { + searchSourceJSON: + '{"index":"4c3f3c30-ac94-11e8-a651-614b2788174a","highlightAll":true,"version":true,"query":{"query":"","language":"lucene"},"filter":[]}', + }, + }, +}; // -> success + +const visualizationObj = { + id: '8411daa0-ac94-11e8-a651-614b2788174a', + type: 'visualization', + attributes: { + title: 'visualization-title', + }, + references: [], + source: { + title: 'mysavedviz', + visState: + '{"title":"mysavedviz","type":"pie","params":{"type":"pie","addTooltip":true,"addLegend":true,"legendPosition":"right","isDonut":true,"labels":{"show":false,"values":true,"last_level":true,"truncate":100}},"aggs":[{"id":"1","enabled":true,"type":"count","schema":"metric","params":{}}]}', + uiStateJSON: '{}', + description: '', + savedSearchId: '6aea5700-ac94-11e8-a651-614b2788174a', + version: 1, + kibanaSavedObjectMeta: { + searchSourceJSON: '{"query":{"query":"","language":"lucene"},"filter":[]}', + }, + }, +}; // non-multi-namespace types shouldn't have origin IDs, but we include test cases to ensure it's handled gracefully // non-multi-namespace types by definition cannot result in an unresolvable conflict, so we don't include test cases for those const importId3 = 'id-foo'; const importId4 = 'id-bar'; const importId8 = 'id-baz'; + const importIdMap = new Map([ [`${obj3.type}:${obj3.id}`, { id: importId3, omitOriginId: true }], [`${obj4.type}:${obj4.id}`, { id: importId4 }], @@ -93,6 +140,8 @@ describe('#createSavedObjects', () => { accumulatedErrors?: SavedObjectsImportError[]; namespace?: string; overwrite?: boolean; + dataSourceId?: string; + dataSourceTitle?: string; }): CreateSavedObjectsParams => { savedObjectsClient = savedObjectsClientMock.create(); bulkCreate = savedObjectsClient.bulkCreate; @@ -177,6 +226,15 @@ describe('#createSavedObjects', () => { expect(createSavedObjectsResult).toEqual({ createdObjects: [], errors: [] }); }); + test('filters out objects that have errors present with data source', async () => { + const error = { type: dataSourceObj1.type, id: dataSourceObj1.id } as SavedObjectsImportError; + const options = setupParams({ objects: [dataSourceObj1], accumulatedErrors: [error] }); + + const createSavedObjectsResult = await createSavedObjects(options); + expect(bulkCreate).not.toHaveBeenCalled(); + expect(createSavedObjectsResult).toEqual({ createdObjects: [], errors: [] }); + }); + test('exits early if there are no objects to create', async () => { const options = setupParams({ objects: [] }); @@ -186,6 +244,13 @@ describe('#createSavedObjects', () => { }); const objs = [obj1, obj2, obj3, obj4, obj5, obj6, obj7, obj8, obj9, obj10, obj11, obj12, obj13]; + const dataSourceObjs = [ + dataSourceObj1, + dataSourceObj2, + dashboardObjWithDataSource, + visualizationObjWithDataSource, + searchObjWithDataSource, + ]; const setupMockResults = (options: CreateSavedObjectsParams) => { bulkCreate.mockResolvedValue({ @@ -207,6 +272,26 @@ describe('#createSavedObjects', () => { }); }; + const setupMockResultsWithDataSource = (options: CreateSavedObjectsParams) => { + bulkCreate.mockResolvedValue({ + saved_objects: [ + getResultMock.conflict(dataSourceObj1.type, dataSourceObj1.id), + getResultMock.success(dataSourceObj2, options), + getResultMock.success(dashboardObjWithDataSource, options), + getResultMock.success(visualizationObjWithDataSource, options), + getResultMock.success(searchObjWithDataSource, options), + ], + }); + }; + const setupMockResultsToConstructDataSource = (options: CreateSavedObjectsParams) => { + bulkCreate.mockResolvedValue({ + saved_objects: [ + getResultMock.success(searchObj, options), + getResultMock.success(visualizationObj, options), + ], + }); + }; + describe('handles accumulated errors as expected', () => { const resolvableErrors: SavedObjectsImportError[] = [ { type: 'foo', id: 'foo-id', error: { type: 'conflict' } } as SavedObjectsImportError, @@ -234,6 +319,14 @@ describe('#createSavedObjects', () => { } }); + test('does not call bulkCreate when resolvable errors are present with data source objects', async () => { + for (const error of resolvableErrors) { + const options = setupParams({ objects: dataSourceObjs, accumulatedErrors: [error] }); + await createSavedObjects(options); + expect(bulkCreate).not.toHaveBeenCalled(); + } + }); + test('calls bulkCreate when unresolvable errors or no errors are present', async () => { for (const error of unresolvableErrors) { const options = setupParams({ objects: objs, accumulatedErrors: [error] }); @@ -247,6 +340,20 @@ describe('#createSavedObjects', () => { await createSavedObjects(options); expect(bulkCreate).toHaveBeenCalledTimes(1); }); + + test('calls bulkCreate when unresolvable errors or no errors are present with data source', async () => { + for (const error of unresolvableErrors) { + const options = setupParams({ objects: dataSourceObjs, accumulatedErrors: [error] }); + setupMockResultsWithDataSource(options); + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + bulkCreate.mockClear(); + } + const options = setupParams({ objects: dataSourceObjs }); + setupMockResultsWithDataSource(options); + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + }); }); it('filters out version from objects before create', async () => { @@ -270,6 +377,52 @@ describe('#createSavedObjects', () => { const argObjs = [obj1, obj2, x3, x4, obj5, obj6, obj7, x8, obj9, obj10, obj11, obj12, obj13]; expectBulkCreateArgs.objects(1, argObjs); }; + + const testBulkCreateObjectsWithDataSource = async ( + namespace?: string, + dataSourceId?: string, + dataSourceTitle?: string + ) => { + const options = setupParams({ + objects: dataSourceObjs, + namespace, + dataSourceId, + dataSourceTitle, + }); + setupMockResultsWithDataSource(options); + + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + const argObjs = [ + dataSourceObj1, + dataSourceObj2, + dashboardObjWithDataSource, + visualizationObjWithDataSource, + searchObjWithDataSource, + ]; + expectBulkCreateArgs.objects(1, argObjs); + }; + + // testBulkCreateObjectsToAddDataSourceTitle + const testBulkCreateObjectsToAddDataSourceTitle = async ( + namespace?: string, + dataSourceId?: string, + dataSourceTitle?: string + ) => { + const options = setupParams({ + objects: [searchObj, visualizationObj], + namespace, + dataSourceId, + dataSourceTitle, + }); + setupMockResultsToConstructDataSource(options); + const result = (await createSavedObjects(options)).createdObjects; + expect(bulkCreate).toHaveBeenCalledTimes(1); + result.map((resultObj) => + expect(JSON.stringify(resultObj.attributes)).toContain('some-data-source-title') + ); + }; + const testBulkCreateOptions = async (namespace?: string) => { const overwrite = (Symbol() as unknown) as boolean; const options = setupParams({ objects: objs, namespace, overwrite }); @@ -279,6 +432,26 @@ describe('#createSavedObjects', () => { expect(bulkCreate).toHaveBeenCalledTimes(1); expectBulkCreateArgs.options(1, options); }; + + const testBulkCreateOptionsWithDataSource = async ( + namespace?: string, + dataSourceId?: string, + dataSourceTitle?: string + ) => { + const overwrite = (Symbol() as unknown) as boolean; + const options = setupParams({ + objects: dataSourceObjs, + namespace, + overwrite, + dataSourceId, + dataSourceTitle, + }); + setupMockResultsWithDataSource(options); + + await createSavedObjects(options); + expect(bulkCreate).toHaveBeenCalledTimes(1); + expectBulkCreateArgs.options(1, options); + }; const testReturnValue = async (namespace?: string) => { const options = setupParams({ objects: objs, namespace }); setupMockResults(options); @@ -293,6 +466,30 @@ describe('#createSavedObjects', () => { expect(results).toEqual(expectedResults); }; + const testReturnValueWithDataSource = async ( + namespace?: string, + dataSourceId?: string, + dataSourceTitle?: string + ) => { + const options = setupParams({ + objects: dataSourceObjs, + namespace, + dataSourceId, + dataSourceTitle, + }); + setupMockResultsWithDataSource(options); + + const results = await createSavedObjects(options); + const resultSavedObjectsWithDataSource = (await bulkCreate.mock.results[0].value).saved_objects; + const [dsr1, dsr2, dsr3, dsr4, dsr5] = resultSavedObjectsWithDataSource; + const transformedResultsWithDataSource = [dsr1, dsr2, dsr3, dsr4, dsr5]; + const expectedResultsWithDataSource = getExpectedResults( + transformedResultsWithDataSource, + dataSourceObjs + ); + expect(results).toEqual(expectedResultsWithDataSource); + }; + describe('with an undefined namespace', () => { test('calls bulkCreate once with input objects', async () => { await testBulkCreateObjects(); @@ -317,4 +514,36 @@ describe('#createSavedObjects', () => { await testReturnValue(namespace); }); }); + + describe('with a data source', () => { + test('calls bulkCreate once with input objects with data source id', async () => { + await testBulkCreateObjectsWithDataSource( + 'some-namespace', + 'some-datasource-id', + 'some-data-source-title' + ); + }); + test('calls bulkCreate once with input options with data source id', async () => { + await testBulkCreateOptionsWithDataSource( + 'some-namespace', + 'some-datasource-id', + 'some-data-source-title' + ); + }); + test('returns bulkCreate results that are remapped to IDs of imported objects with data source id', async () => { + await testReturnValueWithDataSource( + 'some-namespace', + 'some-datasource-id', + 'some-data-source-title' + ); + }); + + test('can correct attach datasource id to a search object', async () => { + await testBulkCreateObjectsToAddDataSourceTitle( + 'some-namespace', + 'some-datasource-id', + 'some-data-source-title' + ); + }); + }); }); diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index a3a1eebbd2ab..6fd08520281e 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -39,6 +39,8 @@ interface CreateSavedObjectsParams { importIdMap: Map; namespace?: string; overwrite?: boolean; + dataSourceId?: string; + dataSourceTitle?: string; } interface CreateSavedObjectsResult { createdObjects: Array>; @@ -56,6 +58,8 @@ export const createSavedObjects = async ({ importIdMap, namespace, overwrite, + dataSourceId, + dataSourceTitle, }: CreateSavedObjectsParams): Promise> => { // filter out any objects that resulted in errors const errorSet = accumulatedErrors.reduce( @@ -76,7 +80,70 @@ export const createSavedObjects = async ({ ); // filter out the 'version' field of each object, if it exists + const objectsToCreate = filteredObjects.map(({ version, ...object }) => { + if (dataSourceId) { + // @ts-expect-error + if (dataSourceTitle && object.attributes.title) { + if ( + object.type === 'dashboard' || + object.type === 'visualization' || + object.type === 'search' + ) { + // @ts-expect-error + object.attributes.title = object.attributes.title + `_${dataSourceTitle}`; + } + } + + if (object.type === 'index-pattern') { + object.references = [ + { + id: `${dataSourceId}`, + type: 'data-source', + name: 'dataSource', + }, + ]; + } + + if (object.type === 'visualization' || object.type === 'search') { + // @ts-expect-error + const searchSourceString = object.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; + // @ts-expect-error + const visStateString = object.attributes?.visState; + + if (searchSourceString) { + const searchSource = JSON.parse(searchSourceString); + if (searchSource.index) { + const searchSourceIndex = searchSource.index.includes('_') + ? searchSource.index.split('_')[searchSource.index.split('_').length - 1] + : searchSource.index; + searchSource.index = `${dataSourceId}_` + searchSourceIndex; + + // @ts-expect-error + object.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + } + } + + if (visStateString) { + const visState = JSON.parse(visStateString); + const controlList = visState.params?.controls; + if (controlList) { + // @ts-expect-error + controlList.map((control) => { + if (control.indexPattern) { + const controlIndexPattern = control.indexPattern.includes('_') + ? control.indexPattern.split('_')[control.indexPattern.split('_').length - 1] + : control.indexPattern; + control.indexPattern = `${dataSourceId}_` + controlIndexPattern; + } + }); + } + // @ts-expect-error + object.attributes.visState = JSON.stringify(visState); + } + } + } + // use the import ID map to ensure that each reference is being created with the correct ID const references = object.references?.map((reference) => { const { type, id } = reference; @@ -96,7 +163,6 @@ export const createSavedObjects = async ({ } return { ...object, ...(references && { references }) }; }); - const resolvableErrors = ['conflict', 'ambiguous_conflict', 'missing_references']; let expectedResults = objectsToCreate; if (!accumulatedErrors.some(({ error: { type } }) => resolvableErrors.includes(type))) { diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index de8fb34dfbed..2a9c4b0ff5ed 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -47,6 +47,7 @@ import { validateReferences } from './validate_references'; import { checkConflicts } from './check_conflicts'; import { checkOriginConflicts } from './check_origin_conflicts'; import { createSavedObjects } from './create_saved_objects'; +import { checkConflictsForDataSource } from './check_conflict_for_data_source'; jest.mock('./collect_saved_objects'); jest.mock('./regenerate_ids'); @@ -54,6 +55,7 @@ jest.mock('./validate_references'); jest.mock('./check_conflicts'); jest.mock('./check_origin_conflicts'); jest.mock('./create_saved_objects'); +jest.mock('./check_conflict_for_data_source'); const getMockFn = any, U>(fn: (...args: Parameters) => U) => fn as jest.MockedFunction<(...args: Parameters) => U>; @@ -80,6 +82,12 @@ describe('#importSavedObjectsFromStream', () => { importIdMap: new Map(), pendingOverwrites: new Set(), }); + getMockFn(checkConflictsForDataSource).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), + pendingOverwrites: new Set(), + }); getMockFn(createSavedObjects).mockResolvedValue({ errors: [], createdObjects: [] }); }); @@ -89,8 +97,12 @@ describe('#importSavedObjectsFromStream', () => { let savedObjectsClient: jest.Mocked; let typeRegistry: jest.Mocked; const namespace = 'some-namespace'; + const testDataSourceId = 'some-datasource'; - const setupOptions = (createNewCopies: boolean = false): SavedObjectsImportOptions => { + const setupOptions = ( + createNewCopies: boolean = false, + dataSourceId: string | undefined = undefined + ): SavedObjectsImportOptions => { readStream = new Readable(); savedObjectsClient = savedObjectsClientMock.create(); typeRegistry = typeRegistryMock.create(); @@ -109,14 +121,17 @@ describe('#importSavedObjectsFromStream', () => { typeRegistry, namespace, createNewCopies, + dataSourceId, }; }; - const createObject = (): SavedObject<{ + const createObject = ( + dataSourceId: string | undefined = undefined + ): SavedObject<{ title: string; }> => { return { type: 'foo-type', - id: uuidv4(), + id: dataSourceId ? `${dataSourceId}_${uuidv4()}` : uuidv4(), references: [], attributes: { title: 'some-title' }, }; @@ -225,6 +240,24 @@ describe('#importSavedObjectsFromStream', () => { expect(checkOriginConflicts).toHaveBeenCalledWith(checkOriginConflictsParams); }); + test('checks data source conflicts', async () => { + const options = setupOptions(false, testDataSourceId); + const collectedObjects = [createObject()]; + getMockFn(collectSavedObjects).mockResolvedValue({ + errors: [], + collectedObjects, + importIdMap: new Map(), + }); + + await importSavedObjectsFromStream(options); + const checkConflictsForDataSourceParams = { + objects: collectedObjects, + ignoreRegularConflicts: overwrite, + dataSourceId: testDataSourceId, + }; + expect(checkConflictsForDataSource).toHaveBeenCalledWith(checkConflictsForDataSourceParams); + }); + test('creates saved objects', async () => { const options = setupOptions(); const collectedObjects = [createObject()]; @@ -281,16 +314,18 @@ describe('#importSavedObjectsFromStream', () => { }); await importSavedObjectsFromStream(options); - expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + expect(regenerateIds).toHaveBeenCalledWith(collectedObjects, undefined); }); - test('does not check conflicts or check origin conflicts', async () => { + test('does not check conflicts or check origin conflicts or check data source conflict', async () => { const options = setupOptions(true); + getMockFn(validateReferences).mockResolvedValue([]); await importSavedObjectsFromStream(options); expect(checkConflicts).not.toHaveBeenCalled(); expect(checkOriginConflicts).not.toHaveBeenCalled(); + expect(checkConflictsForDataSource).not.toHaveBeenCalled(); }); test('creates saved objects', async () => { @@ -348,10 +383,20 @@ describe('#importSavedObjectsFromStream', () => { const obj1 = createObject(); const tmp = createObject(); const obj2 = { ...tmp, destinationId: 'some-destinationId', originId: tmp.id }; - const obj3 = { ...createObject(), destinationId: 'another-destinationId' }; // empty originId + const obj3 = { ...createObject(undefined), destinationId: 'another-destinationId' }; // empty originId const createdObjects = [obj1, obj2, obj3]; const error1 = createError(); const error2 = createError(); + // add some objects with data source id + const dataSourceObj1 = createObject(testDataSourceId); + const tmp2 = createObject(testDataSourceId); + const dataSourceObj2 = { ...tmp2, destinationId: 'some-destinationId', originId: tmp.id }; + const dataSourceObj3 = { + ...createObject(testDataSourceId), + destinationId: 'another-destinationId', + }; + const createdDsObjects = [dataSourceObj1, dataSourceObj2, dataSourceObj3]; + // results const success1 = { type: obj1.type, @@ -372,8 +417,28 @@ describe('#importSavedObjectsFromStream', () => { }; const errors = [error1, error2]; + const dsSuccess1 = { + type: dataSourceObj1.type, + id: dataSourceObj1.id, + meta: { title: dataSourceObj1.attributes.title, icon: `${dataSourceObj1.type}-icon` }, + }; + + const dsSuccess2 = { + type: dataSourceObj2.type, + id: dataSourceObj2.id, + meta: { title: dataSourceObj2.attributes.title, icon: `${dataSourceObj2.type}-icon` }, + destinationId: dataSourceObj2.destinationId, + }; + + const dsSuccess3 = { + type: dataSourceObj3.type, + id: dataSourceObj3.id, + meta: { title: dataSourceObj3.attributes.title, icon: `${dataSourceObj3.type}-icon` }, + destinationId: dataSourceObj3.destinationId, + }; + test('with createNewCopies disabled', async () => { - const options = setupOptions(); + const options = setupOptions(false, undefined); getMockFn(checkConflicts).mockResolvedValue({ errors: [], filteredObjects: [], @@ -410,7 +475,7 @@ describe('#importSavedObjectsFromStream', () => { test('with createNewCopies enabled', async () => { // however, we include it here for posterity - const options = setupOptions(true); + const options = setupOptions(true, undefined); getMockFn(createSavedObjects).mockResolvedValue({ errors, createdObjects }); const result = await importSavedObjectsFromStream(options); @@ -428,10 +493,73 @@ describe('#importSavedObjectsFromStream', () => { errors: errorResults, }); }); + + test('with createNewCopies disabled and with data source id', async () => { + const options = setupOptions(false, testDataSourceId); + getMockFn(checkConflictsForDataSource).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), + pendingOverwrites: new Set([`${dsSuccess2.type}:${dsSuccess2.id}`]), + }); + getMockFn(checkConflicts).mockResolvedValue({ + errors: [], + filteredObjects: [], + importIdMap: new Map(), + pendingOverwrites: new Set([ + `${dsSuccess2.type}:${dsSuccess2.id}`, // the dsSuccess2 object was overwritten + `${error2.type}:${error2.id}`, // an attempt was made to overwrite the error2 object + ]), + }); + getMockFn(createSavedObjects).mockResolvedValue({ + errors, + createdObjects: createdDsObjects, + }); + + const result = await importSavedObjectsFromStream(options); + // successResults only includes the imported object's type, id, and destinationId (if a new one was generated) + const successResults = [ + dsSuccess1, + { ...dsSuccess2, overwrite: true }, + { ...dsSuccess3, createNewCopy: true }, + ]; + const errorResults = [ + { ...error1, meta: { ...error1.meta, icon: `${error1.type}-icon` } }, + { ...error2, meta: { ...error2.meta, icon: `${error2.type}-icon` }, overwrite: true }, + ]; + expect(result).toEqual({ + success: false, + successCount: 3, + successResults, + errors: errorResults, + }); + }); + + test('with createNewCopies enabled and with data source id', async () => { + const options = setupOptions(true, testDataSourceId); + getMockFn(createSavedObjects).mockResolvedValue({ + errors, + createdObjects: createdDsObjects, + }); + + const result = await importSavedObjectsFromStream(options); + const successDsResults = [dsSuccess1, dsSuccess2, dsSuccess3]; + + const errorResults = [ + { ...error1, meta: { ...error1.meta, icon: `${error1.type}-icon` } }, + { ...error2, meta: { ...error2.meta, icon: `${error2.type}-icon` } }, + ]; + expect(result).toEqual({ + success: false, + successCount: 3, + successResults: successDsResults, + errors: errorResults, + }); + }); }); test('accumulates multiple errors', async () => { - const options = setupOptions(); + const options = setupOptions(false, undefined); const errors = [createError(), createError(), createError(), createError(), createError()]; getMockFn(collectSavedObjects).mockResolvedValue({ errors: [errors[0]], diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index cd250fc5f65f..6cbb1059fdc3 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -39,6 +39,7 @@ import { checkOriginConflicts } from './check_origin_conflicts'; import { createSavedObjects } from './create_saved_objects'; import { checkConflicts } from './check_conflicts'; import { regenerateIds } from './regenerate_ids'; +import { checkConflictsForDataSource } from './check_conflict_for_data_source'; /** * Import saved objects from given stream. See the {@link SavedObjectsImportOptions | options} for more @@ -54,6 +55,8 @@ export async function importSavedObjectsFromStream({ savedObjectsClient, typeRegistry, namespace, + dataSourceId, + dataSourceTitle, }: SavedObjectsImportOptions): Promise { let errorAccumulator: SavedObjectsImportError[] = []; const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); @@ -63,6 +66,7 @@ export async function importSavedObjectsFromStream({ readStream, objectLimit, supportedTypes, + dataSourceId, }); errorAccumulator = [...errorAccumulator, ...collectSavedObjectsResult.errors]; /** Map of all IDs for objects that we are attempting to import; each value is empty by default */ @@ -78,8 +82,10 @@ export async function importSavedObjectsFromStream({ errorAccumulator = [...errorAccumulator, ...validateReferencesResult]; if (createNewCopies) { - importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects); + // randomly generated id + importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects, dataSourceId); } else { + // in check conclict and override mode // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { objects: collectSavedObjectsResult.collectedObjects, @@ -87,10 +93,27 @@ export async function importSavedObjectsFromStream({ namespace, ignoreRegularConflicts: overwrite, }; + + // resolve when data source exist, pass the filtered objects to next check conflict + if (dataSourceId) { + const checkConflictsForDataSourceResult = await checkConflictsForDataSource({ + objects: collectSavedObjectsResult.collectedObjects, + ignoreRegularConflicts: overwrite, + dataSourceId, + }); + + checkConflictsParams.objects = checkConflictsForDataSourceResult.filteredObjects; + + pendingOverwrites = new Set([ + ...pendingOverwrites, + ...checkConflictsForDataSourceResult.pendingOverwrites, + ]); + } + const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; importIdMap = new Map([...importIdMap, ...checkConflictsResult.importIdMap]); - pendingOverwrites = checkConflictsResult.pendingOverwrites; + pendingOverwrites = new Set([...pendingOverwrites, ...checkConflictsResult.pendingOverwrites]); // Check multi-namespace object types for origin conflicts in this namespace const checkOriginConflictsParams = { @@ -112,12 +135,16 @@ export async function importSavedObjectsFromStream({ // Create objects in bulk const createSavedObjectsParams = { - objects: collectSavedObjectsResult.collectedObjects, + objects: dataSourceId + ? collectSavedObjectsResult.collectedObjects.filter((object) => object.type !== 'data-source') + : collectSavedObjectsResult.collectedObjects, accumulatedErrors: errorAccumulator, savedObjectsClient, importIdMap, overwrite, namespace, + dataSourceId, + dataSourceTitle, }; const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams); errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; @@ -136,6 +163,7 @@ export async function importSavedObjectsFromStream({ }; } ); + const errorResults = errorAccumulator.map((error) => { const icon = typeRegistry.getType(error.type)?.management?.icon; const attemptedOverwrite = pendingOverwrites.has(`${error.type}:${error.id}`); diff --git a/src/core/server/saved_objects/import/regenerate_ids.test.ts b/src/core/server/saved_objects/import/regenerate_ids.test.ts index 11556c8a21c1..c7dbfb8b50bc 100644 --- a/src/core/server/saved_objects/import/regenerate_ids.test.ts +++ b/src/core/server/saved_objects/import/regenerate_ids.test.ts @@ -39,12 +39,18 @@ describe('#regenerateIds', () => { { type: 'baz', id: '3' }, ] as any) as SavedObject[]; + const dataSourceObjects = ([{ type: 'data-source', id: '1' }] as any) as SavedObject[]; + + test('can filter out data source object', () => { + expect(regenerateIds(dataSourceObjects, '').size).toBe(0); + }); + test('returns expected values', () => { mockUuidv4 .mockReturnValueOnce('uuidv4 #1') .mockReturnValueOnce('uuidv4 #2') .mockReturnValueOnce('uuidv4 #3'); - expect(regenerateIds(objects)).toMatchInlineSnapshot(` + expect(regenerateIds(objects, '')).toMatchInlineSnapshot(` Map { "foo:1" => Object { "id": "uuidv4 #1", diff --git a/src/core/server/saved_objects/import/regenerate_ids.ts b/src/core/server/saved_objects/import/regenerate_ids.ts index 672a8f030620..f1092bed7f55 100644 --- a/src/core/server/saved_objects/import/regenerate_ids.ts +++ b/src/core/server/saved_objects/import/regenerate_ids.ts @@ -36,9 +36,14 @@ import { SavedObject } from '../types'; * * @param objects The saved objects to generate new IDs for. */ -export const regenerateIds = (objects: SavedObject[]) => { +export const regenerateIds = (objects: SavedObject[], dataSourceId: string | undefined) => { const importIdMap = objects.reduce((acc, object) => { - return acc.set(`${object.type}:${object.id}`, { id: uuidv4(), omitOriginId: true }); + return object.type === 'data-source' + ? acc + : acc.set(`${object.type}:${object.id}`, { + id: dataSourceId ? `${dataSourceId}_${uuidv4()}` : uuidv4(), + omitOriginId: true, + }); }, new Map()); return importIdMap; }; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.test.ts b/src/core/server/saved_objects/import/resolve_import_errors.test.ts index bd0ba1afdc9d..ef22155f046b 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.test.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.test.ts @@ -369,7 +369,7 @@ describe('#importSavedObjectsFromStream', () => { }); await resolveSavedObjectsImportErrors(options); - expect(regenerateIds).toHaveBeenCalledWith(collectedObjects); + expect(regenerateIds).toHaveBeenCalledWith(collectedObjects, undefined); }); test('creates saved objects', async () => { diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index 162410c4ce9b..09207c893043 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -59,6 +59,8 @@ export async function resolveSavedObjectsImportErrors({ typeRegistry, namespace, createNewCopies, + dataSourceId, + dataSourceTitle, }: SavedObjectsResolveImportErrorsOptions): Promise { // throw a BadRequest error if we see invalid retries validateRetries(retries); @@ -76,6 +78,7 @@ export async function resolveSavedObjectsImportErrors({ objectLimit, filter, supportedTypes, + dataSourceId, } ); errorAccumulator = [...errorAccumulator, ...collectorErrors]; @@ -116,7 +119,7 @@ export async function resolveSavedObjectsImportErrors({ if (createNewCopies) { // In case any missing reference errors were resolved, ensure that we regenerate those object IDs as well // This is because a retry to resolve a missing reference error may not necessarily specify a destinationId - importIdMap = regenerateIds(objectsToResolve); + importIdMap = regenerateIds(objectsToResolve, dataSourceId); } // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces @@ -126,6 +129,7 @@ export async function resolveSavedObjectsImportErrors({ namespace, retries, createNewCopies, + dataSourceId, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; @@ -157,6 +161,8 @@ export async function resolveSavedObjectsImportErrors({ importIdMap, namespace, overwrite, + dataSourceId, + dataSourceTitle, }; const { createdObjects, errors: bulkCreateErrors } = await createSavedObjects( createSavedObjectsParams diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index 88beacb9d2fd..73bc548b1f24 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -187,6 +187,8 @@ export interface SavedObjectsImportOptions { namespace?: string; /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ createNewCopies: boolean; + dataSourceId?: string; + dataSourceTitle?: string; } /** @@ -208,6 +210,8 @@ export interface SavedObjectsResolveImportErrorsOptions { namespace?: string; /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ createNewCopies: boolean; + dataSourceId?: string; + dataSourceTitle?: string; } export type CreatedObject = SavedObject & { destinationId?: string }; diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index b157feb0860e..259551298748 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -60,6 +60,7 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) { overwrite: schema.boolean({ defaultValue: false }), createNewCopies: schema.boolean({ defaultValue: false }), + dataSourceId: schema.maybe(schema.string({ defaultValue: '' })), }, { validate: (object) => { @@ -75,13 +76,29 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) }, }, router.handleLegacyErrors(async (context, req, res) => { - const { overwrite, createNewCopies } = req.query; + const { overwrite, createNewCopies, dataSourceId } = req.query; const file = req.body.file as FileStream; const fileExtension = extname(file.hapi.filename).toLowerCase(); if (fileExtension !== '.ndjson') { return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); } + // get datasource from saved object service + // dataSource is '' when there is no dataSource pass in the url + const dataSource = dataSourceId + ? await context.core.savedObjects.client + .get('data-source', dataSourceId) + .then((response) => { + const attributes: any = response?.attributes || {}; + return { + id: response.id, + title: attributes.title, + }; + }) + : ''; + + const dataSourceTitle = dataSource ? dataSource.title : ''; + let readStream: Readable; try { readStream = await createSavedObjectsStreamFromNdJson(file); @@ -98,6 +115,8 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) objectLimit: maxImportExportSize, overwrite, createNewCopies, + dataSourceId, + dataSourceTitle, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 5e07125671f1..8e2113af6378 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -58,6 +58,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO validate: { query: schema.object({ createNewCopies: schema.boolean({ defaultValue: false }), + dataSourceId: schema.maybe(schema.string({ defaultValue: '' })), }), body: schema.object({ file: schema.stream(), @@ -89,6 +90,24 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO return res.badRequest({ body: `Invalid file extension ${fileExtension}` }); } + const dataSourceId = req.query.dataSourceId; + + // get datasource from saved object service + + const dataSource = dataSourceId + ? await context.core.savedObjects.client + .get('data-source', dataSourceId) + .then((response) => { + const attributes: any = response?.attributes || {}; + return { + id: response.id, + title: attributes.title, + }; + }) + : ''; + + const dataSourceTitle = dataSource ? dataSource.title : ''; + let readStream: Readable; try { readStream = await createSavedObjectsStreamFromNdJson(file); @@ -105,6 +124,8 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO retries: req.body.retries, objectLimit: maxImportExportSize, createNewCopies: req.query.createNewCopies, + dataSourceId, + dataSourceTitle, }); return res.ok({ body: result }); diff --git a/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.tsx b/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.tsx index c914f47bd853..cb780616e4ae 100644 --- a/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.tsx +++ b/src/plugins/data/public/data_sources/datasource_selector/datasource_selectable.tsx @@ -117,6 +117,7 @@ export const DataSourceSelectable = ({ onGetDataSetError, // onGetDataSetError, Callback for handling get data set errors. Ensure it's memoized. singleSelection = { asPlainText: true }, dataSourceSelectorConfigs, + ...comboBoxProps }: DataSourceSelectableProps) => { // This effect gets data sets and prepares the datasource list for UI rendering. useEffect(() => { @@ -145,6 +146,7 @@ export const DataSourceSelectable = ({ return ( , 'fullWidth'> { dataSources: GenericDataSource[]; onDataSourceSelect: (dataSourceOption: DataSourceOption[]) => void; singleSelection?: boolean | EuiComboBoxSingleSelectionShape; diff --git a/src/plugins/data/public/ui/query_string_input/_query_bar.scss b/src/plugins/data/public/ui/query_string_input/_query_bar.scss index 096c7c0ad8bc..c9eab29d9e2d 100644 --- a/src/plugins/data/public/ui/query_string_input/_query_bar.scss +++ b/src/plugins/data/public/ui/query_string_input/_query_bar.scss @@ -33,9 +33,8 @@ // Unlike most inputs within layout control groups, the text area still needs a border. // These adjusts help it sit above the control groups shadow to line up correctly. - padding: $euiSizeS; - padding-top: $euiSizeS + 3px; - transform: translateY(-1px) translateX(-1px); + padding: ($euiSizeS + 2px) $euiSizeS $euiSizeS; + transform: translateY(-2px) translateX(-1px); &:not(:focus):not(:invalid) { @include euiYScrollWithShadows; diff --git a/src/plugins/data_explorer/public/components/app_container.scss b/src/plugins/data_explorer/public/components/app_container.scss index d5b6df038208..7bd5ed6f69f6 100644 --- a/src/plugins/data_explorer/public/components/app_container.scss +++ b/src/plugins/data_explorer/public/components/app_container.scss @@ -1,8 +1,7 @@ $osdHeaderOffset: $euiHeaderHeightCompensation; .deSidebar { - max-width: 462px; - min-width: 400px; + height: 100%; @include ouiBreakpoint("xs", "s", "m") { max-width: initial; diff --git a/src/plugins/data_explorer/public/components/app_container.tsx b/src/plugins/data_explorer/public/components/app_container.tsx index 8f37e9c1230f..529829140057 100644 --- a/src/plugins/data_explorer/public/components/app_container.tsx +++ b/src/plugins/data_explorer/public/components/app_container.tsx @@ -3,8 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; -import { EuiPage, EuiPageBody } from '@elastic/eui'; +import React, { memo } from 'react'; +import { EuiPage, EuiPageBody, EuiResizableContainer, useIsWithinBreakpoints } from '@elastic/eui'; import { Suspense } from 'react'; import { AppMountParameters } from '../../../../core/public'; import { Sidebar } from './sidebar'; @@ -13,6 +13,7 @@ import { View } from '../services/view_service/view'; import './app_container.scss'; export const AppContainer = ({ view, params }: { view?: View; params: AppMountParameters }) => { + const isMobile = useIsWithinBreakpoints(['xs', 's', 'm']); // TODO: Make this more robust. if (!view) { return ; @@ -20,18 +21,38 @@ export const AppContainer = ({ view, params }: { view?: View; params: AppMountPa const { Canvas, Panel, Context } = view; + const MemoizedPanel = memo(Panel); + const MemoizedCanvas = memo(Canvas); + // Render the application DOM. return ( {/* TODO: improve fallback state */} Loading...}> - - - - - - + + {(EuiResizablePanel, EuiResizableButton) => ( + <> + + + + + + + + + + + + + + )} + diff --git a/src/plugins/data_explorer/public/components/no_view.tsx b/src/plugins/data_explorer/public/components/no_view.tsx index a341e9d0564e..20bdf83f1de6 100644 --- a/src/plugins/data_explorer/public/components/no_view.tsx +++ b/src/plugins/data_explorer/public/components/no_view.tsx @@ -11,7 +11,7 @@ export const NoView = () => { return ( { return ( - - + + { selectedSources={selectedSources} onGetDataSetError={handleGetDataSetError} dataSourceSelectorConfigs={DATA_SOURCE_SELECTOR_CONFIGS} + fullWidth /> - + {children} {modalContent} diff --git a/src/plugins/data_explorer/public/index.scss b/src/plugins/data_explorer/public/index.scss deleted file mode 100644 index 8389e31b426a..000000000000 --- a/src/plugins/data_explorer/public/index.scss +++ /dev/null @@ -1,9 +0,0 @@ -$osdHeaderOffset: $euiHeaderHeightCompensation; - -.dePageTemplate { - height: calc(100vh - #{$osdHeaderOffset}); -} - -.headerIsExpanded .dePageTemplate { - height: calc(100vh - #{$osdHeaderOffset * 2}); -} diff --git a/src/plugins/data_explorer/public/index.ts b/src/plugins/data_explorer/public/index.ts index cb33d2b7d90c..f8adda434ced 100644 --- a/src/plugins/data_explorer/public/index.ts +++ b/src/plugins/data_explorer/public/index.ts @@ -3,8 +3,6 @@ * SPDX-License-Identifier: Apache-2.0 */ -import './index.scss'; - import { DataExplorerPlugin } from './plugin'; // This exports static code and TypeScript types, diff --git a/src/plugins/data_source/config.ts b/src/plugins/data_source/config.ts index b197c4a126fa..1131c500162d 100644 --- a/src/plugins/data_source/config.ts +++ b/src/plugins/data_source/config.ts @@ -13,7 +13,7 @@ const WRAPPING_KEY_SIZE: number = 32; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), - defaultCluster: schema.boolean({ defaultValue: false }), + defaultCluster: schema.boolean({ defaultValue: true }), encryption: schema.object({ wrappingKeyName: schema.string({ minLength: KEY_NAME_MIN_LENGTH, diff --git a/src/plugins/discover/public/application/components/chart/histogram/histogram.tsx b/src/plugins/discover/public/application/components/chart/histogram/histogram.tsx index 51d3f1f1b706..87917c113c2f 100644 --- a/src/plugins/discover/public/application/components/chart/histogram/histogram.tsx +++ b/src/plugins/discover/public/application/components/chart/histogram/histogram.tsx @@ -306,6 +306,14 @@ export class DiscoverHistogram extends Component void; onFilter: DocViewFilterFn; + onMoveColumn: (colName: string, destination: number) => void; onRemoveColumn: (column: string) => void; - onSort: (sort: SortOrder[]) => void; + hits?: number; + onSort: (s: SortOrder[]) => void; rows: OpenSearchSearchHit[]; onSetColumns: (columns: string[]) => void; sort: SortOrder[]; @@ -36,6 +43,7 @@ export interface DataGridTableProps { isToolbarVisible?: boolean; isContextView?: boolean; isLoading?: boolean; + showPagination?: boolean; } export const DataGridTable = ({ @@ -43,10 +51,12 @@ export const DataGridTable = ({ indexPattern, onAddColumn, onFilter, + onMoveColumn, onRemoveColumn, onSetColumns, onSort, sort, + hits, rows, displayTimeColumn, title = '', @@ -54,12 +64,20 @@ export const DataGridTable = ({ isToolbarVisible = true, isContextView = false, isLoading = false, + showPagination, }: DataGridTableProps) => { - const { services } = useOpenSearchDashboards(); - + const services = getServices(); const [inspectedHit, setInspectedHit] = useState(); const rowCount = useMemo(() => (rows ? rows.length : 0), [rows]); - const pageSizeLimit = services.uiSettings?.get(SAMPLE_SIZE_SETTING); + const { toolbarOptions, lineCount } = useToolbarOptions(); + const [pageSizeLimit, isShortDots, hideTimeColumn, defaultSortOrder] = useMemo(() => { + return [ + services.uiSettings.get(SAMPLE_SIZE_SETTING), + services.uiSettings.get(UI_SETTINGS.SHORT_DOTS_ENABLE), + services.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING), + services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING, 'desc') as SortDirection, + ]; + }, [services.uiSettings]); const pagination = usePagination({ rowCount, pageSizeLimit }); let adjustedColumns = buildColumns(columns); @@ -77,10 +95,10 @@ export const DataGridTable = ({ const rowHeightsOptions = useMemo( () => ({ defaultHeight: { - lineCount: adjustedColumns.includes('_source') ? 3 : 1, + lineCount: lineCount || (includeSourceInColumns ? 3 : 1), }, }), - [adjustedColumns] + [includeSourceInColumns, lineCount] ); const onColumnSort = useCallback( @@ -90,12 +108,13 @@ export const DataGridTable = ({ [onSort] ); - const renderCellValue = useMemo(() => fetchTableDataCell(indexPattern, rows), [ + const renderCellValue = useMemo(() => fetchTableDataCell(indexPattern, rows, isShortDots), [ indexPattern, + isShortDots, rows, ]); - const dataGridTableColumns = useMemo( + const displayedTableColumns = useMemo( () => buildDataGridColumns( adjustedColumns, @@ -137,11 +156,53 @@ export const DataGridTable = ({ ]; }, []); - const table = useMemo( + const newDiscoverEnabled = getNewDiscoverSetting(services.storage); + + const legacyDiscoverTable = useMemo( + () => ( + setInspectedHit(undefined)} + sampleSize={pageSizeLimit} + showPagination={showPagination} + isShortDots={isShortDots} + hideTimeColumn={hideTimeColumn} + defaultSortOrder={defaultSortOrder} + /> + ), + [ + adjustedColumns, + hits, + rows, + indexPattern, + sort, + onSort, + onRemoveColumn, + onMoveColumn, + onAddColumn, + onFilter, + pageSizeLimit, + showPagination, + defaultSortOrder, + hideTimeColumn, + isShortDots, + ] + ); + + const dataGridTable = useMemo( () => ( ), [ - dataGridTableColumns, + displayedTableColumns, dataGridTableColumnsVisibility, leadingControlColumns, pagination, @@ -162,10 +223,27 @@ export const DataGridTable = ({ rowCount, sorting, isToolbarVisible, + toolbarOptions, rowHeightsOptions, ] ); + const tablePanelProps = newDiscoverEnabled + ? { + paddingSize: 'none' as const, + style: { + margin: '8px', + }, + color: 'transparent' as const, + } + : { + paddingSize: 'none' as const, + style: { + margin: '0px', + }, + color: 'transparent' as const, + }; + return ( - - - {table} - + + {newDiscoverEnabled ? dataGridTable : legacyDiscoverTable} - {inspectedHit && ( + {newDiscoverEnabled && inspectedHit && ( { it('should display empty span if no data', () => { - const DataGridTableCellValue = fetchTableDataCell(indexPatternMock, dataRowsMock); + const DataGridTableCellValue = fetchTableDataCell(indexPatternMock, dataRowsMock, false); const comp = shallow( { }); it('should display empty span if field is not defined in index pattern', () => { - const DataGridTableCellValue = fetchTableDataCell(indexPatternMock, dataRowsMock); + const DataGridTableCellValue = fetchTableDataCell(indexPatternMock, dataRowsMock, false); const comp = shallow( { }); it('should display JSON string representation of the data if columnId is _source and isDetails is false', () => { - const DataGridTableCellValue = fetchTableDataCell(customizedIndexPatternMock, dataRowsMock); + const DataGridTableCellValue = fetchTableDataCell( + customizedIndexPatternMock, + dataRowsMock, + false + ); const comp = shallow( { }); it('should display EuiDescriptionList if columnId is _source and isDetails is false', () => { - const DataGridTableCellValue = fetchTableDataCell(customizedIndexPatternMock, dataRowsMock); + const DataGridTableCellValue = fetchTableDataCell( + customizedIndexPatternMock, + dataRowsMock, + false + ); const comp = shallow( { expect(comp).toMatchInlineSnapshot(` - order_date + order_date: { }); it('should correctly display data if columnId is in index pattern and is not _source', () => { - const DataGridTableCellValue = fetchTableDataCell(customizedIndexPatternMock, dataRowsMock); + const DataGridTableCellValue = fetchTableDataCell( + customizedIndexPatternMock, + dataRowsMock, + false + ); const comp = shallow( , columnId: string, - isDetails: boolean + isDetails: boolean, + isShortDots: boolean ) { if (isDetails) { return {stringify(row[columnId], null, 2)}; } const formattedRow = idxPattern.formatHit(row); + const rawKeys = Object.keys(formattedRow); + const keys = isShortDots ? rawKeys.map((k) => shortenDottedString(k)) : rawKeys; return ( - - {Object.keys(formattedRow).map((key) => ( + + {keys.map((key, index) => ( - {key} + {key + ':'} + {index !== keys.length - 1 && ' '} ))} @@ -46,7 +51,8 @@ function fetchSourceTypeDataCell( export const fetchTableDataCell = ( idxPattern: IndexPattern, - dataRows: OpenSearchSearchHit[] | undefined + dataRows: OpenSearchSearchHit[] | undefined, + isShortDots: boolean ) => ({ rowIndex, columnId, isDetails }: EuiDataGridCellValueElementProps) => { const singleRow = dataRows ? (dataRows[rowIndex] as Record) : undefined; const flattenedRows = dataRows ? dataRows.map((hit) => idxPattern.flattenHit(hit)) : []; @@ -68,7 +74,7 @@ export const fetchTableDataCell = ( } if (fieldInfo?.type === '_source') { - return fetchSourceTypeDataCell(idxPattern, singleRow, columnId, isDetails); + return fetchSourceTypeDataCell(idxPattern, singleRow, columnId, isDetails, isShortDots); } const formattedValue = idxPattern.formatField(singleRow, columnId); diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_toolbar.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_toolbar.tsx new file mode 100644 index 000000000000..07204560040e --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_toolbar.tsx @@ -0,0 +1,81 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButtonEmpty, + EuiDataGridToolBarVisibilityOptions, + EuiFormRow, + EuiPopover, + EuiRange, +} from '@elastic/eui'; +import React, { useState } from 'react'; +import { useLocalStorage } from 'react-use'; + +const AddtitionalControls = ({ + setLineCount, + lineCount, +}: { + setLineCount: (lineCount: number) => void; + lineCount: number; +}) => { + const [isPopoverOpen, setIsPopoverOpen] = useState(false); + + const handleRangeChange = (e: any) => { + setLineCount(Number(e.target.value)); + }; + + const onButtonClick = () => setIsPopoverOpen((open) => !open); + const closePopover = () => {}; + + const button = ( + + Display + + ); + + return ( + + + + + + ); +}; + +export const useToolbarOptions = (): { + toolbarOptions: EuiDataGridToolBarVisibilityOptions | boolean; + lineCount: number; +} => { + const [lineCount, setLineCount] = useLocalStorage('discover:lineCount', 1); + + const toolbarOptions = { + showColumnSelector: { + allowHide: false, + allowReorder: true, + }, + showStyleSelector: false, + showFullScreenSelector: false, + additionalControls: , + }; + + return { + toolbarOptions, + lineCount, + }; +}; diff --git a/src/plugins/discover/public/application/components/default_discover_table/_doc_table.scss b/src/plugins/discover/public/application/components/default_discover_table/_doc_table.scss new file mode 100644 index 000000000000..1e780a7e4d8a --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/_doc_table.scss @@ -0,0 +1,165 @@ +/** + * 1. Stack content vertically so the table can scroll when its constrained by a fixed container height. + */ +doc-table { + @include euiScrollBar; + + overflow: auto; + flex: 1 1 100%; + flex-direction: column; /* 1 */ + + th { + text-align: left; + font-weight: bold; + } + + .spinner { + position: absolute; + top: 40%; + left: 0; + right: 0; + z-index: $euiZLevel1; + opacity: 0.5; + } +} + +.osdDocTable__container.loading { + opacity: 0.5; +} + +.osdDocTable { + font-size: $euiFontSizeXS; + + th { + white-space: nowrap; + padding-right: $euiSizeS; + + .fa { + font-size: 1.1em; + } + } +} + +.osd-table, +.osdDocTable { + @include ouiCodeFont; + + // To fight intruding styles that conflict with OUI's + & > tbody > tr > td { + line-height: inherit; + } + + /** + * Style OpenSearch document _source in table view
key:
value
+ * Use alpha so this will stand out against non-white backgrounds, e.g. the highlighted + * row in the Context Log. + */ + + dl.source { + margin-bottom: 0; + line-height: 2em; + word-break: break-word; + + dt, + dd { + display: inline; + } + + dt { + background-color: transparentize(shade($euiColorPrimary, 20%), 0.9); + color: $euiTextColor; + padding: calc($euiSizeXS / 2) $euiSizeXS; + margin-right: $euiSizeXS; + word-break: normal; + border-radius: $euiBorderRadius; + } + } +} + +.osdDocTable__row { + td { + position: relative; + + &:hover { + .osdDocTableRowFilterButton { + opacity: 1; + } + } + } +} + +.osdDocTable__row--highlight { + td, + .osdDocTableRowFilterButton { + background-color: tintOrShade($euiColorPrimary, 90%, 70%); + } +} + +.osdDocTable__bar { + margin: $euiSizeXS $euiSizeXS 0; +} + +.osdDocTable__bar--footer { + position: relative; + margin: -($euiSize * 3) $euiSizeXS 0; +} + +.osdDocTable__padBottom { + padding-bottom: $euiSizeXL; +} + +.osdDocTable__error { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1 0 100%; + text-align: center; +} + +.truncate-by-height { + overflow: hidden; +} + +.table { + // Nesting + .table { + background-color: $euiColorEmptyShade; + } +} + +.osd-table { + // sub tables should not have a leading border + .table .table { + margin-bottom: 0; + + tr:first-child > td { + border-top: none; + } + + td.field-name { + font-weight: $euiFontWeightBold; + } + } +} + +table { + th { + i.fa-sort { + color: $euiColorLightShade; + } + + button.fa-sort-asc, + button.fa-sort-down, + i.fa-sort-asc, + i.fa-sort-down { + color: $euiColorPrimary; + } + + button.fa-sort-desc, + button.fa-sort-up, + i.fa-sort-desc, + i.fa-sort-up { + color: $euiColorPrimary; + } + } +} diff --git a/src/plugins/discover/public/application/components/default_discover_table/_pagination.scss b/src/plugins/discover/public/application/components/default_discover_table/_pagination.scss new file mode 100644 index 000000000000..793e707e2085 --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/_pagination.scss @@ -0,0 +1,7 @@ +.osdDocTable_pagination { + width: 100%; + + & ~ table { + margin-bottom: 0; + } +} diff --git a/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss new file mode 100644 index 000000000000..c960e87a9477 --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss @@ -0,0 +1,97 @@ +.osdDocTable__detailsParent { + border-top: none !important; +} + +// stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors +.euiFlexItem.osdDocTable__detailsIconContainer { + margin-right: 0; +} + +.osd-table td.osdDocTableCell__toggleDetails { + padding: 5px 0 0 4px; +} + +/** + * 1. Align icon with text in cell. + * 2. Use opacity to make this element accessible to screen readers and keyboard. + * 3. Show on focus to enable keyboard accessibility. + */ + +.osdDocTableCell { + position: relative; + + &__filter { + position: absolute; + display: flex; + flex-grow: 0; + + // Vertically align the button group with the first line of text + // 8px is set by .table and 2em is the line-height + top: calc(2em / 2 + 8px); + transform: translateY(-50%); + + // Stick it to the right but use the padding of the container to distance it from the edge (below) + right: 0; + + // Just to have some distance from the content behind it; larger for left so we can show a gradiant + // 8px is set by .table + padding: 8px 8px 8px 16px; + + &::before { + content: ""; + position: absolute; + display: block; + right: 0; + top: 0; + height: 100%; + width: 100%; + background-image: linear-gradient(to right, transparent 0, $ouiColorEmptyShade 16px); + z-index: 1; + } + + & > * { + // So they will appear over the background in ::before + z-index: 2; + } + } + + &__filterButton, + &__filter { + opacity: 0; + transition: opacity $euiAnimSpeedFast; + + @include ouiBreakpoint("xs", "s", "m") { + opacity: 1; + } + } + + &:hover &__filterButton, + &:focus &__filterButton, + &:hover &__filter, + &:focus &__filter { + opacity: 1; + } + + .osdDescriptionListFieldTitle { + margin: 0 4px 0 0 !important; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + &.eui-textNoWrap { + // To make sure the time-series column never stretches + width: 1%; + } +} + +.osdDocTableCell__source { + .truncate-by-height { + transform: translateY(-1.5px); + margin-bottom: -1.5px; + } + + dd, + dl, + dt { + font-size: inherit !important; + } +} diff --git a/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss b/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss new file mode 100644 index 000000000000..cd29c1e54ad0 --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss @@ -0,0 +1,36 @@ +.osdDocTableHeader { + white-space: nowrap; + text-align: left; +} + +.osdDocTableHeader button { + margin-left: $euiSizeXS; +} + +.osdDocTableHeader__move, +.osdDocTableHeader__sortChange { + opacity: 0; + + &:focus, + th:hover & { + opacity: 1; + } +} + +.docTableHeaderField { + &__actionButton { + opacity: 0; + height: 10px; + width: 10px; + transition: opacity $euiAnimSpeedFast; + + @include ouiBreakpoint("xs", "s", "m") { + opacity: 1; + } + } + + &:hover &__actionButton, + &:focus &__actionButton { + opacity: 1; + } +} diff --git a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx new file mode 100644 index 000000000000..fe8092ed8c9c --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx @@ -0,0 +1,195 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './_doc_table.scss'; + +import React, { useEffect, useRef, useState } from 'react'; +import { EuiButtonEmpty, EuiCallOut, EuiProgress } from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; +import { TableHeader } from './table_header'; +import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types'; +import { TableRow } from './table_rows'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { Pagination } from './pagination'; +import { getLegacyDisplayedColumns } from './helper'; +import { SortDirection, SortOrder } from '../../../saved_searches/types'; + +export interface DefaultDiscoverTableProps { + columns: string[]; + hits?: number; + rows: OpenSearchSearchHit[]; + indexPattern: IndexPattern; + sort: SortOrder[]; + onSort: (s: SortOrder[]) => void; + onRemoveColumn: (column: string) => void; + onMoveColumn: (colName: string, destination: number) => void; + onAddColumn: (column: string) => void; + onFilter: DocViewFilterFn; + onClose: () => void; + sampleSize: number; + isShortDots: boolean; + hideTimeColumn: boolean; + defaultSortOrder: SortDirection; + showPagination?: boolean; +} + +export const LegacyDiscoverTable = ({ + columns, + hits, + rows, + indexPattern, + sort, + onSort, + onRemoveColumn, + onMoveColumn, + onAddColumn, + onFilter, + onClose, + sampleSize, + isShortDots, + hideTimeColumn, + defaultSortOrder, + showPagination, +}: DefaultDiscoverTableProps) => { + const displayedColumns = getLegacyDisplayedColumns( + columns, + indexPattern, + hideTimeColumn, + isShortDots + ); + const displayedColumnNames = displayedColumns.map((column) => column.name); + const pageSize = 50; + const [renderedRowCount, setRenderedRowCount] = useState(50); // Start with 50 rows + const [displayedRows, setDisplayedRows] = useState(rows.slice(0, pageSize)); + const [currentRowCounts, setCurrentRowCounts] = useState({ + startRow: 0, + endRow: rows.length < pageSize ? rows.length : pageSize, + }); + const observerRef = useRef(null); + const sentinelRef = useRef(null); + + const loadMoreRows = () => { + setRenderedRowCount((prevRowCount) => prevRowCount + 50); // Load 50 more rows + }; + + useEffect(() => { + const sentinel = sentinelRef.current; + observerRef.current = new IntersectionObserver( + (entries) => { + if (entries[0].isIntersecting) { + loadMoreRows(); + } + }, + { threshold: 1.0 } + ); + + if (sentinelRef.current) { + observerRef.current.observe(sentinelRef.current); + } + + return () => { + if (observerRef.current && sentinel) { + observerRef.current.unobserve(sentinel); + } + }; + }, []); + + const [activePage, setActivePage] = useState(0); + const pageCount = Math.ceil(rows.length / pageSize); + + const goToPage = (pageNumber: number) => { + const startRow = pageNumber * pageSize; + const endRow = + rows.length < pageNumber * pageSize + pageSize + ? rows.length + : pageNumber * pageSize + pageSize; + setCurrentRowCounts({ + startRow, + endRow, + }); + setDisplayedRows(rows.slice(startRow, endRow)); + setActivePage(pageNumber); + }; + + return ( + indexPattern && ( + <> + {showPagination ? ( + + ) : null} + + + + + + {(showPagination ? displayedRows : rows.slice(0, renderedRowCount)).map( + (row: OpenSearchSearchHit, index: number) => { + return ( + + ); + } + )} + +
+ {!showPagination && renderedRowCount < rows.length && ( +
+ +
+ )} + {!showPagination && rows.length === sampleSize && ( + + + + window.scrollTo(0, 0)}> + + + + )} + {showPagination ? ( + + ) : null} + + ) + ); +}; diff --git a/src/plugins/discover/public/application/components/default_discover_table/helper.tsx b/src/plugins/discover/public/application/components/default_discover_table/helper.tsx new file mode 100644 index 000000000000..82ac73acd784 --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/helper.tsx @@ -0,0 +1,100 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { shortenDottedString } from '../../helpers'; + +export interface LegacyDisplayedColumn { + name: string; + displayName: string; + isSortable: boolean; + isRemoveable: boolean; + colLeftIdx: number; + colRightIdx: number; +} + +export interface ColumnProps { + name: string; + displayName: string; + isSortable: boolean; + isRemoveable: boolean; + colLeftIdx: number; + colRightIdx: number; +} + +/** + * Returns properties necessary to display the time column + * If it's an IndexPattern with timefield, the time column is + * prepended, not moveable and removeable + * @param timeFieldName + */ +export function getTimeColumn(timeFieldName: string): ColumnProps { + return { + name: timeFieldName, + displayName: 'Time', + isSortable: true, + isRemoveable: false, + colLeftIdx: -1, + colRightIdx: -1, + }; +} +/** + * A given array of column names returns an array of properties + * necessary to display the columns. If the given indexPattern + * has a timefield, a time column is prepended + * @param columns + * @param indexPattern + * @param hideTimeField + * @param isShortDots + */ +export function getLegacyDisplayedColumns( + columns: string[], + indexPattern: IndexPattern, + hideTimeField: boolean, + isShortDots: boolean +): LegacyDisplayedColumn[] { + if (!Array.isArray(columns) || typeof indexPattern !== 'object' || !indexPattern.getFieldByName) { + return []; + } + const columnProps = columns.map((column, idx) => { + const field = indexPattern.getFieldByName(column); + return { + name: column, + displayName: isShortDots ? shortenDottedString(column) : column, + isSortable: field && field.sortable ? true : false, + isRemoveable: column !== '_source' || columns.length > 1, + colLeftIdx: idx - 1 < 0 ? -1 : idx - 1, + colRightIdx: idx + 1 >= columns.length ? -1 : idx + 1, + }; + }); + return !hideTimeField && indexPattern.timeFieldName + ? [getTimeColumn(indexPattern.timeFieldName), ...columnProps] + : columnProps; +} diff --git a/src/plugins/discover/public/application/components/default_discover_table/pagination.tsx b/src/plugins/discover/public/application/components/default_discover_table/pagination.tsx new file mode 100644 index 000000000000..feec7631f735 --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/pagination.tsx @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiFlexGroup, EuiFlexItem, EuiPagination, EuiTextColor } from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import './_pagination.scss'; + +interface Props { + pageCount: number; + activePage: number; + goToPage: (page: number) => void; + startItem: number; + endItem: number; + totalItems?: number; + sampleSize: number; +} + +export const Pagination = ({ + pageCount, + activePage, + goToPage, + startItem, + endItem, + totalItems = 0, + sampleSize, +}: Props) => { + return ( + + {endItem >= sampleSize && ( + + + + + + )} + + + + + goToPage(currentPage)} + /> + + + ); +}; diff --git a/src/plugins/discover/public/application/components/default_discover_table/table_cell.tsx b/src/plugins/discover/public/application/components/default_discover_table/table_cell.tsx new file mode 100644 index 000000000000..a542e70ff646 --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/table_cell.tsx @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import './_table_cell.scss'; + +import React from 'react'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { DocViewFilterFn } from '../../doc_views/doc_views_types'; + +export interface TableCellProps { + columnId: string; + isTimeField?: boolean; + onFilter: DocViewFilterFn; + filterable?: boolean; + fieldMapping?: any; + sanitizedCellValue: string; +} + +export const TableCell = ({ + columnId, + isTimeField, + onFilter, + fieldMapping, + sanitizedCellValue, +}: TableCellProps) => { + const content = ( + <> + {/* eslint-disable-next-line react/no-danger */} + + + + onFilter(columnId, fieldMapping, '+')} + iconType="plusInCircle" + aria-label={i18n.translate('discover.filterForValueLabel', { + defaultMessage: 'Filter for value', + })} + data-test-subj="filterForValue" + className="osdDocTableCell__filterButton" + /> + + + onFilter(columnId, fieldMapping, '-')} + iconType="minusInCircle" + aria-label={i18n.translate('discover.filterOutValueLabel', { + defaultMessage: 'Filter out value', + })} + data-test-subj="filterOutValue" + className="osdDocTableCell__filterButton" + /> + + + + ); + + return isTimeField ? ( + + {content} + + ) : ( + +
{content}
+ + ); +}; diff --git a/src/plugins/discover/public/application/components/default_discover_table/table_header.tsx b/src/plugins/discover/public/application/components/default_discover_table/table_header.tsx new file mode 100644 index 000000000000..52ef078387c8 --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/table_header.tsx @@ -0,0 +1,59 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import './_table_header.scss'; + +import React from 'react'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { TableHeaderColumn } from './table_header_column'; +import { LegacyDisplayedColumn } from './helper'; +import { getDefaultSort } from '../../view_components/utils/get_default_sort'; +import { SortDirection, SortOrder } from '../../../saved_searches/types'; + +interface Props { + displayedColumns: LegacyDisplayedColumn[]; + defaultSortOrder: SortDirection; + indexPattern: IndexPattern; + onChangeSortOrder?: (sortOrder: SortOrder[]) => void; + onRemoveColumn?: (name: string) => void; + onMoveColumn?: (colName: string, destination: number) => void; + sortOrder: SortOrder[]; +} + +export function TableHeader({ + displayedColumns, + defaultSortOrder, + indexPattern, + onChangeSortOrder, + onMoveColumn, + onRemoveColumn, + sortOrder, +}: Props) { + return ( + + + {displayedColumns.map((col) => { + return ( + + ); + })} + + ); +} diff --git a/src/plugins/discover/public/application/components/default_discover_table/table_header_column.tsx b/src/plugins/discover/public/application/components/default_discover_table/table_header_column.tsx new file mode 100644 index 000000000000..9316eb81817e --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/table_header_column.tsx @@ -0,0 +1,195 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import './_table_header.scss'; + +import React, { ReactNode } from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { SortOrder } from '../../../saved_searches/types'; + +interface Props { + colLeftIdx: number; // idx of the column to the left, -1 if moving is not possible + colRightIdx: number; // idx of the column to the right, -1 if moving is not possible + displayName: ReactNode; + isRemoveable: boolean; + isSortable?: boolean; + name: string; + onChangeSortOrder?: (sortOrder: SortOrder[]) => void; + onMoveColumn?: (colName: string, destination: number) => void; + onRemoveColumn?: (name: string) => void; + sortOrder: SortOrder[]; +} + +const sortDirectionToIcon: Record = { + desc: 'sortDown', + asc: 'sortUp', + '': 'sortable', +}; + +export function TableHeaderColumn({ + colLeftIdx, + colRightIdx, + displayName, + isRemoveable, + isSortable, + name, + onChangeSortOrder, + onMoveColumn, + onRemoveColumn, + sortOrder, +}: Props) { + const currentSortWithoutColumn = sortOrder.filter((pair) => pair[0] !== name); + const currentColumnSort = sortOrder.find((pair) => pair[0] === name); + const currentColumnSortDirection = (currentColumnSort && currentColumnSort[1]) || ''; + + const btnSortIcon = sortDirectionToIcon[currentColumnSortDirection]; + const btnSortClassName = + currentColumnSortDirection !== '' + ? btnSortIcon + : `osdDocTableHeader__sortChange ${btnSortIcon}`; + + const handleChangeSortOrder = () => { + if (!onChangeSortOrder) return; + + // Cycle goes Unsorted -> Asc -> Desc -> Unsorted + if (currentColumnSort === undefined) { + onChangeSortOrder([...currentSortWithoutColumn, [name, 'asc']]); + } else if (currentColumnSortDirection === 'asc') { + onChangeSortOrder([...currentSortWithoutColumn, [name, 'desc']]); + } else if (currentColumnSortDirection === 'desc' && currentSortWithoutColumn.length === 0) { + // If we're at the end of the cycle and this is the only existing sort, we switch + // back to ascending sort instead of removing it. + onChangeSortOrder([...currentSortWithoutColumn, [name, 'asc']]); + } else { + onChangeSortOrder(currentSortWithoutColumn); + } + }; + + const getSortButtonAriaLabel = () => { + const sortAscendingMessage = i18n.translate( + 'discover.docTable.tableHeader.sortByColumnAscendingAriaLabel', + { + defaultMessage: 'Sort {columnName} ascending', + values: { columnName: name }, + } + ); + const sortDescendingMessage = i18n.translate( + 'discover.docTable.tableHeader.sortByColumnDescendingAriaLabel', + { + defaultMessage: 'Sort {columnName} descending', + values: { columnName: name }, + } + ); + const stopSortingMessage = i18n.translate( + 'discover.docTable.tableHeader.sortByColumnUnsortedAriaLabel', + { + defaultMessage: 'Stop sorting on {columnName}', + values: { columnName: name }, + } + ); + + if (currentColumnSort === undefined) { + return sortAscendingMessage; + } else if (currentColumnSortDirection === 'asc') { + return sortDescendingMessage; + } else if (currentColumnSortDirection === 'desc' && currentSortWithoutColumn.length === 0) { + return sortAscendingMessage; + } else { + return stopSortingMessage; + } + }; + + // action buttons displayed on the right side of the column name + const buttons = [ + // Sort Button + { + active: isSortable && typeof onChangeSortOrder === 'function', + ariaLabel: getSortButtonAriaLabel(), + className: btnSortClassName, + onClick: handleChangeSortOrder, + testSubject: `docTableHeaderFieldSort_${name}`, + tooltip: getSortButtonAriaLabel(), + iconType: btnSortIcon, + }, + // Remove Button + { + active: isRemoveable && typeof onRemoveColumn === 'function', + ariaLabel: i18n.translate('discover.docTable.tableHeader.removeColumnButtonAriaLabel', { + defaultMessage: 'Remove {columnName} column', + values: { columnName: name }, + }), + className: 'fa fa-remove osdDocTableHeader__move', + onClick: () => onRemoveColumn && onRemoveColumn(name), + testSubject: `docTableRemoveHeader-${name}`, + tooltip: i18n.translate('discover.docTable.tableHeader.removeColumnButtonTooltip', { + defaultMessage: 'Remove Column', + }), + iconType: 'cross', + }, + // Move Left Button + { + active: colLeftIdx >= 0 && typeof onMoveColumn === 'function', + ariaLabel: i18n.translate('discover.docTable.tableHeader.moveColumnLeftButtonAriaLabel', { + defaultMessage: 'Move {columnName} column to the left', + values: { columnName: name }, + }), + className: 'fa fa-angle-double-left osdDocTableHeader__move', + onClick: () => onMoveColumn && onMoveColumn(name, colLeftIdx), + testSubject: `docTableMoveLeftHeader-${name}`, + tooltip: i18n.translate('discover.docTable.tableHeader.moveColumnLeftButtonTooltip', { + defaultMessage: 'Move column to the left', + }), + iconType: 'sortLeft', + }, + // Move Right Button + { + active: colRightIdx >= 0 && typeof onMoveColumn === 'function', + ariaLabel: i18n.translate('discover.docTable.tableHeader.moveColumnRightButtonAriaLabel', { + defaultMessage: 'Move {columnName} column to the right', + values: { columnName: name }, + }), + className: 'fa fa-angle-double-right osdDocTableHeader__move', + onClick: () => onMoveColumn && onMoveColumn(name, colRightIdx), + testSubject: `docTableMoveRightHeader-${name}`, + tooltip: i18n.translate('discover.docTable.tableHeader.moveColumnRightButtonTooltip', { + defaultMessage: 'Move column to the right', + }), + iconType: 'sortRight', + }, + ]; + + return ( + + + {displayName} + {buttons + .filter((button) => button.active) + .map((button, idx) => ( + + + + ))} + + + ); +} diff --git a/src/plugins/discover/public/application/components/default_discover_table/table_rows.tsx b/src/plugins/discover/public/application/components/default_discover_table/table_rows.tsx new file mode 100644 index 000000000000..cb2767afd801 --- /dev/null +++ b/src/plugins/discover/public/application/components/default_discover_table/table_rows.tsx @@ -0,0 +1,183 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React, { useState } from 'react'; +import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiIcon } from '@elastic/eui'; +import dompurify from 'dompurify'; +import { TableCell } from './table_cell'; +import { DocViewerLinks } from '../doc_viewer_links/doc_viewer_links'; +import { DocViewer } from '../doc_viewer/doc_viewer'; +import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { fetchSourceTypeDataCell } from '../data_grid/data_grid_table_cell_value'; + +export interface TableRowProps { + row: OpenSearchSearchHit; + columns: string[]; + indexPattern: IndexPattern; + onRemoveColumn: (column: string) => void; + onAddColumn: (column: string) => void; + onFilter: DocViewFilterFn; + onClose: () => void; + isShortDots: boolean; +} + +export const TableRow = ({ + row, + columns, + indexPattern, + onRemoveColumn, + onAddColumn, + onFilter, + onClose, + isShortDots, +}: TableRowProps) => { + const flattened = indexPattern.flattenHit(row); + const [isExpanded, setIsExpanded] = useState(false); + const tableRow = ( + + + setIsExpanded(!isExpanded)} + iconType={isExpanded ? 'arrowDown' : 'arrowRight'} + aria-label="Next" + data-test-subj="docTableExpandToggleColumn" + /> + + {columns.map((colName) => { + const fieldInfo = indexPattern.fields.getByName(colName); + const fieldMapping = flattened[colName]; + + if (typeof row === 'undefined') { + return ( + + - + + ); + } + + if (fieldInfo?.type === '_source') { + return ( + +
+ {fetchSourceTypeDataCell(indexPattern, row, colName, false, isShortDots)} +
+ + ); + } + + const formattedValue = indexPattern.formatField(row, colName); + + if (typeof formattedValue === 'undefined') { + return ( + + - + + ); + } + + const sanitizedCellValue = dompurify.sanitize(formattedValue); + + if (!fieldInfo?.filterable) { + return ( + +
+ {/* eslint-disable-next-line react/no-danger */} + +
+ + ); + } + + return ( + + ); + })} + + ); + + const expandedTableRow = ( + + + + + + + +

+ Expanded document +

+
+ + + +
+ + + { + onRemoveColumn(columnName); + onClose(); + }} + onAddColumn={(columnName: string) => { + onAddColumn(columnName); + onClose(); + }} + filter={(mapping, value, mode) => { + onFilter(mapping, value, mode); + onClose(); + }} + /> + + + + + ); + + return ( + <> + {tableRow} + {isExpanded && expandedTableRow} + + ); +}; diff --git a/src/plugins/discover/public/application/components/doc_views/context_app.tsx b/src/plugins/discover/public/application/components/doc_views/context_app.tsx index b6fe75473b58..c3a6da2d8cef 100644 --- a/src/plugins/discover/public/application/components/doc_views/context_app.tsx +++ b/src/plugins/discover/public/application/components/doc_views/context_app.tsx @@ -3,8 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useMemo, Fragment } from 'react'; -import { useCallback } from 'react'; +import React, { useMemo, useCallback } from 'react'; import { SurrDocType } from './context/api/context'; import { ActionBar } from './context/components/action_bar/action_bar'; import { CONTEXT_STEP_SETTING, DOC_HIDE_TIME_COLUMN_SETTING } from '../../../../common'; @@ -16,6 +15,7 @@ import { DataGridTable } from '../data_grid/data_grid_table'; import { DocViewFilterFn } from '../../doc_views/doc_views_types'; import { IndexPattern } from '../../../opensearch_dashboards_services'; import { AppState } from './context/utils/context_state'; +import { SortOrder } from '../../../saved_searches/types'; export interface Props { onAddFilter: DocViewFilterFn; @@ -73,7 +73,7 @@ export function ContextApp({ [setAppState] ); - const sort = useMemo(() => { + const sort: SortOrder[] = useMemo(() => { return [[indexPattern.timeFieldName!, SortDirection.desc]]; }, [indexPattern]); @@ -83,7 +83,7 @@ export function ContextApp({ ); return ( - + <> {}} onFilter={onAddFilter} + onMoveColumn={() => {}} onRemoveColumn={() => {}} onSetColumns={() => {}} onSort={() => {}} sort={sort} rows={rows} displayTimeColumn={displayTimeColumn} - services={services} isToolbarVisible={false} isContextView={true} /> @@ -120,6 +120,6 @@ export function ContextApp({ onChangeCount={onChangeCount} type={SurrDocType.SUCCESSORS} /> - + ); } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.scss b/src/plugins/discover/public/application/components/sidebar/discover_field.scss index 39cacdcd0c97..e869707ebcd3 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.scss @@ -14,7 +14,8 @@ } &:hover &__actionButton, - &:focus &__actionButton { + &:focus &__actionButton, + .dscSidebarField__actionButton:focus { opacity: 1; } } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss index 7b1d1e2a82e7..b0bffeb2b216 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss @@ -1,8 +1,14 @@ -.dscSideBarFieldListHeader { - padding-left: $euiSizeS; +.dscSideBar_fieldGroup { + width: 100%; + + .euiButtonEmpty__content { + justify-content: flex-end; + } } .dscSidebar__item:hover, .dscSidebar__item:focus { - background-color: tintOrShade($euiColorPrimary, 90%, 90%); + background-color: $euiColorLightestShade; + padding-left: $euiSizeXS; + margin-left: -$euiSizeXS; } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx index f5e091e6ab82..9dcb5bf337cb 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -32,15 +32,15 @@ import './discover_sidebar.scss'; import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { i18n } from '@osd/i18n'; import { - EuiTitle, EuiDragDropContext, DropResult, EuiDroppable, EuiDraggable, EuiPanel, EuiSplitPanel, + EuiButtonEmpty, } from '@elastic/eui'; -import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { I18nProvider } from '@osd/i18n/react'; import { DiscoverField } from './discover_field'; import { DiscoverFieldSearch } from './discover_field_search'; import { FIELDS_LIMIT_SETTING } from '../../../../common'; @@ -50,6 +50,7 @@ import { getDetails } from './lib/get_details'; import { getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter'; import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; import { getServices } from '../../../opensearch_dashboards_services'; +import { FieldDetails } from './types'; export interface DiscoverSidebarProps { /** @@ -87,16 +88,8 @@ export interface DiscoverSidebarProps { selectedIndexPattern?: IndexPattern; } -export function DiscoverSidebar({ - columns, - fieldCounts, - hits, - onAddField, - onAddFilter, - onRemoveField, - onReorderFields, - selectedIndexPattern, -}: DiscoverSidebarProps) { +export function DiscoverSidebar(props: DiscoverSidebarProps) { + const { columns, fieldCounts, hits, onAddField, onReorderFields, selectedIndexPattern } = props; const [fields, setFields] = useState(null); const [fieldFilterState, setFieldFilterState] = useState(getDefaultFieldFilter()); const services = useMemo(() => getServices(), []); @@ -120,7 +113,7 @@ export function DiscoverSidebar({ ); const popularLimit = services.uiSettings.get(FIELDS_LIMIT_SETTING); - const useShortDots = services.uiSettings.get(UI_SETTINGS.SHORT_DOTS_ENABLE); + const shortDotsEnabled = services.uiSettings.get(UI_SETTINGS.SHORT_DOTS_ENABLE); const { selected: selectedFields, @@ -193,6 +186,7 @@ export function DiscoverSidebar({ )} borderRadius="none" color="transparent" + hasBorder={false} > {fields.length > 0 && ( <> - -

- -

-
- - {selectedFields.map((field: IndexPatternField, index) => { - return ( - - - {/* The panel cannot exist in the DiscoverField component if the on focus highlight during keyboard navigation is needed */} - - - - ); + - -

- -

-
- + {...props} + /> {popularFields.length > 0 && ( - - - - - - {popularFields.map((field: IndexPatternField, index) => { - return ( - - - {/* The panel cannot exist in the DiscoverField component if the on focus highlight during keyboard navigation is needed */} - - - - ); - })} - - + )} - - {unpopularFields.map((field: IndexPatternField, index) => { - return ( - - - {/* The panel cannot exist in the DiscoverField component if the on focus highlight during keyboard navigation is needed */} - - - - ); + + {...props} + /> )}
@@ -354,3 +238,85 @@ export function DiscoverSidebar({ ); } + +interface FieldGroupProps extends DiscoverSidebarProps { + category: 'selected' | 'popular' | 'unpopular'; + title: string; + fields: IndexPatternField[]; + getDetailsByField: (field: IndexPatternField) => FieldDetails; + shortDotsEnabled: boolean; +} + +const FieldList = ({ + category, + title, + fields, + columns, + selectedIndexPattern, + onAddField, + onRemoveField, + onAddFilter, + getDetailsByField, + shortDotsEnabled, +}: FieldGroupProps) => { + const [expanded, setExpanded] = useState(true); + + if (!selectedIndexPattern) return null; + + return ( + <> + setExpanded(!expanded)} + size="xs" + className="dscSideBar_fieldGroup" + > + {title} + + {expanded && ( + + {fields.map((field: IndexPatternField, index) => { + return ( + + + {/* The panel cannot exist in the DiscoverField component if the on focus highlight during keyboard navigation is needed */} + + + + ); + })} + + )} + + ); +}; diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx index 29c19887412c..1b20c444e860 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx @@ -5,8 +5,8 @@ import { i18n } from '@osd/i18n'; import React from 'react'; +import { EuiText } from '@elastic/eui'; import { DiscoverViewServices } from '../../../build_services'; -import { showOpenSearchPanel } from './show_open_search_panel'; import { SavedSearch } from '../../../saved_searches'; import { Adapters } from '../../../../../inspector/public'; import { TopNavMenuData } from '../../../../../navigation/public'; @@ -16,11 +16,17 @@ import { SavedObjectSaveModal, showSaveModal, } from '../../../../../saved_objects/public'; +import { + OpenSearchDashboardsContextProvider, + toMountPoint, +} from '../../../../../opensearch_dashboards_react/public'; import { DiscoverState, setSavedSearchId } from '../../utils/state_management'; import { DOC_HIDE_TIME_COLUMN_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../../common'; import { getSortForSearchSource } from '../../view_components/utils/get_sort_for_search_source'; import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; import { syncQueryStateWithUrl } from '../../../../../data/public'; +import { getNewDiscoverSetting, setNewDiscoverSetting } from '../utils/local_storage'; +import { OpenSearchPanel } from './open_search_panel'; export const getTopNavLinks = ( services: DiscoverViewServices, @@ -38,6 +44,7 @@ export const getTopNavLinks = ( store, data: { query }, osdUrlStateStorage, + storage, } = services; const newSearch = { @@ -162,11 +169,20 @@ export const getTopNavLinks = ( }), testId: 'discoverOpenButton', run: () => { - showOpenSearchPanel({ - makeUrl: (searchId) => `#/view/${encodeURIComponent(searchId)}`, - I18nContext: core.i18n.Context, - services, - }); + const flyoutSession = services.overlays.openFlyout( + toMountPoint( + + { + if (flyoutSession) { + flyoutSession.close(); + } + }} + makeUrl={(searchId) => `#/view/${encodeURIComponent(searchId)}`} + /> + + ) + ); }, }; @@ -197,7 +213,7 @@ export const getTopNavLinks = ( ...sharingData, title: savedSearch.title, }, - isDirty: !savedSearch.id || state.isDirty, + isDirty: !savedSearch.id || state.isDirty || false, }); }, }; @@ -218,7 +234,61 @@ export const getTopNavLinks = ( }, }; + const newDiscoverButtonLabel = i18n.translate('discover.localMenu.discoverButton.label.new', { + defaultMessage: 'Try new Discover', + }); + const oldDiscoverButtonLabel = i18n.translate('discover.localMenu.discoverButton.label.old', { + defaultMessage: 'Use legacy Discover', + }); + const isNewDiscover = getNewDiscoverSetting(storage); + const newTable: TopNavMenuData = { + id: 'table-datagrid', + label: isNewDiscover ? oldDiscoverButtonLabel : newDiscoverButtonLabel, + description: i18n.translate('discover.localMenu.newTableDescription', { + defaultMessage: 'New Discover toggle Experience', + }), + testId: 'datagridTableButton', + run: async () => { + // Read the current state from localStorage + const newDiscoverEnabled = getNewDiscoverSetting(storage); + if (newDiscoverEnabled) { + const confirmed = await services.overlays.openConfirm( + toMountPoint( + +

+ Help drive future improvements by{' '} + + providing feedback + {' '} + about your experience. +

+
+ ), + { + title: i18n.translate('discover.localMenu.newTableConfirmModalTitle', { + defaultMessage: 'Share your thoughts on the latest Discover features', + }), + cancelButtonText: 'Cancel', + confirmButtonText: 'Turn off new features', + defaultFocusedButton: 'confirm', + } + ); + + if (confirmed) { + setNewDiscoverSetting(false, storage); + window.location.reload(); + } + } else { + // Save the new setting to localStorage + setNewDiscoverSetting(true, storage); + window.location.reload(); + } + }, + iconType: isNewDiscover ? 'editorUndo' : 'cheer', + }; + return [ + newTable, newSearch, ...(capabilities.discover?.save ? [saveSearch] : []), openSearch, @@ -278,7 +348,7 @@ const getSharingData = async ({ const searchSourceInstance = searchSource.createCopy(); const indexPattern = await searchSourceInstance.getField('index'); - const { searchFields, selectFields } = await getSharingDataFields( + const { searchFields } = await getSharingDataFields( state.columns, services.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING), indexPattern?.timeFieldName diff --git a/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx b/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx deleted file mode 100644 index 5bc95b1b9cf2..000000000000 --- a/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx +++ /dev/null @@ -1,70 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import ReactDOM from 'react-dom'; -import { OpenSearchPanel } from './open_search_panel'; -import { I18nStart } from '../../../../../../core/public'; -import { OpenSearchDashboardsContextProvider } from '../../../../../opensearch_dashboards_react/public'; -import { DiscoverViewServices } from '../../../build_services'; - -let isOpen = false; - -export function showOpenSearchPanel({ - makeUrl, - I18nContext, - services, -}: { - makeUrl: (id: string) => string; - I18nContext: I18nStart['Context']; - services: DiscoverViewServices; -}) { - if (isOpen) { - return; - } - - isOpen = true; - const container = document.createElement('div'); - const onClose = () => { - ReactDOM.unmountComponentAtNode(container); - document.body.removeChild(container); - isOpen = false; - }; - - document.body.appendChild(container); - const element = ( - - - - - - ); - ReactDOM.render(element, container); -} diff --git a/src/plugins/discover/public/application/components/utils/local_storage.ts b/src/plugins/discover/public/application/components/utils/local_storage.ts new file mode 100644 index 000000000000..5e812de8e97d --- /dev/null +++ b/src/plugins/discover/public/application/components/utils/local_storage.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Storage } from '../../../../../opensearch_dashboards_utils/public'; + +export const NEW_DISCOVER_KEY = 'discover:newExpereince'; + +export const getNewDiscoverSetting = (storage: Storage): boolean => { + const storedValue = storage.get(NEW_DISCOVER_KEY); + return storedValue !== null ? JSON.parse(storedValue) : false; +}; + +export const setNewDiscoverSetting = (value: boolean, storage: Storage) => { + storage.set(NEW_DISCOVER_KEY, JSON.stringify(value)); +}; diff --git a/src/plugins/discover/public/application/helpers/breadcrumbs.ts b/src/plugins/discover/public/application/helpers/breadcrumbs.ts index 382deec77eee..429c4db5e617 100644 --- a/src/plugins/discover/public/application/helpers/breadcrumbs.ts +++ b/src/plugins/discover/public/application/helpers/breadcrumbs.ts @@ -39,7 +39,7 @@ export function getRootBreadcrumbs(): EuiBreadcrumb[] { text: i18n.translate('discover.rootBreadcrumb', { defaultMessage: 'Discover', }), - onClick: () => core.application.navigateToApp('discover'), + onClick: () => core.application.navigateToApp('data-explorer', { path: 'discover' }), }, ]; } diff --git a/src/plugins/discover/public/application/utils/state_management/common.test.ts b/src/plugins/discover/public/application/utils/state_management/common.test.ts index 64a2dba99dd4..c9c41a914c74 100644 --- a/src/plugins/discover/public/application/utils/state_management/common.test.ts +++ b/src/plugins/discover/public/application/utils/state_management/common.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { addColumn, removeColumn, reorderColumn } from './common'; +import { addColumn, removeColumn, reorderColumn, moveColumn } from './common'; describe('commonUtils', () => { it('should handle addColumn', () => { @@ -22,4 +22,41 @@ describe('commonUtils', () => { 'column1', ]); }); + + it('should handle moveColumn', () => { + // test moving a column within the array + expect(moveColumn(['column1', 'column2', 'column3'], 'column2', 0)).toEqual([ + 'column2', + 'column1', + 'column3', + ]); + + // test moving a column to the same index (should result in no change) + expect(moveColumn(['column1', 'column2', 'column3'], 'column2', 1)).toEqual([ + 'column1', + 'column2', + 'column3', + ]); + + // test moving a column to the end + expect(moveColumn(['column1', 'column2', 'column3'], 'column1', 2)).toEqual([ + 'column2', + 'column3', + 'column1', + ]); + + // test trying to move a column to an index out of bounds (should return original array) + expect(moveColumn(['column1', 'column2', 'column3'], 'column1', 3)).toEqual([ + 'column1', + 'column2', + 'column3', + ]); + + // test trying to move a column that doesn't exist (should return original array) + expect(moveColumn(['column1', 'column2', 'column3'], 'column4', 1)).toEqual([ + 'column1', + 'column2', + 'column3', + ]); + }); }); diff --git a/src/plugins/discover/public/application/utils/state_management/common.ts b/src/plugins/discover/public/application/utils/state_management/common.ts index 800753fb4a36..1acba05ecb75 100644 --- a/src/plugins/discover/public/application/utils/state_management/common.ts +++ b/src/plugins/discover/public/application/utils/state_management/common.ts @@ -21,3 +21,13 @@ export const reorderColumn = (columns: string[], source: number, destination: nu newColumns.splice(destination, 0, removed); return newColumns; }; + +export function moveColumn(columns: string[], columnName: string, newIndex: number) { + if (newIndex < 0 || newIndex >= columns.length || !columns.includes(columnName)) { + return columns; + } + const modifiedColumns = [...columns]; + modifiedColumns.splice(modifiedColumns.indexOf(columnName), 1); // remove at old index + modifiedColumns.splice(newIndex, 0, columnName); // insert before new index + return modifiedColumns; +} diff --git a/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx index cbfc9c3769b0..ba94236e1492 100644 --- a/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx @@ -82,4 +82,56 @@ describe('discoverSlice', () => { const result = discoverSlice.reducer(initialState, action); expect(result.sort).toEqual([['field2', 'desc']]); }); + + it('should handle moveColumn', () => { + initialState = { + columns: ['column1', 'column2', 'column3'], + sort: [], + }; + const action = { + type: 'discover/moveColumn', + payload: { columnName: 'column2', destination: 0 }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column2', 'column1', 'column3']); + }); + + it('should maintain columns order when moving a column to its current position', () => { + initialState = { + columns: ['column1', 'column2', 'column3'], + sort: [], + }; + const action = { + type: 'discover/moveColumn', + payload: { columnName: 'column2', destination: 1 }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column1', 'column2', 'column3']); + }); + + it('should handle moveColumn when destination is out of range', () => { + initialState = { + columns: ['column1', 'column2', 'column3'], + sort: [], + }; + const action = { + type: 'discover/moveColumn', + payload: { columnName: 'column1', destination: 5 }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column1', 'column2', 'column3']); + }); + + it('should not change columns if column to move does not exist', () => { + initialState = { + columns: ['column1', 'column2', 'column3'], + sort: [], + }; + const action = { + type: 'discover/moveColumn', + payload: { columnName: 'nonExistingColumn', destination: 0 }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column1', 'column2', 'column3']); + }); }); diff --git a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx index f90d400ff3d5..90fb417c2b0e 100644 --- a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx @@ -42,7 +42,16 @@ export interface DiscoverState { * dirty flag to indicate if the saved search has been modified * since the last save */ - isDirty: boolean; + isDirty?: boolean; + /** + * Metadata for the view + */ + metadata?: { + /** + * Number of lines to display per row + */ + lineCount?: number; + }; } export interface DiscoverRootState extends RootState { @@ -128,6 +137,17 @@ export const discoverSlice = createSlice({ isDirty: true, }; }, + moveColumn(state, action: PayloadAction<{ columnName: string; destination: number }>) { + const columns = utils.moveColumn( + state.columns, + action.payload.columnName, + action.payload.destination + ); + return { + ...state, + columns, + }; + }, setColumns(state, action: PayloadAction<{ columns: string[] }>) { return { ...state, @@ -159,6 +179,15 @@ export const discoverSlice = createSlice({ isDirty: false, }; }, + setMetadata(state, action: PayloadAction>) { + return { + ...state, + metadata: { + ...state.metadata, + ...action.payload, + }, + }; + }, }, }); @@ -167,11 +196,13 @@ export const { addColumn, removeColumn, reorderColumn, + moveColumn, setColumns, setSort, setInterval, setState, updateState, setSavedSearchId, + setMetadata, } = discoverSlice.actions; export const { reducer } = discoverSlice; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss b/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss index 92e1131a05e3..36408bd88366 100644 --- a/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss +++ b/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss @@ -6,6 +6,10 @@ /* stylelint-disable-next-line */ container-name: canvas; height: 100%; + + &_results { + margin-left: $euiSizeM; + } } // TopNav styles for the Discover diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx index 3f7bbf4ec7b8..17f9f26e8b54 100644 --- a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx @@ -10,7 +10,9 @@ import { DataGridTable } from '../../components/data_grid/data_grid_table'; import { useDiscoverContext } from '../context'; import { addColumn, + moveColumn, removeColumn, + reorderColumn, setColumns, setSort, useDispatch, @@ -55,6 +57,14 @@ export const DiscoverTable = ({ rows }: Props) => { dispatch(removeColumn(col)); }; + + const onMoveColumn = (col: string, destination: number) => { + if (indexPattern && capabilities.discover?.save) { + popularizeField(indexPattern, col, indexPatterns); + } + dispatch(moveColumn({ columnName: col, destination })); + }; + const onSetColumns = (cols: string[]) => dispatch(setColumns({ columns: cols })); const onSetSort = (s: SortOrder[]) => { dispatch(setSort(s)); @@ -96,6 +106,7 @@ export const DiscoverTable = ({ rows }: Props) => { indexPattern={indexPattern} onAddColumn={onAddColumn} onFilter={onAddFilter as DocViewFilterFn} + onMoveColumn={onMoveColumn} onRemoveColumn={onRemoveColumn} onSetColumns={onSetColumns} onSort={onSetSort} diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index d5c54158e997..e3efe878aa83 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -112,14 +112,10 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro )} {fetchState.status === ResultStatus.LOADING && } {fetchState.status === ResultStatus.READY && ( - <> - - - - - + + - + )}
); diff --git a/src/plugins/discover/public/application/view_components/utils/get_default_sort.ts b/src/plugins/discover/public/application/view_components/utils/get_default_sort.ts index 584e47047c57..2a958ec00fcb 100644 --- a/src/plugins/discover/public/application/view_components/utils/get_default_sort.ts +++ b/src/plugins/discover/public/application/view_components/utils/get_default_sort.ts @@ -29,18 +29,17 @@ */ import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { SortOrder } from '../../../saved_searches/types'; // @ts-ignore import { isSortable } from './get_sort'; -export type SortOrder = [string, string]; - /** * use in case the user didn't manually sort. * the default sort is returned depending of the index pattern */ export function getDefaultSort( indexPattern: IndexPattern, - defaultSortOrder: string = 'desc' + defaultSortOrder: 'asc' | 'desc' = 'desc' ): SortOrder[] { if (indexPattern.timeFieldName && isSortable(indexPattern.timeFieldName, indexPattern)) { return [[indexPattern.timeFieldName, defaultSortOrder]]; diff --git a/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts b/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts index b19128a432e0..4eba4d833944 100644 --- a/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts +++ b/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts @@ -31,8 +31,7 @@ import { OpenSearchQuerySortValue, IndexPattern } from '../../../opensearch_dashboards_services'; import { getSort } from './get_sort'; import { getDefaultSort } from './get_default_sort'; - -export type SortOrder = [string, string]; +import { SortDirection, SortOrder } from '../../../saved_searches/types'; /** * Prepares sort for search source, that's sending the request to OpenSearch @@ -44,7 +43,7 @@ export type SortOrder = [string, string]; export function getSortForSearchSource( sort?: SortOrder[], indexPattern?: IndexPattern, - defaultDirection: string = 'desc' + defaultDirection: SortDirection = 'desc' ): OpenSearchQuerySortValue[] { if (!sort || !indexPattern) { return []; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 785e72536417..36a8908594f0 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -58,6 +58,7 @@ import { OpenSearchDashboardsLegacyStart } from '../../opensearch_dashboards_leg import { UrlForwardingStart } from '../../url_forwarding/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; import { DataExplorerServices } from '../../data_explorer/public'; +import { Storage } from '../../opensearch_dashboards_utils/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -82,6 +83,7 @@ export interface DiscoverServices { getSavedSearchUrlById: (id: string) => Promise; uiSettings: IUiSettingsClient; visualizations: VisualizationsStart; + storage: Storage; } export function buildServices( @@ -97,6 +99,7 @@ export function buildServices( overlays: core.overlays, }; const savedObjectService = createSavedSearchesLoader(services); + const storage = new Storage(localStorage); return { addBasePath: core.http.basePath.prepend, @@ -123,6 +126,7 @@ export function buildServices( toastNotifications: core.notifications.toasts, uiSettings: core.uiSettings, visualizations: plugins.visualizations, + storage, }; } diff --git a/src/plugins/discover/public/embeddable/search_embeddable.tsx b/src/plugins/discover/public/embeddable/search_embeddable.tsx index a37a001ad798..79080cf8657f 100644 --- a/src/plugins/discover/public/embeddable/search_embeddable.tsx +++ b/src/plugins/discover/public/embeddable/search_embeddable.tsx @@ -80,10 +80,11 @@ export interface SearchProps { onRemoveColumn?: (column: string) => void; onAddColumn?: (column: string) => void; onMoveColumn?: (column: string, index: number) => void; + onReorderColumn?: (col: string, source: number, destination: number) => void; onFilter?: (field: IFieldType, value: string[], operator: string) => void; rows?: any[]; indexPattern?: IndexPattern; - totalHitCount?: number; + hits?: number; isLoading?: boolean; displayTimeColumn?: boolean; services: DiscoverServices; @@ -359,7 +360,7 @@ export class SearchEmbeddable inspectorRequest.stats(getResponseInspectorStats(resp, searchSource)).ok({ json: resp }); this.searchProps!.rows = resp.hits.hits; - this.searchProps!.totalHitCount = resp.hits.total; + this.searchProps!.hits = resp.hits.total; this.searchProps!.isLoading = false; } catch (error) { this.updateOutput({ loading: false, error }); diff --git a/src/plugins/discover/public/embeddable/search_embeddable_component.tsx b/src/plugins/discover/public/embeddable/search_embeddable_component.tsx index 97df2e5c45b7..ecf0f1bc2b30 100644 --- a/src/plugins/discover/public/embeddable/search_embeddable_component.tsx +++ b/src/plugins/discover/public/embeddable/search_embeddable_component.tsx @@ -12,55 +12,62 @@ import { DataGridTableProps, } from '../application/components/data_grid/data_grid_table'; import { VisualizationNoResults } from '../../../visualizations/public'; +import { getServices } from '../opensearch_dashboards_services'; import './search_embeddable.scss'; +import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; interface SearchEmbeddableProps { searchProps: SearchProps; } -export interface DiscoverEmbeddableProps extends DataGridTableProps { - totalHitCount: number; -} +export type DiscoverEmbeddableProps = DataGridTableProps; export const DataGridTableMemoized = React.memo((props: DataGridTableProps) => ( )); export function SearchEmbeddableComponent({ searchProps }: SearchEmbeddableProps) { + const services = getServices(); const discoverEmbeddableProps = { columns: searchProps.columns, indexPattern: searchProps.indexPattern, onAddColumn: searchProps.onAddColumn, onFilter: searchProps.onFilter, + onMoveColumn: searchProps.onMoveColumn, onRemoveColumn: searchProps.onRemoveColumn, + onReorderColumn: searchProps.onReorderColumn, onSort: searchProps.onSort, rows: searchProps.rows, onSetColumns: searchProps.onSetColumns, sort: searchProps.sort, displayTimeColumn: searchProps.displayTimeColumn, services: searchProps.services, - totalHitCount: searchProps.totalHitCount, + hits: searchProps.hits, title: searchProps.title, description: searchProps.description, + showPagination: true, } as DiscoverEmbeddableProps; return ( - - {discoverEmbeddableProps.totalHitCount !== 0 ? ( - - - - ) : ( - - - - )} - + + + {discoverEmbeddableProps.hits !== 0 ? ( + + + + ) : ( + + + + )} + + ); } diff --git a/src/plugins/discover/public/embeddable/types.ts b/src/plugins/discover/public/embeddable/types.ts index 24a1aac92b49..855f2d96e47b 100644 --- a/src/plugins/discover/public/embeddable/types.ts +++ b/src/plugins/discover/public/embeddable/types.ts @@ -34,7 +34,7 @@ import { EmbeddableOutput, IEmbeddable, } from 'src/plugins/embeddable/public'; -import { Filter, IIndexPattern, TimeRange, Query } from '../../../../data/public'; +import { Filter, IIndexPattern, TimeRange, Query } from 'src/plugins/data/public'; import { SortOrder } from '../saved_searches/types'; import { SavedSearch } from '../saved_searches'; diff --git a/src/plugins/discover/public/migrate_state.ts b/src/plugins/discover/public/migrate_state.ts index 2a3cd77d26bc..b0ec5af810aa 100644 --- a/src/plugins/discover/public/migrate_state.ts +++ b/src/plugins/discover/public/migrate_state.ts @@ -66,12 +66,12 @@ export function migrateUrlState(oldPath: string, newPath = '/'): string { let path = newPath; const pathPatterns = [ { - pattern: '#/context/:indexPattern/:id\\?:appState?', + pattern: '#/context/:indexPattern/:id', extraState: { docView: 'context' }, path: `context`, }, { - pattern: '#/doc/:indexPattern/:index\\?:appState?', + pattern: '#/doc/:indexPattern/:id', extraState: { docView: 'doc' }, path: `doc`, }, @@ -99,6 +99,7 @@ export function migrateUrlState(oldPath: string, newPath = '/'): string { case `doc`: case `context`: path = oldPath; + break; case `discover`: case `savedSearch`: const params = matchPath(oldPath, { diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index f67df00a900c..73cb25774c4a 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -31,7 +31,8 @@ import { SavedObject } from '../../../saved_objects/public'; import { ISearchSource } from '../../../data/public'; -export type SortOrder = [string, 'asc' | 'desc']; +export type SortDirection = 'asc' | 'desc'; +export type SortOrder = [string, SortDirection]; export interface SavedSearch extends Pick< SavedObject, diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/components/styles.scss b/src/plugins/vis_augmenter/public/view_events_flyout/components/styles.scss index 75cb154d1957..1fb5c007fadc 100644 --- a/src/plugins/vis_augmenter/public/view_events_flyout/components/styles.scss +++ b/src/plugins/vis_augmenter/public/view_events_flyout/components/styles.scss @@ -1,7 +1,7 @@ $vis-description-width: 200px; $event-vis-height: 55px; $timeline-panel-height: 90px; -$content-padding-top: 110px; // Padding needed within view events flyout content to sit comfortably below flyout header +$content-padding-top: 110px; // Padding required for view events flyout content to align below flyout header. $date-range-height: 45px; // Static height we want for the date range picker component $error-icon-padding-right: -8px; // This is so the error icon is aligned consistent with the event count icons $base-vis-min-height: 25vh; // Visualizations require the container to have a valid width and height to render diff --git a/src/plugins/vis_type_table/public/components/table_vis_grid_columns.tsx b/src/plugins/vis_type_table/public/components/table_vis_grid_columns.tsx index 6bd2c0f1739f..66aa5b6ab491 100644 --- a/src/plugins/vis_type_table/public/components/table_vis_grid_columns.tsx +++ b/src/plugins/vis_type_table/public/components/table_vis_grid_columns.tsx @@ -15,6 +15,7 @@ export const getDataGridColumns = ( event: IInterpreterRenderHandlers['event'], columnWidths: ColumnWidth[] ) => { + const formattedColumnIds = table.formattedColumns.map((column) => column.id); const filterBucket = (rowIndex: number, columnIndex: number, negate: boolean) => { event({ name: 'filterBucket', @@ -22,7 +23,7 @@ export const getDataGridColumns = ( data: [ { table: { - columns: table.columns, + columns: table.columns.filter((column) => formattedColumnIds.includes(column.id)), rows: table.rows, }, row: rowIndex, diff --git a/test/functional/apps/context/_context_navigation.js b/test/functional/apps/context/_context_navigation.js index 5e722a85d099..4e78d3a9c14b 100644 --- a/test/functional/apps/context/_context_navigation.js +++ b/test/functional/apps/context/_context_navigation.js @@ -46,6 +46,7 @@ export default function ({ getService, getPageObjects }) { before(async function () { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) { await PageObjects.discover.clickFieldListItemDetails(columnName); await PageObjects.discover.clickFieldListPlusFilter(columnName, value); diff --git a/test/functional/apps/context/_date_nanos.js b/test/functional/apps/context/_date_nanos.js index ac45d86555d7..8fe1d2034c88 100644 --- a/test/functional/apps/context/_date_nanos.js +++ b/test/functional/apps/context/_date_nanos.js @@ -43,6 +43,8 @@ export default function ({ getService, getPageObjects }) { describe('context view for date_nanos', () => { before(async function () { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); await security.testUser.setRoles([ 'opensearch_dashboards_admin', 'opensearch_dashboards_date_nanos', diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index 5680b28921a7..afbc9390a3c5 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -47,6 +47,7 @@ export default function ({ getService, getPageObjects }) { before(async () => { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); for (const columnName of TEST_COLUMN_NAMES) { await PageObjects.discover.clickFieldListItemAdd(columnName); diff --git a/test/functional/apps/context/_filters.js b/test/functional/apps/context/_filters.js index 077a8376aa7b..17bbe5bcbe08 100644 --- a/test/functional/apps/context/_filters.js +++ b/test/functional/apps/context/_filters.js @@ -41,11 +41,13 @@ export default function ({ getService, getPageObjects }) { const browser = getService('browser'); const testSubjects = getService('testSubjects'); - const PageObjects = getPageObjects(['common', 'context']); + const PageObjects = getPageObjects(['common', 'context', 'discover']); describe('context filters', function contextSize() { beforeEach(async function () { await browser.refresh(); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_ID, { columns: TEST_COLUMN_NAMES, }); diff --git a/test/functional/apps/context/_size.js b/test/functional/apps/context/_size.js index bd44b159bca4..70323b01460a 100644 --- a/test/functional/apps/context/_size.js +++ b/test/functional/apps/context/_size.js @@ -37,11 +37,13 @@ export default function ({ getService, getPageObjects }) { const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const retry = getService('retry'); const dataGrid = getService('dataGrid'); - const PageObjects = getPageObjects(['context']); + const PageObjects = getPageObjects(['common', 'context', 'discover']); let expectedRowLength = 2 * TEST_DEFAULT_CONTEXT_SIZE + 1; describe('context size', function contextSize() { before(async function () { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); await opensearchDashboardsServer.uiSettings.update({ 'context:defaultSize': `${TEST_DEFAULT_CONTEXT_SIZE}`, 'context:step': `${TEST_STEP_SIZE}`, diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.js b/test/functional/apps/dashboard/dashboard_filter_bar.js index 9a0ce6a9042a..dde86c697e3c 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.js +++ b/test/functional/apps/dashboard/dashboard_filter_bar.js @@ -39,7 +39,14 @@ export default function ({ getService, getPageObjects }) { const opensearchArchiver = getService('opensearchArchiver'); const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const browser = getService('browser'); - const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize', 'timePicker']); + const PageObjects = getPageObjects([ + 'common', + 'dashboard', + 'header', + 'visualize', + 'timePicker', + 'discover', + ]); describe('dashboard filter bar', () => { before(async () => { @@ -185,6 +192,9 @@ export default function ({ getService, getPageObjects }) { describe('saved search filtering', function () { before(async () => { await filterBar.ensureFieldEditorModalIsClosed(); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); + await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); await PageObjects.timePicker.setDefaultDataRange(); diff --git a/test/functional/apps/dashboard/dashboard_filtering.js b/test/functional/apps/dashboard/dashboard_filtering.js index e934169513f6..1040b87f6168 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.js +++ b/test/functional/apps/dashboard/dashboard_filtering.js @@ -46,7 +46,14 @@ export default function ({ getService, getPageObjects }) { const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const security = getService('security'); const dashboardPanelActions = getService('dashboardPanelActions'); - const PageObjects = getPageObjects(['common', 'dashboard', 'header', 'visualize', 'timePicker']); + const PageObjects = getPageObjects([ + 'common', + 'dashboard', + 'header', + 'visualize', + 'timePicker', + 'discover', + ]); describe('dashboard filtering', function () { this.tags('includeFirefox'); @@ -72,6 +79,10 @@ export default function ({ getService, getPageObjects }) { describe('adding a filter that excludes all data', () => { before(async () => { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); + await PageObjects.common.navigateToApp('dashboard'); + await PageObjects.dashboard.gotoDashboardLandingPage(); await PageObjects.dashboard.clickNewDashboard(); await PageObjects.timePicker.setDefaultDataRange(); await dashboardAddPanel.addEveryVisualization('"Filter Bytes Test"'); @@ -219,7 +230,7 @@ export default function ({ getService, getPageObjects }) { }); it('saved searches', async () => { - await dashboardExpect.savedSearchRowCount(1); + await testSubjects.existOrFail('docTableExpandToggleColumn'); }); it('vega', async () => { diff --git a/test/functional/apps/dashboard/dashboard_state.js b/test/functional/apps/dashboard/dashboard_state.js index edb2002624f5..11196a1b69b9 100644 --- a/test/functional/apps/dashboard/dashboard_state.js +++ b/test/functional/apps/dashboard/dashboard_state.js @@ -56,6 +56,9 @@ export default function ({ getService, getPageObjects }) { describe('dashboard state', function describeIndexTests() { before(async function () { + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); + await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.initTests(); await PageObjects.dashboard.preserveCrossAppState(); await browser.refresh(); diff --git a/test/functional/apps/dashboard/dashboard_time_picker.js b/test/functional/apps/dashboard/dashboard_time_picker.js index b1e57fbe8e5e..e5da381ec06e 100644 --- a/test/functional/apps/dashboard/dashboard_time_picker.js +++ b/test/functional/apps/dashboard/dashboard_time_picker.js @@ -68,14 +68,14 @@ export default function ({ getService, getPageObjects }) { fields: ['bytes', 'agent'], }); // Current data grid loads 100 rows per page by default with inspect button and time range - await dashboardExpect.dataGridTableCellCount(400); + await dashboardExpect.savedSearchRowCountFromLegacyTable(100); // Set to time range with no data await PageObjects.timePicker.setAbsoluteRange( 'Jan 1, 2000 @ 00:00:00.000', 'Jan 1, 2000 @ 01:00:00.000' ); - await dashboardExpect.dataGridTableCellCount(0); + await dashboardExpect.savedSearchRowCountFromLegacyTable(0); }); it('Timepicker start, end, interval values are set by url', async () => { diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index 4c1cf4f69ce1..a790a884ccbe 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -125,9 +125,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await pieChart.expectPieSliceCount(4); log.debug('Checking area, bar and heatmap charts rendered'); await dashboardExpect.seriesElementCount(15); - // The saved search of data explorer now renders 100 lines max log.debug('Checking saved searches rendered'); - await dashboardExpect.savedSearchRowCount(100); + await dashboardExpect.savedSearchRowCountFromLegacyTable(100); log.debug('Checking input controls rendered'); await dashboardExpect.inputControlItemCount(3); log.debug('Checking tag cloud rendered'); diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 7acceff07fce..8a4659630ee1 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -161,6 +161,7 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 06:31:44.000'; const toTime = 'Sep 18, 2015 @ 18:31:44.000'; await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); @@ -279,6 +280,7 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 06:31:44.000'; const toTime = 'Sep 18, 2015 @ 18:31:44.000'; await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); @@ -375,6 +377,7 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 06:31:44.000'; const toTime = 'Sep 18, 2015 @ 18:31:44.000'; await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); @@ -414,6 +417,7 @@ export default function ({ getService, getPageObjects }) { }); it('should filter by scripted field value in Discover', async function () { + await testSubjects.moveMouseTo(`field-${scriptedPainlessFieldName2}`); await PageObjects.discover.clickFieldListItemDetails(scriptedPainlessFieldName2); await log.debug('filter by "true" in the expanded scripted field list'); await PageObjects.discover.clickFieldListPlusFilter(scriptedPainlessFieldName2, 'true'); @@ -473,6 +477,7 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 19:22:00.000'; const toTime = 'Sep 18, 2015 @ 07:00:00.000'; await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index f81c287a478f..cf0b03022ab9 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -516,6 +516,32 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider `Could not find a clickable list item for column "${columnName}" with list item "${title}".` ); } + + public async switchDiscoverTable(tableType: string) { + await retry.try(async () => { + const switchButton = await testSubjects.find('datagridTableButton'); + const buttonText = await switchButton.getVisibleText(); + + if (tableType === 'new' && buttonText.includes('Try new Discover')) { + await switchButton.click(); + } else if (tableType === 'legacy' && buttonText.includes('Use legacy Discover')) { + await switchButton.click(); + } + }); + + // Wait for the query input to be visible + await this.waitForQueryInput(); + } + + async waitForQueryInput() { + // Wait for the query input to be visible + await retry.try(async () => { + const queryInputVisible = await testSubjects.exists('queryInput'); + if (!queryInputVisible) { + throw new Error('Query input not yet visible'); + } + }); + } } return new DiscoverPage(); diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index 641c56b586fd..266517045747 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -240,6 +240,17 @@ export function DashboardExpectProvider({ getService, getPageObjects }: FtrProvi }); } + async savedSearchRowCountFromLegacyTable(expectedCount: number) { + log.debug(`DashboardExpect.savedSearchRowCount(${expectedCount})`); + await retry.try(async () => { + const savedSearchRows = await testSubjects.findAll( + 'docTableExpandToggleColumn', + findTimeout + ); + expect(savedSearchRows.length).to.be(expectedCount); + }); + } + async seriesElementCount(expectedCount: number) { log.debug(`DashboardExpect.seriesElementCount(${expectedCount})`); await retry.try(async () => { diff --git a/test/plugin_functional/test_suites/doc_views/doc_views.ts b/test/plugin_functional/test_suites/doc_views/doc_views.ts index 0944c8feb77a..b745d6e8a417 100644 --- a/test/plugin_functional/test_suites/doc_views/doc_views.ts +++ b/test/plugin_functional/test_suites/doc_views/doc_views.ts @@ -36,9 +36,10 @@ export default function ({ getService, getPageObjects }: PluginFunctionalProvide const find = getService('find'); const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); - describe('custom doc views', function () { + describe('custom doc views with datagrid table', function () { before(async () => { await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.switchDiscoverTable('new'); // TODO: change back to setDefaultRange() once we resolve // https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5241 await PageObjects.timePicker.setDefaultRangeForDiscover();