diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index 8fd3a402d547..27fbbb2c4e63 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/src/plugins/data_explorer/public/components/app.tsx b/src/plugins/data_explorer/public/components/app.tsx index 40d23d97356b..ff6b5931a404 100644 --- a/src/plugins/data_explorer/public/components/app.tsx +++ b/src/plugins/data_explorer/public/components/app.tsx @@ -3,13 +3,34 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +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..5cc1ddc8f622 --- /dev/null +++ b/src/plugins/data_explorer/public/components/app_container.scss @@ -0,0 +1,4 @@ +.deSidebar { + max-width: 462px; + min-width: 400px; +} diff --git a/src/plugins/data_explorer/public/components/app_container.tsx b/src/plugins/data_explorer/public/components/app_container.tsx index 91b75a12423f..d5db220d6406 100644 --- a/src/plugins/data_explorer/public/components/app_container.tsx +++ b/src/plugins/data_explorer/public/components/app_container.tsx @@ -4,12 +4,13 @@ */ import React from 'react'; -import { EuiPageTemplate } from '@elastic/eui'; +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. @@ -17,28 +18,22 @@ export const AppContainer = ({ view, params }: { view?: View; params: AppMountPa return ; } - const { Canvas, Panel } = view; + const { Canvas, Panel, Context } = view; // Render the application DOM. - // Note that `navigation.ui.TopNavMenu` is a stateful component exported on the `navigation` plugin's start contract. return ( - - Loading...}> - - - - } - className="dePageTemplate" - template="default" - restrictWidth={false} - paddingSize="none" - > + {/* TODO: improve loading state */} Loading...}> - + + + + + + + + - + ); }; diff --git a/src/plugins/data_explorer/public/components/sidebar/index.tsx b/src/plugins/data_explorer/public/components/sidebar/index.tsx index f58bc776982b..a4769fbcf0bc 100644 --- a/src/plugins/data_explorer/public/components/sidebar/index.tsx +++ b/src/plugins/data_explorer/public/components/sidebar/index.tsx @@ -5,7 +5,14 @@ import React, { useMemo, FC, useEffect, useState } from 'react'; import { i18n } from '@osd/i18n'; -import { EuiPanel, EuiComboBox, EuiSelect, EuiComboBoxOptionOption } from '@elastic/eui'; +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'; @@ -57,43 +64,50 @@ export const Sidebar: FC = ({ children }) => { }, [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] || {}; + + + + { + // 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; - } + 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)); - }} - /> - - {children} - + dispatch(setIndexPattern(value)); + }} + /> + + { + dispatch(setView(e.target.value)); + }} + fullWidth + /> + + + {children} + + + ); }; diff --git a/src/plugins/data_explorer/public/index.ts b/src/plugins/data_explorer/public/index.ts index 0a0575e339c1..298cdcc00ded 100644 --- a/src/plugins/data_explorer/public/index.ts +++ b/src/plugins/data_explorer/public/index.ts @@ -12,6 +12,6 @@ import { DataExplorerPlugin } from './plugin'; export function plugin() { return new DataExplorerPlugin(); } -export { DataExplorerPluginSetup, DataExplorerPluginStart, ViewRedirectParams } from './types'; +export { DataExplorerPluginSetup, DataExplorerPluginStart, DataExplorerServices } from './types'; export { ViewProps, ViewDefinition } from './services/view_service'; export { RootState, useTypedSelector, useTypedDispatch } from './utils/state_management'; diff --git a/src/plugins/data_explorer/public/services/view_service/types.ts b/src/plugins/data_explorer/public/services/view_service/types.ts index 2aa3915da468..4138c7d18b82 100644 --- a/src/plugins/data_explorer/public/services/view_service/types.ts +++ b/src/plugins/data_explorer/public/services/view_service/types.ts @@ -7,8 +7,6 @@ import { Slice } from '@reduxjs/toolkit'; import { LazyExoticComponent } from 'react'; import { AppMountParameters } from '../../../../../core/public'; -// TODO: State management props - interface ViewListItem { id: string; label: string; @@ -25,6 +23,9 @@ export interface ViewDefinition { }; 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: { diff --git a/src/plugins/data_explorer/public/services/view_service/view.ts b/src/plugins/data_explorer/public/services/view_service/view.ts index 6268aa731497..ebdab31fddc5 100644 --- a/src/plugins/data_explorer/public/services/view_service/view.ts +++ b/src/plugins/data_explorer/public/services/view_service/view.ts @@ -15,6 +15,7 @@ export class View implements IView { readonly shouldShow?: (state: any) => boolean; readonly Canvas: IView['Canvas']; readonly Panel: IView['Panel']; + readonly Context: IView['Context']; constructor(options: ViewDefinition) { this.id = options.id; @@ -25,5 +26,6 @@ export class View implements IView { this.shouldShow = options.shouldShow; this.Canvas = options.Canvas; this.Panel = options.Panel; + this.Context = options.Context; } } diff --git a/src/plugins/data_explorer/public/types.ts b/src/plugins/data_explorer/public/types.ts index 1c7b21c191dc..5f677fb46cfd 100644 --- a/src/plugins/data_explorer/public/types.ts +++ b/src/plugins/data_explorer/public/types.ts @@ -26,11 +26,6 @@ export interface DataExplorerPluginStartDependencies { data: DataPublicPluginStart; } -export interface ViewRedirectParams { - view: string; - path?: string; -} - export interface DataExplorerServices extends CoreStart { store?: Store; viewRegistry: ViewServiceStart; diff --git a/src/plugins/data_explorer/public/utils/state_management/store.ts b/src/plugins/data_explorer/public/utils/state_management/store.ts index ccc939237dd2..cd967d25fc20 100644 --- a/src/plugins/data_explorer/public/utils/state_management/store.ts +++ b/src/plugins/data_explorer/public/utils/state_management/store.ts @@ -3,19 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; import { combineReducers, configureStore, PreloadedState, Reducer, Slice } from '@reduxjs/toolkit'; import { isEqual } from 'lodash'; -import { Provider } from 'react-redux'; -import { reducer as metadataReducer, MetadataState } from './metadata_slice'; +import { reducer as metadataReducer } from './metadata_slice'; import { loadReduxState, persistReduxState } from './redux_persistence'; import { DataExplorerServices } from '../../types'; -const dynamicReducers: { - metadata: Reducer; +const commonReducers = { + metadata: metadataReducer, +}; + +let dynamicReducers: { + metadata: typeof metadataReducer; [key: string]: Reducer; } = { - metadata: metadataReducer, + ...commonReducers, }; const rootReducer = combineReducers(dynamicReducers); @@ -60,7 +62,15 @@ export const getPreloadedStore = async (services: DataExplorerServices) => { // the store subscriber will automatically detect changes and call handleChange function const unsubscribe = store.subscribe(handleChange); - return { store, unsubscribe }; + const onUnsubscribe = () => { + dynamicReducers = { + ...commonReducers, + }; + + unsubscribe(); + }; + + return { store, unsubscribe: onUnsubscribe }; }; export const registerSlice = (slice: Slice) => { 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 index 5de13826d92b..43e62c0f96b0 100644 --- 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 @@ -10,7 +10,7 @@ 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 { DataGridContext } from './data_grid_table_context'; +import { DiscoverGridContextProvider } from './data_grid_table_context'; import { toolbarVisibility } from './constants'; import { DocViewFilterFn } from '../../doc_views/doc_views_types'; import { DiscoverServices } from '../../../build_services'; @@ -44,7 +44,7 @@ export const DataGridTable = ({ displayTimeColumn, services, }: DataGridTableProps) => { - const [docViewExpand, setDocViewExpand] = useState(undefined); + const [expandedHit, setExpandedHit] = useState(); const rowCount = useMemo(() => (rows ? rows.length : 0), [rows]); const pagination = usePagination(rowCount); @@ -94,11 +94,11 @@ export const DataGridTable = ({ }, []); return ( - - {docViewExpand && ( + {expandedHit && ( setDocViewExpand(undefined)} + onClose={() => setExpandedHit(undefined)} services={services} /> )} - + ); }; 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 index 87112c4bff45..c3568d44082a 100644 --- 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 @@ -5,13 +5,13 @@ import React from 'react'; import { IndexPattern } from '../../../opensearch_dashboards_services'; -import { DocViewFilterFn } from '../../doc_views/doc_views_types'; +import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types'; export interface DataGridContextProps { - docViewExpand: any; + expandedHit?: OpenSearchSearchHit; onFilter: DocViewFilterFn; - setDocViewExpand: (hit: any) => void; - rows: any[]; + setExpandedHit: (hit?: OpenSearchSearchHit) => void; + rows: OpenSearchSearchHit[]; indexPattern: IndexPattern; } 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 index 30aa4e45d0c7..beb8e7de278a 100644 --- 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 @@ -3,22 +3,22 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useContext } from 'react'; +import React from 'react'; import { EuiToolTip, EuiButtonIcon, EuiDataGridCellValueElementProps } from '@elastic/eui'; -import { DataGridContext } from './data_grid_table_context'; +import { useDataGridContext } from './data_grid_table_context'; export const DocViewExpandButton = ({ rowIndex, setCellProps, }: EuiDataGridCellValueElementProps) => { - const { docViewExpand, setDocViewExpand, rows } = useContext(DataGridContext); + const { expandedHit, setExpandedHit, rows } = useDataGridContext(); const currentExpanded = rows[rowIndex]; - const isCurrentExpanded = currentExpanded === docViewExpand; + const isCurrentExpanded = currentExpanded === expandedHit; return ( setDocViewExpand(isCurrentExpanded ? undefined : currentExpanded)} + onClick={() => 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 index b4a8643ba4c8..957679a2faef 100644 --- 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 @@ -4,9 +4,6 @@ */ import React from 'react'; -import { i18n } from '@osd/i18n'; -import { stringify } from 'query-string'; -import rison from 'rison-node'; import { EuiFlyout, @@ -15,15 +12,11 @@ import { EuiFlexGroup, EuiFlexItem, EuiTitle, - EuiLink, - EuiSpacer, } 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 { DiscoverServices } from '../../../build_services'; -import { url } from '../../../../../opensearch_dashboards_utils/common'; -import { opensearchFilters } from '../../../../../data/public'; +import { DocViewerLinks } from '../doc_viewer_links/doc_viewer_links'; interface Props { columns: string[]; @@ -33,7 +26,6 @@ interface Props { onClose: () => void; onFilter: DocViewFilterFn; onRemoveColumn: (column: string) => void; - services: DiscoverServices; } export function DataGridFlyout({ @@ -44,76 +36,40 @@ export function DataGridFlyout({ onClose, onFilter, onRemoveColumn, - services, }: Props) { - const generateSurroundingDocumentsUrl = (hitId: string, indexPatternId: string) => { - const globalFilters = services.filterManager.getGlobalFilters(); - const appFilters = services.filterManager.getAppFilters(); - - const hash = stringify( - url.encodeQuery({ - _g: rison.encode({ - filters: globalFilters || [], - }), - _a: rison.encode({ - columns, - filters: (appFilters || []).map(opensearchFilters.disableFilter), - }), - }), - { encode: false, sort: false } - ); - - return `#/context/${encodeURIComponent(indexPatternId)}/${encodeURIComponent(hitId)}?${hash}`; - }; - - const generateSingleDocumentUrl = (hitObj: any, indexPatternId: string) => { - return `#/doc/${indexPatternId}/${hitObj._index}?id=${encodeURIComponent(hit._id)}`; - }; - // 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

- - - - - {i18n.translate('discover.docTable.tableRow.viewSingleDocumentLinkText', { - defaultMessage: 'View single document', - })} - - - - - {i18n.translate('discover.docTable.tableRow.viewSurroundingDocumentsLinkText', { - defaultMessage: 'View surrounding documents', - })} - - -
- { - onRemoveColumn(columnName); - onClose(); - }} - onAddColumn={(columnName: string) => { - onAddColumn(columnName); - onClose(); - }} - filter={(mapping, value, mode) => { - onFilter(mapping, value, mode); - onClose(); - }} - /> + + + + + { + 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_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 new file mode 100644 index 000000000000..2a3b5a3aa998 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap @@ -0,0 +1,38 @@ +// 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/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx b/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx new file mode 100644 index 000000000000..8aba555b3a37 --- /dev/null +++ b/src/plugins/discover/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/public/application/components/doc_viewer_links/doc_viewer_links.tsx b/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.tsx new file mode 100644 index 000000000000..f529273991a0 --- /dev/null +++ b/src/plugins/discover/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/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap b/src/plugins/discover/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap deleted file mode 100644 index 42c11152e263..000000000000 --- a/src/plugins/discover/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap +++ /dev/null @@ -1,3 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`DiscoverIndexPattern Invalid props dont cause an exception: "" 1`] = `""`; diff --git a/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx b/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx deleted file mode 100644 index 553031f06721..000000000000 --- a/src/plugins/discover/public/application/components/sidebar/change_indexpattern.tsx +++ /dev/null @@ -1,131 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import { i18n } from '@osd/i18n'; -import React, { useState } from 'react'; -import { - EuiButtonEmpty, - EuiPopover, - EuiPopoverTitle, - EuiSelectable, - EuiButtonEmptyProps, -} from '@elastic/eui'; -import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable'; -import { IndexPatternRef } from './types'; - -export type ChangeIndexPatternTriggerProps = EuiButtonEmptyProps & { - label: string; - title?: string; -}; - -export function ChangeIndexPattern({ - indexPatternRefs, - indexPatternId, - onChangeIndexPattern, - trigger, - selectableProps, -}: { - trigger: ChangeIndexPatternTriggerProps; - indexPatternRefs: IndexPatternRef[]; - onChangeIndexPattern: (newId: string) => void; - indexPatternId?: string; - selectableProps?: EuiSelectableProps; -}) { - const [isPopoverOpen, setPopoverIsOpen] = useState(false); - - const createTrigger = function () { - const { label, title, ...rest } = trigger; - return ( - setPopoverIsOpen(!isPopoverOpen)} - {...rest} - > - {label} - - ); - }; - - return ( - setPopoverIsOpen(false)} - className="eui-textTruncate" - anchorClassName="eui-textTruncate" - display="block" - panelPaddingSize="s" - ownFocus - > -
- - {i18n.translate('discover.fieldChooser.indexPattern.changeIndexPatternTitle', { - defaultMessage: 'Change index pattern', - })} - - ({ - label: title, - key: id, - value: id, - checked: id === indexPatternId ? 'on' : undefined, - }))} - onChange={(choices) => { - const choice = (choices.find(({ checked }) => checked) as unknown) as { - value: string; - }; - onChangeIndexPattern(choice.value); - setPopoverIsOpen(false); - }} - searchProps={{ - compressed: true, - ...(selectableProps ? selectableProps.searchProps : undefined), - }} - > - {(list, search) => ( - <> - {search} - {list} - - )} - -
-
- ); -} 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..98789598713c 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 = ( ); - } else if (field.name !== '_source' && selected) { + } else if (!isSourceField && selected) { actionButton = ( - ); - } - 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.scss b/src/plugins/discover/public/application/components/sidebar/discover_field_details.scss deleted file mode 100644 index 7bf0892d0148..000000000000 --- a/src/plugins/discover/public/application/components/sidebar/discover_field_details.scss +++ /dev/null @@ -1,6 +0,0 @@ -.dscFieldDetails__visualizeBtn { - @include euiFontSizeXS; - - height: $euiSizeL !important; - min-width: $euiSize * 4; -} 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 4de6f76b792a..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; @@ -167,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>, @@ -230,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`} @@ -239,7 +225,7 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { }; const selectionPanel = ( -
+ {buttonGroup('aggregatable', aggregatableLabel)} @@ -251,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', { @@ -300,8 +302,8 @@ export function DiscoverFieldSearch({ onChange, value, types }: Props) { /> - -
-
+ + + ); } diff --git a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.test.tsx b/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.test.tsx deleted file mode 100644 index 9298aef92cf0..000000000000 --- a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.test.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import { shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; - -// @ts-ignore -import { ShallowWrapper } from 'enzyme'; -import { ChangeIndexPattern } from './change_indexpattern'; -import { SavedObject } from 'opensearch-dashboards/server'; -import { DiscoverIndexPattern } from './discover_index_pattern'; -import { EuiSelectable } from '@elastic/eui'; -import { IIndexPattern } from 'src/plugins/data/public'; - -const indexPattern = { - id: 'test1', - title: 'test1 title', -} as IIndexPattern; - -const indexPattern1 = { - id: 'test1', - attributes: { - title: 'test1 titleToDisplay', - }, -} as SavedObject; - -const indexPattern2 = { - id: 'test2', - attributes: { - title: 'test2 titleToDisplay', - }, -} as SavedObject; - -const defaultProps = { - indexPatternList: [indexPattern1, indexPattern2], - selectedIndexPattern: indexPattern, - setIndexPattern: jest.fn(async () => {}), -}; - -function getIndexPatternPickerList(instance: ShallowWrapper) { - return instance.find(ChangeIndexPattern).first().dive().find(EuiSelectable); -} - -function getIndexPatternPickerOptions(instance: ShallowWrapper) { - return getIndexPatternPickerList(instance).prop('options'); -} - -function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { - const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( - instance - ).map((option: any) => - option.label === selectedLabel - ? { ...option, checked: 'on' } - : { ...option, checked: undefined } - ); - return getIndexPatternPickerList(instance).prop('onChange')!(options); -} - -describe('DiscoverIndexPattern', () => { - test('Invalid props dont cause an exception', () => { - const props = { - indexPatternList: null, - selectedIndexPattern: null, - setIndexPattern: jest.fn(), - } as any; - - expect(shallow()).toMatchSnapshot(`""`); - }); - test('should list all index patterns', () => { - const instance = shallow(); - - expect(getIndexPatternPickerOptions(instance)!.map((option: any) => option.label)).toEqual([ - 'test1 titleToDisplay', - 'test2 titleToDisplay', - ]); - }); - - test('should switch data panel to target index pattern', () => { - const instance = shallow(); - - selectIndexPatternPickerOption(instance, 'test2 titleToDisplay'); - expect(defaultProps.setIndexPattern).toHaveBeenCalledWith('test2'); - }); -}); diff --git a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.tsx b/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.tsx deleted file mode 100644 index c8487732471a..000000000000 --- a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern.tsx +++ /dev/null @@ -1,104 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React, { useState, useEffect } from 'react'; -import { SavedObject } from 'opensearch-dashboards/public'; -import { IIndexPattern, IndexPatternAttributes } from 'src/plugins/data/public'; -import { I18nProvider } from '@osd/i18n/react'; - -import { IndexPatternRef } from './types'; -import { ChangeIndexPattern } from './change_indexpattern'; -export interface DiscoverIndexPatternProps { - /** - * list of available index patterns, if length > 1, component offers a "change" link - */ - indexPatternList: Array>; - /** - * currently selected index pattern - */ - selectedIndexPattern: IIndexPattern; - /** - * triggered when user selects a new index pattern - */ - setIndexPattern: (id: string) => void; -} - -/** - * Component allows you to select an index pattern in discovers side bar - */ -export function DiscoverIndexPattern({ - indexPatternList, - selectedIndexPattern, - setIndexPattern, -}: DiscoverIndexPatternProps) { - const options: IndexPatternRef[] = (indexPatternList || []).map((entity) => ({ - id: entity.id, - title: entity.attributes!.title, - })); - const { id: selectedId, title: selectedTitle } = selectedIndexPattern || {}; - - const [selected, setSelected] = useState({ - id: selectedId, - title: selectedTitle || '', - }); - useEffect(() => { - const { id, title } = selectedIndexPattern; - const indexPattern = indexPatternList.find((pattern) => pattern.id === id); - const titleToDisplay = indexPattern ? indexPattern.attributes!.title : title; - setSelected({ id, title: titleToDisplay }); - }, [indexPatternList, selectedIndexPattern]); - if (!selectedId) { - return null; - } - - return ( -
- - { - const indexPattern = options.find((pattern) => pattern.id === id); - if (indexPattern) { - setIndexPattern(id); - setSelected(indexPattern); - } - }} - /> - -
- ); -} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_title.tsx b/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_title.tsx deleted file mode 100644 index 30b50a9006c8..000000000000 --- a/src/plugins/discover/public/application/components/sidebar/discover_index_pattern_title.tsx +++ /dev/null @@ -1,95 +0,0 @@ -/* - * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. - */ - -import React from 'react'; -import { EuiToolTip, EuiFlexItem, EuiFlexGroup, EuiTitle, EuiButtonEmpty } from '@elastic/eui'; - -import { FormattedMessage } from '@osd/i18n/react'; -import { i18n } from '@osd/i18n'; -export interface DiscoverIndexPatternTitleProps { - /** - * determines whether the change link is displayed - */ - isChangeable: boolean; - /** - * function triggered when the change link is clicked - */ - onChange: () => void; - /** - * title of the current index pattern - */ - title: string; -} - -/** - * Component displaying the title of the current selected index pattern - * and if changeable is true, a link is provided to change the index pattern - */ -export function DiscoverIndexPatternTitle({ - isChangeable, - onChange, - title, -}: DiscoverIndexPatternTitleProps) { - return ( - - - - -

{title}

-
-
-
- {isChangeable && ( - - - } - > - onChange()} - iconSide="right" - iconType="arrowDown" - color="text" - /> - - - )} -
- ); -} 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..f547dbe9deeb 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; + 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..b4ed88e02ed9 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/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 56da534240f9..3ef8e026702e 100644 --- a/src/plugins/discover/public/application/components/table/table.tsx +++ b/src/plugins/discover/public/application/components/table/table.tsx @@ -33,6 +33,7 @@ import { escapeRegExp } from 'lodash'; import { DocViewTableRow } from './table_row'; import { arrayContainsObjects } from './table_helper'; import { DocViewRenderProps } from '../../doc_views/doc_views_types'; +import './table.scss'; const COLLAPSE_LINE_LENGTH = 350; 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/discover_slice.test.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx new file mode 100644 index 000000000000..71e848bac15e --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx @@ -0,0 +1,67 @@ +/* + * 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']); + + const action2 = { type: 'discover/addColumn', payload: { column: 'column2', index: 0 } }; + const result2 = discoverSlice.reducer(result1, action2); + expect(result2.columns).toEqual(['column2', 'column1']); + }); + + it('should handle removeColumn', () => { + initialState = { + columns: ['column1', 'column2'], + sort: [], + }; + const action = { type: 'discover/removeColumn', payload: 'column1' }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column2']); + }); + + 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 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 index d664c5e1d6d4..998a5340faf6 100644 --- a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx @@ -7,12 +7,13 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; import { Filter, Query } from '../../../../../data/public'; import { DiscoverServices } from '../../../build_services'; import { RootState } from '../../../../../data_explorer/public'; +import { buildColumns } from '../columns'; export interface DiscoverState { /** * Columns displayed in the table */ - columns?: string[]; + columns: string[]; /** * Array of applied filters */ @@ -28,7 +29,7 @@ export interface DiscoverState { /** * Array of the used sorting [[field,direction],...] */ - sort?: string[][]; + sort: Array<[string, string]>; /** * id of the used saved query */ @@ -39,12 +40,14 @@ export interface DiscoverRootState extends RootState { discover: DiscoverState; } -const initialState = {} as DiscoverState; +const initialState: DiscoverState = { + columns: [], + sort: [], +}; export const getPreloadedState = async ({ data }: DiscoverServices): Promise => { return { ...initialState, - interval: data.query.timefilter.timefilter.getRefreshInterval().value.toString(), }; }; @@ -52,23 +55,48 @@ export const discoverSlice = createSlice({ name: 'discover', initialState, reducers: { - setState(state: T, action: PayloadAction) { + setState(state, action: PayloadAction) { return action.payload; }, - updateState(state: T, action: PayloadAction>) { - state = { + addColumn(state, action: PayloadAction<{ column: string; index?: number }>) { + const { column, index } = action.payload; + const columns = [...(state.columns || [])]; + if (index !== undefined) columns.splice(index, 0, column); + else columns.push(column); + return { ...state, columns: buildColumns(columns) }; + }, + removeColumn(state, action: PayloadAction) { + const columns = (state.columns || []).filter((column) => column !== action.payload); + return { + ...state, + columns: buildColumns(columns), + }; + }, + reorderColumn(state, action: PayloadAction<{ source: number; destination: number }>) { + const { source, destination } = action.payload; + const columns = [...(state.columns || [])]; + const [removed] = columns.splice(source, 1); + columns.splice(destination, 0, removed); + return { + ...state, + columns, + }; + }, + updateState(state, action: PayloadAction>) { + return { ...state, ...action.payload, }; - - return state; }, }, }); // Exposing the state functions as generics -export const setState = discoverSlice.actions.setState as (payload: T) => PayloadAction; -export const updateState = discoverSlice.actions.updateState as ( - payload: Partial -) => PayloadAction>; +export const { + addColumn, + removeColumn, + reorderColumn, + setState, + updateState, +} = discoverSlice.actions; export const { reducer } = discoverSlice; diff --git a/src/plugins/discover/public/application/view_components/canvas/canvas.tsx b/src/plugins/discover/public/application/view_components/canvas/canvas.tsx deleted file mode 100644 index fe52673832b2..000000000000 --- a/src/plugins/discover/public/application/view_components/canvas/canvas.tsx +++ /dev/null @@ -1,29 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { AppMountParameters } from '../../../../../../core/public'; -import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; -import { DiscoverServices } from '../../../build_services'; -import { TopNav } from './top_nav'; -import { DiscoverTable } from './discover_table'; - -interface CanvasProps { - opts: { - setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; - }; -} - -export const Canvas = ({ opts }: CanvasProps) => { - const { services } = useOpenSearchDashboards(); - const { history: getHistory } = services; - const history = getHistory(); - return ( -
- - -
- ); -}; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx index 84af208dbff8..e92fe96b7d3d 100644 --- a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx @@ -5,62 +5,70 @@ import React, { useState, useEffect } from 'react'; import { History } from 'history'; -import { IndexPattern } from '../../../opensearch_dashboards_services'; -import { DiscoverServices } from '../../../build_services'; -import { SavedSearch } from '../../../saved_searches'; -import { DiscoverTableService } from './discover_table_service'; -import { fetchIndexPattern, fetchSavedSearch } from '../utils/index_pattern_helper'; +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, useDispatch, useSelector } from '../../utils/state_management'; +import { SearchData } from '../utils/use_search'; -export interface DiscoverTableProps { - services: DiscoverServices; +interface Props { history: History; } -export const DiscoverTable = ({ history, services }: DiscoverTableProps) => { - const { core, chrome, data, uiSettings: config, toastNotifications } = services; - const [savedSearch, setSavedSearch] = useState(); - const [indexPattern, setIndexPattern] = useState(undefined); - // TODO: get id from data explorer since it is handling the routing logic - // Original angular code: const savedSearchId = $route.current.params.id; - const savedSearchId = ''; - useEffect(() => { - const fetchData = async () => { - const indexPatternData = await fetchIndexPattern(data, config); - setIndexPattern(indexPatternData.loaded); +export const DiscoverTable = ({ history }: Props) => { + const { services } = useOpenSearchDashboards(); + const { data$, indexPattern } = useDiscoverContext(); + const [fetchState, setFetchState] = useState({ + status: data$.getValue().status, + rows: [], + }); - const savedSearchData = await fetchSavedSearch( - core, - '', // basePath - history, - savedSearchId, - services, - toastNotifications - ); - if (savedSearchData && !savedSearchData?.searchSource.getField('index')) { - savedSearchData.searchSource.setField('index', indexPatternData); - } - setSavedSearch(savedSearchData); + const { columns } = useSelector((state) => state.discover); + const dispatch = useDispatch(); + + const { rows } = fetchState || {}; - if (savedSearchId) { - chrome.recentlyAccessed.add( - savedSearchData.getFullPath(), - savedSearchData.title, - savedSearchData.id - ); + useEffect(() => { + const subscription = data$.subscribe((next) => { + if (next.status !== fetchState.status || (next.rows && next.rows !== fetchState.rows)) { + setFetchState({ ...fetchState, ...next }); } + }); + return () => { + subscription.unsubscribe(); }; - fetchData(); - }, [data, config, core, chrome, toastNotifications, history, savedSearchId, services]); + }, [data$, fetchState]); - if (!savedSearch || !savedSearch.searchSource || !indexPattern) { - // TODO: handle loading state + if (indexPattern === undefined) { + // TODO: handle better return null; } + + if (!rows || rows.length === 0) { + // TODO: handle better + return
{'loading...'}
; + } + return ( - + dispatch( + addColumn({ + column, + }) + ) + } + onFilter={() => {}} + onRemoveColumn={(column) => dispatch(removeColumn(column))} + onSetColumns={() => {}} + onSort={() => {}} + sort={[]} + rows={rows} + displayTimeColumn={true} + services={services} /> ); }; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_table_app.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_table_app.tsx deleted file mode 100644 index b40492da8db3..000000000000 --- a/src/plugins/discover/public/application/view_components/canvas/discover_table_app.tsx +++ /dev/null @@ -1,62 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ -import React, { useState, useEffect } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPageContent } from '@elastic/eui'; -import { DataGridTable } from '../../components/data_grid/data_grid_table'; - -export const DiscoverTableApplication = ({ data$, indexPattern, savedSearch, services }) => { - const [fetchState, setFetchState] = useState({ - status: data$.getValue().status, - fetchCounter: 0, - fieldCounts: {}, - rows: [], - }); - - const { rows } = fetchState; - - useEffect(() => { - const subscription = data$.subscribe((next) => { - if ( - (next.status && next.status !== fetchState.status) || - (next.rows && next.rows !== fetchState.rows) - ) { - setFetchState({ ...fetchState, ...next }); - } - }); - return () => { - subscription.unsubscribe(); - }; - }, [data$, fetchState]); - - // ToDo: implement columns, onAddColumn, onRemoveColumn, onMoveColumn, onSetColumns using config, indexPattern, appState - - if (rows.length === 0) { - return
{'loading...'}
; - } else { - return ( - - - -
- {}} - onFilter={() => {}} - onRemoveColumn={() => {}} - onSetColumns={() => {}} - onSort={() => {}} - sort={[]} - rows={rows} - displayTimeColumn={true} - services={services} - /> -
-
-
-
- ); - } -}; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_table_service.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_table_service.tsx deleted file mode 100644 index d0b0b0d1543e..000000000000 --- a/src/plugins/discover/public/application/view_components/canvas/discover_table_service.tsx +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React, { useEffect } from 'react'; -import { IndexPattern } from '../../../opensearch_dashboards_services'; -import { DiscoverServices } from '../../../build_services'; -import { SavedSearch } from '../../../saved_searches'; -import { useDiscoverTableService } from './utils/use_discover_canvas_service'; -import { DiscoverTableApplication } from './discover_table_app'; - -export interface DiscoverTableAppProps { - services: DiscoverServices; - savedSearch: SavedSearch; - indexPattern: IndexPattern; -} - -export const DiscoverTableService = ({ - services, - savedSearch, - indexPattern, -}: DiscoverTableAppProps) => { - const { data$, refetch$ } = useDiscoverTableService({ - services, - savedSearch, - indexPattern, - }); - - // trigger manual fetch - // TODO: remove this once we implement refetch data: - // Based on the controller, refetch$ should emit next when - // 1) appStateContainer interval and sort change - // 2) savedSearch id changes - // 3) timefilter.getRefreshInterval().pause === false - // 4) TopNavMenu updateQuery() is called - useEffect(() => { - refetch$.next(); - }, [refetch$]); - - 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 index 34fd6a0bf103..cdfbbfe9b161 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -4,21 +4,20 @@ */ import React from 'react'; +import { TopNav } from './top_nav'; import { ViewProps } from '../../../../../data_explorer/public'; -import { OpenSearchDashboardsContextProvider } from '../../../../../opensearch_dashboards_react/public'; -import { Canvas } from './canvas'; -import { getServices } from '../../../opensearch_dashboards_services'; +import { DiscoverTable } from './discover_table'; // eslint-disable-next-line import/no-default-export -export default function CanvasApp({ setHeaderActionMenu }: ViewProps) { - const services = getServices(); +export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewProps) { return ( - - + - + + ); } diff --git a/src/plugins/discover/public/application/view_components/canvas/utils/use_discover_canvas_service.ts b/src/plugins/discover/public/application/view_components/canvas/utils/use_discover_canvas_service.ts deleted file mode 100644 index 38d2b18b7b9b..000000000000 --- a/src/plugins/discover/public/application/view_components/canvas/utils/use_discover_canvas_service.ts +++ /dev/null @@ -1,49 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { useMemo, useEffect } from 'react'; -import { DiscoverServices } from '../../../../build_services'; -import { SavedSearch } from '../../../../saved_searches'; -import { useSavedSearch } from '../../utils/use_saved_search'; -import { IndexPattern } from '../../../../opensearch_dashboards_services'; - -export interface DiscoverTableServiceProps { - services: DiscoverServices; - savedSearch: SavedSearch; - indexPattern: IndexPattern; -} - -export const useDiscoverTableService = ({ - services, - savedSearch, - indexPattern, -}: DiscoverTableServiceProps) => { - const searchSource = useMemo(() => { - savedSearch.searchSource.setField('index', indexPattern); - return savedSearch.searchSource; - }, [savedSearch, indexPattern]); - - const { data$, refetch$ } = useSavedSearch({ - searchSource, - services, - indexPattern, - }); - - useEffect(() => { - const dataSubscription = data$.subscribe((data) => {}); - const refetchSubscription = refetch$.subscribe((refetch) => {}); - - return () => { - dataSubscription.unsubscribe(); - refetchSubscription.unsubscribe(); - }; - }, [data$, refetch$]); - - return { - data$, - refetch$, - indexPattern, - }; -}; 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/panel/index.tsx b/src/plugins/discover/public/application/view_components/panel/index.tsx index c05807d3a63a..a9e29a34e435 100644 --- a/src/plugins/discover/public/application/view_components/panel/index.tsx +++ b/src/plugins/discover/public/application/view_components/panel/index.tsx @@ -3,18 +3,64 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React from 'react'; +import React, { useEffect, useState } from 'react'; import { ViewProps } from '../../../../../data_explorer/public'; -import { OpenSearchDashboardsContextProvider } from '../../../../../opensearch_dashboards_react/public'; -import { Panel } from './panel'; -import { getServices } from '../../../opensearch_dashboards_services'; +import { + addColumn, + removeColumn, + reorderColumn, + useDispatch, + useSelector, +} from '../../utils/state_management'; +import { DiscoverSidebar } from '../../components/sidebar'; +import { useDiscoverContext } from '../context'; +import { SearchData } from '../utils/use_search'; // eslint-disable-next-line import/no-default-export -export default function PanelApp(props: ViewProps) { - const services = getServices(); +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) => { + 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/panel/panel.tsx b/src/plugins/discover/public/application/view_components/panel/panel.tsx deleted file mode 100644 index fda7f8a44318..000000000000 --- a/src/plugins/discover/public/application/view_components/panel/panel.tsx +++ /dev/null @@ -1,12 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { useSelector } from '../../utils/state_management'; - -export const Panel = () => { - const interval = useSelector((state) => state.discover.interval); - return
{interval}
; -}; diff --git a/src/plugins/discover/public/application/view_components/utils/create_search_source.ts b/src/plugins/discover/public/application/view_components/utils/create_search_source.ts new file mode 100644 index 000000000000..d03c604c5915 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/create_search_source.ts @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IndexPattern } from 'src/plugins/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; +} + +export const createSearchSource = async ({ indexPattern, services, sort }: 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 searchSource = await data.search.searchSource.create({ + index: indexPattern, + sort: sortForSearchSource, + size, + query: data.query.queryString.getQuery() || null, + highlightAll: true, + version: true, + }); + + // Add time filter + const timefilter = data.query.timefilter.timefilter; + const timeRangeFilter = timefilter.createFilter(indexPattern); + if (timeRangeFilter) { + filters.push(timeRangeFilter); + } + searchSource.setField('filter', filters); + + return searchSource; +}; diff --git a/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts b/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts index 0953ccd25b47..b19128a432e0 100644 --- a/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts +++ b/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts @@ -28,7 +28,7 @@ * under the License. */ -import { OpenSearchQuerySortValue, IndexPattern } from '../../../../opensearch_dashboards_services'; +import { OpenSearchQuerySortValue, IndexPattern } from '../../../opensearch_dashboards_services'; import { getSort } from './get_sort'; import { getDefaultSort } from './get_default_sort'; diff --git a/src/plugins/discover/public/application/view_components/utils/update_data_source.ts b/src/plugins/discover/public/application/view_components/utils/update_data_source.ts deleted file mode 100644 index 00ab963e8863..000000000000 --- a/src/plugins/discover/public/application/view_components/utils/update_data_source.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { ISearchSource, IndexPattern } from 'src/plugins/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'; - -export interface UpdateDataSourceProps { - searchSource: ISearchSource; - indexPattern: IndexPattern; - services: DiscoverServices; - sort: SortOrder[] | undefined; -} - -export const updateDataSource = ({ - searchSource, - indexPattern, - services, - sort, -}: UpdateDataSourceProps) => { - const { uiSettings, data } = services; - const sortForSearchSource = getSortForSearchSource( - sort, - indexPattern, - uiSettings.get(SORT_DEFAULT_ORDER_SETTING) - ); - const size = uiSettings.get(SAMPLE_SIZE_SETTING); - const updatedSearchSource = searchSource - .setField('index', indexPattern) - .setField('sort', sortForSearchSource) - .setField('size', size) - .setField('query', data.query.queryString.getQuery() || null) - .setField('filter', data.query.filterManager.getFilters()) - .setField('highlightAll', true) - .setField('version', true); - - return updatedSearchSource; -}; 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..fa8e5d5e884b --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/use_index_pattern.ts @@ -0,0 +1,44 @@ +/* + * 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 { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { DiscoverViewServices } from '../../../build_services'; + +export const useIndexPattern = () => { + const indexPatternId = useSelector((state) => state.metadata.indexPattern); + const [indexPattern, setIndexPattern] = useState(undefined); + + const { + services: { data, toastNotifications }, + } = useOpenSearchDashboards(); + + useEffect(() => { + 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(setIndexPattern) + .catch(() => { + toastNotifications.addDanger({ + title: indexPatternMissingWarning, + }); + }); + }, [indexPatternId, data.indexPatterns, toastNotifications]); + + return indexPattern; +}; diff --git a/src/plugins/discover/public/application/view_components/utils/use_saved_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts similarity index 62% rename from src/plugins/discover/public/application/view_components/utils/use_saved_search.ts rename to src/plugins/discover/public/application/view_components/utils/use_search.ts index 61d802d45140..5726abf70d82 100644 --- a/src/plugins/discover/public/application/view_components/utils/use_saved_search.ts +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -4,14 +4,15 @@ */ import { useCallback, useMemo, useRef } from 'react'; -import { ISearchSource, IndexPattern } from 'src/plugins/data/public'; import { BehaviorSubject, Subject, merge } from 'rxjs'; import { debounceTime } from 'rxjs/operators'; import { useEffect } from 'react'; import { DiscoverServices } from '../../../build_services'; -import { validateTimeRange } from '../../../application/helpers/validate_time_range'; -import { updateDataSource } from './update_data_source'; +import { validateTimeRange } from '../../helpers/validate_time_range'; +import { createSearchSource } from './create_search_source'; +import { useIndexPattern } from './use_index_pattern'; +import { OpenSearchSearchHit } from '../../doc_views/doc_views_types'; export enum FetchStatus { UNINITIALIZED = 'uninitialized', @@ -20,29 +21,34 @@ export enum FetchStatus { ERROR = 'error', } -export interface SavedSearchData { +export interface SearchData { status: FetchStatus; fetchCounter?: number; fieldCounts?: Record; fetchError?: Error; hits?: number; - rows?: any[]; // TODO: type + rows?: OpenSearchSearchHit[]; } -export type SavedSearchRefetch = 'refetch' | undefined; +export type SearchRefetch = 'refetch' | undefined; -export type DataSubject = BehaviorSubject; -export type RefetchSubject = BehaviorSubject; +export type DataSubject = BehaviorSubject; +export type RefetchSubject = Subject; -export const useSavedSearch = ({ - indexPattern, - searchSource, - services, -}: { - indexPattern: IndexPattern; - searchSource: ISearchSource; - services: DiscoverServices; -}) => { +/** + * A hook that provides functionality for fetching and managing discover search data. + * @returns { data: DataSubject, refetch$: RefetchSubject, indexPattern: IndexPattern } - data is a BehaviorSubject that emits the current search data, refetch$ is a Subject that can be used to trigger a refetch. + * @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 indexPattern = useIndexPattern(); const { data, filterManager } = services; const timefilter = data.query.timefilter.timefilter; const fetchStateRef = useRef<{ @@ -56,23 +62,23 @@ export const useSavedSearch = ({ }); const data$ = useMemo( - () => new BehaviorSubject({ state: FetchStatus.UNINITIALIZED }), + () => new BehaviorSubject({ status: FetchStatus.UNINITIALIZED }), [] ); - const refetch$ = useMemo(() => new Subject(), []); + const refetch$ = useMemo(() => new Subject(), []); const fetch = useCallback(async () => { - if (!validateTimeRange(timefilter.getTime(), services.toastNotifications)) { + if (!validateTimeRange(timefilter.getTime(), services.toastNotifications) || !indexPattern) { return Promise.reject(); } if (fetchStateRef.current.abortController) fetchStateRef.current.abortController.abort(); fetchStateRef.current.abortController = new AbortController(); const sort = undefined; - const updatedSearchSource = updateDataSource({ searchSource, indexPattern, services, sort }); + const searchSource = await createSearchSource({ indexPattern, services, sort }); try { - const fetchResp = await updatedSearchSource.fetch({ + const fetchResp = await searchSource.fetch({ abortSignal: fetchStateRef.current.abortController.signal, }); const hits = fetchResp.hits.total as number; @@ -95,13 +101,14 @@ export const useSavedSearch = ({ } catch (err) { // TODO: handle the error } - }, [data$, timefilter, services, searchSource, indexPattern, fetchStateRef]); + }, [data$, timefilter, services, indexPattern]); useEffect(() => { const fetch$ = merge( refetch$, filterManager.getFetches$(), timefilter.getFetch$(), + timefilter.getTimeUpdate$(), timefilter.getAutoRefreshFetch$(), data.query.queryString.getUpdates$() ).pipe(debounceTime(100)); @@ -113,12 +120,15 @@ export const useSavedSearch = ({ } catch (error) { data$.next({ status: FetchStatus.ERROR, - fetchError: error, + fetchError: error as Error, }); } })(); }); + // kick off initial fetch + refetch$.next(); + return () => { subscription.unsubscribe(); }; @@ -127,5 +137,8 @@ export const useSavedSearch = ({ return { data$, refetch$, + indexPattern, }; }; + +export type SearchContextValue = ReturnType; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index a2b70f5c5099..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; @@ -124,3 +125,6 @@ export 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/migrate_state.ts b/src/plugins/discover/public/migrate_state.ts new file mode 100644 index 000000000000..1b463e2167c1 --- /dev/null +++ b/src/plugins/discover/public/migrate_state.ts @@ -0,0 +1,123 @@ +/* + * 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 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: '#/\\?:appState', extraState: {}, path: `discover` }, + ]; + + // Get the first matching path pattern. + const matchingPathPattern = pathPatterns.find((pathPattern) => + matchPath(oldPath, { path: pathPattern.pattern }) + ); + + if (!matchingPathPattern) { + return path; + } + + // Migrate the path. + switch (matchingPathPattern.path) { + case `discover`: + const params = matchPath(oldPath, { + path: matchingPathPattern.pattern, + })!.params; + + const appState = getStateFromOsdUrl('_a', `/#?${params.appState}`); + + 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 205a345acc33..7149454a34ce 100644 --- a/src/plugins/discover/public/opensearch_dashboards_services.ts +++ b/src/plugins/discover/public/opensearch_dashboards_services.ts @@ -36,6 +36,7 @@ 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 services: DiscoverServices | null = null; let uiActions: UiActionsStart; @@ -58,15 +59,14 @@ 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' ); +export const [getDocViewsLinksRegistry, setDocViewsLinksRegistry] = createGetterSetter< + DocViewsLinksRegistry +>('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 81c322fa4f0d..8a581a3d246f 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -5,7 +5,6 @@ import { i18n } from '@osd/i18n'; import { BehaviorSubject } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; import { AppMountParameters, @@ -28,24 +27,27 @@ import { 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 { lazy } from 'react'; import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; import { SavedObjectLoader } from '../../saved_objects/public'; -import { createOsdUrlTracker } 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'; +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, - setUrlTracker, + setDocViewsLinksRegistry, setServices, setHeaderActionMenuMounter, setUiActions, setScopedHistory, - getScopedHistory, syncHistoryLocations, getServices, } from './opensearch_dashboards_services'; @@ -58,13 +60,14 @@ import { } from './url_generator'; // import { SearchEmbeddableFactory } from './application/embeddable'; import { NEW_DISCOVER_APP, PLUGIN_ID } from '../common'; -import { DataExplorerPluginSetup, ViewRedirectParams } from '../../data_explorer/public'; +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 { @@ -83,6 +86,10 @@ export interface DiscoverSetup { */ addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; }; + + docViewsLinks: { + addDocViewLink(docViewLinkRaw: DocViewLink): void; + }; } export interface DiscoverStart { @@ -148,6 +155,7 @@ export class DiscoverPlugin private appStateUpdater = new BehaviorSubject(() => ({})); private docViewsRegistry: DocViewsRegistry | null = null; + private docViewsLinksRegistry: DocViewsLinksRegistry | null = null; private stopUrlTracking: (() => void) | undefined = undefined; private servicesInitialized: boolean = false; private urlGenerator?: DiscoverStart['urlGenerator']; @@ -186,41 +194,51 @@ export class DiscoverPlugin component: JsonCodeBlock, }); - 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), - })) - ), - }, - ], + 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, }); - setUrlTracker({ setTrackedUrl, restorePreviousUrl }); - this.stopUrlTracking = () => { - stopUrlTracker(); - }; core.application.register({ id: PLUGIN_ID, @@ -240,7 +258,6 @@ export class DiscoverPlugin setScopedHistory(params.history); setHeaderActionMenuMounter(params.setHeaderActionMenu); syncHistoryLocations(); - appMounted(); const { core: { application: { navigateToApp }, @@ -256,18 +273,14 @@ export class DiscoverPlugin path, }); } else { + const newPath = migrateUrlState(path); navigateToApp('data-explorer', { replace: true, - path: `/${PLUGIN_ID}`, - state: { - path, - } as ViewRedirectParams, + path: `/${PLUGIN_ID}${newPath}`, }); } - return () => { - appUnMounted(); - }; + return () => {}; }, }); @@ -320,9 +333,10 @@ export class DiscoverPlugin slice: discoverSlice, }, shouldShow: () => true, - // ViewCompon + // 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); @@ -331,6 +345,9 @@ export class DiscoverPlugin docViews: { addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), }, + docViewsLinks: { + addDocViewLink: this.docViewsLinksRegistry.addDocViewLink.bind(this.docViewsLinksRegistry), + }, }; } diff --git a/src/plugins/discover_legacy/public/plugin.ts b/src/plugins/discover_legacy/public/plugin.ts index 85cd68a07791..7e855b707891 100644 --- a/src/plugins/discover_legacy/public/plugin.ts +++ b/src/plugins/discover_legacy/public/plugin.ts @@ -331,12 +331,9 @@ export class DiscoverPlugin const v2Enabled = core.uiSettings.get(NEW_DISCOVER_APP); if (v2Enabled) { - navigateToApp('data-explorer', { + navigateToApp('discover', { replace: true, - path: `/discover`, - state: { - path, - } as ViewRedirectParams, + path, }); } setScopedHistory(params.history); 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 };