diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index 57c7f60ba58c..cdbbca8a84ec 100644 --- a/.github/workflows/build_and_test_workflow.yml +++ b/.github/workflows/build_and_test_workflow.yml @@ -3,10 +3,10 @@ name: Build and test -# trigger on every commit push and PR for all branches except pushes for backport branches +# trigger on every commit push and PR for all branches except pushes for backport branches and feature branches on: push: - branches: ['**', '!backport/**'] + branches: ['**', '!backport/**', '!feature/**'] paths-ignore: - '**/*.md' - 'docs/**' diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index 5e78785f9b88..1c15ad3f18ed 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -1,9 +1,9 @@ name: Run cypress tests -# trigger on every PR for all branches +# trigger on every PR for all branches except feature branches on: pull_request: - branches: [ '**' ] + branches: [ '**', '!feature/**' ] paths-ignore: - '**/*.md' diff --git a/README.md b/README.md index 5bafaf4c7c1a..c7b6d09e1f3d 100644 --- a/README.md +++ b/README.md @@ -53,4 +53,4 @@ Copyright OpenSearch Contributors. See [NOTICE](NOTICE.txt) for details. [codecov-badge]: https://codecov.io/gh/opensearch-project/OpenSearch-Dashboards/branch/main/graphs/badge.svg [codecov-link]: https://app.codecov.io/gh/opensearch-project/OpenSearch-Dashboards [link-checker-badge]: https://github.com/opensearch-project/OpenSearch-Dashboards/actions/workflows/links_checker.yml/badge.svg -[link-checker-link]: https://github.com/opensearch-project/OpenSearch-Dashboards/actions/workflows/links_checker.yml +[link-checker-link]: https://github.com/opensearch-project/OpenSearch-Dashboards/actions/workflows/links_checker.yml \ 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 19451066878c..99b2db2dfb4f 100644 --- a/packages/osd-stylelint-config/config/global_selectors.json +++ b/packages/osd-stylelint-config/config/global_selectors.json @@ -23,8 +23,8 @@ "src/plugins/vis_builder/public/application/components/searchable_dropdown.scss", "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/sidebar/discover_sidebar.scss", - "src/plugins/discover/public/application/angular/doc_table/components/table_row/_open.scss" + "src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss", + "src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss" ] } -} +} \ No newline at end of file diff --git a/packages/osd-stylelint-config/config/restricted_properties.json b/packages/osd-stylelint-config/config/restricted_properties.json index b69012cb61f9..241efad906b4 100644 --- a/packages/osd-stylelint-config/config/restricted_properties.json +++ b/packages/osd-stylelint-config/config/restricted_properties.json @@ -2,7 +2,7 @@ "font-family": { "explanation": "All \"font-family\" styles should be inherited from OUI themes and components. Remove the rule.", "approved": [ - "src/plugins/discover/public/application/_discover.scss", + "src/plugins/discover_legacy/public/application/_discover.scss", "src/plugins/maps_legacy/public/map/_leaflet_overrides.scss", "src/plugins/maps_legacy/public/map/_legend.scss", "src/plugins/opensearch_dashboards_legacy/public/font_awesome/font_awesome.scss", @@ -12,7 +12,7 @@ "src/plugins/data/public/ui/typeahead/_suggestion.scss", "src/plugins/vis_type_timeseries/public/application/components/_error.scss", "packages/osd-ui-framework/src/components/form/check_box/_check_box.scss", - "src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss" + "src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.scss" ] } } diff --git a/src/core/server/core_app/assets/logos/opensearch_dashboards_darkmode.svg b/src/core/server/core_app/assets/logos/opensearch_dashboards_darkmode.svg new file mode 100644 index 000000000000..ba023b5b9a37 --- /dev/null +++ b/src/core/server/core_app/assets/logos/opensearch_dashboards_darkmode.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/dashboard_listing.test.tsx b/src/plugins/dashboard/public/application/components/dashboard_listing/dashboard_listing.test.tsx index edbd0298876b..3d9c8404be5e 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/dashboard_listing.test.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/dashboard_listing.test.tsx @@ -9,6 +9,28 @@ * 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. + */ + +// TODO: +// Rewrite the dashboard listing tests for the new component +// https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4051 jest.mock( 'lodash', () => ({ @@ -24,85 +46,63 @@ jest.mock( { virtual: true } ); -let mockURLsearch = - '?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))'; - -jest.mock('react-router-dom', () => ({ - ...jest.requireActual('react-router-dom'), - useLocation: () => ({ - search: mockURLsearch, - pathname: '', - hash: '', - state: undefined, - }), -})); - import React from 'react'; -import { mount } from 'enzyme'; +import { shallow } from 'enzyme'; import { DashboardListing } from './dashboard_listing'; -import { createDashboardServicesMock } from '../../utils/mocks'; -import { OpenSearchDashboardsContextProvider } from 'src/plugins/opensearch_dashboards_react/public'; -import { I18nProvider } from '@osd/i18n/react'; -import { IOsdUrlStateStorage } from 'src/plugins/opensearch_dashboards_utils/public'; - -function wrapDashboardListingInContext(mockServices: any) { - const osdUrlStateStorage = ({ - set: jest.fn(), - get: jest.fn(() => ({ linked: false })), - flush: jest.fn(), - } as unknown) as IOsdUrlStateStorage; - const services = { - ...mockServices, - osdUrlStateStorage, - dashboardProviders: () => { - return { - dashboard: { - appId: '1', - savedObjectsName: 'dashboardSavedObjects', - viewUrlPathFn: jest.fn(), - editUrlPathFn: jest.fn(), - }, - }; - }, - }; - - return ( - - - - - - ); -} - -describe('dashboard listing', () => { - let mockServices: any; - - beforeEach(() => { - mockServices = createDashboardServicesMock(); - mockServices.savedObjectsClient.find = () => { - const hits: any[] = []; - for (let i = 0; i < 2; i++) { - hits.push({ - type: `dashboard`, - id: `dashboard${i}`, - attributes: { - title: `dashboard${i}`, - description: `dashboard${i} desc`, - }, - }); - } - return Promise.resolve({ - savedObjects: hits, - }); - }; - mockServices.dashboardConfig.getHideWriteControls = () => false; - mockServices.savedObjectsPublic.settings.getListingLimit = () => 100; + +const find = (num: number) => { + const hits = []; + for (let i = 0; i < num; i++) { + hits.push({ + id: `dashboard${i}`, + title: `dashboard${i} title`, + description: `dashboard${i} desc`, + }); + } + return Promise.resolve({ + total: num, + hits, }); +}; + +test.skip('renders empty page in before initial fetch to avoid flickering', () => { + const component = shallow( + {}} + createItem={() => {}} + editItem={() => {}} + viewItem={() => {}} + dashboardItemCreatorClickHandler={() => {}} + dashboardItemCreators={() => []} + initialPageSize={10} + listingLimit={1000} + hideWriteControls={false} + core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} + /> + ); + expect(component).toMatchSnapshot(); +}); - test('renders table rows', async () => { - const component = mount(wrapDashboardListingInContext(mockServices)); +describe.skip('after fetch', () => { + test('initialFilter', async () => { + const component = shallow( + {}} + createItem={() => {}} + editItem={() => {}} + viewItem={() => {}} + dashboardItemCreatorClickHandler={() => {}} + dashboardItemCreators={() => []} + listingLimit={1000} + hideWriteControls={false} + initialPageSize={10} + initialFilter="my dashboard" + core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} + /> + ); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -112,16 +112,22 @@ describe('dashboard listing', () => { expect(component).toMatchSnapshot(); }); - test('renders call to action when no dashboards exist', async () => { - // savedObjectsClient.find() needs to find no dashboard - mockServices.savedObjectsClient.find = () => { - const hits: any[] = []; - return Promise.resolve({ - total: 0, - hits, - }); - }; - const component = mount(wrapDashboardListingInContext(mockServices)); + test('renders table rows', async () => { + const component = shallow( + {}} + createItem={() => {}} + editItem={() => {}} + viewItem={() => {}} + dashboardItemCreatorClickHandler={() => {}} + dashboardItemCreators={() => []} + listingLimit={1000} + initialPageSize={10} + hideWriteControls={false} + core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} + /> + ); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -131,12 +137,22 @@ describe('dashboard listing', () => { expect(component).toMatchSnapshot(); }); - test('hideWriteControls', async () => { - // dashboardConfig.getHideWriteControls() to true - mockServices.dashboardConfig.getHideWriteControls = () => { - return true; - }; - const component = mount(wrapDashboardListingInContext(mockServices)); + test('renders call to action when no dashboards exist', async () => { + const component = shallow( + {}} + createItem={() => {}} + editItem={() => {}} + viewItem={() => {}} + dashboardItemCreatorClickHandler={() => {}} + dashboardItemCreators={() => []} + listingLimit={1} + initialPageSize={10} + hideWriteControls={false} + core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} + /> + ); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -146,10 +162,22 @@ describe('dashboard listing', () => { expect(component).toMatchSnapshot(); }); - test('renders warning when listingLimit is exceeded', async () => { - mockServices.savedObjectsPublic.settings.getListingLimit = () => 1; - - const component = mount(wrapDashboardListingInContext(mockServices)); + test('hideWriteControls', async () => { + const component = shallow( + {}} + createItem={() => {}} + editItem={() => {}} + viewItem={() => {}} + dashboardItemCreatorClickHandler={() => {}} + dashboardItemCreators={() => []} + listingLimit={1} + initialPageSize={10} + hideWriteControls={true} + core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} + /> + ); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); @@ -159,11 +187,22 @@ describe('dashboard listing', () => { expect(component).toMatchSnapshot(); }); - test('render table listing with initial filters from URL', async () => { - mockURLsearch = - '?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time:(from:now-15m,to:now))&filter=dashboard'; - - const component = mount(wrapDashboardListingInContext(mockServices)); + test('renders warning when listingLimit is exceeded', async () => { + const component = shallow( + {}} + createItem={() => {}} + editItem={() => {}} + viewItem={() => {}} + dashboardItemCreatorClickHandler={() => {}} + dashboardItemCreators={() => []} + listingLimit={1} + initialPageSize={10} + hideWriteControls={false} + core={{ notifications: { toasts: {} }, uiSettings: { get: jest.fn(() => 10) } }} + /> + ); // Ensure all promises resolve await new Promise((resolve) => process.nextTick(resolve)); diff --git a/src/plugins/data_explorer/.i18nrc.json b/src/plugins/data_explorer/.i18nrc.json new file mode 100644 index 000000000000..1ea4ccdd1e7b --- /dev/null +++ b/src/plugins/data_explorer/.i18nrc.json @@ -0,0 +1,7 @@ +{ + "prefix": "dataExplorer", + "paths": { + "dataExplorer": "." + }, + "translations": ["translations/ja-JP.json"] +} diff --git a/src/plugins/data_explorer/README.md b/src/plugins/data_explorer/README.md new file mode 100755 index 000000000000..8ea14c17d428 --- /dev/null +++ b/src/plugins/data_explorer/README.md @@ -0,0 +1,11 @@ +# dataExplorer + +A OpenSearch Dashboards plugin + +--- + +## Development + +See the [OpenSearch Dashboards contributing +guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/CONTRIBUTING.md) for instructions +setting up your development environment. diff --git a/src/plugins/data_explorer/common/index.ts b/src/plugins/data_explorer/common/index.ts new file mode 100644 index 000000000000..60d61f38b749 --- /dev/null +++ b/src/plugins/data_explorer/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'data-explorer'; +export const PLUGIN_NAME = 'Data Explorer'; diff --git a/src/plugins/data_explorer/opensearch_dashboards.json b/src/plugins/data_explorer/opensearch_dashboards.json new file mode 100644 index 000000000000..23db353b2cc8 --- /dev/null +++ b/src/plugins/data_explorer/opensearch_dashboards.json @@ -0,0 +1,18 @@ +{ + "id": "dataExplorer", + "version": "1.0.0", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": true, + "ui": true, + "requiredPlugins": [ + "data", + "navigation", + "embeddable", + "expressions" + ], + "optionalPlugins": [], + "requiredBundles": [ + "opensearchDashboardsReact", + "opensearchDashboardsUtils" + ] +} \ No newline at end of file diff --git a/src/plugins/data_explorer/public/application.tsx b/src/plugins/data_explorer/public/application.tsx new file mode 100644 index 000000000000..ae57070f7451 --- /dev/null +++ b/src/plugins/data_explorer/public/application.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider as ReduxProvider } from 'react-redux'; +import { Router, Route, Switch } from 'react-router-dom'; +import { AppMountParameters, CoreStart } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +import { DataExplorerServices } from './types'; +import { DataExplorerApp } from './components/app'; +import { Store } from './utils/state_management'; + +export const renderApp = ( + core: CoreStart, + services: DataExplorerServices, + params: AppMountParameters, + store: Store +) => { + const { history, element } = params; + ReactDOM.render( + + + + + + + + + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/plugins/data_explorer/public/components/app.tsx b/src/plugins/data_explorer/public/components/app.tsx new file mode 100644 index 000000000000..ff6b5931a404 --- /dev/null +++ b/src/plugins/data_explorer/public/components/app.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { AppMountParameters } from '../../../../core/public'; +import { useView } from '../utils/use'; +import { AppContainer } from './app_container'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { DataExplorerServices } from '../types'; +import { syncQueryStateWithUrl } from '../../../data/public'; + +export const DataExplorerApp = ({ params }: { params: AppMountParameters }) => { + const { view } = useView(); + const { + services: { + data: { query }, + osdUrlStateStorage, + }, + } = useOpenSearchDashboards(); + const { pathname } = useLocation(); + + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop } = syncQueryStateWithUrl(query, osdUrlStateStorage); + + return () => stop(); + + // this effect should re-run when pathname is changed to preserve querystring part, + // so the global state is always preserved + }, [query, osdUrlStateStorage, pathname]); + + return ; +}; diff --git a/src/plugins/data_explorer/public/components/app_container.scss b/src/plugins/data_explorer/public/components/app_container.scss new file mode 100644 index 000000000000..d289e7d4be3e --- /dev/null +++ b/src/plugins/data_explorer/public/components/app_container.scss @@ -0,0 +1,18 @@ +$osdHeaderOffset: $euiHeaderHeightCompensation; + +.deSidebar { + max-width: 462px; + min-width: 400px; +} + +.deLayout { + height: calc(100vh - #{$osdHeaderOffset}); + + &__canvas { + height: 100%; + } +} + +.headerIsExpanded .deLayout { + height: calc(100vh - #{$osdHeaderOffset * 2}); +} diff --git a/src/plugins/data_explorer/public/components/app_container.test.tsx b/src/plugins/data_explorer/public/components/app_container.test.tsx new file mode 100644 index 000000000000..d4215f24e795 --- /dev/null +++ b/src/plugins/data_explorer/public/components/app_container.test.tsx @@ -0,0 +1,47 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { AppContainer } from './app_container'; +import { render } from '@testing-library/react'; +import { View } from '../services/view_service/view'; +import { ViewMountParameters } from '../services/view_service'; + +describe('DataExplorerApp', () => { + const createView = () => { + return new View({ + id: 'test-view', + title: 'Test View', + defaultPath: '/test-path', + appExtentions: {} as any, + mount: async ({ canvasElement, panelElement }: ViewMountParameters) => { + const canvasContent = document.createElement('div'); + const panelContent = document.createElement('div'); + canvasContent.innerHTML = 'canvas-content'; + panelContent.innerHTML = 'panel-content'; + canvasElement.appendChild(canvasContent); + panelElement.appendChild(panelContent); + return () => { + canvasContent.remove(); + panelContent.remove(); + }; + }, + }); + }; + + it('should render NoView when a non existent view is selected', () => { + const { container } = render(); + + expect(container).toContainHTML('View not found'); + }); + + // TODO: Complete once state management is in place + // it('should render the canvas and panel when selected', () => { + // const view = createView(); + // const { container } = render(); + + // expect(container).toMatchSnapshot(); + // }); +}); diff --git a/src/plugins/data_explorer/public/components/app_container.tsx b/src/plugins/data_explorer/public/components/app_container.tsx new file mode 100644 index 000000000000..8f37e9c1230f --- /dev/null +++ b/src/plugins/data_explorer/public/components/app_container.tsx @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { Suspense } from 'react'; +import { AppMountParameters } from '../../../../core/public'; +import { Sidebar } from './sidebar'; +import { NoView } from './no_view'; +import { View } from '../services/view_service/view'; +import './app_container.scss'; + +export const AppContainer = ({ view, params }: { view?: View; params: AppMountParameters }) => { + // TODO: Make this more robust. + if (!view) { + return ; + } + + const { Canvas, Panel, Context } = view; + + // Render the application DOM. + return ( + + {/* TODO: improve fallback state */} + Loading...}> + + + + + + + + + + + ); +}; diff --git a/src/plugins/data_explorer/public/components/no_view.tsx b/src/plugins/data_explorer/public/components/no_view.tsx new file mode 100644 index 000000000000..4cf7e84d9735 --- /dev/null +++ b/src/plugins/data_explorer/public/components/no_view.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiPageTemplate, EuiEmptyPrompt } from '@elastic/eui'; + +export const NoView = () => { + return ( + + View not found} + body={ +

+ The view you are trying to access does not exist. Please check the URL and try again. +

+ } + /> +
+ ); +}; diff --git a/src/plugins/data_explorer/public/components/sidebar/index.tsx b/src/plugins/data_explorer/public/components/sidebar/index.tsx new file mode 100644 index 000000000000..579c024acbc7 --- /dev/null +++ b/src/plugins/data_explorer/public/components/sidebar/index.tsx @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, FC, useEffect, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { + EuiComboBox, + EuiSelect, + EuiComboBoxOptionOption, + EuiSpacer, + EuiSplitPanel, + EuiPageSideBar, +} from '@elastic/eui'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { useView } from '../../utils/use'; +import { DataExplorerServices } from '../../types'; +import { useTypedDispatch, useTypedSelector, setIndexPattern } from '../../utils/state_management'; +import { setView } from '../../utils/state_management/metadata_slice'; + +export const Sidebar: FC = ({ children }) => { + const { indexPattern: indexPatternId } = useTypedSelector((state) => state.metadata); + const dispatch = useTypedDispatch(); + const [options, setOptions] = useState>>([]); + const [selectedOption, setSelectedOption] = useState>(); + const { view, viewRegistry } = useView(); + const views = viewRegistry.all(); + const viewOptions = useMemo( + () => + views.map(({ id, title }) => ({ + value: id, + text: title, + })), + [views] + ); + + const { + services: { + data: { indexPatterns }, + notifications: { toasts }, + }, + } = useOpenSearchDashboards(); + + useEffect(() => { + let isMounted = true; + const fetchIndexPatterns = async () => { + await indexPatterns.ensureDefaultIndexPattern(); + const cache = await indexPatterns.getCache(); + const currentOptions = (cache || []).map((indexPattern) => ({ + label: indexPattern.attributes.title, + value: indexPattern.id, + })); + if (isMounted) { + setOptions(currentOptions); + } + }; + fetchIndexPatterns(); + + return () => { + isMounted = false; + }; + }, [indexPatterns]); + + // Set option to the current index pattern + useEffect(() => { + if (indexPatternId) { + const option = options.find((o) => o.value === indexPatternId); + setSelectedOption(option); + } + }, [indexPatternId, options]); + + return ( + + + + { + // TODO: There are many issues with this approach, but it's a start + // 1. Combo box can delete a selected index pattern. This should not be possible + // 2. Combo box is severely truncated. This should be fixed in the EUI component + // 3. The onchange can fire with a option that is not valid. discuss where to handle this. + // 4. value is optional. If the combobox needs to act as a slecet, this should be required. + const { value } = selected[0] || {}; + + if (!value) { + toasts.addWarning({ + id: 'index-pattern-not-found', + title: i18n.translate('dataExplorer.indexPatternError', { + defaultMessage: 'Index pattern not found', + }), + }); + return; + } + + dispatch(setIndexPattern(value)); + }} + /> + + { + dispatch(setView(e.target.value)); + }} + fullWidth + /> + + + {children} + + + + ); +}; diff --git a/src/plugins/data_explorer/public/index.scss b/src/plugins/data_explorer/public/index.scss new file mode 100644 index 000000000000..8389e31b426a --- /dev/null +++ b/src/plugins/data_explorer/public/index.scss @@ -0,0 +1,9 @@ +$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 new file mode 100644 index 000000000000..635a0ec285db --- /dev/null +++ b/src/plugins/data_explorer/public/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './index.scss'; + +import { DataExplorerPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. +export function plugin() { + return new DataExplorerPlugin(); +} +export { DataExplorerPluginSetup, DataExplorerPluginStart, DataExplorerServices } from './types'; +export { ViewProps, ViewDefinition, DefaultViewState } from './services/view_service'; +export { RootState, useTypedSelector, useTypedDispatch } from './utils/state_management'; diff --git a/src/plugins/data_explorer/public/plugin.ts b/src/plugins/data_explorer/public/plugin.ts new file mode 100644 index 000000000000..5935ceb44d80 --- /dev/null +++ b/src/plugins/data_explorer/public/plugin.ts @@ -0,0 +1,149 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; +import { + AppMountParameters, + CoreSetup, + CoreStart, + Plugin, + AppNavLinkStatus, + ScopedHistory, + AppUpdater, +} from '../../../core/public'; +import { + DataExplorerPluginSetup, + DataExplorerPluginSetupDependencies, + DataExplorerPluginStart, + DataExplorerPluginStartDependencies, + DataExplorerServices, +} from './types'; +import { PLUGIN_ID, PLUGIN_NAME } from '../common'; +import { ViewService } from './services/view_service'; +import { + createOsdUrlStateStorage, + createOsdUrlTracker, + withNotifyOnErrors, +} from '../../opensearch_dashboards_utils/public'; +import { getPreloadedStore } from './utils/state_management'; +import { opensearchFilters } from '../../data/public'; + +export class DataExplorerPlugin + implements + Plugin< + DataExplorerPluginSetup, + DataExplorerPluginStart, + DataExplorerPluginSetupDependencies, + DataExplorerPluginStartDependencies + > { + private viewService = new ViewService(); + private appStateUpdater = new BehaviorSubject(() => ({})); + private stopUrlTracking?: () => void; + private currentHistory?: ScopedHistory; + + public setup( + core: CoreSetup, + { data }: DataExplorerPluginSetupDependencies + ): DataExplorerPluginSetup { + const viewService = this.viewService; + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('data_explorer: Setup'); + + const { appMounted, appUnMounted, stop: stopUrlTracker } = createOsdUrlTracker({ + baseUrl: core.http.basePath.prepend(`/app/${PLUGIN_ID}`), + defaultSubUrl: '#/', + storageKey: `lastUrl:${core.http.basePath.get()}:${PLUGIN_ID}`, + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + osdUrlKey: '_g', + stateUpdate$: data.query.state$.pipe( + filter( + ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) + ), + map(({ state }) => ({ + ...state, + filters: state.filters?.filter(opensearchFilters.isFilterPinned), + })) + ), + }, + ], + getHistory: () => { + return this.currentHistory!; + }, + }); + this.stopUrlTracking = () => { + stopUrlTracker(); + }; + + // Register an application into the side navigation menu + core.application.register({ + id: PLUGIN_ID, + title: PLUGIN_NAME, + navLinkStatus: AppNavLinkStatus.hidden, + mount: async (params: AppMountParameters) => { + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('data_explorer: Mounted'); + // Load application bundle + const { renderApp } = await import('./application'); + + const [coreStart, pluginsStart] = await core.getStartServices(); + this.currentHistory = params.history; + + // make sure the index pattern list is up to date + pluginsStart.data.indexPatterns.clearCache(); + + const services: DataExplorerServices = { + ...coreStart, + scopedHistory: this.currentHistory, + data: pluginsStart.data, + embeddable: pluginsStart.embeddable, + expressions: pluginsStart.expressions, + osdUrlStateStorage: createOsdUrlStateStorage({ + history: this.currentHistory, + useHash: coreStart.uiSettings.get('state:storeInSessionStorage'), + ...withNotifyOnErrors(coreStart.notifications.toasts), + }), + viewRegistry: viewService.start(), + }; + + // Get start services as specified in opensearch_dashboards.json + // Render the application + const { store, unsubscribe: unsubscribeStore } = await getPreloadedStore(services); + services.store = store; + + const unmount = renderApp(coreStart, services, params, store); + appMounted(); + + return () => { + unsubscribeStore(); + appUnMounted(); + unmount(); + }; + }, + }); + + return { + ...this.viewService.setup(), + }; + } + + public start(core: CoreStart): DataExplorerPluginStart { + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('data_explorer: Started'); + return {}; + } + + public stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } +} diff --git a/src/plugins/data_explorer/public/services/view_service/index.ts b/src/plugins/data_explorer/public/services/view_service/index.ts new file mode 100644 index 000000000000..06bfe5c341f7 --- /dev/null +++ b/src/plugins/data_explorer/public/services/view_service/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './view_service'; +export * from './types'; diff --git a/src/plugins/data_explorer/public/services/view_service/types.ts b/src/plugins/data_explorer/public/services/view_service/types.ts new file mode 100644 index 000000000000..5ecba7920b63 --- /dev/null +++ b/src/plugins/data_explorer/public/services/view_service/types.ts @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Slice } from '@reduxjs/toolkit'; +import { LazyExoticComponent } from 'react'; +import { AppMountParameters } from '../../../../../core/public'; +import { RootState } from '../../utils/state_management'; + +interface ViewListItem { + id: string; + label: string; +} + +export interface DefaultViewState { + state: T; + root?: Partial; +} + +export type ViewProps = AppMountParameters; + +export interface ViewDefinition { + readonly id: string; + readonly title: string; + readonly ui?: { + defaults: DefaultViewState | (() => DefaultViewState) | (() => Promise); + slice: Slice; + }; + readonly Canvas: LazyExoticComponent<(props: ViewProps) => React.ReactElement>; + readonly Panel: LazyExoticComponent<(props: ViewProps) => React.ReactElement>; + readonly Context: LazyExoticComponent< + (props: React.PropsWithChildren) => React.ReactElement + >; + readonly defaultPath: string; + readonly appExtentions: { + savedObject: { + docTypes: [string]; + toListItem: (obj: { id: string; title: string }) => ViewListItem; + }; + }; + readonly shouldShow?: (state: any) => boolean; +} diff --git a/src/plugins/data_explorer/public/services/view_service/view.ts b/src/plugins/data_explorer/public/services/view_service/view.ts new file mode 100644 index 000000000000..ebdab31fddc5 --- /dev/null +++ b/src/plugins/data_explorer/public/services/view_service/view.ts @@ -0,0 +1,31 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { ViewDefinition } from './types'; + +type IView = ViewDefinition; + +export class View implements IView { + public readonly id: string; + public readonly title: string; + public readonly ui: IView['ui']; + public readonly defaultPath: string; + public readonly appExtentions: IView['appExtentions']; + readonly shouldShow?: (state: any) => boolean; + readonly Canvas: IView['Canvas']; + readonly Panel: IView['Panel']; + readonly Context: IView['Context']; + + constructor(options: ViewDefinition) { + this.id = options.id; + this.title = options.title; + this.ui = options.ui; + this.defaultPath = options.defaultPath; + this.appExtentions = options.appExtentions; + this.shouldShow = options.shouldShow; + this.Canvas = options.Canvas; + this.Panel = options.Panel; + this.Context = options.Context; + } +} diff --git a/src/plugins/data_explorer/public/services/view_service/view_service.test.ts b/src/plugins/data_explorer/public/services/view_service/view_service.test.ts new file mode 100644 index 000000000000..b95046997c8b --- /dev/null +++ b/src/plugins/data_explorer/public/services/view_service/view_service.test.ts @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ViewDefinition } from './types'; +import { ViewService } from './view_service'; + +const DEFAULT_VIEW: ViewDefinition = { + id: 'my-view', + title: 'My view', + defaultPath: '/my-view', + appExtentions: {} as any, // Not required for this test + ui: {} as any, // Not required for this test +}; + +describe('TypeService', () => { + const createViewDefinition = (props?: Partial): ViewDefinition => { + return { + ...DEFAULT_VIEW, + ...props, + }; + }; + + let service: ViewService; + + beforeEach(() => { + service = new ViewService(); + }); + + describe('#setup', () => { + test('should throw an error if two visualizations of the same id are registered', () => { + const { registerView } = service.setup(); + + registerView(createViewDefinition({ id: 'view-1' })); + + expect(() => { + registerView(createViewDefinition({ id: 'view-1' })); + }).toThrowErrorMatchingInlineSnapshot(`"A view with this the id view-1 already exists!"`); + }); + }); + + describe('#start', () => { + test('should return registered view if it exists', () => { + const { registerView } = service.setup(); + registerView(createViewDefinition({ id: 'view-1' })); + + const { get } = service.start(); + expect(get('view-1')).toEqual(expect.objectContaining({ id: 'view-1' })); + expect(get('view-something')).toBeUndefined(); + }); + + test('should return all registered views', () => { + const { registerView } = service.setup(); + registerView(createViewDefinition({ id: 'view-1' })); + registerView(createViewDefinition({ id: 'view-2' })); + + const { all } = service.start(); + const allRegisteredViews = all(); + expect(allRegisteredViews.map(({ id }) => id)).toEqual(['view-1', 'view-2']); + }); + }); +}); diff --git a/src/plugins/data_explorer/public/services/view_service/view_service.ts b/src/plugins/data_explorer/public/services/view_service/view_service.ts new file mode 100644 index 000000000000..02d30d838e42 --- /dev/null +++ b/src/plugins/data_explorer/public/services/view_service/view_service.ts @@ -0,0 +1,88 @@ +/* + * 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 { CoreService } from '../../../../../core/types'; +import { ViewDefinition } from './types'; +import { View } from './view'; + +/** + * Visualization Types Service + * + * @internal + */ +export class ViewService implements CoreService { + private views: Record = {}; + + private registerView(view: View) { + if (this.views[view.id]) { + throw new Error(`A view with this the id ${view.id} already exists!`); + } + this.views[view.id] = view; + } + + public setup() { + return { + /** + * registers a visualization type + * @param config - visualization type definition + */ + registerView: (config: ViewDefinition): void => { + const view = new View(config); + this.registerView(view); + }, + }; + } + + public start() { + return { + /** + * returns specific View or undefined if not found + * @param {string} id - id of view to return + */ + get: (id: string): View | undefined => { + return this.views[id]; + }, + /** + * returns all registered Views + */ + all: (): View[] => { + return [...Object.values(this.views)]; + }, + }; + } + + public stop() { + // nothing to do here yet + } +} + +/** @internal */ +export type ViewServiceSetup = ReturnType; +export type ViewServiceStart = ReturnType; diff --git a/src/plugins/data_explorer/public/types.ts b/src/plugins/data_explorer/public/types.ts new file mode 100644 index 000000000000..5f677fb46cfd --- /dev/null +++ b/src/plugins/data_explorer/public/types.ts @@ -0,0 +1,37 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart, ScopedHistory } from 'opensearch-dashboards/public'; +import { EmbeddableStart } from '../../embeddable/public'; +import { ExpressionsStart } from '../../expressions/public'; +import { ViewServiceStart, ViewServiceSetup } from './services/view_service'; +import { IOsdUrlStateStorage } from '../../opensearch_dashboards_utils/public'; +import { DataPublicPluginSetup, DataPublicPluginStart } from '../../data/public'; +import { Store } from './utils/state_management'; + +export type DataExplorerPluginSetup = ViewServiceSetup; + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface DataExplorerPluginStart {} + +export interface DataExplorerPluginSetupDependencies { + data: DataPublicPluginSetup; +} + +export interface DataExplorerPluginStartDependencies { + expressions: ExpressionsStart; + embeddable: EmbeddableStart; + data: DataPublicPluginStart; +} + +export interface DataExplorerServices extends CoreStart { + store?: Store; + viewRegistry: ViewServiceStart; + expressions: ExpressionsStart; + embeddable: EmbeddableStart; + data: DataPublicPluginStart; + scopedHistory: ScopedHistory; + osdUrlStateStorage: IOsdUrlStateStorage; +} diff --git a/src/plugins/data_explorer/public/utils/mocks.ts b/src/plugins/data_explorer/public/utils/mocks.ts new file mode 100644 index 000000000000..9604a0ba8dc6 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/mocks.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ScopedHistory } from '../../../../core/public'; +import { coreMock, scopedHistoryMock } from '../../../../core/public/mocks'; +import { dataPluginMock } from '../../../data/public/mocks'; +import { embeddablePluginMock } from '../../../embeddable/public/mocks'; +import { expressionsPluginMock } from '../../../expressions/public/mocks'; +import { createOsdUrlStateStorage } from '../../../opensearch_dashboards_utils/public'; +import { DataExplorerServices } from '../types'; + +export const createDataExplorerServicesMock = () => { + const coreStartMock = coreMock.createStart(); + const dataMock = dataPluginMock.createStartContract(); + const embeddableMock = embeddablePluginMock.createStartContract(); + const expressionMock = expressionsPluginMock.createStartContract(); + const osdUrlStateStorageMock = createOsdUrlStateStorage({ useHash: false }); + + const dataExplorerServicesMock: DataExplorerServices = { + ...coreStartMock, + expressions: expressionMock, + data: dataMock, + osdUrlStateStorage: osdUrlStateStorageMock, + embeddable: embeddableMock, + scopedHistory: (scopedHistoryMock.create() as unknown) as ScopedHistory, + viewRegistry: { + get: jest.fn(), + all: jest.fn(() => []), + }, + }; + + return (dataExplorerServicesMock as unknown) as jest.Mocked; +}; diff --git a/src/plugins/data_explorer/public/utils/state_management/hooks.ts b/src/plugins/data_explorer/public/utils/state_management/hooks.ts new file mode 100644 index 000000000000..d4194da3702f --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/hooks.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useDispatch, useSelector } from 'react-redux'; +import type { RootState, AppDispatch } from './store'; + +// Use throughout the app instead of plain `useDispatch` and `useSelector` +export const useTypedDispatch = () => useDispatch(); +export const useTypedSelector: ( + selector: (state: TState) => TSelected, + equalityFn?: (left: TSelected, right: TSelected) => boolean +) => TSelected = useSelector; diff --git a/src/plugins/data_explorer/public/utils/state_management/index.ts b/src/plugins/data_explorer/public/utils/state_management/index.ts new file mode 100644 index 000000000000..edb5c2a17184 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './store'; +export * from './hooks'; diff --git a/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts b/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts new file mode 100644 index 000000000000..e9fe84713120 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { DataExplorerServices } from '../../types'; + +export interface MetadataState { + indexPattern?: string; + originatingApp?: string; + view?: string; +} + +const initialState: MetadataState = {}; + +export const getPreloadedState = async ({ + embeddable, + scopedHistory, + data, +}: DataExplorerServices): Promise => { + const { originatingApp } = + embeddable + .getStateTransfer(scopedHistory) + .getIncomingEditorState({ keysToRemoveAfterFetch: ['id', 'input'] }) || {}; + const defaultIndexPattern = await data.indexPatterns.getDefault(); + const preloadedState: MetadataState = { + ...initialState, + originatingApp, + indexPattern: defaultIndexPattern?.id, + }; + + return preloadedState; +}; + +export const slice = createSlice({ + name: 'metadata', + initialState, + reducers: { + setIndexPattern: (state, action: PayloadAction) => { + state.indexPattern = action.payload; + }, + setOriginatingApp: (state, action: PayloadAction) => { + state.originatingApp = action.payload; + }, + setView: (state, action: PayloadAction) => { + state.view = action.payload; + }, + setState: (_state, action: PayloadAction) => { + return action.payload; + }, + }, +}); + +export const { reducer } = slice; +export const { setIndexPattern, setOriginatingApp, setView, setState } = slice.actions; diff --git a/src/plugins/data_explorer/public/utils/state_management/preload.ts b/src/plugins/data_explorer/public/utils/state_management/preload.ts new file mode 100644 index 000000000000..3dd179ed6432 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/preload.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PreloadedState } from '@reduxjs/toolkit'; +import { getPreloadedState as getPreloadedMetadataState } from './metadata_slice'; +import { RootState } from './store'; +import { DataExplorerServices } from '../../types'; + +export const getPreloadedState = async ( + services: DataExplorerServices +): Promise> => { + let rootState: RootState = { + metadata: await getPreloadedMetadataState(services), + }; + + // initialize the default state for each view + const views = services.viewRegistry.all(); + const promises = views.map(async (view) => { + if (!view.ui) { + return; + } + + const { defaults } = view.ui; + + // defaults can be a function or an object + const preloadedState = typeof defaults === 'function' ? await defaults() : defaults; + rootState[view.id] = preloadedState.state; + + // if the view wants to override the root state, we do that here + if (preloadedState.root) { + rootState = { + ...rootState, + ...preloadedState.root, + }; + } + }); + await Promise.all(promises); + + return rootState; +}; diff --git a/src/plugins/data_explorer/public/utils/state_management/redux_persistence.test.tsx b/src/plugins/data_explorer/public/utils/state_management/redux_persistence.test.tsx new file mode 100644 index 000000000000..62159558a0c4 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/redux_persistence.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataExplorerServices } from '../../types'; +import { createDataExplorerServicesMock } from '../mocks'; +import { loadReduxState, persistReduxState } from './redux_persistence'; + +describe('test redux state persistence', () => { + let mockServices: jest.Mocked; + let reduxStateParams: any; + + beforeEach(() => { + mockServices = createDataExplorerServicesMock(); + reduxStateParams = { + discover: 'visualization', + metadata: 'metadata', + }; + }); + + test('test load default redux state when url is empty', async () => { + const returnStates = await loadReduxState(mockServices); + expect(returnStates).toMatchInlineSnapshot(` + Object { + "metadata": Object { + "indexPattern": "id", + "originatingApp": undefined, + }, + } + `); + }); + + test('test load redux state', async () => { + mockServices.osdUrlStateStorage.set('_a', reduxStateParams, { replace: true }); + const returnStates = await loadReduxState(mockServices); + expect(returnStates).toStrictEqual(reduxStateParams); + }); + + test('test persist redux state', () => { + persistReduxState(reduxStateParams, mockServices); + const urlStates = mockServices.osdUrlStateStorage.get('_a'); + expect(urlStates).toStrictEqual(reduxStateParams); + }); +}); diff --git a/src/plugins/data_explorer/public/utils/state_management/redux_persistence.ts b/src/plugins/data_explorer/public/utils/state_management/redux_persistence.ts new file mode 100644 index 000000000000..81517f3e9f4f --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/redux_persistence.ts @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataExplorerServices } from '../../types'; +import { getPreloadedState } from './preload'; +import { RootState } from './store'; + +export const loadReduxState = async (services: DataExplorerServices) => { + try { + const serializedState = services.osdUrlStateStorage.get('_a'); + if (serializedState !== null) return serializedState; + } catch (err) { + // eslint-disable-next-line no-console + console.error(err); + } + + return await getPreloadedState(services); +}; + +export const persistReduxState = (root: RootState, services: DataExplorerServices) => { + try { + services.osdUrlStateStorage.set('_a', root, { + replace: true, + }); + } catch (err) { + return; + } +}; diff --git a/src/plugins/data_explorer/public/utils/state_management/store.ts b/src/plugins/data_explorer/public/utils/state_management/store.ts new file mode 100644 index 000000000000..cd967d25fc20 --- /dev/null +++ b/src/plugins/data_explorer/public/utils/state_management/store.ts @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { combineReducers, configureStore, PreloadedState, Reducer, Slice } from '@reduxjs/toolkit'; +import { isEqual } from 'lodash'; +import { reducer as metadataReducer } from './metadata_slice'; +import { loadReduxState, persistReduxState } from './redux_persistence'; +import { DataExplorerServices } from '../../types'; + +const commonReducers = { + metadata: metadataReducer, +}; + +let dynamicReducers: { + metadata: typeof metadataReducer; + [key: string]: Reducer; +} = { + ...commonReducers, +}; + +const rootReducer = combineReducers(dynamicReducers); + +export const configurePreloadedStore = (preloadedState: PreloadedState) => { + // After registering the slices the root reducer needs to be updated + const updatedRootReducer = combineReducers(dynamicReducers); + + return configureStore({ + reducer: updatedRootReducer, + preloadedState, + }); +}; + +export const getPreloadedStore = async (services: DataExplorerServices) => { + // For each view preload the data and register the slice + const views = services.viewRegistry.all(); + views.forEach((view) => { + if (!view.ui) return; + + const { slice } = view.ui; + registerSlice(slice); + }); + + const preloadedState = await loadReduxState(services); + const store = configurePreloadedStore(preloadedState); + + let previousState = store.getState(); + + // Listen to changes + const handleChange = () => { + const state = store.getState(); + persistReduxState(state, services); + + if (isEqual(state, previousState)) return; + + // Add Side effects here to apply after changes to the store are made. None for now. + + previousState = state; + }; + + // the store subscriber will automatically detect changes and call handleChange function + const unsubscribe = store.subscribe(handleChange); + + const onUnsubscribe = () => { + dynamicReducers = { + ...commonReducers, + }; + + unsubscribe(); + }; + + return { store, unsubscribe: onUnsubscribe }; +}; + +export const registerSlice = (slice: Slice) => { + if (dynamicReducers[slice.name]) { + throw new Error(`Slice ${slice.name} already registered`); + } + dynamicReducers[slice.name] = slice.reducer; +}; + +// Infer the `RootState` and `AppDispatch` types from the store itself +export type RootState = ReturnType; +export type RenderState = Omit; // Remaining state after auxillary states are removed +export type Store = ReturnType; +export type AppDispatch = Store['dispatch']; + +export { MetadataState, setIndexPattern, setOriginatingApp } from './metadata_slice'; diff --git a/src/plugins/data_explorer/public/utils/use/index.ts b/src/plugins/data_explorer/public/utils/use/index.ts new file mode 100644 index 000000000000..baab8736b66c --- /dev/null +++ b/src/plugins/data_explorer/public/utils/use/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './use_view'; diff --git a/src/plugins/data_explorer/public/utils/use/use_view.ts b/src/plugins/data_explorer/public/utils/use/use_view.ts new file mode 100644 index 000000000000..10f67c08907d --- /dev/null +++ b/src/plugins/data_explorer/public/utils/use/use_view.ts @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useMemo } from 'react'; +import { useParams } from 'react-router-dom'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { DataExplorerServices } from '../../types'; +import { useTypedDispatch, useTypedSelector } from '../state_management'; +import { setView } from '../state_management/metadata_slice'; + +export const useView = () => { + const viewId = useTypedSelector((state) => state.metadata.view); + const { + services: { viewRegistry }, + } = useOpenSearchDashboards(); + const dispatch = useTypedDispatch(); + const { appId } = useParams<{ appId: string }>(); + + const view = useMemo(() => { + if (!viewId) return undefined; + return viewRegistry.get(viewId); + }, [viewId, viewRegistry]); + + useEffect(() => { + const currentView = viewRegistry.get(appId); + + if (!currentView) return; + + dispatch(setView(currentView?.id)); + }, [appId, dispatch, viewRegistry]); + + return { view, viewRegistry }; +}; diff --git a/src/plugins/data_explorer/server/index.ts b/src/plugins/data_explorer/server/index.ts new file mode 100644 index 000000000000..84f443c971fe --- /dev/null +++ b/src/plugins/data_explorer/server/index.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginInitializerContext } from '../../../core/server'; +import { DataExplorerPlugin } from './plugin'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new DataExplorerPlugin(initializerContext); +} + +export { DataExplorerPluginSetup, DataExplorerPluginStart } from './types'; diff --git a/src/plugins/data_explorer/server/plugin.ts b/src/plugins/data_explorer/server/plugin.ts new file mode 100644 index 000000000000..ebc501e2a66d --- /dev/null +++ b/src/plugins/data_explorer/server/plugin.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, +} from '../../../core/server'; + +import { DataExplorerPluginSetup, DataExplorerPluginStart } from './types'; +import { defineRoutes } from './routes'; + +export class DataExplorerPlugin + implements Plugin { + private readonly logger: Logger; + + constructor(initializerContext: PluginInitializerContext) { + this.logger = initializerContext.logger.get(); + } + + public setup(core: CoreSetup) { + this.logger.debug('dataExplorer: Setup'); + const router = core.http.createRouter(); + + // Register server side APIs + defineRoutes(router); + + return {}; + } + + public start(core: CoreStart) { + this.logger.debug('dataExplorer: Started'); + return {}; + } + + public stop() {} +} diff --git a/src/plugins/data_explorer/server/routes/index.ts b/src/plugins/data_explorer/server/routes/index.ts new file mode 100644 index 000000000000..a577e27d9fd7 --- /dev/null +++ b/src/plugins/data_explorer/server/routes/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IRouter } from '../../../../core/server'; + +export function defineRoutes(router: IRouter) { + router.get( + { + path: '/api/data_explorer/example', + validate: false, + }, + async (context, request, response) => { + return response.ok({ + body: { + time: new Date().toISOString(), + }, + }); + } + ); +} diff --git a/src/plugins/data_explorer/server/types.ts b/src/plugins/data_explorer/server/types.ts new file mode 100644 index 000000000000..cbe17c922611 --- /dev/null +++ b/src/plugins/data_explorer/server/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface DataExplorerPluginSetup {} +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface DataExplorerPluginStart {} diff --git a/src/plugins/discover/common/index.ts b/src/plugins/discover/common/index.ts index 371442385bbf..0cac73333e25 100644 --- a/src/plugins/discover/common/index.ts +++ b/src/plugins/discover/common/index.ts @@ -1,33 +1,10 @@ /* + * Copyright OpenSearch Contributors * 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. */ +export const PLUGIN_ID = 'discover'; +export const NEW_DISCOVER_APP = 'discover:v2'; export const DEFAULT_COLUMNS_SETTING = 'defaultColumns'; export const SAMPLE_SIZE_SETTING = 'discover:sampleSize'; export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size'; diff --git a/src/plugins/discover/opensearch_dashboards.json b/src/plugins/discover/opensearch_dashboards.json index 23e00e7dcdcc..bcbfc2096731 100644 --- a/src/plugins/discover/opensearch_dashboards.json +++ b/src/plugins/discover/opensearch_dashboards.json @@ -6,6 +6,7 @@ "requiredPlugins": [ "charts", "data", + "dataExplorer", "embeddable", "inspector", "opensearchDashboardsLegacy", @@ -16,8 +17,8 @@ ], "optionalPlugins": ["home", "share"], "requiredBundles": [ - "opensearchDashboardsUtils", "home", + "opensearchDashboardsUtils", "savedObjects", "opensearchDashboardsReact" ] diff --git a/src/plugins/discover/public/__mock__/index_pattern_mock.ts b/src/plugins/discover/public/__mock__/index_pattern_mock.ts new file mode 100644 index 000000000000..a8f3bcc2c9dd --- /dev/null +++ b/src/plugins/discover/public/__mock__/index_pattern_mock.ts @@ -0,0 +1,100 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { indexPatterns } from '../../../data/public'; +import { IndexPattern } from '../opensearch_dashboards_services'; +import { IIndexPatternFieldList } from '../../../data/common'; + +// Initial data of index pattern fields +const fieldsData = [ + { + name: '_id', + type: 'string', + scripted: false, + aggregatable: true, + filterable: true, + searchable: true, + sortable: true, + }, + { + name: '_index', + type: 'string', + scripted: false, + filterable: true, + aggregatable: true, + searchable: true, + sortable: true, + }, + { + name: '_source', + type: '_source', + scripted: false, + aggregatable: false, + filterable: false, + searchable: false, + sortable: false, + }, +]; + +// Create a mock object for index pattern fields with methods: getAll, getByName and getByType +export const indexPatternFieldMock = { + getAll: () => fieldsData, + getByName: (name) => fieldsData.find((field) => field.name === name), + getByType: (type) => fieldsData.filter((field) => field.type === type), +} as IIndexPatternFieldList; + +// Create a mock for the initial index pattern +export const indexPatternInitialMock = ({ + id: '123', + title: 'test_index', + fields: indexPatternFieldMock, + timeFieldName: 'order_date', + formatHit: jest.fn((hit) => (hit.fields ? hit.fields : hit._source)), + flattenHit: undefined, + formatField: undefined, + metaFields: ['_id', '_index', '_source'], + getFieldByName: jest.fn(() => ({})), +} as unknown) as IndexPattern; + +// Add a flattenHit method to the initial index pattern mock using flattenHitWrapper +const flatternHitMock = indexPatterns.flattenHitWrapper( + indexPatternInitialMock, + indexPatternInitialMock.metaFields +); +indexPatternInitialMock.flattenHit = flatternHitMock; + +// Add a formatField method to the initial index pattern mock +const formatFieldMock = (hit, field) => { + return field === '_source' ? hit._source : indexPatternInitialMock.flattenHit(hit)[field]; +}; +indexPatternInitialMock.formatField = formatFieldMock; + +// Export the fully set up index pattern mock +export const indexPatternMock = indexPatternInitialMock; + +// Export a function that allows customization of index pattern mocks, by adding extra fields to the fieldsData +export const getMockedIndexPatternWithCustomizedFields = (fields) => { + const customizedFieldsData = [...fieldsData, ...fields]; + const customizedFieldsMock = { + getAll: () => customizedFieldsData, + getByName: (name) => customizedFieldsData.find((field) => field.name === name), + getByType: (type) => customizedFieldsData.filter((field) => field.type === type), + } as IIndexPatternFieldList; + + return { + ...indexPatternMock, + fields: customizedFieldsMock, + }; +}; + +// Export a function that allows customization of index pattern mocks with both extra fields and time field +export const getMockedIndexPatternWithTimeField = (fields, timeFiledName: string) => { + const indexPatternWithTimeFieldMock = getMockedIndexPatternWithCustomizedFields(fields); + + return { + ...indexPatternWithTimeFieldMock, + timeFieldName: timeFiledName, + }; +}; diff --git a/src/plugins/discover/public/application/components/chart/chart.tsx b/src/plugins/discover/public/application/components/chart/chart.tsx new file mode 100644 index 000000000000..80692927a002 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/chart.tsx @@ -0,0 +1,96 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import moment from 'moment'; +import dateMath from '@elastic/datemath'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { IUiSettingsClient } from 'opensearch-dashboards/public'; +import { DataPublicPluginStart, search } from '../../../../../data/public'; +import { HitsCounter } from './hits_counter'; +import { TimechartHeader, TimechartHeaderBucketInterval } from './timechart_header'; +import { DiscoverHistogram } from './histogram/histogram'; +import { DiscoverServices } from '../../../build_services'; +import { Chart } from './utils'; + +interface DiscoverChartProps { + bucketInterval: TimechartHeaderBucketInterval; + chartData: Chart; + config: IUiSettingsClient; + data: DataPublicPluginStart; + hits: number; + resetQuery: () => void; + timeField?: string; + services: DiscoverServices; +} + +export const DiscoverChart = ({ + bucketInterval, + chartData, + config, + data, + hits, + resetQuery, + timeField, + services, +}: DiscoverChartProps) => { + const { from, to } = data.query.timefilter.timefilter.getTime(); + const timeRange = { + from: dateMath.parse(from)?.format('YYYY-MM-DDTHH:mm:ss.SSSZ') || '', + to: dateMath.parse(to, { roundUp: true })?.format('YYYY-MM-DDTHH:mm:ss.SSSZ') || '', + }; + + const onChangeInterval = () => {}; + + const timefilterUpdateHandler = useCallback( + (ranges: { from: number; to: number }) => { + data.query.timefilter.timefilter.setTime({ + from: moment(ranges.from).toISOString(), + to: moment(ranges.to).toISOString(), + mode: 'absolute', + }); + }, + [data] + ); + + return ( + + + 0 ? hits : 0} showResetButton={false} onResetQuery={resetQuery} /> + + {timeField && ( + + + + )} + {timeField && chartData && ( + +
+
+ +
+
+
+ )} +
+ ); +}; diff --git a/src/plugins/discover/public/application/components/chart/histogram/histogram.tsx b/src/plugins/discover/public/application/components/chart/histogram/histogram.tsx new file mode 100644 index 000000000000..51d3f1f1b706 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/histogram/histogram.tsx @@ -0,0 +1,360 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSpacer } from '@elastic/eui'; +import moment from 'moment-timezone'; +import { unitOfTime } from 'moment'; +import React, { Component } from 'react'; +import PropTypes from 'prop-types'; +import { euiThemeVars } from '@osd/ui-shared-deps/theme'; + +import { + AnnotationDomainType, + Axis, + Chart, + HistogramBarSeries, + LineAnnotation, + Position, + ScaleType, + Settings, + RectAnnotation, + TooltipValue, + TooltipType, + ElementClickListener, + XYChartElementEvent, + BrushEndListener, + Theme, +} from '@elastic/charts'; + +import { i18n } from '@osd/i18n'; +import { IUiSettingsClient } from 'opensearch-dashboards/public'; +import { EuiChartThemeType } from '@elastic/eui/dist/eui_charts_theme'; +import { Subscription, combineLatest } from 'rxjs'; +import { Chart as IChart } from '../utils/point_series'; +import { DiscoverServices } from '../../../../build_services'; + +export interface DiscoverHistogramProps { + chartData: IChart; + timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; + services: DiscoverServices; +} + +interface DiscoverHistogramState { + chartsTheme: EuiChartThemeType['theme']; + chartsBaseTheme: Theme; +} + +function findIntervalFromDuration( + dateValue: number, + opensearchValue: number, + opensearchUnit: unitOfTime.Base, + timeZone: string +) { + const date = moment.tz(dateValue, timeZone); + const startOfDate = moment.tz(date, timeZone).startOf(opensearchUnit); + const endOfDate = moment + .tz(date, timeZone) + .startOf(opensearchUnit) + .add(opensearchValue, opensearchUnit); + return endOfDate.valueOf() - startOfDate.valueOf(); +} + +function getIntervalInMs( + value: number, + opensearchValue: number, + opensearchUnit: unitOfTime.Base, + timeZone: string +): number { + switch (opensearchUnit) { + case 's': + return 1000 * opensearchValue; + case 'ms': + return 1 * opensearchValue; + default: + return findIntervalFromDuration(value, opensearchValue, opensearchUnit, timeZone); + } +} + +function getTimezone(uiSettings: IUiSettingsClient) { + if (uiSettings.isDefault('dateFormat:tz')) { + const detectedTimezone = moment.tz.guess(); + if (detectedTimezone) return detectedTimezone; + else return moment().format('Z'); + } else { + return uiSettings.get('dateFormat:tz', 'Browser'); + } +} + +export function findMinInterval( + xValues: number[], + opensearchValue: number, + opensearchUnit: string, + timeZone: string +): number { + return xValues.reduce((minInterval, currentXvalue, index) => { + let currentDiff = minInterval; + if (index > 0) { + currentDiff = Math.abs(xValues[index - 1] - currentXvalue); + } + const singleUnitInterval = getIntervalInMs( + currentXvalue, + opensearchValue, + opensearchUnit as unitOfTime.Base, + timeZone + ); + return Math.min(minInterval, singleUnitInterval, currentDiff); + }, Number.MAX_SAFE_INTEGER); +} + +export class DiscoverHistogram extends Component { + public static propTypes = { + chartData: PropTypes.object, + timefilterUpdateHandler: PropTypes.func, + }; + + private subscription?: Subscription; + + componentDidMount() { + this.subscription = combineLatest( + this.props.services.theme.chartsTheme$, + this.props.services.theme.chartsBaseTheme$ + ).subscribe(([chartsTheme, chartsBaseTheme]) => + this.setState({ chartsTheme, chartsBaseTheme }) + ); + } + + componentWillUnmount() { + if (this.subscription) { + this.subscription.unsubscribe(); + } + } + + public onBrushEnd: BrushEndListener = ({ x }) => { + if (!x) { + return; + } + const [from, to] = x; + this.props.timefilterUpdateHandler({ from, to }); + }; + + public onElementClick = (xInterval: number): ElementClickListener => ([elementData]) => { + const startRange = (elementData as XYChartElementEvent)[0].x; + + const range = { + from: startRange, + to: startRange + xInterval, + }; + + this.props.timefilterUpdateHandler(range); + }; + + public formatXValue = (val: string) => { + const xAxisFormat = this.props.chartData.xAxisFormat.params!.pattern; + + return moment(val).format(xAxisFormat); + }; + + public renderBarTooltip = (xInterval: number, domainStart: number, domainEnd: number) => ( + headerData: TooltipValue + ): JSX.Element | string => { + const headerDataValue = headerData.value; + const formattedValue = this.formatXValue(headerDataValue); + + const partialDataText = i18n.translate('discover.histogram.partialData.bucketTooltipText', { + defaultMessage: + 'The selected time range does not include this entire bucket, it may contain partial data.', + }); + + if (headerDataValue < domainStart || headerDataValue + xInterval > domainEnd) { + return ( + + + + + + {partialDataText} + + +

{formattedValue}

+
+ ); + } + + return formattedValue; + }; + + public render() { + const { chartData, services } = this.props; + const { uiSettings } = services; + const timeZone = getTimezone(uiSettings); + const chartsTheme = services.theme.chartsDefaultTheme; + const chartsBaseTheme = services.theme.chartsDefaultBaseTheme; + + if (!chartData) { + return null; + } + + const data = chartData.values; + + /** + * Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval]. + * see https://github.com/elastic/kibana/issues/27410 + * TODO: Once the Discover query has been update, we should change the below to use the new field + */ + const { intervalOpenSearchValue, intervalOpenSearchUnit, interval } = chartData.ordered; + const xInterval = interval.asMilliseconds(); + + const xValues = chartData.xAxisOrderedValues; + const lastXValue = xValues[xValues.length - 1]; + + const domain = chartData.ordered; + const domainStart = domain.min.valueOf(); + const domainEnd = domain.max.valueOf(); + + const domainMin = data[0]?.x > domainStart ? domainStart : data[0]?.x; + const domainMax = domainEnd - xInterval > lastXValue ? domainEnd - xInterval : lastXValue; + + const xDomain = { + min: domainMin, + max: domainMax, + minInterval: findMinInterval( + xValues, + intervalOpenSearchValue, + intervalOpenSearchUnit, + timeZone + ), + }; + + // Domain end of 'now' will be milliseconds behind current time, so we extend time by 1 minute and check if + // the annotation is within this range; if so, the line annotation uses the domainEnd as its value + const now = moment(); + const isAnnotationAtEdge = moment(domainEnd).add(60000).isAfter(now) && now.isAfter(domainEnd); + const lineAnnotationValue = isAnnotationAtEdge ? domainEnd : now; + + const lineAnnotationData = [ + { + dataValue: lineAnnotationValue, + }, + ]; + const isDarkMode = uiSettings.get('theme:darkMode'); + + const lineAnnotationStyle = { + line: { + strokeWidth: 2, + stroke: euiThemeVars.euiColorDanger, + opacity: 0.7, + }, + }; + + const rectAnnotations = []; + if (domainStart !== domainMin) { + rectAnnotations.push({ + coordinates: { + x1: domainStart, + }, + }); + } + if (domainEnd !== domainMax) { + rectAnnotations.push({ + coordinates: { + x0: domainEnd, + }, + }); + } + + const rectAnnotationStyle = { + stroke: isDarkMode ? euiThemeVars.euiColorLightShade : euiThemeVars.euiColorDarkShade, + strokeWidth: 0, + opacity: isDarkMode ? 0.6 : 0.2, + fill: isDarkMode ? euiThemeVars.euiColorLightShade : euiThemeVars.euiColorDarkShade, + }; + + const tooltipProps = { + headerFormatter: this.renderBarTooltip(xInterval, domainStart, domainEnd), + type: TooltipType.VerticalCursor, + }; + + return ( + + + + + + + + + ); + } +} diff --git a/src/plugins/discover/public/application/components/chart/histogram/type.ts b/src/plugins/discover/public/application/components/chart/histogram/type.ts new file mode 100644 index 000000000000..64c5a1124731 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/histogram/type.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface Chart { + values: Array<{ + x: number; + y: number; + }>; + xAxisOrderedValues: number[]; + xAxisFormat: Dimension['format']; + xAxisLabel: Column['name']; + yAxisLabel?: Column['name']; + ordered: Ordered; +} diff --git a/src/plugins/discover/public/application/components/hits_counter/hits_counter.test.tsx b/src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.test.tsx similarity index 100% rename from src/plugins/discover/public/application/components/hits_counter/hits_counter.test.tsx rename to src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.test.tsx diff --git a/src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.tsx b/src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.tsx new file mode 100644 index 000000000000..fede3e04ecd4 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.tsx @@ -0,0 +1,95 @@ +/* + * 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 { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { formatNumWithCommas } from '../../../helpers'; + +export interface HitsCounterProps { + /** + * the number of query hits + */ + hits: number; + /** + * displays the reset button + */ + showResetButton: boolean; + /** + * resets the query + */ + onResetQuery: () => void; +} + +export function HitsCounter({ hits, showResetButton, onResetQuery }: HitsCounterProps) { + return ( + + + + + {formatNumWithCommas(hits)}{' '} + + + + {showResetButton && ( + + + + + + )} + + + ); +} diff --git a/src/plugins/discover/public/application/components/hits_counter/index.ts b/src/plugins/discover/public/application/components/chart/hits_counter/index.ts similarity index 100% rename from src/plugins/discover/public/application/components/hits_counter/index.ts rename to src/plugins/discover/public/application/components/chart/hits_counter/index.ts diff --git a/src/plugins/discover/public/application/components/chart/timechart_header/index.ts b/src/plugins/discover/public/application/components/chart/timechart_header/index.ts new file mode 100644 index 000000000000..34763a05f8e0 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/timechart_header/index.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +export * from './timechart_header'; diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx b/src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.test.tsx similarity index 100% rename from src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx rename to src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.test.tsx diff --git a/src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.tsx b/src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.tsx new file mode 100644 index 000000000000..a79876ccb455 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.tsx @@ -0,0 +1,189 @@ +/* + * 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, { useState, useEffect, useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiText, + EuiSelect, + EuiIconTip, +} from '@elastic/eui'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import moment from 'moment'; + +export interface TimechartHeaderBucketInterval { + scaled?: boolean; + description?: string; + scale?: number; +} + +export interface TimechartHeaderProps { + /** + * Format of date to be displayed + */ + dateFormat?: string; + /** + * Interval for the buckets of the recent request + */ + bucketInterval?: { + scaled?: boolean; + description?: string; + scale?: number; + }; + /** + * Range of dates to be displayed + */ + timeRange?: { + from: string; + to: string; + }; + /** + * Interval Options + */ + options: Array<{ display: string; val: string }>; + /** + * changes the interval + */ + onChangeInterval: (interval: string) => void; + /** + * selected interval + */ + stateInterval: string; +} + +export function TimechartHeader({ + bucketInterval, + dateFormat, + timeRange, + options, + onChangeInterval, + stateInterval, +}: TimechartHeaderProps) { + const [interval, setInterval] = useState(stateInterval); + const toMoment = useCallback( + (datetime: string) => { + if (!datetime) { + return ''; + } + if (!dateFormat) { + return datetime; + } + return moment(datetime).format(dateFormat); + }, + [dateFormat] + ); + + useEffect(() => { + setInterval(stateInterval); + }, [stateInterval]); + + const handleIntervalChange = (e: React.ChangeEvent) => { + setInterval(e.target.value); + onChangeInterval(e.target.value); + }; + + if (!timeRange || !bucketInterval) { + return null; + } + + return ( + + + + + + {`${toMoment(timeRange.from)} - ${toMoment(timeRange.to)} ${ + interval !== 'auto' + ? i18n.translate('discover.timechartHeader.timeIntervalSelect.per', { + defaultMessage: 'per', + }) + : '' + }`} + + + + + val !== 'custom') + .map(({ display, val }) => { + return { + text: display, + value: val, + label: display, + }; + })} + value={interval} + onChange={handleIntervalChange} + append={ + bucketInterval.scaled ? ( + 1 + ? i18n.translate('discover.bucketIntervalTooltip.tooLargeBucketsText', { + defaultMessage: 'buckets that are too large', + }) + : i18n.translate('discover.bucketIntervalTooltip.tooManyBucketsText', { + defaultMessage: 'too many buckets', + }), + bucketIntervalDescription: bucketInterval.description, + }, + })} + color="warning" + size="s" + type="alert" + /> + ) : undefined + } + /> + + + + ); +} diff --git a/src/plugins/discover/public/application/components/chart/utils/create_histogram_configs.ts b/src/plugins/discover/public/application/components/chart/utils/create_histogram_configs.ts new file mode 100644 index 000000000000..8e659e73f9d7 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/create_histogram_configs.ts @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataPublicPluginStart, IndexPattern } from '../../../../../../data/public'; + +export function createHistogramConfigs( + indexPattern: IndexPattern, + histogramInterval: string, + data: DataPublicPluginStart +) { + const visStateAggs = [ + { + type: 'count', + schema: 'metric', + }, + { + type: 'date_histogram', + schema: 'segment', + params: { + field: indexPattern.timeFieldName!, + interval: histogramInterval, + timeRange: data.query.timefilter.timefilter.getTime(), + }, + }, + ]; + return data.search.aggs.createAggConfigs(indexPattern, visStateAggs); +} diff --git a/src/plugins/discover/public/application/components/chart/utils/get_dimension.test.ts b/src/plugins/discover/public/application/components/chart/utils/get_dimension.test.ts new file mode 100644 index 000000000000..a4f1c889a9ba --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/get_dimension.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getDimensions } from './get_dimensions'; +import moment from 'moment'; +import dateMath from '@elastic/datemath'; +import { search } from '../../../../../../data/public'; +import { dataPluginMock } from '../../../../../../data/public/mocks'; +import { + calculateBounds, + IBucketDateHistogramAggConfig, + IAggConfigs, +} from '../../../../../../data/common'; + +describe('getDimensions', () => { + it('should return dimensions when buckets and bounds are defined', () => { + const dataMock = dataPluginMock.createStartContract(); + dataMock.query.timefilter.timefilter.getTime = () => { + return { from: '2021-01-01T00:00:00.000Z', to: '2021-01-31T00:00:00.000Z' }; + }; + dataMock.query.timefilter.timefilter.calculateBounds = (timeRange) => { + return calculateBounds(timeRange); + }; + + const metric = { + toSerializedFieldFormat: jest.fn(() => 'metric-format'), + makeLabel: jest.fn(() => 'metric-label'), + }; + const agg = { + params: { timeRange: null }, + buckets: { + getInterval: jest.fn(() => ({ opensearchUnit: 'day', opensearchValue: 1 })), + getScaledDateFormat: jest.fn(() => 'scaled-date-format'), + getBounds: jest.fn(() => 'bounds'), + }, + makeLabel: jest.fn(() => 'agg-label'), + toSerializedFieldFormat: jest.fn(() => 'agg-format'), + }; + const aggs: IAggConfigs = { + aggs: [metric, agg], + }; + + // Mocking external dependencies + dateMath.parse = jest.fn((date, options) => moment(date)); + search.aggs.isDateHistogramBucketAggConfig = jest.fn( + (bucketAgg: any): bucketAgg is IBucketDateHistogramAggConfig => true + ); + + const result = getDimensions(aggs, dataMock); + + expect(result).toEqual({ + x: { + accessor: 0, + label: 'agg-label', + format: 'agg-format', + params: { + date: true, + interval: moment.duration(1, 'day'), + intervalOpenSearchValue: 1, + intervalOpenSearchUnit: 'day', + format: 'scaled-date-format', + bounds: 'bounds', + }, + }, + y: { + accessor: 1, + format: 'metric-format', + label: 'metric-label', + }, + }); + }); +}); diff --git a/src/plugins/discover/public/application/components/chart/utils/get_dimensions.ts b/src/plugins/discover/public/application/components/chart/utils/get_dimensions.ts new file mode 100644 index 000000000000..a1292e8aefa6 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/get_dimensions.ts @@ -0,0 +1,73 @@ +/* + * 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 moment from 'moment'; +import dateMath from '@elastic/datemath'; +import { IAggConfigs } from '../../../../../../data/common'; +import { search } from '../../../../../../data/public'; + +export function getDimensions(aggs: IAggConfigs, data: any) { + const [metric, agg] = aggs.aggs; + const { from, to } = data.query.timefilter.timefilter.getTime(); + agg.params.timeRange = { + from: dateMath.parse(from), + to: dateMath.parse(to, { roundUp: true }), + }; + const bounds = agg.params.timeRange + ? data.query.timefilter.timefilter.calculateBounds(agg.params.timeRange) + : null; + const buckets = search.aggs.isDateHistogramBucketAggConfig(agg) ? agg.buckets : undefined; + + if (!buckets || !bounds) { + return; + } + + const { opensearchUnit, opensearchValue } = buckets.getInterval(); + return { + x: { + accessor: 0, + label: agg.makeLabel(), + format: agg.toSerializedFieldFormat(), + params: { + date: true, + interval: moment.duration(opensearchValue, opensearchUnit), + intervalOpenSearchValue: opensearchValue, + intervalOpenSearchUnit: opensearchUnit, + format: buckets.getScaledDateFormat(), + bounds: buckets.getBounds(), + }, + }, + y: { + accessor: 1, + format: metric.toSerializedFieldFormat(), + label: metric.makeLabel(), + }, + }; +} diff --git a/src/plugins/discover/public/application/components/chart/utils/index.ts b/src/plugins/discover/public/application/components/chart/utils/index.ts new file mode 100644 index 000000000000..36aa4015782e --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './create_histogram_configs'; +export * from './point_series'; +export * from './get_dimensions'; diff --git a/src/plugins/discover/public/application/components/chart/utils/point_series.test.ts b/src/plugins/discover/public/application/components/chart/utils/point_series.test.ts new file mode 100644 index 000000000000..7348033e834f --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/point_series.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildPointSeriesData, Dimensions, Table } from './point_series'; +import moment from 'moment'; + +describe('buildPointSeriesData', () => { + it('should build the chart data from the table and dimensions', () => { + const table: Table = { + columns: [ + { id: 'x', name: 'X Axis' }, + { id: 'y', name: 'Y Axis' }, + ], + rows: [ + { x: 10, y: 100 }, + { x: 20, y: 200 }, + { x: 10, y: 'NaN' }, // This row should be ignored + ], + }; + + const dimensions: Dimensions = { + x: { + accessor: 0, + format: { id: 'number', params: { pattern: 'number' } }, + params: { + date: true, + interval: moment.duration(1, 'hour'), + intervalOpenSearchValue: 1, + intervalOpenSearchUnit: 'h', + format: 'number', + bounds: { + min: moment('2023-01-01'), + max: moment('2023-01-02'), + }, + }, + }, + y: { + accessor: 1, + format: { id: 'number', params: { pattern: 'number' } }, + }, + }; + + const result = buildPointSeriesData(table, dimensions); + + expect(result.xAxisOrderedValues).toEqual([10, 20]); + expect(result.xAxisFormat).toEqual(dimensions.x.format); + expect(result.xAxisLabel).toEqual('X Axis'); + expect(result.ordered.date).toBe(true); + expect(result.ordered.interval.asHours()).toBe(1); + expect(result.ordered.intervalOpenSearchUnit).toBe('h'); + expect(result.ordered.intervalOpenSearchValue).toBe(1); + expect(result.ordered.min.format()).toBe('2023-01-01T00:00:00+00:00'); + expect(result.ordered.max.format()).toBe('2023-01-02T00:00:00+00:00'); + expect(result.yAxisLabel).toEqual('Y Axis'); + expect(result.values).toEqual([ + { x: 10, y: 100 }, + { x: 20, y: 200 }, + ]); + }); +}); diff --git a/src/plugins/discover/public/application/components/chart/utils/point_series.ts b/src/plugins/discover/public/application/components/chart/utils/point_series.ts new file mode 100644 index 000000000000..4c3e3e9ada53 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/point_series.ts @@ -0,0 +1,122 @@ +/* + * 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 { uniq } from 'lodash'; +import { Duration, Moment } from 'moment'; +import { Unit } from '@elastic/datemath'; + +import { SerializedFieldFormat } from '../../../../../../expressions/common/types'; + +export interface Column { + id: string; + name: string; +} + +export interface Row { + [key: string]: number | 'NaN'; +} + +export interface Table { + columns: Column[]; + rows: Row[]; +} + +interface HistogramParams { + date: true; + interval: Duration; + intervalOpenSearchValue: number; + intervalOpenSearchUnit: Unit; + format: string; + bounds: { + min: Moment; + max: Moment; + }; +} +export interface Dimension { + accessor: 0 | 1; + format: SerializedFieldFormat<{ pattern: string }>; +} + +export interface Dimensions { + x: Dimension & { params: HistogramParams }; + y: Dimension; +} + +interface Ordered { + date: true; + interval: Duration; + intervalOpenSearchUnit: string; + intervalOpenSearchValue: number; + min: Moment; + max: Moment; +} +export interface Chart { + values: Array<{ + x: number; + y: number; + }>; + xAxisOrderedValues: number[]; + xAxisFormat: Dimension['format']; + xAxisLabel: Column['name']; + yAxisLabel?: Column['name']; + ordered: Ordered; +} + +export const buildPointSeriesData = (table: Table, dimensions: Dimensions) => { + const { x, y } = dimensions; + const xAccessor = table.columns[x.accessor].id; + const yAccessor = table.columns[y.accessor].id; + const chart = {} as Chart; + + chart.xAxisOrderedValues = uniq(table.rows.map((r) => r[xAccessor] as number)); + chart.xAxisFormat = x.format; + chart.xAxisLabel = table.columns[x.accessor].name; + + const { intervalOpenSearchUnit, intervalOpenSearchValue, interval, bounds } = x.params; + chart.ordered = { + date: true, + interval, + intervalOpenSearchUnit, + intervalOpenSearchValue, + min: bounds.min, + max: bounds.max, + }; + + chart.yAxisLabel = table.columns[y.accessor].name; + + chart.values = table.rows + .filter((row) => row && row[yAccessor] !== 'NaN') + .map((row) => ({ + x: row[xAccessor] as number, + y: row[yAccessor] as number, + })); + + return chart; +}; diff --git a/src/plugins/discover/public/application/components/data_grid/constants.ts b/src/plugins/discover/public/application/components/data_grid/constants.ts new file mode 100644 index 000000000000..c11f205aff1f --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/constants.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const toolbarVisibility = { + showColumnSelector: { + allowHide: false, + allowReorder: true, + }, + showStyleSelector: false, +}; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx new file mode 100644 index 000000000000..3f99c009e56a --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx @@ -0,0 +1,164 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { EuiDataGrid, EuiDataGridSorting, EuiPanel } from '@elastic/eui'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { fetchTableDataCell } from './data_grid_table_cell_value'; +import { buildDataGridColumns, computeVisibleColumns } from './data_grid_table_columns'; +import { DocViewExpandButton } from './data_grid_table_docview_expand_button'; +import { DataGridFlyout } from './data_grid_table_flyout'; +import { DiscoverGridContextProvider } from './data_grid_table_context'; +import { toolbarVisibility } from './constants'; +import { DocViewFilterFn } from '../../doc_views/doc_views_types'; +import { DiscoverServices } from '../../../build_services'; +import { OpenSearchSearchHit } from '../../doc_views/doc_views_types'; +import { usePagination } from '../utils/use_pagination'; + +export interface DataGridTableProps { + columns: string[]; + indexPattern: IndexPattern; + onAddColumn: (column: string) => void; + onFilter: DocViewFilterFn; + onRemoveColumn: (column: string) => void; + onSort: (sort: Array<[string, 'asc' | 'desc']>) => void; + rows: OpenSearchSearchHit[]; + onSetColumns: (columns: string[]) => void; + sort: Array<[string, 'asc' | 'desc']>; + displayTimeColumn: boolean; + services: DiscoverServices; + isToolbarVisible?: boolean; +} + +export const DataGridTable = ({ + columns, + indexPattern, + onAddColumn, + onFilter, + onRemoveColumn, + onSetColumns, + onSort, + sort, + rows, + displayTimeColumn, + isToolbarVisible = true, +}: DataGridTableProps) => { + const [expandedHit, setExpandedHit] = useState(); + const rowCount = useMemo(() => (rows ? rows.length : 0), [rows]); + const pagination = usePagination(rowCount); + + const sortingColumns = useMemo(() => sort.map(([id, direction]) => ({ id, direction })), [sort]); + const rowHeightsOptions = useMemo( + () => ({ + defaultHeight: { + lineCount: columns.includes('_source') ? 3 : 1, + }, + }), + [columns] + ); + + const onColumnSort = useCallback( + (cols: EuiDataGridSorting['columns']) => { + onSort(cols.map(({ id, direction }) => [id, direction])); + }, + [onSort] + ); + + const renderCellValue = useMemo(() => fetchTableDataCell(indexPattern, rows), [ + indexPattern, + rows, + ]); + + const dataGridTableColumns = useMemo( + () => buildDataGridColumns(columns, indexPattern, displayTimeColumn), + [columns, indexPattern, displayTimeColumn] + ); + + const dataGridTableColumnsVisibility = useMemo( + () => ({ + visibleColumns: computeVisibleColumns(columns, indexPattern, displayTimeColumn) as string[], + setVisibleColumns: (cols: string[]) => { + onSetColumns(cols); + }, + }), + [columns, indexPattern, displayTimeColumn, onSetColumns] + ); + + const sorting: EuiDataGridSorting = useMemo( + () => ({ columns: sortingColumns, onSort: onColumnSort }), + [sortingColumns, onColumnSort] + ); + + const leadingControlColumns = useMemo(() => { + return [ + { + id: 'expandCollapseColumn', + headerCellRender: () => null, + rowCellRender: DocViewExpandButton, + width: 40, + }, + ]; + }, []); + + const table = useMemo( + () => ( + + ), + [ + dataGridTableColumns, + dataGridTableColumnsVisibility, + leadingControlColumns, + pagination, + renderCellValue, + rowCount, + sorting, + isToolbarVisible, + rowHeightsOptions, + ] + ); + + return ( + + <> + + + {table} + + + {expandedHit && ( + setExpandedHit(undefined)} + /> + )} + + + ); +}; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.test.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.test.tsx new file mode 100644 index 000000000000..e9c867a53db7 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.test.tsx @@ -0,0 +1,187 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { + indexPatternMock, + getMockedIndexPatternWithCustomizedFields, +} from '../../../__mock__/index_pattern_mock'; +import { fetchTableDataCell } from './data_grid_table_cell_value'; + +const fieldsData = [ + { + name: 'name', + scripted: false, + filterable: true, + aggregatable: false, + searchable: true, + sortable: false, + }, + { + name: 'currency', + type: 'string', + scripted: false, + filterable: true, + aggregatable: true, + searchable: true, + sortable: true, + }, + { + name: 'order_date', + type: 'date', + scripted: false, + filterable: true, + aggregatable: true, + searchable: true, + sortable: true, + }, +]; + +const dataRowsMock = [ + { + _id: '1', + _index: 'test_index', + _score: 0, + _source: { + name: 'Eddie', + currency: 'EUR', + order_date: '2023-08-07T09:28:48+00:00', + }, + fields: { + order_date: ['2023-08-07T09:28:48.000Z'], + }, + _version: 1, + _type: '_doc', + }, +]; + +const customizedIndexPatternMock = getMockedIndexPatternWithCustomizedFields( + fieldsData +) as IndexPattern; + +describe('Testing fetchTableDataCell function', () => { + it('should display empty span if no data', () => { + const DataGridTableCellValue = fetchTableDataCell(indexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + - + + `); + }); + + it('should display empty span if field is not defined in index pattern', () => { + const DataGridTableCellValue = fetchTableDataCell(indexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + - + + `); + }); + + it('should display JSON string representation of the data if columnId is _source and isDetails is false', () => { + const DataGridTableCellValue = fetchTableDataCell(customizedIndexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + { + "name": "Eddie", + "currency": "EUR", + "order_date": "2023-08-07T09:28:48+00:00" + } + + `); + }); + + it('should display EuiDescriptionList if columnId is _source and isDetails is false', () => { + const DataGridTableCellValue = fetchTableDataCell(customizedIndexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + + order_date + + + + `); + }); + + it('should correctly display data if columnId is in index pattern and is not _source', () => { + const DataGridTableCellValue = fetchTableDataCell(customizedIndexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + `); + }); +}); diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.tsx new file mode 100644 index 000000000000..2e39dde3ba06 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.tsx @@ -0,0 +1,80 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Fragment } from 'react'; +import dompurify from 'dompurify'; + +import { + EuiDataGridCellValueElementProps, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { OpenSearchSearchHit } from '../../doc_views/doc_views_types'; + +function fetchSourceTypeDataCell( + idxPattern: IndexPattern, + row: Record, + columnId: string, + isDetails: boolean +) { + if (isDetails) { + return {JSON.stringify(row[columnId], null, 2)}; + } + const formattedRow = idxPattern.formatHit(row); + + return ( + + {Object.keys(formattedRow).map((key) => ( + + {key} + + + ))} + + ); +} + +export const fetchTableDataCell = ( + idxPattern: IndexPattern, + dataRows: OpenSearchSearchHit[] | undefined +) => ({ rowIndex, columnId, isDetails }: EuiDataGridCellValueElementProps) => { + const singleRow = dataRows ? (dataRows[rowIndex] as Record) : undefined; + const flattenedRows = dataRows ? dataRows.map((hit) => idxPattern.flattenHit(hit)) : []; + const flattenedRow = flattenedRows + ? (flattenedRows[rowIndex] as Record) + : undefined; + const fieldInfo = idxPattern.fields.getByName(columnId); + + if (typeof singleRow === 'undefined' || typeof flattenedRow === 'undefined') { + return -; + } + + if (!fieldInfo?.type && flattenedRow && typeof flattenedRow[columnId] === 'object') { + if (isDetails) { + return {JSON.stringify(flattenedRow[columnId], null, 2)}; + } + + return {JSON.stringify(flattenedRow[columnId])}; + } + + if (fieldInfo?.type === '_source') { + return fetchSourceTypeDataCell(idxPattern, singleRow, columnId, isDetails); + } + + const formattedValue = idxPattern.formatField(singleRow, columnId); + if (typeof formattedValue === 'undefined') { + return -; + } else { + const sanitizedCellValue = dompurify.sanitize(idxPattern.formatField(singleRow, columnId)); + return ( + // eslint-disable-next-line react/no-danger + + ); + } +}; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx new file mode 100644 index 000000000000..f5580b990282 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx @@ -0,0 +1,249 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { + getMockedIndexPatternWithCustomizedFields, + getMockedIndexPatternWithTimeField, +} from '../../../__mock__/index_pattern_mock'; +import { buildDataGridColumns, computeVisibleColumns } from './data_grid_table_columns'; + +const fieldsData = [ + { + name: 'name', + scripted: false, + filterable: true, + aggregatable: false, + searchable: true, + sortable: false, + }, + { + name: 'currency', + type: 'string', + scripted: false, + filterable: true, + aggregatable: true, + searchable: true, + sortable: true, + }, + { + name: 'order_date', + type: 'date', + scripted: false, + filterable: true, + aggregatable: true, + searchable: true, + sortable: true, + }, +]; + +const customizedIndexPatternMock = getMockedIndexPatternWithCustomizedFields( + fieldsData +) as IndexPattern; +const customizedIndexPatternMockWithTimeField = getMockedIndexPatternWithTimeField( + fieldsData, + 'order_date' +) as IndexPattern; + +describe('Testing buildDataGridColumns function ', () => { + it('should return correct columns without time column when displayTimeColumn is false', () => { + const columns = buildDataGridColumns(['name', 'currency'], customizedIndexPatternMock, false); + expect(columns).toHaveLength(2); + expect(columns[0].id).toEqual('name'); + expect(columns[1].id).toEqual('currency'); + expect(columns).toMatchInlineSnapshot(` + Array [ + Object { + "actions": Object { + "showHide": true, + "showMoveLeft": false, + "showMoveRight": false, + }, + "cellActions": Array [], + "display": undefined, + "id": "name", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": true, + "showMoveLeft": false, + "showMoveRight": false, + }, + "cellActions": Array [], + "display": undefined, + "id": "currency", + "isSortable": undefined, + "schema": undefined, + }, + ] + `); + }); + + it('should add time and source columns correctly when displayTimeColumn is true', () => { + const columns = buildDataGridColumns( + ['name', 'currency', '_source'], + customizedIndexPatternMockWithTimeField, + true + ); + expect(columns).toHaveLength(4); + expect(columns[0].id).toEqual('order_date'); + expect(columns[0].display).toEqual('Time (order_date)'); + expect(columns[3].id).toEqual('_source'); + expect(columns[3].display).toEqual('Source'); + expect(columns).toMatchInlineSnapshot(` + Array [ + Object { + "actions": Object { + "showHide": true, + "showMoveLeft": false, + "showMoveRight": false, + }, + "cellActions": Array [], + "display": "Time (order_date)", + "id": "order_date", + "initialWidth": 200, + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": true, + "showMoveLeft": false, + "showMoveRight": false, + }, + "cellActions": Array [], + "display": undefined, + "id": "name", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": true, + "showMoveLeft": false, + "showMoveRight": false, + }, + "cellActions": Array [], + "display": undefined, + "id": "currency", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": true, + "showMoveLeft": false, + "showMoveRight": false, + }, + "cellActions": Array [], + "display": "Source", + "id": "_source", + "isSortable": undefined, + "schema": undefined, + }, + ] + `); + }); + + it('should set display for time column correctly when time field is already included', () => { + const columns = buildDataGridColumns( + ['name', 'currency', 'order_date'], + customizedIndexPatternMockWithTimeField, + true + ); + expect(columns).toHaveLength(3); + expect(columns[2].id).toEqual('order_date'); + expect(columns[2].display).toEqual('Time (order_date)'); + expect(columns).toMatchInlineSnapshot(` + Array [ + Object { + "actions": Object { + "showHide": true, + "showMoveLeft": false, + "showMoveRight": false, + }, + "cellActions": Array [], + "display": undefined, + "id": "name", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": true, + "showMoveLeft": false, + "showMoveRight": false, + }, + "cellActions": Array [], + "display": undefined, + "id": "currency", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": true, + "showMoveLeft": false, + "showMoveRight": false, + }, + "cellActions": Array [], + "display": "Time (order_date)", + "id": "order_date", + "initialWidth": 200, + "isSortable": undefined, + "schema": undefined, + }, + ] + `); + }); +}); + +describe('Testing computeVisibleColumns function ', () => { + it('should include time column when displayTimeColumn is true and time field is missing', () => { + const visibleColumns = computeVisibleColumns( + ['name', 'currency'], + customizedIndexPatternMock, + true + ); + expect(visibleColumns).toMatchInlineSnapshot(` + Array [ + "order_date", + "name", + "currency", + ] + `); + }); + + it('should not add duplicate time column when displayTimeColumn is true and time field is included', () => { + const visibleColumns = computeVisibleColumns( + ['name', 'currency', 'order_date'], + customizedIndexPatternMock, + true + ); + expect(visibleColumns).toMatchInlineSnapshot(` + Array [ + "name", + "currency", + "order_date", + ] + `); + }); + + it('should not add time column when displayTimeColumn is false', () => { + const visibleColumns = computeVisibleColumns( + ['name', 'currency'], + customizedIndexPatternMock, + false + ); + expect(visibleColumns).toMatchInlineSnapshot(` + Array [ + "name", + "currency", + ] + `); + }); +}); diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx new file mode 100644 index 000000000000..1561a7e838da --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiDataGridColumn } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; + +export function buildDataGridColumns( + columnNames: string[], + idxPattern: IndexPattern, + displayTimeColumn: boolean +) { + const timeFieldName = idxPattern.timeFieldName; + let columnsToUse = columnNames; + + if (displayTimeColumn && idxPattern.timeFieldName && !columnNames.includes(timeFieldName)) { + columnsToUse = [idxPattern.timeFieldName, ...columnNames]; + } + + return columnsToUse.map((colName) => generateDataGridTableColumn(colName, idxPattern)); +} + +export function generateDataGridTableColumn(colName: string, idxPattern: IndexPattern) { + const timeLabel = i18n.translate('discover.timeLabel', { + defaultMessage: 'Time', + }); + const idxPatternField = idxPattern.getFieldByName(colName); + const dataGridCol: EuiDataGridColumn = { + id: colName, + schema: idxPatternField?.type, + isSortable: idxPatternField?.sortable, + display: idxPatternField?.displayName, + actions: { + showHide: true, + showMoveLeft: false, + showMoveRight: false, + }, + cellActions: [], + }; + + if (dataGridCol.id === idxPattern.timeFieldName) { + dataGridCol.display = `${timeLabel} (${idxPattern.timeFieldName})`; + dataGridCol.initialWidth = 200; + } + if (dataGridCol.id === '_source') { + dataGridCol.display = i18n.translate('discover.sourceLabel', { + defaultMessage: 'Source', + }); + } + return dataGridCol; +} + +export function computeVisibleColumns( + columnNames: string[], + idxPattern: IndexPattern, + displayTimeColumn: boolean +) { + const timeFieldName = idxPattern.timeFieldName; + let visibleColumnNames = columnNames; + + if (displayTimeColumn && !columnNames.includes(timeFieldName)) { + visibleColumnNames = [timeFieldName, ...columnNames]; + } + + return visibleColumnNames; +} diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_context.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_context.tsx new file mode 100644 index 000000000000..c3568d44082a --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_context.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types'; + +export interface DataGridContextProps { + expandedHit?: OpenSearchSearchHit; + onFilter: DocViewFilterFn; + setExpandedHit: (hit?: OpenSearchSearchHit) => void; + rows: OpenSearchSearchHit[]; + indexPattern: IndexPattern; +} + +export const DataGridContext = React.createContext( + {} as DataGridContextProps +); + +export const DiscoverGridContextProvider = DataGridContext.Provider; +export const useDataGridContext = () => React.useContext(DataGridContext); diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_docview_expand_button.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_docview_expand_button.tsx new file mode 100644 index 000000000000..beb8e7de278a --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_docview_expand_button.tsx @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiToolTip, EuiButtonIcon, EuiDataGridCellValueElementProps } from '@elastic/eui'; +import { useDataGridContext } from './data_grid_table_context'; + +export const DocViewExpandButton = ({ + rowIndex, + setCellProps, +}: EuiDataGridCellValueElementProps) => { + const { expandedHit, setExpandedHit, rows } = useDataGridContext(); + const currentExpanded = rows[rowIndex]; + const isCurrentExpanded = currentExpanded === expandedHit; + + return ( + + setExpandedHit(isCurrentExpanded ? undefined : currentExpanded)} + iconType={isCurrentExpanded ? 'minimize' : 'expand'} + aria-label={`Expand row ${rowIndex}`} + /> + + ); +}; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx new file mode 100644 index 000000000000..957679a2faef --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, +} from '@elastic/eui'; +import { DocViewer } from '../doc_viewer/doc_viewer'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { DocViewFilterFn } from '../../doc_views/doc_views_types'; +import { DocViewerLinks } from '../doc_viewer_links/doc_viewer_links'; + +interface Props { + columns: string[]; + hit: any; + indexPattern: IndexPattern; + onAddColumn: (column: string) => void; + onClose: () => void; + onFilter: DocViewFilterFn; + onRemoveColumn: (column: string) => void; +} + +export function DataGridFlyout({ + hit, + columns, + indexPattern, + onAddColumn, + onClose, + onFilter, + onRemoveColumn, +}: Props) { + // TODO: replace EuiLink with doc_view_links registry + // TODO: Also move the flyout higher in the react tree to prevent redrawing the table component and slowing down page performance + return ( + + + +

Document Details

+
+
+ + + + + + + { + onRemoveColumn(columnName); + onClose(); + }} + onAddColumn={(columnName: string) => { + onAddColumn(columnName); + onClose(); + }} + filter={(mapping, value, mode) => { + onFilter(mapping, value, mode); + onClose(); + }} + /> + + + +
+ ); +} diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx index edc7f40c5e43..17736de5de2a 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx @@ -37,7 +37,6 @@ interface Props { } /** * Responsible for rendering a tab provided by a render function. - * So any other framework can be used (E.g. legacy Angular 3rd party plugin code) * The provided `render` function is called with a reference to the * component's `HTMLDivElement` as 1st arg and `renderProps` as 2nd arg */ diff --git a/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap b/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap index 95fb0c377180..2a3b5a3aa998 100644 --- a/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap +++ b/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap @@ -3,14 +3,17 @@ exports[`Dont Render if generateCb.hide 1`] = ` `; exports[`Render with 2 different links 1`] = ` with 2 different links 1`] = ` /> + {listItems.map((item, index) => ( - + ))} diff --git a/src/plugins/discover/public/application/components/help_menu/help_menu_util.ts b/src/plugins/discover/public/application/components/help_menu/help_menu_util.ts new file mode 100644 index 000000000000..47bf0dbdcf90 --- /dev/null +++ b/src/plugins/discover/public/application/components/help_menu/help_menu_util.ts @@ -0,0 +1,49 @@ +/* + * 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 { i18n } from '@osd/i18n'; +import { ChromeStart } from 'opensearch-dashboards/public'; +import { getServices } from '../../../opensearch_dashboards_services'; + +const { docLinks } = getServices(); + +export function addHelpMenuToAppChrome(chrome: ChromeStart) { + chrome.setHelpExtension({ + appName: i18n.translate('discover.helpMenu.appName', { + defaultMessage: 'Discover', + }), + links: [ + { + linkType: 'documentation', + href: `${docLinks.links.opensearchDashboards.introduction}`, + }, + ], + }); +} diff --git a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.scss b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.scss new file mode 100644 index 000000000000..051ab642c1cf --- /dev/null +++ b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.scss @@ -0,0 +1,4 @@ +.discoverNoResults { + display: flex; + align-items: center; +} diff --git a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx index 697c7a136d60..dc12ba4581fc 100644 --- a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx +++ b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx @@ -28,20 +28,24 @@ * under the License. */ +import './loading_spinner.scss'; import React from 'react'; -import { EuiLoadingSpinner, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiTitle, EuiPanel, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; export function LoadingSpinner() { return ( - <> - -

- -

-
- - - + + } + title={ + +

+ +

+
+ } + /> +
); } diff --git a/src/plugins/discover/public/application/components/no_results/no_results.tsx b/src/plugins/discover/public/application/components/no_results/no_results.tsx new file mode 100644 index 000000000000..ad7bab0e81e9 --- /dev/null +++ b/src/plugins/discover/public/application/components/no_results/no_results.tsx @@ -0,0 +1,212 @@ +/* + * 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, { Fragment } from 'react'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; + +import { + EuiCallOut, + EuiCode, + EuiDescriptionList, + EuiLink, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { getServices } from '../../../opensearch_dashboards_services'; + +interface Props { + timeFieldName?: string; + queryLanguage?: string; +} + +export const DiscoverNoResults = ({ timeFieldName, queryLanguage }: Props) => { + let timeFieldMessage; + + if (timeFieldName) { + timeFieldMessage = ( + + + + +

+ +

+ +

+ +

+
+
+ ); + } + + let luceneQueryMessage; + + if (queryLanguage === 'lucene') { + const searchExamples = [ + { + description: 200, + title: ( + + + + + + ), + }, + { + description: status:200, + title: ( + + + + + + ), + }, + { + description: status:[400 TO 499], + title: ( + + + + + + ), + }, + { + description: status:[400 TO 499] AND extension:PHP, + title: ( + + + + + + ), + }, + { + description: status:[400 TO 499] AND (extension:php OR extension:html), + title: ( + + + + + + ), + }, + ]; + + luceneQueryMessage = ( + + + + +

+ +

+ +

+ + + + ), + }} + /> +

+
+ + + + + + +
+ ); + } + + return ( + + + + } + color="warning" + iconType="help" + data-test-subj="discoverNoResults" + /> + {timeFieldMessage} + {luceneQueryMessage} + + + ); +}; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx index 1b384a4b5550..29d78448f087 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.test.tsx @@ -30,10 +30,8 @@ import React from 'react'; // @ts-ignore -import { findTestSubject } from '@elastic/eui/lib/test'; -// @ts-ignore import stubbedLogstashFields from 'fixtures/logstash_fields'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { render, screen, fireEvent } from 'test_utils/testing_lib_helpers'; import { DiscoverField } from './discover_field'; import { coreMock } from '../../../../../../core/public/mocks'; import { IndexPatternField } from '../../../../../data/public'; @@ -63,7 +61,7 @@ jest.mock('../../../opensearch_dashboards_services', () => ({ }), })); -function getComponent({ +function getProps({ selected = false, showDetails = false, useShortDots = false, @@ -110,24 +108,33 @@ function getComponent({ selected, useShortDots, }; - const comp = mountWithIntl(); - return { comp, props }; + + return props; } describe('discover sidebar field', function () { - it('should allow selecting fields', function () { - const { comp, props } = getComponent({}); - findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + it('should allow selecting fields', async function () { + const props = getProps({}); + render(); + + await fireEvent.click(screen.getByTestId('fieldToggle-bytes')); + expect(props.onAddField).toHaveBeenCalledWith('bytes'); }); - it('should allow deselecting fields', function () { - const { comp, props } = getComponent({ selected: true }); - findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + it('should allow deselecting fields', async function () { + const props = getProps({ selected: true }); + render(); + + await fireEvent.click(screen.getByTestId('fieldToggle-bytes')); + expect(props.onRemoveField).toHaveBeenCalledWith('bytes'); }); - it('should trigger getDetails', function () { - const { comp, props } = getComponent({ selected: true }); - findTestSubject(comp, 'field-bytes-showDetails').simulate('click'); + it('should trigger getDetails', async function () { + const props = getProps({ selected: true }); + render(); + + await fireEvent.click(screen.getByTestId('field-bytes-showDetails')); + expect(props.getDetails).toHaveBeenCalledWith(props.field); }); it('should not allow clicking on _source', function () { @@ -142,11 +149,12 @@ describe('discover sidebar field', function () { }, '_source' ); - const { comp, props } = getComponent({ + const props = getProps({ selected: true, field, }); - findTestSubject(comp, 'field-_source-showDetails').simulate('click'); - expect(props.getDetails).not.toHaveBeenCalled(); + render(); + + expect(screen.queryByTestId('field-_source-showDetails')).toBeNull(); }); }); diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index e807267435eb..73dc40a262e0 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -29,7 +29,15 @@ */ import React, { useState } from 'react'; -import { EuiPopover, EuiPopoverTitle, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { + EuiPopover, + EuiPopoverTitle, + EuiButtonIcon, + EuiToolTip, + EuiFlexGroup, + EuiFlexItem, + EuiText, +} from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { DiscoverFieldDetails } from './discover_field_details'; import { FieldIcon, FieldButton } from '../../../../../opensearch_dashboards_react/public'; @@ -79,17 +87,17 @@ export interface DiscoverFieldProps { useShortDots?: boolean; } -export function DiscoverField({ - columns, +export const DiscoverField = ({ field, - indexPattern, + selected, onAddField, onRemoveField, + columns, + indexPattern, onAddFilter, getDetails, - selected, useShortDots, -}: DiscoverFieldProps) { +}: DiscoverFieldProps) => { const addLabelAria = i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { defaultMessage: 'Add {field} to table', values: { field: field.name }, @@ -101,6 +109,11 @@ export function DiscoverField({ values: { field: field.name }, } ); + const infoLabelAria = i18n.translate('discover.fieldChooser.discoverField.infoButtonAriaLabel', { + defaultMessage: 'View {field} summary', + values: { field: field.name }, + }); + const isSourceField = field.name === '_source'; const [infoIsOpen, setOpen] = useState(false); @@ -112,10 +125,6 @@ export function DiscoverField({ } }; - function togglePopover() { - setOpen(!infoIsOpen); - } - function wrapOnDot(str?: string) { // u200B is a non-width white-space character, which allows // the browser to efficiently word-wrap right after the dot @@ -123,22 +132,18 @@ export function DiscoverField({ return str ? str.replace(/\./g, '.\u200B') : ''; } - const dscFieldIcon = ( - - ); - const fieldName = ( {useShortDots ? wrapOnDot(shortenDottedString(field.name)) : wrapOnDot(field.displayName)} ); let actionButton; - if (field.name !== '_source' && !selected) { + if (!isSourceField && !selected) { actionButton = ( ) => { if (ev.type === 'click') { ev.currentTarget.focus(); @@ -162,7 +166,7 @@ export function DiscoverField({ /> ); - } else if (field.name !== '_source' && selected) { + } else if (!isSourceField && selected) { actionButton = ( ) => { if (ev.type === 'click') { ev.currentTarget.focus(); @@ -189,57 +192,56 @@ export function DiscoverField({ ); } - if (field.type === '_source') { - return ( - - ); - } - return ( - { - togglePopover(); - }} - dataTestSubj={`field-${field.name}-showDetails`} - fieldIcon={dscFieldIcon} - fieldAction={actionButton} - fieldName={fieldName} - /> - } - isOpen={infoIsOpen} - closePopover={() => setOpen(false)} - anchorPosition="rightUp" - panelClassName="dscSidebarItem__fieldPopoverPanel" - > - - {' '} - {i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { - defaultMessage: 'Top 5 values', - })} - - {infoIsOpen && ( - + + + + + {fieldName} + + {!isSourceField && ( + + setOpen(false)} + anchorPosition="rightUp" + button={ + setOpen((state) => !state)} + aria-label={infoLabelAria} + data-test-subj={`field-${field.name}-showDetails`} + /> + } + panelClassName="dscSidebarItem__fieldPopoverPanel" + > + + {' '} + {i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { + defaultMessage: 'Top 5 values', + })} + + {infoIsOpen && ( + + )} + + )} - + {!isSourceField && {actionButton}} +
); -} +}; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx index 906c173ed07d..ce22761e75fa 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_details.tsx @@ -40,7 +40,6 @@ import { } from './lib/visualize_trigger_utils'; import { Bucket, FieldDetails } from './types'; import { IndexPatternField, IndexPattern } from '../../../../../data/public'; -import './discover_field_details.scss'; interface DiscoverFieldDetailsProps { columns: string[]; diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx index f78505e11f1e..bcf72ae57326 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_search.test.tsx @@ -32,7 +32,7 @@ import React from 'react'; import { act } from 'react-dom/test-utils'; import { mountWithIntl } from 'test_utils/enzyme_helpers'; import { findTestSubject } from 'test_utils/helpers'; -import { DiscoverFieldSearch, Props } from './discover_field_search'; +import { DiscoverFieldSearch, NUM_FILTERS, Props } from './discover_field_search'; import { EuiButtonGroupProps, EuiPopover } from '@elastic/eui'; import { ReactWrapper } from 'enzyme'; @@ -63,7 +63,7 @@ describe('DiscoverFieldSearch', () => { const onChange = jest.fn(); const component = mountComponent({ ...defaultProps, ...{ onChange } }); let btn = findTestSubject(component, 'toggleFieldFilterButton'); - expect(btn.hasClass('euiFacetButton--isSelected')).toBeFalsy(); + expect(btn.hasClass('euiFilterButton-hasActiveFilters')).toBeFalsy(); btn.simulate('click'); const aggregatableButtonGroup = findButtonGroup(component, 'aggregatable'); act(() => { @@ -72,7 +72,7 @@ describe('DiscoverFieldSearch', () => { }); component.update(); btn = findTestSubject(component, 'toggleFieldFilterButton'); - expect(btn.hasClass('euiFacetButton--isSelected')).toBe(true); + expect(btn.hasClass('euiFilterButton-hasActiveFilters')).toBe(true); expect(onChange).toBeCalledWith('aggregatable', true); }); @@ -82,8 +82,8 @@ describe('DiscoverFieldSearch', () => { btn.simulate('click'); btn = findTestSubject(component, 'toggleFieldFilterButton'); const badge = btn.find('.euiNotificationBadge'); - // no active filters - expect(badge.text()).toEqual('0'); + // available filters + expect(badge.text()).toEqual(NUM_FILTERS.toString()); // change value of aggregatable select const aggregatableButtonGroup = findButtonGroup(component, 'aggregatable'); act(() => { @@ -114,10 +114,10 @@ describe('DiscoverFieldSearch', () => { const btn = findTestSubject(component, 'toggleFieldFilterButton'); btn.simulate('click'); const badge = btn.find('.euiNotificationBadge'); - expect(badge.text()).toEqual('0'); + expect(badge.text()).toEqual(NUM_FILTERS.toString()); const missingSwitch = findTestSubject(component, 'missingSwitch'); missingSwitch.simulate('change', { target: { value: false } }); - expect(badge.text()).toEqual('0'); + expect(badge.text()).toEqual(NUM_FILTERS.toString()); }); test('change in filters triggers onChange', () => { diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx index 4a1390cb1955..8d90e0ae1099 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field_search.tsx @@ -31,11 +31,9 @@ import React, { OptionHTMLAttributes, ReactNode, useState } from 'react'; import { i18n } from '@osd/i18n'; import { - EuiFacetButton, EuiFieldSearch, EuiFlexGroup, EuiFlexItem, - EuiIcon, EuiPopover, EuiPopoverFooter, EuiPopoverTitle, @@ -46,9 +44,14 @@ import { EuiFormRow, EuiButtonGroup, EuiOutsideClickDetector, + EuiPanel, + EuiFilterButton, + EuiFilterGroup, } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; +export const NUM_FILTERS = 3; + export interface State { searchable: string; aggregatable: string; @@ -106,12 +109,6 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { missing: true, }); - if (typeof value !== 'string') { - // at initial rendering value is undefined (angular related), this catches the warning - // should be removed once all is react - return null; - } - const filterBtnAriaLabel = isPopoverOpen ? i18n.translate('discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', { defaultMessage: 'Hide field filter settings', @@ -173,23 +170,6 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { handleValueChange('missing', missingValue); }; - const buttonContent = ( - } - isSelected={activeFiltersCount > 0} - quantity={activeFiltersCount} - onClick={handleFacetButtonClicked} - > - - - ); - const select = ( id: string, selectOptions: Array<{ text: ReactNode } & OptionHTMLAttributes>, @@ -236,7 +216,7 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { legend={legend} options={toggleButtons(id)} idSelected={`${id}-${values[id]}`} - onChange={(optionId) => handleValueChange(id, optionId.replace(`${id}-`, ''))} + onChange={(optionId: string) => handleValueChange(id, optionId.replace(`${id}-`, ''))} buttonSize="compressed" isFullWidth data-test-subj={`${id}ButtonGroup`} @@ -245,7 +225,7 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { }; const selectionPanel = ( -
+ {buttonGroup('aggregatable', aggregatableLabel)} @@ -257,26 +237,25 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { {select('type', typeOptions, values.type)} -
+ ); return ( - - - + + + {}} isDisabled={!isPopoverOpen}> onChange('name', event.currentTarget.value)} placeholder={searchPlaceholder} value={value} /> - - -
- {}} isDisabled={!isPopoverOpen}> + + + + { setPopoverOpen(false); }} - button={buttonContent} + button={ + 0} + aria-label={filterBtnAriaLabel} + data-test-subj="toggleFieldFilterButton" + numFilters={NUM_FILTERS} + onClick={handleFacetButtonClicked} + numActiveFilters={activeFiltersCount} + isSelected={isPopoverOpen} + > + + + } > {i18n.translate('discover.fieldChooser.filter.filterByTypeLabel', { @@ -306,8 +302,8 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { /> - -
-
+ +
+
); } 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 9c80e0afa600..63e8720f2c59 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss @@ -1,99 +1,3 @@ -.dscSidebar__container { - padding-left: 0 !important; - padding-right: 0 !important; - background-color: transparent; - border-right-color: transparent; - border-bottom-color: transparent; -} - -.dscIndexPattern__container { - display: flex; - align-items: center; - height: $euiSize * 3; - margin-top: -$euiSizeS; -} - -.dscIndexPattern__triggerButton { - @include euiTitle("xs"); - - line-height: $euiSizeXXL; -} - -.dscFieldList { - list-style: none; - margin-bottom: 0; -} - -.dscFieldListHeader { - padding: $euiSizeS $euiSizeS 0 $euiSizeS; - background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade); -} - -.dscFieldList--popular { - background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade); -} - -.dscFieldChooser { - padding-left: $euiSize; -} - -.dscFieldChooser__toggle { - color: $euiColorMediumShade; - margin-left: $euiSizeS !important; -} - -.dscSidebarItem { - &:hover, - &:focus-within, - &[class*="-isActive"] { - .dscSidebarItem__action { - opacity: 1; - } - } -} - -/** - * 1. Only visually hide the action, so that it's still accessible to screen readers. - * 2. When tabbed to, this element needs to be visible for keyboard accessibility. - */ -.dscSidebarItem__action { - opacity: 0; /* 1 */ - transition: none; - - &:focus { - opacity: 1; /* 2 */ - } - - font-size: $euiFontSizeXS; - padding: 2px 6px !important; - height: 22px !important; - min-width: auto !important; - - .euiButton__content { - padding: 0 4px; - } -} - -.dscFieldSearch { - padding: $euiSizeS; -} - -.dscFieldSearch__toggleButton { - width: calc(100% - #{$euiSizeS}); - color: $euiColorPrimary; - padding-left: $euiSizeXS; - margin-left: $euiSizeXS; -} - -.dscFieldSearch__filterWrapper { - flex-grow: 0; -} - -.dscFieldSearch__formWrapper { - padding: $euiSizeM; -} - -.dscFieldDetails { - color: $euiTextColor; - margin-bottom: $euiSizeS; +.dscSideBarFieldListHeader { + padding-left: $euiSizeS; } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx index fa692ca22b5b..6fee8dde6b60 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.test.tsx @@ -29,19 +29,15 @@ */ import _ from 'lodash'; -import { ReactWrapper } from 'enzyme'; -import { findTestSubject } from 'test_utils/helpers'; // @ts-ignore import realHits from 'fixtures/real_hits.js'; // @ts-ignore import stubbedLogstashFields from 'fixtures/logstash_fields'; -import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { render, screen, within, fireEvent } from '@testing-library/react'; import React from 'react'; import { DiscoverSidebar, DiscoverSidebarProps } from './discover_sidebar'; import { coreMock } from '../../../../../../core/public/mocks'; -import { IndexPatternAttributes } from '../../../../../data/common'; import { getStubIndexPattern } from '../../../../../data/public/test_utils'; -import { SavedObject } from '../../../../../../core/types'; jest.mock('../../../opensearch_dashboards_services', () => ({ getServices: () => ({ @@ -74,7 +70,7 @@ jest.mock('./lib/get_index_pattern_field_list', () => ({ getIndexPatternFieldList: jest.fn((indexPattern) => indexPattern.fields), })); -function getCompProps() { +function getCompProps(): DiscoverSidebarProps { const indexPattern = getStubIndexPattern( 'logstash-*', (cfg: any) => cfg, @@ -88,12 +84,6 @@ function getCompProps() { Record >; - const indexPatternList = [ - { id: '0', attributes: { title: 'b' } } as SavedObject, - { id: '1', attributes: { title: 'a' } } as SavedObject, - { id: '2', attributes: { title: 'c' } } as SavedObject, - ]; - const fieldCounts: Record = {}; for (const hit of hits) { @@ -105,44 +95,48 @@ function getCompProps() { columns: ['extension'], fieldCounts, hits, - indexPatternList, onAddFilter: jest.fn(), onAddField: jest.fn(), onRemoveField: jest.fn(), selectedIndexPattern: indexPattern, - setIndexPattern: jest.fn(), - state: {}, + onReorderFields: jest.fn(), }; } describe('discover sidebar', function () { - let props: DiscoverSidebarProps; - let comp: ReactWrapper; + it('should have Selected Fields and Available Fields with Popular Fields sections', async function () { + render(); - beforeAll(() => { - props = getCompProps(); - comp = mountWithIntl(); - }); + const popular = screen.getByTestId('fieldList-popular'); + const selected = screen.getByTestId('fieldList-selected'); + const unpopular = screen.getByTestId('fieldList-unpopular'); - it('should have Selected Fields and Available Fields with Popular Fields sections', function () { - const popular = findTestSubject(comp, 'fieldList-popular'); - const selected = findTestSubject(comp, 'fieldList-selected'); - const unpopular = findTestSubject(comp, 'fieldList-unpopular'); - expect(popular.children().length).toBe(1); - expect(unpopular.children().length).toBe(7); - expect(selected.children().length).toBe(1); + expect(within(popular).getAllByTestId('fieldList-field').length).toBe(1); + expect(within(unpopular).getAllByTestId('fieldList-field').length).toBe(7); + expect(within(selected).getAllByTestId('fieldList-field').length).toBe(1); }); - it('should allow selecting fields', function () { - findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + it('should allow selecting fields', async function () { + const props = getCompProps(); + render(); + + await fireEvent.click(screen.getByTestId('fieldToggle-bytes')); + expect(props.onAddField).toHaveBeenCalledWith('bytes'); }); - it('should allow deselecting fields', function () { - findTestSubject(comp, 'fieldToggle-extension').simulate('click'); + it('should allow deselecting fields', async function () { + const props = getCompProps(); + render(); + + await fireEvent.click(screen.getByTestId('fieldToggle-extension')); + expect(props.onRemoveField).toHaveBeenCalledWith('extension'); }); - it('should allow adding filters', function () { - findTestSubject(comp, 'field-extension-showDetails').simulate('click'); - findTestSubject(comp, 'plus-extension-gif').simulate('click'); + it('should allow adding filters', async function () { + const props = getCompProps(); + render(); + + await fireEvent.click(screen.getByTestId('field-extension-showDetails')); + await fireEvent.click(screen.getByTestId('plus-extension-gif')); expect(props.onAddFilter).toHaveBeenCalled(); }); }); 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 865aff590286..f5e091e6ab82 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_sidebar.tsx @@ -31,14 +31,18 @@ import './discover_sidebar.scss'; import React, { useCallback, useEffect, useState, useMemo } from 'react'; import { i18n } from '@osd/i18n'; -import { EuiButtonIcon, EuiTitle, EuiSpacer } from '@elastic/eui'; -import { sortBy } from 'lodash'; +import { + EuiTitle, + EuiDragDropContext, + DropResult, + EuiDroppable, + EuiDraggable, + EuiPanel, + EuiSplitPanel, +} from '@elastic/eui'; import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; import { DiscoverField } from './discover_field'; -import { DiscoverIndexPattern } from './discover_index_pattern'; import { DiscoverFieldSearch } from './discover_field_search'; -import { IndexPatternAttributes } from '../../../../../data/common'; -import { SavedObject } from '../../../../../../core/types'; import { FIELDS_LIMIT_SETTING } from '../../../../common'; import { groupFields } from './lib/group_fields'; import { IndexPatternField, IndexPattern, UI_SETTINGS } from '../../../../../data/public'; @@ -61,13 +65,13 @@ export interface DiscoverSidebarProps { */ hits: Array>; /** - * List of available index patterns + * Callback function when selecting a field */ - indexPatternList: Array>; + onAddField: (fieldName: string, index?: number) => void; /** - * Callback function when selecting a field + * Callback function when rearranging fields */ - onAddField: (fieldName: string) => void; + onReorderFields: (sourceIdx: number, destinationIdx: number) => void; /** * Callback function when adding a filter from sidebar */ @@ -81,24 +85,18 @@ export interface DiscoverSidebarProps { * Currently selected index pattern */ selectedIndexPattern?: IndexPattern; - /** - * Callback function to select another index pattern - */ - setIndexPattern: (id: string) => void; } export function DiscoverSidebar({ columns, fieldCounts, hits, - indexPatternList, onAddField, onAddFilter, onRemoveField, + onReorderFields, selectedIndexPattern, - setIndexPattern, }: DiscoverSidebarProps) { - const [showFields, setShowFields] = useState(false); const [fields, setFields] = useState(null); const [fieldFilterState, setFieldFilterState] = useState(getDefaultFieldFilter()); const services = useMemo(() => getServices(), []); @@ -148,73 +146,109 @@ export function DiscoverSidebar({ return result; }, [fields]); + const onDragEnd = useCallback( + ({ source, destination }: DropResult) => { + if (!source || !destination || !fields) return; + + // Rearranging fields within the selected fields list + if ( + source.droppableId === 'SELECTED_FIELDS' && + destination.droppableId === 'SELECTED_FIELDS' + ) { + onReorderFields(source.index, destination.index); + return; + } + // Dropping fields into the selected fields list + if ( + source.droppableId !== 'SELECTED_FIELDS' && + destination.droppableId === 'SELECTED_FIELDS' + ) { + const fieldListMap = { + POPULAR_FIELDS: popularFields, + UNPOPULAR_FIELDS: unpopularFields, + }; + const fieldList = fieldListMap[source.droppableId as keyof typeof fieldListMap]; + const field = fieldList[source.index]; + onAddField(field.name, destination.index); + return; + } + }, + [fields, onAddField, onReorderFields, popularFields, unpopularFields] + ); + if (!selectedIndexPattern || !fields) { return null; } return ( -
- o.attributes.title)} - /> -
-
+ + + - -
-
- {fields.length > 0 && ( - <> - -

- -

-
- -
    - {selectedFields.map((field: IndexPatternField) => { - return ( -
  • - -
  • - ); - })} -
-
- + + + {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 */} + + + + ); + })} + +

-
- setShowFields(!showFields)} - aria-label={ - showFields - ? i18n.translate( - 'discover.fieldChooser.filter.indexAndFieldsSectionHideAriaLabel', - { - defaultMessage: 'Hide fields', - } - ) - : i18n.translate( - 'discover.fieldChooser.filter.indexAndFieldsSectionShowAriaLabel', - { - defaultMessage: 'Show fields', - } - ) - } - /> -
-
- - )} - {popularFields.length > 0 && ( -
- - - -
    - {popularFields.map((field: IndexPatternField) => { - return ( -
  • - -
  • - ); - })} -
-
- )} -
    - {unpopularFields.map((field: IndexPatternField) => { - return ( -
  • 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 */} + + + + ); + })} + + + )} + + +
); } diff --git a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx index fad1db402467..dff60827ccd2 100644 --- a/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx +++ b/src/plugins/discover/public/application/components/sidebar/lib/group_fields.tsx @@ -83,5 +83,12 @@ export function groupFields( } } + // sort the selected fields by the column order + result.selected.sort((a, b) => { + const aIndex = columns.indexOf(a.name); + const bIndex = columns.indexOf(b.name); + return aIndex - bIndex; + }); + return result; } diff --git a/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button.tsx b/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button.tsx index a1e5754cb312..a780752fc54c 100644 --- a/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button.tsx +++ b/src/plugins/discover/public/application/components/skip_bottom_button/skip_bottom_button.tsx @@ -49,7 +49,7 @@ export function SkipBottomButton({ onClick }: SkipBottomButtonProps) { // prevent the anchor to reload the page on click event.preventDefault(); // The destinationId prop cannot be leveraged here as the table needs - // to be updated first (angular logic) + // to be updated first onClick(); }} className="dscSkipButton" diff --git a/src/plugins/discover/public/application/components/table/table.scss b/src/plugins/discover/public/application/components/table/table.scss new file mode 100644 index 000000000000..30ba5fea2a4e --- /dev/null +++ b/src/plugins/discover/public/application/components/table/table.scss @@ -0,0 +1,3 @@ +.truncate-by-height { + overflow: hidden; +} diff --git a/src/plugins/discover/public/application/components/table/table.tsx b/src/plugins/discover/public/application/components/table/table.tsx index 90167a515985..3ef8e026702e 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -31,8 +31,9 @@ import React, { useState } from 'react'; import { escapeRegExp } from 'lodash'; import { DocViewTableRow } from './table_row'; -import { arrayContainsObjects, trimAngularSpan } from './table_helper'; +import { arrayContainsObjects } from './table_helper'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; +import './table.scss'; const COLLAPSE_LINE_LENGTH = 350; @@ -61,7 +62,7 @@ export function DocViewTable({ .sort() .map((field) => { const valueRaw = flattened[field]; - const value = trimAngularSpan(String(formatted[field])); + const value = String(formatted[field]); const isCollapsible = value.length > COLLAPSE_LINE_LENGTH; const isCollapsed = isCollapsible && !fieldRowOpen[field]; diff --git a/src/plugins/discover/public/application/components/table/table_helper.tsx b/src/plugins/discover/public/application/components/table/table_helper.tsx index 2e63b43b8310..12f50d5e79fb 100644 --- a/src/plugins/discover/public/application/components/table/table_helper.tsx +++ b/src/plugins/discover/public/application/components/table/table_helper.tsx @@ -34,10 +34,3 @@ export function arrayContainsObjects(value: unknown[]): boolean { return Array.isArray(value) && value.some((v) => typeof v === 'object' && v !== null); } - -/** - * Removes markup added by OpenSearch Dashboards fields html formatter - */ -export function trimAngularSpan(text: string): string { - return text.replace(/^/, '').replace(/<\/span>$/, ''); -} diff --git a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap rename to src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap 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 new file mode 100644 index 000000000000..e893f9ee73c9 --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx @@ -0,0 +1,339 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React from 'react'; +import { DiscoverViewServices } from '../../../build_services'; +import { showOpenSearchPanel } from './show_open_search_panel'; +import { SavedSearch } from '../../../saved_searches'; +import { NEW_DISCOVER_APP } from '../../..'; +import { Adapters } from '../../../../../inspector/public'; +import { TopNavMenuData } from '../../../../../navigation/public'; +import { ISearchSource, unhashUrl } from '../../../opensearch_dashboards_services'; +import { + OnSaveProps, + SavedObjectSaveModal, + showSaveModal, +} from '../../../../../saved_objects/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'; + +export const getTopNavLinks = ( + services: DiscoverViewServices, + inspectorAdapters: Adapters, + savedSearch: SavedSearch +) => { + const { + history, + inspector, + core, + uiSettings, + capabilities, + share, + toastNotifications, + chrome, + store, + } = services; + + const newSearch = { + id: 'new', + label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { + defaultMessage: 'New', + }), + description: i18n.translate('discover.localMenu.newSearchDescription', { + defaultMessage: 'New Search', + }), + run() { + setTimeout(() => { + history().push('/'); + // TODO: figure out why a history push doesn't update the app state. The page reload is a hack around it + window.location.reload(); + }, 0); + }, + testId: 'discoverNewButton', + }; + + const saveSearch: TopNavMenuData = { + id: 'save', + label: i18n.translate('discover.localMenu.saveTitle', { + defaultMessage: 'Save', + }), + description: i18n.translate('discover.localMenu.saveSearchDescription', { + defaultMessage: 'Save Search', + }), + testId: 'discoverSaveButton', + run: async () => { + const onSave = async ({ + newTitle, + newCopyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: OnSaveProps) => { + const currentTitle = savedSearch.title; + savedSearch.title = newTitle; + savedSearch.copyOnSave = newCopyOnSave; + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + + const state: DiscoverState = store!.getState().discover; // store is defined before the view is loaded + + savedSearch.columns = state.columns; + savedSearch.sort = state.sort; + + try { + const id = await savedSearch.save(saveOptions); + + // If the title is a duplicate, the id will be an empty string. Checking for this condition here + if (id) { + toastNotifications.addSuccess({ + title: i18n.translate('discover.notifications.savedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was saved`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + 'data-test-subj': 'saveSearchSuccess', + }); + + if (id !== state.savedSearch) { + setTimeout(() => { + history().push(`/view/${encodeURIComponent(id)}`); + // TODO: figure out why a history push doesn't update the app state. The page reload is a hack around it + window.location.reload(); + }, 0); + } else { + chrome.docTitle.change(savedSearch.lastSavedTitle); + chrome.setBreadcrumbs([ + { + text: i18n.translate('discover.discoverBreadcrumbTitle', { + defaultMessage: 'Discover', + }), + href: '#/', + }, + { text: savedSearch.title }, + ]); + } + + // set App state to clean + store!.dispatch({ type: setSavedSearchId.type, payload: id }); + } + } catch (error) { + toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.notSavedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was not saved.`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + text: (error as Error).message, + }); + + // Reset the original title + savedSearch.title = currentTitle; + } + + return { test: true }; + }; + + const saveModal = ( + {}} + title={savedSearch.title} + showCopyOnSave={!!savedSearch.id} + objectType="search" + description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { + defaultMessage: + 'Save your Discover search so you can use it in visualizations and dashboards', + })} + showDescription={false} + /> + ); + showSaveModal(saveModal, core.i18n.Context); + }, + }; + + const openSearch = { + id: 'open', + label: i18n.translate('discover.localMenu.openTitle', { + defaultMessage: 'Open', + }), + description: i18n.translate('discover.localMenu.openSavedSearchDescription', { + defaultMessage: 'Open Saved Search', + }), + testId: 'discoverOpenButton', + run: () => { + showOpenSearchPanel({ + makeUrl: (searchId) => `#/view/${encodeURIComponent(searchId)}`, + I18nContext: core.i18n.Context, + services, + }); + }, + }; + + const shareSearch: TopNavMenuData = { + id: 'share', + label: i18n.translate('discover.localMenu.shareTitle', { + defaultMessage: 'Share', + }), + description: i18n.translate('discover.localMenu.shareSearchDescription', { + defaultMessage: 'Share Search', + }), + testId: 'shareTopNavButton', + run: async (anchorElement) => { + const state: DiscoverState = store!.getState().discover; // store is defined before the view is loaded + const sharingData = await getSharingData({ + searchSource: savedSearch.searchSource, + state, + services, + }); + share?.toggleShareContextMenu({ + anchorElement, + allowEmbed: false, + allowShortUrl: capabilities.discover.createShortUrl as boolean, + shareableUrl: unhashUrl(window.location.href), + objectId: savedSearch.id, + objectType: 'search', + sharingData: { + ...sharingData, + title: savedSearch.title, + }, + isDirty: !savedSearch.id || state.isDirty, + }); + }, + }; + + const inspectSearch = { + id: 'inspect', + label: i18n.translate('discover.localMenu.inspectTitle', { + defaultMessage: 'Inspect', + }), + description: i18n.translate('discover.localMenu.openInspectorForSearchDescription', { + defaultMessage: 'Open Inspector for search', + }), + testId: 'openInspectorButton', + run() { + inspector.open(inspectorAdapters, { + title: savedSearch?.title, + }); + }, + }; + + const legacyDiscover: TopNavMenuData = { + id: 'discover-new', + label: i18n.translate('discover.localMenu.newDiscoverTitle', { + defaultMessage: 'New Discover', + }), + description: i18n.translate('discover.localMenu.newDiscoverDescription', { + defaultMessage: 'New Discover Experience', + }), + testId: 'discoverNewButton', + run: async () => { + await uiSettings.set(NEW_DISCOVER_APP, false); + window.location.reload(); + }, + type: 'toggle' as const, + emphasize: true, + }; + + return [ + legacyDiscover, + newSearch, + ...(capabilities.discover.save ? [saveSearch] : []), + openSearch, + ...(share ? [shareSearch] : []), // Show share option only if share plugin is available + inspectSearch, + ]; +}; + +// TODO: This does not seem to affect the share menu. need to look into it in future +// const getFieldCounts = async () => { +// // the field counts aren't set until we have the data back, +// // so we wait for the fetch to be done before proceeding +// if ($scope.fetchStatus === fetchStatuses.COMPLETE) { +// return $scope.fieldCounts; +// } + +// return await new Promise((resolve) => { +// const unwatch = $scope.$watch('fetchStatus', (newValue) => { +// if (newValue === fetchStatuses.COMPLETE) { +// unwatch(); +// resolve($scope.fieldCounts); +// } +// }); +// }); +// }; + +const getSharingDataFields = async ( + selectedFields: string[], + hideTimeColumn: boolean, + timeFieldName?: string +) => { + if (selectedFields.length === 1 && selectedFields[0] === '_source') { + // const fieldCounts = await getFieldCounts(); + return { + searchFields: undefined, + // selectFields: keys(fieldCounts).sort(), + }; + } + + const fields = + timeFieldName && !hideTimeColumn ? [timeFieldName, ...selectedFields] : selectedFields; + return { + searchFields: fields, + selectFields: fields, + }; +}; + +const getSharingData = async ({ + searchSource, + state, + services, +}: { + searchSource: ISearchSource; + state: DiscoverState; + services: DiscoverViewServices; +}) => { + const searchSourceInstance = searchSource.createCopy(); + const indexPattern = await searchSourceInstance.getField('index'); + + const { searchFields, selectFields } = await getSharingDataFields( + state.columns, + services.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING), + indexPattern?.timeFieldName + ); + + searchSourceInstance.setField('fields', searchFields); + searchSourceInstance.setField( + 'sort', + getSortForSearchSource( + state.sort, + indexPattern, + services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING) + ) + ); + searchSourceInstance.setField('highlight', null); + searchSourceInstance.setField('highlightAll', undefined); + searchSourceInstance.setField('aggs', null); + searchSourceInstance.setField('size', undefined); + + const body = await searchSource.getSearchRequestBody(); + return { + searchRequest: { + index: indexPattern?.title, + body, + }, + // fields: selectFields, + metaFields: indexPattern?.metaFields, + conflictedTypesFields: indexPattern?.fields + .filter((f) => f.type === 'conflict') + .map((f) => f.name), + indexPatternId: indexPattern?.id, + }; +}; diff --git a/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx b/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx new file mode 100644 index 000000000000..9dab50d341f8 --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx @@ -0,0 +1,53 @@ +/* + * 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 { shallow } from 'enzyme'; + +jest.mock('../../../../../saved_objects/public', () => ({ + SavedObjectFinderUi: () =>
, +})); + +jest.mock('../../../../../opensearch_dashboards_react/public', () => ({ + useOpenSearchDashboards: jest.fn().mockReturnValue({ + services: { + core: { uiSettings: {}, savedObjects: {} }, + addBasePath: (path: string) => path, + }, + }), + withOpenSearchDashboards: jest.fn(), +})); + +import { OpenSearchPanel } from './open_search_panel'; + +test('render', () => { + const component = shallow( {}} makeUrl={(id) => id} />); + expect(component).toMatchSnapshot(); +}); diff --git a/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx b/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx new file mode 100644 index 000000000000..2dd5bb9bbe90 --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx @@ -0,0 +1,127 @@ +/* + * 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 rison from 'rison-node'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage } from '@osd/i18n/react'; +import { + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutFooter, + EuiFlyoutBody, + EuiTitle, +} from '@elastic/eui'; +import { SavedObjectFinderUi } from '../../../../../saved_objects/public'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { DiscoverViewServices } from '../../../build_services'; +import { SAVED_OBJECT_TYPE } from '../../../saved_searches/_saved_search'; + +interface Props { + onClose: () => void; + makeUrl: (id: string) => string; +} + +export function OpenSearchPanel({ onClose, makeUrl }: Props) { + const { + services: { + core: { uiSettings, savedObjects }, + addBasePath, + }, + } = useOpenSearchDashboards(); + + return ( + + + +

+ +

+
+
+ + + } + savedObjectMetaData={[ + { + type: SAVED_OBJECT_TYPE, + getIconForSavedObject: () => 'search', + name: i18n.translate('discover.savedSearch.savedObjectName', { + defaultMessage: 'Saved search', + }), + }, + ]} + onChoose={(id) => { + setTimeout(() => { + window.location.assign(makeUrl(id)); + // TODO: figure out why a history push doesn't update the app state. The page reload is a hack around it + window.location.reload(); + onClose(); + }, 0); + }} + uiSettings={uiSettings} + savedObjects={savedObjects} + /> + + + + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + + + +
+ ); +} 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 new file mode 100644 index 000000000000..5bc95b1b9cf2 --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx @@ -0,0 +1,70 @@ +/* + * 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/angular/directives/uninitialized.tsx b/src/plugins/discover/public/application/components/uninitialized/uninitialized.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/directives/uninitialized.tsx rename to src/plugins/discover/public/application/components/uninitialized/uninitialized.tsx diff --git a/src/plugins/discover/public/application/components/utils/use_pagination.test.ts b/src/plugins/discover/public/application/components/utils/use_pagination.test.ts new file mode 100644 index 000000000000..6eda1f7b4ca8 --- /dev/null +++ b/src/plugins/discover/public/application/components/utils/use_pagination.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { usePagination } from './use_pagination'; + +describe('usePagination', () => { + it('should initialize correctly with visParams and nRow', () => { + const nRow = 30; + const { result } = renderHook(() => usePagination(nRow)); + + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 100, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + pageSizeOptions: [25, 50, 100], + }); + }); + + it('should update pageSize correctly when calling onChangeItemsPerPage', () => { + const nRow = 30; + const { result } = renderHook(() => usePagination(nRow)); + + act(() => { + result.current?.onChangeItemsPerPage(20); + }); + + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 20, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + pageSizeOptions: [25, 50, 100], + }); + }); + + it('should update pageIndex correctly when calling onChangePage', () => { + const nRow = 30; + const { result } = renderHook(() => usePagination(nRow)); + + act(() => { + result.current?.onChangePage(1); + }); + + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 100, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + pageSizeOptions: [25, 50, 100], + }); + }); + + it('should correct pageIndex if it exceeds maximum page index after nRow or perPage change', () => { + const nRow = 300; + const { result } = renderHook(() => usePagination(nRow)); + + act(() => { + result.current?.onChangePage(4); + }); + + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 100, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + pageSizeOptions: [25, 50, 100], + }); + }); +}); diff --git a/src/plugins/discover/public/application/components/utils/use_pagination.ts b/src/plugins/discover/public/application/components/utils/use_pagination.ts new file mode 100644 index 000000000000..98363e57ed95 --- /dev/null +++ b/src/plugins/discover/public/application/components/utils/use_pagination.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo, useCallback } from 'react'; + +export const usePagination = (rowCount: number) => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 100 }); + const pageCount = useMemo(() => Math.ceil(rowCount / pagination.pageSize), [ + rowCount, + pagination, + ]); + + const onChangeItemsPerPage = useCallback( + (pageSize: number) => setPagination((p) => ({ ...p, pageSize })), + [] + ); + + const onChangePage = useCallback( + (pageIndex: number) => setPagination((p) => ({ ...p, pageIndex })), + [] + ); + + return useMemo( + () => + pagination.pageSize + ? { + ...pagination, + onChangeItemsPerPage, + onChangePage, + pageIndex: pagination.pageIndex > pageCount - 1 ? 0 : pagination.pageIndex, + pageSize: pagination.pageSize, + pageSizeOptions: [25, 50, 100], // TODO: make this configurable + } + : undefined, + [pagination, onChangeItemsPerPage, onChangePage, pageCount] + ); +}; diff --git a/src/plugins/discover/public/application/doc_views/doc_views_registry.ts b/src/plugins/discover/public/application/doc_views/doc_views_registry.ts index 56f167b5f2cc..904d3813cd69 100644 --- a/src/plugins/discover/public/application/doc_views/doc_views_registry.ts +++ b/src/plugins/discover/public/application/doc_views/doc_views_registry.ts @@ -28,32 +28,16 @@ * under the License. */ -import { auto } from 'angular'; -import { convertDirectiveToRenderFn } from './doc_views_helpers'; import { DocView, DocViewInput, OpenSearchSearchHit, DocViewInputFn } from './doc_views_types'; export class DocViewsRegistry { private docViews: DocView[] = []; - private angularInjectorGetter: (() => Promise) | null = null; - - setAngularInjectorGetter = (injectorGetter: () => Promise) => { - this.angularInjectorGetter = injectorGetter; - }; /** * Extends and adds the given doc view to the registry array */ addDocView(docViewRaw: DocViewInput | DocViewInputFn) { const docView = typeof docViewRaw === 'function' ? docViewRaw() : docViewRaw; - if (docView.directive) { - // convert angular directive to render function for backwards compatibility - docView.render = convertDirectiveToRenderFn(docView.directive, () => { - if (!this.angularInjectorGetter) { - throw new Error('Angular was not initialized'); - } - return this.angularInjectorGetter(); - }); - } if (typeof docView.shouldShow !== 'function') { docView.shouldShow = () => true; } diff --git a/src/plugins/discover/public/application/doc_views/doc_views_types.ts b/src/plugins/discover/public/application/doc_views/doc_views_types.ts index 961fc98516f6..db9757d385b0 100644 --- a/src/plugins/discover/public/application/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/application/doc_views/doc_views_types.ts @@ -29,17 +29,9 @@ */ import { ComponentType } from 'react'; -import { IScope } from 'angular'; import { SearchResponse } from 'elasticsearch'; import { IndexPattern } from '../../../../data/public'; -export interface AngularDirective { - controller: (...injectedServices: any[]) => void; - template: string; -} - -export type AngularScope = IScope; - export type OpenSearchSearchHit = SearchResponse['hits']['hits'][number]; export interface FieldMapping { @@ -72,7 +64,6 @@ export type DocViewRenderFn = ( export interface DocViewInput { component?: DocViewerComponent; - directive?: AngularDirective; order: number; render?: DocViewRenderFn; shouldShow?: (hit: OpenSearchSearchHit) => boolean; diff --git a/src/plugins/discover/public/application/utils/columns.test.ts b/src/plugins/discover/public/application/utils/columns.test.ts new file mode 100644 index 000000000000..43c4b0555553 --- /dev/null +++ b/src/plugins/discover/public/application/utils/columns.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildColumns } from './columns'; + +describe('buildColumns', () => { + it('returns ["_source"] if columns is empty', () => { + expect(buildColumns([])).toEqual(['_source']); + }); + + it('returns columns if there is only one column', () => { + expect(buildColumns(['foo'])).toEqual(['foo']); + }); + + it('removes "_source" if there are more than one columns', () => { + expect(buildColumns(['foo', '_source', 'bar'])).toEqual(['foo', 'bar']); + }); + + it('returns columns if there are more than one columns but no "_source"', () => { + expect(buildColumns(['foo', 'bar'])).toEqual(['foo', 'bar']); + }); +}); diff --git a/src/plugins/discover/public/application/utils/columns.ts b/src/plugins/discover/public/application/utils/columns.ts new file mode 100644 index 000000000000..062ca24e3ba4 --- /dev/null +++ b/src/plugins/discover/public/application/utils/columns.ts @@ -0,0 +1,43 @@ +/* + * 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. + */ + +/** + * Helper function to provide a fallback to a single _source column if the given array of columns + * is empty, and removes _source if there are more than 1 columns given + * @param columns + */ +export function buildColumns(columns: string[]) { + if (columns.length > 1 && columns.indexOf('_source') !== -1) { + return columns.filter((col) => col !== '_source'); + } else if (columns.length !== 0) { + return columns; + } + return ['_source']; +} 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 new file mode 100644 index 000000000000..c1190f891648 --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/common.test.ts @@ -0,0 +1,33 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { addColumn, removeColumn, reorderColumn, setColumns } from './common'; + +describe('commonUtils', () => { + it('should handle addColumn', () => { + expect(addColumn(['column1'], { column: 'column2' })).toEqual(['column1', 'column2']); + expect(addColumn(['column1'], { column: 'column2', index: 0 })).toEqual(['column2', 'column1']); + }); + + it('should handle removeColumn', () => { + expect(removeColumn(['column1', 'column2'], 'column1')).toEqual(['column2']); + }); + + it('should handle reorderColumn', () => { + expect(reorderColumn(['column1', 'column2', 'column3'], 0, 2)).toEqual([ + 'column2', + 'column3', + 'column1', + ]); + }); + + it('should handle setColumns', () => { + expect(setColumns('timeField', ['timeField', 'column1', 'column2'])).toEqual([ + 'column1', + 'column2', + ]); + expect(setColumns(undefined, ['column1', 'column2'])).toEqual(['column1', 'column2']); + }); +}); diff --git a/src/plugins/discover/public/application/utils/state_management/common.ts b/src/plugins/discover/public/application/utils/state_management/common.ts new file mode 100644 index 000000000000..566ee6e468ff --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/common.ts @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const addColumn = (columns: string[], action: { column: string; index?: number }) => { + const { column, index } = action; + const newColumns = [...(columns || [])]; + if (index !== undefined) newColumns.splice(index, 0, column); + else newColumns.push(column); + return newColumns; +}; + +export const removeColumn = (columns: string[], actionColumn: string) => { + return (columns || []).filter((column) => column !== actionColumn); +}; + +export const reorderColumn = (columns: string[], source: number, destination: number) => { + const newColumns = [...(columns || [])]; + const [removed] = newColumns.splice(source, 1); + newColumns.splice(destination, 0, removed); + return newColumns; +}; + +export const setColumns = (timeField: string | undefined, columns: string[]) => { + const newColumns = timeField && timeField === columns[0] ? columns.slice(1) : columns; + return newColumns; +}; 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 new file mode 100644 index 000000000000..1e2e4ab46a55 --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { discoverSlice, DiscoverState } from './discover_slice'; + +describe('discoverSlice', () => { + let initialState: DiscoverState; + + beforeEach(() => { + initialState = { + columns: [], + sort: [], + }; + }); + + it('should handle setState', () => { + const newState = { + columns: ['column1', 'column2'], + sort: [['field1', 'asc']], + }; + const action = { type: 'discover/setState', payload: newState }; + const result = discoverSlice.reducer(initialState, action); + expect(result).toEqual(newState); + }); + + it('should handle addColumn', () => { + const action1 = { type: 'discover/addColumn', payload: { column: 'column1' } }; + const result1 = discoverSlice.reducer(initialState, action1); + expect(result1.columns).toEqual(['column1']); + }); + + it('should handle removeColumn', () => { + initialState = { + columns: ['column1', 'column2'], + sort: [['column1', 'asc']], + }; + const action = { type: 'discover/removeColumn', payload: 'column1' }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column2']); + expect(result.sort).toEqual([]); + }); + + it('should handle reorderColumn', () => { + initialState = { + columns: ['column1', 'column2', 'column3'], + sort: [], + }; + const action = { + type: 'discover/reorderColumn', + payload: { source: 0, destination: 2 }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column2', 'column3', 'column1']); + }); + + it('should handle setColumns', () => { + const action = { + type: 'discover/setColumns', + payload: { timeField: 'timeField', columns: ['timeField', 'column1', 'column2'] }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column1', 'column2']); + }); + + it('should handle setSort', () => { + const action = { type: 'discover/setSort', payload: [['field1', 'asc']] }; + const result = discoverSlice.reducer(initialState, action); + expect(result.sort).toEqual([['field1', 'asc']]); + }); + + it('should handle updateState', () => { + initialState = { + columns: ['column1', 'column2'], + sort: [['field1', 'asc']], + }; + const action = { + type: 'discover/updateState', + payload: { sort: [['field2', 'desc']] }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.sort).toEqual([['field2', 'desc']]); + }); +}); 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 new file mode 100644 index 000000000000..38c8092d5f73 --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx @@ -0,0 +1,165 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { matchPath } from 'react-router-dom'; +import { Filter, Query } from '../../../../../data/public'; +import { DiscoverServices } from '../../../build_services'; +import { RootState, DefaultViewState } from '../../../../../data_explorer/public'; +import { buildColumns } from '../columns'; +import * as utils from './common'; + +export interface DiscoverState { + /** + * Columns displayed in the table + */ + columns: string[]; + /** + * Array of applied filters + */ + filters?: Filter[]; + /** + * Used interval of the histogram + */ + interval?: string; + /** + * Lucence or DQL query + */ + query?: Query; + /** + * Array of the used sorting [[field,direction],...] + */ + sort: Array<[string, string]>; + /** + * id of the used saved search + */ + savedSearch?: string; + /** + * dirty flag to indicate if the saved search has been modified + * since the last save + */ + isDirty: boolean; +} + +export interface DiscoverRootState extends RootState { + discover: DiscoverState; +} + +const initialState: DiscoverState = { + columns: ['_source'], + sort: [], + isDirty: false, +}; + +export const getPreloadedState = async ({ + getSavedSearchById, +}: DiscoverServices): Promise> => { + const preloadedState: DefaultViewState = { + state: { + ...initialState, + }, + }; + + const hashPath = window.location.hash.split('?')[0]; // hack to remove query params since matchPath considers them part of the id + const savedSearchId = matchPath<{ id?: string }>(hashPath, { + path: '#/view/:id', + })?.params.id; + + if (savedSearchId) { + const savedSearchInstance = await getSavedSearchById(savedSearchId); + + if (savedSearchInstance) { + preloadedState.state.columns = savedSearchInstance.columns; + preloadedState.state.sort = savedSearchInstance.sort; + preloadedState.state.savedSearch = savedSearchInstance.id; + const indexPatternId = savedSearchInstance.searchSource.getField('index')?.id; + preloadedState.root = { + metadata: { + indexPattern: indexPatternId, + }, + }; + + savedSearchInstance.destroy(); // this instance is no longer needed, will create another one later + } + } + + return preloadedState; +}; + +export const discoverSlice = createSlice({ + name: 'discover', + initialState, + reducers: { + setState(state, action: PayloadAction) { + return action.payload; + }, + addColumn(state, action: PayloadAction<{ column: string; index?: number }>) { + const columns = utils.addColumn(state.columns || [], action.payload); + return { ...state, columns: buildColumns(columns) }; + }, + removeColumn(state, action: PayloadAction) { + const columns = utils.removeColumn(state.columns, action.payload); + const sort = + state.sort && state.sort.length ? state.sort.filter((s) => s[0] !== action.payload) : []; + return { + ...state, + columns: buildColumns(columns), + sort, + isDirty: true, + }; + }, + reorderColumn(state, action: PayloadAction<{ source: number; destination: number }>) { + const columns = utils.reorderColumn( + state.columns, + action.payload.source, + action.payload.destination + ); + return { + ...state, + columns, + isDirty: true, + }; + }, + setColumns(state, action: PayloadAction<{ timeField: string | undefined; columns: string[] }>) { + const columns = utils.setColumns(action.payload.timeField, action.payload.columns); + return { + ...state, + columns, + }; + }, + setSort(state, action: PayloadAction>) { + return { + ...state, + sort: action.payload, + }; + }, + updateState(state, action: PayloadAction>) { + return { + ...state, + ...action.payload, + }; + }, + setSavedSearchId(state, action: PayloadAction) { + return { + ...state, + savedSearch: action.payload, + isDirty: false, + }; + }, + }, +}); + +// Exposing the state functions as generics +export const { + addColumn, + removeColumn, + reorderColumn, + setColumns, + setSort, + setState, + updateState, + setSavedSearchId, +} = discoverSlice.actions; +export const { reducer } = discoverSlice; diff --git a/src/plugins/discover/public/application/utils/state_management/index.ts b/src/plugins/discover/public/application/utils/state_management/index.ts new file mode 100644 index 000000000000..d72cc772e6c4 --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TypedUseSelectorHook } from 'react-redux'; +import { RootState, useTypedDispatch, useTypedSelector } from '../../../../../data_explorer/public'; +import { DiscoverState } from './discover_slice'; + +export * from './discover_slice'; + +export interface DiscoverRootState extends RootState { + discover: DiscoverState; +} + +export const useSelector: TypedUseSelectorHook = useTypedSelector; +export const useDispatch = useTypedDispatch; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.scss b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.scss new file mode 100644 index 000000000000..d0d0951c67b9 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.scss @@ -0,0 +1,25 @@ +.dscResultCount { + padding-top: $euiSizeXS; +} + +.dscTimechart { + display: block; + position: relative; + + // SASSTODO: the visualizing component should have an option or a modifier + .series > rect { + fill-opacity: 0.5; + stroke-width: 1; + } +} + +.dscHistogram { + display: flex; + height: 200px; + padding: $euiSizeS; +} + +.dscHistogram__header--partial { + font-weight: $euiFontWeightRegular; + min-width: $euiSize * 12; +} diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx new file mode 100644 index 000000000000..3e421516abcd --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './discover_chart_container.scss'; +import React from 'react'; +import { DiscoverViewServices } from '../../../build_services'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { useDiscoverContext } from '../context'; +import { SearchData } from '../utils/use_search'; +import { DiscoverChart } from '../../components/chart/chart'; + +export const DiscoverChartContainer = ({ hits, bucketInterval, chartData }: SearchData) => { + const { services } = useOpenSearchDashboards(); + const { uiSettings, data } = services; + const { indexPattern } = useDiscoverContext(); + + const timeField = indexPattern?.timeFieldName; + + if (!hits || !bucketInterval || !chartData) { + // TODO: handle better + return null; + } + + return ( + {}} + services={services} + /> + ); +}; 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 new file mode 100644 index 000000000000..814788406f7c --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { History } from 'history'; +import { DiscoverViewServices } from '../../../build_services'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { DataGridTable } from '../../components/data_grid/data_grid_table'; +import { useDiscoverContext } from '../context'; +import { + addColumn, + removeColumn, + setColumns, + setSort, + useDispatch, + useSelector, +} from '../../utils/state_management'; +import { ResultStatus, SearchData } from '../utils/use_search'; +import { IndexPatternField, opensearchFilters } from '../../../../../data/public'; +import { DocViewFilterFn } from '../../doc_views/doc_views_types'; + +interface Props { + history: History; +} + +export const DiscoverTable = ({ history }: Props) => { + const { services } = useOpenSearchDashboards(); + const { filterManager } = services.data.query; + const { data$, indexPattern } = useDiscoverContext(); + const [fetchState, setFetchState] = useState({ + status: data$.getValue().status, + rows: [], + }); + + const { columns, sort } = useSelector((state) => state.discover); + const dispatch = useDispatch(); + const onAddColumn = (col: string) => dispatch(addColumn({ column: col })); + const onRemoveColumn = (col: string) => dispatch(removeColumn(col)); + const onSetColumns = (cols: string[]) => + dispatch(setColumns({ timefield: indexPattern.timeFieldName, columns: cols })); + const onSetSort = (s: Array<[string, string]>) => dispatch(setSort(s)); + const onAddFilter = useCallback( + (field: IndexPatternField, values: string, operation: '+' | '-') => { + const newFilters = opensearchFilters.generateFilters( + filterManager, + field, + values, + operation, + indexPattern.id + ); + return filterManager.addFilters(newFilters); + }, + [filterManager, indexPattern] + ); + + const { rows } = fetchState || {}; + + useEffect(() => { + const subscription = data$.subscribe((next) => { + if (next.status === ResultStatus.LOADING) return; + if (next.status !== fetchState.status || (next.rows && next.rows !== fetchState.rows)) { + setFetchState({ ...fetchState, ...next }); + } + }); + return () => { + subscription.unsubscribe(); + }; + }, [data$, fetchState]); + + if (indexPattern === undefined) { + // TODO: handle better + return null; + } + + if (!rows || rows.length === 0) { + // TODO: handle better + return
{'loading...'}
; + } + + return ( + + ); +}; diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx new file mode 100644 index 000000000000..8caa65af54d6 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -0,0 +1,82 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { TopNav } from './top_nav'; +import { ViewProps } from '../../../../../data_explorer/public'; +import { DiscoverTable } from './discover_table'; +import { DiscoverChartContainer } from './discover_chart_container'; +import { useDiscoverContext } from '../context'; +import { ResultStatus, SearchData } from '../utils/use_search'; +import { DiscoverNoResults } from '../../components/no_results/no_results'; +import { DiscoverUninitialized } from '../../components/uninitialized/uninitialized'; +import { LoadingSpinner } from '../../components/loading_spinner/loading_spinner'; + +// eslint-disable-next-line import/no-default-export +export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewProps) { + const { data$, refetch$, indexPattern } = useDiscoverContext(); + + const [fetchState, setFetchState] = useState({ + status: data$.getValue().status, + hits: 0, + bucketInterval: {}, + }); + + const { status } = fetchState; + + useEffect(() => { + const subscription = data$.subscribe((next) => { + if ( + next.status !== fetchState.status || + (next.hits && next.hits !== fetchState.hits) || + (next.bucketInterval && next.bucketInterval !== fetchState.bucketInterval) || + (next.chartData && next.chartData !== fetchState.chartData) + ) { + setFetchState({ ...fetchState, ...next }); + } + }); + return () => { + subscription.unsubscribe(); + }; + }, [data$, fetchState]); + + const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined; + + return ( + + + + + {status === ResultStatus.NO_RESULTS && ( + + + + )} + {status === ResultStatus.UNINITIALIZED && ( + refetch$.next()} /> + )} + {status === ResultStatus.LOADING && } + {status === ResultStatus.READY && ( + <> + + + + + + + + + + + + )} + + ); +} diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx new file mode 100644 index 000000000000..43681ff023b5 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -0,0 +1,79 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { AppMountParameters } from '../../../../../../core/public'; +import { NEW_DISCOVER_APP, PLUGIN_ID } from '../../../../common'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { DiscoverViewServices } from '../../../build_services'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { getTopNavLinks } from '../../components/top_nav/get_top_nav_links'; +import { useDiscoverContext } from '../context'; + +export interface TopNavProps { + opts: { + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + }; +} + +export const TopNav = ({ opts }: TopNavProps) => { + const { services } = useOpenSearchDashboards(); + const { inspectorAdapters, savedSearch } = useDiscoverContext(); + const [indexPatterns, setIndexPatterns] = useState(undefined); + + const { + uiSettings, + navigation: { + ui: { TopNavMenu }, + }, + core: { + application: { navigateToApp }, + }, + data, + } = services; + + const topNavLinks = savedSearch ? getTopNavLinks(services, inspectorAdapters, savedSearch) : []; + + useEffect(() => { + if (uiSettings.get(NEW_DISCOVER_APP) === false) { + const path = window.location.hash; + navigateToApp('discoverLegacy', { + replace: true, + path, + }); + } + + return () => {}; + }, [navigateToApp, uiSettings]); + + useEffect(() => { + let isMounted = true; + const getDefaultIndexPattern = async () => { + await data.indexPatterns.ensureDefaultIndexPattern(); + const indexPattern = await data.indexPatterns.getDefault(); + + if (!isMounted) return; + + setIndexPatterns(indexPattern ? [indexPattern] : undefined); + }; + + getDefaultIndexPattern(); + + return () => { + isMounted = false; + }; + }, [data.indexPatterns]); + + return ( + + ); +}; diff --git a/src/plugins/discover/public/application/view_components/context/index.tsx b/src/plugins/discover/public/application/view_components/context/index.tsx new file mode 100644 index 000000000000..29daca731714 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/context/index.tsx @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { DataExplorerServices, ViewProps } from '../../../../../data_explorer/public'; +import { + OpenSearchDashboardsContextProvider, + useOpenSearchDashboards, +} from '../../../../../opensearch_dashboards_react/public'; +import { getServices } from '../../../opensearch_dashboards_services'; +import { useSearch, SearchContextValue } from '../utils/use_search'; +import { connectStorageToQueryState, opensearchFilters } from '../../../../../data/public'; + +const SearchContext = React.createContext({} as SearchContextValue); + +// eslint-disable-next-line import/no-default-export +export default function DiscoverContext({ children }: React.PropsWithChildren) { + const services = getServices(); + const searchParams = useSearch(services); + + const { + services: { osdUrlStateStorage }, + } = useOpenSearchDashboards(); + + // Connect the query service to the url state + useEffect(() => { + connectStorageToQueryState(services.data.query, osdUrlStateStorage, { + filters: opensearchFilters.FilterStateStore.APP_STATE, + query: true, + }); + }, [osdUrlStateStorage, services.data.query, services.uiSettings]); + + return ( + + {children} + + ); +} + +export const useDiscoverContext = () => React.useContext(SearchContext); diff --git a/src/plugins/discover/public/application/view_components/index.ts b/src/plugins/discover/public/application/view_components/index.ts new file mode 100644 index 000000000000..45fd68cf1285 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './canvas'; +export * from './panel'; diff --git a/src/plugins/discover/public/application/view_components/panel/index.tsx b/src/plugins/discover/public/application/view_components/panel/index.tsx new file mode 100644 index 000000000000..6f2e04b9a9ba --- /dev/null +++ b/src/plugins/discover/public/application/view_components/panel/index.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { ViewProps } from '../../../../../data_explorer/public'; +import { + addColumn, + removeColumn, + reorderColumn, + useDispatch, + useSelector, +} from '../../utils/state_management'; +import { DiscoverSidebar } from '../../components/sidebar'; +import { useDiscoverContext } from '../context'; +import { ResultStatus, SearchData } from '../utils/use_search'; + +// eslint-disable-next-line import/no-default-export +export default function DiscoverPanel(props: ViewProps) { + const { data$, indexPattern } = useDiscoverContext(); + const [fetchState, setFetchState] = useState(data$.getValue()); + + const { columns } = useSelector((state) => ({ + columns: state.discover.columns, + })); + const dispatch = useDispatch(); + + useEffect(() => { + const subscription = data$.subscribe((next) => { + if (next.status === ResultStatus.LOADING) return; + setFetchState(next); + }); + return () => { + subscription.unsubscribe(); + }; + }, [data$, fetchState]); + + return ( + { + dispatch( + addColumn({ + column: fieldName, + index, + }) + ); + }} + onRemoveField={(fieldName) => { + dispatch(removeColumn(fieldName)); + }} + onReorderFields={(source, destination) => { + dispatch( + reorderColumn({ + source, + destination, + }) + ); + }} + selectedIndexPattern={indexPattern} + onAddFilter={() => {}} + /> + ); +} 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 new file mode 100644 index 000000000000..584e47047c57 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/get_default_sort.ts @@ -0,0 +1,50 @@ +/* + * 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'; +// @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' +): SortOrder[] { + if (indexPattern.timeFieldName && isSortable(indexPattern.timeFieldName, indexPattern)) { + return [[indexPattern.timeFieldName, defaultSortOrder]]; + } else { + return [['_score', defaultSortOrder]]; + } +} diff --git a/src/plugins/discover/public/application/view_components/utils/get_sort.test.ts b/src/plugins/discover/public/application/view_components/utils/get_sort.test.ts new file mode 100644 index 000000000000..f1b4861e4c94 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/get_sort.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { getSort, getSortArray } from './get_sort'; +// @ts-ignore +import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; + +describe('docTable', function () { + let indexPattern: IndexPattern; + + beforeEach(() => { + indexPattern = FixturesStubbedLogstashIndexPatternProvider() as IndexPattern; + }); + + describe('getSort function', function () { + test('should be a function', function () { + expect(typeof getSort === 'function').toBeTruthy(); + }); + + test('should return an array of objects', function () { + expect(getSort([['bytes', 'desc']], indexPattern)).toEqual([{ bytes: 'desc' }]); + + delete indexPattern.timeFieldName; + expect(getSort([['bytes', 'desc']], indexPattern)).toEqual([{ bytes: 'desc' }]); + }); + + test('should passthrough arrays of objects', () => { + expect(getSort([{ bytes: 'desc' }], indexPattern)).toEqual([{ bytes: 'desc' }]); + }); + + test('should return an empty array when passed an unsortable field', function () { + expect(getSort([['non-sortable', 'asc']], indexPattern)).toEqual([]); + expect(getSort([['lol_nope', 'asc']], indexPattern)).toEqual([]); + + delete indexPattern.timeFieldName; + expect(getSort([['non-sortable', 'asc']], indexPattern)).toEqual([]); + }); + + test('should return an empty array ', function () { + expect(getSort([], indexPattern)).toEqual([]); + expect(getSort([['foo', 'bar']], indexPattern)).toEqual([]); + expect(getSort([{ foo: 'bar' }], indexPattern)).toEqual([]); + }); + + test('should convert a legacy sort to an array of objects', function () { + expect(getSort(['foo', 'desc'], indexPattern)).toEqual([{ foo: 'desc' }]); + expect(getSort(['foo', 'asc'], indexPattern)).toEqual([{ foo: 'asc' }]); + }); + }); + + describe('getSortArray function', function () { + test('should have an array method', function () { + expect(getSortArray).toBeInstanceOf(Function); + }); + + test('should return an array of arrays for sortable fields', function () { + expect(getSortArray([['bytes', 'desc']], indexPattern)).toEqual([['bytes', 'desc']]); + }); + + test('should return an array of arrays from an array of elasticsearch sort objects', function () { + expect(getSortArray([{ bytes: 'desc' }], indexPattern)).toEqual([['bytes', 'desc']]); + }); + + test('should sort by an empty array when an unsortable field is given', function () { + expect(getSortArray([{ 'non-sortable': 'asc' }], indexPattern)).toEqual([]); + expect(getSortArray([{ lol_nope: 'asc' }], indexPattern)).toEqual([]); + + delete indexPattern.timeFieldName; + expect(getSortArray([{ 'non-sortable': 'asc' }], indexPattern)).toEqual([]); + }); + + test('should return an empty array when passed an empty sort array', () => { + expect(getSortArray([], indexPattern)).toEqual([]); + + delete indexPattern.timeFieldName; + expect(getSortArray([], indexPattern)).toEqual([]); + }); + }); +}); diff --git a/src/plugins/discover/public/application/view_components/utils/get_sort.ts b/src/plugins/discover/public/application/view_components/utils/get_sort.ts new file mode 100644 index 000000000000..32de9e352f88 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/get_sort.ts @@ -0,0 +1,92 @@ +/* + * 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 _ from 'lodash'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; + +export type SortPairObj = Record; +export type SortPairArr = [string, string]; +export type SortPair = SortPairArr | SortPairObj; +export type SortInput = SortPair | SortPair[]; + +export function isSortable(fieldName: string, indexPattern: IndexPattern) { + const field = indexPattern.getFieldByName(fieldName); + return field && field.sortable; +} + +function createSortObject( + sortPair: SortInput, + indexPattern: IndexPattern +): SortPairObj | undefined { + if ( + Array.isArray(sortPair) && + sortPair.length === 2 && + isSortable(String(sortPair[0]), indexPattern) + ) { + const [field, direction] = sortPair as SortPairArr; + return { [field]: direction }; + } else if (_.isPlainObject(sortPair) && isSortable(Object.keys(sortPair)[0], indexPattern)) { + return sortPair as SortPairObj; + } +} + +export function isLegacySort(sort: SortPair[] | SortPair): sort is SortPair { + return ( + sort.length === 2 && typeof sort[0] === 'string' && (sort[1] === 'desc' || sort[1] === 'asc') + ); +} + +/** + * Take a sorting array and make it into an object + * @param {array} sort two dimensional array [[fieldToSort, directionToSort]] + * or an array of objects [{fieldToSort: directionToSort}] + * @param {object} indexPattern used for determining default sort + * @returns Array<{object}> an array of sort objects + */ +export function getSort(sort: SortPair[] | SortPair, indexPattern: IndexPattern): SortPairObj[] { + if (Array.isArray(sort)) { + if (isLegacySort(sort)) { + // To stay compatible with legacy sort, which just supported a single sort field + return [{ [sort[0]]: sort[1] }]; + } + return sort + .map((sortPair: SortPair) => createSortObject(sortPair, indexPattern)) + .filter((sortPairObj) => typeof sortPairObj === 'object') as SortPairObj[]; + } + return []; +} + +/** + * compared to getSort it doesn't return an array of objects, it returns an array of arrays + * [[fieldToSort: directionToSort]] + */ +export function getSortArray(sort: SortPair[], indexPattern: IndexPattern) { + return getSort(sort, indexPattern).map((sortPair) => Object.entries(sortPair).pop()); +} 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 new file mode 100644 index 000000000000..b19128a432e0 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts @@ -0,0 +1,66 @@ +/* + * 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 { OpenSearchQuerySortValue, IndexPattern } from '../../../opensearch_dashboards_services'; +import { getSort } from './get_sort'; +import { getDefaultSort } from './get_default_sort'; + +export type SortOrder = [string, string]; + +/** + * Prepares sort for search source, that's sending the request to OpenSearch + * - Adds default sort if necessary + * - Handles the special case when there's sorting by date_nanos typed fields + * the addon of the numeric_type guarantees the right sort order + * when there are indices with date and indices with date_nanos field + */ +export function getSortForSearchSource( + sort?: SortOrder[], + indexPattern?: IndexPattern, + defaultDirection: string = 'desc' +): OpenSearchQuerySortValue[] { + if (!sort || !indexPattern) { + return []; + } else if (Array.isArray(sort) && sort.length === 0) { + sort = getDefaultSort(indexPattern, defaultDirection); + } + const { timeFieldName } = indexPattern; + return getSort(sort, indexPattern).map((sortPair: Record) => { + if (indexPattern.isTimeNanosBased() && timeFieldName && sortPair[timeFieldName]) { + return { + [timeFieldName]: { + order: sortPair[timeFieldName], + numeric_type: 'date_nanos', + }, + } as OpenSearchQuerySortValue; + } + return sortPair as OpenSearchQuerySortValue; + }); +} diff --git a/src/plugins/discover/public/application/view_components/utils/index_pattern_helper.ts b/src/plugins/discover/public/application/view_components/utils/index_pattern_helper.ts new file mode 100644 index 000000000000..d6285594367e --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/index_pattern_helper.ts @@ -0,0 +1,114 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { SearchSource, IndexPattern } from 'src/plugins/data/public'; +import { SavedObject, ToastsStart } from 'opensearch-dashboards/public'; +import { redirectWhenMissing, getUrlTracker } from '../../../opensearch_dashboards_services'; +import { getIndexPatternId } from '../../helpers/get_index_pattern_id'; + +export type IndexPatternSavedObject = SavedObject & { title: string }; +export interface IndexPatternData { + loaded: IndexPattern; + stateVal: string; + stateValFound: boolean; +} + +export const fetchIndexPattern = async (data, config) => { + await data.indexPatterns.ensureDefaultIndexPattern(); + const indexPatternList = await data.indexPatterns.getCache(); + const id = getIndexPatternId('', indexPatternList, config.get('defaultIndex')); + const indexPatternData = await data.indexPatterns.get(id); + const ip: IndexPatternData = { + loaded: indexPatternData, + stateVal: '', // TODO: get stateVal from appStateContainer + stateValFound: false, // TODO: get stateValFound from appStateContainer + }; + return ip; +}; + +export const fetchSavedSearch = async ( + core, + basePath, + history, + savedSearchId, + services, + toastNotifications +) => { + try { + const savedSearch = await services.getSavedSearchById(savedSearchId); + return savedSearch; + } catch (error) { + // TODO: handle redirect with Data Explorer + redirectWhenMissing({ + history, + navigateToApp: core.application.navigateToApp, + basePath, + mapping: { + search: '/', + 'index-pattern': { + app: 'management', + path: `opensearch-dashboards/objects/savedSearches/${savedSearchId}`, + }, + }, + toastNotifications, + onBeforeRedirect() { + getUrlTracker().setTrackedUrl('/'); + }, + }); + } +}; + +export function resolveIndexPattern( + ip: IndexPatternData, + searchSource: SearchSource, + toastNotifications: ToastsStart +) { + const { loaded: loadedIndexPattern, stateVal, stateValFound } = ip; + + const ownIndexPattern = searchSource.getOwnField('index'); + + if (ownIndexPattern && !stateVal) { + return ownIndexPattern; + } + + if (stateVal && !stateValFound) { + const warningTitle = i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', { + defaultMessage: '{stateVal} is not a configured index pattern ID', + values: { + stateVal: `"${stateVal}"`, + }, + }); + + if (ownIndexPattern) { + toastNotifications.addWarning({ + title: warningTitle, + text: i18n.translate('discover.showingSavedIndexPatternWarningDescription', { + defaultMessage: + 'Showing the saved index pattern: "{ownIndexPatternTitle}" ({ownIndexPatternId})', + values: { + ownIndexPatternTitle: ownIndexPattern.title, + ownIndexPatternId: ownIndexPattern.id, + }, + }), + }); + return ownIndexPattern; + } + + toastNotifications.addWarning({ + title: warningTitle, + text: i18n.translate('discover.showingDefaultIndexPatternWarningDescription', { + defaultMessage: + 'Showing the default index pattern: "{loadedIndexPatternTitle}" ({loadedIndexPatternId})', + values: { + loadedIndexPatternTitle: loadedIndexPattern.title, + loadedIndexPatternId: loadedIndexPattern.id, + }, + }), + }); + } + + return loadedIndexPattern; +} diff --git a/src/plugins/discover/public/application/view_components/utils/update_search_source.ts b/src/plugins/discover/public/application/view_components/utils/update_search_source.ts new file mode 100644 index 000000000000..1404773eb9d4 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/update_search_source.ts @@ -0,0 +1,72 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IndexPattern, + ISearchSource, + indexPatterns as indexPatternUtils, + AggConfigs, +} from '../../../../../data/public'; +import { DiscoverServices } from '../../../build_services'; +import { SortOrder } from '../../../saved_searches/types'; +import { getSortForSearchSource } from './get_sort_for_search_source'; +import { SORT_DEFAULT_ORDER_SETTING, SAMPLE_SIZE_SETTING } from '../../../../common'; + +interface Props { + indexPattern: IndexPattern; + services: DiscoverServices; + sort: SortOrder[] | undefined; + searchSource?: ISearchSource; + histogramConfigs?: AggConfigs; +} + +export const updateSearchSource = async ({ + indexPattern, + services, + searchSource, + sort, + histogramConfigs, +}: Props) => { + const { uiSettings, data } = services; + const sortForSearchSource = getSortForSearchSource( + sort, + indexPattern, + uiSettings.get(SORT_DEFAULT_ORDER_SETTING) + ); + const size = uiSettings.get(SAMPLE_SIZE_SETTING); + const filters = data.query.filterManager.getFilters(); + + const searchSourceInstance = searchSource || (await data.search.searchSource.create()); + + // searchSource which applies time range + const timeRangeSearchSource = await data.search.searchSource.create(); + const { isDefault } = indexPatternUtils; + if (isDefault(indexPattern)) { + const timefilter = data.query.timefilter.timefilter; + + timeRangeSearchSource.setField('filter', () => { + return timefilter.createFilter(indexPattern); + }); + } + + searchSourceInstance.setParent(timeRangeSearchSource); + + searchSourceInstance.setFields({ + index: indexPattern, + sort: sortForSearchSource, + size, + query: data.query.queryString.getQuery() || null, + highlightAll: true, + version: true, + filter: filters, + }); + + if (histogramConfigs) { + const dslAggs = histogramConfigs.toDsl(); + searchSourceInstance.setField('aggs', dslAggs); + } + + return searchSourceInstance; +}; diff --git a/src/plugins/discover/public/application/view_components/utils/use_index_pattern.ts b/src/plugins/discover/public/application/view_components/utils/use_index_pattern.ts new file mode 100644 index 000000000000..872639107987 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/use_index_pattern.ts @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { IndexPattern } from '../../../../../data/public'; +import { useSelector } from '../../utils/state_management'; +import { DiscoverServices } from '../../../build_services'; + +export const useIndexPattern = (services: DiscoverServices) => { + const indexPatternId = useSelector((state) => state.metadata.indexPattern); + const [indexPattern, setIndexPattern] = useState(undefined); + const { data, toastNotifications } = services; + + useEffect(() => { + let isMounted = true; + if (!indexPatternId) return; + const indexPatternMissingWarning = i18n.translate( + 'discover.valueIsNotConfiguredIndexPatternIDWarningTitle', + { + defaultMessage: '{id} is not a configured index pattern ID', + values: { + id: `"${indexPatternId}"`, + }, + } + ); + + data.indexPatterns + .get(indexPatternId) + .then((result) => { + if (isMounted) { + setIndexPattern(result); + } + }) + .catch(() => { + if (isMounted) { + toastNotifications.addDanger({ + title: indexPatternMissingWarning, + }); + } + }); + + return () => { + isMounted = false; + }; + }, [indexPatternId, data.indexPatterns, toastNotifications]); + + return indexPattern; +}; diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts new file mode 100644 index 000000000000..af845fb1ad2f --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -0,0 +1,268 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useMemo, useRef, useState } from 'react'; +import { BehaviorSubject, Subject, merge } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import { i18n } from '@osd/i18n'; +import { useEffect } from 'react'; +import { RequestAdapter } from '../../../../../inspector/public'; +import { DiscoverServices } from '../../../build_services'; +import { search } from '../../../../../data/public'; +import { validateTimeRange } from '../../helpers/validate_time_range'; +import { updateSearchSource } from './update_search_source'; +import { useIndexPattern } from './use_index_pattern'; +import { OpenSearchSearchHit } from '../../doc_views/doc_views_types'; +import { TimechartHeaderBucketInterval } from '../../components/chart/timechart_header'; +import { tabifyAggResponse } from '../../../opensearch_dashboards_services'; +import { + getDimensions, + buildPointSeriesData, + createHistogramConfigs, + Chart, +} from '../../components/chart/utils'; +import { SavedSearch } from '../../../saved_searches'; +import { useSelector } from '../../utils/state_management'; +import { + getRequestInspectorStats, + getResponseInspectorStats, +} from '../../../opensearch_dashboards_services'; +import { SEARCH_ON_PAGE_LOAD_SETTING } from '../../../../common'; + +export enum ResultStatus { + UNINITIALIZED = 'uninitialized', + LOADING = 'loading', // initial data load + READY = 'ready', // results came back + NO_RESULTS = 'none', // no results came back +} + +export interface SearchData { + status: ResultStatus; + fetchCounter?: number; + fieldCounts?: Record; + hits?: number; + rows?: OpenSearchSearchHit[]; + bucketInterval?: TimechartHeaderBucketInterval | {}; + chartData?: Chart; +} + +export type SearchRefetch = 'refetch' | undefined; + +export type DataSubject = BehaviorSubject; +export type RefetchSubject = Subject; + +/** + * A hook that provides functionality for fetching and managing discover search data. + * @returns { data: DataSubject, refetch$: RefetchSubject, indexPattern: IndexPattern, savedSearch?: SavedSearch, inspectorAdapters } - data is a BehaviorSubject that emits the current search data, refetch$ is a Subject that can be used to trigger a refetch, savedSearch is the saved search object if it exists + * @example + * const { data$, refetch$ } = useSearch(); + * useEffect(() => { + * const subscription = data$.subscribe((d) => { + * // do something with the data + * }); + * return () => subscription.unsubscribe(); + * }, [data$]); + */ +export const useSearch = (services: DiscoverServices) => { + const [savedSearch, setSavedSearch] = useState(undefined); + const savedSearchId = useSelector((state) => state.discover.savedSearch); + const indexPattern = useIndexPattern(services); + const { data, filterManager, getSavedSearchById, core, toastNotifications } = services; + const timefilter = data.query.timefilter.timefilter; + const fetchStateRef = useRef<{ + abortController: AbortController | undefined; + fieldCounts: Record; + rows?: OpenSearchSearchHit[]; + }>({ + abortController: undefined, + fieldCounts: {}, + }); + const inspectorAdapters = { + requests: new RequestAdapter(), + }; + + const shouldSearchOnPageLoad = useCallback(() => { + // A saved search is created on every page load, so we check the ID to see if we're loading a + // previously saved search or if it is just transient + return ( + services.uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING) || + savedSearch?.id !== undefined || + timefilter.getRefreshInterval().pause === false + ); + }, [savedSearch, services.uiSettings, timefilter]); + + const data$ = useMemo( + () => + new BehaviorSubject({ + status: shouldSearchOnPageLoad() ? ResultStatus.LOADING : ResultStatus.UNINITIALIZED, + }), + [shouldSearchOnPageLoad] + ); + const refetch$ = useMemo(() => new Subject(), []); + + const fetch = useCallback(async () => { + if (!indexPattern) { + data$.next({ + status: shouldSearchOnPageLoad() ? ResultStatus.LOADING : ResultStatus.UNINITIALIZED, + }); + return; + } + + if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { + return data$.next({ + status: ResultStatus.NO_RESULTS, + rows: [], + }); + } + + // Abort any in-progress requests before fetching again + if (fetchStateRef.current.abortController) fetchStateRef.current.abortController.abort(); + fetchStateRef.current.abortController = new AbortController(); + const sort = undefined; + const histogramConfigs = indexPattern.timeFieldName + ? createHistogramConfigs(indexPattern, 'auto', data) + : undefined; + const searchSource = await updateSearchSource({ + indexPattern, + services, + sort, + searchSource: savedSearch?.searchSource, + histogramConfigs, + }); + + try { + // Only show loading indicator if we are fetching when the rows are empty + if (fetchStateRef.current.rows?.length === 0) { + data$.next({ status: ResultStatus.LOADING }); + } + + // Initialize inspect adapter for search source + inspectorAdapters.requests.reset(); + const title = i18n.translate('discover.inspectorRequestDataTitle', { + defaultMessage: 'data', + }); + const description = i18n.translate('discover.inspectorRequestDescription', { + defaultMessage: 'This request queries OpenSearch to fetch the data for the search.', + }); + const inspectorRequest = inspectorAdapters.requests.start(title, { description }); + inspectorRequest.stats(getRequestInspectorStats(searchSource)); + searchSource.getSearchRequestBody().then((body) => { + inspectorRequest.json(body); + }); + + // Execute the search + const fetchResp = await searchSource.fetch({ + abortSignal: fetchStateRef.current.abortController.signal, + }); + + inspectorRequest + .stats(getResponseInspectorStats(fetchResp, searchSource)) + .ok({ json: fetchResp }); + const hits = fetchResp.hits.total as number; + const rows = fetchResp.hits.hits; + let bucketInterval = {}; + let chartData; + for (const row of rows) { + const fields = Object.keys(indexPattern.flattenHit(row)); + for (const fieldName of fields) { + fetchStateRef.current.fieldCounts[fieldName] = + (fetchStateRef.current.fieldCounts[fieldName] || 0) + 1; + } + } + + if (histogramConfigs) { + const bucketAggConfig = histogramConfigs.aggs[1]; + const tabifiedData = tabifyAggResponse(histogramConfigs, fetchResp); + const dimensions = getDimensions(histogramConfigs, data); + if (dimensions) { + if (bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig)) { + bucketInterval = bucketAggConfig.buckets?.getInterval(); + } + // @ts-ignore tabifiedData is compatible but due to the way it is typed typescript complains + chartData = buildPointSeriesData(tabifiedData, dimensions); + } + } + + fetchStateRef.current.fieldCounts = fetchStateRef.current.fieldCounts!; + fetchStateRef.current.rows = rows; + data$.next({ + status: rows.length > 0 ? ResultStatus.READY : ResultStatus.NO_RESULTS, + fieldCounts: fetchStateRef.current.fieldCounts, + hits, + rows, + bucketInterval, + chartData, + }); + } catch (error) { + // If the request was aborted then no need to surface this error in the UI + if (error instanceof Error && error.name === 'AbortError') return; + + data$.next({ + status: ResultStatus.NO_RESULTS, + rows: [], + }); + + data.search.showError(error as Error); + } + }, [ + indexPattern, + timefilter, + toastNotifications, + data, + services, + savedSearch?.searchSource, + data$, + shouldSearchOnPageLoad, + inspectorAdapters.requests, + ]); + + useEffect(() => { + const fetch$ = merge( + refetch$, + filterManager.getFetches$(), + timefilter.getFetch$(), + timefilter.getTimeUpdate$(), + timefilter.getAutoRefreshFetch$(), + data.query.queryString.getUpdates$() + ).pipe(debounceTime(100)); + + const subscription = fetch$.subscribe(() => { + (async () => { + try { + await fetch(); + } catch (error) { + core.fatalErrors.add(error as Error); + } + })(); + }); + + // kick off initial fetch + refetch$.next(); + + return () => { + subscription.unsubscribe(); + }; + }, [data$, data.query.queryString, filterManager, refetch$, timefilter, fetch, core.fatalErrors]); + + // Get savedSearch if it exists + useEffect(() => { + (async () => { + const savedSearchInstance = await getSavedSearchById(savedSearchId || ''); + setSavedSearch(savedSearchInstance); + })(); + + return () => {}; + }, [getSavedSearchById, savedSearchId]); + + return { + data$, + refetch$, + indexPattern, + savedSearch, + inspectorAdapters, + }; +}; + +export type SearchContextValue = ReturnType; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 3fdafcff0c40..ebe4e80a70c5 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -57,6 +57,7 @@ import { getHistory } from './opensearch_dashboards_services'; import { OpenSearchDashboardsLegacyStart } from '../../opensearch_dashboards_legacy/public'; import { UrlForwardingStart } from '../../url_forwarding/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; +import { DataExplorerServices } from '../../data_explorer/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -79,17 +80,15 @@ export interface DiscoverServices { toastNotifications: ToastsStart; getSavedSearchById: (id: string) => Promise; getSavedSearchUrlById: (id: string) => Promise; - getEmbeddableInjector: any; uiSettings: IUiSettingsClient; visualizations: VisualizationsStart; } -export async function buildServices( +export function buildServices( core: CoreStart, plugins: DiscoverStartPlugins, - context: PluginInitializerContext, - getEmbeddableInjector: any -): Promise { + context: PluginInitializerContext +): DiscoverServices { const services: SavedObjectOpenSearchDashboardsServices = { savedObjectsClient: core.savedObjects.client, indexPatterns: plugins.data.indexPatterns, @@ -108,7 +107,6 @@ export async function buildServices( docLinks: core.docLinks, theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, - getEmbeddableInjector, getSavedSearchById: async (id: string) => savedObjectService.get(id), getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id), history: getHistory, @@ -127,3 +125,6 @@ export async function buildServices( visualizations: plugins.visualizations, }; } + +// Any component inside the panel and canvas views has access to both these services. +export type DiscoverViewServices = DiscoverServices & DataExplorerServices; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index 6c9ab46b656e..3bc009914940 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -37,5 +37,7 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; -export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; +// TODO: Fix embeddable after removing Angular +// export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator'; +export { NEW_DISCOVER_APP } from '../common'; diff --git a/src/plugins/discover/public/migrate_state.ts b/src/plugins/discover/public/migrate_state.ts new file mode 100644 index 000000000000..ef70a7ddfb67 --- /dev/null +++ b/src/plugins/discover/public/migrate_state.ts @@ -0,0 +1,138 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { matchPath } from 'react-router-dom'; +import { getStateFromOsdUrl, setStateToOsdUrl } from '../../opensearch_dashboards_utils/public'; +import { Filter, Query } from '../../data/public'; + +interface CommonParams { + appState?: string; +} + +interface DiscoverParams extends CommonParams { + id?: string; +} + +interface ContextParams extends CommonParams { + indexPattern: string; + id: string; +} + +interface DocParams extends CommonParams { + indexPattern: string; + index: string; +} + +export interface LegacyDiscoverState { + /** + * Columns displayed in the table + */ + columns?: string[]; + /** + * Array of applied filters + */ + filters?: Filter[]; + /** + * id of the used index pattern + */ + index?: string; + /** + * Used interval of the histogram + */ + interval?: string; + /** + * Lucence or DQL query + */ + query?: Query; + /** + * Array of the used sorting [[field,direction],...] + */ + sort?: string[][]; + /** + * id of the used saved query + */ + savedQuery?: string; +} + +// TODO: Write unit tests once all routes have been migrated. +/** + * Migrates legacy URLs to the current URL format. + * @param oldPath The legacy hash that contains the state. + * @param newPath The new base path. + */ +export function migrateUrlState(oldPath: string, newPath = '/'): string { + let path = newPath; + const pathPatterns = [ + { + pattern: '#/context/:indexPattern/:id\\?:appState?', + extraState: { docView: 'context' }, + path: `context`, + }, + { + pattern: '#/doc/:indexPattern/:index\\?:appState?', + extraState: { docView: 'doc' }, + path: `doc`, + }, + { + pattern: '#/view/:id', + extraState: {}, + path: `savedSearch`, + }, + { pattern: '#/', extraState: {}, path: `discover` }, + ]; + + // Get the first matching path pattern. + const matchingPathPattern = pathPatterns.find((pathPattern) => + matchPath(oldPath, { path: pathPattern.pattern, strict: false }) + ); + + if (!matchingPathPattern) { + return path; + } + + // Migrate the path. + switch (matchingPathPattern.path) { + case `discover`: + case `savedSearch`: + const params = matchPath(oldPath, { + path: matchingPathPattern.pattern, + })!.params; + + // if there is a saved search id, use the saved search path + if (params.id) { + path = `${path}#/view/${params.id}`; + } + + const appState = getStateFromOsdUrl('_a', oldPath); + + if (!appState) return path; + + const { columns, filters, index, interval, query, sort, savedQuery } = appState; + + const _q = { + query, + filters, + }; + + const _a = { + discover: { + columns, + interval, + sort, + savedQuery, + }, + metadata: { + indexPattern: index, + }, + }; + + path = setStateToOsdUrl('_a', _a, { useHash: false }, path); + path = setStateToOsdUrl('_q', _q, { useHash: false }, path); + + break; + } + + return path; +} diff --git a/src/plugins/discover/public/opensearch_dashboards_services.ts b/src/plugins/discover/public/opensearch_dashboards_services.ts index 8531564e0cc7..7149454a34ce 100644 --- a/src/plugins/discover/public/opensearch_dashboards_services.ts +++ b/src/plugins/discover/public/opensearch_dashboards_services.ts @@ -38,24 +38,9 @@ import { search } from '../../data/public'; import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; import { DocViewsLinksRegistry } from './application/doc_views_links/doc_views_links_registry'; -let angularModule: any = null; let services: DiscoverServices | null = null; let uiActions: UiActionsStart; -/** - * set bootstrapped inner angular module - */ -export function setAngularModule(module: any) { - angularModule = module; -} - -/** - * get boostrapped inner angular module - */ -export function getAngularModule() { - return angularModule; -} - export function getServices(): DiscoverServices { if (!services) { throw new Error('Discover services are not yet available'); @@ -74,11 +59,6 @@ export const [getHeaderActionMenuMounter, setHeaderActionMenuMounter] = createGe AppMountParameters['setHeaderActionMenu'] >('headerActionMenuMounter'); -export const [getUrlTracker, setUrlTracker] = createGetterSetter<{ - setTrackedUrl: (url: string) => void; - restorePreviousUrl: () => void; -}>('urlTracker'); - export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( 'DocViewsRegistry' ); @@ -86,6 +66,7 @@ export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter('DocViewsLinksRegistry'); + /** * Makes sure discover and context are using one instance of history. */ diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 62f6e6908ba1..8a581a3d246f 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -1,37 +1,10 @@ /* + * Copyright OpenSearch Contributors * 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 { i18n } from '@osd/i18n'; -import angular, { auto } from 'angular'; import { BehaviorSubject } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; import { AppMountParameters, @@ -56,9 +29,10 @@ import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; import { stringify } from 'query-string'; import rison from 'rison-node'; +import { lazy } from 'react'; import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; import { SavedObjectLoader } from '../../saved_objects/public'; -import { createOsdUrlTracker, url } from '../../opensearch_dashboards_utils/public'; +import { url } from '../../opensearch_dashboards_utils/public'; import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; @@ -70,25 +44,30 @@ import { JsonCodeBlock } from './application/components/json_code_block/json_cod import { setDocViewsRegistry, setDocViewsLinksRegistry, - setUrlTracker, - setAngularModule, setServices, setHeaderActionMenuMounter, setUiActions, setScopedHistory, - getScopedHistory, syncHistoryLocations, getServices, } from './opensearch_dashboards_services'; import { createSavedSearchesLoader } from './saved_searches'; -import { registerFeature } from './register_feature'; import { buildServices } from './build_services'; import { DiscoverUrlGeneratorState, DISCOVER_APP_URL_GENERATOR, DiscoverUrlGenerator, } from './url_generator'; -import { SearchEmbeddableFactory } from './application/embeddable'; +// import { SearchEmbeddableFactory } from './application/embeddable'; +import { NEW_DISCOVER_APP, PLUGIN_ID } from '../common'; +import { DataExplorerPluginSetup } from '../../data_explorer/public'; +import { registerFeature } from './register_feature'; +import { + DiscoverState, + discoverSlice, + getPreloadedState, +} from './application/utils/state_management/discover_slice'; +import { migrateUrlState } from './migrate_state'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -103,7 +82,6 @@ export interface DiscoverSetup { docViews: { /** * Add new doc view shown along with table view and json view in the details of each document in Discover. - * Both react and angular doc views are supported. * @param docViewRaw */ addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; @@ -148,6 +126,7 @@ export interface DiscoverSetupPlugins { home?: HomePublicPluginSetup; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; + dataExplorer: DataExplorerPluginSetup; } /** @@ -166,13 +145,9 @@ export interface DiscoverStartPlugins { visualizations: VisualizationsStart; } -const innerAngularName = 'app/discover'; -const embeddableAngularName = 'app/discoverEmbeddable'; - /** * Contains Discover, one of the oldest parts of OpenSearch Dashboards - * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular - * Discover provides embeddables, those contain a slimmer Angular + * Discover provides embeddables for Dashboards */ export class DiscoverPlugin implements Plugin { @@ -181,20 +156,15 @@ export class DiscoverPlugin private appStateUpdater = new BehaviorSubject(() => ({})); private docViewsRegistry: DocViewsRegistry | null = null; private docViewsLinksRegistry: DocViewsLinksRegistry | null = null; - private embeddableInjector: auto.IInjectorService | null = null; private stopUrlTracking: (() => void) | undefined = undefined; private servicesInitialized: boolean = false; - private innerAngularInitialized: boolean = false; private urlGenerator?: DiscoverStart['urlGenerator']; - - /** - * why are those functions public? they are needed for some mocha tests - * can be removed once all is Jest - */ - public initializeInnerAngular?: () => void; - public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; + private initializeServices?: () => { core: CoreStart; plugins: DiscoverStartPlugins }; setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('DiscoverPlugin.setup()'); const baseUrl = core.http.basePath.prepend('/app/discover'); if (plugins.share) { @@ -270,45 +240,8 @@ export class DiscoverPlugin order: 2, }); - const { - appMounted, - appUnMounted, - stop: stopUrlTracker, - setActiveUrl: setTrackedUrl, - restorePreviousUrl, - } = createOsdUrlTracker({ - // we pass getter here instead of plain `history`, - // so history is lazily created (when app is mounted) - // this prevents redundant `#` when not in discover app - getHistory: getScopedHistory, - baseUrl, - defaultSubUrl: '#/', - storageKey: `lastUrl:${core.http.basePath.get()}:discover`, - navLinkUpdater$: this.appStateUpdater, - toastNotifications: core.notifications.toasts, - stateParams: [ - { - osdUrlKey: '_g', - stateUpdate$: plugins.data.query.state$.pipe( - filter( - ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) - ), - map(({ state }) => ({ - ...state, - filters: state.filters?.filter(opensearchFilters.isFilterPinned), - })) - ), - }, - ], - }); - setUrlTracker({ setTrackedUrl, restorePreviousUrl }); - this.stopUrlTracking = () => { - stopUrlTracker(); - }; - - this.docViewsRegistry.setAngularInjectorGetter(this.getEmbeddableInjector); core.application.register({ - id: 'discover', + id: PLUGIN_ID, title: 'Discover', updater$: this.appStateUpdater.asObservable(), order: 1000, @@ -316,61 +249,97 @@ export class DiscoverPlugin defaultPath: '#/', category: DEFAULT_APP_CATEGORIES.opensearchDashboards, mount: async (params: AppMountParameters) => { + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('DiscoverPlugin.mount()'); if (!this.initializeServices) { throw Error('Discover plugin method initializeServices is undefined'); } - if (!this.initializeInnerAngular) { - throw Error('Discover plugin method initializeInnerAngular is undefined'); - } setScopedHistory(params.history); setHeaderActionMenuMounter(params.setHeaderActionMenu); syncHistoryLocations(); - appMounted(); const { - plugins: { data: dataStart }, + core: { + application: { navigateToApp }, + }, } = await this.initializeServices(); - await this.initializeInnerAngular(); - - // make sure the index pattern list is up to date - await dataStart.indexPatterns.clearCache(); - const { renderApp } = await import('./application/application'); - params.element.classList.add('dscAppWrapper'); - const unmount = await renderApp(innerAngularName, params.element); - return () => { - params.element.classList.remove('dscAppWrapper'); - unmount(); - appUnMounted(); - }; + + // This is for instances where the user navigates to the app from the application nav menu + const path = window.location.hash; + const v2Enabled = await core.uiSettings.get(NEW_DISCOVER_APP); + if (!v2Enabled) { + navigateToApp('discoverLegacy', { + replace: true, + path, + }); + } else { + const newPath = migrateUrlState(path); + navigateToApp('data-explorer', { + replace: true, + path: `/${PLUGIN_ID}${newPath}`, + }); + } + + return () => {}; }, }); - plugins.urlForwarding.forwardApp('doc', 'discover', (path) => { - return `#${path}`; - }); - plugins.urlForwarding.forwardApp('context', 'discover', (path) => { - const urlParts = path.split('/'); - // take care of urls containing legacy url, those split in the following way - // ["", "context", indexPatternId, _type, id + params] - if (urlParts[4]) { - // remove _type part - const newPath = [...urlParts.slice(0, 3), ...urlParts.slice(4)].join('/'); - return `#${newPath}`; - } - return `#${path}`; - }); - plugins.urlForwarding.forwardApp('discover', 'discover', (path) => { - const [, id, tail] = /discover\/([^\?]+)(.*)/.exec(path) || []; - if (!id) { - return `#${path.replace('/discover', '') || '/'}`; - } - return `#/view/${id}${tail || ''}`; - }); + // TODO: These routes need to be handled for Discover 2.0 to support legacy saved URLS's + // plugins.urlForwarding.forwardApp('doc', 'discover', (path) => { + // return `#${path}`; + // }); + // plugins.urlForwarding.forwardApp('context', 'discover', (path) => { + // const urlParts = path.split('/'); + // // take care of urls containing legacy url, those split in the following way + // // ["", "context", indexPatternId, _type, id + params] + // if (urlParts[4]) { + // // remove _type part + // const newPath = [...urlParts.slice(0, 3), ...urlParts.slice(4)].join('/'); + // return `#${newPath}`; + // } + // return `#${path}`; + // }); + // plugins.urlForwarding.forwardApp('discover', 'discover', (path) => { + // const [, id, tail] = /discover\/([^\?]+)(.*)/.exec(path) || []; + // if (!id) { + // return `#${path.replace('/discover', '') || '/'}`; + // } + // return `#/view/${id}${tail || ''}`; + // }); if (plugins.home) { registerFeature(plugins.home); } - this.registerEmbeddable(core, plugins); + plugins.dataExplorer.registerView({ + id: PLUGIN_ID, + title: 'Discover', + defaultPath: '#/', + appExtentions: { + savedObject: { + docTypes: ['search'], + toListItem: (obj) => ({ + id: obj.id, + label: obj.title, + }), + }, + }, + ui: { + defaults: async () => { + this.initializeServices?.(); + const services = getServices(); + return await getPreloadedState(services); + }, + slice: discoverSlice, + }, + shouldShow: () => true, + // ViewComponent + Canvas: lazy(() => import('./application/view_components/canvas')), + Panel: lazy(() => import('./application/view_components/panel')), + Context: lazy(() => import('./application/view_components/context')), + }); + + // this.registerEmbeddable(core, plugins); return { docViews: { @@ -383,44 +352,24 @@ export class DiscoverPlugin } start(core: CoreStart, plugins: DiscoverStartPlugins) { - // we need to register the application service at setup, but to render it - // there are some start dependencies necessary, for this reason - // initializeInnerAngular + initializeServices are assigned at start and used - // when the application/embeddable is mounted - this.initializeInnerAngular = async () => { - if (this.innerAngularInitialized) { - return; - } - // this is used by application mount and tests - const { getInnerAngularModule } = await import('./get_inner_angular'); - const module = getInnerAngularModule( - innerAngularName, - core, - plugins, - this.initializerContext - ); - setAngularModule(module); - this.innerAngularInitialized = true; - }; - + // TODO: Remove this before merge to main + // eslint-disable-next-line no-console + console.log('DiscoverPlugin.start()'); setUiActions(plugins.uiActions); - this.initializeServices = async () => { + this.initializeServices = () => { if (this.servicesInitialized) { return { core, plugins }; } - const services = await buildServices( - core, - plugins, - this.initializerContext, - this.getEmbeddableInjector - ); + const services = buildServices(core, plugins, this.initializerContext); setServices(services); this.servicesInitialized = true; return { core, plugins }; }; + this.initializeServices(); + return { urlGenerator: this.urlGenerator, savedSearchLoader: createSavedSearchesLoader({ @@ -439,14 +388,11 @@ export class DiscoverPlugin } } + // TODO: Use this registration when legacy discover is removed /** * register embeddable with a slimmer embeddable version of inner angular */ private registerEmbeddable(core: CoreSetup, plugins: DiscoverSetupPlugins) { - if (!this.getEmbeddableInjector) { - throw Error('Discover plugin method getEmbeddableInjector is undefined'); - } - const getStartServices = async () => { const [coreStart, deps] = await core.getStartServices(); return { @@ -455,23 +401,8 @@ export class DiscoverPlugin }; }; - const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector); - plugins.embeddable.registerEmbeddableFactory(factory.type, factory); + // TODO: Refactor to remove angular + // const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector); + // plugins.embeddable.registerEmbeddableFactory(factory.type, factory); } - - private getEmbeddableInjector = async () => { - if (!this.embeddableInjector) { - if (!this.initializeServices) { - throw Error('Discover plugin getEmbeddableInjector: initializeServices is undefined'); - } - const { core, plugins } = await this.initializeServices(); - getServices().opensearchDashboardsLegacy.loadFontAwesome(); - const { getInnerAngularModuleEmbeddable } = await import('./get_inner_angular'); - getInnerAngularModuleEmbeddable(embeddableAngularName, core, plugins); - const mountpoint = document.createElement('div'); - this.embeddableInjector = angular.bootstrap(mountpoint, [embeddableAngularName]); - } - - return this.embeddableInjector; - }; } diff --git a/src/plugins/discover/public/saved_searches/_saved_search.ts b/src/plugins/discover/public/saved_searches/_saved_search.ts index 55cd59104ecb..9b43e3a89203 100644 --- a/src/plugins/discover/public/saved_searches/_saved_search.ts +++ b/src/plugins/discover/public/saved_searches/_saved_search.ts @@ -34,6 +34,8 @@ import { SavedObjectOpenSearchDashboardsServices, } from '../../../saved_objects/public'; +export const SAVED_OBJECT_TYPE = 'search'; + export function createSavedSearchClass(services: SavedObjectOpenSearchDashboardsServices) { const SavedObjectClass = createSavedObjectClass(services); diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index e02fd65e6899..97bd4966e94c 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -28,18 +28,16 @@ * under the License. */ +import { SavedObject } from '../../../saved_objects/public'; import { ISearchSource } from '../../../data/public'; export type SortOrder = [string, string]; -export interface SavedSearch { - readonly id: string; - title: string; - searchSource: ISearchSource; +export interface SavedSearch + extends Pick { + searchSource: ISearchSource; // This is optional in SavedObject, but required for SavedSearch description?: string; columns: string[]; sort: SortOrder[]; - destroy: () => void; - lastSavedTitle?: string; } export interface SavedSearchLoader { get: (id: string) => Promise; diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 70eab306e7fb..2b35384c2e5c 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -33,6 +33,7 @@ import { schema } from '@osd/config-schema'; import { UiSettingsParams } from 'opensearch-dashboards/server'; import { + NEW_DISCOVER_APP, DEFAULT_COLUMNS_SETTING, SAMPLE_SIZE_SETTING, AGGS_TERMS_SIZE_SETTING, @@ -47,6 +48,17 @@ import { } from '../common'; export const uiSettings: Record = { + [NEW_DISCOVER_APP]: { + name: i18n.translate('discover.advancedSettings.legacyToggleTitle', { + defaultMessage: 'Enable new discover app', + }), + value: true, + description: i18n.translate('discover.advancedSettings.legacyToggleText', { + defaultMessage: 'Disabling the new discover app will redirect to the legacy app.', + }), + category: ['discover'], + schema: schema.boolean(), + }, [DEFAULT_COLUMNS_SETTING]: { name: i18n.translate('discover.advancedSettings.defaultColumnsTitle', { defaultMessage: 'Default columns', diff --git a/src/plugins/discover_legacy/README.md b/src/plugins/discover_legacy/README.md new file mode 100644 index 000000000000..a914d651eef3 --- /dev/null +++ b/src/plugins/discover_legacy/README.md @@ -0,0 +1 @@ +Contains the Discover application and the saved search embeddable. \ No newline at end of file diff --git a/src/plugins/discover_legacy/common/index.ts b/src/plugins/discover_legacy/common/index.ts new file mode 100644 index 000000000000..371442385bbf --- /dev/null +++ b/src/plugins/discover_legacy/common/index.ts @@ -0,0 +1,41 @@ +/* + * 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. + */ + +export const DEFAULT_COLUMNS_SETTING = 'defaultColumns'; +export const SAMPLE_SIZE_SETTING = 'discover:sampleSize'; +export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size'; +export const SORT_DEFAULT_ORDER_SETTING = 'discover:sort:defaultOrder'; +export const SEARCH_ON_PAGE_LOAD_SETTING = 'discover:searchOnPageLoad'; +export const DOC_HIDE_TIME_COLUMN_SETTING = 'doc_table:hideTimeColumn'; +export const FIELDS_LIMIT_SETTING = 'fields:popularLimit'; +export const CONTEXT_DEFAULT_SIZE_SETTING = 'context:defaultSize'; +export const CONTEXT_STEP_SETTING = 'context:step'; +export const CONTEXT_TIE_BREAKER_FIELDS_SETTING = 'context:tieBreakerFields'; +export const MODIFY_COLUMNS_ON_SWITCH = 'discover:modifyColumnsOnSwitch'; diff --git a/src/plugins/discover_legacy/opensearch_dashboards.json b/src/plugins/discover_legacy/opensearch_dashboards.json new file mode 100644 index 000000000000..6a4259a41d75 --- /dev/null +++ b/src/plugins/discover_legacy/opensearch_dashboards.json @@ -0,0 +1,27 @@ +{ + "id": "discoverLegacy", + "version": "opensearchDashboards", + "server": false, + "ui": true, + "requiredPlugins": [ + "charts", + "data", + "embeddable", + "inspector", + "opensearchDashboardsLegacy", + "urlForwarding", + "navigation", + "uiActions", + "visualizations" + ], + "optionalPlugins": [ + "home", + "share" + ], + "requiredBundles": [ + "opensearchDashboardsUtils", + "savedObjects", + "opensearchDashboardsReact", + "discover" + ] +} \ No newline at end of file diff --git a/src/plugins/discover/public/application/_discover.scss b/src/plugins/discover_legacy/public/application/_discover.scss similarity index 100% rename from src/plugins/discover/public/application/_discover.scss rename to src/plugins/discover_legacy/public/application/_discover.scss diff --git a/src/plugins/discover/public/application/angular/_index.scss b/src/plugins/discover_legacy/public/application/angular/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/_index.scss rename to src/plugins/discover_legacy/public/application/angular/_index.scss diff --git a/src/plugins/discover/public/application/angular/context.html b/src/plugins/discover_legacy/public/application/angular/context.html similarity index 100% rename from src/plugins/discover/public/application/angular/context.html rename to src/plugins/discover_legacy/public/application/angular/context.html diff --git a/src/plugins/discover/public/application/angular/context.js b/src/plugins/discover_legacy/public/application/angular/context.js similarity index 100% rename from src/plugins/discover/public/application/angular/context.js rename to src/plugins/discover_legacy/public/application/angular/context.js diff --git a/src/plugins/discover/public/application/angular/context/NOTES.md b/src/plugins/discover_legacy/public/application/angular/context/NOTES.md similarity index 100% rename from src/plugins/discover/public/application/angular/context/NOTES.md rename to src/plugins/discover_legacy/public/application/angular/context/NOTES.md diff --git a/src/plugins/discover/public/application/angular/context/_index.scss b/src/plugins/discover_legacy/public/application/angular/context/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/context/_index.scss rename to src/plugins/discover_legacy/public/application/angular/context/_index.scss diff --git a/src/plugins/discover/public/application/angular/context/api/_stubs.js b/src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/_stubs.js rename to src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js diff --git a/src/plugins/discover/public/application/angular/context/api/anchor.js b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/anchor.js rename to src/plugins/discover_legacy/public/application/angular/context/api/anchor.js diff --git a/src/plugins/discover/public/application/angular/context/api/anchor.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/anchor.test.js rename to src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js diff --git a/src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js rename to src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js diff --git a/src/plugins/discover/public/application/angular/context/api/context.successors.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/context.successors.test.js rename to src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js diff --git a/src/plugins/discover/public/application/angular/context/api/context.ts b/src/plugins/discover_legacy/public/application/angular/context/api/context.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/context.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/context.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/date_conversion.test.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/date_conversion.test.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.test.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/date_conversion.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/date_conversion.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/fetch_hits_in_interval.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/fetch_hits_in_interval.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/generate_intervals.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/generate_intervals.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/get_opensearch_query_sort.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/get_opensearch_query_sort.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/sorting.test.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/sorting.test.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/sorting.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/sorting.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/_action_bar.scss b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_action_bar.scss similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/_action_bar.scss rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_action_bar.scss diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/_index.scss b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/_index.scss rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_index.scss diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.test.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.test.tsx rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.tsx rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_directive.ts b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_directive.ts rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_warning.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_warning.tsx rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/index.ts b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/index.ts rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts diff --git a/src/plugins/discover/public/application/angular/context/helpers/call_after_bindings_workaround.js b/src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/helpers/call_after_bindings_workaround.js rename to src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js diff --git a/src/plugins/discover/public/application/angular/context/query/actions.js b/src/plugins/discover_legacy/public/application/angular/context/query/actions.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query/actions.js rename to src/plugins/discover_legacy/public/application/angular/context/query/actions.js diff --git a/src/plugins/discover/public/application/angular/context/query/constants.js b/src/plugins/discover_legacy/public/application/angular/context/query/constants.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query/constants.js rename to src/plugins/discover_legacy/public/application/angular/context/query/constants.js diff --git a/src/plugins/discover/public/application/angular/context/query/index.js b/src/plugins/discover_legacy/public/application/angular/context/query/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query/index.js rename to src/plugins/discover_legacy/public/application/angular/context/query/index.js diff --git a/src/plugins/discover/public/application/angular/context/query/state.js b/src/plugins/discover_legacy/public/application/angular/context/query/state.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query/state.js rename to src/plugins/discover_legacy/public/application/angular/context/query/state.js diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/actions.js b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/actions.js rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/actions.test.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/actions.test.ts rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/constants.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/constants.ts rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/index.js b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/index.js rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/state.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/state.ts rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts diff --git a/src/plugins/discover/public/application/angular/context_app.html b/src/plugins/discover_legacy/public/application/angular/context_app.html similarity index 100% rename from src/plugins/discover/public/application/angular/context_app.html rename to src/plugins/discover_legacy/public/application/angular/context_app.html diff --git a/src/plugins/discover/public/application/angular/context_app.js b/src/plugins/discover_legacy/public/application/angular/context_app.js similarity index 100% rename from src/plugins/discover/public/application/angular/context_app.js rename to src/plugins/discover_legacy/public/application/angular/context_app.js diff --git a/src/plugins/discover/public/application/angular/context_state.test.ts b/src/plugins/discover_legacy/public/application/angular/context_state.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context_state.test.ts rename to src/plugins/discover_legacy/public/application/angular/context_state.test.ts diff --git a/src/plugins/discover/public/application/angular/context_state.ts b/src/plugins/discover_legacy/public/application/angular/context_state.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context_state.ts rename to src/plugins/discover_legacy/public/application/angular/context_state.ts diff --git a/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap b/src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap similarity index 100% rename from src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap rename to src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap diff --git a/src/plugins/discover/public/application/angular/directives/_histogram.scss b/src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss similarity index 100% rename from src/plugins/discover/public/application/angular/directives/_histogram.scss rename to src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss diff --git a/src/plugins/discover/public/application/angular/directives/_index.scss b/src/plugins/discover_legacy/public/application/angular/directives/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/directives/_index.scss rename to src/plugins/discover_legacy/public/application/angular/directives/_index.scss diff --git a/src/plugins/discover/public/application/angular/directives/_no_results.scss b/src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss similarity index 100% rename from src/plugins/discover/public/application/angular/directives/_no_results.scss rename to src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss diff --git a/src/plugins/discover/public/application/angular/directives/debounce/debounce.js b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/debounce/debounce.js rename to src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js diff --git a/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts rename to src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts diff --git a/src/plugins/discover/public/application/angular/directives/debounce/index.js b/src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/debounce/index.js rename to src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js diff --git a/src/plugins/discover/public/application/angular/directives/fixed_scroll.js b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/fixed_scroll.js rename to src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js diff --git a/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js rename to src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.test.js diff --git a/src/plugins/discover/public/application/angular/directives/histogram.tsx b/src/plugins/discover_legacy/public/application/angular/directives/histogram.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/directives/histogram.tsx rename to src/plugins/discover_legacy/public/application/angular/directives/histogram.tsx diff --git a/src/plugins/discover/public/application/angular/directives/index.js b/src/plugins/discover_legacy/public/application/angular/directives/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/index.js rename to src/plugins/discover_legacy/public/application/angular/directives/index.js diff --git a/src/plugins/discover/public/application/angular/directives/no_results.js b/src/plugins/discover_legacy/public/application/angular/directives/no_results.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/no_results.js rename to src/plugins/discover_legacy/public/application/angular/directives/no_results.js diff --git a/src/plugins/discover/public/application/angular/directives/no_results.test.js b/src/plugins/discover_legacy/public/application/angular/directives/no_results.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/no_results.test.js rename to src/plugins/discover_legacy/public/application/angular/directives/no_results.test.js diff --git a/src/plugins/discover/public/application/angular/directives/render_complete.ts b/src/plugins/discover_legacy/public/application/angular/directives/render_complete.ts similarity index 100% rename from src/plugins/discover/public/application/angular/directives/render_complete.ts rename to src/plugins/discover_legacy/public/application/angular/directives/render_complete.ts diff --git a/src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx b/src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx new file mode 100644 index 000000000000..9cc47b034d1e --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx @@ -0,0 +1,78 @@ +/* + * 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 { FormattedMessage, I18nProvider } from '@osd/i18n/react'; + +import { EuiButton, EuiEmptyPrompt, EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; + +interface Props { + onRefresh: () => void; +} + +export const DiscoverUninitialized = ({ onRefresh }: Props) => { + return ( + + + + + + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> +
+
+
+
+ ); +}; diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover_legacy/public/application/angular/discover.js similarity index 98% rename from src/plugins/discover/public/application/angular/discover.js rename to src/plugins/discover_legacy/public/application/angular/discover.js index de244e3c44b6..f8a96928784c 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover_legacy/public/application/angular/discover.js @@ -93,6 +93,7 @@ import { DOC_HIDE_TIME_COLUMN_SETTING, MODIFY_COLUMNS_ON_SWITCH, } from '../../../common'; +import { NEW_DISCOVER_APP } from '../../../../discover/public'; const fetchStatuses = { UNINITIALIZED: 'uninitialized', @@ -480,7 +481,24 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }, }; + const newDiscover = { + id: 'discover-new', + label: i18n.translate('discover.localMenu.newDiscoverTitle', { + defaultMessage: 'New Discover', + }), + description: i18n.translate('discover.localMenu.newDiscoverDescription', { + defaultMessage: 'New Discover Experience', + }), + testId: 'discoverNewButton', + run: async function () { + await getServices().uiSettings.set(NEW_DISCOVER_APP, true); + window.location.reload(); + }, + type: 'toggle', + }; + return [ + newDiscover, newSearch, ...(uiCapabilities.discover.save ? [saveSearch] : []), openSearch, diff --git a/src/plugins/discover/public/application/angular/discover_legacy.html b/src/plugins/discover_legacy/public/application/angular/discover_legacy.html similarity index 100% rename from src/plugins/discover/public/application/angular/discover_legacy.html rename to src/plugins/discover_legacy/public/application/angular/discover_legacy.html diff --git a/src/plugins/discover/public/application/angular/discover_state.test.ts b/src/plugins/discover_legacy/public/application/angular/discover_state.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/discover_state.test.ts rename to src/plugins/discover_legacy/public/application/angular/discover_state.test.ts diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover_legacy/public/application/angular/discover_state.ts similarity index 100% rename from src/plugins/discover/public/application/angular/discover_state.ts rename to src/plugins/discover_legacy/public/application/angular/discover_state.ts diff --git a/src/plugins/discover/public/application/angular/doc.html b/src/plugins/discover_legacy/public/application/angular/doc.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc.html rename to src/plugins/discover_legacy/public/application/angular/doc.html diff --git a/src/plugins/discover/public/application/angular/doc.ts b/src/plugins/discover_legacy/public/application/angular/doc.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc.ts rename to src/plugins/discover_legacy/public/application/angular/doc.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/_doc_table.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/_doc_table.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/_doc_table.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/_doc_table.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/actions/columns.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/actions/columns.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/actions/columns.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/actions/columns.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/components/_index.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/_index.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/_index.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/_table_header.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/_table_header.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/_table_header.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/_table_header.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/index.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/index.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/index.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js b/src/plugins/discover_legacy/public/application/angular/doc_table/components/row_headers.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/row_headers.test.js diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/helpers.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/helpers.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/helpers.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/helpers.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.test.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.test.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header_column.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header_column.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header_column.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header_column.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/_cell.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_cell.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/_cell.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_cell.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/_details.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_details.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/_details.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_details.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/_index.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/_index.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_index.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/_open.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/_open.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/cell.html b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/cell.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/cell.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/cell.html diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/details.html b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/details.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/details.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/details.html diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/open.html b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/open.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/open.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/open.html diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/truncate_by_height.html b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/truncate_by_height.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/truncate_by_height.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/truncate_by_height.html diff --git a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/create_doc_table_react.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/create_doc_table_react.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.html b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/doc_table.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.html diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/doc_table.test.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.test.js diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/doc_table.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table_strings.js b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table_strings.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/doc_table_strings.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/doc_table_strings.js diff --git a/src/plugins/discover/public/application/angular/doc_table/index.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/index.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/index.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/index.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/index.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/index.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/infinite_scroll.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/infinite_scroll.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/infinite_scroll.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/infinite_scroll.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_default_sort.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_default_sort.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.test.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort_for_search_source.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort_for_search_source.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/pager/index.js b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/pager/index.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/index.js diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/pager/pager.js b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/pager/pager.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager.js diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/pager/pager_factory.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager_factory.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/pager/pager_factory.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager_factory.ts diff --git a/src/plugins/discover/public/application/angular/doc_viewer.tsx b/src/plugins/discover_legacy/public/application/angular/doc_viewer.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_viewer.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_viewer.tsx diff --git a/src/plugins/discover/public/application/angular/doc_viewer_links.tsx b/src/plugins/discover_legacy/public/application/angular/doc_viewer_links.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_viewer_links.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_viewer_links.tsx diff --git a/src/plugins/discover/public/application/angular/helpers/index.ts b/src/plugins/discover_legacy/public/application/angular/helpers/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/helpers/index.ts rename to src/plugins/discover_legacy/public/application/angular/helpers/index.ts diff --git a/src/plugins/discover/public/application/angular/helpers/point_series.ts b/src/plugins/discover_legacy/public/application/angular/helpers/point_series.ts similarity index 100% rename from src/plugins/discover/public/application/angular/helpers/point_series.ts rename to src/plugins/discover_legacy/public/application/angular/helpers/point_series.ts diff --git a/src/plugins/discover/public/application/angular/index.ts b/src/plugins/discover_legacy/public/application/angular/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/index.ts rename to src/plugins/discover_legacy/public/application/angular/index.ts diff --git a/src/plugins/discover/public/application/angular/redirect.ts b/src/plugins/discover_legacy/public/application/angular/redirect.ts similarity index 100% rename from src/plugins/discover/public/application/angular/redirect.ts rename to src/plugins/discover_legacy/public/application/angular/redirect.ts diff --git a/src/plugins/discover/public/application/angular/response_handler.js b/src/plugins/discover_legacy/public/application/angular/response_handler.js similarity index 100% rename from src/plugins/discover/public/application/angular/response_handler.js rename to src/plugins/discover_legacy/public/application/angular/response_handler.js diff --git a/src/plugins/discover/public/application/application.ts b/src/plugins/discover_legacy/public/application/application.ts similarity index 100% rename from src/plugins/discover/public/application/application.ts rename to src/plugins/discover_legacy/public/application/application.ts diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx b/src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.test.tsx similarity index 100% rename from src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx rename to src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.test.tsx diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx b/src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.tsx similarity index 100% rename from src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx rename to src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.tsx diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts b/src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message_directive.ts similarity index 100% rename from src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts rename to src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message_directive.ts diff --git a/src/plugins/discover/public/application/components/context_error_message/index.ts b/src/plugins/discover_legacy/public/application/components/context_error_message/index.ts similarity index 100% rename from src/plugins/discover/public/application/components/context_error_message/index.ts rename to src/plugins/discover_legacy/public/application/components/context_error_message/index.ts diff --git a/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts b/src/plugins/discover_legacy/public/application/components/create_discover_legacy_directive.ts similarity index 100% rename from src/plugins/discover/public/application/components/create_discover_legacy_directive.ts rename to src/plugins/discover_legacy/public/application/components/create_discover_legacy_directive.ts diff --git a/src/plugins/discover/public/application/components/discover_legacy.tsx b/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx similarity index 95% rename from src/plugins/discover/public/application/components/discover_legacy.tsx rename to src/plugins/discover_legacy/public/application/components/discover_legacy.tsx index 3e6e96fc6124..d458e4339d3b 100644 --- a/src/plugins/discover/public/application/components/discover_legacy.tsx +++ b/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx @@ -166,21 +166,23 @@ export function DiscoverLegacy({

{savedSearch.title}

- +
+ +
{ + let registry: any[] = []; + + return { + getServices: () => ({ + metadata: { + branch: 'test', + }, + data: { + search: { + search: mockSearchApi, + }, + }, + }), + getDocViewsRegistry: () => ({ + addDocView(view: any) { + registry.push(view); + }, + getDocViewsSorted() { + return registry; + }, + resetRegistry: () => { + registry = []; + }, + }), + getDocViewsLinksRegistry: () => ({ + addDocViewLink(view: any) { + registry.push(view); + }, + getDocViewsLinksSorted() { + return registry; + }, + resetRegistry: () => { + registry = []; + }, + }), + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +const waitForPromises = async () => + act(async () => { + await new Promise((resolve) => setTimeout(resolve)); + }); + +/** + * this works but logs ugly error messages until we're using React 16.9 + * should be adapted when we upgrade + */ +async function mountDoc(update = false, indexPatternGetter: any = null) { + const indexPattern = { + getComputedFields: () => [], + }; + const indexPatternService = { + get: indexPatternGetter ? indexPatternGetter : jest.fn(() => Promise.resolve(indexPattern)), + } as any; + + const props = { + id: '1', + index: 'index1', + indexPatternId: 'xyz', + indexPatternService, + } as DocProps; + let comp!: ReactWrapper; + await act(async () => { + comp = mountWithIntl(); + if (update) comp.update(); + }); + if (update) { + await waitForPromises(); + comp.update(); + } + return comp; +} + +describe('Test of of Discover', () => { + test('renders loading msg', async () => { + const comp = await mountDoc(); + expect(findTestSubject(comp, 'doc-msg-loading').length).toBe(1); + }); + + test('renders IndexPattern notFound msg', async () => { + const indexPatternGetter = jest.fn(() => Promise.reject({ savedObjectId: '007' })); + const comp = await mountDoc(true, indexPatternGetter); + expect(findTestSubject(comp, 'doc-msg-notFoundIndexPattern').length).toBe(1); + }); + + test('renders notFound msg', async () => { + mockSearchApi.mockImplementation(() => throwError({ status: 404 })); + const comp = await mountDoc(true); + expect(findTestSubject(comp, 'doc-msg-notFound').length).toBe(1); + }); + + test('renders error msg', async () => { + mockSearchApi.mockImplementation(() => throwError({ error: 'something else' })); + const comp = await mountDoc(true); + expect(findTestSubject(comp, 'doc-msg-error').length).toBe(1); + }); + + test('renders opensearch hit ', async () => { + mockSearchApi.mockImplementation(() => + of({ rawResponse: { hits: { total: 1, hits: [{ _id: 1, _source: { test: 1 } }] } } }) + ); + const comp = await mountDoc(true); + expect(findTestSubject(comp, 'doc-hit').length).toBe(1); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/doc/doc.tsx b/src/plugins/discover_legacy/public/application/components/doc/doc.tsx new file mode 100644 index 000000000000..204a16d64757 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc/doc.tsx @@ -0,0 +1,140 @@ +/* + * 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 { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { EuiCallOut, EuiLink, EuiLoadingSpinner, EuiPageContent } from '@elastic/eui'; +import { IndexPatternsContract } from 'src/plugins/data/public'; +import { OpenSearchRequestState, useOpenSearchDocSearch } from './use_opensearch_doc_search'; +import { DocViewer } from '../doc_viewer/doc_viewer'; + +export interface DocProps { + /** + * Id of the doc in OpenSearch + */ + id: string; + /** + * Index in OpenSearch to query + */ + index: string; + /** + * IndexPattern ID used to get IndexPattern entity + * that's used for adding additional fields (stored_fields, script_fields, docvalue_fields) + */ + indexPatternId: string; + /** + * IndexPatternService to get a given index pattern by ID + */ + indexPatternService: IndexPatternsContract; +} + +export function Doc(props: DocProps) { + const [reqState, hit, indexPattern] = useOpenSearchDocSearch(props); + return ( + + + {reqState === OpenSearchRequestState.NotFoundIndexPattern && ( + + } + /> + )} + {reqState === OpenSearchRequestState.NotFound && ( + + } + > + + + )} + + {reqState === OpenSearchRequestState.Error && ( + + } + > + {' '} + + + + + )} + + {reqState === OpenSearchRequestState.Loading && ( + + {' '} + + + )} + + {reqState === OpenSearchRequestState.Found && hit !== null && indexPattern && ( +
+ +
+ )} +
+
+ ); +} diff --git a/src/plugins/discover_legacy/public/application/components/doc/use_opensearch_doc_search.test.tsx b/src/plugins/discover_legacy/public/application/components/doc/use_opensearch_doc_search.test.tsx new file mode 100644 index 000000000000..cb716a4f17cb --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc/use_opensearch_doc_search.test.tsx @@ -0,0 +1,98 @@ +/* + * 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 { renderHook, act } from '@testing-library/react-hooks'; +import { + buildSearchBody, + useOpenSearchDocSearch, + OpenSearchRequestState, +} from './use_opensearch_doc_search'; +import { DocProps } from './doc'; +import { Observable } from 'rxjs'; + +const mockSearchResult = new Observable(); + +jest.mock('../../../opensearch_dashboards_services', () => ({ + getServices: () => ({ + data: { + search: { + search: jest.fn(() => { + return mockSearchResult; + }), + }, + }, + }), +})); + +describe('Test of helper / hook', () => { + test('buildSearchBody', () => { + const indexPattern = { + getComputedFields: () => ({ storedFields: [], scriptFields: [], docvalueFields: [] }), + } as any; + const actual = buildSearchBody('1', indexPattern); + expect(actual).toMatchInlineSnapshot(` + Object { + "_source": true, + "docvalue_fields": Array [], + "query": Object { + "ids": Object { + "values": Array [ + "1", + ], + }, + }, + "script_fields": Array [], + "stored_fields": Array [], + } + `); + }); + + test('useOpenSearchDocSearch', async () => { + const indexPattern = { + getComputedFields: () => [], + }; + const indexPatternService = { + get: jest.fn(() => Promise.resolve(indexPattern)), + } as any; + const props = { + id: '1', + index: 'index1', + indexPatternId: 'xyz', + indexPatternService, + } as DocProps; + let hook; + await act(async () => { + hook = renderHook((p: DocProps) => useOpenSearchDocSearch(p), { initialProps: props }); + }); + // @ts-ignore + expect(hook.result.current).toEqual([OpenSearchRequestState.Loading, null, indexPattern]); + expect(indexPatternService.get).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/doc/use_opensearch_doc_search.ts b/src/plugins/discover_legacy/public/application/components/doc/use_opensearch_doc_search.ts new file mode 100644 index 000000000000..b5ca9fec1c2f --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc/use_opensearch_doc_search.ts @@ -0,0 +1,114 @@ +/* + * 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 { useEffect, useState } from 'react'; +import { IndexPattern, getServices } from '../../../opensearch_dashboards_services'; +import { DocProps } from './doc'; +import { OpenSearchSearchHit } from '../../doc_views/doc_views_types'; + +export enum OpenSearchRequestState { + Loading, + NotFound, + Found, + Error, + NotFoundIndexPattern, +} + +/** + * helper function to build a query body for OpenSearch + * https://opensearch.org/docs/latest/opensearch/query-dsl/index/ + */ +export function buildSearchBody(id: string, indexPattern: IndexPattern): Record { + const computedFields = indexPattern.getComputedFields(); + + return { + query: { + ids: { + values: [id], + }, + }, + stored_fields: computedFields.storedFields, + _source: true, + script_fields: computedFields.scriptFields, + docvalue_fields: computedFields.docvalueFields, + }; +} + +/** + * Custom react hook for querying a single doc in OpenSearch + */ +export function useOpenSearchDocSearch({ + id, + index, + indexPatternId, + indexPatternService, +}: DocProps): [OpenSearchRequestState, OpenSearchSearchHit | null, IndexPattern | null] { + const [indexPattern, setIndexPattern] = useState(null); + const [status, setStatus] = useState(OpenSearchRequestState.Loading); + const [hit, setHit] = useState(null); + + useEffect(() => { + async function requestData() { + try { + const indexPatternEntity = await indexPatternService.get(indexPatternId); + setIndexPattern(indexPatternEntity); + + const { rawResponse } = await getServices() + .data.search.search({ + dataSourceId: indexPatternEntity.dataSourceRef?.id, + params: { + index, + body: buildSearchBody(id, indexPatternEntity), + }, + }) + .toPromise(); + + const hits = rawResponse.hits; + + if (hits?.hits?.[0]) { + setStatus(OpenSearchRequestState.Found); + setHit(hits.hits[0]); + } else { + setStatus(OpenSearchRequestState.NotFound); + } + } catch (err) { + if (err.savedObjectId) { + setStatus(OpenSearchRequestState.NotFoundIndexPattern); + } else if (err.status === 404) { + setStatus(OpenSearchRequestState.NotFound); + } else { + setStatus(OpenSearchRequestState.Error); + } + } + } + requestData(); + }, [id, index, indexPatternId, indexPatternService]); + return [status, hit, indexPattern]; +} diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap b/src/plugins/discover_legacy/public/application/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap new file mode 100644 index 000000000000..cc1647fe264e --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap @@ -0,0 +1,56 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Render with 3 different tabs 1`] = ` +
+ , + "id": "osd_doc_viewer_tab_0", + "name": "Render function", + }, + Object { + "content": , + "id": "osd_doc_viewer_tab_1", + "name": "React component", + }, + Object { + "content": , + "id": "osd_doc_viewer_tab_2", + "name": "Invalid doc view", + }, + ] + } + /> +
+`; diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap b/src/plugins/discover_legacy/public/application/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap new file mode 100644 index 000000000000..31509659ce41 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Mounting and unmounting DocViewerRenderTab 1`] = ` +[MockFunction] { + "calls": Array [ + Array [ +
, + Object { + "hit": Object {}, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": [MockFunction], + }, + ], +} +`; diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.scss b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.scss new file mode 100644 index 000000000000..91b66fc84297 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.scss @@ -0,0 +1,72 @@ +.osdDocViewerTable { + margin-top: $euiSizeS; +} + +.osdDocViewer { + pre, + .osdDocViewer__value { + display: inline-block; + word-break: break-all; + word-wrap: break-word; + white-space: pre-wrap; + color: $euiColorFullShade; + vertical-align: top; + padding-top: 2px; + } + + .osdDocViewer__field { + padding-top: 8px; + } + + .dscFieldName { + color: $euiColorDarkShade; + } + + td, + pre { + font-family: $euiCodeFontFamily; + } + + tr:first-child td { + border-top-color: transparent; + } + + tr:hover { + .osdDocViewer__actionButton { + opacity: 1; + } + } +} + +.osdDocViewer__buttons, +.osdDocViewer__field { + white-space: nowrap; +} + +.osdDocViewer__buttons { + width: 60px; + + // Show all icons if one is focused, + // IE doesn't support, but the fallback is just the focused button becomes visible + &:focus-within { + .osdDocViewer__actionButton { + opacity: 1; + } + } +} + +.osdDocViewer__field { + width: 160px; +} + +.osdDocViewer__actionButton { + opacity: 0; + + &:focus { + opacity: 1; + } +} + +.osdDocViewer__warning { + margin-right: $euiSizeS; +} diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.test.tsx b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.test.tsx new file mode 100644 index 000000000000..ccab0be41ed2 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.test.tsx @@ -0,0 +1,94 @@ +/* + * 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 { mount, shallow } from 'enzyme'; +import { DocViewer } from './doc_viewer'; +import { findTestSubject } from 'test_utils/helpers'; +import { getDocViewsRegistry } from '../../../opensearch_dashboards_services'; +import { DocViewRenderProps } from '../../doc_views/doc_views_types'; + +jest.mock('../../../opensearch_dashboards_services', () => { + let registry: any[] = []; + return { + getDocViewsRegistry: () => ({ + addDocView(view: any) { + registry.push(view); + }, + getDocViewsSorted() { + return registry; + }, + resetRegistry: () => { + registry = []; + }, + }), + }; +}); + +beforeEach(() => { + (getDocViewsRegistry() as any).resetRegistry(); + jest.clearAllMocks(); +}); + +test('Render with 3 different tabs', () => { + const registry = getDocViewsRegistry(); + registry.addDocView({ order: 10, title: 'Render function', render: jest.fn() }); + registry.addDocView({ order: 20, title: 'React component', component: () =>
test
}); + registry.addDocView({ order: 30, title: 'Invalid doc view' }); + + const renderProps = { hit: {} } as DocViewRenderProps; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); +}); + +test('Render with 1 tab displaying error message', () => { + function SomeComponent() { + // this is just a placeholder + return null; + } + + const registry = getDocViewsRegistry(); + registry.addDocView({ + order: 10, + title: 'React component', + component: SomeComponent, + }); + + const renderProps = { hit: {} } as DocViewRenderProps; + const errorMsg = 'Catch me if you can!'; + + const wrapper = mount(); + const error = new Error(errorMsg); + wrapper.find(SomeComponent).simulateError(error); + const errorMsgComponent = findTestSubject(wrapper, 'docViewerError'); + expect(errorMsgComponent.text()).toMatch(new RegExp(`${errorMsg}`)); +}); diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.tsx b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.tsx new file mode 100644 index 000000000000..d165c9bd05b8 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.tsx @@ -0,0 +1,75 @@ +/* + * 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 './doc_viewer.scss'; +import React from 'react'; +import { EuiTabbedContent } from '@elastic/eui'; +import { getDocViewsRegistry } from '../../../opensearch_dashboards_services'; +import { DocViewerTab } from './doc_viewer_tab'; +import { DocView, DocViewRenderProps } from '../../doc_views/doc_views_types'; + +/** + * Rendering tabs with different views of 1 OpenSearch hit in Discover. + * The tabs are provided by the `docs_views` registry. + * A view can contain a React `component`, or any JS framework by using + * a `render` function. + */ +export function DocViewer(renderProps: DocViewRenderProps) { + const docViewsRegistry = getDocViewsRegistry(); + const tabs = docViewsRegistry + .getDocViewsSorted(renderProps.hit) + .map(({ title, render, component }: DocView, idx: number) => { + return { + id: `osd_doc_viewer_tab_${idx}`, + name: title, + content: ( + + ), + }; + }); + + if (!tabs.length) { + // There there's a minimum of 2 tabs active in Discover. + // This condition takes care of unit tests with 0 tabs. + return null; + } + + return ( +
+ +
+ ); +} diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_error.tsx b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_error.tsx new file mode 100644 index 000000000000..1cb14d191a57 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_error.tsx @@ -0,0 +1,48 @@ +/* + * 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 { EuiCallOut, EuiCodeBlock } from '@elastic/eui'; +import { formatMsg, formatStack } from '../../../../../opensearch_dashboards_legacy/public'; + +interface Props { + error: Error | string; +} + +export function DocViewerError({ error }: Props) { + const errMsg = formatMsg(error); + const errStack = typeof error === 'object' ? formatStack(error) : ''; + + return ( + + {errStack && {errStack}} + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx new file mode 100644 index 000000000000..83d857b24fc5 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 { mount } from 'enzyme'; +import { DocViewRenderTab } from './doc_viewer_render_tab'; +import { DocViewRenderProps } from '../../doc_views/doc_views_types'; + +test('Mounting and unmounting DocViewerRenderTab', () => { + const unmountFn = jest.fn(); + const renderFn = jest.fn(() => unmountFn); + const renderProps = { + hit: {}, + }; + + const wrapper = mount( + + ); + + expect(renderFn).toMatchSnapshot(); + + wrapper.unmount(); + + expect(unmountFn).toBeCalled(); +}); diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_tab.tsx b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_tab.tsx new file mode 100644 index 000000000000..edc7f40c5e43 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_tab.tsx @@ -0,0 +1,52 @@ +/* + * 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, { useRef, useEffect } from 'react'; +import { DocViewRenderFn, DocViewRenderProps } from '../../doc_views/doc_views_types'; + +interface Props { + render: DocViewRenderFn; + renderProps: DocViewRenderProps; +} +/** + * Responsible for rendering a tab provided by a render function. + * So any other framework can be used (E.g. legacy Angular 3rd party plugin code) + * The provided `render` function is called with a reference to the + * component's `HTMLDivElement` as 1st arg and `renderProps` as 2nd arg + */ +export function DocViewRenderTab({ render, renderProps }: Props) { + const ref = useRef(null); + useEffect(() => { + if (ref && ref.current) { + return render(ref.current, renderProps); + } + }, [render, renderProps]); + return
; +} diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_tab.tsx b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_tab.tsx new file mode 100644 index 000000000000..6e7a5f1ac434 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_tab.tsx @@ -0,0 +1,101 @@ +/* + * 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 { I18nProvider } from '@osd/i18n/react'; +import { DocViewRenderTab } from './doc_viewer_render_tab'; +import { DocViewerError } from './doc_viewer_render_error'; +import { DocViewRenderFn, DocViewRenderProps } from '../../doc_views/doc_views_types'; + +interface Props { + component?: React.ComponentType; + id: number; + render?: DocViewRenderFn; + renderProps: DocViewRenderProps; + title: string; +} + +interface State { + error: Error | string; + hasError: boolean; +} +/** + * Renders the tab content of a doc view. + * Displays an error message when it encounters exceptions, thanks to + * Error Boundaries. + */ +export class DocViewerTab extends React.Component { + state = { + hasError: false, + error: '', + }; + + static getDerivedStateFromError(error: unknown) { + // Update state so the next render will show the fallback UI. + return { hasError: true, error }; + } + + shouldComponentUpdate(nextProps: Props, nextState: State) { + return ( + nextProps.renderProps.hit._id !== this.props.renderProps.hit._id || + nextProps.id !== this.props.id || + nextState.hasError + ); + } + + render() { + const { component, render, renderProps, title } = this.props; + const { hasError, error } = this.state; + + if (hasError && error) { + return ; + } else if (!render && !component) { + return ( + + ); + } + + if (render) { + // doc view is provided by a render function, e.g. for legacy Angular code + return ; + } + + // doc view is provided by a react component + + const Component = component as any; + return ( + + + + ); + } +} diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap b/src/plugins/discover_legacy/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap new file mode 100644 index 000000000000..95fb0c377180 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Dont Render if generateCb.hide 1`] = ` + +`; + +exports[`Render with 2 different links 1`] = ` + + + + + + + + +`; diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx b/src/plugins/discover_legacy/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx new file mode 100644 index 000000000000..8aba555b3a37 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx @@ -0,0 +1,68 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { shallow } from 'enzyme'; +import { DocViewerLinks } from './doc_viewer_links'; +import { getDocViewsLinksRegistry } from '../../../opensearch_dashboards_services'; +import { DocViewLinkRenderProps } from '../../doc_views_links/doc_views_links_types'; + +jest.mock('../../../opensearch_dashboards_services', () => { + let registry: any[] = []; + return { + getDocViewsLinksRegistry: () => ({ + addDocViewLink(view: any) { + registry.push(view); + }, + getDocViewsLinksSorted() { + return registry; + }, + resetRegistry: () => { + registry = []; + }, + }), + }; +}); + +beforeEach(() => { + (getDocViewsLinksRegistry() as any).resetRegistry(); + jest.clearAllMocks(); +}); + +test('Render with 2 different links', () => { + const registry = getDocViewsLinksRegistry(); + registry.addDocViewLink({ + order: 10, + label: 'generateCb link', + generateCb: () => ({ + url: 'aaa', + }), + }); + registry.addDocViewLink({ order: 20, label: 'href link', href: 'bbb' }); + + const renderProps = { hit: {} } as DocViewLinkRenderProps; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); +}); + +test('Dont Render if generateCb.hide', () => { + const registry = getDocViewsLinksRegistry(); + registry.addDocViewLink({ + order: 10, + label: 'generateCb link', + generateCb: () => ({ + url: 'aaa', + hide: true, + }), + }); + + const renderProps = { hit: {} } as DocViewLinkRenderProps; + + const wrapper = shallow(); + + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/plugins/discover_legacy/public/application/components/doc_viewer_links/doc_viewer_links.tsx b/src/plugins/discover_legacy/public/application/components/doc_viewer_links/doc_viewer_links.tsx new file mode 100644 index 000000000000..9efb0693fde6 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/doc_viewer_links/doc_viewer_links.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiFlexGroup, EuiFlexItem, EuiListGroupItem, EuiListGroupItemProps } from '@elastic/eui'; +import { getDocViewsLinksRegistry } from '../../../opensearch_dashboards_services'; +import { DocViewLinkRenderProps } from '../../doc_views_links/doc_views_links_types'; + +export function DocViewerLinks(renderProps: DocViewLinkRenderProps) { + const listItems = getDocViewsLinksRegistry() + .getDocViewsLinksSorted() + .filter((item) => !(item.generateCb && item.generateCb(renderProps)?.hide)) + .map((item) => { + const { generateCb, href, ...props } = item; + const listItem: EuiListGroupItemProps = { + 'data-test-subj': 'docTableRowAction', + ...props, + href: generateCb ? generateCb(renderProps).url : href, + }; + + return listItem; + }); + + return ( + + {listItems.map((item, index) => ( + + + + ))} + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/field_name/__snapshots__/field_name.test.tsx.snap b/src/plugins/discover_legacy/public/application/components/field_name/__snapshots__/field_name.test.tsx.snap new file mode 100644 index 000000000000..cfd81a66acae --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/field_name/__snapshots__/field_name.test.tsx.snap @@ -0,0 +1,94 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`FieldName renders a geo field, useShortDots is set to true 1`] = ` +
+
+ + + +
+
+ + + t.t.test + + +
+
+`; + +exports[`FieldName renders a number field by providing a field record, useShortDots is set to false 1`] = ` +
+
+ + + +
+
+ + + test.test.test + + +
+
+`; + +exports[`FieldName renders a string field by providing fieldType and fieldName 1`] = ` +
+
+ + + +
+
+ + + test + + +
+
+`; diff --git a/src/plugins/discover_legacy/public/application/components/field_name/field_name.test.tsx b/src/plugins/discover_legacy/public/application/components/field_name/field_name.test.tsx new file mode 100644 index 000000000000..54dc902837d0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/field_name/field_name.test.tsx @@ -0,0 +1,52 @@ +/* + * 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 { render } from 'enzyme'; +import { FieldName } from './field_name'; + +// Note that it currently provides just 2 basic tests, there should be more, but +// the components involved will soon change +test('FieldName renders a string field by providing fieldType and fieldName', () => { + const component = render(); + expect(component).toMatchSnapshot(); +}); + +test('FieldName renders a number field by providing a field record, useShortDots is set to false', () => { + const component = render(); + expect(component).toMatchSnapshot(); +}); + +test('FieldName renders a geo field, useShortDots is set to true', () => { + const component = render( + + ); + expect(component).toMatchSnapshot(); +}); diff --git a/src/plugins/discover_legacy/public/application/components/field_name/field_name.tsx b/src/plugins/discover_legacy/public/application/components/field_name/field_name.tsx new file mode 100644 index 000000000000..bbd9ab79d0fb --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/field_name/field_name.tsx @@ -0,0 +1,75 @@ +/* + * 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 { EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; + +import { FieldIcon, FieldIconProps } from '../../../../../opensearch_dashboards_react/public'; +import { shortenDottedString } from '../../helpers'; +import { getFieldTypeName } from './field_type_name'; + +// properties fieldType and fieldName are provided in osd_doc_view +// this should be changed when both components are deangularized +interface Props { + fieldName: string; + fieldType: string; + useShortDots?: boolean; + fieldIconProps?: Omit; + scripted?: boolean; +} + +export function FieldName({ + fieldName, + fieldType, + useShortDots, + fieldIconProps, + scripted = false, +}: Props) { + const typeName = getFieldTypeName(fieldType); + const displayName = useShortDots ? shortenDottedString(fieldName) : fieldName; + + return ( + + + + + + + {displayName} + + + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/field_name/field_type_name.ts b/src/plugins/discover_legacy/public/application/components/field_name/field_type_name.ts new file mode 100644 index 000000000000..38b18792d3e4 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/field_name/field_type_name.ts @@ -0,0 +1,85 @@ +/* + * 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 { i18n } from '@osd/i18n'; + +export function getFieldTypeName(type: string) { + switch (type) { + case 'boolean': + return i18n.translate('discover.fieldNameIcons.booleanAriaLabel', { + defaultMessage: 'Boolean field', + }); + case 'conflict': + return i18n.translate('discover.fieldNameIcons.conflictFieldAriaLabel', { + defaultMessage: 'Conflicting field', + }); + case 'date': + return i18n.translate('discover.fieldNameIcons.dateFieldAriaLabel', { + defaultMessage: 'Date field', + }); + case 'geo_point': + return i18n.translate('discover.fieldNameIcons.geoPointFieldAriaLabel', { + defaultMessage: 'Geo point field', + }); + case 'geo_shape': + return i18n.translate('discover.fieldNameIcons.geoShapeFieldAriaLabel', { + defaultMessage: 'Geo shape field', + }); + case 'ip': + return i18n.translate('discover.fieldNameIcons.ipAddressFieldAriaLabel', { + defaultMessage: 'IP address field', + }); + case 'murmur3': + return i18n.translate('discover.fieldNameIcons.murmur3FieldAriaLabel', { + defaultMessage: 'Murmur3 field', + }); + case 'number': + return i18n.translate('discover.fieldNameIcons.numberFieldAriaLabel', { + defaultMessage: 'Number field', + }); + case 'source': + // Note that this type is currently not provided, type for _source is undefined + return i18n.translate('discover.fieldNameIcons.sourceFieldAriaLabel', { + defaultMessage: 'Source field', + }); + case 'string': + return i18n.translate('discover.fieldNameIcons.stringFieldAriaLabel', { + defaultMessage: 'String field', + }); + case 'nested': + return i18n.translate('discover.fieldNameIcons.nestedFieldAriaLabel', { + defaultMessage: 'Nested field', + }); + default: + return i18n.translate('discover.fieldNameIcons.unknownFieldAriaLabel', { + defaultMessage: 'Unknown field', + }); + } +} diff --git a/src/plugins/discover/public/application/components/help_menu/help_menu_util.js b/src/plugins/discover_legacy/public/application/components/help_menu/help_menu_util.js similarity index 100% rename from src/plugins/discover/public/application/components/help_menu/help_menu_util.js rename to src/plugins/discover_legacy/public/application/components/help_menu/help_menu_util.js diff --git a/src/plugins/discover_legacy/public/application/components/hits_counter/hits_counter.test.tsx b/src/plugins/discover_legacy/public/application/components/hits_counter/hits_counter.test.tsx new file mode 100644 index 000000000000..998ababbc47f --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/hits_counter/hits_counter.test.tsx @@ -0,0 +1,80 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ReactWrapper } from 'enzyme'; +import { HitsCounter, HitsCounterProps } from './hits_counter'; +import { findTestSubject } from 'test_utils/helpers'; + +describe('hits counter', function () { + let props: HitsCounterProps; + let component: ReactWrapper; + + beforeAll(() => { + props = { + onResetQuery: jest.fn(), + showResetButton: true, + hits: 2, + }; + }); + + it('HitsCounter renders a button by providing the showResetButton property', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'resetSavedSearch').length).toBe(1); + }); + + it('HitsCounter not renders a button when the showResetButton property is false', () => { + component = mountWithIntl( + + ); + expect(findTestSubject(component, 'resetSavedSearch').length).toBe(0); + }); + + it('expect to render the number of hits', function () { + component = mountWithIntl(); + const hits = findTestSubject(component, 'discoverQueryHits'); + expect(hits.text()).toBe('2'); + }); + + it('expect to render 1,899 hits if 1899 hits given', function () { + component = mountWithIntl( + + ); + const hits = findTestSubject(component, 'discoverQueryHits'); + expect(hits.text()).toBe('1,899'); + }); + + it('should reset query', function () { + component = mountWithIntl(); + findTestSubject(component, 'resetSavedSearch').simulate('click'); + expect(props.onResetQuery).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover/public/application/components/hits_counter/hits_counter.tsx b/src/plugins/discover_legacy/public/application/components/hits_counter/hits_counter.tsx similarity index 100% rename from src/plugins/discover/public/application/components/hits_counter/hits_counter.tsx rename to src/plugins/discover_legacy/public/application/components/hits_counter/hits_counter.tsx diff --git a/src/plugins/discover_legacy/public/application/components/hits_counter/index.ts b/src/plugins/discover_legacy/public/application/components/hits_counter/index.ts new file mode 100644 index 000000000000..213cf96e0cc8 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/hits_counter/index.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +export { HitsCounter } from './hits_counter'; diff --git a/src/plugins/discover_legacy/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap b/src/plugins/discover_legacy/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap new file mode 100644 index 000000000000..3897e22c50f1 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`returns the \`JsonCodeEditor\` component 1`] = ` + + { + "_index": "test", + "_type": "doc", + "_id": "foo", + "_score": 1, + "_source": { + "test": 123 + } +} + +`; diff --git a/src/plugins/discover_legacy/public/application/components/json_code_block/json_code_block.test.tsx b/src/plugins/discover_legacy/public/application/components/json_code_block/json_code_block.test.tsx new file mode 100644 index 000000000000..2cb700b4d2ac --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/json_code_block/json_code_block.test.tsx @@ -0,0 +1,46 @@ +/* + * 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 { shallow } from 'enzyme'; +import { JsonCodeBlock } from './json_code_block'; +import { IndexPattern } from '../../../../../data/public'; + +it('returns the `JsonCodeEditor` component', () => { + const props = { + hit: { _index: 'test', _type: 'doc', _id: 'foo', _score: 1, _source: { test: 123 } }, + columns: [], + indexPattern: {} as IndexPattern, + filter: jest.fn(), + onAddColumn: jest.fn(), + onRemoveColumn: jest.fn(), + }; + expect(shallow()).toMatchSnapshot(); +}); diff --git a/src/plugins/discover_legacy/public/application/components/json_code_block/json_code_block.tsx b/src/plugins/discover_legacy/public/application/components/json_code_block/json_code_block.tsx new file mode 100644 index 000000000000..f33cae438cb2 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/json_code_block/json_code_block.tsx @@ -0,0 +1,45 @@ +/* + * 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 { EuiCodeBlock } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { DocViewRenderProps } from '../../doc_views/doc_views_types'; + +export function JsonCodeBlock({ hit }: DocViewRenderProps) { + const label = i18n.translate('discover.docViews.json.codeEditorAriaLabel', { + defaultMessage: 'Read only JSON view of an opensearch document', + }); + return ( + + {JSON.stringify(hit, null, 2)} + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/loading_spinner/loading_spinner.test.tsx b/src/plugins/discover_legacy/public/application/components/loading_spinner/loading_spinner.test.tsx new file mode 100644 index 000000000000..fbc98e2550e0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/loading_spinner/loading_spinner.test.tsx @@ -0,0 +1,45 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ReactWrapper } from 'enzyme'; +import { LoadingSpinner } from './loading_spinner'; +import { findTestSubject } from 'test_utils/helpers'; + +describe('loading spinner', function () { + let component: ReactWrapper; + + it('LoadingSpinner renders a Searching text and a spinner', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'loadingSpinnerText').text()).toBe('Searching'); + expect(findTestSubject(component, 'loadingSpinner').length).toBe(1); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/loading_spinner/loading_spinner.tsx b/src/plugins/discover_legacy/public/application/components/loading_spinner/loading_spinner.tsx new file mode 100644 index 000000000000..697c7a136d60 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/loading_spinner/loading_spinner.tsx @@ -0,0 +1,47 @@ +/* + * 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 { EuiLoadingSpinner, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; + +export function LoadingSpinner() { + return ( + <> + +

+ +

+
+ + + + ); +} diff --git a/src/plugins/discover/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap b/src/plugins/discover_legacy/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap rename to src/plugins/discover_legacy/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap diff --git a/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/change_indexpattern.tsx similarity index 100% rename from src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx rename to src/plugins/discover_legacy/public/application/components/sidebar/change_indexpattern.tsx diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.scss b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.scss new file mode 100644 index 000000000000..8e1dd41f66ab --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.scss @@ -0,0 +1,4 @@ +.dscSidebarItem__fieldPopoverPanel { + min-width: 260px; + max-width: 300px; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.test.tsx new file mode 100644 index 000000000000..1b384a4b5550 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.test.tsx @@ -0,0 +1,152 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +// @ts-ignore +import { findTestSubject } from '@elastic/eui/lib/test'; +// @ts-ignore +import stubbedLogstashFields from 'fixtures/logstash_fields'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { DiscoverField } from './discover_field'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { IndexPatternField } from '../../../../../data/public'; +import { getStubIndexPattern } from '../../../../../data/public/test_utils'; + +jest.mock('../../../opensearch_dashboards_services', () => ({ + getServices: () => ({ + history: () => ({ + location: { + search: '', + }, + }), + capabilities: { + visualize: { + show: true, + }, + }, + uiSettings: { + get: (key: string) => { + if (key === 'fields:popularLimit') { + return 5; + } else if (key === 'shortDots:enable') { + return false; + } + }, + }, + }), +})); + +function getComponent({ + selected = false, + showDetails = false, + useShortDots = false, + field, +}: { + selected?: boolean; + showDetails?: boolean; + useShortDots?: boolean; + field?: IndexPatternField; +}) { + const indexPattern = getStubIndexPattern( + 'logstash-*', + (cfg: any) => cfg, + 'time', + stubbedLogstashFields(), + coreMock.createSetup() + ); + + const finalField = + field ?? + new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + + const props = { + indexPattern, + columns: [], + field: finalField, + getDetails: jest.fn(() => ({ buckets: [], error: '', exists: 1, total: 1 })), + onAddFilter: jest.fn(), + onAddField: jest.fn(), + onRemoveField: jest.fn(), + showDetails, + selected, + useShortDots, + }; + const comp = mountWithIntl(); + return { comp, props }; +} + +describe('discover sidebar field', function () { + it('should allow selecting fields', function () { + const { comp, props } = getComponent({}); + findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + expect(props.onAddField).toHaveBeenCalledWith('bytes'); + }); + it('should allow deselecting fields', function () { + const { comp, props } = getComponent({ selected: true }); + findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + expect(props.onRemoveField).toHaveBeenCalledWith('bytes'); + }); + it('should trigger getDetails', function () { + const { comp, props } = getComponent({ selected: true }); + findTestSubject(comp, 'field-bytes-showDetails').simulate('click'); + expect(props.getDetails).toHaveBeenCalledWith(props.field); + }); + it('should not allow clicking on _source', function () { + const field = new IndexPatternField( + { + name: '_source', + type: '_source', + esTypes: ['_source'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + '_source' + ); + const { comp, props } = getComponent({ + selected: true, + field, + }); + findTestSubject(comp, 'field-_source-showDetails').simulate('click'); + expect(props.getDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.tsx new file mode 100644 index 000000000000..e807267435eb --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.tsx @@ -0,0 +1,245 @@ +/* + * 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, { useState } from 'react'; +import { EuiPopover, EuiPopoverTitle, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { DiscoverFieldDetails } from './discover_field_details'; +import { FieldIcon, FieldButton } from '../../../../../opensearch_dashboards_react/public'; +import { FieldDetails } from './types'; +import { IndexPatternField, IndexPattern } from '../../../../../data/public'; +import { shortenDottedString } from '../../helpers'; +import { getFieldTypeName } from './lib/get_field_type_name'; +import './discover_field.scss'; + +export interface DiscoverFieldProps { + /** + * the selected columns displayed in the doc table in discover + */ + columns: string[]; + /** + * The displayed field + */ + field: IndexPatternField; + /** + * The currently selected index pattern + */ + indexPattern: IndexPattern; + /** + * Callback to add/select the field + */ + onAddField: (fieldName: string) => void; + /** + * Callback to add a filter to filter bar + */ + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + /** + * Callback to remove/deselect a the field + * @param fieldName + */ + onRemoveField: (fieldName: string) => void; + /** + * Retrieve details data for the field + */ + getDetails: (field: IndexPatternField) => FieldDetails; + /** + * Determines whether the field is selected + */ + selected?: boolean; + /** + * Determines whether the field name is shortened test.sub1.sub2 = t.s.sub2 + */ + useShortDots?: boolean; +} + +export function DiscoverField({ + columns, + field, + indexPattern, + onAddField, + onRemoveField, + onAddFilter, + getDetails, + selected, + useShortDots, +}: DiscoverFieldProps) { + const addLabelAria = i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { + defaultMessage: 'Add {field} to table', + values: { field: field.name }, + }); + const removeLabelAria = i18n.translate( + 'discover.fieldChooser.discoverField.removeButtonAriaLabel', + { + defaultMessage: 'Remove {field} from table', + values: { field: field.name }, + } + ); + + const [infoIsOpen, setOpen] = useState(false); + + const toggleDisplay = (f: IndexPatternField) => { + if (selected) { + onRemoveField(f.name); + } else { + onAddField(f.name); + } + }; + + function togglePopover() { + setOpen(!infoIsOpen); + } + + function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; + } + + const dscFieldIcon = ( + + ); + + const fieldName = ( + + {useShortDots ? wrapOnDot(shortenDottedString(field.name)) : wrapOnDot(field.displayName)} + + ); + + let actionButton; + if (field.name !== '_source' && !selected) { + actionButton = ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={addLabelAria} + /> + + ); + } else if (field.name !== '_source' && selected) { + actionButton = ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={removeLabelAria} + /> + + ); + } + + if (field.type === '_source') { + return ( + + ); + } + + return ( + { + togglePopover(); + }} + dataTestSubj={`field-${field.name}-showDetails`} + fieldIcon={dscFieldIcon} + fieldAction={actionButton} + fieldName={fieldName} + /> + } + isOpen={infoIsOpen} + closePopover={() => setOpen(false)} + anchorPosition="rightUp" + panelClassName="dscSidebarItem__fieldPopoverPanel" + > + + {' '} + {i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { + defaultMessage: 'Top 5 values', + })} + + {infoIsOpen && ( + + )} + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.scss b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.scss new file mode 100644 index 000000000000..90b645f70084 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.scss @@ -0,0 +1,4 @@ +.dscFieldDetails__barContainer { + // Constrains value to the flex item, and allows for truncation when necessary + min-width: 0; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.tsx new file mode 100644 index 000000000000..6a4dbe295e50 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.tsx @@ -0,0 +1,133 @@ +/* + * 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 { EuiText, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { StringFieldProgressBar } from './string_progress_bar'; +import { Bucket } from './types'; +import { IndexPatternField } from '../../../../../data/public'; +import './discover_field_bucket.scss'; + +interface Props { + bucket: Bucket; + field: IndexPatternField; + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; +} + +export function DiscoverFieldBucket({ field, bucket, onAddFilter }: Props) { + const emptyTxt = i18n.translate('discover.fieldChooser.detailViews.emptyStringText', { + defaultMessage: 'Empty string', + }); + const addLabel = i18n.translate('discover.fieldChooser.detailViews.filterValueButtonAriaLabel', { + defaultMessage: 'Filter for {field}: "{value}"', + values: { value: bucket.value, field: field.name }, + }); + const removeLabel = i18n.translate( + 'discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel', + { + defaultMessage: 'Filter out {field}: "{value}"', + values: { value: bucket.value, field: field.name }, + } + ); + + return ( + <> + + + + + + {bucket.display === '' ? emptyTxt : bucket.display} + + + + + {bucket.percent.toFixed(1)}% + + + + + + {field.filterable && ( + +
+ onAddFilter(field, bucket.value, '+')} + aria-label={addLabel} + data-test-subj={`plus-${field.name}-${bucket.value}`} + style={{ + minHeight: 'auto', + minWidth: 'auto', + paddingRight: 2, + paddingLeft: 2, + paddingTop: 0, + paddingBottom: 0, + }} + className={'euiButtonIcon--auto'} + /> + onAddFilter(field, bucket.value, '-')} + aria-label={removeLabel} + data-test-subj={`minus-${field.name}-${bucket.value}`} + style={{ + minHeight: 'auto', + minWidth: 'auto', + paddingTop: 0, + paddingBottom: 0, + paddingRight: 2, + paddingLeft: 2, + }} + className={'euiButtonIcon--auto'} + /> +
+
+ )} +
+ + + ); +} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field_details.scss b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.scss similarity index 100% rename from src/plugins/discover/public/application/components/sidebar/discover_field_details.scss rename to src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.scss diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.test.tsx new file mode 100644 index 000000000000..63d5c7ace303 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.test.tsx @@ -0,0 +1,312 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +// @ts-ignore +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from '@testing-library/react'; +// @ts-ignore +import stubbedLogstashFields from 'fixtures/logstash_fields'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { DiscoverFieldDetails } from './discover_field_details'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { IndexPatternField } from '../../../../../data/public'; +import { getStubIndexPattern } from '../../../../../data/public/test_utils'; + +const mockGetHref = jest.fn(); +const mockGetTriggerCompatibleActions = jest.fn(); + +jest.mock('../../../opensearch_dashboards_services', () => ({ + getUiActions: () => ({ + getTriggerCompatibleActions: mockGetTriggerCompatibleActions, + }), +})); + +const indexPattern = getStubIndexPattern( + 'logstash-*', + (cfg: any) => cfg, + 'time', + stubbedLogstashFields(), + coreMock.createSetup() +); + +describe('discover sidebar field details', function () { + const defaultProps = { + columns: [], + details: { buckets: [], error: '', exists: 1, total: 1 }, + indexPattern, + onAddFilter: jest.fn(), + }; + + beforeEach(() => { + mockGetHref.mockReturnValue('/foo/bar'); + mockGetTriggerCompatibleActions.mockReturnValue([ + { + getHref: mockGetHref, + }, + ]); + }); + + function mountComponent(field: IndexPatternField, props?: Record) { + const compProps = { ...defaultProps, ...props, field }; + return mountWithIntl(); + } + + it('should render buckets if they exist', async function () { + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const buckets = [1, 2, 3].map((n) => ({ + display: `display-${n}`, + value: `value-${n}`, + percent: 25, + count: 100, + })); + const comp = mountComponent(visualizableField, { + details: { ...defaultProps.details, buckets }, + }); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').children().length).toBe( + buckets.length + ); + // Visualize link should not be rendered until async hook update + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(0); + + // Complete async hook + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').children().length).toBe( + buckets.length + ); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(1); + }); + + it('should only render buckets if they exist', async function () { + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(visualizableField); + expect(findTestSubject(comp, 'fieldVisualizeContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(0); + + await act(async () => { + await nextTick(); + comp.update(); + }); + + expect(findTestSubject(comp, 'fieldVisualizeContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(1); + }); + + it('should render a details error', async function () { + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const errText = 'Some error'; + const comp = mountComponent(visualizableField, { + details: { ...defaultProps.details, error: errText }, + }); + expect(findTestSubject(comp, 'fieldVisualizeContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeError').text()).toBe(errText); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(1); + }); + + it('should handle promise rejection from isFieldVisualizable', async function () { + mockGetTriggerCompatibleActions.mockRejectedValue(new Error('Async error')); + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(visualizableField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(0); + }); + + it('should handle promise rejection from getVisualizeHref', async function () { + mockGetHref.mockRejectedValue(new Error('Async error')); + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(visualizableField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(0); + }); + + it('should enable the visualize link for a number field', async function () { + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(visualizableField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(1); + }); + + it('should disable the visualize link for an _id field', async function () { + expect.assertions(1); + const conflictField = new IndexPatternField( + { + name: '_id', + type: 'string', + esTypes: ['_id'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'test' + ); + const comp = mountComponent(conflictField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualize-_id').length).toBe(0); + }); + + it('should disable the visualize link for an unknown field', async function () { + const unknownField = new IndexPatternField( + { + name: 'test', + type: 'unknown', + esTypes: ['double'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'test' + ); + const comp = mountComponent(unknownField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualize-test').length).toBe(0); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.tsx new file mode 100644 index 000000000000..906c173ed07d --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.tsx @@ -0,0 +1,153 @@ +/* + * 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, { useState, useEffect } from 'react'; +import { EuiLink, EuiIconTip, EuiText, EuiPopoverFooter, EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; +import { DiscoverFieldBucket } from './discover_field_bucket'; +import { getWarnings } from './lib/get_warnings'; +import { + triggerVisualizeActions, + isFieldVisualizable, + getVisualizeHref, +} from './lib/visualize_trigger_utils'; +import { Bucket, FieldDetails } from './types'; +import { IndexPatternField, IndexPattern } from '../../../../../data/public'; +import './discover_field_details.scss'; + +interface DiscoverFieldDetailsProps { + columns: string[]; + details: FieldDetails; + field: IndexPatternField; + indexPattern: IndexPattern; + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; +} + +export function DiscoverFieldDetails({ + columns, + details, + field, + indexPattern, + onAddFilter, +}: DiscoverFieldDetailsProps) { + const warnings = getWarnings(field); + const [showVisualizeLink, setShowVisualizeLink] = useState(false); + const [visualizeLink, setVisualizeLink] = useState(''); + + useEffect(() => { + const checkIfVisualizable = async () => { + const visualizable = await isFieldVisualizable(field, indexPattern.id, columns).catch( + () => false + ); + + setShowVisualizeLink(visualizable); + if (visualizable) { + const href = await getVisualizeHref(field, indexPattern.id, columns).catch(() => ''); + setVisualizeLink(href || ''); + } + }; + checkIfVisualizable(); + }, [field, indexPattern.id, columns]); + + const handleVisualizeLinkClick = (event: React.MouseEvent) => { + // regular link click. let the uiActions code handle the navigation and show popup if needed + event.preventDefault(); + triggerVisualizeActions(field, indexPattern.id, columns); + }; + + return ( + <> +
+ {details.error && ( + + {details.error} + + )} + + {!details.error && details.buckets.length > 0 && ( +
+ {details.buckets.map((bucket: Bucket, idx: number) => ( + + ))} +
+ )} + + {showVisualizeLink && visualizeLink && ( +
+ + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + handleVisualizeLinkClick(e)} + href={visualizeLink} + size="s" + className="dscFieldDetails__visualizeBtn" + data-test-subj={`fieldVisualize-${field.name}`} + > + + + {warnings.length > 0 && ( + + )} +
+ )} +
+ {!details.error && ( + + + {!indexPattern.metaFields.includes(field.name) && !field.scripted ? ( + onAddFilter('_exists_', field.name, '+')}> + {' '} + {details.exists} + + ) : ( + {details.exists} + )}{' '} + / {details.total}{' '} + + + + )} + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.test.tsx new file mode 100644 index 000000000000..f78505e11f1e --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.test.tsx @@ -0,0 +1,160 @@ +/* + * 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 { act } from 'react-dom/test-utils'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/helpers'; +import { DiscoverFieldSearch, Props } from './discover_field_search'; +import { EuiButtonGroupProps, EuiPopover } from '@elastic/eui'; +import { ReactWrapper } from 'enzyme'; + +describe('DiscoverFieldSearch', () => { + const defaultProps = { + onChange: jest.fn(), + value: 'test', + types: ['any', 'string', '_source'], + }; + + function mountComponent(props?: Props) { + const compProps = props || defaultProps; + return mountWithIntl(); + } + + function findButtonGroup(component: ReactWrapper, id: string) { + return component.find(`[data-test-subj="${id}ButtonGroup"]`).first(); + } + + test('enter value', () => { + const component = mountComponent(); + const input = findTestSubject(component, 'fieldFilterSearchInput'); + input.simulate('change', { target: { value: 'new filter' } }); + expect(defaultProps.onChange).toBeCalledTimes(1); + }); + + test('change in active filters should change facet selection and call onChange', () => { + const onChange = jest.fn(); + const component = mountComponent({ ...defaultProps, ...{ onChange } }); + let btn = findTestSubject(component, 'toggleFieldFilterButton'); + expect(btn.hasClass('euiFacetButton--isSelected')).toBeFalsy(); + btn.simulate('click'); + const aggregatableButtonGroup = findButtonGroup(component, 'aggregatable'); + act(() => { + // @ts-ignore + (aggregatableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null); + }); + component.update(); + btn = findTestSubject(component, 'toggleFieldFilterButton'); + expect(btn.hasClass('euiFacetButton--isSelected')).toBe(true); + expect(onChange).toBeCalledWith('aggregatable', true); + }); + + test('change in active filters should change filters count', () => { + const component = mountComponent(); + let btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + btn = findTestSubject(component, 'toggleFieldFilterButton'); + const badge = btn.find('.euiNotificationBadge'); + // no active filters + expect(badge.text()).toEqual('0'); + // change value of aggregatable select + const aggregatableButtonGroup = findButtonGroup(component, 'aggregatable'); + act(() => { + // @ts-ignore + (aggregatableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null); + }); + component.update(); + expect(badge.text()).toEqual('1'); + // change value of searchable select + const searchableButtonGroup = findButtonGroup(component, 'searchable'); + act(() => { + // @ts-ignore + (searchableButtonGroup.props() as EuiButtonGroupProps).onChange('searchable-true', null); + }); + component.update(); + expect(badge.text()).toEqual('2'); + // change value of searchable select + act(() => { + // @ts-ignore + (searchableButtonGroup.props() as EuiButtonGroupProps).onChange('searchable-any', null); + }); + component.update(); + expect(badge.text()).toEqual('1'); + }); + + test('change in missing fields switch should not change filter count', () => { + const component = mountComponent(); + const btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + const badge = btn.find('.euiNotificationBadge'); + expect(badge.text()).toEqual('0'); + const missingSwitch = findTestSubject(component, 'missingSwitch'); + missingSwitch.simulate('change', { target: { value: false } }); + expect(badge.text()).toEqual('0'); + }); + + test('change in filters triggers onChange', () => { + const onChange = jest.fn(); + const component = mountComponent({ ...defaultProps, ...{ onChange } }); + const btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + const aggregtableButtonGroup = findButtonGroup(component, 'aggregatable'); + const missingSwitch = findTestSubject(component, 'missingSwitch'); + act(() => { + // @ts-ignore + (aggregtableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null); + }); + missingSwitch.simulate('click'); + expect(onChange).toBeCalledTimes(2); + }); + + test('change in type filters triggers onChange with appropriate value', () => { + const onChange = jest.fn(); + const component = mountComponent({ ...defaultProps, ...{ onChange } }); + const btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + const typeSelector = findTestSubject(component, 'typeSelect'); + typeSelector.simulate('change', { target: { value: 'string' } }); + expect(onChange).toBeCalledWith('type', 'string'); + typeSelector.simulate('change', { target: { value: 'any' } }); + expect(onChange).toBeCalledWith('type', 'any'); + }); + + test('click on filter button should open and close popover', () => { + const component = mountComponent(); + const btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + let popover = component.find(EuiPopover); + expect(popover.prop('isOpen')).toBe(true); + btn.simulate('click'); + popover = component.find(EuiPopover); + expect(popover.prop('isOpen')).toBe(false); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.tsx new file mode 100644 index 000000000000..4a1390cb1955 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.tsx @@ -0,0 +1,313 @@ +/* + * 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, { OptionHTMLAttributes, ReactNode, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { + EuiFacetButton, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSelect, + EuiSwitch, + EuiSwitchEvent, + EuiForm, + EuiFormRow, + EuiButtonGroup, + EuiOutsideClickDetector, +} from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; + +export interface State { + searchable: string; + aggregatable: string; + type: string; + missing: boolean; + [index: string]: string | boolean; +} + +export interface Props { + /** + * triggered on input of user into search field + */ + onChange: (field: string, value: string | boolean | undefined) => void; + + /** + * the input value of the user + */ + value?: string; + + /** + * types for the type filter + */ + types: string[]; +} + +/** + * Component is Discover's side bar to search of available fields + * Additionally there's a button displayed that allows the user to show/hide more filter fields + */ +export function DiscoverFieldSearch({ onChange, value, types }: Props) { + const searchPlaceholder = i18n.translate('discover.fieldChooser.searchPlaceHolder', { + defaultMessage: 'Search field names', + }); + const aggregatableLabel = i18n.translate('discover.fieldChooser.filter.aggregatableLabel', { + defaultMessage: 'Aggregatable', + }); + const searchableLabel = i18n.translate('discover.fieldChooser.filter.searchableLabel', { + defaultMessage: 'Searchable', + }); + const typeLabel = i18n.translate('discover.fieldChooser.filter.typeLabel', { + defaultMessage: 'Type', + }); + const typeOptions = types + ? types.map((type) => { + return { value: type, text: type }; + }) + : [{ value: 'any', text: 'any' }]; + + const [activeFiltersCount, setActiveFiltersCount] = useState(0); + const [isPopoverOpen, setPopoverOpen] = useState(false); + const [values, setValues] = useState({ + searchable: 'any', + aggregatable: 'any', + type: 'any', + missing: true, + }); + + if (typeof value !== 'string') { + // at initial rendering value is undefined (angular related), this catches the warning + // should be removed once all is react + return null; + } + + const filterBtnAriaLabel = isPopoverOpen + ? i18n.translate('discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', { + defaultMessage: 'Hide field filter settings', + }) + : i18n.translate('discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel', { + defaultMessage: 'Show field filter settings', + }); + + const handleFacetButtonClicked = () => { + setPopoverOpen(!isPopoverOpen); + }; + + const applyFilterValue = (id: string, filterValue: string | boolean) => { + switch (filterValue) { + case 'any': + if (id !== 'type') { + onChange(id, undefined); + } else { + onChange(id, filterValue); + } + break; + case 'true': + onChange(id, true); + break; + case 'false': + onChange(id, false); + break; + default: + onChange(id, filterValue); + } + }; + + const isFilterActive = (name: string, filterValue: string | boolean) => { + return name !== 'missing' && filterValue !== 'any'; + }; + + const handleValueChange = (name: string, filterValue: string | boolean) => { + const previousValue = values[name]; + updateFilterCount(name, previousValue, filterValue); + const updatedValues = { ...values }; + updatedValues[name] = filterValue; + setValues(updatedValues); + applyFilterValue(name, filterValue); + }; + + const updateFilterCount = ( + name: string, + previousValue: string | boolean, + currentValue: string | boolean + ) => { + const previouslyFilterActive = isFilterActive(name, previousValue); + const filterActive = isFilterActive(name, currentValue); + const diff = Number(filterActive) - Number(previouslyFilterActive); + setActiveFiltersCount(activeFiltersCount + diff); + }; + + const handleMissingChange = (e: EuiSwitchEvent) => { + const missingValue = e.target.checked; + handleValueChange('missing', missingValue); + }; + + const buttonContent = ( + } + isSelected={activeFiltersCount > 0} + quantity={activeFiltersCount} + onClick={handleFacetButtonClicked} + > + + + ); + + const select = ( + id: string, + selectOptions: Array<{ text: ReactNode } & OptionHTMLAttributes>, + selectValue: string + ) => { + return ( + ) => + handleValueChange(id, e.target.value) + } + aria-label={i18n.translate('discover.fieldChooser.filter.fieldSelectorLabel', { + defaultMessage: 'Selection of {id} filter options', + values: { id }, + })} + data-test-subj={`${id}Select`} + compressed + /> + ); + }; + + const toggleButtons = (id: string) => { + return [ + { + id: `${id}-any`, + label: 'any', + }, + { + id: `${id}-true`, + label: 'yes', + }, + { + id: `${id}-false`, + label: 'no', + }, + ]; + }; + + const buttonGroup = (id: string, legend: string) => { + return ( + handleValueChange(id, optionId.replace(`${id}-`, ''))} + buttonSize="compressed" + isFullWidth + data-test-subj={`${id}ButtonGroup`} + /> + ); + }; + + const selectionPanel = ( +
+ + + {buttonGroup('aggregatable', aggregatableLabel)} + + + {buttonGroup('searchable', searchableLabel)} + + + {select('type', typeOptions, values.type)} + + +
+ ); + + return ( + + + + onChange('name', event.currentTarget.value)} + placeholder={searchPlaceholder} + value={value} + /> + + +
+ {}} isDisabled={!isPopoverOpen}> + { + setPopoverOpen(false); + }} + button={buttonContent} + > + + {i18n.translate('discover.fieldChooser.filter.filterByTypeLabel', { + defaultMessage: 'Filter by type', + })} + + {selectionPanel} + + + + + +
+
+ ); +} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.test.tsx similarity index 100% rename from src/plugins/discover/public/application/components/sidebar/discover_index_pattern.test.tsx rename to src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.test.tsx diff --git a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.tsx similarity index 100% rename from src/plugins/discover/public/application/components/sidebar/discover_index_pattern.tsx rename to src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.tsx diff --git a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_title.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern_title.tsx similarity index 100% rename from src/plugins/discover/public/application/components/sidebar/discover_index_pattern_title.tsx rename to src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern_title.tsx diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss new file mode 100644 index 000000000000..9c80e0afa600 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss @@ -0,0 +1,99 @@ +.dscSidebar__container { + padding-left: 0 !important; + padding-right: 0 !important; + background-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; +} + +.dscIndexPattern__container { + display: flex; + align-items: center; + height: $euiSize * 3; + margin-top: -$euiSizeS; +} + +.dscIndexPattern__triggerButton { + @include euiTitle("xs"); + + line-height: $euiSizeXXL; +} + +.dscFieldList { + list-style: none; + margin-bottom: 0; +} + +.dscFieldListHeader { + padding: $euiSizeS $euiSizeS 0 $euiSizeS; + background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade); +} + +.dscFieldList--popular { + background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade); +} + +.dscFieldChooser { + padding-left: $euiSize; +} + +.dscFieldChooser__toggle { + color: $euiColorMediumShade; + margin-left: $euiSizeS !important; +} + +.dscSidebarItem { + &:hover, + &:focus-within, + &[class*="-isActive"] { + .dscSidebarItem__action { + opacity: 1; + } + } +} + +/** + * 1. Only visually hide the action, so that it's still accessible to screen readers. + * 2. When tabbed to, this element needs to be visible for keyboard accessibility. + */ +.dscSidebarItem__action { + opacity: 0; /* 1 */ + transition: none; + + &:focus { + opacity: 1; /* 2 */ + } + + font-size: $euiFontSizeXS; + padding: 2px 6px !important; + height: 22px !important; + min-width: auto !important; + + .euiButton__content { + padding: 0 4px; + } +} + +.dscFieldSearch { + padding: $euiSizeS; +} + +.dscFieldSearch__toggleButton { + width: calc(100% - #{$euiSizeS}); + color: $euiColorPrimary; + padding-left: $euiSizeXS; + margin-left: $euiSizeXS; +} + +.dscFieldSearch__filterWrapper { + flex-grow: 0; +} + +.dscFieldSearch__formWrapper { + padding: $euiSizeM; +} + +.dscFieldDetails { + color: $euiTextColor; + margin-bottom: $euiSizeS; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.test.tsx new file mode 100644 index 000000000000..fa692ca22b5b --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.test.tsx @@ -0,0 +1,148 @@ +/* + * 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 _ from 'lodash'; +import { ReactWrapper } from 'enzyme'; +import { findTestSubject } from 'test_utils/helpers'; +// @ts-ignore +import realHits from 'fixtures/real_hits.js'; +// @ts-ignore +import stubbedLogstashFields from 'fixtures/logstash_fields'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { DiscoverSidebar, DiscoverSidebarProps } from './discover_sidebar'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { IndexPatternAttributes } from '../../../../../data/common'; +import { getStubIndexPattern } from '../../../../../data/public/test_utils'; +import { SavedObject } from '../../../../../../core/types'; + +jest.mock('../../../opensearch_dashboards_services', () => ({ + getServices: () => ({ + history: () => ({ + location: { + search: '', + }, + }), + capabilities: { + visualize: { + show: true, + }, + discover: { + save: false, + }, + }, + uiSettings: { + get: (key: string) => { + if (key === 'fields:popularLimit') { + return 5; + } else if (key === 'shortDots:enable') { + return false; + } + }, + }, + }), +})); + +jest.mock('./lib/get_index_pattern_field_list', () => ({ + getIndexPatternFieldList: jest.fn((indexPattern) => indexPattern.fields), +})); + +function getCompProps() { + const indexPattern = getStubIndexPattern( + 'logstash-*', + (cfg: any) => cfg, + 'time', + stubbedLogstashFields(), + coreMock.createSetup() + ); + + // @ts-expect-error _.each() is passing additional args to flattenHit + const hits = _.each(_.cloneDeep(realHits), indexPattern.flattenHit) as Array< + Record + >; + + const indexPatternList = [ + { id: '0', attributes: { title: 'b' } } as SavedObject, + { id: '1', attributes: { title: 'a' } } as SavedObject, + { id: '2', attributes: { title: 'c' } } as SavedObject, + ]; + + const fieldCounts: Record = {}; + + for (const hit of hits) { + for (const key of Object.keys(indexPattern.flattenHit(hit))) { + fieldCounts[key] = (fieldCounts[key] || 0) + 1; + } + } + return { + columns: ['extension'], + fieldCounts, + hits, + indexPatternList, + onAddFilter: jest.fn(), + onAddField: jest.fn(), + onRemoveField: jest.fn(), + selectedIndexPattern: indexPattern, + setIndexPattern: jest.fn(), + state: {}, + }; +} + +describe('discover sidebar', function () { + let props: DiscoverSidebarProps; + let comp: ReactWrapper; + + beforeAll(() => { + props = getCompProps(); + comp = mountWithIntl(); + }); + + it('should have Selected Fields and Available Fields with Popular Fields sections', function () { + const popular = findTestSubject(comp, 'fieldList-popular'); + const selected = findTestSubject(comp, 'fieldList-selected'); + const unpopular = findTestSubject(comp, 'fieldList-unpopular'); + expect(popular.children().length).toBe(1); + expect(unpopular.children().length).toBe(7); + expect(selected.children().length).toBe(1); + }); + it('should allow selecting fields', function () { + findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + expect(props.onAddField).toHaveBeenCalledWith('bytes'); + }); + it('should allow deselecting fields', function () { + findTestSubject(comp, 'fieldToggle-extension').simulate('click'); + expect(props.onRemoveField).toHaveBeenCalledWith('extension'); + }); + it('should allow adding filters', function () { + findTestSubject(comp, 'field-extension-showDetails').simulate('click'); + findTestSubject(comp, 'plus-extension-gif').simulate('click'); + expect(props.onAddFilter).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.tsx new file mode 100644 index 000000000000..865aff590286 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.tsx @@ -0,0 +1,326 @@ +/* + * 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 './discover_sidebar.scss'; +import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiButtonIcon, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { sortBy } from 'lodash'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { DiscoverField } from './discover_field'; +import { DiscoverIndexPattern } from './discover_index_pattern'; +import { DiscoverFieldSearch } from './discover_field_search'; +import { IndexPatternAttributes } from '../../../../../data/common'; +import { SavedObject } from '../../../../../../core/types'; +import { FIELDS_LIMIT_SETTING } from '../../../../common'; +import { groupFields } from './lib/group_fields'; +import { IndexPatternField, IndexPattern, UI_SETTINGS } from '../../../../../data/public'; +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'; + +export interface DiscoverSidebarProps { + /** + * the selected columns displayed in the doc table in discover + */ + columns: string[]; + /** + * a statistics of the distribution of fields in the given hits + */ + fieldCounts: Record; + /** + * hits fetched from OpenSearch, displayed in the doc table + */ + hits: Array>; + /** + * List of available index patterns + */ + indexPatternList: Array>; + /** + * Callback function when selecting a field + */ + onAddField: (fieldName: string) => void; + /** + * Callback function when adding a filter from sidebar + */ + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + /** + * Callback function when removing a field + * @param fieldName + */ + onRemoveField: (fieldName: string) => void; + /** + * Currently selected index pattern + */ + selectedIndexPattern?: IndexPattern; + /** + * Callback function to select another index pattern + */ + setIndexPattern: (id: string) => void; +} + +export function DiscoverSidebar({ + columns, + fieldCounts, + hits, + indexPatternList, + onAddField, + onAddFilter, + onRemoveField, + selectedIndexPattern, + setIndexPattern, +}: DiscoverSidebarProps) { + const [showFields, setShowFields] = useState(false); + const [fields, setFields] = useState(null); + const [fieldFilterState, setFieldFilterState] = useState(getDefaultFieldFilter()); + const services = useMemo(() => getServices(), []); + + useEffect(() => { + const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts); + setFields(newFields); + }, [selectedIndexPattern, fieldCounts, hits, services]); + + const onChangeFieldSearch = useCallback( + (field: string, value: string | boolean | undefined) => { + const newState = setFieldFilterProp(fieldFilterState, field, value); + setFieldFilterState(newState); + }, + [fieldFilterState] + ); + + const getDetailsByField = useCallback( + (ipField: IndexPatternField) => getDetails(ipField, hits, selectedIndexPattern), + [hits, selectedIndexPattern] + ); + + const popularLimit = services.uiSettings.get(FIELDS_LIMIT_SETTING); + const useShortDots = services.uiSettings.get(UI_SETTINGS.SHORT_DOTS_ENABLE); + + const { + selected: selectedFields, + popular: popularFields, + unpopular: unpopularFields, + } = useMemo(() => groupFields(fields, columns, popularLimit, fieldCounts, fieldFilterState), [ + fields, + columns, + popularLimit, + fieldCounts, + fieldFilterState, + ]); + + const fieldTypes = useMemo(() => { + const result = ['any']; + if (Array.isArray(fields)) { + for (const field of fields) { + if (result.indexOf(field.type) === -1) { + result.push(field.type); + } + } + } + return result; + }, [fields]); + + if (!selectedIndexPattern || !fields) { + return null; + } + + return ( + +
+ o.attributes.title)} + /> +
+
+ + +
+
+ {fields.length > 0 && ( + <> + +

+ +

+
+ +
    + {selectedFields.map((field: IndexPatternField) => { + return ( +
  • + +
  • + ); + })} +
+
+ +

+ +

+
+
+ setShowFields(!showFields)} + aria-label={ + showFields + ? i18n.translate( + 'discover.fieldChooser.filter.indexAndFieldsSectionHideAriaLabel', + { + defaultMessage: 'Hide fields', + } + ) + : i18n.translate( + 'discover.fieldChooser.filter.indexAndFieldsSectionShowAriaLabel', + { + defaultMessage: 'Show fields', + } + ) + } + /> +
+
+ + )} + {popularFields.length > 0 && ( +
+ + + +
    + {popularFields.map((field: IndexPatternField) => { + return ( +
  • + +
  • + ); + })} +
+
+ )} + +
    + {unpopularFields.map((field: IndexPatternField) => { + return ( +
  • + +
  • + ); + })} +
+
+
+
+ ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/index.ts b/src/plugins/discover_legacy/public/application/components/sidebar/index.ts new file mode 100644 index 000000000000..2799d47da83f --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/index.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +export { DiscoverSidebar } from './discover_sidebar'; diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_calculator.test.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_calculator.test.ts new file mode 100644 index 000000000000..d580f7ae228a --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_calculator.test.ts @@ -0,0 +1,268 @@ +/* + * 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 _ from 'lodash'; +// @ts-ignore +import realHits from 'fixtures/real_hits.js'; +// @ts-ignore +import stubbedLogstashFields from 'fixtures/logstash_fields'; +import { coreMock } from '../../../../../../../core/public/mocks'; +import { IndexPattern, IndexPatternField } from '../../../../../../data/public'; +import { getStubIndexPattern } from '../../../../../../data/public/test_utils'; +import { + groupValues, + getFieldValues, + getFieldValueCounts, + FieldValueCountsParams, +} from './field_calculator'; +import { Bucket } from '../types'; + +let indexPattern: IndexPattern; + +describe('field_calculator', function () { + beforeEach(function () { + indexPattern = getStubIndexPattern( + 'logstash-*', + (cfg: any) => cfg, + 'time', + stubbedLogstashFields(), + coreMock.createSetup() + ); + }); + + describe('groupValues', function () { + let groups: Record; + let grouped: boolean; + let values: any[]; + beforeEach(function () { + values = [ + ['foo', 'bar'], + 'foo', + 'foo', + undefined, + ['foo', 'bar'], + 'bar', + 'baz', + null, + null, + null, + 'foo', + undefined, + ]; + groups = groupValues(values, grouped); + }); + + it('should return an object values', function () { + expect(groups).toBeInstanceOf(Object); + }); + + it('should throw an error if any value is a plain object', function () { + expect(function () { + groupValues([{}, true, false], grouped); + }).toThrowError(); + }); + + it('should handle values with dots in them', function () { + values = ['0', '0.........', '0.......,.....']; + groups = groupValues(values, grouped); + expect(groups[values[0]].count).toBe(1); + expect(groups[values[1]].count).toBe(1); + expect(groups[values[2]].count).toBe(1); + }); + + it('should have a key for value in the array when not grouping array terms', function () { + expect(_.keys(groups).length).toBe(3); + expect(groups.foo).toBeInstanceOf(Object); + expect(groups.bar).toBeInstanceOf(Object); + expect(groups.baz).toBeInstanceOf(Object); + }); + + it('should count array terms independently', function () { + expect(groups['foo,bar']).toBeUndefined(); + expect(groups.foo.count).toBe(5); + expect(groups.bar.count).toBe(3); + expect(groups.baz.count).toBe(1); + }); + + describe('grouped array terms', function () { + beforeEach(function () { + grouped = true; + groups = groupValues(values, grouped); + }); + + it('should group array terms when grouped is true', function () { + expect(_.keys(groups).length).toBe(4); + expect(groups['foo,bar']).toBeInstanceOf(Object); + }); + + it('should contain the original array as the value', function () { + expect(groups['foo,bar'].value).toEqual(['foo', 'bar']); + }); + + it('should count the pairs separately from the values they contain', function () { + expect(groups['foo,bar'].count).toBe(2); + expect(groups.foo.count).toBe(3); + expect(groups.bar.count).toBe(1); + }); + }); + }); + + describe('getFieldValues', function () { + let hits: any; + + beforeEach(function () { + hits = _.each(_.cloneDeep(realHits), (hit) => indexPattern.flattenHit(hit)); + }); + + it('should return an array of values for _source fields', function () { + const extensions = getFieldValues({ + hits, + field: indexPattern.fields.getByName('extension') as IndexPatternField, + indexPattern, + }); + expect(extensions).toBeInstanceOf(Array); + expect( + _.filter(extensions, function (v) { + return v === 'html'; + }).length + ).toBe(8); + expect(_.uniq(_.clone(extensions)).sort()).toEqual(['gif', 'html', 'php', 'png']); + }); + + it('should return an array of values for core meta fields', function () { + const types = getFieldValues({ + hits, + field: indexPattern.fields.getByName('_type') as IndexPatternField, + indexPattern, + }); + expect(types).toBeInstanceOf(Array); + expect( + _.filter(types, function (v) { + return v === 'apache'; + }).length + ).toBe(18); + expect(_.uniq(_.clone(types)).sort()).toEqual(['apache', 'nginx']); + }); + }); + + describe('getFieldValueCounts', function () { + let params: FieldValueCountsParams; + beforeEach(function () { + params = { + hits: _.cloneDeep(realHits), + field: indexPattern.fields.getByName('extension') as IndexPatternField, + count: 3, + indexPattern, + }; + }); + + it('counts the top 5 values by default', function () { + params.hits = params.hits.map((hit: Record, i) => ({ + ...hit, + _source: { + extension: `${hit._source.extension}-${i}`, + }, + })); + params.count = undefined; + const extensions = getFieldValueCounts(params); + expect(extensions).toBeInstanceOf(Object); + expect(extensions.buckets).toBeInstanceOf(Array); + const buckets = extensions.buckets as Bucket[]; + expect(buckets.length).toBe(5); + expect(extensions.error).toBeUndefined(); + }); + + it('counts only distinct values if less than default', function () { + params.count = undefined; + const extensions = getFieldValueCounts(params); + expect(extensions).toBeInstanceOf(Object); + expect(extensions.buckets).toBeInstanceOf(Array); + const buckets = extensions.buckets as Bucket[]; + expect(buckets.length).toBe(4); + expect(extensions.error).toBeUndefined(); + }); + + it('counts only distinct values if less than specified count', function () { + params.count = 10; + const extensions = getFieldValueCounts(params); + expect(extensions).toBeInstanceOf(Object); + expect(extensions.buckets).toBeInstanceOf(Array); + const buckets = extensions.buckets as Bucket[]; + expect(buckets.length).toBe(4); + expect(extensions.error).toBeUndefined(); + }); + + it('counts the top 3 values', function () { + const extensions = getFieldValueCounts(params); + expect(extensions).toBeInstanceOf(Object); + expect(extensions.buckets).toBeInstanceOf(Array); + const buckets = extensions.buckets as Bucket[]; + expect(buckets.length).toBe(3); + expect(_.map(buckets, 'value')).toEqual(['html', 'gif', 'php']); + expect(extensions.error).toBeUndefined(); + }); + + it('fails to analyze geo and attachment types', function () { + params.field = indexPattern.fields.getByName('point') as IndexPatternField; + expect(getFieldValueCounts(params).error).not.toBeUndefined(); + + params.field = indexPattern.fields.getByName('area') as IndexPatternField; + expect(getFieldValueCounts(params).error).not.toBeUndefined(); + + params.field = indexPattern.fields.getByName('request_body') as IndexPatternField; + expect(getFieldValueCounts(params).error).not.toBeUndefined(); + }); + + it('fails to analyze fields that are in the mapping, but not the hits', function () { + params.field = indexPattern.fields.getByName('ip') as IndexPatternField; + expect(getFieldValueCounts(params).error).not.toBeUndefined(); + }); + + it('counts the total hits', function () { + expect(getFieldValueCounts(params).total).toBe(params.hits.length); + }); + + it('counts the hits the field exists in', function () { + params.field = indexPattern.fields.getByName('phpmemory') as IndexPatternField; + expect(getFieldValueCounts(params).exists).toBe(5); + }); + + it('catches and returns errors', function () { + params.hits = params.hits.map((hit: Record) => ({ + ...hit, + _source: { + extension: { foo: hit._source.extension }, + }, + })); + params.grouped = true; + expect(typeof getFieldValueCounts(params).error).toBe('string'); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_calculator.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_calculator.ts new file mode 100644 index 000000000000..54f8832fa1fc --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_calculator.ts @@ -0,0 +1,148 @@ +/* + * 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 { i18n } from '@osd/i18n'; +import { IndexPattern, IndexPatternField } from 'src/plugins/data/public'; +import { FieldValueCounts } from '../types'; + +const NO_ANALYSIS_TYPES = ['geo_point', 'geo_shape', 'attachment']; + +interface FieldValuesParams { + hits: Array>; + field: IndexPatternField; + indexPattern: IndexPattern; +} + +interface FieldValueCountsParams extends FieldValuesParams { + count?: number; + grouped?: boolean; +} + +const getFieldValues = ({ hits, field, indexPattern }: FieldValuesParams) => { + const name = field.name; + const flattenHit = indexPattern.flattenHit; + return hits.map((hit) => flattenHit(hit)[name]); +}; + +const getFieldValueCounts = (params: FieldValueCountsParams): FieldValueCounts => { + const { hits, field, indexPattern, count = 5, grouped = false } = params; + const { type: fieldType } = field; + + if (NO_ANALYSIS_TYPES.includes(fieldType)) { + return { + error: i18n.translate( + 'discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForGeoFieldsErrorMessage', + { + defaultMessage: 'Analysis is not available for {fieldType} fields.', + values: { + fieldType, + }, + } + ), + }; + } + + const allValues = getFieldValues({ hits, field, indexPattern }); + const missing = allValues.filter((v) => v === undefined || v === null).length; + + try { + const groups = groupValues(allValues, grouped); + const counts = Object.keys(groups) + .sort((a, b) => groups[b].count - groups[a].count) + .slice(0, count) + .map((key) => ({ + value: groups[key].value, + count: groups[key].count, + percent: (groups[key].count / (hits.length - missing)) * 100, + display: indexPattern.getFormatterForField(field).convert(groups[key].value), + })); + + if (hits.length === missing) { + return { + error: i18n.translate( + 'discover.fieldChooser.fieldCalculator.fieldIsNotPresentInDocumentsErrorMessage', + { + defaultMessage: + 'This field is present in your OpenSearch mapping but not in the {hitsLength} documents shown in the doc table. You may still be able to visualize or search on it.', + values: { + hitsLength: hits.length, + }, + } + ), + }; + } + + return { + total: hits.length, + exists: hits.length - missing, + missing, + buckets: counts, + }; + } catch (e) { + return { + error: e instanceof Error ? e.message : String(e), + }; + } +}; + +const groupValues = ( + allValues: any[], + grouped?: boolean +): Record => { + const values = grouped ? allValues : allValues.flat(); + + return values + .filter((v) => { + if (v instanceof Object && !Array.isArray(v)) { + throw new Error( + i18n.translate( + 'discover.fieldChooser.fieldCalculator.analysisIsNotAvailableForObjectFieldsErrorMessage', + { + defaultMessage: 'Analysis is not available for object fields.', + } + ) + ); + } + return v !== undefined && v !== null; + }) + .reduce((groups, value) => { + if (groups.hasOwnProperty(value)) { + groups[value].count++; + } else { + groups[value] = { + value, + count: 1, + }; + } + return groups; + }, {}); +}; + +export { FieldValueCountsParams, groupValues, getFieldValues, getFieldValueCounts }; diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_filter.test.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_filter.test.ts new file mode 100644 index 000000000000..a21d93cb5bc4 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_filter.test.ts @@ -0,0 +1,107 @@ +/* + * 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 { getDefaultFieldFilter, setFieldFilterProp, isFieldFiltered } from './field_filter'; +import { IndexPatternField } from '../../../../../../data/public'; + +describe('field_filter', function () { + it('getDefaultFieldFilter should return default filter state', function () { + expect(getDefaultFieldFilter()).toMatchInlineSnapshot(` + Object { + "aggregatable": null, + "missing": true, + "name": "", + "searchable": null, + "type": "any", + } + `); + }); + it('setFieldFilterProp should return allow filter changes', function () { + const state = getDefaultFieldFilter(); + const targetState = { + aggregatable: true, + missing: true, + name: 'test', + searchable: true, + type: 'string', + }; + const actualState = Object.entries(targetState).reduce((acc, kv) => { + return setFieldFilterProp(acc, kv[0], kv[1]); + }, state); + expect(actualState).toMatchInlineSnapshot(` + Object { + "aggregatable": true, + "missing": true, + "name": "test", + "searchable": true, + "type": "string", + } + `); + }); + it('filters a given list', () => { + const defaultState = getDefaultFieldFilter(); + const fieldList = [ + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: false, + aggregatable: false, + }, + { + name: 'extension', + type: 'string', + esTypes: ['text'], + count: 10, + scripted: true, + searchable: true, + aggregatable: true, + }, + ] as IndexPatternField[]; + + [ + { filter: {}, result: ['bytes', 'extension'] }, + { filter: { name: 'by' }, result: ['bytes'] }, + { filter: { aggregatable: true }, result: ['extension'] }, + { filter: { aggregatable: true, searchable: false }, result: [] }, + { filter: { type: 'string' }, result: ['extension'] }, + ].forEach((test) => { + const filtered = fieldList + .filter((field) => + isFieldFiltered(field, { ...defaultState, ...test.filter }, { bytes: 1, extension: 1 }) + ) + .map((field) => field.name); + + expect(filtered).toEqual(test.result); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_filter.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_filter.ts new file mode 100644 index 000000000000..d72af29b43e0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/field_filter.ts @@ -0,0 +1,89 @@ +/* + * 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 { IndexPatternField } from '../../../../../../data/public'; + +export interface FieldFilterState { + missing: boolean; + type: string; + name: string; + aggregatable: null | boolean; + searchable: null | boolean; +} + +export function getDefaultFieldFilter(): FieldFilterState { + return { + missing: true, + type: 'any', + name: '', + aggregatable: null, + searchable: null, + }; +} + +export function setFieldFilterProp( + state: FieldFilterState, + name: string, + value: string | boolean | null | undefined +): FieldFilterState { + const newState = { ...state }; + if (name === 'missing') { + newState.missing = Boolean(value); + } else if (name === 'aggregatable') { + newState.aggregatable = typeof value !== 'boolean' ? null : value; + } else if (name === 'searchable') { + newState.searchable = typeof value !== 'boolean' ? null : value; + } else if (name === 'name') { + newState.name = String(value); + } else if (name === 'type') { + newState.type = String(value); + } + return newState; +} + +export function isFieldFiltered( + field: IndexPatternField, + filterState: FieldFilterState, + fieldCounts: Record +): boolean { + const matchFilter = filterState.type === 'any' || field.type === filterState.type; + const isAggregatable = + filterState.aggregatable === null || field.aggregatable === filterState.aggregatable; + const isSearchable = + filterState.searchable === null || field.searchable === filterState.searchable; + const scriptedOrMissing = + !filterState.missing || + field.type === '_source' || + field.scripted || + fieldCounts[field.name] > 0; + const matchName = !filterState.name || field.name.indexOf(filterState.name) !== -1; + + return matchFilter && isAggregatable && isSearchable && scriptedOrMissing && matchName; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_details.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_details.ts new file mode 100644 index 000000000000..823cbde9ba72 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_details.ts @@ -0,0 +1,71 @@ +/* + * 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. + */ + +// @ts-ignore +import { i18n } from '@osd/i18n'; +import { getFieldValueCounts } from './field_calculator'; +import { IndexPattern, IndexPatternField } from '../../../../../../data/public'; + +export function getDetails( + field: IndexPatternField, + hits: Array>, + indexPattern?: IndexPattern +) { + const defaultDetails = { + error: '', + exists: 0, + total: 0, + buckets: [], + }; + if (!indexPattern) { + return { + ...defaultDetails, + error: i18n.translate('discover.fieldChooser.noIndexPatternSelectedErrorMessage', { + defaultMessage: 'Index pattern not specified.', + }), + }; + } + const details = { + ...defaultDetails, + ...getFieldValueCounts({ + hits, + field, + indexPattern, + count: 5, + grouped: false, + }), + }; + if (details.buckets) { + for (const bucket of details.buckets) { + bucket.display = indexPattern.getFormatterForField(field).convert(bucket.value); + } + } + return details; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_field_type_name.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_field_type_name.ts new file mode 100644 index 000000000000..38b18792d3e4 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_field_type_name.ts @@ -0,0 +1,85 @@ +/* + * 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 { i18n } from '@osd/i18n'; + +export function getFieldTypeName(type: string) { + switch (type) { + case 'boolean': + return i18n.translate('discover.fieldNameIcons.booleanAriaLabel', { + defaultMessage: 'Boolean field', + }); + case 'conflict': + return i18n.translate('discover.fieldNameIcons.conflictFieldAriaLabel', { + defaultMessage: 'Conflicting field', + }); + case 'date': + return i18n.translate('discover.fieldNameIcons.dateFieldAriaLabel', { + defaultMessage: 'Date field', + }); + case 'geo_point': + return i18n.translate('discover.fieldNameIcons.geoPointFieldAriaLabel', { + defaultMessage: 'Geo point field', + }); + case 'geo_shape': + return i18n.translate('discover.fieldNameIcons.geoShapeFieldAriaLabel', { + defaultMessage: 'Geo shape field', + }); + case 'ip': + return i18n.translate('discover.fieldNameIcons.ipAddressFieldAriaLabel', { + defaultMessage: 'IP address field', + }); + case 'murmur3': + return i18n.translate('discover.fieldNameIcons.murmur3FieldAriaLabel', { + defaultMessage: 'Murmur3 field', + }); + case 'number': + return i18n.translate('discover.fieldNameIcons.numberFieldAriaLabel', { + defaultMessage: 'Number field', + }); + case 'source': + // Note that this type is currently not provided, type for _source is undefined + return i18n.translate('discover.fieldNameIcons.sourceFieldAriaLabel', { + defaultMessage: 'Source field', + }); + case 'string': + return i18n.translate('discover.fieldNameIcons.stringFieldAriaLabel', { + defaultMessage: 'String field', + }); + case 'nested': + return i18n.translate('discover.fieldNameIcons.nestedFieldAriaLabel', { + defaultMessage: 'Nested field', + }); + default: + return i18n.translate('discover.fieldNameIcons.unknownFieldAriaLabel', { + defaultMessage: 'Unknown field', + }); + } +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_index_pattern_field_list.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_index_pattern_field_list.ts new file mode 100644 index 000000000000..b3a8ff5cd8d9 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_index_pattern_field_list.ts @@ -0,0 +1,53 @@ +/* + * 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 { difference } from 'lodash'; +import { IndexPattern, IndexPatternField } from 'src/plugins/data/public'; + +export function getIndexPatternFieldList( + indexPattern?: IndexPattern, + fieldCounts?: Record +) { + if (!indexPattern || !fieldCounts) return []; + + const fieldNamesInDocs = Object.keys(fieldCounts); + const fieldNamesInIndexPattern = indexPattern.fields.getAll().map((fld) => fld.name); + const unknownTypes: IndexPatternField[] = []; + + difference(fieldNamesInDocs, fieldNamesInIndexPattern).forEach((unknownFieldName) => { + unknownTypes.push({ + displayName: String(unknownFieldName), + name: String(unknownFieldName), + type: 'unknown', + } as IndexPatternField); + }); + + return [...indexPattern.fields.getAll(), ...unknownTypes]; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_warnings.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_warnings.ts new file mode 100644 index 000000000000..770a0ce664e4 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/get_warnings.ts @@ -0,0 +1,55 @@ +/* + * 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 { i18n } from '@osd/i18n'; +import { IndexPatternField } from '../../../../../../data/public'; + +export function getWarnings(field: IndexPatternField) { + let warnings = []; + + if (field.scripted) { + warnings.push( + i18n.translate( + 'discover.fieldChooser.discoverField.scriptedFieldsTakeLongExecuteDescription', + { + defaultMessage: 'Scripted fields can take a long time to execute.', + } + ) + ); + } + + if (warnings.length > 1) { + warnings = warnings.map(function (warning, i) { + return (i > 0 ? '\n' : '') + (i + 1) + ' - ' + warning; + }); + } + + return warnings; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/group_fields.test.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/group_fields.test.ts new file mode 100644 index 000000000000..7301ce3a4c96 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/group_fields.test.ts @@ -0,0 +1,125 @@ +/* + * 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 { groupFields } from './group_fields'; +import { getDefaultFieldFilter } from './field_filter'; + +describe('group_fields', function () { + it('should group fields in selected, popular, unpopular group', function () { + const fields = [ + { + name: 'category', + type: 'string', + esTypes: ['text'], + count: 1, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'currency', + type: 'string', + esTypes: ['keyword'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + { + name: 'customer_birth_date', + type: 'date', + esTypes: ['date'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + ]; + + const fieldCounts = { + category: 1, + currency: 1, + customer_birth_date: 1, + }; + + const fieldFilterState = getDefaultFieldFilter(); + + const actual = groupFields(fields as any, ['currency'], 5, fieldCounts, fieldFilterState); + expect(actual).toMatchInlineSnapshot(` + Object { + "popular": Array [ + Object { + "aggregatable": true, + "count": 1, + "esTypes": Array [ + "text", + ], + "name": "category", + "readFromDocValues": true, + "scripted": false, + "searchable": true, + "type": "string", + }, + ], + "selected": Array [ + Object { + "aggregatable": true, + "count": 0, + "esTypes": Array [ + "keyword", + ], + "name": "currency", + "readFromDocValues": true, + "scripted": false, + "searchable": true, + "type": "string", + }, + ], + "unpopular": Array [ + Object { + "aggregatable": true, + "count": 0, + "esTypes": Array [ + "date", + ], + "name": "customer_birth_date", + "readFromDocValues": true, + "scripted": false, + "searchable": true, + "type": "date", + }, + ], + } + `); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/group_fields.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/lib/group_fields.tsx new file mode 100644 index 000000000000..fad1db402467 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/group_fields.tsx @@ -0,0 +1,87 @@ +/* + * 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 { IndexPatternField } from 'src/plugins/data/public'; +import { FieldFilterState, isFieldFiltered } from './field_filter'; + +interface GroupedFields { + selected: IndexPatternField[]; + popular: IndexPatternField[]; + unpopular: IndexPatternField[]; +} + +/** + * group the fields into selected, popular and unpopular, filter by fieldFilterState + */ +export function groupFields( + fields: IndexPatternField[] | null, + columns: string[], + popularLimit: number, + fieldCounts: Record, + fieldFilterState: FieldFilterState +): GroupedFields { + const result: GroupedFields = { + selected: [], + popular: [], + unpopular: [], + }; + if (!Array.isArray(fields) || !Array.isArray(columns) || typeof fieldCounts !== 'object') { + return result; + } + + const popular = fields + .filter((field) => !columns.includes(field.name) && field.count) + .sort((a: IndexPatternField, b: IndexPatternField) => (b.count || 0) - (a.count || 0)) + .map((field) => field.name) + .slice(0, popularLimit); + + const compareFn = (a: IndexPatternField, b: IndexPatternField) => { + if (!a.displayName) { + return 0; + } + return a.displayName.localeCompare(b.displayName || ''); + }; + const fieldsSorted = fields.sort(compareFn); + + for (const field of fieldsSorted) { + if (!isFieldFiltered(field, fieldFilterState, fieldCounts)) { + continue; + } + if (columns.includes(field.name)) { + result.selected.push(field); + } else if (popular.includes(field.name) && field.type !== '_source') { + result.popular.push(field); + } else if (field.type !== '_source') { + result.unpopular.push(field); + } + } + + return result; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/lib/visualize_trigger_utils.ts b/src/plugins/discover_legacy/public/application/components/sidebar/lib/visualize_trigger_utils.ts new file mode 100644 index 000000000000..36a6bcf2e329 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/lib/visualize_trigger_utils.ts @@ -0,0 +1,122 @@ +/* + * 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 { + VISUALIZE_FIELD_TRIGGER, + VISUALIZE_GEO_FIELD_TRIGGER, + visualizeFieldTrigger, + visualizeGeoFieldTrigger, +} from '../../../../../../ui_actions/public'; +import { getUiActions } from '../../../../opensearch_dashboards_services'; +import { IndexPatternField, OSD_FIELD_TYPES } from '../../../../../../data/public'; + +function getTriggerConstant(type: string) { + return type === OSD_FIELD_TYPES.GEO_POINT || type === OSD_FIELD_TYPES.GEO_SHAPE + ? VISUALIZE_GEO_FIELD_TRIGGER + : VISUALIZE_FIELD_TRIGGER; +} + +function getTrigger(type: string) { + return type === OSD_FIELD_TYPES.GEO_POINT || type === OSD_FIELD_TYPES.GEO_SHAPE + ? visualizeGeoFieldTrigger + : visualizeFieldTrigger; +} + +async function getCompatibleActions( + fieldName: string, + indexPatternId: string, + contextualFields: string[], + trigger: typeof VISUALIZE_FIELD_TRIGGER | typeof VISUALIZE_GEO_FIELD_TRIGGER +) { + const compatibleActions = await getUiActions().getTriggerCompatibleActions(trigger, { + indexPatternId, + fieldName, + contextualFields, + }); + return compatibleActions; +} + +export async function getVisualizeHref( + field: IndexPatternField, + indexPatternId: string | undefined, + contextualFields: string[] +) { + if (!indexPatternId) return undefined; + const triggerOptions = { + indexPatternId, + fieldName: field.name, + contextualFields, + trigger: getTrigger(field.type), + }; + const compatibleActions = await getCompatibleActions( + field.name, + indexPatternId, + contextualFields, + getTriggerConstant(field.type) + ); + // enable the link only if only one action is registered + return compatibleActions.length === 1 + ? compatibleActions[0].getHref?.(triggerOptions) + : undefined; +} + +export function triggerVisualizeActions( + field: IndexPatternField, + indexPatternId: string | undefined, + contextualFields: string[] +) { + if (!indexPatternId) return; + const trigger = getTriggerConstant(field.type); + const triggerOptions = { + indexPatternId, + fieldName: field.name, + contextualFields, + }; + getUiActions().getTrigger(trigger).exec(triggerOptions); +} + +export async function isFieldVisualizable( + field: IndexPatternField, + indexPatternId: string | undefined, + contextualFields: string[] +) { + if (field.name === '_id' || !indexPatternId) { + // for first condition you'd get a 'Fielddata access on the _id field is disallowed' error on OpenSearch side. + return false; + } + const trigger = getTriggerConstant(field.type); + const compatibleActions = await getCompatibleActions( + field.name, + indexPatternId, + contextualFields, + trigger + ); + return compatibleActions.length > 0 && field.visualizable; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/string_progress_bar.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/string_progress_bar.tsx new file mode 100644 index 000000000000..dba087d0f9ed --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/string_progress_bar.tsx @@ -0,0 +1,46 @@ +/* + * 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 { EuiProgress } from '@elastic/eui'; + +interface Props { + percent: number; + count: number; + value: string; +} + +export function StringFieldProgressBar({ value, percent, count }: Props) { + const ariaLabel = `${value}: ${count} (${percent}%)`; + + return ( + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/types.ts b/src/plugins/discover_legacy/public/application/components/sidebar/types.ts new file mode 100644 index 000000000000..a43120b28e96 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/types.ts @@ -0,0 +1,52 @@ +/* + * 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. + */ + +export interface IndexPatternRef { + id: string; + title: string; +} + +export interface FieldDetails { + error: string; + exists: number; + total: number; + buckets: Bucket[]; +} + +export interface FieldValueCounts extends Partial { + missing?: number; +} + +export interface Bucket { + display: string; + value: string; + percent: number; + count: number; +} diff --git a/src/plugins/discover_legacy/public/application/components/skip_bottom_button/index.ts b/src/plugins/discover_legacy/public/application/components/skip_bottom_button/index.ts new file mode 100644 index 000000000000..094d8e286875 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/skip_bottom_button/index.ts @@ -0,0 +1,31 @@ +/* + * 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. + */ + +export { SkipBottomButton } from './skip_bottom_button'; diff --git a/src/plugins/discover_legacy/public/application/components/skip_bottom_button/skip_bottom_button.test.tsx b/src/plugins/discover_legacy/public/application/components/skip_bottom_button/skip_bottom_button.test.tsx new file mode 100644 index 000000000000..28ffef9dae86 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/skip_bottom_button/skip_bottom_button.test.tsx @@ -0,0 +1,51 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ReactWrapper } from 'enzyme'; +import { SkipBottomButton, SkipBottomButtonProps } from './skip_bottom_button'; + +describe('Skip to Bottom Button', function () { + let props: SkipBottomButtonProps; + let component: ReactWrapper; + + beforeAll(() => { + props = { + onClick: jest.fn(), + }; + }); + + it('should be clickable', function () { + component = mountWithIntl(); + component.simulate('click'); + expect(props.onClick).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/skip_bottom_button/skip_bottom_button.tsx b/src/plugins/discover_legacy/public/application/components/skip_bottom_button/skip_bottom_button.tsx new file mode 100644 index 000000000000..a1e5754cb312 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/skip_bottom_button/skip_bottom_button.tsx @@ -0,0 +1,66 @@ +/* + * 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 { EuiSkipLink } from '@elastic/eui'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; + +export interface SkipBottomButtonProps { + /** + * Action to perform on click + */ + onClick: () => void; +} + +export function SkipBottomButton({ onClick }: SkipBottomButtonProps) { + return ( + + { + // prevent the anchor to reload the page on click + event.preventDefault(); + // The destinationId prop cannot be leveraged here as the table needs + // to be updated first (angular logic) + onClick(); + }} + className="dscSkipButton" + destinationId="" + data-test-subj="discoverSkipTableButton" + > + + + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table.test.tsx b/src/plugins/discover_legacy/public/application/components/table/table.test.tsx new file mode 100644 index 000000000000..220ac57feae2 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table.test.tsx @@ -0,0 +1,279 @@ +/* + * 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 { mount } from 'enzyme'; +import { findTestSubject } from 'test_utils/helpers'; +import { DocViewTable } from './table'; +import { indexPatterns, IndexPattern } from '../../../../../data/public'; + +const indexPattern = ({ + fields: { + getAll: () => [ + { + name: '_index', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'message', + type: 'string', + scripted: false, + filterable: false, + }, + { + name: 'extension', + type: 'string', + scripted: false, + filterable: true, + }, + { + name: 'bytes', + type: 'number', + scripted: false, + filterable: true, + }, + { + name: 'scripted', + type: 'number', + scripted: true, + filterable: false, + }, + ], + }, + metaFields: ['_index', '_score'], + flattenHit: undefined, + formatHit: jest.fn((hit) => hit._source), +} as unknown) as IndexPattern; + +indexPattern.fields.getByName = (name: string) => { + return indexPattern.fields.getAll().find((field) => field.name === name); +}; + +indexPattern.flattenHit = indexPatterns.flattenHitWrapper(indexPattern, indexPattern.metaFields); + +describe('DocViewTable at Discover', () => { + // At Discover's main view, all buttons are rendered + // check for existence of action buttons and warnings + + const hit = { + _index: 'logstash-2014.09.09', + _type: 'doc', + _id: 'id123', + _score: 1, + _source: { + message: + 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. \ + Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus \ + et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, \ + ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. \ + Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, \ + rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. \ + Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. \ + Phasellus ullamcorper ipsum rutrum nunc. Nunc nonummy metus. Vestibulum volutpat pretium libero. Cras id dui. Aenean ut', + extension: 'html', + not_mapped: 'yes', + bytes: 100, + objectArray: [{ foo: true }], + relatedContent: { + test: 1, + }, + scripted: 123, + _underscore: 123, + }, + }; + + const props = { + hit, + columns: ['extension'], + indexPattern, + filter: jest.fn(), + onAddColumn: jest.fn(), + onRemoveColumn: jest.fn(), + }; + const component = mount(); + [ + { + _property: '_index', + addInclusiveFilterButton: true, + collapseBtn: false, + noMappingWarning: false, + toggleColumnButton: true, + underscoreWarning: false, + }, + { + _property: 'message', + addInclusiveFilterButton: false, + collapseBtn: true, + noMappingWarning: false, + toggleColumnButton: true, + underscoreWarning: false, + }, + { + _property: '_underscore', + addInclusiveFilterButton: false, + collapseBtn: false, + noMappingWarning: false, + toggleColumnButton: true, + underScoreWarning: true, + }, + { + _property: 'scripted', + addInclusiveFilterButton: false, + collapseBtn: false, + noMappingWarning: false, + toggleColumnButton: true, + underScoreWarning: false, + }, + { + _property: 'not_mapped', + addInclusiveFilterButton: false, + collapseBtn: false, + noMappingWarning: true, + toggleColumnButton: true, + underScoreWarning: false, + }, + ].forEach((check) => { + const rowComponent = findTestSubject(component, `tableDocViewRow-${check._property}`); + + it(`renders row for ${check._property}`, () => { + expect(rowComponent.length).toBe(1); + }); + + ([ + 'addInclusiveFilterButton', + 'collapseBtn', + 'toggleColumnButton', + 'underscoreWarning', + ] as const).forEach((element) => { + const elementExist = check[element]; + + if (typeof elementExist === 'boolean') { + const btn = findTestSubject(rowComponent, element); + + it(`renders ${element} for '${check._property}' correctly`, () => { + const disabled = btn.length ? btn.props().disabled : true; + const clickAble = btn.length && !disabled ? true : false; + expect(clickAble).toBe(elementExist); + }); + } + }); + + (['noMappingWarning'] as const).forEach((element) => { + const elementExist = check[element]; + + if (typeof elementExist === 'boolean') { + const el = findTestSubject(rowComponent, element); + + it(`renders ${element} for '${check._property}' correctly`, () => { + expect(el.length).toBe(elementExist ? 1 : 0); + }); + } + }); + }); +}); + +describe('DocViewTable at Discover Doc', () => { + const hit = { + _index: 'logstash-2014.09.09', + _score: 1, + _type: 'doc', + _id: 'id123', + _source: { + extension: 'html', + not_mapped: 'yes', + }, + }; + // here no action buttons are rendered + const props = { + hit, + indexPattern, + }; + const component = mount(); + const foundLength = findTestSubject(component, 'addInclusiveFilterButton').length; + + it(`renders no action buttons`, () => { + expect(foundLength).toBe(0); + }); +}); + +describe('DocViewTable at Discover Context', () => { + // here no toggleColumnButtons are rendered + const hit = { + _index: 'logstash-2014.09.09', + _type: 'doc', + _id: 'id123', + _score: 1, + _source: { + message: + 'Lorem ipsum dolor sit amet, consectetuer adipiscing elit. \ + Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus \ + et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, \ + ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. \ + Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim justo, \ + rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pretium. \ + Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifend tellus. \ + Phasellus ullamcorper ipsum rutrum nunc. Nunc nonummy metus. Vestibulum volutpat pretium libero. Cras id dui. Aenean ut', + }, + }; + const props = { + hit, + columns: ['extension'], + indexPattern, + filter: jest.fn(), + }; + + const component = mount(); + + it(`renders no toggleColumnButton`, () => { + const foundLength = findTestSubject(component, 'toggleColumnButtons').length; + expect(foundLength).toBe(0); + }); + + it(`renders addInclusiveFilterButton`, () => { + const row = findTestSubject(component, `tableDocViewRow-_index`); + const btn = findTestSubject(row, 'addInclusiveFilterButton'); + expect(btn.length).toBe(1); + btn.simulate('click'); + expect(props.filter).toBeCalled(); + }); + + it(`renders functional collapse button`, () => { + const btn = findTestSubject(component, `collapseBtn`); + const html = component.html(); + + expect(component.html()).toContain('truncate-by-height'); + + expect(btn.length).toBe(1); + btn.simulate('click'); + expect(component.html() !== html).toBeTruthy(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/table/table.tsx b/src/plugins/discover_legacy/public/application/components/table/table.tsx new file mode 100644 index 000000000000..90167a515985 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table.tsx @@ -0,0 +1,149 @@ +/* + * 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, { useState } from 'react'; +import { escapeRegExp } from 'lodash'; +import { DocViewTableRow } from './table_row'; +import { arrayContainsObjects, trimAngularSpan } from './table_helper'; +import { DocViewRenderProps } from '../../doc_views/doc_views_types'; + +const COLLAPSE_LINE_LENGTH = 350; + +export function DocViewTable({ + hit, + indexPattern, + filter, + columns, + onAddColumn, + onRemoveColumn, +}: DocViewRenderProps) { + const mapping = indexPattern.fields.getByName; + const flattened = indexPattern.flattenHit(hit); + const formatted = indexPattern.formatHit(hit, 'html'); + const [fieldRowOpen, setFieldRowOpen] = useState({} as Record); + + function toggleValueCollapse(field: string) { + fieldRowOpen[field] = fieldRowOpen[field] !== true; + setFieldRowOpen({ ...fieldRowOpen }); + } + + return ( + + + {Object.keys(flattened) + .sort() + .map((field) => { + const valueRaw = flattened[field]; + const value = trimAngularSpan(String(formatted[field])); + + const isCollapsible = value.length > COLLAPSE_LINE_LENGTH; + const isCollapsed = isCollapsible && !fieldRowOpen[field]; + const toggleColumn = + onRemoveColumn && onAddColumn && Array.isArray(columns) + ? () => { + if (columns.includes(field)) { + onRemoveColumn(field); + } else { + onAddColumn(field); + } + } + : undefined; + const isArrayOfObjects = + Array.isArray(flattened[field]) && arrayContainsObjects(flattened[field]); + const displayUnderscoreWarning = !mapping(field) && field.indexOf('_') === 0; + const displayNoMappingWarning = + !mapping(field) && !displayUnderscoreWarning && !isArrayOfObjects; + + // Discover doesn't flatten arrays of objects, so for documents with an `object` or `nested` field that + // contains an array, Discover will only detect the top level root field. We want to detect when those + // root fields are `nested` so that we can display the proper icon and label. However, those root + // `nested` fields are not a part of the index pattern. Their children are though, and contain nested path + // info. So to detect nested fields we look through the index pattern for nested children + // whose path begins with the current field. There are edge cases where + // this could incorrectly identify a plain `object` field as `nested`. Say we had the following document + // where `foo` is a plain object field and `bar` is a nested field. + // { + // "foo": [ + // { + // "bar": [ + // { + // "baz": "qux" + // } + // ] + // }, + // { + // "bar": [ + // { + // "baz": "qux" + // } + // ] + // } + // ] + // } + // + // The following code will search for `foo`, find it at the beginning of the path to the nested child field + // `foo.bar.baz` and incorrectly mark `foo` as nested. Any time we're searching for the name of a plain object + // field that happens to match a segment of a nested path, we'll get a false positive. + // We're aware of this issue and we'll have to live with + // it in the short term. The long term fix will be to add info about the `nested` and `object` root fields + // to the index pattern, but that has its own complications which you can read more about in the following + // issue: https://github.com/elastic/kibana/issues/54957 + const isNestedField = + !indexPattern.fields.getByName(field) && + !!indexPattern.fields.getAll().find((patternField) => { + // We only want to match a full path segment + const nestedRootRegex = new RegExp(escapeRegExp(field) + '(\\.|$)'); + return nestedRootRegex.test(patternField.subType?.nested?.path ?? ''); + }); + const fieldType = isNestedField ? 'nested' : indexPattern.fields.getByName(field)?.type; + + return ( + toggleValueCollapse(field)} + onToggleColumn={toggleColumn} + value={value} + valueRaw={valueRaw} + /> + ); + })} + +
+ ); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table_helper.test.ts b/src/plugins/discover_legacy/public/application/components/table/table_helper.test.ts new file mode 100644 index 000000000000..20c1092ef86d --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_helper.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { arrayContainsObjects } from './table_helper'; + +describe('arrayContainsObjects', () => { + it(`returns false for an array of primitives`, () => { + const actual = arrayContainsObjects(['test', 'test']); + expect(actual).toBeFalsy(); + }); + + it(`returns true for an array of objects`, () => { + const actual = arrayContainsObjects([{}, {}]); + expect(actual).toBeTruthy(); + }); + + it(`returns true for an array of objects and primitves`, () => { + const actual = arrayContainsObjects([{}, 'sdf']); + expect(actual).toBeTruthy(); + }); + + it(`returns false for an array of null values`, () => { + const actual = arrayContainsObjects([null, null]); + expect(actual).toBeFalsy(); + }); + + it(`returns false if no array is given`, () => { + const actual = arrayContainsObjects([null, null]); + expect(actual).toBeFalsy(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/table/table_helper.tsx b/src/plugins/discover_legacy/public/application/components/table/table_helper.tsx new file mode 100644 index 000000000000..2e63b43b8310 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_helper.tsx @@ -0,0 +1,43 @@ +/* + * 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. + */ + +/** + * Returns true if the given array contains at least 1 object + */ +export function arrayContainsObjects(value: unknown[]): boolean { + return Array.isArray(value) && value.some((v) => typeof v === 'object' && v !== null); +} + +/** + * Removes markup added by OpenSearch Dashboards fields html formatter + */ +export function trimAngularSpan(text: string): string { + return text.replace(/^/, '').replace(/<\/span>$/, ''); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table_row.tsx b/src/plugins/discover_legacy/public/application/components/table/table_row.tsx new file mode 100644 index 000000000000..95ba38106e3e --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_row.tsx @@ -0,0 +1,129 @@ +/* + * 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 classNames from 'classnames'; +import React, { ReactNode } from 'react'; +import { FieldMapping, DocViewFilterFn } from '../../doc_views/doc_views_types'; +import { DocViewTableRowBtnFilterAdd } from './table_row_btn_filter_add'; +import { DocViewTableRowBtnFilterRemove } from './table_row_btn_filter_remove'; +import { DocViewTableRowBtnToggleColumn } from './table_row_btn_toggle_column'; +import { DocViewTableRowBtnCollapse } from './table_row_btn_collapse'; +import { DocViewTableRowBtnFilterExists } from './table_row_btn_filter_exists'; +import { DocViewTableRowIconNoMapping } from './table_row_icon_no_mapping'; +import { DocViewTableRowIconUnderscore } from './table_row_icon_underscore'; +import { FieldName } from '../field_name/field_name'; + +export interface Props { + field: string; + fieldMapping?: FieldMapping; + fieldType: string; + displayNoMappingWarning: boolean; + displayUnderscoreWarning: boolean; + isCollapsible: boolean; + isColumnActive: boolean; + isCollapsed: boolean; + onToggleCollapse: () => void; + onFilter?: DocViewFilterFn; + onToggleColumn?: () => void; + value: string | ReactNode; + valueRaw: unknown; +} + +export function DocViewTableRow({ + field, + fieldMapping, + fieldType, + displayNoMappingWarning, + displayUnderscoreWarning, + isCollapsible, + isCollapsed, + isColumnActive, + onFilter, + onToggleCollapse, + onToggleColumn, + value, + valueRaw, +}: Props) { + const valueClassName = classNames({ + // eslint-disable-next-line @typescript-eslint/naming-convention + osdDocViewer__value: true, + 'truncate-by-height': isCollapsible && isCollapsed, + }); + + return ( + + {typeof onFilter === 'function' && ( + + onFilter(fieldMapping, valueRaw, '+')} + /> + onFilter(fieldMapping, valueRaw, '-')} + /> + {typeof onToggleColumn === 'function' && ( + + )} + onFilter('_exists_', field, '+')} + scripted={fieldMapping && fieldMapping.scripted} + /> + + )} + + + + + {isCollapsible && ( + + )} + {displayUnderscoreWarning && } + {displayNoMappingWarning && } +
+ + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table_row_btn_collapse.tsx b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_collapse.tsx new file mode 100644 index 000000000000..de25c73e9c95 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_collapse.tsx @@ -0,0 +1,56 @@ +/* + * 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 { i18n } from '@osd/i18n'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; + +export interface Props { + onClick: () => void; + isCollapsed: boolean; +} + +export function DocViewTableRowBtnCollapse({ onClick, isCollapsed }: Props) { + const label = i18n.translate('discover.docViews.table.toggleFieldDetails', { + defaultMessage: 'Toggle field details', + }); + return ( + + onClick()} + iconType={isCollapsed ? 'arrowRight' : 'arrowDown'} + iconSize={'s'} + /> + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_add.tsx b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_add.tsx new file mode 100644 index 000000000000..1707861faf28 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_add.tsx @@ -0,0 +1,69 @@ +/* + * 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 { FormattedMessage } from '@osd/i18n/react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +export interface Props { + onClick: () => void; + disabled: boolean; +} + +export function DocViewTableRowBtnFilterAdd({ onClick, disabled = false }: Props) { + const tooltipContent = disabled ? ( + + ) : ( + + ); + + return ( + + + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_exists.tsx b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_exists.tsx new file mode 100644 index 000000000000..d4f401282e14 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_exists.tsx @@ -0,0 +1,81 @@ +/* + * 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 { FormattedMessage } from '@osd/i18n/react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +export interface Props { + onClick: () => void; + disabled?: boolean; + scripted?: boolean; +} + +export function DocViewTableRowBtnFilterExists({ + onClick, + disabled = false, + scripted = false, +}: Props) { + const tooltipContent = disabled ? ( + scripted ? ( + + ) : ( + + ) + ) : ( + + ); + + return ( + + + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_remove.tsx b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_remove.tsx new file mode 100644 index 000000000000..3b58fbfdc282 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_remove.tsx @@ -0,0 +1,69 @@ +/* + * 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 { FormattedMessage } from '@osd/i18n/react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +export interface Props { + onClick: () => void; + disabled?: boolean; +} + +export function DocViewTableRowBtnFilterRemove({ onClick, disabled = false }: Props) { + const tooltipContent = disabled ? ( + + ) : ( + + ); + + return ( + + + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table_row_btn_toggle_column.tsx b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_toggle_column.tsx new file mode 100644 index 000000000000..74f0972fa0ee --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_row_btn_toggle_column.tsx @@ -0,0 +1,79 @@ +/* + * 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 { FormattedMessage } from '@osd/i18n/react'; +import { EuiToolTip, EuiButtonIcon } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +export interface Props { + active: boolean; + disabled?: boolean; + onClick: () => void; +} + +export function DocViewTableRowBtnToggleColumn({ onClick, active, disabled = false }: Props) { + if (disabled) { + return ( + + ); + } + return ( + + } + > + + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table_row_icon_no_mapping.tsx b/src/plugins/discover_legacy/public/application/components/table/table_row_icon_no_mapping.tsx new file mode 100644 index 000000000000..edc4bea91bd8 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_row_icon_no_mapping.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. + */ + +/* + * 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 { EuiIconTip } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +export function DocViewTableRowIconNoMapping() { + const ariaLabel = i18n.translate('discover.docViews.table.noCachedMappingForThisFieldAriaLabel', { + defaultMessage: 'Warning', + }); + const tooltipContent = i18n.translate( + 'discover.docViews.table.noCachedMappingForThisFieldTooltip', + { + defaultMessage: + 'No cached mapping for this field. Refresh field list from the Management > Index Patterns page', + } + ); + return ( + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/table/table_row_icon_underscore.tsx b/src/plugins/discover_legacy/public/application/components/table/table_row_icon_underscore.tsx new file mode 100644 index 000000000000..f1d09e2c8d44 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/table/table_row_icon_underscore.tsx @@ -0,0 +1,63 @@ +/* + * 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 { EuiIconTip } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +export function DocViewTableRowIconUnderscore() { + const ariaLabel = i18n.translate( + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedAriaLabel', + { + defaultMessage: 'Warning', + } + ); + const tooltipContent = i18n.translate( + 'discover.docViews.table.fieldNamesBeginningWithUnderscoreUnsupportedTooltip', + { + defaultMessage: 'Field names beginning with {underscoreSign} are not supported', + values: { underscoreSign: '_' }, + } + ); + + return ( + + ); +} diff --git a/src/plugins/discover/public/application/components/timechart_header/index.ts b/src/plugins/discover_legacy/public/application/components/timechart_header/index.ts similarity index 100% rename from src/plugins/discover/public/application/components/timechart_header/index.ts rename to src/plugins/discover_legacy/public/application/components/timechart_header/index.ts diff --git a/src/plugins/discover_legacy/public/application/components/timechart_header/timechart_header.test.tsx b/src/plugins/discover_legacy/public/application/components/timechart_header/timechart_header.test.tsx new file mode 100644 index 000000000000..9011c38a6acb --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/timechart_header/timechart_header.test.tsx @@ -0,0 +1,110 @@ +/* + * 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 { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ReactWrapper } from 'enzyme'; +import { TimechartHeader, TimechartHeaderProps } from './timechart_header'; +import { EuiIconTip } from '@elastic/eui'; +import { findTestSubject } from 'test_utils/helpers'; + +describe('timechart header', function () { + let props: TimechartHeaderProps; + let component: ReactWrapper; + + beforeAll(() => { + props = { + timeRange: { + from: 'May 14, 2020 @ 11:05:13.590', + to: 'May 14, 2020 @ 11:20:13.590', + }, + stateInterval: 's', + options: [ + { + display: 'Auto', + val: 'auto', + }, + { + display: 'Millisecond', + val: 'ms', + }, + { + display: 'Second', + val: 's', + }, + ], + onChangeInterval: jest.fn(), + bucketInterval: { + scaled: undefined, + description: 'second', + scale: undefined, + }, + }; + }); + + it('TimechartHeader not renders an info text when the showScaledInfo property is not provided', () => { + component = mountWithIntl(); + expect(component.find(EuiIconTip).length).toBe(0); + }); + + it('TimechartHeader renders an info when bucketInterval.scale is set to true', () => { + props.bucketInterval!.scaled = true; + component = mountWithIntl(); + expect(component.find(EuiIconTip).length).toBe(1); + }); + + it('expect to render the date range', function () { + component = mountWithIntl(); + const datetimeRangeText = findTestSubject(component, 'discoverIntervalDateRange'); + expect(datetimeRangeText.text()).toBe( + 'May 14, 2020 @ 11:05:13.590 - May 14, 2020 @ 11:20:13.590 per' + ); + }); + + it('expects to render a dropdown with the interval options', () => { + component = mountWithIntl(); + const dropdown = findTestSubject(component, 'discoverIntervalSelect'); + expect(dropdown.length).toBe(1); + // @ts-ignore + const values = dropdown.find('option').map((option) => option.prop('value')); + expect(values).toEqual(['auto', 'ms', 's']); + // @ts-ignore + const labels = dropdown.find('option').map((option) => option.text()); + expect(labels).toEqual(['Auto', 'Millisecond', 'Second']); + }); + + it('should change the interval', function () { + component = mountWithIntl(); + findTestSubject(component, 'discoverIntervalSelect').simulate('change', { + target: { value: 'ms' }, + }); + expect(props.onChangeInterval).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx b/src/plugins/discover_legacy/public/application/components/timechart_header/timechart_header.tsx similarity index 100% rename from src/plugins/discover/public/application/components/timechart_header/timechart_header.tsx rename to src/plugins/discover_legacy/public/application/components/timechart_header/timechart_header.tsx diff --git a/src/plugins/discover_legacy/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap b/src/plugins/discover_legacy/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap new file mode 100644 index 000000000000..342dea206c30 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`render 1`] = ` + + + +

+ +

+
+
+ + + } + onChoose={[Function]} + savedObjectMetaData={ + Array [ + Object { + "getIconForSavedObject": [Function], + "name": "Saved search", + "type": "search", + }, + ] + } + savedObjects={Object {}} + uiSettings={Object {}} + /> + + + + + + + + + + +
+`; diff --git a/src/plugins/discover/public/application/components/top_nav/open_search_panel.js b/src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.js similarity index 100% rename from src/plugins/discover/public/application/components/top_nav/open_search_panel.js rename to src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.js diff --git a/src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js b/src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.test.js similarity index 100% rename from src/plugins/discover/public/application/components/top_nav/open_search_panel.test.js rename to src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.test.js diff --git a/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js b/src/plugins/discover_legacy/public/application/components/top_nav/show_open_search_panel.js similarity index 100% rename from src/plugins/discover/public/application/components/top_nav/show_open_search_panel.js rename to src/plugins/discover_legacy/public/application/components/top_nav/show_open_search_panel.js diff --git a/src/plugins/discover/public/application/doc_views/doc_views_helpers.tsx b/src/plugins/discover_legacy/public/application/doc_views/doc_views_helpers.tsx similarity index 100% rename from src/plugins/discover/public/application/doc_views/doc_views_helpers.tsx rename to src/plugins/discover_legacy/public/application/doc_views/doc_views_helpers.tsx diff --git a/src/plugins/discover_legacy/public/application/doc_views/doc_views_registry.ts b/src/plugins/discover_legacy/public/application/doc_views/doc_views_registry.ts new file mode 100644 index 000000000000..56f167b5f2cc --- /dev/null +++ b/src/plugins/discover_legacy/public/application/doc_views/doc_views_registry.ts @@ -0,0 +1,70 @@ +/* + * 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 { auto } from 'angular'; +import { convertDirectiveToRenderFn } from './doc_views_helpers'; +import { DocView, DocViewInput, OpenSearchSearchHit, DocViewInputFn } from './doc_views_types'; + +export class DocViewsRegistry { + private docViews: DocView[] = []; + private angularInjectorGetter: (() => Promise) | null = null; + + setAngularInjectorGetter = (injectorGetter: () => Promise) => { + this.angularInjectorGetter = injectorGetter; + }; + + /** + * Extends and adds the given doc view to the registry array + */ + addDocView(docViewRaw: DocViewInput | DocViewInputFn) { + const docView = typeof docViewRaw === 'function' ? docViewRaw() : docViewRaw; + if (docView.directive) { + // convert angular directive to render function for backwards compatibility + docView.render = convertDirectiveToRenderFn(docView.directive, () => { + if (!this.angularInjectorGetter) { + throw new Error('Angular was not initialized'); + } + return this.angularInjectorGetter(); + }); + } + if (typeof docView.shouldShow !== 'function') { + docView.shouldShow = () => true; + } + this.docViews.push(docView as DocView); + } + /** + * Returns a sorted array of doc_views for rendering tabs + */ + getDocViewsSorted(hit: OpenSearchSearchHit) { + return this.docViews + .filter((docView) => docView.shouldShow(hit)) + .sort((a, b) => (Number(a.order) > Number(b.order) ? 1 : -1)); + } +} diff --git a/src/plugins/discover_legacy/public/application/doc_views/doc_views_types.ts b/src/plugins/discover_legacy/public/application/doc_views/doc_views_types.ts new file mode 100644 index 000000000000..961fc98516f6 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/doc_views/doc_views_types.ts @@ -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. + */ + +/* + * 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 { ComponentType } from 'react'; +import { IScope } from 'angular'; +import { SearchResponse } from 'elasticsearch'; +import { IndexPattern } from '../../../../data/public'; + +export interface AngularDirective { + controller: (...injectedServices: any[]) => void; + template: string; +} + +export type AngularScope = IScope; + +export type OpenSearchSearchHit = SearchResponse['hits']['hits'][number]; + +export interface FieldMapping { + filterable?: boolean; + scripted?: boolean; + rowCount?: number; + type: string; + name: string; +} + +export type DocViewFilterFn = ( + mapping: FieldMapping | string | undefined, + value: unknown, + mode: '+' | '-' +) => void; + +export interface DocViewRenderProps { + columns?: string[]; + filter?: DocViewFilterFn; + hit: OpenSearchSearchHit; + indexPattern: IndexPattern; + onAddColumn?: (columnName: string) => void; + onRemoveColumn?: (columnName: string) => void; +} +export type DocViewerComponent = ComponentType; +export type DocViewRenderFn = ( + domeNode: HTMLDivElement, + renderProps: DocViewRenderProps +) => () => void; + +export interface DocViewInput { + component?: DocViewerComponent; + directive?: AngularDirective; + order: number; + render?: DocViewRenderFn; + shouldShow?: (hit: OpenSearchSearchHit) => boolean; + title: string; +} + +export interface DocView extends DocViewInput { + shouldShow: (hit: OpenSearchSearchHit) => boolean; +} + +export type DocViewInputFn = () => DocViewInput; diff --git a/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_registry.ts b/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_registry.ts new file mode 100644 index 000000000000..16653f5d5377 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_registry.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DocViewLink } from './doc_views_links_types'; + +export class DocViewsLinksRegistry { + private docViewsLinks: DocViewLink[] = []; + + addDocViewLink(docViewLink: DocViewLink) { + this.docViewsLinks.push(docViewLink); + } + + getDocViewsLinksSorted() { + return this.docViewsLinks.sort((a, b) => (Number(a.order) > Number(b.order) ? 1 : -1)); + } +} diff --git a/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_types.ts b/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_types.ts new file mode 100644 index 000000000000..bbc5caadafcd --- /dev/null +++ b/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_types.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiListGroupItemProps } from '@elastic/eui'; +import { OpenSearchSearchHit } from '../doc_views/doc_views_types'; +import { IndexPattern } from '../../../../data/public'; + +export interface DocViewLink extends EuiListGroupItemProps { + href?: string; + order: number; + generateCb?( + renderProps: any + ): { + url: string; + hide?: boolean; + }; +} + +export interface DocViewLinkRenderProps { + columns?: string[]; + hit: OpenSearchSearchHit; + indexPattern: IndexPattern; +} diff --git a/src/plugins/discover/public/application/embeddable/constants.ts b/src/plugins/discover_legacy/public/application/embeddable/constants.ts similarity index 100% rename from src/plugins/discover/public/application/embeddable/constants.ts rename to src/plugins/discover_legacy/public/application/embeddable/constants.ts diff --git a/src/plugins/discover/public/application/embeddable/index.ts b/src/plugins/discover_legacy/public/application/embeddable/index.ts similarity index 100% rename from src/plugins/discover/public/application/embeddable/index.ts rename to src/plugins/discover_legacy/public/application/embeddable/index.ts diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.scss b/src/plugins/discover_legacy/public/application/embeddable/search_embeddable.scss similarity index 100% rename from src/plugins/discover/public/application/embeddable/search_embeddable.scss rename to src/plugins/discover_legacy/public/application/embeddable/search_embeddable.scss diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable.ts b/src/plugins/discover_legacy/public/application/embeddable/search_embeddable.ts similarity index 100% rename from src/plugins/discover/public/application/embeddable/search_embeddable.ts rename to src/plugins/discover_legacy/public/application/embeddable/search_embeddable.ts diff --git a/src/plugins/discover/public/application/embeddable/search_embeddable_factory.ts b/src/plugins/discover_legacy/public/application/embeddable/search_embeddable_factory.ts similarity index 100% rename from src/plugins/discover/public/application/embeddable/search_embeddable_factory.ts rename to src/plugins/discover_legacy/public/application/embeddable/search_embeddable_factory.ts diff --git a/src/plugins/discover/public/application/embeddable/search_template.html b/src/plugins/discover_legacy/public/application/embeddable/search_template.html similarity index 100% rename from src/plugins/discover/public/application/embeddable/search_template.html rename to src/plugins/discover_legacy/public/application/embeddable/search_template.html diff --git a/src/plugins/discover/public/application/embeddable/types.ts b/src/plugins/discover_legacy/public/application/embeddable/types.ts similarity index 100% rename from src/plugins/discover/public/application/embeddable/types.ts rename to src/plugins/discover_legacy/public/application/embeddable/types.ts diff --git a/src/plugins/discover_legacy/public/application/helpers/breadcrumbs.ts b/src/plugins/discover_legacy/public/application/helpers/breadcrumbs.ts new file mode 100644 index 000000000000..e30f50206aef --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/breadcrumbs.ts @@ -0,0 +1,51 @@ +/* + * 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 { i18n } from '@osd/i18n'; + +export function getRootBreadcrumbs() { + return [ + { + text: i18n.translate('discover.rootBreadcrumb', { + defaultMessage: 'Discover', + }), + href: '#/', + }, + ]; +} + +export function getSavedSearchBreadcrumbs($route: any) { + return [ + ...getRootBreadcrumbs(), + { + text: $route.current.locals.savedObjects.savedSearch.id, + }, + ]; +} diff --git a/src/plugins/discover_legacy/public/application/helpers/format_number_with_commas.ts b/src/plugins/discover_legacy/public/application/helpers/format_number_with_commas.ts new file mode 100644 index 000000000000..b1b3c96e0958 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/format_number_with_commas.ts @@ -0,0 +1,38 @@ +/* + * 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. + */ + +const COMMA_SEPARATOR_RE = /(\d)(?=(\d{3})+(?!\d))/g; + +/** + * Converts a number to a string and adds commas + * as thousands separators + */ +export const formatNumWithCommas = (input: number) => + String(input).replace(COMMA_SEPARATOR_RE, '$1,'); diff --git a/src/plugins/discover_legacy/public/application/helpers/get_index_pattern_id.ts b/src/plugins/discover_legacy/public/application/helpers/get_index_pattern_id.ts new file mode 100644 index 000000000000..dfb02c0b0740 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/get_index_pattern_id.ts @@ -0,0 +1,71 @@ +/* + * 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 { IIndexPattern } from '../../../../data/common/index_patterns'; + +export function findIndexPatternById( + indexPatterns: IIndexPattern[], + id: string +): IIndexPattern | undefined { + if (!Array.isArray(indexPatterns) || !id) { + return; + } + return indexPatterns.find((o) => o.id === id); +} + +/** + * Checks if the given defaultIndex exists and returns + * the first available index pattern id if not + */ +export function getFallbackIndexPatternId( + indexPatterns: IIndexPattern[], + defaultIndex: string = '' +): string { + if (defaultIndex && findIndexPatternById(indexPatterns, defaultIndex)) { + return defaultIndex; + } + return !indexPatterns || !indexPatterns.length || !indexPatterns[0].id ? '' : indexPatterns[0].id; +} + +/** + * A given index pattern id is checked for existence and a fallback is provided if it doesn't exist + * The provided defaultIndex is usually configured in Advanced Settings, if it's also invalid + * the first entry of the given list of Indexpatterns is used + */ +export function getIndexPatternId( + id: string = '', + indexPatterns: IIndexPattern[], + defaultIndex: string = '' +): string { + if (!id || !findIndexPatternById(indexPatterns, id)) { + return getFallbackIndexPatternId(indexPatterns, defaultIndex); + } + return id; +} diff --git a/src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.test.ts b/src/plugins/discover_legacy/public/application/helpers/get_switch_index_pattern_app_state.test.ts similarity index 100% rename from src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.test.ts rename to src/plugins/discover_legacy/public/application/helpers/get_switch_index_pattern_app_state.test.ts diff --git a/src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.ts b/src/plugins/discover_legacy/public/application/helpers/get_switch_index_pattern_app_state.ts similarity index 100% rename from src/plugins/discover/public/application/helpers/get_switch_index_pattern_app_state.ts rename to src/plugins/discover_legacy/public/application/helpers/get_switch_index_pattern_app_state.ts diff --git a/src/plugins/discover_legacy/public/application/helpers/index.ts b/src/plugins/discover_legacy/public/application/helpers/index.ts new file mode 100644 index 000000000000..d765fdf60cee --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/index.ts @@ -0,0 +1,32 @@ +/* + * 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. + */ + +export { shortenDottedString } from './shorten_dotted_string'; +export { formatNumWithCommas } from './format_number_with_commas'; diff --git a/src/plugins/discover_legacy/public/application/helpers/migrate_legacy_query.ts b/src/plugins/discover_legacy/public/application/helpers/migrate_legacy_query.ts new file mode 100644 index 000000000000..90458c135b98 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/migrate_legacy_query.ts @@ -0,0 +1,48 @@ +/* + * 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 { has } from 'lodash'; +import { Query } from 'src/plugins/data/public'; + +/** + * Creates a standardized query object from old queries that were either strings or pure OpenSearch query DSL + * + * @param query - a legacy query, what used to be stored in SearchSource's query property + * @return Object + */ + +export function migrateLegacyQuery(query: Query | { [key: string]: any } | string): Query { + // Lucene was the only option before, so language-less queries are all lucene + if (!has(query, 'language')) { + return { query, language: 'lucene' }; + } + + return query as Query; +} diff --git a/src/plugins/discover_legacy/public/application/helpers/popularize_field.test.ts b/src/plugins/discover_legacy/public/application/helpers/popularize_field.test.ts new file mode 100644 index 000000000000..cdd49c0f77f1 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/popularize_field.test.ts @@ -0,0 +1,93 @@ +/* + * 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, IndexPatternsService } from '../../../../data/public'; +import { popularizeField } from './popularize_field'; + +describe('Popularize field', () => { + test('returns undefined if index pattern lacks id', async () => { + const indexPattern = ({} as unknown) as IndexPattern; + const fieldName = '@timestamp'; + const indexPatternsService = ({} as unknown) as IndexPatternsService; + const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + expect(result).toBeUndefined(); + }); + + test('returns undefined if field not found', async () => { + const indexPattern = ({ + fields: { + getByName: () => {}, + }, + } as unknown) as IndexPattern; + const fieldName = '@timestamp'; + const indexPatternsService = ({} as unknown) as IndexPatternsService; + const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + expect(result).toBeUndefined(); + }); + + test('returns undefined if successful', async () => { + const field = { + count: 0, + }; + const indexPattern = ({ + id: 'id', + fields: { + getByName: () => field, + }, + } as unknown) as IndexPattern; + const fieldName = '@timestamp'; + const indexPatternsService = ({ + updateSavedObject: async () => {}, + } as unknown) as IndexPatternsService; + const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + expect(result).toBeUndefined(); + expect(field.count).toEqual(1); + }); + + test('hides errors', async () => { + const field = { + count: 0, + }; + const indexPattern = ({ + id: 'id', + fields: { + getByName: () => field, + }, + } as unknown) as IndexPattern; + const fieldName = '@timestamp'; + const indexPatternsService = ({ + updateSavedObject: async () => { + throw new Error('unknown error'); + }, + } as unknown) as IndexPatternsService; + const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/helpers/popularize_field.ts b/src/plugins/discover_legacy/public/application/helpers/popularize_field.ts new file mode 100644 index 000000000000..e7c4b900fa19 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/popularize_field.ts @@ -0,0 +1,52 @@ +/* + * 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, IndexPatternsService } from '../../../../data/public'; + +async function popularizeField( + indexPattern: IndexPattern, + fieldName: string, + indexPatternsService: IndexPatternsService +) { + if (!indexPattern.id) return; + const field = indexPattern.fields.getByName(fieldName); + if (!field) { + return; + } + + field.count++; + // Catch 409 errors caused by user adding columns in a higher frequency that the changes can be persisted to OpenSearch + try { + await indexPatternsService.updateSavedObject(indexPattern, 0, true); + // eslint-disable-next-line no-empty + } catch {} +} + +export { popularizeField }; diff --git a/src/plugins/discover_legacy/public/application/helpers/shorten_dotted_string.ts b/src/plugins/discover_legacy/public/application/helpers/shorten_dotted_string.ts new file mode 100644 index 000000000000..39450f8c82c0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/shorten_dotted_string.ts @@ -0,0 +1,37 @@ +/* + * 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. + */ + +const DOT_PREFIX_RE = /(.).+?\./g; + +/** + * Convert a dot.notated.string into a short + * version (d.n.string) + */ +export const shortenDottedString = (input: string) => input.replace(DOT_PREFIX_RE, '$1.'); diff --git a/src/plugins/discover_legacy/public/application/helpers/validate_time_range.test.ts b/src/plugins/discover_legacy/public/application/helpers/validate_time_range.test.ts new file mode 100644 index 000000000000..902f3d8a4b62 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/validate_time_range.test.ts @@ -0,0 +1,58 @@ +/* + * 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 { validateTimeRange } from './validate_time_range'; +import { notificationServiceMock } from '../../../../../core/public/mocks'; + +describe('Discover validateTimeRange', () => { + test('validates given time ranges correctly', async () => { + const { toasts } = notificationServiceMock.createStartContract(); + [ + { from: '', to: '', result: false }, + { from: 'now', to: 'now+1h', result: true }, + { from: 'now', to: 'lala+1h', result: false }, + { from: '', to: 'now', result: false }, + { from: 'now', to: '', result: false }, + { from: ' 2020-06-02T13:36:13.689Z', to: 'now', result: true }, + { from: ' 2020-06-02T13:36:13.689Z', to: '2020-06-02T13:36:13.690Z', result: true }, + ].map((test) => { + expect(validateTimeRange({ from: test.from, to: test.to }, toasts)).toEqual(test.result); + }); + }); + + test('displays a toast when invalid data is entered', async () => { + const { toasts } = notificationServiceMock.createStartContract(); + expect(validateTimeRange({ from: 'now', to: 'null' }, toasts)).toEqual(false); + expect(toasts.addDanger).toHaveBeenCalledWith({ + title: 'Invalid time range', + text: "The provided time range is invalid. (from: 'now', to: 'null')", + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/helpers/validate_time_range.ts b/src/plugins/discover_legacy/public/application/helpers/validate_time_range.ts new file mode 100644 index 000000000000..d23a84aabb14 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/validate_time_range.ts @@ -0,0 +1,61 @@ +/* + * 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 dateMath from '@elastic/datemath'; +import { i18n } from '@osd/i18n'; +import { ToastsStart } from 'opensearch-dashboards/public'; + +/** + * Validates a given time filter range, provided by URL or UI + * Unless valid, it returns false and displays a notification + */ +export function validateTimeRange( + { from, to }: { from: string; to: string }, + toastNotifications: ToastsStart +): boolean { + const fromMoment = dateMath.parse(from); + const toMoment = dateMath.parse(to); + if (!fromMoment || !toMoment || !fromMoment.isValid() || !toMoment.isValid()) { + toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.invalidTimeRangeTitle', { + defaultMessage: `Invalid time range`, + }), + text: i18n.translate('discover.notifications.invalidTimeRangeText', { + defaultMessage: `The provided time range is invalid. (from: '{from}', to: '{to}')`, + values: { + from, + to, + }, + }), + }); + return false; + } + return true; +} diff --git a/src/plugins/discover/public/application/index.scss b/src/plugins/discover_legacy/public/application/index.scss similarity index 100% rename from src/plugins/discover/public/application/index.scss rename to src/plugins/discover_legacy/public/application/index.scss diff --git a/src/plugins/discover_legacy/public/build_services.ts b/src/plugins/discover_legacy/public/build_services.ts new file mode 100644 index 000000000000..3fdafcff0c40 --- /dev/null +++ b/src/plugins/discover_legacy/public/build_services.ts @@ -0,0 +1,129 @@ +/* + * 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 { History } from 'history'; + +import { + Capabilities, + ChromeStart, + CoreStart, + DocLinksStart, + ToastsStart, + IUiSettingsClient, + PluginInitializerContext, +} from 'opensearch-dashboards/public'; +import { + FilterManager, + TimefilterContract, + IndexPatternsContract, + DataPublicPluginStart, +} from 'src/plugins/data/public'; +import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; +import { SharePluginStart } from 'src/plugins/share/public'; +import { ChartsPluginStart } from 'src/plugins/charts/public'; +import { VisualizationsStart } from 'src/plugins/visualizations/public'; +import { SavedObjectOpenSearchDashboardsServices } from 'src/plugins/saved_objects/public'; + +import { DiscoverStartPlugins } from './plugin'; +import { createSavedSearchesLoader, SavedSearch } from './saved_searches'; +import { getHistory } from './opensearch_dashboards_services'; +import { OpenSearchDashboardsLegacyStart } from '../../opensearch_dashboards_legacy/public'; +import { UrlForwardingStart } from '../../url_forwarding/public'; +import { NavigationPublicPluginStart } from '../../navigation/public'; + +export interface DiscoverServices { + addBasePath: (path: string) => string; + capabilities: Capabilities; + chrome: ChromeStart; + core: CoreStart; + data: DataPublicPluginStart; + docLinks: DocLinksStart; + history: () => History; + theme: ChartsPluginStart['theme']; + filterManager: FilterManager; + indexPatterns: IndexPatternsContract; + inspector: InspectorPublicPluginStart; + metadata: { branch: string }; + navigation: NavigationPublicPluginStart; + share?: SharePluginStart; + opensearchDashboardsLegacy: OpenSearchDashboardsLegacyStart; + urlForwarding: UrlForwardingStart; + timefilter: TimefilterContract; + toastNotifications: ToastsStart; + getSavedSearchById: (id: string) => Promise; + getSavedSearchUrlById: (id: string) => Promise; + getEmbeddableInjector: any; + uiSettings: IUiSettingsClient; + visualizations: VisualizationsStart; +} + +export async function buildServices( + core: CoreStart, + plugins: DiscoverStartPlugins, + context: PluginInitializerContext, + getEmbeddableInjector: any +): Promise { + const services: SavedObjectOpenSearchDashboardsServices = { + savedObjectsClient: core.savedObjects.client, + indexPatterns: plugins.data.indexPatterns, + search: plugins.data.search, + chrome: core.chrome, + overlays: core.overlays, + }; + const savedObjectService = createSavedSearchesLoader(services); + + return { + addBasePath: core.http.basePath.prepend, + capabilities: core.application.capabilities, + chrome: core.chrome, + core, + data: plugins.data, + docLinks: core.docLinks, + theme: plugins.charts.theme, + filterManager: plugins.data.query.filterManager, + getEmbeddableInjector, + getSavedSearchById: async (id: string) => savedObjectService.get(id), + getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id), + history: getHistory, + indexPatterns: plugins.data.indexPatterns, + inspector: plugins.inspector, + metadata: { + branch: context.env.packageInfo.branch, + }, + navigation: plugins.navigation, + share: plugins.share, + opensearchDashboardsLegacy: plugins.opensearchDashboardsLegacy, + urlForwarding: plugins.urlForwarding, + timefilter: plugins.data.query.timefilter.timefilter, + toastNotifications: core.notifications.toasts, + uiSettings: core.uiSettings, + visualizations: plugins.visualizations, + }; +} diff --git a/src/plugins/discover/public/get_inner_angular.ts b/src/plugins/discover_legacy/public/get_inner_angular.ts similarity index 100% rename from src/plugins/discover/public/get_inner_angular.ts rename to src/plugins/discover_legacy/public/get_inner_angular.ts diff --git a/src/plugins/discover_legacy/public/index.ts b/src/plugins/discover_legacy/public/index.ts new file mode 100644 index 000000000000..6c9ab46b656e --- /dev/null +++ b/src/plugins/discover_legacy/public/index.ts @@ -0,0 +1,41 @@ +/* + * 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 { PluginInitializerContext } from 'opensearch-dashboards/public'; +import { DiscoverPlugin } from './plugin'; + +export { DiscoverSetup, DiscoverStart } from './plugin'; +export function plugin(initializerContext: PluginInitializerContext) { + return new DiscoverPlugin(initializerContext); +} + +export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; +export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; +export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator'; diff --git a/src/plugins/discover_legacy/public/mocks.ts b/src/plugins/discover_legacy/public/mocks.ts new file mode 100644 index 000000000000..4724ced290ff --- /dev/null +++ b/src/plugins/discover_legacy/public/mocks.ts @@ -0,0 +1,61 @@ +/* + * 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 { DiscoverSetup, DiscoverStart } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = { + docViews: { + addDocView: jest.fn(), + }, + docViewsLinks: { + addDocViewLink: jest.fn(), + }, + }; + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = { + savedSearchLoader: {} as any, + urlGenerator: { + createUrl: jest.fn(), + } as any, + }; + return startContract; +}; + +export const discoverPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/src/plugins/discover_legacy/public/opensearch_dashboards_services.ts b/src/plugins/discover_legacy/public/opensearch_dashboards_services.ts new file mode 100644 index 000000000000..8531564e0cc7 --- /dev/null +++ b/src/plugins/discover_legacy/public/opensearch_dashboards_services.ts @@ -0,0 +1,129 @@ +/* + * 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 _ from 'lodash'; +import { createHashHistory } from 'history'; +import { ScopedHistory, AppMountParameters } from 'opensearch-dashboards/public'; +import { UiActionsStart } from 'src/plugins/ui_actions/public'; +import { DiscoverServices } from './build_services'; +import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; +import { search } from '../../data/public'; +import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; +import { DocViewsLinksRegistry } from './application/doc_views_links/doc_views_links_registry'; + +let angularModule: any = null; +let services: DiscoverServices | null = null; +let uiActions: UiActionsStart; + +/** + * set bootstrapped inner angular module + */ +export function setAngularModule(module: any) { + angularModule = module; +} + +/** + * get boostrapped inner angular module + */ +export function getAngularModule() { + return angularModule; +} + +export function getServices(): DiscoverServices { + if (!services) { + throw new Error('Discover services are not yet available'); + } + return services; +} + +export function setServices(newServices: any) { + services = newServices; +} + +export const setUiActions = (pluginUiActions: UiActionsStart) => (uiActions = pluginUiActions); +export const getUiActions = () => uiActions; + +export const [getHeaderActionMenuMounter, setHeaderActionMenuMounter] = createGetterSetter< + AppMountParameters['setHeaderActionMenu'] +>('headerActionMenuMounter'); + +export const [getUrlTracker, setUrlTracker] = createGetterSetter<{ + setTrackedUrl: (url: string) => void; + restorePreviousUrl: () => void; +}>('urlTracker'); + +export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( + 'DocViewsRegistry' +); + +export const [getDocViewsLinksRegistry, setDocViewsLinksRegistry] = createGetterSetter< + DocViewsLinksRegistry +>('DocViewsLinksRegistry'); +/** + * Makes sure discover and context are using one instance of history. + */ +export const getHistory = _.once(() => createHashHistory()); + +/** + * Discover currently uses two `history` instances: one from OpenSearch Dashboards Platform and + * another from `history` package. Below function is used every time Discover + * app is loaded to synchronize both instances. + * + * This helper is temporary until https://github.com/elastic/kibana/issues/65161 is resolved. + */ +export const syncHistoryLocations = () => { + const h = getHistory(); + Object.assign(h.location, createHashHistory().location); + return h; +}; + +export const [getScopedHistory, setScopedHistory] = createGetterSetter( + 'scopedHistory' +); + +export const { getRequestInspectorStats, getResponseInspectorStats, tabifyAggResponse } = search; +export { unhashUrl, redirectWhenMissing } from '../../opensearch_dashboards_utils/public'; +export { + formatMsg, + formatStack, + subscribeWithScope, +} from '../../opensearch_dashboards_legacy/public'; + +// EXPORT types +export { + IndexPatternsContract, + IIndexPattern, + IndexPattern, + indexPatterns, + IFieldType, + ISearchSource, + OpenSearchQuerySortValue, + SortDirection, +} from '../../data/public'; diff --git a/src/plugins/discover_legacy/public/plugin.ts b/src/plugins/discover_legacy/public/plugin.ts new file mode 100644 index 000000000000..7e855b707891 --- /dev/null +++ b/src/plugins/discover_legacy/public/plugin.ts @@ -0,0 +1,487 @@ +/* + * 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 { i18n } from '@osd/i18n'; +import angular, { auto } from 'angular'; +import { BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; + +import { + AppMountParameters, + AppUpdater, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from 'opensearch-dashboards/public'; +import { UiActionsStart, UiActionsSetup } from 'src/plugins/ui_actions/public'; +import { EmbeddableStart, EmbeddableSetup } from 'src/plugins/embeddable/public'; +import { ChartsPluginStart } from 'src/plugins/charts/public'; +import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; +import { SharePluginStart, SharePluginSetup, UrlGeneratorContract } from 'src/plugins/share/public'; +import { VisualizationsStart, VisualizationsSetup } from 'src/plugins/visualizations/public'; +import { + OpenSearchDashboardsLegacySetup, + OpenSearchDashboardsLegacyStart, +} from 'src/plugins/opensearch_dashboards_legacy/public'; +import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; +import { HomePublicPluginSetup } from 'src/plugins/home/public'; +import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; +import { stringify } from 'query-string'; +import rison from 'rison-node'; +import { NEW_DISCOVER_APP } from '../../discover/public'; +import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; +import { SavedObjectLoader } from '../../saved_objects/public'; +import { createOsdUrlTracker, url } from '../../opensearch_dashboards_utils/public'; +import { UrlGeneratorState } from '../../share/public'; +import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; +import { DocViewLink } from './application/doc_views_links/doc_views_links_types'; +import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; +import { DocViewsLinksRegistry } from './application/doc_views_links/doc_views_links_registry'; +import { DocViewTable } from './application/components/table/table'; +import { JsonCodeBlock } from './application/components/json_code_block/json_code_block'; +import { + setDocViewsRegistry, + setDocViewsLinksRegistry, + setUrlTracker, + setAngularModule, + setServices, + setHeaderActionMenuMounter, + setUiActions, + setScopedHistory, + getScopedHistory, + syncHistoryLocations, + getServices, +} from './opensearch_dashboards_services'; +import { createSavedSearchesLoader } from './saved_searches'; +import { buildServices } from './build_services'; +import { + DiscoverUrlGeneratorState, + DISCOVER_APP_URL_GENERATOR, + DiscoverUrlGenerator, +} from './url_generator'; +import { SearchEmbeddableFactory } from './application/embeddable'; +import { AppNavLinkStatus } from '../../../core/public'; +import { ViewRedirectParams } from '../../data_explorer/public'; + +declare module '../../share/public' { + export interface UrlGeneratorStateMapping { + [DISCOVER_APP_URL_GENERATOR]: UrlGeneratorState; + } +} + +/** + * @public + */ +export interface DiscoverSetup { + docViews: { + /** + * Add new doc view shown along with table view and json view in the details of each document in Discover. + * Both react and angular doc views are supported. + * @param docViewRaw + */ + addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; + }; + + docViewsLinks: { + addDocViewLink(docViewLinkRaw: DocViewLink): void; + }; +} + +export interface DiscoverStart { + savedSearchLoader: SavedObjectLoader; + + /** + * `share` plugin URL generator for Discover app. Use it to generate links into + * Discover application, example: + * + * ```ts + * const url = await plugins.discover.urlGenerator.createUrl({ + * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', + * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', + * timeRange: { + * to: 'now', + * from: 'now-15m', + * mode: 'relative', + * }, + * }); + * ``` + */ + readonly urlGenerator: undefined | UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; +} + +/** + * @internal + */ +export interface DiscoverSetupPlugins { + share?: SharePluginSetup; + uiActions: UiActionsSetup; + embeddable: EmbeddableSetup; + opensearchDashboardsLegacy: OpenSearchDashboardsLegacySetup; + urlForwarding: UrlForwardingSetup; + home?: HomePublicPluginSetup; + visualizations: VisualizationsSetup; + data: DataPublicPluginSetup; +} + +/** + * @internal + */ +export interface DiscoverStartPlugins { + uiActions: UiActionsStart; + embeddable: EmbeddableStart; + navigation: NavigationStart; + charts: ChartsPluginStart; + data: DataPublicPluginStart; + share?: SharePluginStart; + opensearchDashboardsLegacy: OpenSearchDashboardsLegacyStart; + urlForwarding: UrlForwardingStart; + inspector: InspectorPublicPluginStart; + visualizations: VisualizationsStart; +} + +const innerAngularName = 'app/discover'; +const embeddableAngularName = 'app/discoverEmbeddable'; + +/** + * Contains Discover, one of the oldest parts of OpenSearch Dashboards + * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular + * Discover provides embeddables, those contain a slimmer Angular + */ +export class DiscoverPlugin + implements Plugin { + constructor(private readonly initializerContext: PluginInitializerContext) {} + + private appStateUpdater = new BehaviorSubject(() => ({})); + private docViewsRegistry: DocViewsRegistry | null = null; + private docViewsLinksRegistry: DocViewsLinksRegistry | null = null; + private embeddableInjector: auto.IInjectorService | null = null; + private stopUrlTracking: (() => void) | undefined = undefined; + private servicesInitialized: boolean = false; + private innerAngularInitialized: boolean = false; + private urlGenerator?: DiscoverStart['urlGenerator']; + + /** + * why are those functions public? they are needed for some mocha tests + * can be removed once all is Jest + */ + public initializeInnerAngular?: () => void; + public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; + + setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { + const baseUrl = core.http.basePath.prepend('/app/discover'); + + if (plugins.share) { + this.urlGenerator = plugins.share.urlGenerators.registerUrlGenerator( + new DiscoverUrlGenerator({ + appBasePath: baseUrl, + useHash: core.uiSettings.get('state:storeInSessionStorage'), + }) + ); + } + + this.docViewsRegistry = new DocViewsRegistry(); + setDocViewsRegistry(this.docViewsRegistry); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.table.tableTitle', { + defaultMessage: 'Table', + }), + order: 10, + component: DocViewTable, + }); + + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.json.jsonTitle', { + defaultMessage: 'JSON', + }), + order: 20, + component: JsonCodeBlock, + }); + + this.docViewsLinksRegistry = new DocViewsLinksRegistry(); + setDocViewsLinksRegistry(this.docViewsLinksRegistry); + + this.docViewsLinksRegistry.addDocViewLink({ + label: i18n.translate('discover.docTable.tableRow.viewSurroundingDocumentsLinkText', { + defaultMessage: 'View surrounding documents', + }), + generateCb: (renderProps: any) => { + const globalFilters: any = getServices().filterManager.getGlobalFilters(); + const appFilters: any = getServices().filterManager.getAppFilters(); + + const hash = stringify( + url.encodeQuery({ + _g: rison.encode({ + filters: globalFilters || [], + }), + _a: rison.encode({ + columns: renderProps.columns, + filters: (appFilters || []).map(opensearchFilters.disableFilter), + }), + }), + { encode: false, sort: false } + ); + + return { + url: `#/context/${encodeURIComponent(renderProps.indexPattern.id)}/${encodeURIComponent( + renderProps.hit._id + )}?${hash}`, + hide: !renderProps.indexPattern.isTimeBased(), + }; + }, + order: 1, + }); + + this.docViewsLinksRegistry.addDocViewLink({ + label: i18n.translate('discover.docTable.tableRow.viewSingleDocumentLinkText', { + defaultMessage: 'View single document', + }), + generateCb: (renderProps) => ({ + url: `#/doc/${renderProps.indexPattern.id}/${ + renderProps.hit._index + }?id=${encodeURIComponent(renderProps.hit._id)}`, + }), + order: 2, + }); + + const { + appMounted, + appUnMounted, + stop: stopUrlTracker, + setActiveUrl: setTrackedUrl, + restorePreviousUrl, + } = createOsdUrlTracker({ + // we pass getter here instead of plain `history`, + // so history is lazily created (when app is mounted) + // this prevents redundant `#` when not in discover app + getHistory: getScopedHistory, + baseUrl, + defaultSubUrl: '#/', + storageKey: `lastUrl:${core.http.basePath.get()}:discover_legacy`, + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + osdUrlKey: '_g', + stateUpdate$: plugins.data.query.state$.pipe( + filter( + ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) + ), + map(({ state }) => ({ + ...state, + filters: state.filters?.filter(opensearchFilters.isFilterPinned), + })) + ), + }, + ], + }); + setUrlTracker({ setTrackedUrl, restorePreviousUrl }); + this.stopUrlTracking = () => { + stopUrlTracker(); + }; + + this.docViewsRegistry.setAngularInjectorGetter(this.getEmbeddableInjector); + core.application.register({ + id: 'discoverLegacy', + title: 'Discover Legacy', + defaultPath: '#/', + navLinkStatus: AppNavLinkStatus.hidden, + mount: async (params: AppMountParameters) => { + if (!this.initializeServices) { + throw Error('Discover plugin method initializeServices is undefined'); + } + if (!this.initializeInnerAngular) { + throw Error('Discover plugin method initializeInnerAngular is undefined'); + } + + // If a user explicitly tries to access the legacy app URL + const { + core: { + application: { navigateToApp }, + }, + } = await this.initializeServices(); + const path = window.location.hash; + + const v2Enabled = core.uiSettings.get(NEW_DISCOVER_APP); + if (v2Enabled) { + navigateToApp('discover', { + replace: true, + path, + }); + } + setScopedHistory(params.history); + setHeaderActionMenuMounter(params.setHeaderActionMenu); + syncHistoryLocations(); + appMounted(); + const { + plugins: { data: dataStart }, + } = await this.initializeServices(); + await this.initializeInnerAngular(); + + // make sure the index pattern list is up to date + await dataStart.indexPatterns.clearCache(); + const { renderApp } = await import('./application/application'); + params.element.classList.add('dscAppWrapper'); + const unmount = await renderApp(innerAngularName, params.element); + return () => { + params.element.classList.remove('dscAppWrapper'); + unmount(); + appUnMounted(); + }; + }, + }); + + plugins.urlForwarding.forwardApp('doc', 'discoverLegacy', (path) => { + return `#${path}`; + }); + plugins.urlForwarding.forwardApp('context', 'discoverLegacy', (path) => { + const urlParts = path.split('/'); + // take care of urls containing legacy url, those split in the following way + // ["", "context", indexPatternId, _type, id + params] + if (urlParts[4]) { + // remove _type part + const newPath = [...urlParts.slice(0, 3), ...urlParts.slice(4)].join('/'); + return `#${newPath}`; + } + return `#${path}`; + }); + plugins.urlForwarding.forwardApp('discover', 'discoverLegacy', (path) => { + const [, id, tail] = /discover\/([^\?]+)(.*)/.exec(path) || []; + if (!id) { + return `#${path.replace('/discover', '') || '/'}`; + } + return `#/view/${id}${tail || ''}`; + }); + + this.registerEmbeddable(core, plugins); + + return { + docViews: { + addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), + }, + docViewsLinks: { + addDocViewLink: this.docViewsLinksRegistry.addDocViewLink.bind(this.docViewsLinksRegistry), + }, + }; + } + + start(core: CoreStart, plugins: DiscoverStartPlugins) { + // we need to register the application service at setup, but to render it + // there are some start dependencies necessary, for this reason + // initializeInnerAngular + initializeServices are assigned at start and used + // when the application/embeddable is mounted + this.initializeInnerAngular = async () => { + if (this.innerAngularInitialized) { + return; + } + // this is used by application mount and tests + const { getInnerAngularModule } = await import('./get_inner_angular'); + const module = getInnerAngularModule( + innerAngularName, + core, + plugins, + this.initializerContext + ); + setAngularModule(module); + this.innerAngularInitialized = true; + }; + + setUiActions(plugins.uiActions); + + this.initializeServices = async () => { + if (this.servicesInitialized) { + return { core, plugins }; + } + const services = await buildServices( + core, + plugins, + this.initializerContext, + this.getEmbeddableInjector + ); + setServices(services); + this.servicesInitialized = true; + + return { core, plugins }; + }; + + return { + urlGenerator: this.urlGenerator, + savedSearchLoader: createSavedSearchesLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: plugins.data.indexPatterns, + search: plugins.data.search, + chrome: core.chrome, + overlays: core.overlays, + }), + }; + } + + stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } + + /** + * register embeddable with a slimmer embeddable version of inner angular + */ + private registerEmbeddable(core: CoreSetup, plugins: DiscoverSetupPlugins) { + if (!this.getEmbeddableInjector) { + throw Error('Discover plugin method getEmbeddableInjector is undefined'); + } + + const getStartServices = async () => { + const [coreStart, deps] = await core.getStartServices(); + return { + executeTriggerActions: deps.uiActions.executeTriggerActions, + isEditable: () => coreStart.application.capabilities.discover.save as boolean, + }; + }; + + const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector); + plugins.embeddable.registerEmbeddableFactory(factory.type, factory); + } + + private getEmbeddableInjector = async () => { + if (!this.embeddableInjector) { + if (!this.initializeServices) { + throw Error('Discover plugin getEmbeddableInjector: initializeServices is undefined'); + } + const { core, plugins } = await this.initializeServices(); + getServices().opensearchDashboardsLegacy.loadFontAwesome(); + const { getInnerAngularModuleEmbeddable } = await import('./get_inner_angular'); + getInnerAngularModuleEmbeddable(embeddableAngularName, core, plugins); + const mountpoint = document.createElement('div'); + this.embeddableInjector = angular.bootstrap(mountpoint, [embeddableAngularName]); + } + + return this.embeddableInjector; + }; +} diff --git a/src/plugins/discover_legacy/public/saved_searches/_saved_search.ts b/src/plugins/discover_legacy/public/saved_searches/_saved_search.ts new file mode 100644 index 000000000000..55cd59104ecb --- /dev/null +++ b/src/plugins/discover_legacy/public/saved_searches/_saved_search.ts @@ -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. + */ + +/* + * 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 { + createSavedObjectClass, + SavedObject, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; + +export function createSavedSearchClass(services: SavedObjectOpenSearchDashboardsServices) { + const SavedObjectClass = createSavedObjectClass(services); + + class SavedSearch extends SavedObjectClass { + public static type: string = 'search'; + public static mapping = { + title: 'text', + description: 'text', + hits: 'integer', + columns: 'keyword', + sort: 'keyword', + version: 'integer', + }; + // Order these fields to the top, the rest are alphabetical + public static fieldOrder = ['title', 'description']; + public static searchSource = true; + + public id: string; + public showInRecentlyAccessed: boolean; + + constructor(id: string) { + super({ + id, + type: 'search', + mapping: { + title: 'text', + description: 'text', + hits: 'integer', + columns: 'keyword', + sort: 'keyword', + version: 'integer', + }, + searchSource: true, + defaults: { + title: '', + description: '', + columns: [], + hits: 0, + sort: [], + version: 1, + }, + }); + this.showInRecentlyAccessed = true; + this.id = id; + this.getFullPath = () => `/app/discover#/view/${String(id)}`; + } + } + + return SavedSearch as new (id: string) => SavedObject; +} diff --git a/src/plugins/discover_legacy/public/saved_searches/index.ts b/src/plugins/discover_legacy/public/saved_searches/index.ts new file mode 100644 index 000000000000..f576a9a9377a --- /dev/null +++ b/src/plugins/discover_legacy/public/saved_searches/index.ts @@ -0,0 +1,32 @@ +/* + * 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. + */ + +export { createSavedSearchesLoader } from './saved_searches'; +export { SavedSearch, SavedSearchLoader } from './types'; diff --git a/src/plugins/discover_legacy/public/saved_searches/saved_searches.ts b/src/plugins/discover_legacy/public/saved_searches/saved_searches.ts new file mode 100644 index 000000000000..dd3243568159 --- /dev/null +++ b/src/plugins/discover_legacy/public/saved_searches/saved_searches.ts @@ -0,0 +1,50 @@ +/* + * 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 { + SavedObjectLoader, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; +import { createSavedSearchClass } from './_saved_search'; + +export function createSavedSearchesLoader(services: SavedObjectOpenSearchDashboardsServices) { + const SavedSearchClass = createSavedSearchClass(services); + const savedSearchLoader = new SavedObjectLoader(SavedSearchClass, services.savedObjectsClient); + // Customize loader properties since adding an 's' on type doesn't work for type 'search' . + savedSearchLoader.loaderProperties = { + name: 'searches', + noun: 'Saved Search', + nouns: 'saved searches', + }; + + savedSearchLoader.urlFor = (id: string) => (id ? `#/view/${encodeURIComponent(id)}` : '#/'); + + return savedSearchLoader; +} diff --git a/src/plugins/discover_legacy/public/saved_searches/types.ts b/src/plugins/discover_legacy/public/saved_searches/types.ts new file mode 100644 index 000000000000..e02fd65e6899 --- /dev/null +++ b/src/plugins/discover_legacy/public/saved_searches/types.ts @@ -0,0 +1,47 @@ +/* + * 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 { ISearchSource } from '../../../data/public'; + +export type SortOrder = [string, string]; +export interface SavedSearch { + readonly id: string; + title: string; + searchSource: ISearchSource; + description?: string; + columns: string[]; + sort: SortOrder[]; + destroy: () => void; + lastSavedTitle?: string; +} +export interface SavedSearchLoader { + get: (id: string) => Promise; + urlFor: (id: string) => string; +} diff --git a/src/plugins/discover_legacy/public/url_generator.test.ts b/src/plugins/discover_legacy/public/url_generator.test.ts new file mode 100644 index 000000000000..c352dd5133a4 --- /dev/null +++ b/src/plugins/discover_legacy/public/url_generator.test.ts @@ -0,0 +1,269 @@ +/* + * 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 { DiscoverUrlGenerator } from './url_generator'; +import { hashedItemStore, getStatesFromOsdUrl } from '../../opensearch_dashboards_utils/public'; +import { mockStorage } from '../../opensearch_dashboards_utils/public/storage/hashed_item_store/mock'; +import { FilterStateStore } from '../../data/common'; + +const appBasePath: string = 'xyz/app/discover'; +const indexPatternId: string = 'c367b774-a4c2-11ea-bb37-0242ac130002'; +const savedSearchId: string = '571aaf70-4c88-11e8-b3d7-01146121b73d'; + +interface SetupParams { + useHash?: boolean; +} + +const setup = async ({ useHash = false }: SetupParams = {}) => { + const generator = new DiscoverUrlGenerator({ + appBasePath, + useHash, + }); + + return { + generator, + }; +}; + +beforeEach(() => { + // @ts-ignore + hashedItemStore.storage = mockStorage; +}); + +describe('Discover url generator', () => { + test('can create a link to Discover with no state and no saved search', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({}); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(url.startsWith(appBasePath)).toBe(true); + expect(_a).toEqual({}); + expect(_g).toEqual({}); + }); + + test('can create a link to a saved search in Discover', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ savedSearchId }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(url.startsWith(`${appBasePath}#/${savedSearchId}`)).toBe(true); + expect(_a).toEqual({}); + expect(_g).toEqual({}); + }); + + test('can specify specific index pattern', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + indexPatternId, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({ + index: indexPatternId, + }); + expect(_g).toEqual({}); + }); + + test('can specify specific time range', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + time: { + from: 'now-15m', + mode: 'relative', + to: 'now', + }, + }); + }); + + test('can specify query', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + expect(_g).toEqual({}); + }); + + test('can specify local and global filters', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + filters: [ + { + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + { + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.GLOBAL_STATE, + }, + }, + ], + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({ + filters: [ + { + $state: { + store: 'appState', + }, + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + }, + ], + }); + expect(_g).toEqual({ + filters: [ + { + $state: { + store: 'globalState', + }, + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + }, + ], + }); + }); + + test('can set refresh interval', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + refreshInterval: { + pause: false, + value: 666, + }, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + refreshInterval: { + pause: false, + value: 666, + }, + }); + }); + + test('can set time range', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + timeRange: { + from: 'now-3h', + to: 'now', + }, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + time: { + from: 'now-3h', + to: 'now', + }, + }); + }); + + describe('useHash property', () => { + describe('when default useHash is set to false', () => { + test('when using default, sets index pattern ID in the generated URL', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + indexPatternId, + }); + + expect(url.indexOf(indexPatternId) > -1).toBe(true); + }); + + test('when enabling useHash, does not set index pattern ID in the generated URL', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + useHash: true, + indexPatternId, + }); + + expect(url.indexOf(indexPatternId) > -1).toBe(false); + }); + }); + + describe('when default useHash is set to true', () => { + test('when using default, does not set index pattern ID in the generated URL', async () => { + const { generator } = await setup({ useHash: true }); + const url = await generator.createUrl({ + indexPatternId, + }); + + expect(url.indexOf(indexPatternId) > -1).toBe(false); + }); + + test('when disabling useHash, sets index pattern ID in the generated URL', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + useHash: false, + indexPatternId, + }); + + expect(url.indexOf(indexPatternId) > -1).toBe(true); + }); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/url_generator.ts b/src/plugins/discover_legacy/public/url_generator.ts new file mode 100644 index 000000000000..25e8517c8c9d --- /dev/null +++ b/src/plugins/discover_legacy/public/url_generator.ts @@ -0,0 +1,127 @@ +/* + * 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 { + TimeRange, + Filter, + Query, + opensearchFilters, + QueryState, + RefreshInterval, +} from '../../data/public'; +import { setStateToOsdUrl } from '../../opensearch_dashboards_utils/public'; +import { UrlGeneratorsDefinition } from '../../share/public'; + +export const DISCOVER_APP_URL_GENERATOR = 'DISCOVER_APP_URL_GENERATOR'; + +export interface DiscoverUrlGeneratorState { + /** + * Optionally set saved search ID. + */ + savedSearchId?: string; + + /** + * Optionally set index pattern ID. + */ + indexPatternId?: string; + + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval; + + /** + * Optionally apply filers. + */ + filters?: Filter[]; + + /** + * Optionally set a query. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has a query saved with it, this will _replace_ that query. + */ + query?: Query; + + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + useHash?: boolean; +} + +interface Params { + appBasePath: string; + useHash: boolean; +} + +export class DiscoverUrlGenerator + implements UrlGeneratorsDefinition { + constructor(private readonly params: Params) {} + + public readonly id = DISCOVER_APP_URL_GENERATOR; + + public readonly createUrl = async ({ + filters, + indexPatternId, + query, + refreshInterval, + savedSearchId, + timeRange, + useHash = this.params.useHash, + }: DiscoverUrlGeneratorState): Promise => { + const savedSearchPath = savedSearchId ? encodeURIComponent(savedSearchId) : ''; + const appState: { + query?: Query; + filters?: Filter[]; + index?: string; + } = {}; + const queryState: QueryState = {}; + + if (query) appState.query = query; + if (filters && filters.length) + appState.filters = filters?.filter((f) => !opensearchFilters.isFilterPinned(f)); + if (indexPatternId) appState.index = indexPatternId; + + if (timeRange) queryState.time = timeRange; + if (filters && filters.length) + queryState.filters = filters?.filter((f) => opensearchFilters.isFilterPinned(f)); + if (refreshInterval) queryState.refreshInterval = refreshInterval; + + let url = `${this.params.appBasePath}#/${savedSearchPath}`; + url = setStateToOsdUrl('_g', queryState, { useHash }, url); + url = setStateToOsdUrl('_a', appState, { useHash }, url); + + return url; + }; +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index 3865e9852882..f541456c93b4 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -45,6 +45,8 @@ export interface TopNavMenuData { emphasize?: boolean; iconType?: EuiIconType; iconSide?: EuiButtonProps['iconSide']; + // @deprecated - experimental, do not use yet. Will be removed in a future minor version + type?: 'toggle' | 'button'; } export interface RegisteredTopNavMenuData extends TopNavMenuData { diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 7f987d937b96..bbc6ff1a7165 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -30,7 +30,7 @@ import { upperFirst, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; -import { EuiToolTip, EuiButton, EuiHeaderLink } from '@elastic/eui'; +import { EuiToolTip, EuiButton, EuiHeaderLink, EuiSwitch } from '@elastic/eui'; import { TopNavMenuData } from './top_nav_menu_data'; export function TopNavMenuItem(props: TopNavMenuData) { @@ -58,21 +58,36 @@ export function TopNavMenuItem(props: TopNavMenuData) { className: props.className, }; - const btn = props.emphasize ? ( - - {upperFirst(props.label || props.id!)} - - ) : ( - - {upperFirst(props.label || props.id!)} - - ); + let component; + if (props.type === 'toggle') { + component = ( + { + handleClick((e as unknown) as MouseEvent); + }} + data-test-subj={props.testId} + className={props.className} + /> + ); + } else { + component = props.emphasize ? ( + + {upperFirst(props.label || props.id!)} + + ) : ( + + {upperFirst(props.label || props.id!)} + + ); + } const tooltip = getTooltip(); if (tooltip) { - return {btn}; + return {component}; } - return btn; + return component; } TopNavMenuItem.defaultProps = { diff --git a/src/test_utils/public/helpers/find_test_subject.ts b/src/test_utils/public/helpers/find_test_subject.ts index 98687e3f0eef..ccb17b336059 100644 --- a/src/test_utils/public/helpers/find_test_subject.ts +++ b/src/test_utils/public/helpers/find_test_subject.ts @@ -54,8 +54,8 @@ const MATCHERS: Matcher[] = [ * @param testSubjectSelector The data test subject selector * @param matcher optional matcher */ -export const findTestSubject = ( - reactWrapper: ReactWrapper, +export const findTestSubject = ( + reactWrapper: ReactWrapper, testSubjectSelector: T, matcher: Matcher = '~=' ) => { diff --git a/src/test_utils/public/testing_lib_helpers.tsx b/src/test_utils/public/testing_lib_helpers.tsx new file mode 100644 index 000000000000..1e39a0cdcecc --- /dev/null +++ b/src/test_utils/public/testing_lib_helpers.tsx @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { ReactElement } from 'react'; +import { render as rtlRender } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; + +// src: https://testing-library.com/docs/example-react-intl/#creating-a-custom-render-function +function render(ui: ReactElement, { ...renderOptions } = {}) { + const Wrapper: React.FC = ({ children }) => { + return {children}; + }; + return rtlRender(ui, { wrapper: Wrapper, ...renderOptions }); +} + +// re-export everything +export * from '@testing-library/react'; + +// override render method +export { render }; diff --git a/test/functional/apps/context/_date_nanos.js b/test/functional/apps/context/_date_nanos.js index e612c8d3c41d..23350c81b18f 100644 --- a/test/functional/apps/context/_date_nanos.js +++ b/test/functional/apps/context/_date_nanos.js @@ -52,6 +52,7 @@ export default function ({ getService, getPageObjects }) { await opensearchDashboardsServer.uiSettings.update({ 'context:defaultSize': `${TEST_DEFAULT_CONTEXT_SIZE}`, 'context:step': `${TEST_STEP_SIZE}`, + 'discover:v2': false, }); }); diff --git a/test/functional/apps/context/_date_nanos_custom_timestamp.js b/test/functional/apps/context/_date_nanos_custom_timestamp.js index 2c6bef3a366b..52864a0d7ea3 100644 --- a/test/functional/apps/context/_date_nanos_custom_timestamp.js +++ b/test/functional/apps/context/_date_nanos_custom_timestamp.js @@ -52,6 +52,7 @@ export default function ({ getService, getPageObjects }) { await opensearchDashboardsServer.uiSettings.update({ 'context:defaultSize': `${TEST_DEFAULT_CONTEXT_SIZE}`, 'context:step': `${TEST_STEP_SIZE}`, + 'discover:v2': false, }); }); diff --git a/test/functional/apps/context/_filters.js b/test/functional/apps/context/_filters.js index a20e6a6a40ca..17219a836230 100644 --- a/test/functional/apps/context/_filters.js +++ b/test/functional/apps/context/_filters.js @@ -38,11 +38,17 @@ export default function ({ getService, getPageObjects }) { const docTable = getService('docTable'); const filterBar = getService('filterBar'); const retry = getService('retry'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const browser = getService('browser'); const PageObjects = getPageObjects(['common', 'context']); describe('context filters', function contextSize() { beforeEach(async function () { + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); + await browser.refresh(); await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, TEST_ANCHOR_ID, { columns: TEST_COLUMN_NAMES, }); diff --git a/test/functional/apps/context/index.js b/test/functional/apps/context/index.js index a5c3c94474ea..07fbfe00ac2b 100644 --- a/test/functional/apps/context/index.js +++ b/test/functional/apps/context/index.js @@ -41,7 +41,10 @@ export default function ({ getService, getPageObjects, loadTestFile }) { await browser.setWindowSize(1200, 800); await opensearchArchiver.loadIfNeeded('logstash_functional'); await opensearchArchiver.load('visualize'); - await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'logstash-*' }); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + 'discover:v2': false, + }); await PageObjects.common.navigateToApp('discover'); }); diff --git a/test/functional/apps/dashboard/dashboard_state.js b/test/functional/apps/dashboard/dashboard_state.js index 5beca47f9cae..e23a2caba0c7 100644 --- a/test/functional/apps/dashboard/dashboard_state.js +++ b/test/functional/apps/dashboard/dashboard_state.js @@ -43,6 +43,7 @@ export default function ({ getService, getPageObjects }) { 'tileMap', 'visChart', 'timePicker', + 'common', ]); const testSubjects = getService('testSubjects'); const browser = getService('browser'); @@ -52,11 +53,17 @@ export default function ({ getService, getPageObjects }) { const retry = getService('retry'); const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardAddPanel = getService('dashboardAddPanel'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); describe('dashboard state', function describeIndexTests() { before(async function () { await PageObjects.dashboard.initTests(); await PageObjects.dashboard.preserveCrossAppState(); + + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); + await browser.refresh(); }); after(async function () { @@ -88,6 +95,8 @@ export default function ({ getService, getPageObjects }) { expect(colorChoiceRetained).to.be(true); }); + // the following three tests are skipped because of save search save window bug: + // https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4698 it('Saved search with no changes will update when the saved object changes', async () => { await PageObjects.dashboard.gotoDashboardLandingPage(); @@ -107,6 +116,9 @@ export default function ({ getService, getPageObjects }) { expect(inViewMode).to.be(true); await PageObjects.header.clickDiscover(); + // Add load save search here since discover link won't take it to the save search link for + // the legacy discover plugin + await PageObjects.discover.loadSavedSearch('my search'); await PageObjects.discover.clickFieldListItemAdd('agent'); await PageObjects.discover.saveSearch('my search'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -126,6 +138,9 @@ export default function ({ getService, getPageObjects }) { await PageObjects.dashboard.saveDashboard('Has local edits'); await PageObjects.header.clickDiscover(); + // Add load save search here since discover link won't take it to the save search link for + // the legacy discover plugin + await PageObjects.discover.loadSavedSearch('my search'); await PageObjects.discover.clickFieldListItemAdd('clientip'); await PageObjects.discover.saveSearch('my search'); await PageObjects.header.waitUntilLoadingHasFinished(); diff --git a/test/functional/apps/dashboard/dashboard_time_picker.js b/test/functional/apps/dashboard/dashboard_time_picker.js index c1f4e50e6a65..5c97872cdb7e 100644 --- a/test/functional/apps/dashboard/dashboard_time_picker.js +++ b/test/functional/apps/dashboard/dashboard_time_picker.js @@ -83,7 +83,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.dashboard.clickNewDashboard(); log.debug('Clicked new dashboard'); await dashboardVisualizations.createAndAddSavedSearch({ - name: 'saved search', + name: 'saved search 1', fields: ['bytes', 'agent'], }); log.debug('added saved search'); diff --git a/test/functional/apps/dashboard/panel_context_menu.ts b/test/functional/apps/dashboard/panel_context_menu.ts index 2d00c81581e6..07af447d4e15 100644 --- a/test/functional/apps/dashboard/panel_context_menu.ts +++ b/test/functional/apps/dashboard/panel_context_menu.ts @@ -38,12 +38,15 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const dashboardPanelActions = getService('dashboardPanelActions'); const dashboardAddPanel = getService('dashboardAddPanel'); const dashboardVisualizations = getService('dashboardVisualizations'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const listingTable = getService('listingTable'); const PageObjects = getPageObjects([ 'dashboard', 'header', 'visualize', 'discover', 'timePicker', + 'common', ]); const dashboardName = 'Dashboard Panel Controls Test'; @@ -114,6 +117,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const panelCount = await PageObjects.dashboard.getPanelCount(); expect(panelCount).to.be(0); + // need to find the correct save + await PageObjects.dashboard.saveDashboard(dashboardName); }); }); @@ -121,11 +126,20 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const searchName = 'my search'; before(async () => { + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); + await browser.refresh(); await PageObjects.header.clickDiscover(); await PageObjects.discover.clickNewSearchButton(); await dashboardVisualizations.createSavedSearch({ name: searchName, fields: ['bytes'] }); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.header.clickDashboard(); + // Have to add steps to actually click on the dashboard; since added browser.refresh() will make + // clickDashboard() to only land on the dashboard listing page + // We need to add browser.refresh() so clickDiscover() lands correctly on the legacy discover page + await listingTable.clickItemLink('dashboard', dashboardName); + await PageObjects.header.waitUntilLoadingHasFinished(); const inViewMode = await PageObjects.dashboard.getIsInViewMode(); if (inViewMode) await PageObjects.dashboard.switchToEditMode(); diff --git a/test/functional/apps/discover/_date_nanos.js b/test/functional/apps/discover/_date_nanos.js index 721a5de07871..e96d507087fe 100644 --- a/test/functional/apps/discover/_date_nanos.js +++ b/test/functional/apps/discover/_date_nanos.js @@ -41,7 +41,10 @@ export default function ({ getService, getPageObjects }) { describe('date_nanos', function () { before(async function () { await opensearchArchiver.loadIfNeeded('date_nanos'); - await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'date-nanos' }); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'date-nanos', + 'discover:v2': false, + }); await security.testUser.setRoles([ 'opensearch_dashboards_admin', 'opensearch_dashboards_date_nanos', diff --git a/test/functional/apps/discover/_date_nanos_mixed.js b/test/functional/apps/discover/_date_nanos_mixed.js index 8578572dfbc5..05b94d3d1d67 100644 --- a/test/functional/apps/discover/_date_nanos_mixed.js +++ b/test/functional/apps/discover/_date_nanos_mixed.js @@ -41,7 +41,10 @@ export default function ({ getService, getPageObjects }) { describe('date_nanos_mixed', function () { before(async function () { await opensearchArchiver.loadIfNeeded('date_nanos_mixed'); - await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'timestamp-*' }); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'timestamp-*', + 'discover:v2': false, + }); await security.testUser.setRoles([ 'opensearch_dashboards_admin', 'opensearch_dashboards_date_nanos_mixed', diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js index 66555ddd0851..d132454a090e 100644 --- a/test/functional/apps/discover/_discover.js +++ b/test/functional/apps/discover/_discover.js @@ -41,16 +41,17 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); const defaultSettings = { defaultIndex: 'logstash-*', + 'discover:v2': false, }; describe('discover app', function describeIndexTests() { before(async function () { - // delete .kibana index and update configDoc - await opensearchDashboardsServer.uiSettings.replace(defaultSettings); - log.debug('load opensearch-dashboards index with default index pattern'); await opensearchArchiver.load('discover'); + // delete .kibana index and update configDoc + await opensearchDashboardsServer.uiSettings.replace(defaultSettings); + // and load a set of makelogs data await opensearchArchiver.loadIfNeeded('logstash_functional'); log.debug('discover'); @@ -258,7 +259,10 @@ export default function ({ getService, getPageObjects }) { }); }); it('should show bars in the correct time zone after switching', async function () { - await opensearchDashboardsServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' }); + await opensearchDashboardsServer.uiSettings.replace({ + 'dateFormat:tz': 'America/Phoenix', + 'discover:v2': false, + }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitOpenSearchDashboardsChrome(); await queryBar.clearQuery(); @@ -273,7 +277,10 @@ export default function ({ getService, getPageObjects }) { }); describe('usage of discover:searchOnPageLoad', () => { it('should fetch data from OpenSearch initially when discover:searchOnPageLoad is false', async function () { - await opensearchDashboardsServer.uiSettings.replace({ 'discover:searchOnPageLoad': false }); + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:searchOnPageLoad': false, + 'discover:v2': false, + }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitOpenSearchDashboardsChrome(); @@ -281,7 +288,10 @@ export default function ({ getService, getPageObjects }) { }); it('should not fetch data from OpenSearch initially when discover:searchOnPageLoad is true', async function () { - await opensearchDashboardsServer.uiSettings.replace({ 'discover:searchOnPageLoad': true }); + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:searchOnPageLoad': true, + 'discover:v2': false, + }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitOpenSearchDashboardsChrome(); @@ -306,6 +316,7 @@ export default function ({ getService, getPageObjects }) { it('should update the histogram timerange when the query is resubmitted', async function () { await opensearchDashboardsServer.uiSettings.update({ 'timepicker:timeDefaults': '{ "from": "2015-09-18T19:37:13.000Z", "to": "now"}', + 'discover:v2': false, }); await PageObjects.common.navigateToApp('discover'); await PageObjects.header.awaitOpenSearchDashboardsChrome(); diff --git a/test/functional/apps/discover/_discover_histogram.ts b/test/functional/apps/discover/_discover_histogram.ts index 391fa97e00a9..f32a85add6fa 100644 --- a/test/functional/apps/discover/_discover_histogram.ts +++ b/test/functional/apps/discover/_discover_histogram.ts @@ -40,6 +40,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const defaultSettings = { defaultIndex: 'long-window-logstash-*', 'dateFormat:tz': 'Europe/Berlin', + 'discover:v2': false, }; describe('discover histogram', function describeIndexTests() { diff --git a/test/functional/apps/discover/_doc_navigation.js b/test/functional/apps/discover/_doc_navigation.js index bc0afcb92250..6978c363689b 100644 --- a/test/functional/apps/discover/_doc_navigation.js +++ b/test/functional/apps/discover/_doc_navigation.js @@ -37,6 +37,7 @@ export default function ({ getService, getPageObjects }) { const testSubjects = getService('testSubjects'); const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'context']); const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const retry = getService('retry'); describe('doc link in discover', function contextSize() { @@ -45,6 +46,9 @@ export default function ({ getService, getPageObjects }) { await opensearchArchiver.loadIfNeeded('discover'); await opensearchArchiver.loadIfNeeded('logstash_functional'); + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); await PageObjects.common.navigateToApp('discover'); await PageObjects.timePicker.setDefaultAbsoluteRange(); await PageObjects.discover.waitForDocTableLoadingComplete(); diff --git a/test/functional/apps/discover/_doc_table.ts b/test/functional/apps/discover/_doc_table.ts index ed7e30201cf2..166aa954c364 100644 --- a/test/functional/apps/discover/_doc_table.ts +++ b/test/functional/apps/discover/_doc_table.ts @@ -40,6 +40,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); const defaultSettings = { defaultIndex: 'logstash-*', + 'discover:v2': false, }; describe('discover doc table', function describeIndexTests() { diff --git a/test/functional/apps/discover/_errors.ts b/test/functional/apps/discover/_errors.ts index f2df3714e230..3251b9215e1a 100644 --- a/test/functional/apps/discover/_errors.ts +++ b/test/functional/apps/discover/_errors.ts @@ -33,6 +33,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const toasts = getService('toasts'); const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); @@ -40,6 +41,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async function () { await opensearchArchiver.loadIfNeeded('logstash_functional'); await opensearchArchiver.load('invalid_scripted_field'); + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); }); diff --git a/test/functional/apps/discover/_field_data.js b/test/functional/apps/discover/_field_data.js index a9c4ac34f735..90de964dc4ec 100644 --- a/test/functional/apps/discover/_field_data.js +++ b/test/functional/apps/discover/_field_data.js @@ -46,6 +46,7 @@ export default function ({ getService, getPageObjects }) { await opensearchArchiver.load('discover'); await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'logstash-*', + 'discover:v2': false, }); await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); diff --git a/test/functional/apps/discover/_field_visualize.ts b/test/functional/apps/discover/_field_visualize.ts index ecefc610b746..50ecb54d27ff 100644 --- a/test/functional/apps/discover/_field_visualize.ts +++ b/test/functional/apps/discover/_field_visualize.ts @@ -41,6 +41,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker', 'visualize']); const defaultSettings = { defaultIndex: 'logstash-*', + 'discover:v2': false, }; describe('discover field visualize button', function () { diff --git a/test/functional/apps/discover/_filter_editor.js b/test/functional/apps/discover/_filter_editor.js index 482376994d79..7692d7e6148b 100644 --- a/test/functional/apps/discover/_filter_editor.js +++ b/test/functional/apps/discover/_filter_editor.js @@ -39,6 +39,7 @@ export default function ({ getService, getPageObjects }) { const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); const defaultSettings = { defaultIndex: 'logstash-*', + 'discover:v2': false, }; describe('discover filter editor', function describeIndexTests() { diff --git a/test/functional/apps/discover/_indexpattern_with_encoded_id.ts b/test/functional/apps/discover/_indexpattern_with_encoded_id.ts index 4ff77a744c20..f2cb85fb9280 100644 --- a/test/functional/apps/discover/_indexpattern_with_encoded_id.ts +++ b/test/functional/apps/discover/_indexpattern_with_encoded_id.ts @@ -16,7 +16,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('indexpattern with encoded id', () => { before(async () => { await opensearchArchiver.loadIfNeeded('index_pattern_with_encoded_id'); - await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'with-encoded-id' }); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'with-encoded-id', + 'discover:v2': false, + }); await PageObjects.common.navigateToApp('discover'); }); diff --git a/test/functional/apps/discover/_indexpattern_without_timefield.ts b/test/functional/apps/discover/_indexpattern_without_timefield.ts index 6aa50ec2b7df..1f89753c0336 100644 --- a/test/functional/apps/discover/_indexpattern_without_timefield.ts +++ b/test/functional/apps/discover/_indexpattern_without_timefield.ts @@ -43,7 +43,10 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { 'opensearch_dashboards_timefield', ]); await opensearchArchiver.loadIfNeeded('index_pattern_without_timefield'); - await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'without-timefield' }); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'without-timefield', + 'discover:v2': false, + }); await PageObjects.common.navigateToApp('discover'); }); diff --git a/test/functional/apps/discover/_inspector.js b/test/functional/apps/discover/_inspector.js index 5c4262118fa0..5f4c2438fce1 100644 --- a/test/functional/apps/discover/_inspector.js +++ b/test/functional/apps/discover/_inspector.js @@ -53,6 +53,7 @@ export default function ({ getService, getPageObjects }) { // delete .kibana index and update configDoc await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'logstash-*', + 'discover:v2': false, }); await PageObjects.common.navigateToApp('discover'); diff --git a/test/functional/apps/discover/_large_string.js b/test/functional/apps/discover/_large_string.js index 6b0395aa02fd..2cad2b0daf90 100644 --- a/test/functional/apps/discover/_large_string.js +++ b/test/functional/apps/discover/_large_string.js @@ -47,7 +47,10 @@ export default function ({ getService, getPageObjects }) { ]); await opensearchArchiver.load('empty_opensearch_dashboards'); await opensearchArchiver.loadIfNeeded('hamlet'); - await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'testlargestring' }); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'testlargestring', + 'discover:v2': false, + }); }); it('verify the large string book present', async function () { diff --git a/test/functional/apps/discover/_saved_queries.js b/test/functional/apps/discover/_saved_queries.js index 6ba2002653af..c51850eac00d 100644 --- a/test/functional/apps/discover/_saved_queries.js +++ b/test/functional/apps/discover/_saved_queries.js @@ -35,11 +35,12 @@ export default function ({ getService, getPageObjects }) { const log = getService('log'); const opensearchArchiver = getService('opensearchArchiver'); const opensearchDashboardsServer = getService('opensearchDashboardsServer'); - const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker', 'settings']); const browser = getService('browser'); const defaultSettings = { defaultIndex: 'logstash-*', + 'discover:v2': false, }; const filterBar = getService('filterBar'); const queryBar = getService('queryBar'); @@ -54,6 +55,7 @@ export default function ({ getService, getPageObjects }) { // and load a set of makelogs data await opensearchArchiver.loadIfNeeded('logstash_functional'); await opensearchDashboardsServer.uiSettings.replace(defaultSettings); + log.debug('discover'); await PageObjects.common.navigateToApp('discover'); await PageObjects.timePicker.setDefaultAbsoluteRange(); diff --git a/test/functional/apps/discover/_shared_links.js b/test/functional/apps/discover/_shared_links.js index ecae25d8d4d3..0583ff398283 100644 --- a/test/functional/apps/discover/_shared_links.js +++ b/test/functional/apps/discover/_shared_links.js @@ -62,6 +62,7 @@ export default function ({ getService, getPageObjects }) { await opensearchDashboardsServer.uiSettings.replace({ 'state:storeInSessionStorage': storeStateInSessionStorage, + 'discover:v2': false, }); log.debug('discover'); @@ -96,7 +97,7 @@ export default function ({ getService, getPageObjects }) { it('should allow for copying the snapshot URL', async function () { const expectedUrl = baseUrl + - '/app/discover?_t=1453775307251#' + + '/app/discoverLegacy?_t=1453775307251#' + '/?_g=(filters:!(),refreshInterval:(pause:!t,value:0),time' + ":(from:'2015-09-19T06:31:44.000Z',to:'2015-09" + "-23T18:31:44.000Z'))&_a=(columns:!(_source),filters:!(),index:'logstash-" + @@ -121,7 +122,7 @@ export default function ({ getService, getPageObjects }) { it('should allow for copying the saved object URL', async function () { const expectedUrl = baseUrl + - '/app/discover#' + + '/app/discoverLegacy#' + '/view/ab12e3c0-f231-11e6-9486-733b1ac9221a' + '?_g=%28filters%3A%21%28%29%2CrefreshInterval%3A%28pause%3A%21t%2Cvalue%3A0%29%' + '2Ctime%3A%28from%3A%272015-09-19T06%3A31%3A44.000Z%27%2C' + @@ -160,7 +161,7 @@ export default function ({ getService, getPageObjects }) { await browser.get(actualUrl, false); await retry.waitFor('shortUrl resolves and opens', async () => { const resolvedUrl = await browser.getCurrentUrl(); - expect(resolvedUrl).to.match(/discover/); + expect(resolvedUrl).to.match(/discoverLegacy/); const resolvedTime = await PageObjects.timePicker.getTimeConfig(); expect(resolvedTime.start).to.equal(actualTime.start); expect(resolvedTime.end).to.equal(actualTime.end); @@ -175,7 +176,7 @@ export default function ({ getService, getPageObjects }) { await browser.get(currentUrl, false); await retry.waitFor('discover to open', async () => { const resolvedUrl = await browser.getCurrentUrl(); - expect(resolvedUrl).to.match(/discover/); + expect(resolvedUrl).to.match(/discoverLegacy/); const { message } = await toasts.getErrorToast(); expect(message).to.contain( 'Unable to completely restore the URL, be sure to use the share functionality.' diff --git a/test/functional/apps/discover/_sidebar.js b/test/functional/apps/discover/_sidebar.js index 446cb1b760e0..5d6bcb5134e6 100644 --- a/test/functional/apps/discover/_sidebar.js +++ b/test/functional/apps/discover/_sidebar.js @@ -38,17 +38,18 @@ export default function ({ getService, getPageObjects }) { describe('discover sidebar', function describeIndexTests() { before(async function () { - // delete .kibana index and update configDoc - await opensearchDashboardsServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - }); - log.debug('load opensearch-dashboards index with default index pattern'); await opensearchArchiver.load('discover'); // and load a set of makelogs data await opensearchArchiver.loadIfNeeded('logstash_functional'); + // delete .kibana index and update configDoc + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + 'discover:v2': false, + }); + log.debug('discover'); await PageObjects.common.navigateToApp('discover'); diff --git a/test/functional/apps/discover/_source_filters.js b/test/functional/apps/discover/_source_filters.js index d324a0972b8a..1f425a4e341f 100644 --- a/test/functional/apps/discover/_source_filters.js +++ b/test/functional/apps/discover/_source_filters.js @@ -38,17 +38,18 @@ export default function ({ getService, getPageObjects }) { describe('source filters', function describeIndexTests() { before(async function () { - // delete .kibana index and update configDoc - await opensearchDashboardsServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - }); - log.debug('load opensearch-dashboards index with default index pattern'); await opensearchArchiver.load('visualize_source-filters'); // and load a set of makelogs data await opensearchArchiver.loadIfNeeded('logstash_functional'); + // delete .kibana index and update configDoc + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + 'discover:v2': false, + }); + log.debug('discover'); await PageObjects.common.navigateToApp('discover'); diff --git a/test/functional/apps/home/_navigation.ts b/test/functional/apps/home/_navigation.ts index 03230f1270ed..36f8e50ea543 100644 --- a/test/functional/apps/home/_navigation.ts +++ b/test/functional/apps/home/_navigation.ts @@ -36,11 +36,14 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker']); const appsMenu = getService('appsMenu'); const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); describe('OpenSearch Dashboards browser back navigation should work', function describeIndexTests() { before(async () => { await opensearchArchiver.loadIfNeeded('discover'); await opensearchArchiver.loadIfNeeded('logstash_functional'); + + await opensearchDashboardsServer.uiSettings.replace({ 'discover:v2': false }); }); it('detect navigate back issues', async () => { diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index c5c6456f0442..203345e38f91 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -32,6 +32,7 @@ import expect from '@osd/expect'; export default function ({ getService, getPageObjects }) { const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const opensearch = getService('legacyOpenSearch'); const retry = getService('retry'); const security = getService('security'); @@ -42,6 +43,9 @@ export default function ({ getService, getPageObjects }) { await security.testUser.setRoles(['opensearch_dashboards_admin', 'test_alias_reader']); await opensearchArchiver.loadIfNeeded('alias'); await opensearchArchiver.load('empty_opensearch_dashboards'); + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); await opensearch.indices.updateAliases({ body: { actions: [ diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 9ce2a57436e1..fd290ce76b8a 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -71,8 +71,9 @@ export default function ({ getService, getPageObjects }) { before(async function () { await browser.setWindowSize(1200, 800); await opensearchArchiver.load('discover'); - // delete .kibana index and then wait for OpenSearch Dashboards to re-create it - await opensearchDashboardsServer.uiSettings.replace({}); + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); await opensearchDashboardsServer.uiSettings.update({}); }); diff --git a/test/functional/apps/visualize/_lab_mode.js b/test/functional/apps/visualize/_lab_mode.js index 82ecbcb2a655..d852ac484eaa 100644 --- a/test/functional/apps/visualize/_lab_mode.js +++ b/test/functional/apps/visualize/_lab_mode.js @@ -34,8 +34,14 @@ import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../src/plugins/visualiza export default function ({ getService, getPageObjects }) { const log = getService('log'); const PageObjects = getPageObjects(['common', 'header', 'discover', 'settings']); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); describe('visualize lab mode', () => { + before(async () => { + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); + }); it('disabling does not break loading saved searches', async () => { await PageObjects.common.navigateToUrl('discover', '', { useActualUrl: true }); await PageObjects.discover.saveSearch('visualize_lab_mode_test'); diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index fb7e721db7de..2bdc5990b928 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -49,6 +49,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'logstash-*', [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', + 'discover:v2': false, }); isOss = await deployment.isOss(); }); diff --git a/test/functional/config.js b/test/functional/config.js index d927aea2966f..b862208276bf 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -80,7 +80,7 @@ export default async function ({ readConfigFile }) { pathname: '/status', }, discover: { - pathname: '/app/discover', + pathname: '/app/discoverLegacy', hash: '/', }, context: { diff --git a/test/functional/services/dashboard/visualizations.ts b/test/functional/services/dashboard/visualizations.ts index aee71c8d58ba..588368d4b4a1 100644 --- a/test/functional/services/dashboard/visualizations.ts +++ b/test/functional/services/dashboard/visualizations.ts @@ -37,6 +37,8 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F const queryBar = getService('queryBar'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); + const browser = getService('browser'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const PageObjects = getPageObjects([ 'dashboard', 'visualize', @@ -44,6 +46,8 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F 'header', 'discover', 'timePicker', + 'common', + 'settings', ]); return new (class DashboardVisualizations { @@ -69,6 +73,11 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F fields?: string[]; }) { log.debug(`createSavedSearch(${name})`); + + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); + await browser.refresh(); await PageObjects.header.clickDiscover(); await PageObjects.timePicker.setHistoricalDataRange();