;
+
+export class I18nProvider implements angular.IServiceProvider {
+ public addTranslation = i18n.addTranslation;
+ public getTranslation = i18n.getTranslation;
+ public setLocale = i18n.setLocale;
+ public getLocale = i18n.getLocale;
+ public setDefaultLocale = i18n.setDefaultLocale;
+ public getDefaultLocale = i18n.getDefaultLocale;
+ public setFormats = i18n.setFormats;
+ public getFormats = i18n.getFormats;
+ public getRegisteredLocales = i18n.getRegisteredLocales;
+ public init = i18n.init;
+ public load = i18n.load;
+ public $get = () => i18n.translate;
+}
diff --git a/packages/osd-stylelint-config/config/global_selectors.json b/packages/osd-stylelint-config/config/global_selectors.json
index d89561a04cbc..760717c8dab5 100644
--- a/packages/osd-stylelint-config/config/global_selectors.json
+++ b/packages/osd-stylelint-config/config/global_selectors.json
@@ -23,7 +23,9 @@
"src/plugins/vis_builder/public/application/components/searchable_dropdown.scss",
"src/plugins/vis_builder/public/application/components/side_nav.scss",
"packages/osd-ui-framework/src/components/button/button_group/_button_group.scss",
+ "src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss",
+ "src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss",
"src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.scss"
]
}
-}
\ No newline at end of file
+}
diff --git a/packages/osd-ui-framework/src/components/local_nav/_local_date_picker.scss b/packages/osd-ui-framework/src/components/local_nav/_local_date_picker.scss
index ec60ffb4a918..0a38a115c4a0 100644
--- a/packages/osd-ui-framework/src/components/local_nav/_local_date_picker.scss
+++ b/packages/osd-ui-framework/src/components/local_nav/_local_date_picker.scss
@@ -54,6 +54,19 @@
.kuiDatePickerRowCell {
padding: 0;
text-align: center;
+
+ /**
+ * This state class exists to support weird angular-bootstrap datepicker functionality,
+ * in which you can't select a day on the "From" calendar if it falls after the selected day in
+ * the "To" calendar (and vice versa, you can't select a "To" day if it is before the "From" day).
+ */
+ &.kuiDatePickerRowCell-isBlocked {
+ cursor: not-allowed;
+
+ .kuiDatePickerRowCellContent {
+ pointer-events: none;
+ }
+ }
}
/**
diff --git a/packages/osd-ui-shared-deps/entry.js b/packages/osd-ui-shared-deps/entry.js
index 8afb2b08da6c..813fe512a554 100644
--- a/packages/osd-ui-shared-deps/entry.js
+++ b/packages/osd-ui-shared-deps/entry.js
@@ -30,13 +30,16 @@
require('./polyfills');
+// must load before angular
export const Jquery = require('jquery');
window.$ = window.jQuery = Jquery;
require('./flot_charts');
// stateful deps
export const OsdI18n = require('@osd/i18n');
+export const OsdI18nAngular = require('@osd/i18n/angular');
export const OsdI18nReact = require('@osd/i18n/react');
+export const Angular = require('angular');
export const Moment = require('moment');
export const MomentTimezone = require('moment-timezone/moment-timezone');
export const OsdMonaco = require('@osd/monaco');
diff --git a/packages/osd-ui-shared-deps/index.js b/packages/osd-ui-shared-deps/index.js
index 36218a28d4eb..1ebd54a55a97 100644
--- a/packages/osd-ui-shared-deps/index.js
+++ b/packages/osd-ui-shared-deps/index.js
@@ -40,7 +40,9 @@ exports.darkCssDistFilename = 'osd-ui-shared-deps.v7.dark.css';
exports.darkV8CssDistFilename = 'osd-ui-shared-deps.v8.dark.css';
exports.externals = {
// stateful deps
+ angular: '__osdSharedDeps__.Angular',
'@osd/i18n': '__osdSharedDeps__.OsdI18n',
+ '@osd/i18n/angular': '__osdSharedDeps__.OsdI18nAngular',
'@osd/i18n/react': '__osdSharedDeps__.OsdI18nReact',
jquery: '__osdSharedDeps__.Jquery',
moment: '__osdSharedDeps__.Moment',
diff --git a/packages/osd-ui-shared-deps/package.json b/packages/osd-ui-shared-deps/package.json
index ca6028d56acc..fca9abd7c537 100644
--- a/packages/osd-ui-shared-deps/package.json
+++ b/packages/osd-ui-shared-deps/package.json
@@ -16,6 +16,7 @@
"@osd/i18n": "1.0.0",
"@osd/monaco": "1.0.0",
"abortcontroller-polyfill": "^1.4.0",
+ "angular": "^1.8.2",
"axios": "^0.27.2",
"compression-webpack-plugin": "npm:@amoo-miki/compression-webpack-plugin@4.0.1-rc.1",
"core-js": "^3.6.5",
@@ -51,4 +52,4 @@
"val-loader": "^2.1.2",
"webpack": "npm:@amoo-miki/webpack@4.46.0-rc.2"
}
-}
\ No newline at end of file
+}
diff --git a/src/dev/i18n/README.md b/src/dev/i18n/README.md
index 7cfb938d38ec..4a9e3f45eff0 100644
--- a/src/dev/i18n/README.md
+++ b/src/dev/i18n/README.md
@@ -4,7 +4,7 @@
### Description
-The tool is used to extract default messages from all `*.{js, ts, jsx, tsx }` files in provided plugins directories to a JSON file.
+The tool is used to extract default messages from all `*.{js, ts, jsx, tsx, html }` files in provided plugins directories to a JSON file.
It uses Babel to parse code and build an AST for each file or a single JS expression if whole file parsing is impossible. The tool is able to validate, extract and match IDs, default messages and descriptions only if they are defined statically and together, otherwise it will fail with detailed explanation. That means one can't define ID in one place and default message in another, or use function call to dynamically create default message etc.
@@ -18,6 +18,33 @@ The `defaultMessage` must contain ICU references to all keys in the `values` and
The `description` is optional, `values` is optional too unless `defaultMessage` references to it.
+* **Angular (.html)**
+
+ * **Filter**
+
+ ```
+ {{ ::'pluginNamespace.messageId' | i18n: {
+ defaultMessage: 'Default message string literal, {key}',
+ values: { key: 'value' },
+ description: 'Message context or description'
+ } }}
+ ```
+
+ * Don't break `| i18n: {` with line breaks, and don't skip whitespaces around `i18n:`.
+ * `::` operator is optional. Omit it if you need data binding for the `values`.
+
+ * **Directive**
+
+ ```html
+
+ ```
+
+ * `html_` prefixes will be removed from `i18n-values` keys before validation.
* **React (.jsx, .tsx)**
diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html
new file mode 100644
index 000000000000..f725fa288405
--- /dev/null
+++ b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html
@@ -0,0 +1,8 @@
+
+
+
diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html
new file mode 100644
index 000000000000..c12843602b13
--- /dev/null
+++ b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html
@@ -0,0 +1 @@
+{{ ::'plugin_2.message-id' | i18n: { defaultMessage: 'Message text' } }}
diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx
deleted file mode 100644
index b3f0c8d2b9c1..000000000000
--- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx
+++ /dev/null
@@ -1,6 +0,0 @@
-/*
- * Copyright OpenSearch Contributors
- * SPDX-License-Identifier: Apache-2.0
- */
-
-i18n('plugin_2.message-id', { defaultMessage: 'Message text' });
diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_4/test_file_4.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_4/test_file_4.jsx
deleted file mode 100644
index 5ce7b916bcd4..000000000000
--- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_4/test_file_4.jsx
+++ /dev/null
@@ -1,18 +0,0 @@
-/*
- * Copyright OpenSearch Contributors
- * SPDX-License-Identifier: Apache-2.0
- */
-
-/* eslint-disable */
-class Component extends PureComponent {
- render() {
- return (
-
-
-
- );
- }
-}
\ No newline at end of file
diff --git a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap
index cf91d6252d05..b19b366a8db7 100644
--- a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap
+++ b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap
@@ -30,52 +30,11 @@ Array [
"message": "Message 4",
},
],
-]
-`;
-
-exports[`dev/i18n/extract_default_translations extracts messages from path to map 2`] = `
-Array [
- Array [
- "plugin_2.message-id",
- Object {
- "description": undefined,
- "message": "Message text",
- },
- ],
-]
-`;
-
-exports[`dev/i18n/extract_default_translations extracts messages from path to map 3`] = `
-Array [
- Array [
- "plugin_3.duplicate_id",
- Object {
- "description": undefined,
- "message": "Message 1",
- },
- ],
-]
-`;
-
-exports[`dev/i18n/extract_default_translations extracts messages from path to map 4`] = `
-Array [
Array [
- "plugin_3.duplicate_id",
+ "plugin_1.id_7",
Object {
"description": undefined,
- "message": "Message 1",
- },
- ],
-]
-`;
-
-exports[`dev/i18n/extract_default_translations extracts messages from path to map 5`] = `
-Array [
- Array [
- "plugin_4.id_1",
- Object {
- "description": undefined,
- "message": "Message 4",
+ "message": "Message 7",
},
],
]
diff --git a/src/dev/i18n/extract_default_translations.test.js b/src/dev/i18n/extract_default_translations.test.js
index c995ec562735..ea4754f3645e 100644
--- a/src/dev/i18n/extract_default_translations.test.js
+++ b/src/dev/i18n/extract_default_translations.test.js
@@ -42,7 +42,6 @@ const pluginsPaths = [
path.join(fixturesPath, 'test_plugin_2'),
path.join(fixturesPath, 'test_plugin_3'),
path.join(fixturesPath, 'test_plugin_3_additional_path'),
- path.join(fixturesPath, 'test_plugin_4'),
];
const config = {
@@ -53,19 +52,17 @@ const config = {
'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3',
'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path',
],
- plugin_4: ['src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_4'],
},
exclude: [],
};
describe('dev/i18n/extract_default_translations', () => {
test('extracts messages from path to map', async () => {
- for (const pluginPath of pluginsPaths) {
- const resultMap = new Map();
+ const [pluginPath] = pluginsPaths;
+ const resultMap = new Map();
- await extractMessagesFromPathToMap(pluginPath, resultMap, config, new ErrorReporter());
- expect([...resultMap].sort()).toMatchSnapshot();
- }
+ await extractMessagesFromPathToMap(pluginPath, resultMap, config, new ErrorReporter());
+ expect([...resultMap].sort()).toMatchSnapshot();
});
test('throws on id collision', async () => {
@@ -91,11 +88,11 @@ describe('dev/i18n/extract_default_translations', () => {
const id = 'plugin_3.message-id';
const filePath1 = path.resolve(
__dirname,
- '__fixtures__/extract_default_translations/test_plugin_3/test_file.jsx'
+ '__fixtures__/extract_default_translations/test_plugin_3/test_file.html'
);
const filePath2 = path.resolve(
__dirname,
- '__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.jsx'
+ '__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.html'
);
expect(() => validateMessageNamespace(id, filePath1, config.paths)).not.toThrow();
expect(() => validateMessageNamespace(id, filePath2, config.paths)).not.toThrow();
@@ -106,7 +103,7 @@ describe('dev/i18n/extract_default_translations', () => {
const id = 'wrong_plugin_namespace.message-id';
const filePath = path.resolve(
__dirname,
- '__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx'
+ '__fixtures__/extract_default_translations/test_plugin_2/test_file.html'
);
expect(() => validateMessageNamespace(id, filePath, config.paths, { report })).not.toThrow();
diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx
index dd874d3419f2..956bb5a7a836 100644
--- a/src/plugins/dashboard/public/plugin.tsx
+++ b/src/plugins/dashboard/public/plugin.tsx
@@ -80,6 +80,7 @@ import {
withNotifyOnErrors,
} from '../../opensearch_dashboards_utils/public';
import {
+ initAngularBootstrap,
OpenSearchDashboardsLegacySetup,
OpenSearchDashboardsLegacyStart,
} from '../../opensearch_dashboards_legacy/public';
@@ -451,6 +452,9 @@ export class DashboardPlugin
},
};
+ // TODO: delete this when discover de-angular is completed
+ initAngularBootstrap();
+
core.application.register(app);
urlForwarding.forwardApp(
DashboardConstants.DASHBOARDS_ID,
diff --git a/src/plugins/data/common/search/aggs/utils/prop_filter.ts b/src/plugins/data/common/search/aggs/utils/prop_filter.ts
index 2670e3d26b82..341032e47bf6 100644
--- a/src/plugins/data/common/search/aggs/utils/prop_filter.ts
+++ b/src/plugins/data/common/search/aggs/utils/prop_filter.ts
@@ -37,7 +37,7 @@ type FilterFunc = (item: T[P]) => boolean;
* - fieldType filters a list of fields by their type property
* - aggFilter filters a list of aggs by their name property
*
- * @returns the filter function
+ * @returns the filter function which can be registered with angular
*/
export function propFilter
(prop: P) {
/**
diff --git a/src/plugins/data/common/search/aggs/utils/to_angular_json.ts b/src/plugins/data/common/search/aggs/utils/to_angular_json.ts
index 3eac6a1fcfe4..0efafa7884a1 100644
--- a/src/plugins/data/common/search/aggs/utils/to_angular_json.ts
+++ b/src/plugins/data/common/search/aggs/utils/to_angular_json.ts
@@ -33,7 +33,6 @@
* https://github.com/angular/angular.js/blob/master/src/Angular.js#L1312
*
* @internal
- * @deprecated This function will be removed in the next major version.
*/
export function toAngularJSON(obj: any, pretty?: any): string {
if (obj === undefined) return '';
diff --git a/src/plugins/data_explorer/public/components/sidebar/index.tsx b/src/plugins/data_explorer/public/components/sidebar/index.tsx
index 42abed642e36..921e3894983b 100644
--- a/src/plugins/data_explorer/public/components/sidebar/index.tsx
+++ b/src/plugins/data_explorer/public/components/sidebar/index.tsx
@@ -76,7 +76,6 @@ export const Sidebar: FC = ({ children }) => {
{
const [inspectedHit, setInspectedHit] = useState();
const rowCount = useMemo(() => (rows ? rows.length : 0), [rows]);
@@ -172,12 +166,7 @@ export const DataGridTable = ({
indexPattern,
}}
>
-
+ <>
{table}
@@ -194,7 +183,7 @@ export const DataGridTable = ({
onClose={() => setInspectedHit(undefined)}
/>
)}
-
+ >
);
};
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 315d5ca9c006..8cfeaf9795af 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
@@ -13,15 +13,14 @@ import {
EuiFlexItem,
EuiTitle,
} from '@elastic/eui';
-import { FormattedMessage } from '@osd/i18n/react';
import { DocViewer } from '../doc_viewer/doc_viewer';
import { IndexPattern } from '../../../opensearch_dashboards_services';
-import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types';
+import { DocViewFilterFn } from '../../doc_views/doc_views_types';
import { DocViewerLinks } from '../doc_viewer_links/doc_viewer_links';
interface Props {
columns: string[];
- hit: OpenSearchSearchHit;
+ hit: any;
indexPattern: IndexPattern;
onAddColumn: (column: string) => void;
onClose: () => void;
@@ -41,12 +40,10 @@ export function DataGridFlyout({
// 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
diff --git a/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap b/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap
index 52dfa61e07f3..5da67e79a7c7 100644
--- a/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap
+++ b/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap
@@ -17,7 +17,7 @@ exports[`Render with 2 different links 1`] = `
key="0"
>
with 2 different links 1`] = `
key="1"
>
{
const { generateCb, href, ...props } = item;
const listItem: EuiListGroupItemProps = {
- 'data-test-subj': `docTableRowAction`,
+ 'data-test-subj': 'docTableRowAction',
...props,
href: generateCb ? generateCb(renderProps).url : href,
};
@@ -31,7 +31,7 @@ export function DocViewerLinks(renderProps: DocViewLinkRenderProps) {
href={item.href}
target="_blank"
style={{ fontWeight: 'normal' }}
- data-test-subj={`${item['data-test-subj']}-${index}`}
+ data-test-subj={item['data-test-subj']}
>
{item.label}
diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.scss b/src/plugins/discover/public/application/components/sidebar/discover_field.scss
index 643f74b809c2..8e1dd41f66ab 100644
--- a/src/plugins/discover/public/application/components/sidebar/discover_field.scss
+++ b/src/plugins/discover/public/application/components/sidebar/discover_field.scss
@@ -2,13 +2,3 @@
min-width: 260px;
max-width: 300px;
}
-
-.dscSidebarField__actionButton {
- opacity: 0;
- transition: opacity $ouiAnimSpeedExtraFast;
-
- &:hover,
- &:focus {
- opacity: 1;
- }
-}
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 a924191c88f1..73dc40a262e0 100644
--- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx
+++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx
@@ -40,7 +40,7 @@ import {
} from '@elastic/eui';
import { i18n } from '@osd/i18n';
import { DiscoverFieldDetails } from './discover_field_details';
-import { FieldIcon } from '../../../../../opensearch_dashboards_react/public';
+import { FieldIcon, FieldButton } from '../../../../../opensearch_dashboards_react/public';
import { FieldDetails } from './types';
import { IndexPatternField, IndexPattern } from '../../../../../data/public';
import { shortenDottedString } from '../../helpers';
@@ -163,7 +163,6 @@ export const DiscoverField = ({
}}
data-test-subj={`fieldToggle-${field.name}`}
aria-label={addLabelAria}
- className="dscSidebarField__actionButton"
/>
);
@@ -188,7 +187,6 @@ export const DiscoverField = ({
}}
data-test-subj={`fieldToggle-${field.name}`}
aria-label={removeLabelAria}
- className="dscSidebarField__actionButton"
/>
);
@@ -221,7 +219,6 @@ export const DiscoverField = ({
onClick={() => setOpen((state) => !state)}
aria-label={infoLabelAria}
data-test-subj={`field-${field.name}-showDetails`}
- className="dscSidebarField__actionButton"
/>
}
panelClassName="dscSidebarItem__fieldPopoverPanel"
diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx
index 3a0cdd17d238..8c19d38eb20d 100644
--- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx
+++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx
@@ -8,6 +8,7 @@ import React from 'react';
import { DiscoverViewServices } from '../../../build_services';
import { showOpenSearchPanel } from './show_open_search_panel';
import { SavedSearch } from '../../../saved_searches';
+import { NEW_DISCOVER_APP } from '../../../../common';
import { Adapters } from '../../../../../inspector/public';
import { TopNavMenuData } from '../../../../../navigation/public';
import { ISearchSource, unhashUrl } from '../../../opensearch_dashboards_services';
@@ -29,6 +30,7 @@ export const getTopNavLinks = (
history,
inspector,
core,
+ uiSettings,
capabilities,
share,
toastNotifications,
@@ -192,7 +194,7 @@ export const getTopNavLinks = (
share?.toggleShareContextMenu({
anchorElement,
allowEmbed: false,
- allowShortUrl: capabilities.discover?.createShortUrl as boolean,
+ allowShortUrl: capabilities.discover.createShortUrl as boolean,
shareableUrl: unhashUrl(window.location.href),
objectId: savedSearch.id,
objectType: 'search',
@@ -221,9 +223,27 @@ export const getTopNavLinks = (
},
};
+ const legacyDiscover: TopNavMenuData = {
+ id: 'discover-new',
+ label: i18n.translate('discover.localMenu.newDiscoverTitle', {
+ defaultMessage: 'New Discover',
+ }),
+ description: i18n.translate('discover.localMenu.newDiscoverDescription', {
+ defaultMessage: 'New Discover Experience',
+ }),
+ testId: 'discoverNewButton',
+ run: async () => {
+ await uiSettings.set(NEW_DISCOVER_APP, false);
+ window.location.reload();
+ },
+ type: 'toggle' as const,
+ emphasize: true,
+ };
+
return [
+ legacyDiscover,
newSearch,
- ...(capabilities.discover?.save ? [saveSearch] : []),
+ ...(capabilities.discover.save ? [saveSearch] : []),
openSearch,
...(share ? [shareSearch] : []), // Show share option only if share plugin is available
inspectSearch,
diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss b/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss
deleted file mode 100644
index e1a21bcf201c..000000000000
--- a/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss
+++ /dev/null
@@ -1,5 +0,0 @@
-.dscCanvas {
- @include euiYScrollWithShadows;
-
- height: 100%;
-}
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 dcac343c4ce8..8d9966af0ec0 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
@@ -17,7 +17,7 @@ import {
useDispatch,
useSelector,
} from '../../utils/state_management';
-import { ResultStatus, SearchData, useSearch } from '../utils/use_search';
+import { ResultStatus, SearchData } from '../utils/use_search';
import { IndexPatternField, opensearchFilters } from '../../../../../data/public';
import { DocViewFilterFn } from '../../doc_views/doc_views_types';
import { SortOrder } from '../../../saved_searches/types';
@@ -71,7 +71,6 @@ export const DiscoverTable = ({ history }: Props) => {
);
const { rows } = fetchState || {};
- const { savedSearch } = useSearch(services);
useEffect(() => {
const subscription = data$.subscribe((next) => {
@@ -108,8 +107,6 @@ export const DiscoverTable = ({ history }: Props) => {
rows={rows}
displayTimeColumn={displayTimeColumn}
services={services}
- title={savedSearch?.id ? savedSearch.title : ''}
- description={savedSearch?.id ? savedSearch.description : ''}
/>
);
};
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 3c25e5a221dc..b5adc9596321 100644
--- a/src/plugins/discover/public/application/view_components/canvas/index.tsx
+++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx
@@ -4,7 +4,7 @@
*/
import React, { useEffect, useState, useRef } from 'react';
-import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui';
+import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiCallOut, EuiLink } from '@elastic/eui';
import { TopNav } from './top_nav';
import { ViewProps } from '../../../../../data_explorer/public';
import { DiscoverTable } from './discover_table';
@@ -19,7 +19,9 @@ import { DiscoverViewServices } from '../../../build_services';
import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public';
import { filterColumns } from '../utils/filter_columns';
import { DEFAULT_COLUMNS_SETTING } from '../../../../common';
-import './discover_canvas.scss';
+
+const KEY_SHOW_NOTICE = 'discover:deprecation-notice:show';
+
// eslint-disable-next-line import/no-default-export
export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewProps) {
const { data$, refetch$, indexPattern } = useDiscoverContext();
@@ -41,6 +43,39 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro
bucketInterval: {},
});
+ const [isCallOutVisible, setIsCallOutVisible] = useState(
+ localStorage.getItem(KEY_SHOW_NOTICE) !== 'false'
+ );
+ const closeCallOut = () => {
+ localStorage.setItem(KEY_SHOW_NOTICE, 'false');
+ setIsCallOutVisible(false);
+ };
+
+ let callOut;
+
+ if (isCallOutVisible) {
+ callOut = (
+
+
+
+
+ To provide feedback,{' '}
+
+ open an issue
+
+ .
+
+
+
+
+ );
+ }
+
const { status } = fetchState;
useEffect(() => {
@@ -69,7 +104,7 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro
const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined;
return (
-
+
}
{status === ResultStatus.READY && (
<>
+ {callOut}
diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx
index 4a8ebfb1bf29..7a777142d2f0 100644
--- a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx
+++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx
@@ -5,7 +5,7 @@
import React, { useEffect, useMemo, useState } from 'react';
import { AppMountParameters } from '../../../../../../core/public';
-import { PLUGIN_ID } from '../../../../common';
+import { NEW_DISCOVER_APP, PLUGIN_ID } from '../../../../common';
import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public';
import { DiscoverViewServices } from '../../../build_services';
import { IndexPattern } from '../../../opensearch_dashboards_services';
@@ -25,11 +25,12 @@ export const TopNav = ({ opts }: TopNavProps) => {
const [indexPatterns, setIndexPatterns] = useState(undefined);
const {
+ uiSettings,
navigation: {
ui: { TopNavMenu },
},
core: {
- application: { getUrlForApp },
+ application: { navigateToApp, getUrlForApp },
},
data,
chrome,
@@ -37,6 +38,18 @@ export const TopNav = ({ opts }: TopNavProps) => {
const topNavLinks = savedSearch ? getTopNavLinks(services, inspectorAdapters, savedSearch) : [];
+ useEffect(() => {
+ if (uiSettings.get(NEW_DISCOVER_APP) === false) {
+ const path = window.location.hash;
+ navigateToApp('discoverLegacy', {
+ replace: true,
+ path,
+ });
+ }
+
+ return () => {};
+ }, [navigateToApp, uiSettings]);
+
useEffect(() => {
let isMounted = true;
const getDefaultIndexPattern = async () => {
diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts
index 6414d0328715..b3fbefcdfad1 100644
--- a/src/plugins/discover/public/application/view_components/utils/use_search.ts
+++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts
@@ -67,7 +67,6 @@ export type RefetchSubject = Subject;
* }, [data$]);
*/
export const useSearch = (services: DiscoverServices) => {
- const initalSearchComplete = useRef(false);
const [savedSearch, setSavedSearch] = useState(undefined);
const { savedSearch: savedSearchId, sort, interval } = useSelector((state) => state.discover);
const { data, filterManager, getSavedSearchById, core, toastNotifications, store } = services;
@@ -206,8 +205,6 @@ export const useSearch = (services: DiscoverServices) => {
});
data.search.showError(error as Error);
- } finally {
- initalSearchComplete.current = true;
}
}, [
indexPattern,
@@ -243,29 +240,18 @@ export const useSearch = (services: DiscoverServices) => {
})();
});
- // kick off initial refetch on page load
- if (shouldSearchOnPageLoad() || initalSearchComplete.current === true) {
- refetch$.next();
- }
+ // kick off initial fetch
+ refetch$.next();
return () => {
subscription.unsubscribe();
};
- }, [
- data$,
- data.query.queryString,
- filterManager,
- refetch$,
- timefilter,
- fetch,
- core.fatalErrors,
- shouldSearchOnPageLoad,
- ]);
+ }, [data$, data.query.queryString, filterManager, refetch$, timefilter, fetch, core.fatalErrors]);
// Get savedSearch if it exists
useEffect(() => {
(async () => {
- const savedSearchInstance = await getSavedSearchById(savedSearchId);
+ const savedSearchInstance = await getSavedSearchById(savedSearchId || '');
setSavedSearch(savedSearchInstance);
// sync initial app filters from savedObject to filterManager
diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts
index 785e72536417..ebe4e80a70c5 100644
--- a/src/plugins/discover/public/build_services.ts
+++ b/src/plugins/discover/public/build_services.ts
@@ -78,7 +78,7 @@ export interface DiscoverServices {
urlForwarding: UrlForwardingStart;
timefilter: TimefilterContract;
toastNotifications: ToastsStart;
- getSavedSearchById: (id?: string) => Promise;
+ getSavedSearchById: (id: string) => Promise;
getSavedSearchUrlById: (id: string) => Promise;
uiSettings: IUiSettingsClient;
visualizations: VisualizationsStart;
@@ -107,7 +107,7 @@ export function buildServices(
docLinks: core.docLinks,
theme: plugins.charts.theme,
filterManager: plugins.data.query.filterManager,
- getSavedSearchById: async (id?: string) => savedObjectService.get(id),
+ getSavedSearchById: async (id: string) => savedObjectService.get(id),
getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id),
history: getHistory,
indexPatterns: plugins.data.indexPatterns,
diff --git a/src/plugins/discover/public/embeddable/search_embeddable_component.tsx b/src/plugins/discover/public/embeddable/search_embeddable_component.tsx
deleted file mode 100644
index c8ae54a16429..000000000000
--- a/src/plugins/discover/public/embeddable/search_embeddable_component.tsx
+++ /dev/null
@@ -1,65 +0,0 @@
-/*
- * Copyright OpenSearch Contributors
- * SPDX-License-Identifier: Apache-2.0
- */
-
-import React from 'react';
-import { I18nProvider } from '@osd/i18n/react';
-import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui';
-import { SearchProps } from './search_embeddable';
-import {
- DataGridTable,
- DataGridTableProps,
-} from '../application/components/data_grid/data_grid_table';
-import { VisualizationNoResults } from '../../../visualizations/public';
-
-interface SearchEmbeddableProps {
- searchProps: SearchProps;
-}
-export interface DiscoverEmbeddableProps extends DataGridTableProps {
- totalHitCount: number;
-}
-
-export const DataGridTableMemoized = React.memo((props: DataGridTableProps) => (
-
-));
-
-export function SearchEmbeddableComponent({ searchProps }: SearchEmbeddableProps) {
- const discoverEmbeddableProps = {
- columns: searchProps.columns,
- indexPattern: searchProps.indexPattern,
- onAddColumn: searchProps.onAddColumn,
- onFilter: searchProps.onFilter,
- onRemoveColumn: searchProps.onRemoveColumn,
- onSort: searchProps.onSort,
- rows: searchProps.rows,
- onSetColumns: searchProps.onSetColumns,
- sort: searchProps.sort,
- displayTimeColumn: searchProps.displayTimeColumn,
- services: searchProps.services,
- totalHitCount: searchProps.totalHitCount,
- title: searchProps.title,
- description: searchProps.description,
- } as DiscoverEmbeddableProps;
-
- return (
-
-
- {discoverEmbeddableProps.totalHitCount !== 0 ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
- );
-}
diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts
index 164aea1fb5bc..3bc009914940 100644
--- a/src/plugins/discover/public/index.ts
+++ b/src/plugins/discover/public/index.ts
@@ -37,6 +37,7 @@ export function plugin(initializerContext: PluginInitializerContext) {
}
export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches';
-
-export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './embeddable';
+// TODO: Fix embeddable after removing Angular
+// export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable';
export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator';
+export { NEW_DISCOVER_APP } from '../common';
diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts
index 4d28083b8892..f1532b6f776b 100644
--- a/src/plugins/discover/public/plugin.ts
+++ b/src/plugins/discover/public/plugin.ts
@@ -59,8 +59,8 @@ import {
DISCOVER_APP_URL_GENERATOR,
DiscoverUrlGenerator,
} from './url_generator';
-import { SearchEmbeddableFactory } from './embeddable';
-import { PLUGIN_ID } from '../common';
+// import { SearchEmbeddableFactory } from './application/embeddable';
+import { NEW_DISCOVER_APP, PLUGIN_ID } from '../common';
import { DataExplorerPluginSetup } from '../../data_explorer/public';
import { registerFeature } from './register_feature';
import {
@@ -266,45 +266,55 @@ export class DiscoverPlugin
// This is for instances where the user navigates to the app from the application nav menu
const path = window.location.hash;
- const newPath = migrateUrlState(path);
- if (newPath.startsWith('#/context') || newPath.startsWith('#/doc')) {
- const { renderDocView } = await import('./application/components/doc_views');
- const unmount = renderDocView(params.element);
- return () => {
- unmount();
- };
- } else {
- navigateToApp('data-explorer', {
+ const v2Enabled = await core.uiSettings.get(NEW_DISCOVER_APP);
+
+ if (!v2Enabled) {
+ navigateToApp('discoverLegacy', {
replace: true,
- path: `/${PLUGIN_ID}${newPath}`,
+ path,
});
+ } else {
+ const newPath = migrateUrlState(path);
+ if (newPath.startsWith('#/context') || newPath.startsWith('#/doc')) {
+ const { renderDocView } = await import('./application/components/doc_views');
+ const unmount = renderDocView(params.element);
+ return () => {
+ unmount();
+ };
+ } else {
+ navigateToApp('data-explorer', {
+ replace: true,
+ path: `/${PLUGIN_ID}${newPath}`,
+ });
+ }
}
return () => {};
},
});
- plugins.urlForwarding.forwardApp('doc', 'discover', (path) => {
- return `#${path}`;
- });
- plugins.urlForwarding.forwardApp('context', 'discover', (path) => {
- const urlParts = path.split('/');
- // take care of urls containing legacy url, those split in the following way
- // ["", "context", indexPatternId, _type, id + params]
- if (urlParts[4]) {
- // remove _type part
- const newPath = [...urlParts.slice(0, 3), ...urlParts.slice(4)].join('/');
- return `#${newPath}`;
- }
- return `#${path}`;
- });
- plugins.urlForwarding.forwardApp('discover', 'discover', (path) => {
- const [, id, tail] = /discover\/([^\?]+)(.*)/.exec(path) || [];
- if (!id) {
- return `#${path.replace('/discover', '') || '/'}`;
- }
- return `#/view/${id}${tail || ''}`;
- });
+ // TODO: These routes need to be handled for Discover 2.0 to support legacy saved URLS's
+ // plugins.urlForwarding.forwardApp('doc', 'discover', (path) => {
+ // return `#${path}`;
+ // });
+ // plugins.urlForwarding.forwardApp('context', 'discover', (path) => {
+ // const urlParts = path.split('/');
+ // // take care of urls containing legacy url, those split in the following way
+ // // ["", "context", indexPatternId, _type, id + params]
+ // if (urlParts[4]) {
+ // // remove _type part
+ // const newPath = [...urlParts.slice(0, 3), ...urlParts.slice(4)].join('/');
+ // return `#${newPath}`;
+ // }
+ // return `#${path}`;
+ // });
+ // plugins.urlForwarding.forwardApp('discover', 'discover', (path) => {
+ // const [, id, tail] = /discover\/([^\?]+)(.*)/.exec(path) || [];
+ // if (!id) {
+ // return `#${path.replace('/discover', '') || '/'}`;
+ // }
+ // return `#/view/${id}${tail || ''}`;
+ // });
if (plugins.home) {
registerFeature(plugins.home);
@@ -338,7 +348,7 @@ export class DiscoverPlugin
Context: lazy(() => import('./application/view_components/context')),
});
- this.registerEmbeddable(core, plugins);
+ // this.registerEmbeddable(core, plugins);
return {
docViews: {
@@ -384,8 +394,9 @@ export class DiscoverPlugin
}
}
+ // TODO: Use this registration when legacy discover is removed
/**
- * register embeddable
+ * register embeddable with a slimmer embeddable version of inner angular
*/
private registerEmbeddable(core: CoreSetup, plugins: DiscoverSetupPlugins) {
const getStartServices = async () => {
@@ -396,7 +407,8 @@ export class DiscoverPlugin
};
};
- const factory = new SearchEmbeddableFactory(getStartServices);
- plugins.embeddable.registerEmbeddableFactory(factory.type, factory);
+ // TODO: Refactor to remove angular
+ // const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector);
+ // plugins.embeddable.registerEmbeddableFactory(factory.type, factory);
}
}
diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts
index 70eab306e7fb..2b35384c2e5c 100644
--- a/src/plugins/discover/server/ui_settings.ts
+++ b/src/plugins/discover/server/ui_settings.ts
@@ -33,6 +33,7 @@ import { schema } from '@osd/config-schema';
import { UiSettingsParams } from 'opensearch-dashboards/server';
import {
+ NEW_DISCOVER_APP,
DEFAULT_COLUMNS_SETTING,
SAMPLE_SIZE_SETTING,
AGGS_TERMS_SIZE_SETTING,
@@ -47,6 +48,17 @@ import {
} from '../common';
export const uiSettings: Record = {
+ [NEW_DISCOVER_APP]: {
+ name: i18n.translate('discover.advancedSettings.legacyToggleTitle', {
+ defaultMessage: 'Enable new discover app',
+ }),
+ value: true,
+ description: i18n.translate('discover.advancedSettings.legacyToggleText', {
+ defaultMessage: 'Disabling the new discover app will redirect to the legacy app.',
+ }),
+ category: ['discover'],
+ schema: schema.boolean(),
+ },
[DEFAULT_COLUMNS_SETTING]: {
name: i18n.translate('discover.advancedSettings.defaultColumnsTitle', {
defaultMessage: 'Default columns',
diff --git a/src/plugins/discover_legacy/README.md b/src/plugins/discover_legacy/README.md
new file mode 100644
index 000000000000..a914d651eef3
--- /dev/null
+++ b/src/plugins/discover_legacy/README.md
@@ -0,0 +1 @@
+Contains the Discover application and the saved search embeddable.
\ No newline at end of file
diff --git a/src/plugins/discover_legacy/common/index.ts b/src/plugins/discover_legacy/common/index.ts
new file mode 100644
index 000000000000..371442385bbf
--- /dev/null
+++ b/src/plugins/discover_legacy/common/index.ts
@@ -0,0 +1,41 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const DEFAULT_COLUMNS_SETTING = 'defaultColumns';
+export const SAMPLE_SIZE_SETTING = 'discover:sampleSize';
+export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size';
+export const SORT_DEFAULT_ORDER_SETTING = 'discover:sort:defaultOrder';
+export const SEARCH_ON_PAGE_LOAD_SETTING = 'discover:searchOnPageLoad';
+export const DOC_HIDE_TIME_COLUMN_SETTING = 'doc_table:hideTimeColumn';
+export const FIELDS_LIMIT_SETTING = 'fields:popularLimit';
+export const CONTEXT_DEFAULT_SIZE_SETTING = 'context:defaultSize';
+export const CONTEXT_STEP_SETTING = 'context:step';
+export const CONTEXT_TIE_BREAKER_FIELDS_SETTING = 'context:tieBreakerFields';
+export const MODIFY_COLUMNS_ON_SWITCH = 'discover:modifyColumnsOnSwitch';
diff --git a/src/plugins/discover_legacy/opensearch_dashboards.json b/src/plugins/discover_legacy/opensearch_dashboards.json
new file mode 100644
index 000000000000..6a4259a41d75
--- /dev/null
+++ b/src/plugins/discover_legacy/opensearch_dashboards.json
@@ -0,0 +1,27 @@
+{
+ "id": "discoverLegacy",
+ "version": "opensearchDashboards",
+ "server": false,
+ "ui": true,
+ "requiredPlugins": [
+ "charts",
+ "data",
+ "embeddable",
+ "inspector",
+ "opensearchDashboardsLegacy",
+ "urlForwarding",
+ "navigation",
+ "uiActions",
+ "visualizations"
+ ],
+ "optionalPlugins": [
+ "home",
+ "share"
+ ],
+ "requiredBundles": [
+ "opensearchDashboardsUtils",
+ "savedObjects",
+ "opensearchDashboardsReact",
+ "discover"
+ ]
+}
\ No newline at end of file
diff --git a/src/plugins/discover_legacy/public/application/_discover.scss b/src/plugins/discover_legacy/public/application/_discover.scss
new file mode 100644
index 000000000000..f574357c5ff4
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/_discover.scss
@@ -0,0 +1,164 @@
+.dscAppWrapper {
+ display: flex;
+ flex-direction: column;
+ flex-grow: 1;
+ overflow: hidden;
+}
+
+.dscAppContainer {
+ > * {
+ position: relative;
+ }
+}
+
+discover-app {
+ flex-grow: 1;
+}
+
+.dscHistogram {
+ display: flex;
+ height: 200px;
+ padding: $euiSizeS;
+}
+
+// SASSTODO: replace the z-index value with a variable
+.dscWrapper {
+ padding-left: $euiSizeXL;
+ padding-right: $euiSizeS;
+ z-index: 1;
+
+ @include euiBreakpoint("xs", "s", "m") {
+ padding-left: $euiSizeS;
+ }
+}
+
+@include euiPanel(".dscWrapper__content");
+
+.dscWrapper__content {
+ padding-top: $euiSizeXS;
+ background-color: $euiColorEmptyShade;
+
+ .osd-table {
+ margin-bottom: 0;
+ }
+}
+
+.dscTimechart {
+ display: block;
+ position: relative;
+
+ // SASSTODO: the visualizing component should have an option or a modifier
+ .series > rect {
+ fill-opacity: 0.5;
+ stroke-width: 1;
+ }
+}
+
+.dscResultCount {
+ padding-top: $euiSizeXS;
+}
+
+.dscTimechart__header {
+ display: flex;
+ justify-content: center;
+ min-height: $euiSizeXXL;
+ padding: $euiSizeXS 0;
+}
+
+.dscOverlay {
+ position: absolute;
+ top: 0;
+ right: 0;
+ bottom: 0;
+ left: 0;
+ z-index: 20;
+ padding-top: $euiSizeM;
+ opacity: 0.75;
+ text-align: center;
+ background-color: transparent;
+}
+
+.dscTable {
+ overflow: auto;
+
+ // SASSTODO: add a monospace modifier to the doc-table component
+ .osdDocTable__row {
+ font-family: $euiCodeFontFamily;
+ font-size: $euiFontSizeXS;
+ }
+}
+
+// SASSTODO: replace the padding value with a variable
+.dscTable__footer {
+ background-color: $euiColorLightShade;
+ padding: 5px 10px;
+ text-align: center;
+}
+
+.dscResults {
+ h3 {
+ margin: -20px 0 10px;
+ text-align: center;
+ }
+}
+
+.dscResults__interval {
+ display: inline-block;
+ width: auto;
+}
+
+.dscSkipButton {
+ position: absolute;
+ right: $euiSizeM;
+ top: $euiSizeXS;
+}
+
+.dscTableFixedScroll {
+ overflow-x: auto;
+ padding-bottom: 0;
+
+ + .dscTableFixedScroll__scroller {
+ position: fixed;
+ bottom: 0;
+ overflow-x: auto;
+ overflow-y: hidden;
+ }
+}
+
+.dscCollapsibleSidebar {
+ position: relative;
+ z-index: $euiZLevel1;
+
+ .dscCollapsibleSidebar__collapseButton {
+ position: absolute;
+ top: 0;
+ right: -$euiSizeXL + 4;
+ cursor: pointer;
+ z-index: -1;
+ min-height: $euiSizeM;
+ min-width: $euiSizeM;
+ padding: $euiSizeXS * 0.5;
+ }
+
+ &.closed {
+ width: 0 !important;
+ border-right-width: 0;
+ border-left-width: 0;
+
+ .dscCollapsibleSidebar__collapseButton {
+ right: -$euiSizeL + 4;
+ }
+ }
+}
+
+@include euiBreakpoint("xs", "s", "m") {
+ .dscCollapsibleSidebar {
+ &.closed {
+ display: none;
+ }
+
+ .dscCollapsibleSidebar__collapseButton {
+ display: none;
+ }
+ }
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/_index.scss b/src/plugins/discover_legacy/public/application/angular/_index.scss
new file mode 100644
index 000000000000..acc755e4a170
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/_index.scss
@@ -0,0 +1,2 @@
+@import "directives/index";
+@import "context/index";
diff --git a/src/plugins/discover_legacy/public/application/angular/context.html b/src/plugins/discover_legacy/public/application/angular/context.html
new file mode 100644
index 000000000000..2c8e9a2a5d6f
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context.html
@@ -0,0 +1,9 @@
+
diff --git a/src/plugins/discover_legacy/public/application/angular/context.js b/src/plugins/discover_legacy/public/application/angular/context.js
new file mode 100644
index 000000000000..4757102315c0
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context.js
@@ -0,0 +1,122 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import _ from 'lodash';
+import { i18n } from '@osd/i18n';
+import { CONTEXT_DEFAULT_SIZE_SETTING } from '../../../common';
+import { getAngularModule, getServices } from '../../opensearch_dashboards_services';
+import './context_app';
+import { getState } from './context_state';
+import contextAppRouteTemplate from './context.html';
+import { getRootBreadcrumbs } from '../helpers/breadcrumbs';
+
+const k7Breadcrumbs = ($route) => {
+ const { indexPattern } = $route.current.locals;
+ const { id } = $route.current.params;
+
+ return [
+ ...getRootBreadcrumbs(),
+ {
+ text: i18n.translate('discover.context.breadcrumb', {
+ defaultMessage: 'Context of {indexPatternTitle}#{docId}',
+ values: {
+ indexPatternTitle: indexPattern.title,
+ docId: id,
+ },
+ }),
+ },
+ ];
+};
+
+getAngularModule().config(($routeProvider) => {
+ $routeProvider.when('/context/:indexPatternId/:id*', {
+ controller: ContextAppRouteController,
+ k7Breadcrumbs,
+ controllerAs: 'contextAppRoute',
+ resolve: {
+ indexPattern: ($route, Promise) => {
+ const indexPattern = getServices().indexPatterns.get($route.current.params.indexPatternId);
+ return Promise.props({ ip: indexPattern });
+ },
+ },
+ template: contextAppRouteTemplate,
+ });
+});
+
+function ContextAppRouteController($routeParams, $scope, $route) {
+ const filterManager = getServices().filterManager;
+ const indexPattern = $route.current.locals.indexPattern.ip;
+ const {
+ startSync: startStateSync,
+ stopSync: stopStateSync,
+ appState,
+ getFilters,
+ setFilters,
+ setAppState,
+ flushToUrl,
+ } = getState({
+ defaultStepSize: getServices().uiSettings.get(CONTEXT_DEFAULT_SIZE_SETTING),
+ timeFieldName: indexPattern.timeFieldName,
+ storeInSessionStorage: getServices().uiSettings.get('state:storeInSessionStorage'),
+ history: getServices().history(),
+ toasts: getServices().core.notifications.toasts,
+ });
+ this.state = { ...appState.getState() };
+ this.anchorId = decodeURIComponent($routeParams.id);
+ this.indexPattern = indexPattern;
+ filterManager.setFilters(_.cloneDeep(getFilters()));
+ startStateSync();
+
+ // take care of parameter changes in UI
+ $scope.$watchGroup(
+ [
+ 'contextAppRoute.state.columns',
+ 'contextAppRoute.state.predecessorCount',
+ 'contextAppRoute.state.successorCount',
+ ],
+ (newValues) => {
+ const [columns, predecessorCount, successorCount] = newValues;
+ if (Array.isArray(columns) && predecessorCount >= 0 && successorCount >= 0) {
+ setAppState({ columns, predecessorCount, successorCount });
+ flushToUrl(true);
+ }
+ }
+ );
+ // take care of parameter filter changes
+ const filterObservable = filterManager.getUpdates$().subscribe(() => {
+ setFilters(filterManager);
+ $route.reload();
+ });
+
+ $scope.$on('$destroy', () => {
+ stopStateSync();
+ filterObservable.unsubscribe();
+ });
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/NOTES.md b/src/plugins/discover_legacy/public/application/angular/context/NOTES.md
new file mode 100644
index 000000000000..046e15fab327
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/NOTES.md
@@ -0,0 +1,95 @@
+# Discover Context App Implementation Notes
+
+The implementation of this app is intended to exhibit certain desirable
+properties by adhering to a set of *principles*. This document aims to explain
+those and the *concepts* employed to achieve that.
+
+
+## Principles
+
+**Single Source of Truth**: A good user experience depends on the UI displaying
+consistent information across the whole page. To achieve this, there should
+always be a single source of truth for the application's state. In this
+application this is the `ContextAppController::state` object.
+
+**Unidirectional Data Flow**: While a single state promotes rendering
+consistency, it does little to make the state changes easier to reason about.
+To avoid having state mutations scattered all over the code, this app
+implements a unidirectional data flow architecture. That means that the state
+is treated as immutable throughout the application except for actions, which
+may modify it to cause angular to re-render and watches to trigger.
+
+**Unit-Testability**: Creating unit tests for large parts of the UI code is
+made easy by expressing the as much of the logic as possible as
+side-effect-free functions. The only place where side-effects are allowed are
+actions. Due to the nature of AngularJS a certain amount of impure code must be
+employed in some cases, e.g. when dealing with the isolate scope bindings in
+`ContextAppController`.
+
+**Loose Coupling**: An attempt was made to couple the parts that make up this
+app as loosely as possible. This means using pure functions whenever possible
+and isolating the angular directives diligently. To that end, the app has been
+implemented as the independent `ContextApp` directive in [app.js](app.js). It
+does not access the OpenSearch Dashboards `AppState` directly but communicates only via its
+directive properties. The binding of these attributes to the state and thereby
+to the route is performed by the `CreateAppRouteController`in
+[index.js](index.js). Similarly, the `SizePicker` directive only communicates
+with its parent via the passed properties.
+
+
+## Concepts
+
+To adhere to the principles mentioned above, this app borrows some concepts
+from the redux architecture that forms a ciruclar unidirectional data flow:
+
+```
+
+ |* create initial state
+ v
+ +->+
+ | v
+ | |* state
+ | v
+ | |* angular templates render state
+ | v
+ | |* angular calls actions in response to user action/system events
+ | v
+ | |* actions modify state
+ | v
+ +--+
+
+```
+
+**State**: The state is the single source of truth at
+`ContextAppController::state` and may only be modified by actions.
+
+**Action**: Actions are functions that are called in response to user or system
+actions and may modified the state the are bound to via their closure.
+
+
+## Directory Structure
+
+**index.js**: Defines the route and renders the `` directive,
+binding it to the `AppState`.
+
+**app.js**: Defines the `` directive, that is at the root of the
+application. Creates the store, reducer and bound actions/selectors.
+
+**query**: Exports the actions, reducers and selectors related to the
+query status and results.
+
+**query_parameters**: Exports the actions, reducers and selectors related to
+the parameters used to construct the query.
+
+**components/action_bar**: Defines the ``
+directive including its respective styles.
+
+
+**api/anchor.js**: Exports `fetchAnchor()` that creates and executes the
+query for the anchor document.
+
+**api/context.js**: Exports `fetchPredecessors()`, `fetchSuccessors()`, `fetchSurroundingDocs()` that
+create and execute the queries for the preceeding and succeeding documents.
+
+**api/utils**: Exports various functions used to create and transform
+queries.
diff --git a/src/plugins/discover_legacy/public/application/angular/context/_index.scss b/src/plugins/discover_legacy/public/application/angular/context/_index.scss
new file mode 100644
index 000000000000..4ac09e25eb9c
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/_index.scss
@@ -0,0 +1,8 @@
+// Prefix all styles with "cxt" to avoid conflicts.
+// Examples
+// cxtChart
+// cxtChart__legend
+// cxtChart__legend--small
+// cxtChart__legend-isLoading
+
+@import "components/action_bar/index";
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js b/src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js
new file mode 100644
index 000000000000..99b531edfc0b
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js
@@ -0,0 +1,112 @@
+/*
+ * 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 sinon from 'sinon';
+import moment from 'moment';
+
+export function createIndexPatternsStub() {
+ return {
+ get: sinon.spy((indexPatternId) =>
+ Promise.resolve({
+ id: indexPatternId,
+ isTimeNanosBased: () => false,
+ popularizeField: () => {},
+ })
+ ),
+ };
+}
+
+/**
+ * A stubbed search source with a `fetch` method that returns all of `_stubHits`.
+ */
+export function createSearchSourceStub(hits, timeField) {
+ const searchSourceStub = {
+ _stubHits: hits,
+ _stubTimeField: timeField,
+ _createStubHit: (timestamp, tiebreaker = 0) => ({
+ [searchSourceStub._stubTimeField]: timestamp,
+ sort: [timestamp, tiebreaker],
+ }),
+ };
+
+ searchSourceStub.setParent = sinon.spy(() => searchSourceStub);
+ searchSourceStub.setField = sinon.spy(() => searchSourceStub);
+
+ searchSourceStub.getField = sinon.spy((key) => {
+ const previousSetCall = searchSourceStub.setField.withArgs(key).lastCall;
+ return previousSetCall ? previousSetCall.args[1] : null;
+ });
+
+ searchSourceStub.fetch = sinon.spy(() =>
+ Promise.resolve({
+ hits: {
+ hits: searchSourceStub._stubHits,
+ total: searchSourceStub._stubHits.length,
+ },
+ })
+ );
+
+ return searchSourceStub;
+}
+
+/**
+ * A stubbed search source with a `fetch` method that returns a filtered set of `_stubHits`.
+ */
+export function createContextSearchSourceStub(hits, timeField = '@timestamp') {
+ const searchSourceStub = createSearchSourceStub(hits, timeField);
+
+ searchSourceStub.fetch = sinon.spy(() => {
+ const timeField = searchSourceStub._stubTimeField;
+ const lastQuery = searchSourceStub.setField.withArgs('query').lastCall.args[1];
+ const timeRange = lastQuery.query.bool.must.constant_score.filter.range[timeField];
+ const lastSort = searchSourceStub.setField.withArgs('sort').lastCall.args[1];
+ const sortDirection = lastSort[0][timeField];
+ const sortFunction =
+ sortDirection === 'asc'
+ ? (first, second) => first[timeField] - second[timeField]
+ : (first, second) => second[timeField] - first[timeField];
+ const filteredHits = searchSourceStub._stubHits
+ .filter(
+ (hit) =>
+ moment(hit[timeField]).isSameOrAfter(timeRange.gte) &&
+ moment(hit[timeField]).isSameOrBefore(timeRange.lte)
+ )
+ .sort(sortFunction);
+
+ return Promise.resolve({
+ hits: {
+ hits: filteredHits,
+ total: filteredHits.length,
+ },
+ });
+ });
+
+ return searchSourceStub;
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/anchor.js b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.js
new file mode 100644
index 000000000000..599379e128b0
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.js
@@ -0,0 +1,71 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import _ from 'lodash';
+import { i18n } from '@osd/i18n';
+
+export function fetchAnchorProvider(indexPatterns, searchSource) {
+ return async function fetchAnchor(indexPatternId, anchorId, sort) {
+ const indexPattern = await indexPatterns.get(indexPatternId);
+ searchSource
+ .setParent(undefined)
+ .setField('index', indexPattern)
+ .setField('version', true)
+ .setField('size', 1)
+ .setField('query', {
+ query: {
+ constant_score: {
+ filter: {
+ ids: {
+ values: [anchorId],
+ },
+ },
+ },
+ },
+ language: 'lucene',
+ })
+ .setField('sort', sort);
+
+ const response = await searchSource.fetch();
+
+ if (_.get(response, ['hits', 'total'], 0) < 1) {
+ throw new Error(
+ i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', {
+ defaultMessage: 'Failed to load anchor document.',
+ })
+ );
+ }
+
+ return {
+ ..._.get(response, ['hits', 'hits', 0]),
+ $$_isAnchor: true,
+ };
+ };
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js
new file mode 100644
index 000000000000..676aabb5c3b8
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js
@@ -0,0 +1,158 @@
+/*
+ * 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 { createIndexPatternsStub, createSearchSourceStub } from './_stubs';
+
+import { fetchAnchorProvider } from './anchor';
+
+describe('context app', function () {
+ describe('function fetchAnchor', function () {
+ let fetchAnchor;
+ let searchSourceStub;
+
+ beforeEach(() => {
+ searchSourceStub = createSearchSourceStub([{ _id: 'hit1' }]);
+ fetchAnchor = fetchAnchorProvider(createIndexPatternsStub(), searchSourceStub);
+ });
+
+ it('should use the `fetch` method of the SearchSource', function () {
+ return fetchAnchor('INDEX_PATTERN_ID', 'id', [
+ { '@timestamp': 'desc' },
+ { _doc: 'desc' },
+ ]).then(() => {
+ expect(searchSourceStub.fetch.calledOnce).toBe(true);
+ });
+ });
+
+ it('should configure the SearchSource to not inherit from the implicit root', function () {
+ return fetchAnchor('INDEX_PATTERN_ID', 'id', [
+ { '@timestamp': 'desc' },
+ { _doc: 'desc' },
+ ]).then(() => {
+ const setParentSpy = searchSourceStub.setParent;
+ expect(setParentSpy.calledOnce).toBe(true);
+ expect(setParentSpy.firstCall.args[0]).toBe(undefined);
+ });
+ });
+
+ it('should set the SearchSource index pattern', function () {
+ return fetchAnchor('INDEX_PATTERN_ID', 'id', [
+ { '@timestamp': 'desc' },
+ { _doc: 'desc' },
+ ]).then(() => {
+ const setFieldSpy = searchSourceStub.setField;
+ expect(setFieldSpy.firstCall.args[1].id).toEqual('INDEX_PATTERN_ID');
+ });
+ });
+
+ it('should set the SearchSource version flag to true', function () {
+ return fetchAnchor('INDEX_PATTERN_ID', 'id', [
+ { '@timestamp': 'desc' },
+ { _doc: 'desc' },
+ ]).then(() => {
+ const setVersionSpy = searchSourceStub.setField.withArgs('version');
+ expect(setVersionSpy.calledOnce).toBe(true);
+ expect(setVersionSpy.firstCall.args[1]).toEqual(true);
+ });
+ });
+
+ it('should set the SearchSource size to 1', function () {
+ return fetchAnchor('INDEX_PATTERN_ID', 'id', [
+ { '@timestamp': 'desc' },
+ { _doc: 'desc' },
+ ]).then(() => {
+ const setSizeSpy = searchSourceStub.setField.withArgs('size');
+ expect(setSizeSpy.calledOnce).toBe(true);
+ expect(setSizeSpy.firstCall.args[1]).toEqual(1);
+ });
+ });
+
+ it('should set the SearchSource query to an ids query', function () {
+ return fetchAnchor('INDEX_PATTERN_ID', 'id', [
+ { '@timestamp': 'desc' },
+ { _doc: 'desc' },
+ ]).then(() => {
+ const setQuerySpy = searchSourceStub.setField.withArgs('query');
+ expect(setQuerySpy.calledOnce).toBe(true);
+ expect(setQuerySpy.firstCall.args[1]).toEqual({
+ query: {
+ constant_score: {
+ filter: {
+ ids: {
+ values: ['id'],
+ },
+ },
+ },
+ },
+ language: 'lucene',
+ });
+ });
+ });
+
+ it('should set the SearchSource sort order', function () {
+ return fetchAnchor('INDEX_PATTERN_ID', 'id', [
+ { '@timestamp': 'desc' },
+ { _doc: 'desc' },
+ ]).then(() => {
+ const setSortSpy = searchSourceStub.setField.withArgs('sort');
+ expect(setSortSpy.calledOnce).toBe(true);
+ expect(setSortSpy.firstCall.args[1]).toEqual([{ '@timestamp': 'desc' }, { _doc: 'desc' }]);
+ });
+ });
+
+ it('should reject with an error when no hits were found', function () {
+ searchSourceStub._stubHits = [];
+
+ return fetchAnchor('INDEX_PATTERN_ID', 'id', [
+ { '@timestamp': 'desc' },
+ { _doc: 'desc' },
+ ]).then(
+ () => {
+ expect().fail('expected the promise to be rejected');
+ },
+ (error) => {
+ expect(error).toBeInstanceOf(Error);
+ }
+ );
+ });
+
+ it('should return the first hit after adding an anchor marker', function () {
+ searchSourceStub._stubHits = [{ property1: 'value1' }, { property2: 'value2' }];
+
+ return fetchAnchor('INDEX_PATTERN_ID', 'id', [
+ { '@timestamp': 'desc' },
+ { _doc: 'desc' },
+ ]).then((anchorDocument) => {
+ expect(anchorDocument).toHaveProperty('property1', 'value1');
+ expect(anchorDocument).toHaveProperty('$$_isAnchor', true);
+ });
+ });
+ });
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js
new file mode 100644
index 000000000000..52ddc2978ad8
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js
@@ -0,0 +1,241 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import moment from 'moment';
+import { get, last } from 'lodash';
+import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs';
+import { fetchContextProvider } from './context';
+import { setServices } from '../../../../opensearch_dashboards_services';
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+const ANCHOR_TIMESTAMP = new Date(MS_PER_DAY).toJSON();
+const ANCHOR_TIMESTAMP_3 = new Date(MS_PER_DAY * 3).toJSON();
+const ANCHOR_TIMESTAMP_1000 = new Date(MS_PER_DAY * 1000).toJSON();
+const ANCHOR_TIMESTAMP_3000 = new Date(MS_PER_DAY * 3000).toJSON();
+
+describe('context app', function () {
+ describe('function fetchPredecessors', function () {
+ let fetchPredecessors;
+ let mockSearchSource;
+
+ beforeEach(() => {
+ mockSearchSource = createContextSearchSourceStub([], '@timestamp', MS_PER_DAY * 8);
+
+ setServices({
+ data: {
+ search: {
+ searchSource: {
+ create: jest.fn().mockImplementation(() => mockSearchSource),
+ },
+ },
+ },
+ });
+
+ fetchPredecessors = (
+ indexPatternId,
+ timeField,
+ sortDir,
+ timeValIso,
+ timeValNr,
+ tieBreakerField,
+ tieBreakerValue,
+ size
+ ) => {
+ const anchor = {
+ _source: {
+ [timeField]: timeValIso,
+ },
+ sort: [timeValNr, tieBreakerValue],
+ };
+
+ return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs(
+ 'predecessors',
+ indexPatternId,
+ anchor,
+ timeField,
+ tieBreakerField,
+ sortDir,
+ size,
+ []
+ );
+ };
+ });
+
+ it('should perform exactly one query when enough hits are returned', function () {
+ mockSearchSource._stubHits = [
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000 + 2),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000 + 1),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000),
+ mockSearchSource._createStubHit(MS_PER_DAY * 2000),
+ mockSearchSource._createStubHit(MS_PER_DAY * 1000),
+ ];
+
+ return fetchPredecessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_3000,
+ MS_PER_DAY * 3000,
+ '_doc',
+ 0,
+ 3,
+ []
+ ).then((hits) => {
+ expect(mockSearchSource.fetch.calledOnce).toBe(true);
+ expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3));
+ });
+ });
+
+ it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () {
+ mockSearchSource._stubHits = [
+ mockSearchSource._createStubHit(MS_PER_DAY * 3010),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3002),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000),
+ mockSearchSource._createStubHit(MS_PER_DAY * 2998),
+ mockSearchSource._createStubHit(MS_PER_DAY * 2990),
+ ];
+
+ return fetchPredecessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_3000,
+ MS_PER_DAY * 3000,
+ '_doc',
+ 0,
+ 6,
+ []
+ ).then((hits) => {
+ const intervals = mockSearchSource.setField.args
+ .filter(([property]) => property === 'query')
+ .map(([, { query }]) =>
+ get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp'])
+ );
+
+ expect(
+ intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true))
+ ).toBe(true);
+ // should have started at the given time
+ expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 3000).toISOString());
+ // should have ended with a half-open interval
+ expect(Object.keys(last(intervals))).toEqual(['format', 'gte']);
+ expect(intervals.length).toBeGreaterThan(1);
+
+ expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3));
+ });
+ });
+
+ it('should perform multiple queries until the expected hit count is returned', function () {
+ mockSearchSource._stubHits = [
+ mockSearchSource._createStubHit(MS_PER_DAY * 1700),
+ mockSearchSource._createStubHit(MS_PER_DAY * 1200),
+ mockSearchSource._createStubHit(MS_PER_DAY * 1100),
+ mockSearchSource._createStubHit(MS_PER_DAY * 1000),
+ ];
+
+ return fetchPredecessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_1000,
+ MS_PER_DAY * 1000,
+ '_doc',
+ 0,
+ 3,
+ []
+ ).then((hits) => {
+ const intervals = mockSearchSource.setField.args
+ .filter(([property]) => property === 'query')
+ .map(([, { query }]) =>
+ get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp'])
+ );
+
+ // should have started at the given time
+ expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 1000).toISOString());
+ // should have stopped before reaching MS_PER_DAY * 1700
+ expect(moment(last(intervals).lte).valueOf()).toBeLessThan(MS_PER_DAY * 1700);
+ expect(intervals.length).toBeGreaterThan(1);
+ expect(hits).toEqual(mockSearchSource._stubHits.slice(-3));
+ });
+ });
+
+ it('should return an empty array when no hits were found', function () {
+ return fetchPredecessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_3,
+ MS_PER_DAY * 3,
+ '_doc',
+ 0,
+ 3,
+ []
+ ).then((hits) => {
+ expect(hits).toEqual([]);
+ });
+ });
+
+ it('should configure the SearchSource to not inherit from the implicit root', function () {
+ return fetchPredecessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_3,
+ MS_PER_DAY * 3,
+ '_doc',
+ 0,
+ 3,
+ []
+ ).then(() => {
+ const setParentSpy = mockSearchSource.setParent;
+ expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true);
+ expect(setParentSpy.called).toBe(true);
+ });
+ });
+
+ it('should set the tiebreaker sort order to the opposite as the time field', function () {
+ return fetchPredecessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP,
+ MS_PER_DAY,
+ '_doc',
+ 0,
+ 3,
+ []
+ ).then(() => {
+ expect(
+ mockSearchSource.setField.calledWith('sort', [{ '@timestamp': 'asc' }, { _doc: 'asc' }])
+ ).toBe(true);
+ });
+ });
+ });
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js
new file mode 100644
index 000000000000..7086495e29e0
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js
@@ -0,0 +1,245 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import moment from 'moment';
+import { get, last } from 'lodash';
+
+import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs';
+import { setServices } from '../../../../opensearch_dashboards_services';
+
+import { fetchContextProvider } from './context';
+
+const MS_PER_DAY = 24 * 60 * 60 * 1000;
+const ANCHOR_TIMESTAMP = new Date(MS_PER_DAY).toJSON();
+const ANCHOR_TIMESTAMP_3 = new Date(MS_PER_DAY * 3).toJSON();
+const ANCHOR_TIMESTAMP_3000 = new Date(MS_PER_DAY * 3000).toJSON();
+
+describe('context app', function () {
+ describe('function fetchSuccessors', function () {
+ let fetchSuccessors;
+ let mockSearchSource;
+
+ beforeEach(() => {
+ mockSearchSource = createContextSearchSourceStub([], '@timestamp');
+
+ setServices({
+ data: {
+ search: {
+ searchSource: {
+ create: jest.fn().mockImplementation(() => mockSearchSource),
+ },
+ },
+ },
+ });
+
+ fetchSuccessors = (
+ indexPatternId,
+ timeField,
+ sortDir,
+ timeValIso,
+ timeValNr,
+ tieBreakerField,
+ tieBreakerValue,
+ size
+ ) => {
+ const anchor = {
+ _source: {
+ [timeField]: timeValIso,
+ },
+ sort: [timeValNr, tieBreakerValue],
+ };
+
+ return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs(
+ 'successors',
+ indexPatternId,
+ anchor,
+ timeField,
+ tieBreakerField,
+ sortDir,
+ size,
+ []
+ );
+ };
+ });
+
+ it('should perform exactly one query when enough hits are returned', function () {
+ mockSearchSource._stubHits = [
+ mockSearchSource._createStubHit(MS_PER_DAY * 5000),
+ mockSearchSource._createStubHit(MS_PER_DAY * 4000),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 1),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2),
+ ];
+
+ return fetchSuccessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_3000,
+ MS_PER_DAY * 3000,
+ '_doc',
+ 0,
+ 3,
+ []
+ ).then((hits) => {
+ expect(mockSearchSource.fetch.calledOnce).toBe(true);
+ expect(hits).toEqual(mockSearchSource._stubHits.slice(-3));
+ });
+ });
+
+ it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () {
+ mockSearchSource._stubHits = [
+ mockSearchSource._createStubHit(MS_PER_DAY * 3010),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3002),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000),
+ mockSearchSource._createStubHit(MS_PER_DAY * 2998),
+ mockSearchSource._createStubHit(MS_PER_DAY * 2990),
+ ];
+
+ return fetchSuccessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_3000,
+ MS_PER_DAY * 3000,
+ '_doc',
+ 0,
+ 6,
+ []
+ ).then((hits) => {
+ const intervals = mockSearchSource.setField.args
+ .filter(([property]) => property === 'query')
+ .map(([, { query }]) =>
+ get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp'])
+ );
+
+ expect(
+ intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true))
+ ).toBe(true);
+ // should have started at the given time
+ expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString());
+ // should have ended with a half-open interval
+ expect(Object.keys(last(intervals))).toEqual(['format', 'lte']);
+ expect(intervals.length).toBeGreaterThan(1);
+
+ expect(hits).toEqual(mockSearchSource._stubHits.slice(-3));
+ });
+ });
+
+ it('should perform multiple queries until the expected hit count is returned', function () {
+ mockSearchSource._stubHits = [
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 1),
+ mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2),
+ mockSearchSource._createStubHit(MS_PER_DAY * 2800),
+ mockSearchSource._createStubHit(MS_PER_DAY * 2200),
+ mockSearchSource._createStubHit(MS_PER_DAY * 1000),
+ ];
+
+ return fetchSuccessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_3000,
+ MS_PER_DAY * 3000,
+ '_doc',
+ 0,
+ 4,
+ []
+ ).then((hits) => {
+ const intervals = mockSearchSource.setField.args
+ .filter(([property]) => property === 'query')
+ .map(([, { query }]) =>
+ get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp'])
+ );
+
+ // should have started at the given time
+ expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString());
+ // should have stopped before reaching MS_PER_DAY * 2200
+ expect(moment(last(intervals).gte).valueOf()).toBeGreaterThan(MS_PER_DAY * 2200);
+ expect(intervals.length).toBeGreaterThan(1);
+
+ expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 4));
+ });
+ });
+
+ it('should return an empty array when no hits were found', function () {
+ return fetchSuccessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_3,
+ MS_PER_DAY * 3,
+ '_doc',
+ 0,
+ 3,
+ []
+ ).then((hits) => {
+ expect(hits).toEqual([]);
+ });
+ });
+
+ it('should configure the SearchSource to not inherit from the implicit root', function () {
+ return fetchSuccessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP_3,
+ MS_PER_DAY * 3,
+ '_doc',
+ 0,
+ 3,
+ []
+ ).then(() => {
+ const setParentSpy = mockSearchSource.setParent;
+ expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true);
+ expect(setParentSpy.called).toBe(true);
+ });
+ });
+
+ it('should set the tiebreaker sort order to the same as the time field', function () {
+ return fetchSuccessors(
+ 'INDEX_PATTERN_ID',
+ '@timestamp',
+ 'desc',
+ ANCHOR_TIMESTAMP,
+ MS_PER_DAY,
+ '_doc',
+ 0,
+ 3,
+ []
+ ).then(() => {
+ expect(
+ mockSearchSource.setField.calledWith('sort', [{ '@timestamp': 'desc' }, { _doc: 'desc' }])
+ ).toBe(true);
+ });
+ });
+ });
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/context.ts b/src/plugins/discover_legacy/public/application/angular/context/api/context.ts
new file mode 100644
index 000000000000..046438f08339
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/context.ts
@@ -0,0 +1,137 @@
+/*
+ * 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 { Filter, IndexPatternsContract, IndexPattern } from 'src/plugins/data/public';
+import { reverseSortDir, SortDirection } from './utils/sorting';
+import { extractNanos, convertIsoToMillis } from './utils/date_conversion';
+import { fetchHitsInInterval } from './utils/fetch_hits_in_interval';
+import { generateIntervals } from './utils/generate_intervals';
+import { getOpenSearchQuerySearchAfter } from './utils/get_opensearch_query_search_after';
+import { getOpenSearchQuerySort } from './utils/get_opensearch_query_sort';
+import { getServices } from '../../../../opensearch_dashboards_services';
+
+export type SurrDocType = 'successors' | 'predecessors';
+export interface OpenSearchHitRecord {
+ fields: Record;
+ sort: number[];
+ _source: Record;
+ _id: string;
+}
+export type OpenSearchHitRecordList = OpenSearchHitRecord[];
+
+const DAY_MILLIS = 24 * 60 * 60 * 1000;
+
+// look from 1 day up to 10000 days into the past and future
+const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000].map((days) => days * DAY_MILLIS);
+
+function fetchContextProvider(indexPatterns: IndexPatternsContract) {
+ return {
+ fetchSurroundingDocs,
+ };
+
+ /**
+ * Fetch successor or predecessor documents of a given anchor document
+ *
+ * @param {SurrDocType} type - `successors` or `predecessors`
+ * @param {string} indexPatternId
+ * @param {OpenSearchHitRecord} anchor - anchor record
+ * @param {string} timeField - name of the timefield, that's sorted on
+ * @param {string} tieBreakerField - name of the tie breaker, the 2nd sort field
+ * @param {SortDirection} sortDir - direction of sorting
+ * @param {number} size - number of records to retrieve
+ * @param {Filter[]} filters - to apply in the query
+ * @returns {Promise}
+ */
+ async function fetchSurroundingDocs(
+ type: SurrDocType,
+ indexPatternId: string,
+ anchor: OpenSearchHitRecord,
+ timeField: string,
+ tieBreakerField: string,
+ sortDir: SortDirection,
+ size: number,
+ filters: Filter[]
+ ) {
+ if (typeof anchor !== 'object' || anchor === null || !size) {
+ return [];
+ }
+ const indexPattern = await indexPatterns.get(indexPatternId);
+ const searchSource = await createSearchSource(indexPattern, filters);
+ const sortDirToApply = type === 'successors' ? sortDir : reverseSortDir(sortDir);
+
+ const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor._source[timeField]) : '';
+ const timeValueMillis =
+ nanos !== '' ? convertIsoToMillis(anchor._source[timeField]) : anchor.sort[0];
+
+ const intervals = generateIntervals(LOOKUP_OFFSETS, timeValueMillis, type, sortDir);
+ let documents: OpenSearchHitRecordList = [];
+
+ for (const interval of intervals) {
+ const remainingSize = size - documents.length;
+
+ if (remainingSize <= 0) {
+ break;
+ }
+
+ const searchAfter = getOpenSearchQuerySearchAfter(type, documents, timeField, anchor, nanos);
+
+ const sort = getOpenSearchQuerySort(timeField, tieBreakerField, sortDirToApply);
+
+ const hits = await fetchHitsInInterval(
+ searchSource,
+ timeField,
+ sort,
+ sortDirToApply,
+ interval,
+ searchAfter,
+ remainingSize,
+ nanos,
+ anchor._id
+ );
+
+ documents =
+ type === 'successors' ? [...documents, ...hits] : [...hits.slice().reverse(), ...documents];
+ }
+
+ return documents;
+ }
+
+ async function createSearchSource(indexPattern: IndexPattern, filters: Filter[]) {
+ const { data } = getServices();
+
+ const searchSource = await data.search.searchSource.create();
+ return searchSource
+ .setParent(undefined)
+ .setField('index', indexPattern)
+ .setField('filter', filters);
+ }
+}
+
+export { fetchContextProvider };
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.test.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.test.ts
new file mode 100644
index 000000000000..fe1a18bf938f
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.test.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.
+ */
+
+import { extractNanos } from './date_conversion';
+
+describe('function extractNanos', function () {
+ test('extract nanos of 2014-01-01', function () {
+ expect(extractNanos('2014-01-01')).toBe('000000000');
+ });
+ test('extract nanos of 2014-01-01T12:12:12.234Z', function () {
+ expect(extractNanos('2014-01-01T12:12:12.234Z')).toBe('234000000');
+ });
+ test('extract nanos of 2014-01-01T12:12:12.234123321Z', function () {
+ expect(extractNanos('2014-01-01T12:12:12.234123321Z')).toBe('234123321');
+ });
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.ts
new file mode 100644
index 000000000000..8f4bfb30375d
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.ts
@@ -0,0 +1,76 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import moment from 'moment';
+/**
+ * extract nanoseconds if available in ISO timestamp
+ * returns the nanos as string like this:
+ * 9ns -> 000000009
+ * 10000ns -> 0000010000
+ * returns 000000000 for invalid timestamps or timestamps with just date
+ **/
+export function extractNanos(timeFieldValue: string = ''): string {
+ const fieldParts = timeFieldValue.split('.');
+ const fractionSeconds = fieldParts.length === 2 ? fieldParts[1].replace('Z', '') : '';
+ return fractionSeconds.length !== 9 ? fractionSeconds.padEnd(9, '0') : fractionSeconds;
+}
+
+/**
+ * convert an iso formatted string to number of milliseconds since
+ * 1970-01-01T00:00:00.000Z
+ * @param {string} isoValue
+ * @returns {number}
+ */
+export function convertIsoToMillis(isoValue: string): number {
+ const date = new Date(isoValue);
+ return date.getTime();
+}
+/**
+ * the given time value in milliseconds is converted to a ISO formatted string
+ * if nanosValue is provided, the given value replaces the fractional seconds part
+ * of the formated string since moment.js doesn't support formatting timestamps
+ * with a higher precision then microseconds
+ * The browser rounds date nanos values:
+ * 2019-09-18T06:50:12.999999999 -> browser rounds to 1568789413000000000
+ * 2019-09-18T06:50:59.999999999 -> browser rounds to 1568789460000000000
+ * 2017-12-31T23:59:59.999999999 -> browser rounds 1514761199999999999 to 1514761200000000000
+ */
+export function convertTimeValueToIso(timeValueMillis: number, nanosValue: string): string | null {
+ if (!timeValueMillis) {
+ return null;
+ }
+ const isoString = moment(timeValueMillis).toISOString();
+ if (!isoString) {
+ return null;
+ } else if (nanosValue !== '') {
+ return `${isoString.substring(0, isoString.length - 4)}${nanosValue}Z`;
+ }
+ return isoString;
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts
new file mode 100644
index 000000000000..262b64ba8c15
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts
@@ -0,0 +1,107 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ ISearchSource,
+ OpenSearchQuerySortValue,
+ SortDirection,
+} from '../../../../../../../data/public';
+import { convertTimeValueToIso } from './date_conversion';
+import { OpenSearchHitRecordList, OpenSearchHitRecord } from '../context';
+import { IntervalValue } from './generate_intervals';
+import { OpenSearchQuerySearchAfter } from './get_opensearch_query_search_after';
+
+interface RangeQuery {
+ format: string;
+ lte?: string | null;
+ gte?: string | null;
+}
+
+/**
+ * Fetch the hits between a given `interval` up to a maximum of `maxCount` documents.
+ * The documents are sorted by `sort`
+ *
+ * The `searchSource` is assumed to have the appropriate index pattern
+ * and filters set.
+ */
+export async function fetchHitsInInterval(
+ searchSource: ISearchSource,
+ timeField: string,
+ sort: [OpenSearchQuerySortValue, OpenSearchQuerySortValue],
+ sortDir: SortDirection,
+ interval: IntervalValue[],
+ searchAfter: OpenSearchQuerySearchAfter,
+ maxCount: number,
+ nanosValue: string,
+ anchorId: string
+): Promise {
+ const range: RangeQuery = {
+ format: 'strict_date_optional_time',
+ };
+ const [start, stop] = interval;
+
+ if (start) {
+ range[sortDir === SortDirection.asc ? 'gte' : 'lte'] = convertTimeValueToIso(start, nanosValue);
+ }
+
+ if (stop) {
+ range[sortDir === SortDirection.asc ? 'lte' : 'gte'] = convertTimeValueToIso(stop, nanosValue);
+ }
+ const response = await searchSource
+ .setField('size', maxCount)
+ .setField('query', {
+ query: {
+ bool: {
+ must: {
+ constant_score: {
+ filter: {
+ range: {
+ [timeField]: range,
+ },
+ },
+ },
+ },
+ must_not: {
+ ids: {
+ values: [anchorId],
+ },
+ },
+ },
+ },
+ language: 'lucene',
+ })
+ .setField('searchAfter', searchAfter)
+ .setField('sort', sort)
+ .setField('version', true)
+ .fetch();
+
+ // TODO: There's a difference in the definition of SearchResponse and OpenSearchHitRecord
+ return ((response.hits?.hits as unknown) as OpenSearchHitRecord[]) || [];
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts
new file mode 100644
index 000000000000..fda2e23eb234
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts
@@ -0,0 +1,66 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SortDirection } from '../../../../../../../data/public';
+
+export type IntervalValue = number | null;
+
+/**
+ * Generate a sequence of pairs from the iterable that looks like
+ * `[[x_0, x_1], [x_1, x_2], [x_2, x_3], ..., [x_(n-1), x_n]]`.
+ */
+export function* asPairs(iterable: Iterable): IterableIterator {
+ let currentPair: IntervalValue[] = [];
+ for (const value of iterable) {
+ currentPair = [...currentPair, value].slice(-2);
+ if (currentPair.length === 2) {
+ yield currentPair;
+ }
+ }
+}
+
+/**
+ * Returns a iterable containing intervals `[start,end]` for OpenSearch date range queries
+ * depending on type (`successors` or `predecessors`) and sort (`asc`, `desc`) these are ascending or descending intervals.
+ */
+export function generateIntervals(
+ offsets: number[],
+ startTime: number,
+ type: string,
+ sort: SortDirection
+): IterableIterator {
+ const offsetSign =
+ (sort === SortDirection.asc && type === 'successors') ||
+ (sort === SortDirection.desc && type === 'predecessors')
+ ? 1
+ : -1;
+ // ending with `null` opens the last interval
+ return asPairs([...offsets.map((offset) => startTime + offset * offsetSign), null]);
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts
new file mode 100644
index 000000000000..eb6a5af565ba
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts
@@ -0,0 +1,58 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { SurrDocType, OpenSearchHitRecordList, OpenSearchHitRecord } from '../context';
+
+export type OpenSearchQuerySearchAfter = [string | number, string | number];
+
+/**
+ * Get the searchAfter query value for opensearch
+ * When there are already documents available, which means successors or predecessors
+ * were already fetched, the new searchAfter for the next fetch has to be the sort value
+ * of the first (prececessor), or last (successor) of the list
+ */
+export function getOpenSearchQuerySearchAfter(
+ type: SurrDocType,
+ documents: OpenSearchHitRecordList,
+ timeFieldName: string,
+ anchor: OpenSearchHitRecord,
+ nanoSeconds: string
+): OpenSearchQuerySearchAfter {
+ if (documents.length) {
+ // already surrounding docs -> first or last record is used
+ const afterTimeRecIdx = type === 'successors' && documents.length ? documents.length - 1 : 0;
+ const afterTimeDoc = documents[afterTimeRecIdx];
+ const afterTimeValue = nanoSeconds ? afterTimeDoc._source[timeFieldName] : afterTimeDoc.sort[0];
+ return [afterTimeValue, afterTimeDoc.sort[1]];
+ }
+ // if data_nanos adapt timestamp value for sorting, since numeric value was rounded by browser
+ // OpenSearch search_after also works when number is provided as string
+ return [nanoSeconds ? anchor._source[timeFieldName] : anchor.sort[0], anchor.sort[1]];
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts
new file mode 100644
index 000000000000..30c4888fa438
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts
@@ -0,0 +1,49 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import {
+ OpenSearchQuerySortValue,
+ SortDirection,
+} from '../../../../../opensearch_dashboards_services';
+
+/**
+ * Returns `OpenSearchQuerySort` which is used to sort records in the OpenSearch query
+ * https://opensearch.org/docs/latest/opensearch/ux/#sort-results
+ * @param timeField
+ * @param tieBreakerField
+ * @param sortDir
+ */
+export function getOpenSearchQuerySort(
+ timeField: string,
+ tieBreakerField: string,
+ sortDir: SortDirection
+): [OpenSearchQuerySortValue, OpenSearchQuerySortValue] {
+ return [{ [timeField]: sortDir }, { [tieBreakerField]: sortDir }];
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts
new file mode 100644
index 000000000000..6944591d40cd
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts
@@ -0,0 +1,38 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { reverseSortDir, SortDirection } from './sorting';
+
+describe('function reverseSortDir', function () {
+ test('reverse a given sort direction', function () {
+ expect(reverseSortDir(SortDirection.asc)).toBe(SortDirection.desc);
+ expect(reverseSortDir(SortDirection.desc)).toBe(SortDirection.asc);
+ });
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts
new file mode 100644
index 000000000000..52b6df12e467
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts
@@ -0,0 +1,63 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import { IndexPattern } from '../../../../../opensearch_dashboards_services';
+
+export enum SortDirection {
+ asc = 'asc',
+ desc = 'desc',
+}
+
+/**
+ * The list of field names that are allowed for sorting, but not included in
+ * index pattern fields.
+ */
+const META_FIELD_NAMES: string[] = ['_seq_no', '_doc', '_uid'];
+
+/**
+ * Returns a field from the intersection of the set of sortable fields in the
+ * given index pattern and a given set of candidate field names.
+ */
+export function getFirstSortableField(indexPattern: IndexPattern, fieldNames: string[]) {
+ const sortableFields = fieldNames.filter(
+ (fieldName) =>
+ META_FIELD_NAMES.includes(fieldName) ||
+ // @ts-ignore
+ (indexPattern.fields.getByName(fieldName) || { sortable: false }).sortable
+ );
+ return sortableFields[0];
+}
+
+/**
+ * Return the reversed sort direction.
+ */
+export function reverseSortDir(sortDirection: SortDirection) {
+ return sortDirection === SortDirection.asc ? SortDirection.desc : SortDirection.asc;
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_action_bar.scss b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_action_bar.scss
new file mode 100644
index 000000000000..da0911c3a452
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_action_bar.scss
@@ -0,0 +1,10 @@
+.cxtSizePicker {
+ text-align: center;
+ width: $euiSize * 5;
+
+ &::-webkit-outer-spin-button,
+ &::-webkit-inner-spin-button {
+ appearance: none; // Hide increment and decrement buttons for type="number" input.
+ margin: 0;
+ }
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_index.scss b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_index.scss
new file mode 100644
index 000000000000..40a446220577
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_index.scss
@@ -0,0 +1 @@
+@import "action_bar";
diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx
new file mode 100644
index 000000000000..2f7cc40b7d9a
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx
@@ -0,0 +1,104 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React from 'react';
+import { mountWithIntl } from 'test_utils/enzyme_helpers';
+import { ActionBar, ActionBarProps } from './action_bar';
+import { findTestSubject } from 'test_utils/helpers';
+import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from '../../query_parameters/constants';
+
+describe('Test Discover Context ActionBar for successor | predecessor records', () => {
+ ['successors', 'predecessors'].forEach((type) => {
+ const onChangeCount = jest.fn();
+ const props = {
+ defaultStepSize: 5,
+ docCount: 20,
+ docCountAvailable: 0,
+ isDisabled: false,
+ isLoading: false,
+ onChangeCount,
+ type,
+ } as ActionBarProps;
+ const wrapper = mountWithIntl( );
+
+ const input = findTestSubject(wrapper, `${type}CountPicker`);
+ const btn = findTestSubject(wrapper, `${type}LoadMoreButton`);
+
+ test(`${type}: Load button click`, () => {
+ btn.simulate('click');
+ expect(onChangeCount).toHaveBeenCalledWith(25);
+ });
+
+ test(`${type}: Load button click doesnt submit when MAX_CONTEXT_SIZE was reached`, () => {
+ onChangeCount.mockClear();
+ input.simulate('change', { target: { valueAsNumber: MAX_CONTEXT_SIZE } });
+ btn.simulate('click');
+ expect(onChangeCount).toHaveBeenCalledTimes(0);
+ });
+
+ test(`${type}: Count input change submits on blur`, () => {
+ input.simulate('change', { target: { valueAsNumber: 123 } });
+ input.simulate('blur');
+ expect(onChangeCount).toHaveBeenCalledWith(123);
+ });
+
+ test(`${type}: Count input change submits on return`, () => {
+ input.simulate('change', { target: { valueAsNumber: 124 } });
+ input.simulate('submit');
+ expect(onChangeCount).toHaveBeenCalledWith(124);
+ });
+
+ test(`${type}: Count input doesnt submits values higher than MAX_CONTEXT_SIZE `, () => {
+ onChangeCount.mockClear();
+ input.simulate('change', { target: { valueAsNumber: MAX_CONTEXT_SIZE + 1 } });
+ input.simulate('submit');
+ expect(onChangeCount).toHaveBeenCalledTimes(0);
+ });
+
+ test(`${type}: Count input doesnt submits values lower than MIN_CONTEXT_SIZE `, () => {
+ onChangeCount.mockClear();
+ input.simulate('change', { target: { valueAsNumber: MIN_CONTEXT_SIZE - 1 } });
+ input.simulate('submit');
+ expect(onChangeCount).toHaveBeenCalledTimes(0);
+ });
+
+ test(`${type}: Warning about limitation of additional records`, () => {
+ if (type === 'predecessors') {
+ expect(findTestSubject(wrapper, 'predecessorsWarningMsg').text()).toBe(
+ 'No documents newer than the anchor could be found.'
+ );
+ } else {
+ expect(findTestSubject(wrapper, 'successorsWarningMsg').text()).toBe(
+ 'No documents older than the anchor could be found.'
+ );
+ }
+ });
+ });
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx
new file mode 100644
index 000000000000..8a4b0b308047
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx
@@ -0,0 +1,182 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React, { useState } from 'react';
+import { i18n } from '@osd/i18n';
+import { FormattedMessage, I18nProvider } from '@osd/i18n/react';
+import {
+ EuiButtonEmpty,
+ EuiFieldNumber,
+ EuiFlexGroup,
+ EuiFlexItem,
+ EuiFormRow,
+ EuiSpacer,
+} from '@elastic/eui';
+import { ActionBarWarning } from './action_bar_warning';
+import { SurrDocType } from '../../api/context';
+import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from '../../query_parameters/constants';
+
+export interface ActionBarProps {
+ /**
+ * the number of documents fetched initially and added when the load button is clicked
+ */
+ defaultStepSize: number;
+ /**
+ * the number of docs to be displayed
+ */
+ docCount: number;
+ /**
+ * the number of documents that are available
+ * display warning when it's lower than docCount
+ */
+ docCountAvailable: number;
+ /**
+ * is true while the anchor record is fetched
+ */
+ isDisabled: boolean;
+ /**
+ * is true when list entries are fetched
+ */
+ isLoading: boolean;
+ /**
+ * is triggered when the input containing count is changed
+ * @param count
+ */
+ onChangeCount: (count: number) => void;
+ /**
+ * can be `predecessors` or `successors`, usage in context:
+ * predecessors action bar + records (these are newer records)
+ * anchor record
+ * successors records + action bar (these are older records)
+ */
+ type: SurrDocType;
+}
+
+export function ActionBar({
+ defaultStepSize,
+ docCount,
+ docCountAvailable,
+ isDisabled,
+ isLoading,
+ onChangeCount,
+ type,
+}: ActionBarProps) {
+ const showWarning = !isDisabled && !isLoading && docCountAvailable < docCount;
+ const isSuccessor = type === 'successors';
+ const [newDocCount, setNewDocCount] = useState(docCount);
+ const isValid = (value: number) => value >= MIN_CONTEXT_SIZE && value <= MAX_CONTEXT_SIZE;
+ const onSubmit = (ev: React.FormEvent) => {
+ ev.preventDefault();
+ if (newDocCount !== docCount && isValid(newDocCount)) {
+ onChangeCount(newDocCount);
+ }
+ };
+
+ return (
+
+
+
+ );
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts
new file mode 100644
index 000000000000..3aa62e72353e
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts
@@ -0,0 +1,36 @@
+/*
+ * 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 { getAngularModule } from '../../../../../opensearch_dashboards_services';
+import { ActionBar } from './action_bar';
+
+getAngularModule().directive('contextActionBar', function (reactDirective: any) {
+ return reactDirective(ActionBar);
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx
new file mode 100644
index 000000000000..cfdc3cc0c8cc
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx
@@ -0,0 +1,84 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import React from 'react';
+import { FormattedMessage } from '@osd/i18n/react';
+import { EuiCallOut } from '@elastic/eui';
+import { SurrDocType } from '../../api/context';
+
+export function ActionBarWarning({ docCount, type }: { docCount: number; type: SurrDocType }) {
+ if (type === 'predecessors') {
+ return (
+
+ ) : (
+
+ )
+ }
+ size="s"
+ />
+ );
+ }
+
+ return (
+
+ ) : (
+
+ )
+ }
+ size="s"
+ />
+ );
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts
new file mode 100644
index 000000000000..1e3799d2121a
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts
@@ -0,0 +1,31 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import './action_bar_directive';
diff --git a/src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js b/src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js
new file mode 100644
index 000000000000..455cc57b3d4d
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js
@@ -0,0 +1,74 @@
+/*
+ * 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.
+ */
+
+/**
+ * WHAT NEEDS THIS WORKAROUND?
+ * ===========================
+ * Any directive that meets all of the following criteria:
+ * - uses isolate scope bindings
+ * - sets `bindToController: true`
+ * - synchronously accesses the bound values in the controller constructor
+ *
+ *
+ *
+ * HOW DO I GET RID OF IT?
+ * =======================
+ * The quick band-aid solution:
+ * Wrap your constructor logic so it doesn't access bound values
+ * synchronously. This can have subtle bugs which is why I didn't
+ * just wrap all of the offenders in $timeout() and made this
+ * workaround instead.
+ *
+ * The more complete solution:
+ * Use the new component lifecycle methods, like `$onInit()`, to access
+ * bindings immediately after the constructor is called, which shouldn't
+ * have any observable effect outside of the constructor.
+ *
+ * NOTE: `$onInit()` is not dependency injected, if you need controller specific
+ * dependencies like `$scope` then you're probably using watchers and should
+ * take a look at the new one-way data flow facilities available to
+ * directives/components:
+ *
+ * https://docs.angularjs.org/guide/component#component-based-application-architecture
+ *
+ */
+
+export function callAfterBindingsWorkaround(constructor) {
+ return function InitAfterBindingsWrapper($injector, $attrs, $element, $scope, $transclude) {
+ this.$onInit = () => {
+ $injector.invoke(constructor, this, {
+ $attrs,
+ $element,
+ $scope,
+ $transclude,
+ });
+ };
+ };
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/query/actions.js b/src/plugins/discover_legacy/public/application/angular/context/query/actions.js
new file mode 100644
index 000000000000..d4b4f9ba9977
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/query/actions.js
@@ -0,0 +1,203 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import _ from 'lodash';
+import { i18n } from '@osd/i18n';
+import React from 'react';
+import { getServices } from '../../../../opensearch_dashboards_services';
+
+import { fetchAnchorProvider } from '../api/anchor';
+import { fetchContextProvider } from '../api/context';
+import { getQueryParameterActions } from '../query_parameters';
+import { FAILURE_REASONS, LOADING_STATUS } from './constants';
+import { MarkdownSimple } from '../../../../../../opensearch_dashboards_react/public';
+
+export function QueryActionsProvider(Promise) {
+ const { filterManager, indexPatterns, data } = getServices();
+ const fetchAnchor = fetchAnchorProvider(indexPatterns, data.search.searchSource.createEmpty());
+ const { fetchSurroundingDocs } = fetchContextProvider(indexPatterns);
+ const { setPredecessorCount, setQueryParameters, setSuccessorCount } = getQueryParameterActions(
+ filterManager,
+ indexPatterns
+ );
+
+ const setFailedStatus = (state) => (subject, details = {}) =>
+ (state.loadingStatus[subject] = {
+ status: LOADING_STATUS.FAILED,
+ reason: FAILURE_REASONS.UNKNOWN,
+ ...details,
+ });
+
+ const setLoadedStatus = (state) => (subject) =>
+ (state.loadingStatus[subject] = {
+ status: LOADING_STATUS.LOADED,
+ });
+
+ const setLoadingStatus = (state) => (subject) =>
+ (state.loadingStatus[subject] = {
+ status: LOADING_STATUS.LOADING,
+ });
+
+ const fetchAnchorRow = (state) => () => {
+ const {
+ queryParameters: { indexPatternId, anchorId, sort, tieBreakerField },
+ } = state;
+
+ if (!tieBreakerField) {
+ return Promise.reject(
+ setFailedStatus(state)('anchor', {
+ reason: FAILURE_REASONS.INVALID_TIEBREAKER,
+ })
+ );
+ }
+
+ setLoadingStatus(state)('anchor');
+
+ return Promise.try(() =>
+ fetchAnchor(indexPatternId, anchorId, [_.fromPairs([sort]), { [tieBreakerField]: sort[1] }])
+ ).then(
+ (anchorDocument) => {
+ setLoadedStatus(state)('anchor');
+ state.rows.anchor = anchorDocument;
+ return anchorDocument;
+ },
+ (error) => {
+ setFailedStatus(state)('anchor', { error });
+ getServices().toastNotifications.addDanger({
+ title: i18n.translate('discover.context.unableToLoadAnchorDocumentDescription', {
+ defaultMessage: 'Unable to load the anchor document',
+ }),
+ text: {error.message} ,
+ });
+ throw error;
+ }
+ );
+ };
+
+ const fetchSurroundingRows = (type, state) => {
+ const {
+ queryParameters: { indexPatternId, sort, tieBreakerField },
+ rows: { anchor },
+ } = state;
+ const filters = getServices().filterManager.getFilters();
+
+ const count =
+ type === 'successors'
+ ? state.queryParameters.successorCount
+ : state.queryParameters.predecessorCount;
+
+ if (!tieBreakerField) {
+ return Promise.reject(
+ setFailedStatus(state)(type, {
+ reason: FAILURE_REASONS.INVALID_TIEBREAKER,
+ })
+ );
+ }
+
+ setLoadingStatus(state)(type);
+ const [sortField, sortDir] = sort;
+
+ return Promise.try(() =>
+ fetchSurroundingDocs(
+ type,
+ indexPatternId,
+ anchor,
+ sortField,
+ tieBreakerField,
+ sortDir,
+ count,
+ filters
+ )
+ ).then(
+ (documents) => {
+ setLoadedStatus(state)(type);
+ state.rows[type] = documents;
+ return documents;
+ },
+ (error) => {
+ setFailedStatus(state)(type, { error });
+ getServices().toastNotifications.addDanger({
+ title: i18n.translate('discover.context.unableToLoadDocumentDescription', {
+ defaultMessage: 'Unable to load documents',
+ }),
+ text: {error.message} ,
+ });
+ throw error;
+ }
+ );
+ };
+
+ const fetchContextRows = (state) => () =>
+ Promise.all([
+ fetchSurroundingRows('predecessors', state),
+ fetchSurroundingRows('successors', state),
+ ]);
+
+ const fetchAllRows = (state) => () =>
+ Promise.try(fetchAnchorRow(state)).then(fetchContextRows(state));
+
+ const fetchContextRowsWithNewQueryParameters = (state) => (queryParameters) => {
+ setQueryParameters(state)(queryParameters);
+ return fetchContextRows(state)();
+ };
+
+ const fetchAllRowsWithNewQueryParameters = (state) => (queryParameters) => {
+ setQueryParameters(state)(queryParameters);
+ return fetchAllRows(state)();
+ };
+
+ const fetchGivenPredecessorRows = (state) => (count) => {
+ setPredecessorCount(state)(count);
+ return fetchSurroundingRows('predecessors', state);
+ };
+
+ const fetchGivenSuccessorRows = (state) => (count) => {
+ setSuccessorCount(state)(count);
+ return fetchSurroundingRows('successors', state);
+ };
+
+ const setAllRows = (state) => (predecessorRows, anchorRow, successorRows) =>
+ (state.rows.all = [
+ ...(predecessorRows || []),
+ ...(anchorRow ? [anchorRow] : []),
+ ...(successorRows || []),
+ ]);
+
+ return {
+ fetchAllRows,
+ fetchAllRowsWithNewQueryParameters,
+ fetchAnchorRow,
+ fetchContextRows,
+ fetchContextRowsWithNewQueryParameters,
+ fetchGivenPredecessorRows,
+ fetchGivenSuccessorRows,
+ setAllRows,
+ };
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/query/constants.js b/src/plugins/discover_legacy/public/application/angular/context/query/constants.js
new file mode 100644
index 000000000000..99039d463b5b
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/query/constants.js
@@ -0,0 +1,41 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export const FAILURE_REASONS = {
+ UNKNOWN: 'unknown',
+ INVALID_TIEBREAKER: 'invalid_tiebreaker',
+};
+
+export const LOADING_STATUS = {
+ FAILED: 'failed',
+ LOADED: 'loaded',
+ LOADING: 'loading',
+ UNINITIALIZED: 'uninitialized',
+};
diff --git a/src/plugins/discover_legacy/public/application/angular/context/query/index.js b/src/plugins/discover_legacy/public/application/angular/context/query/index.js
new file mode 100644
index 000000000000..cbb0a7484ea7
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/query/index.js
@@ -0,0 +1,33 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { QueryActionsProvider } from './actions';
+export { FAILURE_REASONS, LOADING_STATUS } from './constants';
+export { createInitialLoadingStatusState } from './state';
diff --git a/src/plugins/discover_legacy/public/application/angular/context/query/state.js b/src/plugins/discover_legacy/public/application/angular/context/query/state.js
new file mode 100644
index 000000000000..7a38ea8ebe3a
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/query/state.js
@@ -0,0 +1,39 @@
+/*
+ * 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 { LOADING_STATUS } from './constants';
+
+export function createInitialLoadingStatusState() {
+ return {
+ anchor: LOADING_STATUS.UNINITIALIZED,
+ predecessors: LOADING_STATUS.UNINITIALIZED,
+ successors: LOADING_STATUS.UNINITIALIZED,
+ };
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js
new file mode 100644
index 000000000000..f191d7c0d5a2
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js
@@ -0,0 +1,86 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import _ from 'lodash';
+import { opensearchFilters } from '../../../../../../data/public';
+import { popularizeField } from '../../../helpers/popularize_field';
+
+import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE, QUERY_PARAMETER_KEYS } from './constants';
+
+export function getQueryParameterActions(filterManager, indexPatterns) {
+ const setPredecessorCount = (state) => (predecessorCount) =>
+ (state.queryParameters.predecessorCount = clamp(
+ MIN_CONTEXT_SIZE,
+ MAX_CONTEXT_SIZE,
+ predecessorCount
+ ));
+
+ const setSuccessorCount = (state) => (successorCount) =>
+ (state.queryParameters.successorCount = clamp(
+ MIN_CONTEXT_SIZE,
+ MAX_CONTEXT_SIZE,
+ successorCount
+ ));
+
+ const setQueryParameters = (state) => (queryParameters) =>
+ Object.assign(state.queryParameters, _.pick(queryParameters, QUERY_PARAMETER_KEYS));
+
+ const updateFilters = () => (filters) => {
+ filterManager.setFilters(filters);
+ };
+
+ const addFilter = (state) => async (field, values, operation) => {
+ const indexPatternId = state.queryParameters.indexPatternId;
+ const newFilters = opensearchFilters.generateFilters(
+ filterManager,
+ field,
+ values,
+ operation,
+ indexPatternId
+ );
+ filterManager.addFilters(newFilters);
+ if (indexPatterns) {
+ const indexPattern = await indexPatterns.get(indexPatternId);
+ await popularizeField(indexPattern, field.name, indexPatterns);
+ }
+ };
+
+ return {
+ addFilter,
+ updateFilters,
+ setPredecessorCount,
+ setQueryParameters,
+ setSuccessorCount,
+ };
+}
+
+function clamp(minimum, maximum, value) {
+ return Math.max(Math.min(maximum, value), minimum);
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts
new file mode 100644
index 000000000000..2e3c69a32ff6
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts
@@ -0,0 +1,168 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+// @ts-ignore
+import { getQueryParameterActions } from './actions';
+import { FilterManager } from '../../../../../../data/public';
+import { coreMock } from '../../../../../../../core/public/mocks';
+const setupMock = coreMock.createSetup();
+
+let state: {
+ queryParameters: {
+ defaultStepSize: number;
+ indexPatternId: string;
+ predecessorCount: number;
+ successorCount: number;
+ };
+};
+let filterManager: FilterManager;
+let filterManagerSpy: jest.SpyInstance;
+
+beforeEach(() => {
+ filterManager = new FilterManager(setupMock.uiSettings);
+ filterManagerSpy = jest.spyOn(filterManager, 'addFilters');
+
+ state = {
+ queryParameters: {
+ defaultStepSize: 3,
+ indexPatternId: 'INDEX_PATTERN_ID',
+ predecessorCount: 10,
+ successorCount: 10,
+ },
+ };
+});
+
+describe('context query_parameter actions', function () {
+ describe('action addFilter', () => {
+ it('should pass the given arguments to the filterManager', () => {
+ const { addFilter } = getQueryParameterActions(filterManager);
+
+ addFilter(state)('FIELD_NAME', 'FIELD_VALUE', 'FILTER_OPERATION');
+
+ // get the generated filter
+ const generatedFilter = filterManagerSpy.mock.calls[0][0][0];
+ const queryKeys = Object.keys(generatedFilter.query.match_phrase);
+ expect(filterManagerSpy.mock.calls.length).toBe(1);
+ expect(queryKeys[0]).toBe('FIELD_NAME');
+ expect(generatedFilter.query.match_phrase[queryKeys[0]]).toBe('FIELD_VALUE');
+ });
+
+ it('should pass the index pattern id to the filterManager', () => {
+ const { addFilter } = getQueryParameterActions(filterManager);
+ addFilter(state)('FIELD_NAME', 'FIELD_VALUE', 'FILTER_OPERATION');
+ const generatedFilter = filterManagerSpy.mock.calls[0][0][0];
+ expect(generatedFilter.meta.index).toBe('INDEX_PATTERN_ID');
+ });
+ });
+ describe('action setPredecessorCount', () => {
+ it('should set the predecessorCount to the given value', () => {
+ const { setPredecessorCount } = getQueryParameterActions(filterManager);
+ setPredecessorCount(state)(20);
+ expect(state.queryParameters.predecessorCount).toBe(20);
+ });
+
+ it('should limit the predecessorCount to 0 as a lower bound', () => {
+ const { setPredecessorCount } = getQueryParameterActions(filterManager);
+ setPredecessorCount(state)(-1);
+ expect(state.queryParameters.predecessorCount).toBe(0);
+ });
+
+ it('should limit the predecessorCount to 10000 as an upper bound', () => {
+ const { setPredecessorCount } = getQueryParameterActions(filterManager);
+ setPredecessorCount(state)(20000);
+ expect(state.queryParameters.predecessorCount).toBe(10000);
+ });
+ });
+ describe('action setSuccessorCount', () => {
+ it('should set the successorCount to the given value', function () {
+ const { setSuccessorCount } = getQueryParameterActions(filterManager);
+ setSuccessorCount(state)(20);
+
+ expect(state.queryParameters.successorCount).toBe(20);
+ });
+
+ it('should limit the successorCount to 0 as a lower bound', () => {
+ const { setSuccessorCount } = getQueryParameterActions(filterManager);
+ setSuccessorCount(state)(-1);
+ expect(state.queryParameters.successorCount).toBe(0);
+ });
+
+ it('should limit the successorCount to 10000 as an upper bound', () => {
+ const { setSuccessorCount } = getQueryParameterActions(filterManager);
+ setSuccessorCount(state)(20000);
+ expect(state.queryParameters.successorCount).toBe(10000);
+ });
+ });
+ describe('action setQueryParameters', function () {
+ const { setQueryParameters } = getQueryParameterActions(filterManager);
+
+ it('should update the queryParameters with valid properties from the given object', function () {
+ const newState = {
+ ...state,
+ queryParameters: {
+ additionalParameter: 'ADDITIONAL_PARAMETER',
+ },
+ };
+
+ const actualState = setQueryParameters(newState)({
+ anchorId: 'ANCHOR_ID',
+ columns: ['column'],
+ defaultStepSize: 3,
+ filters: ['filter'],
+ indexPatternId: 'INDEX_PATTERN',
+ predecessorCount: 100,
+ successorCount: 100,
+ sort: ['field'],
+ });
+
+ expect(actualState).toEqual({
+ additionalParameter: 'ADDITIONAL_PARAMETER',
+ anchorId: 'ANCHOR_ID',
+ columns: ['column'],
+ defaultStepSize: 3,
+ filters: ['filter'],
+ indexPatternId: 'INDEX_PATTERN',
+ predecessorCount: 100,
+ successorCount: 100,
+ sort: ['field'],
+ });
+ });
+
+ it('should ignore invalid properties', function () {
+ const newState = { ...state };
+
+ setQueryParameters(newState)({
+ additionalParameter: 'ADDITIONAL_PARAMETER',
+ });
+
+ expect(state.queryParameters).toEqual(newState.queryParameters);
+ });
+ });
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts
new file mode 100644
index 000000000000..a6dcc5653c87
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts
@@ -0,0 +1,35 @@
+/*
+ * 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 { createInitialQueryParametersState } from './state';
+
+export const MAX_CONTEXT_SIZE = 10000; // OpenSearch's default maximum size limit
+export const MIN_CONTEXT_SIZE = 0;
+export const QUERY_PARAMETER_KEYS = Object.keys(createInitialQueryParametersState());
diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js
new file mode 100644
index 000000000000..03f172bd12cb
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js
@@ -0,0 +1,33 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { getQueryParameterActions } from './actions';
+export { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE, QUERY_PARAMETER_KEYS } from './constants';
+export { createInitialQueryParametersState } from './state';
diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts
new file mode 100644
index 000000000000..a9f44a0f7bef
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts
@@ -0,0 +1,47 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export function createInitialQueryParametersState(
+ defaultStepSize: number = 5,
+ tieBreakerField: string = '_doc'
+) {
+ return {
+ anchorType: null,
+ anchorId: null,
+ columns: [],
+ defaultStepSize,
+ filters: [],
+ indexPatternId: null,
+ predecessorCount: 0,
+ successorCount: 0,
+ sort: [],
+ tieBreakerField,
+ };
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context_app.html b/src/plugins/discover_legacy/public/application/angular/context_app.html
new file mode 100644
index 000000000000..1d3971a41132
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context_app.html
@@ -0,0 +1,55 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/src/plugins/discover_legacy/public/application/angular/context_app.js b/src/plugins/discover_legacy/public/application/angular/context_app.js
new file mode 100644
index 000000000000..fa487c726612
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context_app.js
@@ -0,0 +1,151 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import _ from 'lodash';
+import { CONTEXT_STEP_SETTING, CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../common';
+import { getAngularModule, getServices } from '../../opensearch_dashboards_services';
+import contextAppTemplate from './context_app.html';
+import './context/components/action_bar';
+import { getFirstSortableField } from './context/api/utils/sorting';
+import {
+ createInitialQueryParametersState,
+ getQueryParameterActions,
+ QUERY_PARAMETER_KEYS,
+} from './context/query_parameters';
+import {
+ createInitialLoadingStatusState,
+ FAILURE_REASONS,
+ LOADING_STATUS,
+ QueryActionsProvider,
+} from './context/query';
+import { callAfterBindingsWorkaround } from './context/helpers/call_after_bindings_workaround';
+
+getAngularModule().directive('contextApp', function ContextApp() {
+ return {
+ bindToController: true,
+ controller: callAfterBindingsWorkaround(ContextAppController),
+ controllerAs: 'contextApp',
+ restrict: 'E',
+ scope: {
+ anchorId: '=',
+ columns: '=',
+ indexPattern: '=',
+ filters: '=',
+ predecessorCount: '=',
+ successorCount: '=',
+ sort: '=',
+ },
+ template: contextAppTemplate,
+ };
+});
+
+function ContextAppController($scope, Private) {
+ const { filterManager, indexPatterns, uiSettings } = getServices();
+ const queryParameterActions = getQueryParameterActions(filterManager, indexPatterns);
+ const queryActions = Private(QueryActionsProvider);
+ this.state = createInitialState(
+ parseInt(uiSettings.get(CONTEXT_STEP_SETTING), 10),
+ getFirstSortableField(this.indexPattern, uiSettings.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING))
+ );
+
+ this.actions = _.mapValues(
+ {
+ ...queryParameterActions,
+ ...queryActions,
+ },
+ (action) => (...args) => action(this.state)(...args)
+ );
+
+ this.constants = {
+ FAILURE_REASONS,
+ LOADING_STATUS,
+ };
+
+ $scope.$watchGroup(
+ [
+ () => this.state.rows.predecessors,
+ () => this.state.rows.anchor,
+ () => this.state.rows.successors,
+ ],
+ (newValues) => this.actions.setAllRows(...newValues)
+ );
+
+ /**
+ * Sync properties to state
+ */
+ $scope.$watchCollection(
+ () => ({
+ ..._.pick(this, QUERY_PARAMETER_KEYS),
+ indexPatternId: this.indexPattern.id,
+ }),
+ (newQueryParameters) => {
+ const { queryParameters } = this.state;
+ if (
+ newQueryParameters.indexPatternId !== queryParameters.indexPatternId ||
+ newQueryParameters.anchorId !== queryParameters.anchorId ||
+ !_.isEqual(newQueryParameters.sort, queryParameters.sort)
+ ) {
+ this.actions.fetchAllRowsWithNewQueryParameters(_.cloneDeep(newQueryParameters));
+ } else if (
+ newQueryParameters.predecessorCount !== queryParameters.predecessorCount ||
+ newQueryParameters.successorCount !== queryParameters.successorCount ||
+ !_.isEqual(newQueryParameters.filters, queryParameters.filters)
+ ) {
+ this.actions.fetchContextRowsWithNewQueryParameters(_.cloneDeep(newQueryParameters));
+ }
+ }
+ );
+
+ /**
+ * Sync state to properties
+ */
+ $scope.$watchCollection(
+ () => ({
+ predecessorCount: this.state.queryParameters.predecessorCount,
+ successorCount: this.state.queryParameters.successorCount,
+ }),
+ (newParameters) => {
+ _.assign(this, newParameters);
+ }
+ );
+}
+
+function createInitialState(defaultStepSize, tieBreakerField) {
+ return {
+ queryParameters: createInitialQueryParametersState(defaultStepSize, tieBreakerField),
+ rows: {
+ all: [],
+ anchor: null,
+ predecessors: [],
+ successors: [],
+ },
+ loadingStatus: createInitialLoadingStatusState(),
+ };
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/context_state.test.ts b/src/plugins/discover_legacy/public/application/angular/context_state.test.ts
new file mode 100644
index 000000000000..23d4581a158b
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context_state.test.ts
@@ -0,0 +1,204 @@
+/*
+ * 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 { getState } from './context_state';
+import { createBrowserHistory, History } from 'history';
+import { FilterManager, Filter } from '../../../../data/public';
+import { coreMock } from '../../../../../core/public/mocks';
+const setupMock = coreMock.createSetup();
+
+describe('Test Discover Context State', () => {
+ let history: History;
+ let state: any;
+ const getCurrentUrl = () => history.createHref(history.location);
+ beforeEach(async () => {
+ history = createBrowserHistory();
+ history.push('/');
+ state = await getState({
+ defaultStepSize: '4',
+ timeFieldName: 'time',
+ history,
+ });
+ state.startSync();
+ });
+ afterEach(() => {
+ state.stopSync();
+ });
+ test('getState function default return', () => {
+ expect(state.appState.getState()).toMatchInlineSnapshot(`
+ Object {
+ "columns": Array [
+ "_source",
+ ],
+ "filters": Array [],
+ "predecessorCount": 4,
+ "sort": Array [
+ "time",
+ "desc",
+ ],
+ "successorCount": 4,
+ }
+ `);
+ expect(state.globalState.getState()).toMatchInlineSnapshot(`null`);
+ expect(state.startSync).toBeDefined();
+ expect(state.stopSync).toBeDefined();
+ expect(state.getFilters()).toStrictEqual([]);
+ });
+ test('getState -> setAppState syncing to url', async () => {
+ state.setAppState({ predecessorCount: 10 });
+ state.flushToUrl();
+ expect(getCurrentUrl()).toMatchInlineSnapshot(
+ `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,sort:!(time,desc),successorCount:4)"`
+ );
+ });
+ test('getState -> url to appState syncing', async () => {
+ history.push(
+ '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)'
+ );
+ expect(state.appState.getState()).toMatchInlineSnapshot(`
+ Object {
+ "columns": Array [
+ "_source",
+ ],
+ "predecessorCount": 1,
+ "sort": Array [
+ "time",
+ "desc",
+ ],
+ "successorCount": 1,
+ }
+ `);
+ });
+ test('getState -> url to appState syncing with return to a url without state', async () => {
+ history.push(
+ '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)'
+ );
+ expect(state.appState.getState()).toMatchInlineSnapshot(`
+ Object {
+ "columns": Array [
+ "_source",
+ ],
+ "predecessorCount": 1,
+ "sort": Array [
+ "time",
+ "desc",
+ ],
+ "successorCount": 1,
+ }
+ `);
+ history.push('/');
+ expect(state.appState.getState()).toMatchInlineSnapshot(`
+ Object {
+ "columns": Array [
+ "_source",
+ ],
+ "predecessorCount": 1,
+ "sort": Array [
+ "time",
+ "desc",
+ ],
+ "successorCount": 1,
+ }
+ `);
+ });
+
+ test('getState -> filters', async () => {
+ const filterManager = new FilterManager(setupMock.uiSettings);
+ const filterGlobal = {
+ query: { match: { extension: { query: 'jpg', type: 'phrase' } } },
+ meta: { index: 'logstash-*', negate: false, disabled: false, alias: null },
+ } as Filter;
+ filterManager.setGlobalFilters([filterGlobal]);
+ const filterApp = {
+ query: { match: { extension: { query: 'png', type: 'phrase' } } },
+ meta: { index: 'logstash-*', negate: true, disabled: false, alias: null },
+ } as Filter;
+ filterManager.setAppFilters([filterApp]);
+ state.setFilters(filterManager);
+ expect(state.getFilters()).toMatchInlineSnapshot(`
+ Array [
+ Object {
+ "$state": Object {
+ "store": "globalState",
+ },
+ "meta": Object {
+ "alias": null,
+ "disabled": false,
+ "index": "logstash-*",
+ "key": "extension",
+ "negate": false,
+ "params": Object {
+ "query": "jpg",
+ },
+ "type": "phrase",
+ "value": [Function],
+ },
+ "query": Object {
+ "match": Object {
+ "extension": Object {
+ "query": "jpg",
+ "type": "phrase",
+ },
+ },
+ },
+ },
+ Object {
+ "$state": Object {
+ "store": "appState",
+ },
+ "meta": Object {
+ "alias": null,
+ "disabled": false,
+ "index": "logstash-*",
+ "key": "extension",
+ "negate": true,
+ "params": Object {
+ "query": "png",
+ },
+ "type": "phrase",
+ "value": [Function],
+ },
+ "query": Object {
+ "match": Object {
+ "extension": Object {
+ "query": "png",
+ "type": "phrase",
+ },
+ },
+ },
+ },
+ ]
+ `);
+ state.flushToUrl();
+ expect(getCurrentUrl()).toMatchInlineSnapshot(
+ `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match:(extension:(query:jpg,type:phrase))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match:(extension:(query:png,type:phrase))))),predecessorCount:4,sort:!(time,desc),successorCount:4)"`
+ );
+ });
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/context_state.ts b/src/plugins/discover_legacy/public/application/angular/context_state.ts
new file mode 100644
index 000000000000..1b19b1d43e78
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/context_state.ts
@@ -0,0 +1,307 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import _ from 'lodash';
+import { History } from 'history';
+import { NotificationsStart } from 'opensearch-dashboards/public';
+import {
+ createStateContainer,
+ createOsdUrlStateStorage,
+ syncStates,
+ BaseStateContainer,
+ withNotifyOnErrors,
+} from '../../../../opensearch_dashboards_utils/public';
+import { opensearchFilters, FilterManager, Filter, Query } from '../../../../data/public';
+
+export interface AppState {
+ /**
+ * Columns displayed in the table, cannot be changed by UI, just in discover's main app
+ */
+ columns: string[];
+ /**
+ * Array of filters
+ */
+ filters: Filter[];
+ /**
+ * Number of records to be fetched before anchor records (newer records)
+ */
+ predecessorCount: number;
+ /**
+ * Sorting of the records to be fetched, assumed to be a legacy parameter
+ */
+ sort: string[];
+ /**
+ * Number of records to be fetched after the anchor records (older records)
+ */
+ successorCount: number;
+ query?: Query;
+}
+
+interface GlobalState {
+ /**
+ * Array of filters
+ */
+ filters: Filter[];
+}
+
+interface GetStateParams {
+ /**
+ * Number of records to be fetched when 'Load' link/button is clicked
+ */
+ defaultStepSize: string;
+ /**
+ * The timefield used for sorting
+ */
+ timeFieldName: string;
+ /**
+ * Determins the use of long vs. short/hashed urls
+ */
+ storeInSessionStorage?: boolean;
+ /**
+ * History instance to use
+ */
+ history: History;
+
+ /**
+ * Core's notifications.toasts service
+ * In case it is passed in,
+ * osdUrlStateStorage will use it notifying about inner errors
+ */
+ toasts?: NotificationsStart['toasts'];
+}
+
+interface GetStateReturn {
+ /**
+ * Global state, the _g part of the URL
+ */
+ globalState: BaseStateContainer;
+ /**
+ * App state, the _a part of the URL
+ */
+ appState: BaseStateContainer;
+ /**
+ * Start sync between state and URL
+ */
+ startSync: () => void;
+ /**
+ * Stop sync between state and URL
+ */
+ stopSync: () => void;
+ /**
+ * Set app state to with a partial new app state
+ */
+ setAppState: (newState: Partial) => void;
+ /**
+ * Get all filters, global and app state
+ */
+ getFilters: () => Filter[];
+ /**
+ * Set global state and app state filters by the given FilterManager instance
+ * @param filterManager
+ */
+ setFilters: (filterManager: FilterManager) => void;
+ /**
+ * sync state to URL, used for testing
+ */
+ flushToUrl: (replace?: boolean) => void;
+}
+const GLOBAL_STATE_URL_KEY = '_g';
+const APP_STATE_URL_KEY = '_a';
+
+/**
+ * Builds and returns appState and globalState containers
+ * provides helper functions to start/stop syncing with URL
+ */
+export function getState({
+ defaultStepSize,
+ timeFieldName,
+ storeInSessionStorage = false,
+ history,
+ toasts,
+}: GetStateParams): GetStateReturn {
+ const stateStorage = createOsdUrlStateStorage({
+ useHash: storeInSessionStorage,
+ history,
+ ...(toasts && withNotifyOnErrors(toasts)),
+ });
+
+ const globalStateInitial = stateStorage.get(GLOBAL_STATE_URL_KEY) as GlobalState;
+ const globalStateContainer = createStateContainer(globalStateInitial);
+
+ const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState;
+ const appStateInitial = createInitialAppState(defaultStepSize, timeFieldName, appStateFromUrl);
+ const appStateContainer = createStateContainer(appStateInitial);
+
+ const { start, stop } = syncStates([
+ {
+ storageKey: GLOBAL_STATE_URL_KEY,
+ stateContainer: {
+ ...globalStateContainer,
+ ...{
+ set: (value: GlobalState | null) => {
+ if (value) {
+ globalStateContainer.set(value);
+ }
+ },
+ },
+ },
+ stateStorage,
+ },
+ {
+ storageKey: APP_STATE_URL_KEY,
+ stateContainer: {
+ ...appStateContainer,
+ ...{
+ set: (value: AppState | null) => {
+ if (value) {
+ appStateContainer.set(value);
+ }
+ },
+ },
+ },
+ stateStorage,
+ },
+ ]);
+
+ return {
+ globalState: globalStateContainer,
+ appState: appStateContainer,
+ startSync: start,
+ stopSync: stop,
+ setAppState: (newState: Partial) => {
+ const oldState = appStateContainer.getState();
+ const mergedState = { ...oldState, ...newState };
+
+ if (!isEqualState(oldState, mergedState)) {
+ appStateContainer.set(mergedState);
+ }
+ },
+ getFilters: () => [
+ ...getFilters(globalStateContainer.getState()),
+ ...getFilters(appStateContainer.getState()),
+ ],
+ setFilters: (filterManager: FilterManager) => {
+ // global state filters
+ const globalFilters = filterManager.getGlobalFilters();
+ const globalFilterChanged = !isEqualFilters(
+ globalFilters,
+ getFilters(globalStateContainer.getState())
+ );
+ if (globalFilterChanged) {
+ globalStateContainer.set({ filters: globalFilters });
+ }
+ // app state filters
+ const appFilters = filterManager.getAppFilters();
+ const appFilterChanged = !isEqualFilters(
+ appFilters,
+ getFilters(appStateContainer.getState())
+ );
+ if (appFilterChanged) {
+ appStateContainer.set({ ...appStateContainer.getState(), ...{ filters: appFilters } });
+ }
+ },
+ // helper function just needed for testing
+ flushToUrl: (replace?: boolean) => stateStorage.flush({ replace }),
+ };
+}
+
+/**
+ * Helper function to compare 2 different filter states
+ */
+export function isEqualFilters(filtersA: Filter[], filtersB: Filter[]) {
+ if (!filtersA && !filtersB) {
+ return true;
+ } else if (!filtersA || !filtersB) {
+ return false;
+ }
+ return opensearchFilters.compareFilters(
+ filtersA,
+ filtersB,
+ opensearchFilters.COMPARE_ALL_OPTIONS
+ );
+}
+
+/**
+ * Helper function to compare 2 different states, is needed since comparing filters
+ * works differently, doesn't work with _.isEqual
+ */
+function isEqualState(stateA: AppState | GlobalState, stateB: AppState | GlobalState) {
+ if (!stateA && !stateB) {
+ return true;
+ } else if (!stateA || !stateB) {
+ return false;
+ }
+ const { filters: stateAFilters = [], ...stateAPartial } = stateA;
+ const { filters: stateBFilters = [], ...stateBPartial } = stateB;
+ return (
+ _.isEqual(stateAPartial, stateBPartial) &&
+ opensearchFilters.compareFilters(
+ stateAFilters,
+ stateBFilters,
+ opensearchFilters.COMPARE_ALL_OPTIONS
+ )
+ );
+}
+
+/**
+ * Helper function to return array of filter object of a given state
+ */
+function getFilters(state: AppState | GlobalState): Filter[] {
+ if (!state || !Array.isArray(state.filters)) {
+ return [];
+ }
+ return state.filters;
+}
+
+/**
+ * Helper function to return the initial app state, which is a merged object of url state and
+ * default state. The default size is the default number of successor/predecessor records to fetch
+ */
+function createInitialAppState(
+ defaultSize: string,
+ timeFieldName: string,
+ urlState: AppState
+): AppState {
+ const defaultState = {
+ columns: ['_source'],
+ filters: [],
+ predecessorCount: parseInt(defaultSize, 10),
+ sort: [timeFieldName, 'desc'],
+ successorCount: parseInt(defaultSize, 10),
+ };
+ if (typeof urlState !== 'object') {
+ return defaultState;
+ }
+
+ return {
+ ...defaultState,
+ ...urlState,
+ };
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap b/src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap
new file mode 100644
index 000000000000..f2abb590008c
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap
@@ -0,0 +1,230 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`DiscoverNoResults props queryLanguage supports lucene and renders doc link 1`] = `
+Array [
+
,
+
+
+
+
+
+
+
+
+ Refine your query
+
+
+ The search bar at the top uses OpenSearch’s support for Lucene
+
+ Query String syntax
+
+
+ (opens in a new tab or window)
+
+
+ . Here are some examples of how you can search for web server logs that have been parsed into a few fields.
+
+
+
+
+
+
+
+ Find requests that contain the number 200, in any field
+
+
+
+
+
+
+ 200
+
+
+
+
+
+
+ Find 200 in the status field
+
+
+
+
+
+
+ status:200
+
+
+
+
+
+
+ Find all status codes between 400-499
+
+
+
+
+
+
+ status:[400 TO 499]
+
+
+
+
+
+
+ Find status codes 400-499 with the extension php
+
+
+
+
+
+
+ status:[400 TO 499] AND extension:PHP
+
+
+
+
+
+
+ Find status codes 400-499 with the extension php or html
+
+
+
+
+
+
+ status:[400 TO 499] AND (extension:php OR extension:html)
+
+
+
+
+
+
+
,
+]
+`;
+
+exports[`DiscoverNoResults props timeFieldName renders time range feedback 1`] = `
+Array [
+
,
+
+
+
+
+
+
+
+
+ Expand your time range
+
+
+ One or more of the indices you’re looking at contains a date field. Your query may not match anything in the current time range, or there may not be any data at all in the currently selected time range. You can try changing the time range to one which contains data.
+
+
+
+
,
+]
+`;
diff --git a/src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss b/src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss
new file mode 100644
index 000000000000..1e625fa064e2
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss
@@ -0,0 +1,11 @@
+.dscHistogram__header--partial {
+ font-weight: $euiFontWeightRegular;
+ min-width: $euiSize * 12;
+}
+
+// Temporary override to inlined styles provided by ElasticCharts theming
+// Will be unnecessary when we migrate the histogram to a different rendering library:
+// https: //github.com/opensearch-project/OpenSearch-Dashboards/issues/4643
+.dscHistogram .echChartBackground {
+ background-color: inherit !important;
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/directives/_index.scss b/src/plugins/discover_legacy/public/application/angular/directives/_index.scss
new file mode 100644
index 000000000000..01f5bbb6fd57
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/directives/_index.scss
@@ -0,0 +1,2 @@
+@import "no_results";
+@import "histogram";
diff --git a/src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss b/src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss
new file mode 100644
index 000000000000..7ea945e820bf
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss
@@ -0,0 +1,3 @@
+.dscNoResults {
+ max-width: 1000px;
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js
new file mode 100644
index 000000000000..da0f4893b909
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js
@@ -0,0 +1,79 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import _ from 'lodash';
+// Debounce service, angularized version of lodash debounce
+// borrowed heavily from https://github.com/shahata/angular-debounce
+
+export function createDebounceProviderTimeout($timeout) {
+ return function (func, wait, options) {
+ let timeout;
+ let args;
+ let self;
+ let result;
+ options = _.defaults(options || {}, {
+ leading: false,
+ trailing: true,
+ invokeApply: true,
+ });
+
+ function debounce() {
+ self = this;
+ args = arguments;
+
+ const later = function () {
+ timeout = null;
+ if (!options.leading || options.trailing) {
+ result = func.apply(self, args);
+ }
+ };
+
+ const callNow = options.leading && !timeout;
+
+ if (timeout) {
+ $timeout.cancel(timeout);
+ }
+ timeout = $timeout(later, wait, options.invokeApply);
+
+ if (callNow) {
+ result = func.apply(self, args);
+ }
+
+ return result;
+ }
+
+ debounce.cancel = function () {
+ $timeout.cancel(timeout);
+ timeout = null;
+ };
+
+ return debounce;
+ };
+}
diff --git a/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts
new file mode 100644
index 000000000000..635ed560df40
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts
@@ -0,0 +1,148 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import sinon, { SinonSpy } from 'sinon';
+import angular, { auto, ITimeoutService } from 'angular';
+import 'angular-mocks';
+import 'angular-sanitize';
+import 'angular-route';
+
+// @ts-ignore
+import { createDebounceProviderTimeout } from './debounce';
+import { coreMock } from '../../../../../../../core/public/mocks';
+import { initializeInnerAngularModule } from '../../../../get_inner_angular';
+import { navigationPluginMock } from '../../../../../../navigation/public/mocks';
+import { dataPluginMock } from '../../../../../../data/public/mocks';
+import { initAngularBootstrap } from '../../../../../../opensearch_dashboards_legacy/public';
+
+describe('debounce service', function () {
+ let debounce: (fn: () => void, timeout: number, options?: any) => any;
+ let $timeout: ITimeoutService;
+ let spy: SinonSpy;
+
+ beforeEach(() => {
+ spy = sinon.spy();
+
+ initAngularBootstrap();
+
+ initializeInnerAngularModule(
+ 'app/discover',
+ coreMock.createStart(),
+ navigationPluginMock.createStartContract(),
+ dataPluginMock.createStartContract()
+ );
+
+ angular.mock.module('app/discover');
+
+ angular.mock.inject(($injector: auto.IInjectorService, _$timeout_: ITimeoutService) => {
+ $timeout = _$timeout_;
+
+ debounce = createDebounceProviderTimeout($timeout);
+ });
+ });
+
+ it('should have a cancel method', function () {
+ const bouncer = debounce(() => {}, 100);
+
+ expect(bouncer).toHaveProperty('cancel');
+ });
+
+ describe('delayed execution', function () {
+ const sandbox = sinon.createSandbox();
+
+ beforeEach(() => sandbox.useFakeTimers());
+ afterEach(() => sandbox.restore());
+
+ it('should delay execution', function () {
+ const bouncer = debounce(spy, 100);
+
+ bouncer();
+ sinon.assert.notCalled(spy);
+ $timeout.flush();
+ sinon.assert.calledOnce(spy);
+
+ spy.resetHistory();
+ });
+
+ it('should fire on leading edge', function () {
+ const bouncer = debounce(spy, 100, { leading: true });
+
+ bouncer();
+ sinon.assert.calledOnce(spy);
+ $timeout.flush();
+ sinon.assert.calledTwice(spy);
+
+ spy.resetHistory();
+ });
+
+ it('should only fire on leading edge', function () {
+ const bouncer = debounce(spy, 100, { leading: true, trailing: false });
+
+ bouncer();
+ sinon.assert.calledOnce(spy);
+ $timeout.flush();
+ sinon.assert.calledOnce(spy);
+
+ spy.resetHistory();
+ });
+
+ it('should reset delayed execution', function () {
+ const cancelSpy = sinon.spy($timeout, 'cancel');
+ const bouncer = debounce(spy, 100);
+
+ bouncer();
+ sandbox.clock.tick(1);
+
+ bouncer();
+ sinon.assert.notCalled(spy);
+ $timeout.flush();
+ sinon.assert.calledOnce(spy);
+ sinon.assert.calledOnce(cancelSpy);
+
+ spy.resetHistory();
+ cancelSpy.resetHistory();
+ });
+ });
+
+ describe('cancel', function () {
+ it('should cancel the $timeout', function () {
+ const cancelSpy = sinon.spy($timeout, 'cancel');
+ const bouncer = debounce(spy, 100);
+
+ bouncer();
+ bouncer.cancel();
+ sinon.assert.calledOnce(cancelSpy);
+ // throws if pending timeouts
+ $timeout.verifyNoPendingTasks();
+
+ cancelSpy.resetHistory();
+ });
+ });
+});
diff --git a/src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js b/src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js
new file mode 100644
index 000000000000..6acd0d680d4d
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js
@@ -0,0 +1,31 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+export { createDebounceProviderTimeout } from './debounce';
diff --git a/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js
new file mode 100644
index 000000000000..6b80caaa1f71
--- /dev/null
+++ b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js
@@ -0,0 +1,166 @@
+/*
+ * SPDX-License-Identifier: Apache-2.0
+ *
+ * The OpenSearch Contributors require contributions made to
+ * this file be licensed under the Apache-2.0 license or a
+ * compatible open source license.
+ *
+ * Any modifications Copyright OpenSearch Contributors. See
+ * GitHub history for details.
+ */
+
+/*
+ * Licensed to Elasticsearch B.V. under one or more contributor
+ * license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright
+ * ownership. Elasticsearch B.V. licenses this file to you under
+ * the Apache License, Version 2.0 (the "License"); you may
+ * not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+ */
+
+import $ from 'jquery';
+import _ from 'lodash';
+import { createDebounceProviderTimeout } from './debounce';
+
+const SCROLLER_HEIGHT = 20;
+
+/**
+ * This directive adds a fixed horizontal scrollbar to the bottom of the window that proxies its scroll events
+ * to the target element's real scrollbar. This is useful when the target element's horizontal scrollbar
+ * might be waaaay down the page, like the doc table on Discover.
+ */
+export function FixedScrollProvider($timeout) {
+ return {
+ restrict: 'A',
+ link: function ($scope, $el) {
+ return createFixedScroll($scope, $timeout)($el);
+ },
+ };
+}
+
+export function createFixedScroll($scope, $timeout) {
+ const debounce = createDebounceProviderTimeout($timeout);
+ return function (el) {
+ const $el = typeof el.css === 'function' ? el : $(el);
+ let $window = $(window);
+ let $scroller = $('