From 96af5358f2e968f47723f4d5a68ebd39270359bf Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Thu, 31 Aug 2023 11:30:28 -0700 Subject: [PATCH] [Manual Backport 2.x] Combined backport of Discover 2.0 commits (#4872) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * [Data Explorer] Merge to main (#4806) * Squashed commit of the following: commit 551e4328a21ed204eaa96039f878118dc82ee249 Author: Ashwin P Chandran Date: Wed Aug 23 14:31:52 2023 -0700 [Data Explorer] Merge main conflicts (#4800) * [Saved Object Service] Adds Repository Factory Provider (#4149) * Adds Repository Factory Provider Signed-off-by: Bandini Bhopi * add category option for context menus (#4144) * enhance grouping for context menu options Signed-off-by: David Sinclair * change log Signed-off-by: David Sinclair * remove type export Signed-off-by: David Sinclair * revert border and prevent destroy options Signed-off-by: David Sinclair * update comments for building panels Signed-off-by: David Sinclair * build panels tests and more comments Signed-off-by: David Sinclair * add category option for context menus Signed-off-by: David Sinclair * changelog Signed-off-by: David Sinclair * add order to groups Signed-off-by: David Sinclair * documentation, shorter copyrighty, minor cleanup Signed-off-by: David Sinclair * changelog Signed-off-by: David Sinclair --------- Signed-off-by: David Sinclair Signed-off-by: David Sinclair Signed-off-by: Ashish Agrawal Co-authored-by: Ashish Agrawal * [CCI] Add bluebird replaces for src/plugins/saved_objects (#4026) * Add bluebird replaces for src/plugins/saved_objects * Add changelog entry --------- Signed-off-by: Alexei Karikov * Validate and correct change log after 2.8 release (#4275) Signed-off-by: Su * [DEVELOPER_GUIDE] resolving links (#3989) * links Signed-off-by: Aigerim Suleimenova * new section for doveloper guide Signed-off-by: Aigerim Suleimenova * updates Signed-off-by: Aigerim Suleimenova * Update DEVELOPER_GUIDE.md Co-authored-by: Ashwin P Chandran Signed-off-by: Aigerim Suleimenova * Update DEVELOPER_GUIDE.md Signed-off-by: Josh Romero --------- Signed-off-by: Aigerim Suleimenova Signed-off-by: Josh Romero Co-authored-by: Ashwin P Chandran Co-authored-by: Josh Romero * Enable data client with sample data server side (#4268) * Enable data client with sample data server side * Add dataSourceId into savedObject Signed-off-by: Kristen Tian * Functional list, install uninstall Signed-off-by: Kristen Tian * add change log Signed-off-by: Kristen Tian * address comments Signed-off-by: Kristen Tian * add ut Signed-off-by: Kristen Tian --------- Signed-off-by: Kristen Tian * Upgrade the backport workflow (#4343) * Copy over the labels from the original PR * Label the backport PR with `autocut` * Label a PR that fails to backport Signed-off-by: Miki * Hide any output from `use_node` checking for Node compatibility (#4237) Signed-off-by: Miki Signed-off-by: Josh Romero Co-authored-by: Josh Romero * [Vis Colors] Update default color in TSVB to use `ouiPaletteColorBlind()[0]` (#4363) Signed-off-by: Manasvini B Suryanarayana * Add BWC tests for 2.7 and 2.8 (#4023) Signed-off-by: Manasvini B Suryanarayana * [Vis colors] Replace vis_type_timeline colors with `ouiPaletteColorBlind()` (#4366) Signed-off-by: Manasvini B Suryanarayana * [Lint] add custom stylelint rules and config (#4290) * [Lint] add custom stylelint rules and config Adding `@osd/stylelint-config` and `@osd/stylelint-plugin-stylelint` packages. These packages are utilized by OSD core and can be ran with the following: `yarn lint:style` Can be used to fix known non-compliant styling with the following: `yarn lint:style --fix` Can be used to audit untracked styling (based on defined rules) with the following: ``` export OUI_AUDIT_ENABLED=true yarn lint:style ``` --- `@osd/stylelint-config` Defines rules approved by UX and OSD core in JSON files and is added to OSD core. Within this commit is defined `colors.json` and `global_selectors.json`. `colors.json` defines a property that can be matched with a regex of a selector. If the selector is tracked it will have an `approved` value and a list of `rejected` values that UX knows if a value should be something. `global_selectors.json` defines a selector that if tracked, it will have an `approved` list of relative paths to files that can modify the global selector. --- `@osd/stylelint-plugin-stylelint` Creates the functionality that utilizes the JSON files within the `@osd/stylelint-config`. Within this commit is defined `no_custom_colors` and `no_modifying_global_selectors` rules. `no_custom_colors` checks if a property is a color property. It then utilizes a compliance engine helper to check the `colors.json` to see if the property being modified has a compliance rule available for the property for the specific selector and if it is not compliant. For example, if a selector matches `button` and we are trying to apply `background-color: red` to it. Stylelint will catch this and flag this as a known non-compliance issue since it knows that it should `$euiColorWarning`. If we pass `--fix` the property will be updated to be `$euiColorWarning`. If `OUI_AUDIT_ENABLED` is true it will catch all `background-color` being modified that is not being defined explicitly in `colors.json` `no_modifying_global_selectors` checks if a selector being modified is defined in `global_selectors.json` to see if a selector not defined in a specific list of approved files. For example, if a selector matches `#opensearch-dashboards-body` and it is being modified in `src/core/public/rendering/_base.scss`. Stylelint will catch this and flag this as a non-compliance issue. Since no other file should be modifying this selector. If we pass `--fix` the styling will be complete removed from the non-compliant file. --- Next steps: * Migrate these packages to OUI * Consider adding `yarn lint:style --fix` to the build release script here: https://github.com/opensearch-project/opensearch-build/blob/main/scripts/default/opensearch-dashboards/build.sh#L89 Issue: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4246 Signed-off-by: Kawika Avilla * fix to use find Signed-off-by: Kawika Avilla * Add regex matching and OUI modification lint Signed-off-by: Matt Provost * add changelog Signed-off-by: Josh Romero * address issues Signed-off-by: Kawika Avilla --------- Signed-off-by: Kawika Avilla Signed-off-by: Matt Provost Signed-off-by: Josh Romero Co-authored-by: Matt Provost Co-authored-by: Josh Romero * Fix linked deps resolution (#4342) Signed-off-by: Miki * Add configurable `defaults` to `uiSettings` (#4344) Also now: * `theme:darkMode` and `theme:version` can be configured via `defaults` * unauthenticated users are no longer forced to light mode Signed-off-by: Miki * Refactor hardcode color to use OUI in `maps_legacy` (#4294) * Refactor color to use OUI * Pull theme value from actual active theme * Update changelog --------- Signed-off-by: Matt Provost Signed-off-by: Josh Romero Co-authored-by: Anan Zhuang Co-authored-by: Josh Romero * Remove broken flot documentation link for Ruby API (#4384) * Remove broken documentation link for Ruby API https://apidock.com/ruby/Time/to_i is currently down for maintenance But we don't need this link anyway, because it's talking about standard methods. And we plan to deprecate flot_charts altogether: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4267 Signed-off-by: Josh Romero * remove leftover link brackets Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * [CCI] Fix relationships header overflow (#4070) * Fix relationships header overflow (#3967) Co-authored-by: Andrey Myssak Signed-off-by: Sergey Myssak * Replace relationships css file with oui classname (#3967) Co-authored-by: Andrey Myssak Signed-off-by: Sergey Myssak * Make title overflow wrap instead of truncation (#3967) Co-authored-by: Andrey Myssak Signed-off-by: Sergey Myssak * Remove icon from flyout header and wrap title Signed-off-by: Josh Romero --------- Signed-off-by: Sergey Myssak Signed-off-by: Josh Romero Co-authored-by: Andrey Myssak Co-authored-by: Josh Romero * Refactor color maps to use OUI color palettes (#4293) * Remove color_util Signed-off-by: Matt Provost * Update changelog Signed-off-by: Matt Provost * Revert "Remove color_util" This reverts commit 9ca9c56e6bc5d2750971e04a2df7028f5c472b8b. Signed-off-by: Matt Provost * Refactor color maps to use Oui color palettes Signed-off-by: Matt Provost * Update changelog pt 2: electric boogaloo Signed-off-by: Matt Provost * Make gradients look better Signed-off-by: Matt Provost * Fix typescript ignore Signed-off-by: Matt Provost * Fix tests Signed-off-by: Matt Provost * Add todo followup Signed-off-by: Matt Provost --------- Signed-off-by: Matt Provost Signed-off-by: Josh Romero Co-authored-by: Josh Romero * [CCI] Remove unused tags in the navigation plugin (#3964) * Remove unused tags in the navigation plugin (#3962) Signed-off-by: Andrey Myssak * Update CHANGELOG.md (#3962) Signed-off-by: Andrey Myssak --------- Signed-off-by: Andrey Myssak Signed-off-by: Manasvini B Suryanarayana Co-authored-by: Manasvini B Suryanarayana * [Stylelint] Add invalid properties rule (#4374) * Add invalid properties rule Signed-off-by: Matt Provost * Update changelog Signed-off-by: Matt Provost * Rename old variable Signed-off-by: Matt Provost * Add types for configs Signed-off-by: Matt Provost * Rename rule to no_restricted_properties Signed-off-by: Matt Provost * Refactor duplicate functions into generic one Signed-off-by: Matt Provost * Add type definitions Signed-off-by: Matt Provost * Add some documentation about supported config types Signed-off-by: Matt Provost * Update changelog Signed-off-by: Matt Provost * Optchain instead of unwrapping source file Signed-off-by: Matt Provost --------- Signed-off-by: Matt Provost * Migrate from legacy elasticsearch client to opensearch-js client in `osd-opensearch-archiver` package (#4142) Signed-off-by: Manasvini B Suryanarayana Signed-off-by: Josh Romero Co-authored-by: Josh Romero * Chore (deps): Bump OUI to 1.1.2 to add anomoly detection icon (#4408) * Chore (deps): Bump OUI to 1.1.1 to add anomoly detection icon Signed-off-by: Josh Romero * update changelog Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Excludes broken sass-lang link from Link checker (#4415) * fix: Link checker exclude Signed-off-by: Ashwin P Chandran * just ignore the broken URL for now Signed-off-by: Ashwin P Chandran --------- Signed-off-by: Ashwin P Chandran * [Vis Colors] Update legacy seed colors to use `ouiPaletteColorBlind()` (#4348) Signed-off-by: Manasvini B Suryanarayana * Release notes for 1.3.11 (#4423) (#4427) * chore: Adds 1.3.11 release notes * chore: Adds 1.3.11 release notes updated --------- (cherry picked from commit 63908e83196d6fa09a4230897c48ebfacebfbd25) Signed-off-by: Ashwin P Chandran Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] * Updates Release notes for 1.3.11 (#4428) (#4430) * chore: Adds 1.3.11 release notes * chore: Adds 1.3.11 release notes updated * chore: Adds 1.3.11 release notes adds skipped changelog PR --------- (cherry picked from commit 7de483fa3c02e2c526d85db1ebdbcd57f63ce192) Signed-off-by: Ashwin P Chandran Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] * [Stylelint] Add typing to Stylelint rules (#4392) * Add typing to Stylelint rules Signed-off-by: Matt Provost * Extract get color property parent into function Signed-off-by: Matt Provost * Optchain instead of unwrapping source file Signed-off-by: Matt Provost --------- Signed-off-by: Matt Provost Co-authored-by: Josh Romero * [Vis colors] Update legacy mapped colors in charts plugin to use ouiPaletteColorBlind() (#4398) Signed-off-by: Manasvini B Suryanarayana Co-authored-by: Ashwin P Chandran * [CVE-2022-25883] Resolve semver to 7.5.3 and remove unused package (#4411) In this PR, we resolve semver to 7.5.3 from 5.x, 6.x and 7.x. There are breaking changes in API in 7.5.3 compared to 5.x/6.x. However, these API changes do not impact any usages. Issue Resolve https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4370 Signed-off-by: ananzh Co-authored-by: Ashwin P Chandran * Enable sample data with Multiple datasource frontend (#4412) Signed-off-by: Kristen Tian * Feature (home): Add vis audit sample dashboard (#4339) * Feature (home): Add vis audit sample dashboard Signed-off-by: Josh Romero * add changelog Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Add fonts for previewing the new theme (#4381) Also: * Expose default font-family values as CSS variables * Make default fonts load from OSD and not OUI * Make fonts differentiable across themes * Use the theme font in the Legacy Editor Signed-off-by: Miki Co-authored-by: Josh Romero * possible fix for flakey ci9 test (#4450) * possible fix for flakey ci9 test Signed-off-by: Ashwin P Chandran * fix syntax Signed-off-by: Ashwin P Chandran --------- Signed-off-by: Ashwin P Chandran * Chore (VisBuilder): Update icon to use OUI icon (#4446) * Chore (VisBuilder): Update icon to use OUI icon Fixes https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3691 Signed-off-by: Josh Romero * update changelog Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Relocate tutorials imagery (#4382) Signed-off-by: Miki * Update main menu to display 'Dashboards' for consistency (#4453) * Update main menu to display 'Dashboards' for consistency. Fixes #4296 This resolves the inconsistency highlighted in issue #4296 (https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4296). The decision to make this change was made in issue #68 (https://github.com/opensearch-project/ux/issues/68)." * Update OpenSearch Dashboard to OpenSearch Dashboards for consistence * CHANGELOG.md update --------- Signed-off-by: Danila Gulderov * Adding Matt as a maintainer (#4469) * Chore: Add Matt as a maintainer * Adds changelog --------- Signed-off-by: Ashwin P Chandran * Add `color-scheme` to the root styling (#4477) Signed-off-by: Miki * Refactor hardcoded color to use OUI in `region_map` (#4299) * Refactor hardcoded color to use OUI Signed-off-by: Matt Provost * Update changelog Signed-off-by: Matt Provost --------- Signed-off-by: Matt Provost Signed-off-by: Josh Romero Co-authored-by: Josh Romero * [Console] Convert lib/mappings to TypeScript (#4008) * Convert mappings.js to TypeScript Signed-off-by: Sirazh Gabdullin * Convert mappings.test.js to TypeScript Signed-off-by: Sirazh Gabdullin * Update CHANGELOG.md Signed-off-by: Sirazh Gabdullin * Add test for getTypes with multi-index mode Signed-off-by: Sirazh Gabdullin * type update Signed-off-by: Sirazh Gabdullin * update typing Signed-off-by: Sirazh Gabdullin * CHANGELOG fix Signed-off-by: Sirazh Gabdullin * Changelog update Signed-off-by: Sirazh Gabdullin * Update Changelog Signed-off-by: Sirazh Gabdullin --------- Signed-off-by: Sirazh Gabdullin Signed-off-by: Kawika Avilla Signed-off-by: Josh Romero Signed-off-by: Anan Zhuang Co-authored-by: Kawika Avilla Co-authored-by: Qingyang(Abby) Hu Co-authored-by: Ashwin P Chandran Co-authored-by: Anan Zhuang Co-authored-by: Josh Romero * Enable plugins to augment visualizations with additional data and context (#4361) Signed-off-by: Tyler Ohlsen * Update header logo selection logic and assets (#4383) Signed-off-by: Miki * [CI] Split build and verify into parallel jobs (#4467) * Also made linter and NOTICE validation run only on Linux Signed-off-by: Miki * New management overview page and rename stack management to dashboard management (#4287) Support navigation changes for administrative features. This change includes * Rename stack management to Dashboard management * Add new management overview page * Replace Stack Management to Management overview page on home app categories page * Make home plugin optional for managemnet overview Issue Resolved: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4132 --------- Signed-off-by: Hailong Cui Co-authored-by: Josh Romero Co-authored-by: Ashwin P Chandran * Retain the original sample data interface (#4526) Signed-off-by: Kristen Tian * [Vis Augmenter Add UT for few fns (#4516) * Add UT for few fns Signed-off-by: Tyler Ohlsen * add changelog entry Signed-off-by: Tyler Ohlsen --------- Signed-off-by: Tyler Ohlsen * Bump tough-cookie from 4.0.0 to 4.1.3 (#4531) Bumps [tough-cookie](https://github.com/salesforce/tough-cookie) from 4.0.0 to 4.1.3. - [Release notes](https://github.com/salesforce/tough-cookie/releases) - [Changelog](https://github.com/salesforce/tough-cookie/blob/master/CHANGELOG.md) - [Commits](https://github.com/salesforce/tough-cookie/compare/v4.0.0...v4.1.3) --- updated-dependencies: - dependency-name: tough-cookie dependency-type: direct:development ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZilongX <99905560+ZilongX@users.noreply.github.com> * [Vis Augmenter] Update base vis height in view events flyout (#4535) * [Vis Augmenter] Update base vis height in view events flyout Signed-off-by: Tyler Ohlsen * Update CHANGELOG Signed-off-by: Tyler Ohlsen --------- Signed-off-by: Tyler Ohlsen * Dashboard De-Angularization (#4502) Removes Angular from the `plugins/dashboard` utilizing React. This includes refactoring to address changes in state management but will require fast follow to address none blocking issues raised which can be found here: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3365 Partially Resolves: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/3365 --- * Replace angular modules with react components * Use React to start up the dashboard app, and use react routing to configure basic routing for dashboard plugin. * [Dashboard De-Angular] Render dashboard listing page (#4015) * https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4000 * Render the dashboard listing component with basic functionalities: * When there is no dashboard, render the empty dashboard page * When there are dashboards, show the dashboard listing table * When click on the dashboard, show the editor page * Delete the dashboards when selected * Can use search bar to filter dashboard * Basic top nav bar for dashboard (#4108) * Basic top nav bar for dashboard * This PR will add basic structure to render top nav bar, including a basic implementation for dashboard app state. * Render editor page with basic nav actions (#4213) * Added dashboard embeddable container to render the dashboard editor page. * Add visualization (#4257) * Add and save visualization to dashboard * Render empty screen (#4346) * Render empty screen with correct edit and view view when creating a new dashboard. * Fix routing (#4357) * Fix the edit action routing on the dashboard listing page; also fix routing when the route has no match. Add '_g' param to the URL on both dashboard listing page and dashboard editor page. * [Dashboard De-Angular] Enable time filter functionalities (#4364) * Fix time filter on dashboard * Save dashboard with time restore * Dashboard be able to save query and app filter * Enable functional test for dashboard * Fix comments and add ui bootstrap back * No index pattern routing (#4401) * Should redirect to stack management page if there is no index pattern detected. * Add embed mode and other URL param options (#4407) * UI should render based on URL param options * [Dashboard De-Angular] Add dashboard class for discard flow (#3563) * Add Dashboard class for state managing * isDirty working for cancel flow * [Dashboard De-Angular] Add breadcrumb with view/edit mode and unsaved flow (#4479) * set isDirty back to false when saving successfully * Breadcrumb working * change to dashboards in breadcrumb * [Dashboard De-Angular] Enable URL title param for initial filter on dashboard listing (#4480) * Fix dashboard listing functional test * Can filter dashboards using URL title param * Fix the functional tests * [Dashboard De-Angular] Fix dashboard save and back button functional test (#4491) * fix copy on save and functional test 5 * Fix back button navigation * Fix version migration for panels * Fix conversions between saved panel and container panel type * Fix redundant browser update by re-structure app state and global state sync logic in order for back button to work, also fix the corresponding functional test * migration version * Add initialization dirty flag and fix full mode filter bar * [Dashboard De-Angular] Fix filter and query related functional tests in functional test group 3 (#4495) * fix index pattern window * Fix time filter and query related functional test in group 3 * [Dashboard De-Angular] Fix remaining functional tests (#4496) * fix dashboard state function test in group 4 * fix expanding panel * fix dashboard listing delete (#4508) * [Dashboard De-Angular] Initial clean up and linter fix (#4511) * Clean up linter issues * Add changelog and other fixes * [Dashboard De-Angular] Cypress fix (#4521) * fix cypress * refactor scss files * delete old unit test for state management * Refactor app state and cleanup unused imports (#4504) * Clean up app state for Dashboards plugin. * Removes the dashboard container hook in place of a single dashboard app state container * Still recovers some follow-ups and clean up * Skips test for rendering of a legacy test. * Set dashboard container functions and fix license headers (#4540) * Set dashboard container after defining functions * renderEmpty was not being set prior to the current container was being dispatched. * fix up license headers for new files * add TODOs from PR Signed-off-by: abbyhu2000 Signed-off-by: Kawika Avilla Co-authored-by: Qingyang(Abby) Hu Co-authored-by: Kawika Avilla Co-authored-by: Miki * Add v2.9.0 release notes (#4550) Signed-off-by: Manasvini B Suryanarayana * Fix line to vega conversion bug (#4554) * Fix line to vega conversion bug Signed-off-by: Ashish Agrawal * Update CHANGELOG and release notes Signed-off-by: Ashish Agrawal * Address comments Signed-off-by: Ashish Agrawal * Add more details to comment Signed-off-by: Ashish Agrawal * Not a released changed, so no need to document Signed-off-by: Ashish Agrawal --------- Signed-off-by: Ashish Agrawal * Fix Node.js download link (#4556) https://mirrors.nodejs.org/ is no longer available which prevents the ability from release builds being made. Restoring back the original link. Follow-up should be considered about a caching mechanism and param to the CLI to utilize another link or path. Signed-off-by: Miki * [Vis Augmenter] Fix stats API visualization ID bug (#4565) * Fix vis augmenter stats api vis ID map Signed-off-by: Tyler Ohlsen * Update changelog Signed-off-by: Tyler Ohlsen * Update tests Signed-off-by: Tyler Ohlsen * Remove changelog entry Signed-off-by: Tyler Ohlsen --------- Signed-off-by: Tyler Ohlsen * [CCI] Add new or remove extra tags and styles in `saved_objects_management` plugin (#4069) * Add new or remove extra tags and styles (#3967) Co-authored-by: Andrey Myssak Signed-off-by: Sergey Myssak * Remove extra Fragment tags (#3967) Co-authored-by: Andrey Myssak Signed-off-by: Sergey Myssak --------- Signed-off-by: Sergey Myssak Signed-off-by: Josh Romero Co-authored-by: Andrey Myssak Co-authored-by: Josh Romero * Add documentation to vis_augmenter (#4527) * Add documentation to vis_augmenter Signed-off-by: Tyler Ohlsen * Add more Signed-off-by: Tyler Ohlsen * Add section about settings Signed-off-by: Tyler Ohlsen * Minor nits Signed-off-by: Tyler Ohlsen * More nits Signed-off-by: Tyler Ohlsen * Update changelog Signed-off-by: Tyler Ohlsen * Update CHANGELOG.md Signed-off-by: Josh Romero --------- Signed-off-by: Tyler Ohlsen Signed-off-by: Josh Romero Co-authored-by: Josh Romero * [Vis Augmenter] Fix bug of undefined tooltip when all plugin layers are empty (#4577) Signed-off-by: Tyler Ohlsen * [Dashboards] restructure folder to be more cohesive with the project (#4575) Fast follow to: https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4502 * Cleanup to just use `utils` * Move empty screen into embeddables folder with related features * Get rid of the export file in favor matching other plugins * Combine folders of components that are related, e.g., top_nav Issue: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4574 Signed-off-by: Kawika Avilla * chore (home): Update visual consistency dashboard TSVB colors (#4501) To be consistent with https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4363 The TSVB visualization saved object includes the default color value, so #4363 changed it for all new TSVB visualizations, but any existing saved objects, like those in the visualization consistency dashboard, need to be updated manually. Signed-off-by: Josh Romero * [VisLib] Replace legend color palette with OUI color palette (#4365) * [VisLib] Replace legend color palette with OUI color palette Replace hard-coded palette (of 8 colors with 7 variations each) with rotations from euiPaletteColorBlind (of 10 colors with 7 variations each) Fixes https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4321 Signed-off-by: Josh Romero * update changelog Signed-off-by: Josh Romero * Update legend unit test Signed-off-by: Josh Romero * Update hard-coded legend color values in functional tests Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Bump word-wrap from 1.2.3 to 1.2.4 (#4589) * Bump word-wrap from 1.2.3 to 1.2.4 Bumps [word-wrap](https://github.com/jonschlinkert/word-wrap) from 1.2.3 to 1.2.4. - [Release notes](https://github.com/jonschlinkert/word-wrap/releases) - [Commits](https://github.com/jonschlinkert/word-wrap/compare/1.2.3...1.2.4) --- updated-dependencies: - dependency-name: word-wrap dependency-type: indirect ... Signed-off-by: dependabot[bot] * Add changelog Signed-off-by: Josh Romero --------- Signed-off-by: dependabot[bot] Signed-off-by: Josh Romero Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: Josh Romero * Removed KUI usage in maps_legacy plugin (#3998) * Removed KUI usage from map_legacy plugin Signed-off-by: Malika Shamgunova * Removed KUI icon from maps_legacy plugin Signed-off-by: Malika Shamgunova * import Signed-off-by: Malika Shamgunova * Added to CHNAGELOG.md Signed-off-by: Malika Shamgunova * Updated CHANGELOG.md Signed-off-by: Malika Shamgunova * Update in CHANGELOG.md Signed-off-by: Malika Shamgunova * Upgrade the backport workflow (#4343) * Copy over the labels from the original PR * Label the backport PR with `autocut` * Label a PR that fails to backport Signed-off-by: Miki * Hide any output from `use_node` checking for Node compatibility (#4237) Signed-off-by: Miki Signed-off-by: Josh Romero Co-authored-by: Josh Romero * Resolved merge conflicts Signed-off-by: Malika Shamgunova * Apply suggestions from code review Resolve changelog conflicts Signed-off-by: Josh Romero --------- Signed-off-by: Malika Shamgunova Signed-off-by: Anan Zhuang Signed-off-by: Miki Signed-off-by: Josh Romero Co-authored-by: Qingyang(Abby) Hu Co-authored-by: Anan Zhuang Co-authored-by: Miki Co-authored-by: Josh Romero * Feature (home): Update visual consistency sample dashboard with more vis (#4581) * Feature (home): Update visual consistency sample dashboard with more vis Signed-off-by: Josh Romero * Use the correct playground URL Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Optimize `augment-vis` saved obj searching by adding arg to saved obj client (#4595) Signed-off-by: Tyler Ohlsen * [Markdown] Replace custom css styles and native html with OUI. (#4390) * replace custom styling Signed-off-by: Sirazh Gabdullin * update CHANGELOG Signed-off-by: Sirazh Gabdullin * Update CHANGELOG.md Co-authored-by: Miki Signed-off-by: Josh Romero --------- Signed-off-by: Sirazh Gabdullin Signed-off-by: Josh Romero Co-authored-by: Josh Romero Co-authored-by: Miki * Add resource ID filtering in fetch `augment-vis` obj queries (#4608) Signed-off-by: Tyler Ohlsen * Fix (styles): Make ace code editor themes consistent (#4609) * Fix (styles): Make ace code editor themes consistent Use "textmate" theme everywhere Update override selector to apply to root, to style portaled components, too (such as filter editor) Signed-off-by: Josh Romero * add changelog Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Enable theme-switching via Advanced Settings to preview the Next theme (#4475) * Enable theme-switching via Advanced Settings to preview the Next theme Also: * Remove the unused "v8 (beta)" theme * Remove the overrides that locked in the light default theme * Correct theme version selection logic in the legacy UI renderer * Use the latest preview of OUI Signed-off-by: Miki * Enhance ComboBox handling in functional tests Signed-off-by: Miki --------- Signed-off-by: Miki * [Console] Converted all ```/lib/autocomplete/**/*.js``` files to typescript (#4148) Major changes are: * Convert autocomplete part to TS * reafactor and improve typing * clean comments for compileBodyDescription * refactor getTemplate --------- Signed-off-by: Sirazh Gabdullin Signed-off-by: Josh Romero Co-authored-by: Josh Romero * [Console] Convert all non-autocomplete lib files to typescript (#4150) * Convert non-autocomplete part to TS Signed-off-by: Sirazh Gabdullin * Update CHANGELOG.md Signed-off-by: Sirazh Gabdullin * refactor and improve typing Signed-off-by: Sirazh Gabdullin --------- Signed-off-by: Sirazh Gabdullin * [Table Visualization] Replace div containers with OuiFlex components (#4272) * replace div containers with OuiFlex Signed-off-by: Sirazh Gabdullin * Update test to not include removed class Signed-off-by: Sirazh Gabdullin * Update Changelog Signed-off-by: Sirazh Gabdullin * wrap table in FlexItem Signed-off-by: Sirazh Gabdullin --------- Signed-off-by: Sirazh Gabdullin Signed-off-by: Josh Romero Co-authored-by: Josh Romero * Chore(CHANGELOG): Update to CHANGELOG post 2.9 release (#4625) Signed-off-by: Manasvini B Suryanarayana * Refactor/remove breadcrumb styling main (#4621) * chore(chrome): Remove OSD breadcrumb styling and classes Now that the breacrumb styling is coming from OUI Signed-off-by: Josh Romero * chore (chrome): Remove other remnants of breadcrumb styling Essentially reverting: - https://github.com/opensearch-project/OpenSearch-Dashboards/pull/1954 - https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2085 Signed-off-by: Josh Romero * Update snapshot Signed-off-by: Josh Romero * add changelog Signed-off-by: Josh Romero * Update header snapshot Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Feat (home): Add remaining vis type examples (#4619) * Feat (home): Add remaining vis type examples - Add and improve vega equivalents Signed-off-by: Josh Romero * add changelog Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * [i18n] fix generation scripts (#4252) * [i18n] fix generation scripts Gave file permissions to the i18n scripts Generated: https://github.com/opensearch-project/dashboards-i18n/pull/25 With: ``` ./scripts/use_node scripts/i18n_extract.js --output-dir plugins/dashboards-i18n/translations/ ``` Had to fix some issues generating the scripts for example incorrect namespacing. Issue: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/867 Signed-off-by: Kawika Avilla * update snapshot Signed-off-by: Kawika Avilla --------- Signed-off-by: Kawika Avilla Signed-off-by: Qingyang(Abby) Hu Co-authored-by: Ashwin P Chandran Co-authored-by: Qingyang(Abby) Hu * Feat (Discover): Update styles to be compatible with next theme (#4644) * Feat (Discover): Update styles to be compatible with next theme 1. Change doc table source highlight to use standard color functions instead of transparency 2. Build KUI CSS for next themes in OUI, and conditionally load to ensure surrounding doc view styled correctly 3. Update histogram styles to follow design guidance and avoid theme-specific imports Signed-off-by: Josh Romero * add changelog Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Fix (Legacy Maps): Add necessary specificity for dark mode style over… (#4658) * Fix (Legacy Maps): Add necessary specificity for dark mode style overrides - wrap override styles in visualization selector - remove temp SASS var - update attribution background to be opaque for consistency with other controls - add raster tile filter to map tiles in dark mode, since OpenSearch doesn't serve dark mode raster tilesets - Fix tooltip behavior so that tooltip only appears when there's content to render fixes https://github.com/opensearch-project/dashboards-maps/issues/449, fixes https://github.com/opensearch-project/dashboards-maps/issues/450 Signed-off-by: Josh Romero * update changelog Signed-off-by: Josh Romero * Fix spacing linter issues Signed-off-by: Josh Romero * Update font-family overrides to use CSS var definitions Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Bump `node-sass` to a version that uses a newer `libsass` (#4651) Also: * bump `sass-loader` to a version that supports this `node-sass` Signed-off-by: Miki * Update webpack environment targets (#4649) Also: * Bump browserslist * Widen browser support matrix * Update browser typescript target to ES2018 * Bump `autoprefixer` but remove its usage as it spams the logs about it being unnecessary Signed-off-by: Miki * Reduce the amount of comments in compiled CSS (#4648) Signed-off-by: Miki * units test for utils folder (#4641) Signed-off-by: abbyhu2000 * Fix --font-text CSS var usage and add more leaflet font overrides (#4674) * Add missing quotes to --font-text CSS vars Signed-off-by: Josh Romero * Fix usage of --font-text CSS var Remove quotes Add additional leaflet font family overrides Move legacy map custom button styles to separate file Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Add saved objects service status api (#4696) Signed-off-by: Bandini Bhopi * Chore: Add 1.3.12 release notes (#4690) (#4709) * Chore: Add 1.3.12 release notes and update changelog Signed-off-by: Josh Romero * Add entries for late-resolving CVEs Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero (cherry picked from commit 492b055041dc02910b431c3d56fd98d20acac099) Signed-off-by: github-actions[bot] Co-authored-by: github-actions[bot] * Test (linkchecker): Exclude checking dead link (#4720) Link in copyright header must be preserved, but we shouldn't check for it Signed-off-by: Josh Romero * [@osd/pm] Automate multi-target bootstrap and build (#4650) Also: * build @osd/std for multiple targets * convert @osd/i18n and @osd/ace from custom build scripts to targeted build process * have @osd/optimizer ignore already built artifacts Signed-off-by: Miki * [chore] Update CHANGELOG.md with 1.3.12 release (#4739) Signed-off-by: Manasvini B Suryanarayana * Feat (home): Remove color customizations from sample dashboards (#4741) * Feat (home): Remove color customizations from sample dashboards So that they better reflect default color palettes and theming - Remove `uiStateJSON` `vis.colors` and `vis.defaultColors` customizations - Remove `embeddableConfig` `vis.colors` and `vis.defaultColors` cusomtizations from dashboard `panelsJSON` - TSVB visualizations require explicit color picker values - use only defined categorical palette values - Replace named and explicit colors in vega spec with OUI palette values - Add missing `time_field` and `index_pattern` properties to TSVB configs that were missing them - Update vega/vega-lite default config to use `OuiTextColor` for `text` marks Signed-off-by: Josh Romero * update changelog Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * Bump version of tinygradient from 0.4.3 to 1.1.5 (#4742) * Bump version of tinygradient from 0.4.3 to 1.1.5 Signed-off-by: Matt Provost * Update changelog Signed-off-by: Matt Provost * Update to use carat version Signed-off-by: Matt Provost --------- Signed-off-by: Matt Provost * Remove visualization editor background (#4719) * Remove vis editor background Signed-off-by: Matt Provost * Update changelog Signed-off-by: Matt Provost --------- Signed-off-by: Matt Provost Signed-off-by: Josh Romero Co-authored-by: Josh Romero * [Dashboard De-Angular] Add unit tests for dashboard_listing and dashboard_top_nav (#4640) * Add unit tests Signed-off-by: abbyhu2000 * fix other unit tests and update snapshots Signed-off-by: abbyhu2000 * update snapshots Signed-off-by: abbyhu2000 * fix osdUrlStateStorage Signed-off-by: abbyhu2000 * fix snapshots Signed-off-by: abbyhu2000 --------- Signed-off-by: abbyhu2000 * [Stylelint] Add no_restricted_values linter rule (#4413) * Add no_restricted_values linter rule Signed-off-by: Matt Provost * Update changelog Signed-off-by: Matt Provost * Add explanation for file based configs Signed-off-by: Matt Provost * Update error messages Signed-off-by: Matt Provost --------- Signed-off-by: Matt Provost Signed-off-by: Matt Provost Co-authored-by: Ashwin P Chandran Co-authored-by: Anan Zhuang Co-authored-by: Josh Romero * Add @curq as co-maintainer (#4760) * Add @curq as co-maintainer Signed-off-by: Josh Romero * add changelog Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * [Tests] Add BWC tests for 2.9 and 2.10 versions (#4762) Signed-off-by: Manasvini B Suryanarayana * Updated README.md (#4769) Changed a Typo in README.md file Signed-off-by: Suyash Srivastava * Refactor logo usage (#4702) Also: * Move logos to a central location * Make the loading spinner color-scheme-aware * Recreate `OverviewPageHeader`, `HomeIcon`, `HeaderLogo`, `SolutionTitle`, `Welcome`, `Overview` tests * Enhance `ExitFullScreenButton`, `Header` tests * Tinified favicon assets Signed-off-by: Miki * fixes merge conflict resolution error Signed-off-by: Ashwin P Chandran * fixes snapshot Signed-off-by: Ashwin P Chandran --------- Signed-off-by: Bandini Bhopi Signed-off-by: David Sinclair Signed-off-by: David Sinclair Signed-off-by: Ashish Agrawal Signed-off-by: Alexei Karikov Signed-off-by: Su Signed-off-by: Aigerim Suleimenova Signed-off-by: Josh Romero Signed-off-by: Kristen Tian Signed-off-by: Miki Signed-off-by: Manasvini B Suryanarayana Signed-off-by: Kawika Avilla Signed-off-by: Matt Provost Signed-off-by: Sergey Myssak Signed-off-by: Andrey Myssak Signed-off-by: Ashwin P Chandran Signed-off-by: github-actions[bot] Signed-off-by: ananzh Signed-off-by: Danila Gulderov Signed-off-by: Sirazh Gabdullin Signed-off-by: Anan Zhuang Signed-off-by: Tyler Ohlsen Signed-off-by: Hailong Cui Signed-off-by: dependabot[bot] Signed-off-by: abbyhu2000 Signed-off-by: Ashish Agrawal Signed-off-by: Malika Shamgunova Signed-off-by: Qingyang(Abby) Hu Signed-off-by: Matt Provost Signed-off-by: Suyash Srivastava Co-authored-by: Bandini <63824432+bandinib-amzn@users.noreply.github.com> Co-authored-by: David Sinclair <24573542+sikhote@users.noreply.github.com> Co-authored-by: Ashish Agrawal Co-authored-by: Alexei Karikov Co-authored-by: Zhongnan Su Co-authored-by: Aigerim Suleimenova Co-authored-by: Josh Romero Co-authored-by: Kristen Tian <105667444+kristenTian@users.noreply.github.com> Co-authored-by: Miki Co-authored-by: Manasvini B Suryanarayana Co-authored-by: Kawika Avilla Co-authored-by: Matt Provost Co-authored-by: Anan Zhuang Co-authored-by: Sergey Myssak Co-authored-by: Andrey Myssak Co-authored-by: Andrey Myssak <40265277+andreymyssak@users.noreply.github.com> Co-authored-by: opensearch-trigger-bot[bot] <98922864+opensearch-trigger-bot[bot]@users.noreply.github.com> Co-authored-by: github-actions[bot] Co-authored-by: gulderov Co-authored-by: Sirazh Gabdullin Co-authored-by: Qingyang(Abby) Hu Co-authored-by: Tyler Ohlsen Co-authored-by: Hailong Cui Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> Co-authored-by: ZilongX <99905560+ZilongX@users.noreply.github.com> Co-authored-by: Miki Co-authored-by: miamia1999 <123776561+miamia1999@users.noreply.github.com> Co-authored-by: Suyash Srivastava commit 8c2e8bf659bf7a3a8fad88c62f48ce350c4c5ce5 Author: Ashwin P Chandran Date: Tue Aug 22 19:29:16 2023 -0700 [Data Explorer] Altrnate result state views + fixes (#4764) * multiple minor fixes and no results view Signed-off-by: Ashwin P Chandran * Apply suggestions from code review Co-authored-by: Miki Signed-off-by: Ashwin P Chandran --------- Signed-off-by: Ashwin P Chandran Signed-off-by: Ashwin P Chandran Co-authored-by: Miki commit c1ba8cd2f8de4082bc0849a3b57d7634d47c5551 Author: Qingyang(Abby) Hu Date: Mon Aug 21 16:23:13 2023 -0700 Merge main to feature (#4789) * [Saved Object Service] Adds Repository Factory Provider (#4149) * Adds Repository Factory Provider Signed-off-by: Bandini Bhopi * add category option for context menus (#4144) * enhance grouping for context menu options Signed-off-by: David Sinclair * change log Signed-off-by: David Sinclair * remove type export Signed-off-by: David Sinclair * revert border and prevent destroy options Signed-off-by: David Sinclair * update comments for building panels Signed-off-by: David Sinclair * build panels tests and more comments Signed-off-by: David Sinclair * add category option for context menus Signed-off-by: David Sinclair * changelog Signed-off-by: David Sinclair * add order to groups Signed-off-by: David Sinclair * documentation, shorter copyrighty, minor cleanup Signed-off-by: David Sinclair * changelog Signed-off-by: David Sinclair --------- Signed-off-by: David Sinclair Signed-off-by: David Sinclair Signed-off-by: Ashish Agrawal Co-authored-by: Ashish Agrawal * [CCI] Add bluebird replaces for src/plugins/saved_objects (#4026) * Add bluebird replaces for src/plugins/saved_objects * Add changelog entry --------- Signed-off-by: Alexei Karikov * Validate and correct change log after 2.8 release (#4275) Signed-off-by: Su * [DEVELOPER_GUIDE] resolving links (#3989) * links Signed-off-by: Aigerim Suleimenova * new section for doveloper guide Signed-off-by: Aigerim Suleimenova * updates Signed-off-by: Aigerim Suleimenova * Update DEVELOPER_GUIDE.md Co-authored-by: Ashwin P Chandran Signed-off-by: Aigerim Suleimenova * Update DEVELOPER_GUIDE.md Signed-off-by: Josh Romero --------- Signed-off-by: Aigerim Suleimenova Signed-off-by: Josh Romero Co-authored-by: Ashwin P Chandran Co-authored-by: Josh Romero * Enable data client with sample data server side (#4268) * Enable data client with sample data server side * Add dataSourceId into savedObject Signed-off-by: Kristen Tian * Functional list, install uninstall Signed-off-by: Kristen Tian * add change log Signed-off-by: Kristen Tian * address comments Signed-off-by: Kristen Tian * add ut Signed-off-by: Kristen Tian --------- Signed-off-by: Kristen Tian * Upgrade the backport workflow (#4343) * Copy over the labels from the original PR * Label the backport PR with `autocut` * Label a PR that fails to backport Signed-off-by: Miki * Hide any output from `use_node` checking for Node compatibility (#4237) Signed-off-by: Miki Signed-off-by: Josh Romero Co-authored-by: Josh Romero * [Vis Colors] Update default color in TSVB to use `ouiPaletteColorBlind()[0]` (#4363) Signed-off-by: Manasvini B Suryanarayana * Add BWC tests for 2.7 and 2.8 (#4023) Signed-off-by: Manasvini B Suryanarayana * [Vis colors] Replace vis_type_timeline colors with `ouiPaletteColorBlind()` (#4366) Signed-off-by: Manasvini B Suryanarayana * [Lint] add custom stylelint rules and config (#4290) * [Lint] add custom stylelint rules and config Adding `@osd/stylelint-config` and `@osd/stylelint-plugin-stylelint` packages. These packages are utilized by OSD core and can be ran with the following: `yarn lint:style` Can be used to fix known non-compliant styling with the following: `yarn lint:style --fix` Can be used to audit untracked styling (based on defined rules) with the following: ``` export OUI_AUDIT_ENABLED=true yarn lint:style ``` --- `@osd/stylelint-config` Defines rules approved by UX and OSD core in JSON files and is added to OSD core. Within this commit is defined `colors.json` and `global_selectors.json`. `colors.json` defines a property that can be matched with a regex of a selector. If the selector is tracked it will have an `approved` value and a list of `rejected` values that UX knows if a value should be something. `global_selectors.json` defines a selector that if tracked, it will have an `approved` list of relative paths to files that can modify the global selector. --- `@osd/stylelint-plugin-stylelint` Creates the functionality that utilizes the JSON files within the `@osd/stylelint-config`. Within this commit is defined `no_custom_colors` and `no_modifying_global_selectors` rules. `no_custom_colors` checks if a property is a color property. It then utilizes a compliance engine helper to check the `colors.json` to see if the property being modified has a compliance rule available for the property for the specific selector and if it is not compliant. For example, if a selector matches `button` and we are trying to apply `background-color: red` to it. Stylelint will catch this and flag this as a known non-compliance issue since it knows that it should `$euiColorWarning`. If we pass `--fix` the property will be updated to be `$euiColorWarning`. If `OUI_AUDIT_ENABLED` is true it will catch all `background-color` being modified that is not being defined explicitly in `colors.json` `no_modifying_global_selectors` checks if a selector being modified is defined in `global_selectors.json` to see if a selector not defined in a specific list of approved files. For example, if a selector matches `#opensearch-dashboards-body` and it is being modified in `src/core/public/rendering/_base.scss`. Stylelint will catch this and flag this as a non-compliance issue. Since no other file should be modifying this selector. If we pass `--fix` the styling will be complete removed from the non-compliant file. --- Next steps: * Migrate these packages to OUI * Consider adding `yarn lint:style --fix` to the build release script here: https://github.com/opensearch-project/opensearch-build/blob/main/scripts/default/opensearch-dashboards/build.sh#L89 Issue: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4246 Signed-off-by: Kawika Avilla * fix to use find Signed-off-by: Kawika Avilla * Add regex matching and OUI modification lint Signed-off-by: Matt Provost * add changelog Signed-off-by: Josh Romero * address issues Signed-off-by: Kawika Avilla --------- Signed-off-by: Kawika Avilla Signed-off-by: Matt Provost Signed-off-by: Josh Romero Co-authored-by: Matt Provost Co-authored-by: Josh Romero * Fix linked deps resolution (#4342) Signed-off-by: Miki * Add configurable `defaults` to `uiSettings` (#4344) Also now: * `theme:darkMode` and `theme:version` can be configured via `defaults` * unauthenticated users are no longer forced to light mode Signed-off-by: Miki * Refactor hardcode color to use OUI in `maps_legacy` (#4294) * Refactor color to use OUI * Pull theme value from actual active theme * Update changelog --------- Signed-off-by: Matt Provost Signed-off-by: Josh Romero Co-authored-by: Anan Zhuang Co-authored-by: Josh Romero * Remove broken flot documentation link for Ruby API (#4384) * Remove broken documentation link for Ruby API https://apidock.com/ruby/Time/to_i is currently down for maintenance But we don't need this link anyway, because it's talking about standard methods. And we plan to deprecate flot_charts altogether: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4267 Signed-off-by: Josh Romero * remove leftover link brackets Signed-off-by: Josh Romero --------- Signed-off-by: Josh Romero * [CCI] Fix relationships header overflow (#4070) * Fix relationships header overflow (#3967) Co-authored-by: Andrey Myssak Signed-off-by: Sergey Myssak * Replace relationships css file with oui classname (#3967) Co-authored-by: Andrey Myssak Signed-off-by: Sergey Myssak * Make title overflow wrap instead of truncation (#3967) Co-authored-by: Andrey Myssak Signed-off-by: Sergey Myssak * Remove icon from flyout header and wrap title Signed-off-by: Josh Romero --------- Signed-off-by: Sergey Myssak Signed-off-by: Josh Romero Co-authored-by: Andrey Myssak Co-authored-by: Josh Romero * Refactor color maps to use OUI color palettes (#4293) * Remove color_util Signed-off-by: Matt Provost * Update changelog Signed-off-by: Matt Provost * Revert "Remove color_util" This reverts commit 9ca9c56e6bc5d2750971e04a2df7028f5c472b8b. Signed-off-by: Matt Provost * Refactor color maps to use Oui color palettes Signed-off-by: Matt Provost * Update changelog pt 2: electric boogaloo Signed-off-by: Matt Provost * Make … * [Data Explorer][Discover 2.0] restore single and surroundings doc view (#4816) * add initial route logic to single/surroundings doc view and re-organize files * restore surrounding doc view comp * restore single doc view comp Signed-off-by: ananzh (cherry picked from commit 4aa6d37b5ccee963c840ff9d1457e4265e4b4d96) * [Data Explorer][Discover 2.0] Add missing change interval on TimeChart and improve fetch (#4850) Issue Resolve https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4847 Signed-off-by: ananzh (cherry picked from commit a239fc204d9b505e800054a7eae27d2ebe939d00) * [Data Explorer][Discover 2.0] Replace hide column and add move left/right (#4838) * disable full screen mode * customize hide column to delete column * add move left and move right Issue Resolve: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4822 https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4823 https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4824 Signed-off-by: ananzh (cherry picked from commit 27381317e68264d08952ed5c0fa0e8a4f4b233a7) * [Data Explorer][Discover 2.0] Append popout icon to oui link for doc viewer links (#4855) * [Data Explorer][Discover 2.0] Append popout icon to oui link for doc viewer links Issue Resolve https://github.com/opensearch-project/OpenSearch-Dashboards/issues/4825 Signed-off-by: ananzh * fix all failed ut Signed-off-by: ananzh --------- Signed-off-by: ananzh (cherry picked from commit e9ad10ffb22f358e05d883f9ad8998e169e45fd0) * [Data Explorer] Fix breadcrumb for data explorer (#4856) * add breadcrumb --------- Signed-off-by: abbyhu2000 (cherry picked from commit 7faa088c1a23cf4a47c8148919c5ea99d35d5a03) * [Data Explorer] Add dismissible callout for discover (#4857) * add callout * add dimissible --------- Signed-off-by: abbyhu2000 (cherry picked from commit 58ee450a7a63277222a7e1361865f0c5a8eb5538) * [Data Explorer] Fix darkmode for histogram (#4858) * fix darkmode for histogram Signed-off-by: abbyhu2000 * changelog Signed-off-by: abbyhu2000 * remove changelog Signed-off-by: abbyhu2000 --------- Signed-off-by: abbyhu2000 (cherry picked from commit 89013dad0848567094243bb86de42acd127bc5da) --------- Signed-off-by: Ashwin P Chandran Co-authored-by: Anan Zhuang Co-authored-by: Qingyang(Abby) Hu --- .../config/global_selectors.json | 6 +- .../config/restricted_properties.json | 7 +- src/plugins/data_explorer/.i18nrc.json | 7 + src/plugins/data_explorer/README.md | 11 + src/plugins/data_explorer/common/index.ts | 7 + .../data_explorer/opensearch_dashboards.json | 18 + .../data_explorer/public/application.tsx | 41 ++ .../__snapshots__/app_container.test.tsx.snap | 13 + .../data_explorer/public/components/app.tsx | 36 ++ .../public/components/app_container.scss | 18 + .../public/components/app_container.test.tsx | 45 ++ .../public/components/app_container.tsx | 39 ++ .../public/components/no_view.tsx | 40 ++ .../public/components/sidebar/index.tsx | 120 +++++ src/plugins/data_explorer/public/index.scss | 9 + src/plugins/data_explorer/public/index.ts | 17 + src/plugins/data_explorer/public/plugin.ts | 140 +++++ .../public/services/view_service/index.ts | 7 + .../public/services/view_service/types.ts | 43 ++ .../public/services/view_service/view.ts | 31 ++ .../view_service/view_service.test.ts | 63 +++ .../services/view_service/view_service.ts | 88 ++++ src/plugins/data_explorer/public/types.ts | 37 ++ .../data_explorer/public/utils/mocks.ts | 35 ++ .../public/utils/state_management/hooks.ts | 14 + .../public/utils/state_management/index.ts | 7 + .../utils/state_management/metadata_slice.ts | 56 ++ .../public/utils/state_management/preload.ts | 47 ++ .../redux_persistence.test.tsx | 45 ++ .../state_management/redux_persistence.ts | 30 ++ .../public/utils/state_management/store.ts | 89 ++++ .../data_explorer/public/utils/use/index.ts | 6 + .../public/utils/use/use_view.ts | 35 ++ src/plugins/data_explorer/server/index.ts | 16 + src/plugins/data_explorer/server/plugin.ts | 41 ++ .../data_explorer/server/routes/index.ts | 22 + src/plugins/data_explorer/server/types.ts | 9 + src/plugins/discover/common/index.ts | 29 +- .../discover/opensearch_dashboards.json | 3 +- .../public/__mock__/index_pattern_mock.ts | 100 ++++ .../chart}/_histogram.scss | 0 .../application/components/chart/chart.tsx | 110 ++++ .../components/chart/histogram/histogram.tsx | 360 +++++++++++++ .../components/chart/histogram/type.ts | 16 + .../hits_counter/hits_counter.test.tsx | 0 .../chart/hits_counter/hits_counter.tsx | 95 ++++ .../{ => chart}/hits_counter/index.ts | 0 .../chart/timechart_header/index.ts | 31 ++ .../timechart_header.test.tsx | 0 .../timechart_header/timechart_header.tsx | 189 +++++++ .../chart/utils/create_histogram_configs.ts | 29 ++ .../chart/utils/get_dimension.test.ts | 74 +++ .../components/chart/utils/get_dimensions.ts | 73 +++ .../components/chart/utils/index.ts | 8 + .../chart/utils/point_series.test.ts | 62 +++ .../components/chart/utils/point_series.ts | 122 +++++ .../components/data_grid/constants.ts | 13 + .../components/data_grid/data_grid_table.tsx | 189 +++++++ .../data_grid_table_cell_actions.tsx | 76 +++ .../data_grid_table_cell_value.test.tsx | 187 +++++++ .../data_grid/data_grid_table_cell_value.tsx | 80 +++ .../data_grid_table_columns.test.tsx | 267 ++++++++++ .../data_grid/data_grid_table_columns.tsx | 88 ++++ .../data_grid/data_grid_table_context.tsx | 23 + ...data_grid_table_docview_inspect_button.tsx | 28 + .../data_grid/data_grid_table_flyout.tsx | 77 +++ .../components/doc_viewer/doc_viewer.test.tsx | 2 +- .../doc_viewer/doc_viewer_render_tab.test.tsx | 2 +- .../doc_viewer/doc_viewer_render_tab.tsx | 3 +- .../doc_viewer_links.test.tsx.snap | 38 +- .../doc_viewer_links.test.tsx | 2 +- .../doc_viewer_links/doc_viewer_links.tsx | 10 +- .../components/doc_views/context/NOTES.md | 55 ++ .../doc_views}/context/_index.scss | 0 .../doc_views/context/api/_stubs.ts | 132 +++++ .../doc_views/context/api/anchor.ts | 91 ++++ .../doc_views/context/api/context.ts | 133 +++++ .../context/api/utils/date_conversion.test.ts | 0 .../context/api/utils/date_conversion.ts | 0 .../api/utils/fetch_hits_in_interval.ts | 107 ++++ .../context/api/utils/generate_intervals.ts | 66 +++ .../get_opensearch_query_search_after.ts | 0 .../api/utils/get_opensearch_query_sort.ts | 49 ++ .../context/api/utils/sorting.test.ts | 0 .../doc_views/context/api/utils/sorting.ts | 63 +++ .../components/action_bar/_action_bar.scss | 0 .../context/components/action_bar/_index.scss | 0 .../components/action_bar/action_bar.test.tsx | 104 ++++ .../components/action_bar/action_bar.tsx | 190 +++++++ .../action_bar/action_bar_warning.tsx | 0 .../context/utils/context_query_state.ts | 32 ++ .../context/utils/context_state.test.ts | 204 ++++++++ .../doc_views/context/utils/context_state.ts | 307 +++++++++++ .../context/utils/use_context_state.ts | 69 +++ .../context/utils/use_query_actions.ts | 188 +++++++ .../components/doc_views/context_app.tsx | 120 +++++ .../components/doc_views/doc_views_router.tsx | 36 ++ .../doc_views/generate_doc_views_url.ts | 30 ++ .../components/doc_views/index.tsx | 14 + .../components/doc_views/single_doc_app.tsx | 88 ++++ .../doc_views/surrounding_docs_app.tsx | 70 +++ .../doc_views/surrounding_docs_view.tsx | 137 +++++ .../components/help_menu/help_menu_util.ts | 49 ++ .../loading_spinner/loading_spinner.scss | 4 + .../loading_spinner/loading_spinner.tsx | 24 +- .../components/no_results/no_results.tsx | 212 ++++++++ .../sidebar/discover_field.test.tsx | 44 +- .../components/sidebar/discover_field.tsx | 140 ++--- .../sidebar/discover_field_details.tsx | 1 - .../sidebar/discover_field_search.test.tsx | 16 +- .../sidebar/discover_field_search.tsx | 76 ++- .../components/sidebar/discover_sidebar.scss | 100 +--- .../sidebar/discover_sidebar.test.tsx | 64 ++- .../components/sidebar/discover_sidebar.tsx | 368 +++++++------ .../components/sidebar/lib/group_fields.tsx | 7 + .../skip_bottom_button/skip_bottom_button.tsx | 2 +- .../application/components/table/table.scss | 3 + .../application/components/table/table.tsx | 5 +- .../components/table/table_helper.tsx | 7 - ...s.snap => open_search_panel.test.tsx.snap} | 0 .../components/top_nav/get_top_nav_links.tsx | 339 ++++++++++++ .../top_nav/open_search_panel.test.tsx | 53 ++ .../components/top_nav/open_search_panel.tsx | 127 +++++ .../top_nav/show_open_search_panel.tsx | 70 +++ .../uninitialized}/uninitialized.tsx | 0 .../components/utils/use_pagination.test.ts | 73 +++ .../components/utils/use_pagination.ts | 39 ++ .../doc_views/doc_views_registry.ts | 16 - .../application/doc_views/doc_views_types.ts | 9 - .../public/application/helpers/breadcrumbs.ts | 13 +- .../public/application/utils/columns.test.ts | 24 + .../public/application/utils/columns.ts | 43 ++ .../utils/state_management/common.test.ts | 25 + .../utils/state_management/common.ts | 23 + .../state_management/discover_slice.test.tsx | 85 +++ .../utils/state_management/discover_slice.tsx | 172 +++++++ .../utils/state_management/index.ts | 17 + .../canvas/discover_chart_container.scss | 25 + .../canvas/discover_chart_container.tsx | 42 ++ .../view_components/canvas/discover_table.tsx | 100 ++++ .../view_components/canvas/index.tsx | 109 ++++ .../view_components/canvas/top_nav.tsx | 108 ++++ .../view_components/context/index.tsx | 42 ++ .../application/view_components/index.ts | 7 + .../view_components/panel/index.tsx | 67 +++ .../view_components/utils/get_default_sort.ts | 50 ++ .../view_components/utils/get_sort.test.ts | 107 ++++ .../view_components/utils/get_sort.ts | 92 ++++ .../utils/get_sort_for_search_source.ts | 66 +++ .../utils/index_pattern_helper.ts | 114 ++++ .../utils/update_search_source.ts | 72 +++ .../utils/use_index_pattern.ts | 51 ++ .../view_components/utils/use_search.ts | 270 ++++++++++ src/plugins/discover/public/build_services.ts | 13 +- src/plugins/discover/public/index.ts | 4 +- src/plugins/discover/public/migrate_state.ts | 143 +++++ .../public/opensearch_dashboards_services.ts | 21 +- src/plugins/discover/public/plugin.ts | 301 +++++------ .../public/saved_searches/_saved_search.ts | 2 + .../discover/public/saved_searches/types.ts | 12 +- src/plugins/discover/server/ui_settings.ts | 12 + src/plugins/discover_legacy/README.md | 1 + src/plugins/discover_legacy/common/index.ts | 41 ++ .../opensearch_dashboards.json | 27 + .../public/application/_discover.scss | 0 .../public/application/angular/_index.scss | 0 .../public/application/angular/context.html | 0 .../public/application/angular/context.js | 0 .../application/angular/context/NOTES.md | 0 .../application/angular/context/_index.scss | 8 + .../application/angular/context/api/_stubs.js | 0 .../application/angular/context/api/anchor.js | 0 .../angular/context/api/anchor.test.js | 0 .../context/api/context.predecessors.test.js | 0 .../context/api/context.successors.test.js | 0 .../angular/context/api/context.ts | 0 .../context/api/utils/date_conversion.test.ts | 43 ++ .../context/api/utils/date_conversion.ts | 76 +++ .../api/utils/fetch_hits_in_interval.ts | 0 .../context/api/utils/generate_intervals.ts | 0 .../get_opensearch_query_search_after.ts | 58 +++ .../api/utils/get_opensearch_query_sort.ts | 0 .../angular/context/api/utils/sorting.test.ts | 38 ++ .../angular/context/api/utils/sorting.ts | 0 .../components/action_bar/_action_bar.scss | 10 + .../context/components/action_bar/_index.scss | 1 + .../components/action_bar/action_bar.test.tsx | 0 .../components/action_bar/action_bar.tsx | 0 .../action_bar/action_bar_directive.ts | 0 .../action_bar/action_bar_warning.tsx | 84 +++ .../context/components/action_bar/index.ts | 0 .../helpers/call_after_bindings_workaround.js | 0 .../angular/context/query/actions.js | 0 .../angular/context/query/constants.js | 0 .../angular/context/query/index.js | 0 .../angular/context/query/state.js | 0 .../context/query_parameters/actions.js | 0 .../context/query_parameters/actions.test.ts | 0 .../context/query_parameters/constants.ts | 0 .../angular/context/query_parameters/index.js | 0 .../angular/context/query_parameters/state.ts | 0 .../application/angular/context_app.html | 0 .../public/application/angular/context_app.js | 0 .../application/angular/context_state.test.ts | 0 .../application/angular/context_state.ts | 0 .../__snapshots__/no_results.test.js.snap | 0 .../angular/directives/_histogram.scss | 11 + .../angular/directives/_index.scss | 0 .../angular/directives/_no_results.scss | 0 .../angular/directives/debounce/debounce.js | 0 .../directives/debounce/debounce.test.ts | 0 .../angular/directives/debounce/index.js | 0 .../angular/directives/fixed_scroll.js | 0 .../angular/directives/fixed_scroll.test.js | 0 .../angular/directives/histogram.tsx | 0 .../application/angular/directives/index.js | 0 .../angular/directives/no_results.js | 0 .../angular/directives/no_results.test.js | 0 .../angular/directives/render_complete.ts | 0 .../angular/directives/uninitialized.tsx | 78 +++ .../public/application/angular/discover.js | 18 + .../application/angular/discover_legacy.html | 0 .../angular/discover_state.test.ts | 0 .../application/angular/discover_state.ts | 0 .../public/application/angular/doc.html | 0 .../public/application/angular/doc.ts | 0 .../angular/doc_table/_doc_table.scss | 0 .../angular/doc_table/actions/columns.ts | 0 .../angular/doc_table/components/_index.scss | 0 .../doc_table/components/_table_header.scss | 0 .../tool_bar_pager_buttons.test.tsx.snap | 0 .../tool_bar_pager_text.test.tsx.snap | 0 .../doc_table/components/pager/index.ts | 0 .../pager/tool_bar_pager_buttons.test.tsx | 0 .../pager/tool_bar_pager_buttons.tsx | 0 .../pager/tool_bar_pager_text.test.tsx | 0 .../components/pager/tool_bar_pager_text.tsx | 0 .../doc_table/components/row_headers.test.js | 0 .../doc_table/components/table_header.ts | 0 .../__snapshots__/table_header.test.tsx.snap | 0 .../components/table_header/helpers.tsx | 0 .../table_header/table_header.test.tsx | 0 .../components/table_header/table_header.tsx | 0 .../table_header/table_header_column.tsx | 0 .../angular/doc_table/components/table_row.ts | 0 .../doc_table/components/table_row/_cell.scss | 0 .../components/table_row/_details.scss | 0 .../components/table_row/_index.scss | 0 .../doc_table/components/table_row/_open.scss | 0 .../doc_table/components/table_row/cell.html | 0 .../components/table_row/details.html | 0 .../doc_table/components/table_row/open.html | 0 .../table_row/truncate_by_height.html | 0 .../doc_table/create_doc_table_react.tsx | 0 .../angular/doc_table/doc_table.html | 0 .../angular/doc_table/doc_table.test.js | 0 .../angular/doc_table/doc_table.ts | 0 .../angular/doc_table/doc_table_strings.js | 0 .../application/angular/doc_table/index.scss | 0 .../application/angular/doc_table/index.ts | 0 .../angular/doc_table/infinite_scroll.ts | 0 .../angular/doc_table/lib/get_default_sort.ts | 0 .../angular/doc_table/lib/get_sort.test.ts | 0 .../angular/doc_table/lib/get_sort.ts | 0 .../lib/get_sort_for_search_source.ts | 0 .../angular/doc_table/lib/pager/index.js | 0 .../angular/doc_table/lib/pager/pager.js | 0 .../doc_table/lib/pager/pager_factory.ts | 0 .../public/application/angular/doc_viewer.tsx | 0 .../application/angular/doc_viewer_links.tsx | 0 .../application/angular/helpers/index.ts | 0 .../angular/helpers/point_series.ts | 0 .../public/application/angular/index.ts | 0 .../public/application/angular/redirect.ts | 0 .../application/angular/response_handler.js | 0 .../public/application/application.ts | 0 .../context_error_message.test.tsx | 0 .../context_error_message.tsx | 0 .../context_error_message_directive.ts | 0 .../components/context_error_message/index.ts | 0 .../create_discover_legacy_directive.ts | 0 .../components/discover_legacy.tsx | 61 ++- .../application/components/doc/doc.test.tsx | 150 ++++++ .../public/application/components/doc/doc.tsx | 140 +++++ .../doc/use_opensearch_doc_search.test.tsx | 98 ++++ .../doc/use_opensearch_doc_search.ts | 114 ++++ .../__snapshots__/doc_viewer.test.tsx.snap | 56 ++ .../doc_viewer_render_tab.test.tsx.snap | 20 + .../components/doc_viewer/doc_viewer.scss | 72 +++ .../components/doc_viewer/doc_viewer.test.tsx | 94 ++++ .../components/doc_viewer/doc_viewer.tsx | 75 +++ .../doc_viewer/doc_viewer_render_error.tsx | 48 ++ .../doc_viewer/doc_viewer_render_tab.test.tsx | 52 ++ .../doc_viewer/doc_viewer_render_tab.tsx | 52 ++ .../components/doc_viewer/doc_viewer_tab.tsx | 101 ++++ .../doc_viewer_links.test.tsx.snap | 34 ++ .../doc_viewer_links.test.tsx | 68 +++ .../doc_viewer_links/doc_viewer_links.tsx | 35 ++ .../__snapshots__/field_name.test.tsx.snap | 94 ++++ .../components/field_name/field_name.test.tsx | 52 ++ .../components/field_name/field_name.tsx | 75 +++ .../components/field_name/field_type_name.ts | 85 +++ .../components/help_menu/help_menu_util.js | 0 .../hits_counter/hits_counter.test.tsx | 80 +++ .../components/hits_counter/hits_counter.tsx | 0 .../components/hits_counter/index.ts | 31 ++ .../json_code_block.test.tsx.snap | 20 + .../json_code_block/json_code_block.test.tsx | 46 ++ .../json_code_block/json_code_block.tsx | 45 ++ .../loading_spinner/loading_spinner.test.tsx | 45 ++ .../loading_spinner/loading_spinner.tsx | 47 ++ .../discover_index_pattern.test.tsx.snap | 0 .../sidebar/change_indexpattern.tsx | 0 .../components/sidebar/discover_field.scss | 4 + .../sidebar/discover_field.test.tsx | 152 ++++++ .../components/sidebar/discover_field.tsx | 245 +++++++++ .../sidebar/discover_field_bucket.scss | 4 + .../sidebar/discover_field_bucket.tsx | 133 +++++ .../sidebar/discover_field_details.scss | 0 .../sidebar/discover_field_details.test.tsx | 312 +++++++++++ .../sidebar/discover_field_details.tsx | 153 ++++++ .../sidebar/discover_field_search.test.tsx | 160 ++++++ .../sidebar/discover_field_search.tsx | 313 +++++++++++ .../sidebar/discover_index_pattern.test.tsx | 0 .../sidebar/discover_index_pattern.tsx | 0 .../sidebar/discover_index_pattern_title.tsx | 0 .../components/sidebar/discover_sidebar.scss | 99 ++++ .../sidebar/discover_sidebar.test.tsx | 148 ++++++ .../components/sidebar/discover_sidebar.tsx | 326 ++++++++++++ .../application/components/sidebar/index.ts | 31 ++ .../sidebar/lib/field_calculator.test.ts | 268 ++++++++++ .../sidebar/lib/field_calculator.ts | 148 ++++++ .../sidebar/lib/field_filter.test.ts | 107 ++++ .../components/sidebar/lib/field_filter.ts | 89 ++++ .../components/sidebar/lib/get_details.ts | 71 +++ .../sidebar/lib/get_field_type_name.ts | 85 +++ .../lib/get_index_pattern_field_list.ts | 53 ++ .../components/sidebar/lib/get_warnings.ts | 55 ++ .../sidebar/lib/group_fields.test.ts | 125 +++++ .../components/sidebar/lib/group_fields.tsx | 87 ++++ .../sidebar/lib/visualize_trigger_utils.ts | 122 +++++ .../sidebar/string_progress_bar.tsx | 46 ++ .../application/components/sidebar/types.ts | 52 ++ .../components/skip_bottom_button/index.ts | 31 ++ .../skip_bottom_button.test.tsx | 51 ++ .../skip_bottom_button/skip_bottom_button.tsx | 66 +++ .../components/table/table.test.tsx | 279 ++++++++++ .../application/components/table/table.tsx | 149 ++++++ .../components/table/table_helper.test.ts | 58 +++ .../components/table/table_helper.tsx | 43 ++ .../components/table/table_row.tsx | 129 +++++ .../table/table_row_btn_collapse.tsx | 56 ++ .../table/table_row_btn_filter_add.tsx | 69 +++ .../table/table_row_btn_filter_exists.tsx | 81 +++ .../table/table_row_btn_filter_remove.tsx | 69 +++ .../table/table_row_btn_toggle_column.tsx | 79 +++ .../table/table_row_icon_no_mapping.tsx | 59 +++ .../table/table_row_icon_underscore.tsx | 63 +++ .../components/timechart_header/index.ts | 0 .../timechart_header.test.tsx | 110 ++++ .../timechart_header/timechart_header.tsx | 0 .../open_search_panel.test.js.snap | 69 +++ .../components/top_nav/open_search_panel.js | 0 .../top_nav/open_search_panel.test.js | 0 .../top_nav/show_open_search_panel.js | 0 .../doc_views/doc_views_helpers.tsx | 0 .../doc_views/doc_views_registry.ts | 70 +++ .../application/doc_views/doc_views_types.ts | 86 ++++ .../doc_views_links_registry.ts | 18 + .../doc_views_links/doc_views_links_types.ts | 25 + .../application/embeddable/constants.ts | 0 .../public/application/embeddable/index.ts | 0 .../embeddable/search_embeddable.scss | 0 .../embeddable/search_embeddable.ts | 0 .../embeddable/search_embeddable_factory.ts | 0 .../embeddable/search_template.html | 0 .../public/application/embeddable/types.ts | 0 .../public/application/helpers/breadcrumbs.ts | 51 ++ .../helpers/format_number_with_commas.ts | 38 ++ .../helpers/get_index_pattern_id.ts | 71 +++ ...get_switch_index_pattern_app_state.test.ts | 0 .../get_switch_index_pattern_app_state.ts | 0 .../public/application/helpers/index.ts | 32 ++ .../helpers/migrate_legacy_query.ts | 48 ++ .../helpers/popularize_field.test.ts | 93 ++++ .../application/helpers/popularize_field.ts | 52 ++ .../helpers/shorten_dotted_string.ts | 37 ++ .../helpers/validate_time_range.test.ts | 58 +++ .../helpers/validate_time_range.ts | 61 +++ .../public/application/index.scss | 0 .../discover_legacy/public/build_services.ts | 129 +++++ .../public/get_inner_angular.ts | 0 src/plugins/discover_legacy/public/index.ts | 41 ++ src/plugins/discover_legacy/public/mocks.ts | 61 +++ .../public/opensearch_dashboards_services.ts | 129 +++++ src/plugins/discover_legacy/public/plugin.ts | 487 ++++++++++++++++++ .../public/saved_searches/_saved_search.ts | 86 ++++ .../public/saved_searches/index.ts | 32 ++ .../public/saved_searches/saved_searches.ts | 50 ++ .../public/saved_searches/types.ts | 47 ++ .../public/url_generator.test.ts | 269 ++++++++++ .../discover_legacy/public/url_generator.ts | 127 +++++ .../public/top_nav_menu/top_nav_menu_data.tsx | 2 + .../public/top_nav_menu/top_nav_menu_item.tsx | 39 +- .../routes/lib/short_url_assert_valid.test.ts | 8 +- .../routes/lib/short_url_assert_valid.ts | 2 +- .../public/helpers/find_test_subject.ts | 4 +- src/test_utils/public/testing_lib_helpers.tsx | 22 + test/functional/apps/context/_date_nanos.js | 1 + .../context/_date_nanos_custom_timestamp.js | 1 + test/functional/apps/context/_filters.js | 6 + test/functional/apps/context/index.js | 5 +- .../apps/dashboard/dashboard_state.js | 15 + .../apps/dashboard/dashboard_time_picker.js | 2 +- .../apps/dashboard/panel_context_menu.ts | 14 + test/functional/apps/discover/_date_nanos.js | 5 +- .../apps/discover/_date_nanos_mixed.js | 5 +- test/functional/apps/discover/_discover.js | 23 +- .../apps/discover/_discover_histogram.ts | 1 + .../apps/discover/_doc_navigation.js | 4 + test/functional/apps/discover/_doc_table.ts | 1 + test/functional/apps/discover/_errors.ts | 4 + test/functional/apps/discover/_field_data.js | 1 + .../apps/discover/_field_visualize.ts | 1 + .../apps/discover/_filter_editor.js | 1 + .../discover/_indexpattern_with_encoded_id.ts | 5 +- .../_indexpattern_without_timefield.ts | 5 +- test/functional/apps/discover/_inspector.js | 1 + .../functional/apps/discover/_large_string.js | 5 +- .../apps/discover/_saved_queries.js | 4 +- .../functional/apps/discover/_shared_links.js | 9 +- test/functional/apps/discover/_sidebar.js | 11 +- .../apps/discover/_source_filters.js | 11 +- test/functional/apps/home/_navigation.ts | 3 + .../apps/management/_handle_alias.js | 4 + .../apps/management/_scripted_fields.js | 5 +- test/functional/apps/visualize/_lab_mode.js | 6 + test/functional/apps/visualize/index.ts | 1 + test/functional/config.js | 2 +- .../services/dashboard/visualizations.ts | 9 + 440 files changed, 18895 insertions(+), 830 deletions(-) create mode 100644 src/plugins/data_explorer/.i18nrc.json create mode 100755 src/plugins/data_explorer/README.md create mode 100644 src/plugins/data_explorer/common/index.ts create mode 100644 src/plugins/data_explorer/opensearch_dashboards.json create mode 100644 src/plugins/data_explorer/public/application.tsx create mode 100644 src/plugins/data_explorer/public/components/__snapshots__/app_container.test.tsx.snap create mode 100644 src/plugins/data_explorer/public/components/app.tsx create mode 100644 src/plugins/data_explorer/public/components/app_container.scss create mode 100644 src/plugins/data_explorer/public/components/app_container.test.tsx create mode 100644 src/plugins/data_explorer/public/components/app_container.tsx create mode 100644 src/plugins/data_explorer/public/components/no_view.tsx create mode 100644 src/plugins/data_explorer/public/components/sidebar/index.tsx create mode 100644 src/plugins/data_explorer/public/index.scss create mode 100644 src/plugins/data_explorer/public/index.ts create mode 100644 src/plugins/data_explorer/public/plugin.ts create mode 100644 src/plugins/data_explorer/public/services/view_service/index.ts create mode 100644 src/plugins/data_explorer/public/services/view_service/types.ts create mode 100644 src/plugins/data_explorer/public/services/view_service/view.ts create mode 100644 src/plugins/data_explorer/public/services/view_service/view_service.test.ts create mode 100644 src/plugins/data_explorer/public/services/view_service/view_service.ts create mode 100644 src/plugins/data_explorer/public/types.ts create mode 100644 src/plugins/data_explorer/public/utils/mocks.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/hooks.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/index.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/metadata_slice.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/preload.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/redux_persistence.test.tsx create mode 100644 src/plugins/data_explorer/public/utils/state_management/redux_persistence.ts create mode 100644 src/plugins/data_explorer/public/utils/state_management/store.ts create mode 100644 src/plugins/data_explorer/public/utils/use/index.ts create mode 100644 src/plugins/data_explorer/public/utils/use/use_view.ts create mode 100644 src/plugins/data_explorer/server/index.ts create mode 100644 src/plugins/data_explorer/server/plugin.ts create mode 100644 src/plugins/data_explorer/server/routes/index.ts create mode 100644 src/plugins/data_explorer/server/types.ts create mode 100644 src/plugins/discover/public/__mock__/index_pattern_mock.ts rename src/plugins/discover/public/application/{angular/directives => components/chart}/_histogram.scss (100%) create mode 100644 src/plugins/discover/public/application/components/chart/chart.tsx create mode 100644 src/plugins/discover/public/application/components/chart/histogram/histogram.tsx create mode 100644 src/plugins/discover/public/application/components/chart/histogram/type.ts rename src/plugins/discover/public/application/components/{ => chart}/hits_counter/hits_counter.test.tsx (100%) create mode 100644 src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.tsx rename src/plugins/discover/public/application/components/{ => chart}/hits_counter/index.ts (100%) create mode 100644 src/plugins/discover/public/application/components/chart/timechart_header/index.ts rename src/plugins/discover/public/application/components/{ => chart}/timechart_header/timechart_header.test.tsx (100%) create mode 100644 src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.tsx create mode 100644 src/plugins/discover/public/application/components/chart/utils/create_histogram_configs.ts create mode 100644 src/plugins/discover/public/application/components/chart/utils/get_dimension.test.ts create mode 100644 src/plugins/discover/public/application/components/chart/utils/get_dimensions.ts create mode 100644 src/plugins/discover/public/application/components/chart/utils/index.ts create mode 100644 src/plugins/discover/public/application/components/chart/utils/point_series.test.ts create mode 100644 src/plugins/discover/public/application/components/chart/utils/point_series.ts create mode 100644 src/plugins/discover/public/application/components/data_grid/constants.ts create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_actions.tsx create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.test.tsx create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.tsx create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table_context.tsx create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table_docview_inspect_button.tsx create mode 100644 src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx create mode 100644 src/plugins/discover/public/application/components/doc_views/context/NOTES.md rename src/plugins/discover/public/application/{angular => components/doc_views}/context/_index.scss (100%) create mode 100644 src/plugins/discover/public/application/components/doc_views/context/api/_stubs.ts create mode 100644 src/plugins/discover/public/application/components/doc_views/context/api/anchor.ts create mode 100644 src/plugins/discover/public/application/components/doc_views/context/api/context.ts rename src/plugins/discover/public/application/{angular => components/doc_views}/context/api/utils/date_conversion.test.ts (100%) rename src/plugins/discover/public/application/{angular => components/doc_views}/context/api/utils/date_conversion.ts (100%) create mode 100644 src/plugins/discover/public/application/components/doc_views/context/api/utils/fetch_hits_in_interval.ts create mode 100644 src/plugins/discover/public/application/components/doc_views/context/api/utils/generate_intervals.ts rename src/plugins/discover/public/application/{angular => components/doc_views}/context/api/utils/get_opensearch_query_search_after.ts (100%) create mode 100644 src/plugins/discover/public/application/components/doc_views/context/api/utils/get_opensearch_query_sort.ts rename src/plugins/discover/public/application/{angular => components/doc_views}/context/api/utils/sorting.test.ts (100%) create mode 100644 src/plugins/discover/public/application/components/doc_views/context/api/utils/sorting.ts rename src/plugins/discover/public/application/{angular => components/doc_views}/context/components/action_bar/_action_bar.scss (100%) rename src/plugins/discover/public/application/{angular => components/doc_views}/context/components/action_bar/_index.scss (100%) create mode 100644 src/plugins/discover/public/application/components/doc_views/context/components/action_bar/action_bar.test.tsx create mode 100644 src/plugins/discover/public/application/components/doc_views/context/components/action_bar/action_bar.tsx rename src/plugins/discover/public/application/{angular => components/doc_views}/context/components/action_bar/action_bar_warning.tsx (100%) create mode 100644 src/plugins/discover/public/application/components/doc_views/context/utils/context_query_state.ts create mode 100644 src/plugins/discover/public/application/components/doc_views/context/utils/context_state.test.ts create mode 100644 src/plugins/discover/public/application/components/doc_views/context/utils/context_state.ts create mode 100644 src/plugins/discover/public/application/components/doc_views/context/utils/use_context_state.ts create mode 100644 src/plugins/discover/public/application/components/doc_views/context/utils/use_query_actions.ts create mode 100644 src/plugins/discover/public/application/components/doc_views/context_app.tsx create mode 100644 src/plugins/discover/public/application/components/doc_views/doc_views_router.tsx create mode 100644 src/plugins/discover/public/application/components/doc_views/generate_doc_views_url.ts create mode 100644 src/plugins/discover/public/application/components/doc_views/index.tsx create mode 100644 src/plugins/discover/public/application/components/doc_views/single_doc_app.tsx create mode 100644 src/plugins/discover/public/application/components/doc_views/surrounding_docs_app.tsx create mode 100644 src/plugins/discover/public/application/components/doc_views/surrounding_docs_view.tsx create mode 100644 src/plugins/discover/public/application/components/help_menu/help_menu_util.ts create mode 100644 src/plugins/discover/public/application/components/loading_spinner/loading_spinner.scss create mode 100644 src/plugins/discover/public/application/components/no_results/no_results.tsx create mode 100644 src/plugins/discover/public/application/components/table/table.scss rename src/plugins/discover/public/application/components/top_nav/__snapshots__/{open_search_panel.test.js.snap => open_search_panel.test.tsx.snap} (100%) create mode 100644 src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx create mode 100644 src/plugins/discover/public/application/components/top_nav/open_search_panel.test.tsx create mode 100644 src/plugins/discover/public/application/components/top_nav/open_search_panel.tsx create mode 100644 src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx rename src/plugins/discover/public/application/{angular/directives => components/uninitialized}/uninitialized.tsx (100%) create mode 100644 src/plugins/discover/public/application/components/utils/use_pagination.test.ts create mode 100644 src/plugins/discover/public/application/components/utils/use_pagination.ts create mode 100644 src/plugins/discover/public/application/utils/columns.test.ts create mode 100644 src/plugins/discover/public/application/utils/columns.ts create mode 100644 src/plugins/discover/public/application/utils/state_management/common.test.ts create mode 100644 src/plugins/discover/public/application/utils/state_management/common.ts create mode 100644 src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx create mode 100644 src/plugins/discover/public/application/utils/state_management/discover_slice.tsx create mode 100644 src/plugins/discover/public/application/utils/state_management/index.ts create mode 100644 src/plugins/discover/public/application/view_components/canvas/discover_chart_container.scss create mode 100644 src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx create mode 100644 src/plugins/discover/public/application/view_components/canvas/discover_table.tsx create mode 100644 src/plugins/discover/public/application/view_components/canvas/index.tsx create mode 100644 src/plugins/discover/public/application/view_components/canvas/top_nav.tsx create mode 100644 src/plugins/discover/public/application/view_components/context/index.tsx create mode 100644 src/plugins/discover/public/application/view_components/index.ts create mode 100644 src/plugins/discover/public/application/view_components/panel/index.tsx create mode 100644 src/plugins/discover/public/application/view_components/utils/get_default_sort.ts create mode 100644 src/plugins/discover/public/application/view_components/utils/get_sort.test.ts create mode 100644 src/plugins/discover/public/application/view_components/utils/get_sort.ts create mode 100644 src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts create mode 100644 src/plugins/discover/public/application/view_components/utils/index_pattern_helper.ts create mode 100644 src/plugins/discover/public/application/view_components/utils/update_search_source.ts create mode 100644 src/plugins/discover/public/application/view_components/utils/use_index_pattern.ts create mode 100644 src/plugins/discover/public/application/view_components/utils/use_search.ts create mode 100644 src/plugins/discover/public/migrate_state.ts create mode 100644 src/plugins/discover_legacy/README.md create mode 100644 src/plugins/discover_legacy/common/index.ts create mode 100644 src/plugins/discover_legacy/opensearch_dashboards.json rename src/plugins/{discover => discover_legacy}/public/application/_discover.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/_index.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/NOTES.md (100%) create mode 100644 src/plugins/discover_legacy/public/application/angular/context/_index.scss rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/_stubs.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/anchor.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/anchor.test.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/context.predecessors.test.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/context.successors.test.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/context.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.test.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.ts rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/utils/fetch_hits_in_interval.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/utils/generate_intervals.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/utils/get_opensearch_query_sort.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts rename src/plugins/{discover => discover_legacy}/public/application/angular/context/api/utils/sorting.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_action_bar.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_index.scss rename src/plugins/{discover => discover_legacy}/public/application/angular/context/components/action_bar/action_bar.test.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/components/action_bar/action_bar.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/components/action_bar/action_bar_directive.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx rename src/plugins/{discover => discover_legacy}/public/application/angular/context/components/action_bar/index.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/helpers/call_after_bindings_workaround.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/query/actions.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/query/constants.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/query/index.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/query/state.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/query_parameters/actions.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/query_parameters/actions.test.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/query_parameters/constants.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/query_parameters/index.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context/query_parameters/state.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context_app.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context_app.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context_state.test.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/context_state.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/__snapshots__/no_results.test.js.snap (100%) create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/_index.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/_no_results.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/debounce/debounce.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/debounce/debounce.test.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/debounce/index.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/fixed_scroll.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/fixed_scroll.test.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/histogram.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/index.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/no_results.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/no_results.test.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/directives/render_complete.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx rename src/plugins/{discover => discover_legacy}/public/application/angular/discover.js (98%) rename src/plugins/{discover => discover_legacy}/public/application/angular/discover_legacy.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/discover_state.test.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/discover_state.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/_doc_table.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/actions/columns.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/_index.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/_table_header.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/pager/index.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/row_headers.test.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_header.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_header/helpers.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_header/table_header.test.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_header/table_header.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_header/table_header_column.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_row.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_row/_cell.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_row/_details.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_row/_index.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_row/_open.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_row/cell.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_row/details.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_row/open.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/components/table_row/truncate_by_height.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/create_doc_table_react.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/doc_table.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/doc_table.test.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/doc_table.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/doc_table_strings.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/index.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/index.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/infinite_scroll.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/lib/get_default_sort.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/lib/get_sort.test.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/lib/get_sort.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/lib/get_sort_for_search_source.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/lib/pager/index.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/lib/pager/pager.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_table/lib/pager/pager_factory.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_viewer.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/doc_viewer_links.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/helpers/index.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/helpers/point_series.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/index.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/redirect.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/angular/response_handler.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/application.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/context_error_message/context_error_message.test.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/context_error_message/context_error_message.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/context_error_message/context_error_message_directive.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/context_error_message/index.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/create_discover_legacy_directive.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/discover_legacy.tsx (89%) create mode 100644 src/plugins/discover_legacy/public/application/components/doc/doc.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc/doc.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc/use_opensearch_doc_search.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc/use_opensearch_doc_search.ts create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer/__snapshots__/doc_viewer.test.tsx.snap create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer/__snapshots__/doc_viewer_render_tab.test.tsx.snap create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.scss create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_error.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_render_tab.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer_tab.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/doc_viewer_links/doc_viewer_links.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/field_name/__snapshots__/field_name.test.tsx.snap create mode 100644 src/plugins/discover_legacy/public/application/components/field_name/field_name.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/field_name/field_name.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/field_name/field_type_name.ts rename src/plugins/{discover => discover_legacy}/public/application/components/help_menu/help_menu_util.js (100%) create mode 100644 src/plugins/discover_legacy/public/application/components/hits_counter/hits_counter.test.tsx rename src/plugins/{discover => discover_legacy}/public/application/components/hits_counter/hits_counter.tsx (100%) create mode 100644 src/plugins/discover_legacy/public/application/components/hits_counter/index.ts create mode 100644 src/plugins/discover_legacy/public/application/components/json_code_block/__snapshots__/json_code_block.test.tsx.snap create mode 100644 src/plugins/discover_legacy/public/application/components/json_code_block/json_code_block.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/json_code_block/json_code_block.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/loading_spinner/loading_spinner.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/loading_spinner/loading_spinner.tsx rename src/plugins/{discover => discover_legacy}/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/sidebar/change_indexpattern.tsx (100%) create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field.scss create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.scss create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.tsx rename src/plugins/{discover => discover_legacy}/public/application/components/sidebar/discover_field_details.scss (100%) create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.tsx rename src/plugins/{discover => discover_legacy}/public/application/components/sidebar/discover_index_pattern.test.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/sidebar/discover_index_pattern.tsx (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/sidebar/discover_index_pattern_title.tsx (100%) create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/index.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/field_calculator.test.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/field_calculator.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/field_filter.test.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/field_filter.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/get_details.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/get_field_type_name.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/get_index_pattern_field_list.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/get_warnings.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/group_fields.test.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/group_fields.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/lib/visualize_trigger_utils.ts create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/string_progress_bar.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/types.ts create mode 100644 src/plugins/discover_legacy/public/application/components/skip_bottom_button/index.ts create mode 100644 src/plugins/discover_legacy/public/application/components/skip_bottom_button/skip_bottom_button.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/skip_bottom_button/skip_bottom_button.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_helper.test.ts create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_helper.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_row.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_row_btn_collapse.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_add.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_exists.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_row_btn_filter_remove.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_row_btn_toggle_column.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_row_icon_no_mapping.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/table/table_row_icon_underscore.tsx rename src/plugins/{discover => discover_legacy}/public/application/components/timechart_header/index.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/components/timechart_header/timechart_header.test.tsx rename src/plugins/{discover => discover_legacy}/public/application/components/timechart_header/timechart_header.tsx (100%) create mode 100644 src/plugins/discover_legacy/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap rename src/plugins/{discover => discover_legacy}/public/application/components/top_nav/open_search_panel.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/top_nav/open_search_panel.test.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/components/top_nav/show_open_search_panel.js (100%) rename src/plugins/{discover => discover_legacy}/public/application/doc_views/doc_views_helpers.tsx (100%) create mode 100644 src/plugins/discover_legacy/public/application/doc_views/doc_views_registry.ts create mode 100644 src/plugins/discover_legacy/public/application/doc_views/doc_views_types.ts create mode 100644 src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_registry.ts create mode 100644 src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_types.ts rename src/plugins/{discover => discover_legacy}/public/application/embeddable/constants.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/embeddable/index.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/embeddable/search_embeddable.scss (100%) rename src/plugins/{discover => discover_legacy}/public/application/embeddable/search_embeddable.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/embeddable/search_embeddable_factory.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/embeddable/search_template.html (100%) rename src/plugins/{discover => discover_legacy}/public/application/embeddable/types.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/helpers/breadcrumbs.ts create mode 100644 src/plugins/discover_legacy/public/application/helpers/format_number_with_commas.ts create mode 100644 src/plugins/discover_legacy/public/application/helpers/get_index_pattern_id.ts rename src/plugins/{discover => discover_legacy}/public/application/helpers/get_switch_index_pattern_app_state.test.ts (100%) rename src/plugins/{discover => discover_legacy}/public/application/helpers/get_switch_index_pattern_app_state.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/helpers/index.ts create mode 100644 src/plugins/discover_legacy/public/application/helpers/migrate_legacy_query.ts create mode 100644 src/plugins/discover_legacy/public/application/helpers/popularize_field.test.ts create mode 100644 src/plugins/discover_legacy/public/application/helpers/popularize_field.ts create mode 100644 src/plugins/discover_legacy/public/application/helpers/shorten_dotted_string.ts create mode 100644 src/plugins/discover_legacy/public/application/helpers/validate_time_range.test.ts create mode 100644 src/plugins/discover_legacy/public/application/helpers/validate_time_range.ts rename src/plugins/{discover => discover_legacy}/public/application/index.scss (100%) create mode 100644 src/plugins/discover_legacy/public/build_services.ts rename src/plugins/{discover => discover_legacy}/public/get_inner_angular.ts (100%) create mode 100644 src/plugins/discover_legacy/public/index.ts create mode 100644 src/plugins/discover_legacy/public/mocks.ts create mode 100644 src/plugins/discover_legacy/public/opensearch_dashboards_services.ts create mode 100644 src/plugins/discover_legacy/public/plugin.ts create mode 100644 src/plugins/discover_legacy/public/saved_searches/_saved_search.ts create mode 100644 src/plugins/discover_legacy/public/saved_searches/index.ts create mode 100644 src/plugins/discover_legacy/public/saved_searches/saved_searches.ts create mode 100644 src/plugins/discover_legacy/public/saved_searches/types.ts create mode 100644 src/plugins/discover_legacy/public/url_generator.test.ts create mode 100644 src/plugins/discover_legacy/public/url_generator.ts create mode 100644 src/test_utils/public/testing_lib_helpers.tsx diff --git a/packages/osd-stylelint-config/config/global_selectors.json b/packages/osd-stylelint-config/config/global_selectors.json index 19451066878..99b2db2dfb4 100644 --- a/packages/osd-stylelint-config/config/global_selectors.json +++ b/packages/osd-stylelint-config/config/global_selectors.json @@ -23,8 +23,8 @@ "src/plugins/vis_builder/public/application/components/searchable_dropdown.scss", "src/plugins/vis_builder/public/application/components/side_nav.scss", "packages/osd-ui-framework/src/components/button/button_group/_button_group.scss", - "src/plugins/discover/public/application/components/sidebar/discover_sidebar.scss", - "src/plugins/discover/public/application/angular/doc_table/components/table_row/_open.scss" + "src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss", + "src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss" ] } -} +} \ No newline at end of file diff --git a/packages/osd-stylelint-config/config/restricted_properties.json b/packages/osd-stylelint-config/config/restricted_properties.json index b69012cb61f..d229764c8d8 100644 --- a/packages/osd-stylelint-config/config/restricted_properties.json +++ b/packages/osd-stylelint-config/config/restricted_properties.json @@ -2,7 +2,7 @@ "font-family": { "explanation": "All \"font-family\" styles should be inherited from OUI themes and components. Remove the rule.", "approved": [ - "src/plugins/discover/public/application/_discover.scss", + "src/plugins/discover_legacy/public/application/_discover.scss", "src/plugins/maps_legacy/public/map/_leaflet_overrides.scss", "src/plugins/maps_legacy/public/map/_legend.scss", "src/plugins/opensearch_dashboards_legacy/public/font_awesome/font_awesome.scss", @@ -12,7 +12,8 @@ "src/plugins/data/public/ui/typeahead/_suggestion.scss", "src/plugins/vis_type_timeseries/public/application/components/_error.scss", "packages/osd-ui-framework/src/components/form/check_box/_check_box.scss", - "src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss" + "src/plugins/discover/public/application/components/doc_viewer/doc_viewer.scss", + "src/plugins/discover_legacy/public/application/components/doc_viewer/doc_viewer.scss" ] } -} +} \ No newline at end of file diff --git a/src/plugins/data_explorer/.i18nrc.json b/src/plugins/data_explorer/.i18nrc.json new file mode 100644 index 00000000000..1ea4ccdd1e7 --- /dev/null +++ b/src/plugins/data_explorer/.i18nrc.json @@ -0,0 +1,7 @@ +{ + "prefix": "dataExplorer", + "paths": { + "dataExplorer": "." + }, + "translations": ["translations/ja-JP.json"] +} diff --git a/src/plugins/data_explorer/README.md b/src/plugins/data_explorer/README.md new file mode 100755 index 00000000000..8ea14c17d42 --- /dev/null +++ b/src/plugins/data_explorer/README.md @@ -0,0 +1,11 @@ +# dataExplorer + +A OpenSearch Dashboards plugin + +--- + +## Development + +See the [OpenSearch Dashboards contributing +guide](https://github.com/opensearch-project/OpenSearch-Dashboards/blob/main/CONTRIBUTING.md) for instructions +setting up your development environment. diff --git a/src/plugins/data_explorer/common/index.ts b/src/plugins/data_explorer/common/index.ts new file mode 100644 index 00000000000..60d61f38b74 --- /dev/null +++ b/src/plugins/data_explorer/common/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const PLUGIN_ID = 'data-explorer'; +export const PLUGIN_NAME = 'Data Explorer'; diff --git a/src/plugins/data_explorer/opensearch_dashboards.json b/src/plugins/data_explorer/opensearch_dashboards.json new file mode 100644 index 00000000000..23db353b2cc --- /dev/null +++ b/src/plugins/data_explorer/opensearch_dashboards.json @@ -0,0 +1,18 @@ +{ + "id": "dataExplorer", + "version": "1.0.0", + "opensearchDashboardsVersion": "opensearchDashboards", + "server": true, + "ui": true, + "requiredPlugins": [ + "data", + "navigation", + "embeddable", + "expressions" + ], + "optionalPlugins": [], + "requiredBundles": [ + "opensearchDashboardsReact", + "opensearchDashboardsUtils" + ] +} \ No newline at end of file diff --git a/src/plugins/data_explorer/public/application.tsx b/src/plugins/data_explorer/public/application.tsx new file mode 100644 index 00000000000..ae57070f745 --- /dev/null +++ b/src/plugins/data_explorer/public/application.tsx @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { Provider as ReduxProvider } from 'react-redux'; +import { Router, Route, Switch } from 'react-router-dom'; +import { AppMountParameters, CoreStart } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +import { DataExplorerServices } from './types'; +import { DataExplorerApp } from './components/app'; +import { Store } from './utils/state_management'; + +export const renderApp = ( + core: CoreStart, + services: DataExplorerServices, + params: AppMountParameters, + store: Store +) => { + const { history, element } = params; + ReactDOM.render( + + + + + + + + + + + + + , + element + ); + + return () => ReactDOM.unmountComponentAtNode(element); +}; diff --git a/src/plugins/data_explorer/public/components/__snapshots__/app_container.test.tsx.snap b/src/plugins/data_explorer/public/components/__snapshots__/app_container.test.tsx.snap new file mode 100644 index 00000000000..29b8e5ab54e --- /dev/null +++ b/src/plugins/data_explorer/public/components/__snapshots__/app_container.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DataExplorerApp should render the canvas and panel when selected 1`] = ` +
+
+
+ Context +
+
+
+`; diff --git a/src/plugins/data_explorer/public/components/app.tsx b/src/plugins/data_explorer/public/components/app.tsx new file mode 100644 index 00000000000..ff6b5931a40 --- /dev/null +++ b/src/plugins/data_explorer/public/components/app.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { useLocation } from 'react-router-dom'; +import { AppMountParameters } from '../../../../core/public'; +import { useView } from '../utils/use'; +import { AppContainer } from './app_container'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { DataExplorerServices } from '../types'; +import { syncQueryStateWithUrl } from '../../../data/public'; + +export const DataExplorerApp = ({ params }: { params: AppMountParameters }) => { + const { view } = useView(); + const { + services: { + data: { query }, + osdUrlStateStorage, + }, + } = useOpenSearchDashboards(); + const { pathname } = useLocation(); + + useEffect(() => { + // syncs `_g` portion of url with query services + const { stop } = syncQueryStateWithUrl(query, osdUrlStateStorage); + + return () => stop(); + + // this effect should re-run when pathname is changed to preserve querystring part, + // so the global state is always preserved + }, [query, osdUrlStateStorage, pathname]); + + return ; +}; diff --git a/src/plugins/data_explorer/public/components/app_container.scss b/src/plugins/data_explorer/public/components/app_container.scss new file mode 100644 index 00000000000..d289e7d4be3 --- /dev/null +++ b/src/plugins/data_explorer/public/components/app_container.scss @@ -0,0 +1,18 @@ +$osdHeaderOffset: $euiHeaderHeightCompensation; + +.deSidebar { + max-width: 462px; + min-width: 400px; +} + +.deLayout { + height: calc(100vh - #{$osdHeaderOffset}); + + &__canvas { + height: 100%; + } +} + +.headerIsExpanded .deLayout { + height: calc(100vh - #{$osdHeaderOffset * 2}); +} diff --git a/src/plugins/data_explorer/public/components/app_container.test.tsx b/src/plugins/data_explorer/public/components/app_container.test.tsx new file mode 100644 index 00000000000..14175369490 --- /dev/null +++ b/src/plugins/data_explorer/public/components/app_container.test.tsx @@ -0,0 +1,45 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { AppContainer } from './app_container'; +import { View } from '../services/view_service/view'; +import { AppMountParameters } from '../../../../core/public'; +import { render } from 'test_utils/testing_lib_helpers'; + +describe('DataExplorerApp', () => { + const createView = () => { + return new View({ + id: 'test-view', + title: 'Test View', + defaultPath: '/test-path', + appExtentions: {} as any, + Canvas: (() =>
canvas
) as any, + Panel: (() =>
panel
) as any, + Context: (() =>
Context
) as any, + }); + }; + + const params: AppMountParameters = { + element: document.createElement('div'), + history: {} as any, + onAppLeave: jest.fn(), + setHeaderActionMenu: jest.fn(), + appBasePath: '', + }; + + it('should render NoView when a non existent view is selected', () => { + const { container } = render(); + + expect(container).toContainHTML('View not found'); + }); + + it('should render the canvas and panel when selected', () => { + const view = createView(); + const { container } = render(); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data_explorer/public/components/app_container.tsx b/src/plugins/data_explorer/public/components/app_container.tsx new file mode 100644 index 00000000000..8f37e9c1230 --- /dev/null +++ b/src/plugins/data_explorer/public/components/app_container.tsx @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { Suspense } from 'react'; +import { AppMountParameters } from '../../../../core/public'; +import { Sidebar } from './sidebar'; +import { NoView } from './no_view'; +import { View } from '../services/view_service/view'; +import './app_container.scss'; + +export const AppContainer = ({ view, params }: { view?: View; params: AppMountParameters }) => { + // TODO: Make this more robust. + if (!view) { + return ; + } + + const { Canvas, Panel, Context } = view; + + // Render the application DOM. + return ( + + {/* TODO: improve fallback state */} + Loading...}> + + + + + + + + + + + ); +}; diff --git a/src/plugins/data_explorer/public/components/no_view.tsx b/src/plugins/data_explorer/public/components/no_view.tsx new file mode 100644 index 00000000000..a341e9d0564 --- /dev/null +++ b/src/plugins/data_explorer/public/components/no_view.tsx @@ -0,0 +1,40 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiPageTemplate, EuiEmptyPrompt } from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; + +export const NoView = () => { + return ( + + + + + } + body={ +

+ +

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

{formattedValue}

+
+ ); + } + + return formattedValue; + }; + + public render() { + const { chartData, services } = this.props; + const { uiSettings } = services; + const timeZone = getTimezone(uiSettings); + const chartsTheme = services.theme.chartsDefaultTheme; + const chartsBaseTheme = services.theme.chartsDefaultBaseTheme; + + if (!chartData) { + return null; + } + + const data = chartData.values; + + /** + * Deprecation: [interval] on [date_histogram] is deprecated, use [fixed_interval] or [calendar_interval]. + * see https://github.com/elastic/kibana/issues/27410 + * TODO: Once the Discover query has been update, we should change the below to use the new field + */ + const { intervalOpenSearchValue, intervalOpenSearchUnit, interval } = chartData.ordered; + const xInterval = interval.asMilliseconds(); + + const xValues = chartData.xAxisOrderedValues; + const lastXValue = xValues[xValues.length - 1]; + + const domain = chartData.ordered; + const domainStart = domain.min.valueOf(); + const domainEnd = domain.max.valueOf(); + + const domainMin = data[0]?.x > domainStart ? domainStart : data[0]?.x; + const domainMax = domainEnd - xInterval > lastXValue ? domainEnd - xInterval : lastXValue; + + const xDomain = { + min: domainMin, + max: domainMax, + minInterval: findMinInterval( + xValues, + intervalOpenSearchValue, + intervalOpenSearchUnit, + timeZone + ), + }; + + // Domain end of 'now' will be milliseconds behind current time, so we extend time by 1 minute and check if + // the annotation is within this range; if so, the line annotation uses the domainEnd as its value + const now = moment(); + const isAnnotationAtEdge = moment(domainEnd).add(60000).isAfter(now) && now.isAfter(domainEnd); + const lineAnnotationValue = isAnnotationAtEdge ? domainEnd : now; + + const lineAnnotationData = [ + { + dataValue: lineAnnotationValue, + }, + ]; + const isDarkMode = uiSettings.get('theme:darkMode'); + + const lineAnnotationStyle = { + line: { + strokeWidth: 2, + stroke: euiThemeVars.euiColorDanger, + opacity: 0.7, + }, + }; + + const rectAnnotations = []; + if (domainStart !== domainMin) { + rectAnnotations.push({ + coordinates: { + x1: domainStart, + }, + }); + } + if (domainEnd !== domainMax) { + rectAnnotations.push({ + coordinates: { + x0: domainEnd, + }, + }); + } + + const rectAnnotationStyle = { + stroke: isDarkMode ? euiThemeVars.euiColorLightShade : euiThemeVars.euiColorDarkShade, + strokeWidth: 0, + opacity: isDarkMode ? 0.6 : 0.2, + fill: isDarkMode ? euiThemeVars.euiColorLightShade : euiThemeVars.euiColorDarkShade, + }; + + const tooltipProps = { + headerFormatter: this.renderBarTooltip(xInterval, domainStart, domainEnd), + type: TooltipType.VerticalCursor, + }; + + return ( + + + + + + + + + ); + } +} diff --git a/src/plugins/discover/public/application/components/chart/histogram/type.ts b/src/plugins/discover/public/application/components/chart/histogram/type.ts new file mode 100644 index 00000000000..64c5a112473 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/histogram/type.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface Chart { + values: Array<{ + x: number; + y: number; + }>; + xAxisOrderedValues: number[]; + xAxisFormat: Dimension['format']; + xAxisLabel: Column['name']; + yAxisLabel?: Column['name']; + ordered: Ordered; +} diff --git a/src/plugins/discover/public/application/components/hits_counter/hits_counter.test.tsx b/src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.test.tsx similarity index 100% rename from src/plugins/discover/public/application/components/hits_counter/hits_counter.test.tsx rename to src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.test.tsx diff --git a/src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.tsx b/src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.tsx new file mode 100644 index 00000000000..fede3e04ecd --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/hits_counter/hits_counter.tsx @@ -0,0 +1,95 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { EuiButtonEmpty, EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { formatNumWithCommas } from '../../../helpers'; + +export interface HitsCounterProps { + /** + * the number of query hits + */ + hits: number; + /** + * displays the reset button + */ + showResetButton: boolean; + /** + * resets the query + */ + onResetQuery: () => void; +} + +export function HitsCounter({ hits, showResetButton, onResetQuery }: HitsCounterProps) { + return ( + + + + + {formatNumWithCommas(hits)}{' '} + + + + {showResetButton && ( + + + + + + )} + + + ); +} diff --git a/src/plugins/discover/public/application/components/hits_counter/index.ts b/src/plugins/discover/public/application/components/chart/hits_counter/index.ts similarity index 100% rename from src/plugins/discover/public/application/components/hits_counter/index.ts rename to src/plugins/discover/public/application/components/chart/hits_counter/index.ts diff --git a/src/plugins/discover/public/application/components/chart/timechart_header/index.ts b/src/plugins/discover/public/application/components/chart/timechart_header/index.ts new file mode 100644 index 00000000000..34763a05f8e --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/timechart_header/index.ts @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export * from './timechart_header'; diff --git a/src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx b/src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.test.tsx similarity index 100% rename from src/plugins/discover/public/application/components/timechart_header/timechart_header.test.tsx rename to src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.test.tsx diff --git a/src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.tsx b/src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.tsx new file mode 100644 index 00000000000..a79876ccb45 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/timechart_header/timechart_header.tsx @@ -0,0 +1,189 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + EuiFlexGroup, + EuiFlexItem, + EuiToolTip, + EuiText, + EuiSelect, + EuiIconTip, +} from '@elastic/eui'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import moment from 'moment'; + +export interface TimechartHeaderBucketInterval { + scaled?: boolean; + description?: string; + scale?: number; +} + +export interface TimechartHeaderProps { + /** + * Format of date to be displayed + */ + dateFormat?: string; + /** + * Interval for the buckets of the recent request + */ + bucketInterval?: { + scaled?: boolean; + description?: string; + scale?: number; + }; + /** + * Range of dates to be displayed + */ + timeRange?: { + from: string; + to: string; + }; + /** + * Interval Options + */ + options: Array<{ display: string; val: string }>; + /** + * changes the interval + */ + onChangeInterval: (interval: string) => void; + /** + * selected interval + */ + stateInterval: string; +} + +export function TimechartHeader({ + bucketInterval, + dateFormat, + timeRange, + options, + onChangeInterval, + stateInterval, +}: TimechartHeaderProps) { + const [interval, setInterval] = useState(stateInterval); + const toMoment = useCallback( + (datetime: string) => { + if (!datetime) { + return ''; + } + if (!dateFormat) { + return datetime; + } + return moment(datetime).format(dateFormat); + }, + [dateFormat] + ); + + useEffect(() => { + setInterval(stateInterval); + }, [stateInterval]); + + const handleIntervalChange = (e: React.ChangeEvent) => { + setInterval(e.target.value); + onChangeInterval(e.target.value); + }; + + if (!timeRange || !bucketInterval) { + return null; + } + + return ( + + + + + + {`${toMoment(timeRange.from)} - ${toMoment(timeRange.to)} ${ + interval !== 'auto' + ? i18n.translate('discover.timechartHeader.timeIntervalSelect.per', { + defaultMessage: 'per', + }) + : '' + }`} + + + + + val !== 'custom') + .map(({ display, val }) => { + return { + text: display, + value: val, + label: display, + }; + })} + value={interval} + onChange={handleIntervalChange} + append={ + bucketInterval.scaled ? ( + 1 + ? i18n.translate('discover.bucketIntervalTooltip.tooLargeBucketsText', { + defaultMessage: 'buckets that are too large', + }) + : i18n.translate('discover.bucketIntervalTooltip.tooManyBucketsText', { + defaultMessage: 'too many buckets', + }), + bucketIntervalDescription: bucketInterval.description, + }, + })} + color="warning" + size="s" + type="alert" + /> + ) : undefined + } + /> + + + + ); +} diff --git a/src/plugins/discover/public/application/components/chart/utils/create_histogram_configs.ts b/src/plugins/discover/public/application/components/chart/utils/create_histogram_configs.ts new file mode 100644 index 00000000000..8e659e73f9d --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/create_histogram_configs.ts @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DataPublicPluginStart, IndexPattern } from '../../../../../../data/public'; + +export function createHistogramConfigs( + indexPattern: IndexPattern, + histogramInterval: string, + data: DataPublicPluginStart +) { + const visStateAggs = [ + { + type: 'count', + schema: 'metric', + }, + { + type: 'date_histogram', + schema: 'segment', + params: { + field: indexPattern.timeFieldName!, + interval: histogramInterval, + timeRange: data.query.timefilter.timefilter.getTime(), + }, + }, + ]; + return data.search.aggs.createAggConfigs(indexPattern, visStateAggs); +} diff --git a/src/plugins/discover/public/application/components/chart/utils/get_dimension.test.ts b/src/plugins/discover/public/application/components/chart/utils/get_dimension.test.ts new file mode 100644 index 00000000000..a4f1c889a9b --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/get_dimension.test.ts @@ -0,0 +1,74 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getDimensions } from './get_dimensions'; +import moment from 'moment'; +import dateMath from '@elastic/datemath'; +import { search } from '../../../../../../data/public'; +import { dataPluginMock } from '../../../../../../data/public/mocks'; +import { + calculateBounds, + IBucketDateHistogramAggConfig, + IAggConfigs, +} from '../../../../../../data/common'; + +describe('getDimensions', () => { + it('should return dimensions when buckets and bounds are defined', () => { + const dataMock = dataPluginMock.createStartContract(); + dataMock.query.timefilter.timefilter.getTime = () => { + return { from: '2021-01-01T00:00:00.000Z', to: '2021-01-31T00:00:00.000Z' }; + }; + dataMock.query.timefilter.timefilter.calculateBounds = (timeRange) => { + return calculateBounds(timeRange); + }; + + const metric = { + toSerializedFieldFormat: jest.fn(() => 'metric-format'), + makeLabel: jest.fn(() => 'metric-label'), + }; + const agg = { + params: { timeRange: null }, + buckets: { + getInterval: jest.fn(() => ({ opensearchUnit: 'day', opensearchValue: 1 })), + getScaledDateFormat: jest.fn(() => 'scaled-date-format'), + getBounds: jest.fn(() => 'bounds'), + }, + makeLabel: jest.fn(() => 'agg-label'), + toSerializedFieldFormat: jest.fn(() => 'agg-format'), + }; + const aggs: IAggConfigs = { + aggs: [metric, agg], + }; + + // Mocking external dependencies + dateMath.parse = jest.fn((date, options) => moment(date)); + search.aggs.isDateHistogramBucketAggConfig = jest.fn( + (bucketAgg: any): bucketAgg is IBucketDateHistogramAggConfig => true + ); + + const result = getDimensions(aggs, dataMock); + + expect(result).toEqual({ + x: { + accessor: 0, + label: 'agg-label', + format: 'agg-format', + params: { + date: true, + interval: moment.duration(1, 'day'), + intervalOpenSearchValue: 1, + intervalOpenSearchUnit: 'day', + format: 'scaled-date-format', + bounds: 'bounds', + }, + }, + y: { + accessor: 1, + format: 'metric-format', + label: 'metric-label', + }, + }); + }); +}); diff --git a/src/plugins/discover/public/application/components/chart/utils/get_dimensions.ts b/src/plugins/discover/public/application/components/chart/utils/get_dimensions.ts new file mode 100644 index 00000000000..a1292e8aefa --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/get_dimensions.ts @@ -0,0 +1,73 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; +import dateMath from '@elastic/datemath'; +import { IAggConfigs } from '../../../../../../data/common'; +import { search } from '../../../../../../data/public'; + +export function getDimensions(aggs: IAggConfigs, data: any) { + const [metric, agg] = aggs.aggs; + const { from, to } = data.query.timefilter.timefilter.getTime(); + agg.params.timeRange = { + from: dateMath.parse(from), + to: dateMath.parse(to, { roundUp: true }), + }; + const bounds = agg.params.timeRange + ? data.query.timefilter.timefilter.calculateBounds(agg.params.timeRange) + : null; + const buckets = search.aggs.isDateHistogramBucketAggConfig(agg) ? agg.buckets : undefined; + + if (!buckets || !bounds) { + return; + } + + const { opensearchUnit, opensearchValue } = buckets.getInterval(); + return { + x: { + accessor: 0, + label: agg.makeLabel(), + format: agg.toSerializedFieldFormat(), + params: { + date: true, + interval: moment.duration(opensearchValue, opensearchUnit), + intervalOpenSearchValue: opensearchValue, + intervalOpenSearchUnit: opensearchUnit, + format: buckets.getScaledDateFormat(), + bounds: buckets.getBounds(), + }, + }, + y: { + accessor: 1, + format: metric.toSerializedFieldFormat(), + label: metric.makeLabel(), + }, + }; +} diff --git a/src/plugins/discover/public/application/components/chart/utils/index.ts b/src/plugins/discover/public/application/components/chart/utils/index.ts new file mode 100644 index 00000000000..36aa4015782 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './create_histogram_configs'; +export * from './point_series'; +export * from './get_dimensions'; diff --git a/src/plugins/discover/public/application/components/chart/utils/point_series.test.ts b/src/plugins/discover/public/application/components/chart/utils/point_series.test.ts new file mode 100644 index 00000000000..7348033e834 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/point_series.test.ts @@ -0,0 +1,62 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildPointSeriesData, Dimensions, Table } from './point_series'; +import moment from 'moment'; + +describe('buildPointSeriesData', () => { + it('should build the chart data from the table and dimensions', () => { + const table: Table = { + columns: [ + { id: 'x', name: 'X Axis' }, + { id: 'y', name: 'Y Axis' }, + ], + rows: [ + { x: 10, y: 100 }, + { x: 20, y: 200 }, + { x: 10, y: 'NaN' }, // This row should be ignored + ], + }; + + const dimensions: Dimensions = { + x: { + accessor: 0, + format: { id: 'number', params: { pattern: 'number' } }, + params: { + date: true, + interval: moment.duration(1, 'hour'), + intervalOpenSearchValue: 1, + intervalOpenSearchUnit: 'h', + format: 'number', + bounds: { + min: moment('2023-01-01'), + max: moment('2023-01-02'), + }, + }, + }, + y: { + accessor: 1, + format: { id: 'number', params: { pattern: 'number' } }, + }, + }; + + const result = buildPointSeriesData(table, dimensions); + + expect(result.xAxisOrderedValues).toEqual([10, 20]); + expect(result.xAxisFormat).toEqual(dimensions.x.format); + expect(result.xAxisLabel).toEqual('X Axis'); + expect(result.ordered.date).toBe(true); + expect(result.ordered.interval.asHours()).toBe(1); + expect(result.ordered.intervalOpenSearchUnit).toBe('h'); + expect(result.ordered.intervalOpenSearchValue).toBe(1); + expect(result.ordered.min.format()).toBe('2023-01-01T00:00:00+00:00'); + expect(result.ordered.max.format()).toBe('2023-01-02T00:00:00+00:00'); + expect(result.yAxisLabel).toEqual('Y Axis'); + expect(result.values).toEqual([ + { x: 10, y: 100 }, + { x: 20, y: 200 }, + ]); + }); +}); diff --git a/src/plugins/discover/public/application/components/chart/utils/point_series.ts b/src/plugins/discover/public/application/components/chart/utils/point_series.ts new file mode 100644 index 00000000000..4c3e3e9ada5 --- /dev/null +++ b/src/plugins/discover/public/application/components/chart/utils/point_series.ts @@ -0,0 +1,122 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { uniq } from 'lodash'; +import { Duration, Moment } from 'moment'; +import { Unit } from '@elastic/datemath'; + +import { SerializedFieldFormat } from '../../../../../../expressions/common/types'; + +export interface Column { + id: string; + name: string; +} + +export interface Row { + [key: string]: number | 'NaN'; +} + +export interface Table { + columns: Column[]; + rows: Row[]; +} + +interface HistogramParams { + date: true; + interval: Duration; + intervalOpenSearchValue: number; + intervalOpenSearchUnit: Unit; + format: string; + bounds: { + min: Moment; + max: Moment; + }; +} +export interface Dimension { + accessor: 0 | 1; + format: SerializedFieldFormat<{ pattern: string }>; +} + +export interface Dimensions { + x: Dimension & { params: HistogramParams }; + y: Dimension; +} + +interface Ordered { + date: true; + interval: Duration; + intervalOpenSearchUnit: string; + intervalOpenSearchValue: number; + min: Moment; + max: Moment; +} +export interface Chart { + values: Array<{ + x: number; + y: number; + }>; + xAxisOrderedValues: number[]; + xAxisFormat: Dimension['format']; + xAxisLabel: Column['name']; + yAxisLabel?: Column['name']; + ordered: Ordered; +} + +export const buildPointSeriesData = (table: Table, dimensions: Dimensions) => { + const { x, y } = dimensions; + const xAccessor = table.columns[x.accessor].id; + const yAccessor = table.columns[y.accessor].id; + const chart = {} as Chart; + + chart.xAxisOrderedValues = uniq(table.rows.map((r) => r[xAccessor] as number)); + chart.xAxisFormat = x.format; + chart.xAxisLabel = table.columns[x.accessor].name; + + const { intervalOpenSearchUnit, intervalOpenSearchValue, interval, bounds } = x.params; + chart.ordered = { + date: true, + interval, + intervalOpenSearchUnit, + intervalOpenSearchValue, + min: bounds.min, + max: bounds.max, + }; + + chart.yAxisLabel = table.columns[y.accessor].name; + + chart.values = table.rows + .filter((row) => row && row[yAccessor] !== 'NaN') + .map((row) => ({ + x: row[xAccessor] as number, + y: row[yAccessor] as number, + })); + + return chart; +}; diff --git a/src/plugins/discover/public/application/components/data_grid/constants.ts b/src/plugins/discover/public/application/components/data_grid/constants.ts new file mode 100644 index 00000000000..be96468a3a0 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/constants.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const toolbarVisibility = { + showColumnSelector: { + allowHide: false, + allowReorder: true, + }, + showStyleSelector: false, + showFullScreenSelector: false, +}; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx new file mode 100644 index 00000000000..596e0b97ae0 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table.tsx @@ -0,0 +1,189 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { EuiDataGrid, EuiDataGridSorting, EuiPanel } from '@elastic/eui'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { fetchTableDataCell } from './data_grid_table_cell_value'; +import { buildDataGridColumns, computeVisibleColumns } from './data_grid_table_columns'; +import { DocViewInspectButton } from './data_grid_table_docview_inspect_button'; +import { DataGridFlyout } from './data_grid_table_flyout'; +import { DiscoverGridContextProvider } from './data_grid_table_context'; +import { toolbarVisibility } from './constants'; +import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types'; +import { DiscoverServices } from '../../../build_services'; +import { usePagination } from '../utils/use_pagination'; +import { SortOrder } from '../../../saved_searches/types'; +import { buildColumns } from '../../utils/columns'; + +export interface DataGridTableProps { + columns: string[]; + indexPattern: IndexPattern; + onAddColumn: (column: string) => void; + onFilter: DocViewFilterFn; + onRemoveColumn: (column: string) => void; + onSort: (sort: SortOrder[]) => void; + rows: OpenSearchSearchHit[]; + onSetColumns: (columns: string[]) => void; + sort: SortOrder[]; + displayTimeColumn: boolean; + services: DiscoverServices; + isToolbarVisible?: boolean; + isContextView?: boolean; +} + +export const DataGridTable = ({ + columns, + indexPattern, + onAddColumn, + onFilter, + onRemoveColumn, + onSetColumns, + onSort, + sort, + rows, + displayTimeColumn, + isToolbarVisible = true, + isContextView = false, +}: DataGridTableProps) => { + const [inspectedHit, setInspectedHit] = useState(); + const rowCount = useMemo(() => (rows ? rows.length : 0), [rows]); + const pagination = usePagination(rowCount); + + let adjustedColumns = buildColumns(columns); + // handle case where the user removes selected filed and leaves only time column + if ( + adjustedColumns.length === 1 && + indexPattern && + adjustedColumns[0] === indexPattern.timeFieldName + ) { + adjustedColumns = [...adjustedColumns, '_source']; + } + + const includeSourceInColumns = adjustedColumns.includes('_source'); + const sortingColumns = useMemo(() => sort.map(([id, direction]) => ({ id, direction })), [sort]); + const rowHeightsOptions = useMemo( + () => ({ + defaultHeight: { + lineCount: adjustedColumns.includes('_source') ? 3 : 1, + }, + }), + [adjustedColumns] + ); + + const onColumnSort = useCallback( + (cols: EuiDataGridSorting['columns']) => { + onSort(cols.map(({ id, direction }) => [id, direction])); + }, + [onSort] + ); + + const renderCellValue = useMemo(() => fetchTableDataCell(indexPattern, rows), [ + indexPattern, + rows, + ]); + + const dataGridTableColumns = useMemo( + () => + buildDataGridColumns( + adjustedColumns, + indexPattern, + displayTimeColumn, + includeSourceInColumns, + isContextView + ), + [adjustedColumns, indexPattern, displayTimeColumn, includeSourceInColumns, isContextView] + ); + + const dataGridTableColumnsVisibility = useMemo( + () => ({ + visibleColumns: computeVisibleColumns( + adjustedColumns, + indexPattern, + displayTimeColumn + ) as string[], + setVisibleColumns: (cols: string[]) => { + onSetColumns(cols); + }, + }), + [adjustedColumns, indexPattern, displayTimeColumn, onSetColumns] + ); + + const sorting: EuiDataGridSorting = useMemo( + () => ({ columns: sortingColumns, onSort: onColumnSort }), + [sortingColumns, onColumnSort] + ); + + const leadingControlColumns = useMemo(() => { + return [ + { + id: 'inspectCollapseColumn', + headerCellRender: () => null, + rowCellRender: DocViewInspectButton, + width: 40, + }, + ]; + }, []); + + const table = useMemo( + () => ( + + ), + [ + dataGridTableColumns, + dataGridTableColumnsVisibility, + leadingControlColumns, + pagination, + renderCellValue, + rowCount, + sorting, + isToolbarVisible, + rowHeightsOptions, + ] + ); + + return ( + + <> + + + {table} + + + {inspectedHit && ( + setInspectedHit(undefined)} + /> + )} + + + ); +}; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_actions.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_actions.tsx new file mode 100644 index 00000000000..3d81ba1afb3 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_actions.tsx @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiDataGridColumnCellActionProps } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { IndexPatternField } from '../../../../../data/common'; +import { useDataGridContext } from './data_grid_table_context'; + +export function getCellActions(field: IndexPatternField) { + const cellActions = field.filterable + ? [ + ({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => { + const { indexPattern, rows, onFilter } = useDataGridContext(); + + const filterForValueText = i18n.translate('discover.filterForValue', { + defaultMessage: 'Filter for value', + }); + const filterForValueLabel = i18n.translate('discover.filterForValueLabel', { + defaultMessage: 'Filter for value: {value}', + values: { value: columnId }, + }); + + return ( + { + const row = rows[rowIndex]; + const flattened = indexPattern.flattenHit(row); + + if (flattened) { + onFilter(columnId, flattened[columnId], '+'); + } + }} + iconType="plusInCircle" + aria-label={filterForValueLabel} + data-test-subj="filterForValue" + > + {filterForValueText} + + ); + }, + ({ rowIndex, columnId, Component }: EuiDataGridColumnCellActionProps) => { + const { indexPattern, rows, onFilter } = useDataGridContext(); + + const filterOutValueText = i18n.translate('discover.filterOutValue', { + defaultMessage: 'Filter out value', + }); + const filterOutValueLabel = i18n.translate('discover.filterOutValueLabel', { + defaultMessage: 'Filter out value: {value}', + values: { value: columnId }, + }); + + return ( + { + const row = rows[rowIndex]; + const flattened = indexPattern.flattenHit(row); + + if (flattened) { + onFilter(columnId, flattened[columnId], '-'); + } + }} + iconType="minusInCircle" + aria-label={filterOutValueLabel} + data-test-subj="filterOutValue" + > + {filterOutValueText} + + ); + }, + ] + : undefined; + return cellActions; +} diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.test.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.test.tsx new file mode 100644 index 00000000000..e9c867a53db --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.test.tsx @@ -0,0 +1,187 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { shallow } from 'enzyme'; + +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { + indexPatternMock, + getMockedIndexPatternWithCustomizedFields, +} from '../../../__mock__/index_pattern_mock'; +import { fetchTableDataCell } from './data_grid_table_cell_value'; + +const fieldsData = [ + { + name: 'name', + scripted: false, + filterable: true, + aggregatable: false, + searchable: true, + sortable: false, + }, + { + name: 'currency', + type: 'string', + scripted: false, + filterable: true, + aggregatable: true, + searchable: true, + sortable: true, + }, + { + name: 'order_date', + type: 'date', + scripted: false, + filterable: true, + aggregatable: true, + searchable: true, + sortable: true, + }, +]; + +const dataRowsMock = [ + { + _id: '1', + _index: 'test_index', + _score: 0, + _source: { + name: 'Eddie', + currency: 'EUR', + order_date: '2023-08-07T09:28:48+00:00', + }, + fields: { + order_date: ['2023-08-07T09:28:48.000Z'], + }, + _version: 1, + _type: '_doc', + }, +]; + +const customizedIndexPatternMock = getMockedIndexPatternWithCustomizedFields( + fieldsData +) as IndexPattern; + +describe('Testing fetchTableDataCell function', () => { + it('should display empty span if no data', () => { + const DataGridTableCellValue = fetchTableDataCell(indexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + - + + `); + }); + + it('should display empty span if field is not defined in index pattern', () => { + const DataGridTableCellValue = fetchTableDataCell(indexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + - + + `); + }); + + it('should display JSON string representation of the data if columnId is _source and isDetails is false', () => { + const DataGridTableCellValue = fetchTableDataCell(customizedIndexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + { + "name": "Eddie", + "currency": "EUR", + "order_date": "2023-08-07T09:28:48+00:00" + } + + `); + }); + + it('should display EuiDescriptionList if columnId is _source and isDetails is false', () => { + const DataGridTableCellValue = fetchTableDataCell(customizedIndexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + + order_date + + + + `); + }); + + it('should correctly display data if columnId is in index pattern and is not _source', () => { + const DataGridTableCellValue = fetchTableDataCell(customizedIndexPatternMock, dataRowsMock); + const comp = shallow( + + ); + + expect(comp).toMatchInlineSnapshot(` + + `); + }); +}); diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.tsx new file mode 100644 index 00000000000..2e39dde3ba0 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.tsx @@ -0,0 +1,80 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Fragment } from 'react'; +import dompurify from 'dompurify'; + +import { + EuiDataGridCellValueElementProps, + EuiDescriptionList, + EuiDescriptionListTitle, + EuiDescriptionListDescription, +} from '@elastic/eui'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { OpenSearchSearchHit } from '../../doc_views/doc_views_types'; + +function fetchSourceTypeDataCell( + idxPattern: IndexPattern, + row: Record, + columnId: string, + isDetails: boolean +) { + if (isDetails) { + return {JSON.stringify(row[columnId], null, 2)}; + } + const formattedRow = idxPattern.formatHit(row); + + return ( + + {Object.keys(formattedRow).map((key) => ( + + {key} + + + ))} + + ); +} + +export const fetchTableDataCell = ( + idxPattern: IndexPattern, + dataRows: OpenSearchSearchHit[] | undefined +) => ({ rowIndex, columnId, isDetails }: EuiDataGridCellValueElementProps) => { + const singleRow = dataRows ? (dataRows[rowIndex] as Record) : undefined; + const flattenedRows = dataRows ? dataRows.map((hit) => idxPattern.flattenHit(hit)) : []; + const flattenedRow = flattenedRows + ? (flattenedRows[rowIndex] as Record) + : undefined; + const fieldInfo = idxPattern.fields.getByName(columnId); + + if (typeof singleRow === 'undefined' || typeof flattenedRow === 'undefined') { + return -; + } + + if (!fieldInfo?.type && flattenedRow && typeof flattenedRow[columnId] === 'object') { + if (isDetails) { + return {JSON.stringify(flattenedRow[columnId], null, 2)}; + } + + return {JSON.stringify(flattenedRow[columnId])}; + } + + if (fieldInfo?.type === '_source') { + return fetchSourceTypeDataCell(idxPattern, singleRow, columnId, isDetails); + } + + const formattedValue = idxPattern.formatField(singleRow, columnId); + if (typeof formattedValue === 'undefined') { + return -; + } else { + const sanitizedCellValue = dompurify.sanitize(idxPattern.formatField(singleRow, columnId)); + return ( + // eslint-disable-next-line react/no-danger + + ); + } +}; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx new file mode 100644 index 00000000000..3123b0a1a21 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.test.tsx @@ -0,0 +1,267 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { + getMockedIndexPatternWithCustomizedFields, + getMockedIndexPatternWithTimeField, +} from '../../../__mock__/index_pattern_mock'; +import { buildDataGridColumns, computeVisibleColumns } from './data_grid_table_columns'; + +const fieldsData = [ + { + name: 'name', + scripted: false, + filterable: true, + aggregatable: false, + searchable: true, + sortable: false, + }, + { + name: 'currency', + type: 'string', + scripted: false, + filterable: true, + aggregatable: true, + searchable: true, + sortable: true, + }, + { + name: 'order_date', + type: 'date', + scripted: false, + filterable: true, + aggregatable: true, + searchable: true, + sortable: true, + }, +]; + +const customizedIndexPatternMock = getMockedIndexPatternWithCustomizedFields( + fieldsData +) as IndexPattern; +const customizedIndexPatternMockWithTimeField = getMockedIndexPatternWithTimeField( + fieldsData, + 'order_date' +) as IndexPattern; + +describe('Testing buildDataGridColumns function ', () => { + it('should return correct columns without time column when displayTimeColumn is false', () => { + const columns = buildDataGridColumns(['name', 'currency'], customizedIndexPatternMock, false); + expect(columns).toHaveLength(2); + expect(columns[0].id).toEqual('name'); + expect(columns[1].id).toEqual('currency'); + expect(columns).toMatchInlineSnapshot(` + Array [ + Object { + "actions": Object { + "showHide": Object { + "iconType": "cross", + "label": "Remove column", + }, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": undefined, + "display": undefined, + "id": "name", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": Object { + "iconType": "cross", + "label": "Remove column", + }, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": undefined, + "display": undefined, + "id": "currency", + "isSortable": undefined, + "schema": undefined, + }, + ] + `); + }); + + it('should add time and source columns correctly when displayTimeColumn is true', () => { + const columns = buildDataGridColumns( + ['name', 'currency', '_source'], + customizedIndexPatternMockWithTimeField, + true + ); + expect(columns).toHaveLength(4); + expect(columns[0].id).toEqual('order_date'); + expect(columns[0].display).toEqual('Time (order_date)'); + expect(columns[3].id).toEqual('_source'); + expect(columns[3].display).toEqual('Source'); + expect(columns).toMatchInlineSnapshot(` + Array [ + Object { + "actions": Object { + "showHide": false, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": undefined, + "display": "Time (order_date)", + "id": "order_date", + "initialWidth": 200, + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": Object { + "iconType": "cross", + "label": "Remove column", + }, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": undefined, + "display": undefined, + "id": "name", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": Object { + "iconType": "cross", + "label": "Remove column", + }, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": undefined, + "display": undefined, + "id": "currency", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": false, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": undefined, + "display": "Source", + "id": "_source", + "isSortable": undefined, + "schema": undefined, + }, + ] + `); + }); + + it('should set display for time column correctly when time field is already included', () => { + const columns = buildDataGridColumns( + ['name', 'currency', 'order_date'], + customizedIndexPatternMockWithTimeField, + true + ); + expect(columns).toHaveLength(3); + expect(columns[2].id).toEqual('order_date'); + expect(columns[2].display).toEqual('Time (order_date)'); + expect(columns).toMatchInlineSnapshot(` + Array [ + Object { + "actions": Object { + "showHide": Object { + "iconType": "cross", + "label": "Remove column", + }, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": undefined, + "display": undefined, + "id": "name", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": Object { + "iconType": "cross", + "label": "Remove column", + }, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": undefined, + "display": undefined, + "id": "currency", + "isSortable": undefined, + "schema": undefined, + }, + Object { + "actions": Object { + "showHide": false, + "showMoveLeft": true, + "showMoveRight": true, + }, + "cellActions": undefined, + "display": "Time (order_date)", + "id": "order_date", + "initialWidth": 200, + "isSortable": undefined, + "schema": undefined, + }, + ] + `); + }); +}); + +describe('Testing computeVisibleColumns function ', () => { + it('should include time column when displayTimeColumn is true and time field is missing', () => { + const visibleColumns = computeVisibleColumns( + ['name', 'currency'], + customizedIndexPatternMock, + true + ); + expect(visibleColumns).toMatchInlineSnapshot(` + Array [ + "order_date", + "name", + "currency", + ] + `); + }); + + it('should not add duplicate time column when displayTimeColumn is true and time field is included', () => { + const visibleColumns = computeVisibleColumns( + ['name', 'currency', 'order_date'], + customizedIndexPatternMock, + true + ); + expect(visibleColumns).toMatchInlineSnapshot(` + Array [ + "name", + "currency", + "order_date", + ] + `); + }); + + it('should not add time column when displayTimeColumn is false', () => { + const visibleColumns = computeVisibleColumns( + ['name', 'currency'], + customizedIndexPatternMock, + false + ); + expect(visibleColumns).toMatchInlineSnapshot(` + Array [ + "name", + "currency", + ] + `); + }); +}); diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx new file mode 100644 index 00000000000..ba80e719491 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_columns.tsx @@ -0,0 +1,88 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiDataGridColumn } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { getCellActions } from './data_grid_table_cell_actions'; + +export function buildDataGridColumns( + columnNames: string[], + idxPattern: IndexPattern, + displayTimeColumn: boolean, + includeSourceInColumns: boolean, + isContextView: boolean +) { + const timeFieldName = idxPattern.timeFieldName; + let columnsToUse = columnNames; + + if (displayTimeColumn && timeFieldName && !columnNames.includes(timeFieldName)) { + columnsToUse = [timeFieldName, ...columnNames]; + } + + return columnsToUse.map((colName) => + generateDataGridTableColumn(colName, idxPattern, includeSourceInColumns, isContextView) + ); +} + +export function generateDataGridTableColumn( + colName: string, + idxPattern: IndexPattern, + includeSourceInColumns: boolean, + isContextView: boolean +) { + const timeLabel = i18n.translate('discover.timeLabel', { + defaultMessage: 'Time', + }); + const idxPatternField = idxPattern.getFieldByName(colName); + const shouldHide = colName === '_source' || colName === idxPattern.timeFieldName; + const dataGridCol: EuiDataGridColumn = { + id: colName, + schema: idxPatternField?.type, + isSortable: idxPatternField?.sortable, + display: idxPatternField?.displayName, + actions: isContextView + ? false + : { + showHide: shouldHide + ? false + : { + label: i18n.translate('discover.removeColumn.label', { + defaultMessage: 'Remove column', + }), + iconType: 'cross', + }, + showMoveLeft: !includeSourceInColumns, + showMoveRight: !includeSourceInColumns, + }, + cellActions: idxPatternField ? getCellActions(idxPatternField) : [], + }; + + if (dataGridCol.id === idxPattern.timeFieldName) { + dataGridCol.display = `${timeLabel} (${idxPattern.timeFieldName})`; + dataGridCol.initialWidth = 200; + } + if (dataGridCol.id === '_source') { + dataGridCol.display = i18n.translate('discover.sourceLabel', { + defaultMessage: 'Source', + }); + } + return dataGridCol; +} + +export function computeVisibleColumns( + columnNames: string[], + idxPattern: IndexPattern, + displayTimeColumn: boolean +) { + const timeFieldName = idxPattern.timeFieldName; + let visibleColumnNames = columnNames; + + if (displayTimeColumn && !columnNames.includes(timeFieldName)) { + visibleColumnNames = [timeFieldName, ...columnNames]; + } + + return visibleColumnNames; +} diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_context.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_context.tsx new file mode 100644 index 00000000000..886b6fa60f8 --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_context.tsx @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types'; + +export interface DataGridContextProps { + inspectedHit?: OpenSearchSearchHit; + onFilter: DocViewFilterFn; + setInspectedHit: (hit?: OpenSearchSearchHit) => void; + rows: OpenSearchSearchHit[]; + indexPattern: IndexPattern; +} + +export const DataGridContext = React.createContext( + {} as DataGridContextProps +); + +export const DiscoverGridContextProvider = DataGridContext.Provider; +export const useDataGridContext = () => React.useContext(DataGridContext); diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_docview_inspect_button.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_docview_inspect_button.tsx new file mode 100644 index 00000000000..df5e691240e --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_docview_inspect_button.tsx @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiToolTip, EuiButtonIcon, EuiDataGridCellValueElementProps } from '@elastic/eui'; +import { useDataGridContext } from './data_grid_table_context'; + +export const DocViewInspectButton = ({ rowIndex }: EuiDataGridCellValueElementProps) => { + const { inspectedHit, setInspectedHit, rows } = useDataGridContext(); + const currentInspected = rows[rowIndex]; + const isCurrentInspected = currentInspected === inspectedHit; + const inspectHintMsg = i18n.translate('discover.docViews.table.inspectAriaLabel', { + defaultMessage: 'Inspect document details', + }); + + return ( + + setInspectedHit(isCurrentInspected ? undefined : currentInspected)} + iconType={isCurrentInspected ? 'minimize' : 'inspect'} + aria-label={inspectHintMsg} + /> + + ); +}; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx new file mode 100644 index 00000000000..957679a2fae --- /dev/null +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx @@ -0,0 +1,77 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +import { + EuiFlyout, + EuiFlyoutHeader, + EuiFlyoutBody, + EuiFlexGroup, + EuiFlexItem, + EuiTitle, +} from '@elastic/eui'; +import { DocViewer } from '../doc_viewer/doc_viewer'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { DocViewFilterFn } from '../../doc_views/doc_views_types'; +import { DocViewerLinks } from '../doc_viewer_links/doc_viewer_links'; + +interface Props { + columns: string[]; + hit: any; + indexPattern: IndexPattern; + onAddColumn: (column: string) => void; + onClose: () => void; + onFilter: DocViewFilterFn; + onRemoveColumn: (column: string) => void; +} + +export function DataGridFlyout({ + hit, + columns, + indexPattern, + onAddColumn, + onClose, + onFilter, + onRemoveColumn, +}: Props) { + // TODO: replace EuiLink with doc_view_links registry + // TODO: Also move the flyout higher in the react tree to prevent redrawing the table component and slowing down page performance + return ( + + + +

Document Details

+
+
+ + + + + + + { + onRemoveColumn(columnName); + onClose(); + }} + onAddColumn={(columnName: string) => { + onAddColumn(columnName); + onClose(); + }} + filter={(mapping, value, mode) => { + onFilter(mapping, value, mode); + onClose(); + }} + /> + + + +
+ ); +} diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx index 843ad578825..9a1cb719f5b 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer.test.tsx @@ -33,7 +33,7 @@ import { mount, shallow } from 'enzyme'; import { DocViewer } from './doc_viewer'; import { findTestSubject } from '@elastic/eui/lib/test'; import { getDocViewsRegistry } from '../../../opensearch_dashboards_services'; -import { DocViewRenderProps } from '../../doc_views/doc_views_types'; +import { DocViewRenderProps } from '../../../doc_views/doc_views_types'; jest.mock('../../../opensearch_dashboards_services', () => { let registry: any[] = []; diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx index 83d857b24fc..93bd94375de 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.test.tsx @@ -31,7 +31,7 @@ import React from 'react'; import { mount } from 'enzyme'; import { DocViewRenderTab } from './doc_viewer_render_tab'; -import { DocViewRenderProps } from '../../doc_views/doc_views_types'; +import { DocViewRenderProps } from '../../../doc_views/doc_views_types'; test('Mounting and unmounting DocViewerRenderTab', () => { const unmountFn = jest.fn(); diff --git a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx index edc7f40c5e4..043d0f7a60e 100644 --- a/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer/doc_viewer_render_tab.tsx @@ -29,7 +29,7 @@ */ import React, { useRef, useEffect } from 'react'; -import { DocViewRenderFn, DocViewRenderProps } from '../../doc_views/doc_views_types'; +import { DocViewRenderFn, DocViewRenderProps } from '../../../doc_views/doc_views_types'; interface Props { render: DocViewRenderFn; @@ -37,7 +37,6 @@ interface Props { } /** * Responsible for rendering a tab provided by a render function. - * So any other framework can be used (E.g. legacy Angular 3rd party plugin code) * The provided `render` function is called with a reference to the * component's `HTMLDivElement` as 1st arg and `renderProps` as 2nd arg */ diff --git a/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap b/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap index 95fb0c37718..075cafd3747 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 @@ -2,33 +2,47 @@ exports[`Dont Render if generateCb.hide 1`] = ` `; exports[`Render with 2 different links 1`] = ` - + style={ + Object { + "fontWeight": "normal", + } + } + target="_blank" + > + generateCb link + - + style={ + Object { + "fontWeight": "normal", + } + } + target="_blank" + > + href link + `; diff --git a/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx b/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx index 8aba555b3a3..22982b4aa51 100644 --- a/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.test.tsx @@ -7,7 +7,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { DocViewerLinks } from './doc_viewer_links'; import { getDocViewsLinksRegistry } from '../../../opensearch_dashboards_services'; -import { DocViewLinkRenderProps } from '../../doc_views_links/doc_views_links_types'; +import { DocViewLinkRenderProps } from '../doc_views/doc_views_links/doc_views_links_types'; jest.mock('../../../opensearch_dashboards_services', () => { let registry: any[] = []; diff --git a/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.tsx b/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.tsx index 9efb0693fde..ea4262814bb 100644 --- a/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.tsx +++ b/src/plugins/discover/public/application/components/doc_viewer_links/doc_viewer_links.tsx @@ -4,7 +4,7 @@ */ import React from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiListGroupItem, EuiListGroupItemProps } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiListGroupItemProps, EuiLink } from '@elastic/eui'; import { getDocViewsLinksRegistry } from '../../../opensearch_dashboards_services'; import { DocViewLinkRenderProps } from '../../doc_views_links/doc_views_links_types'; @@ -24,10 +24,12 @@ export function DocViewerLinks(renderProps: DocViewLinkRenderProps) { }); return ( - + {listItems.map((item, index) => ( - - + + + {item.label} + ))} diff --git a/src/plugins/discover/public/application/components/doc_views/context/NOTES.md b/src/plugins/discover/public/application/components/doc_views/context/NOTES.md new file mode 100644 index 00000000000..c62ce8a450c --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/NOTES.md @@ -0,0 +1,55 @@ +# Discover Context App Implementation Notes + +## 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 the updated application, this is managed via the useContextState and useQueryActions hooks, which manage the application state and actions respectively. + +**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 re-render and updates. + +**Unit-Testability**: Creating unit tests for large parts of the UI code is made easy by expressing as much of the logic as possible as side-effect-free functions. The only place where side-effects are allowed are actions. + +**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 components diligently. It does not access the OpenSearch Dashboards AppState directly but communicates only via its properties. + +## Concepts +To adhere to the principles mentioned above, this app borrows some concepts from the redux architecture that forms a circular unidirectional data flow. + +**State**: The `contextAppState` and `contextQueryState` are the single sources of truth and may only be modified by actions. + +**Action**: Actions are functions that are called in response to user or system actions and may modify the state they are bound to via their closure. For example, the `setContextAppState` and `fetchSurroundingRows` functions in the `useContextState` and `useQueryActions` hooks, respectively. + +## Implementation +The updated application leverages React hooks to manage state and actions. The useContextState hook manages the application state and provides functions to update the state, while the useQueryActions hook manages the fetching of documents and provides functions to fetch the anchor document, surrounding documents, and all documents. + +The `useContextState` hook uses the useState and useEffect hooks to manage the application state and side effects. The `contextAppState` is the application state, and the `setContextAppState` function is used to update the state. The useEffect hook is used to reset the `contextQueryState` and to fetch the surrounding documents based on the `contextAppState`. + +The `useQueryActions` hook uses the useState, useMemo, and useCallback hooks to manage the query state and actions. The `contextQueryState` is the query state, and various functions are provided to update the state and fetch documents. The useMemo hook is used to derive the rows from the contextQueryState, and the useCallback hook is used to create memoized versions of the functions that update the state and fetch documents. + +The `useQueryActions` hook provides several functions for fetching documents: + +**fetchAnchorRow**: Fetches the anchor document. + +**fetchSurroundingRows**: Fetches the surrounding documents (predecessors or successors) of the anchor document. + +**fetchContextRows**: Fetches both the predecessors and successors of the anchor document. + +**fetchAllRows**: Fetches the anchor document and then fetches the surrounding documents. +**resetContextQueryState**: Resets the contextQueryState to its initial state. + +These functions update the `contextQueryState` to reflect the loading status and the fetched documents. + + +## Directory Structure + +**components/action_bar**: Defines the `ActionBar` component. + +**api/anchor.ts**: Exports `fetchAnchor()` function that creates and executes the query for the anchor document. It also exports `updateSearchSource()` function which updates the search source with specified parameters. + +**api/context.ts**: Exports `fetchSurroundingDocs()` function that fetches the surrounding documents (either successors or predecessors) of a specified anchor document. It also exports `createSearchSource()` function that creates a search source with specified index pattern and filters. + +**api/utils**: Exports various functions used to create and transform +queries. + +**utils/context_state**: Exports functions for fetching surrounding documents, creating a search source, and managing application and global states. Additionally, several helper functions are exported for comparing filters and states, retrieving filters from a state, and creating the initial app state. The module also defines constants for the global and app state URL keys. + +**utils/use_query_actions**: Defines a React hook to manage and fetch data related to OpenSearch documents. The hook maintains a local state, contextQueryState, to track the status of fetching operations and the fetched documents. It provides several functions: `fetchAnchorRow()` to fetch the anchor document, `fetchSurroundingRows()` to fetch surrounding documents, `fetchContextRows()` to fetch both predecessors and successors, and `fetchAllRows()` to fetch the anchor and all surrounding documents. Additionally, it provides a `resetContextQueryState()` function to reset the local state to its initial value. Each fetch operation updates the contextQueryState and, in case of an error, displays a toast notification with a failure message. + +**utils/use_context_query**: Defines a React hook that manages the application state and synchronization with the URL and OpenSearch Dashboards services. The `startSync` and `stopSync` functions are used to start and stop the synchronization of the application state with the URL. The `setContextAppState` function is used to update the application state and immediately reflect the changes in the URL. The hook returns the current `contextAppState` and the `setContextAppState` function, which can be used by the components that consume this hook. diff --git a/src/plugins/discover/public/application/angular/context/_index.scss b/src/plugins/discover/public/application/components/doc_views/context/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/context/_index.scss rename to src/plugins/discover/public/application/components/doc_views/context/_index.scss diff --git a/src/plugins/discover/public/application/components/doc_views/context/api/_stubs.ts b/src/plugins/discover/public/application/components/doc_views/context/api/_stubs.ts new file mode 100644 index 00000000000..e8d4ab9d95b --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/api/_stubs.ts @@ -0,0 +1,132 @@ +/* + * 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 moment from 'moment'; +import { OpenSearchHitRecordList } from './context'; + +type Hit = { + [key in string]: number; +} & { + sort: [number, number]; +}; + +interface SearchSourceStub { + _stubHits: any[]; + _stubTimeField?: string; + _createStubHit: (timestamp: any, tiebreaker?: number) => Record; + setParent: SinonSpy; + setField: SinonSpy; + getField: SinonSpy; + fetch: SinonSpy; +} + +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: OpenSearchHitRecordList, timeField?: string) { + const searchSourceStub: Partial = { + _stubHits: hits, + _stubTimeField: timeField, + _createStubHit: (timestamp: number, tiebreaker = 0) => ({ + [searchSourceStub._stubTimeField]: timestamp, + sort: [timestamp, tiebreaker], + }), + }; + + searchSourceStub.setParent = sinon.spy(() => searchSourceStub); + searchSourceStub.setField = sinon.spy(() => searchSourceStub); + + searchSourceStub.getField = sinon.spy((key: string) => { + 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 as SearchSourceStub; +} + +/** + * A stubbed search source with a `fetch` method that returns a filtered set of `_stubHits`. + */ +export function createContextSearchSourceStub( + hits: OpenSearchHitRecordList, + timeField: string = '@timestamp' +) { + const searchSourceStub = createSearchSourceStub(hits, timeField); + + searchSourceStub.fetch = sinon.spy(() => { + const timeFieldStr = searchSourceStub._stubTimeField as string; + const lastQuery = searchSourceStub.setField.withArgs('query').lastCall.args[1]; + const timeRange = lastQuery.query.bool.must.constant_score.filter.range[timeFieldStr]; + const lastSort = searchSourceStub.setField.withArgs('sort').lastCall.args[1]; + const sortDirection = lastSort[0][timeFieldStr]; + const sortFunction = + sortDirection === 'asc' + ? (first: Hit, second: Hit) => first[timeFieldStr] - second[timeFieldStr] + : (first: Hit, second: Hit) => second[timeFieldStr] - first[timeFieldStr]; + const filteredHits = searchSourceStub._stubHits + .filter( + (hit: Hit) => + moment(hit[timeFieldStr]).isSameOrAfter(timeRange.gte) && + moment(hit[timeFieldStr]).isSameOrBefore(timeRange.lte) + ) + .sort(sortFunction); + + return Promise.resolve({ + hits: { + hits: filteredHits, + total: filteredHits.length, + }, + }); + }); + + return searchSourceStub; +} diff --git a/src/plugins/discover/public/application/components/doc_views/context/api/anchor.ts b/src/plugins/discover/public/application/components/doc_views/context/api/anchor.ts new file mode 100644 index 00000000000..55bd3a26579 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/api/anchor.ts @@ -0,0 +1,91 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@osd/i18n'; + +import { + ISearchSource, + OpenSearchQuerySortValue, + IndexPattern, +} from '../../../../../../../data/public'; +import { OpenSearchHitRecord } from './context'; + +export async function fetchAnchor( + anchorId: string, + indexPattern: IndexPattern, + searchSource: ISearchSource, + sort: OpenSearchQuerySortValue[] +): Promise { + updateSearchSource(searchSource, anchorId, sort, indexPattern); + + const response = await searchSource.fetch(); + const doc = response.hits?.hits?.[0]; + + if (!doc) { + throw new Error( + i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', { + defaultMessage: 'Failed to load anchor document.', + }) + ); + } + + return { + ...doc, + isAnchor: true, + } as OpenSearchHitRecord; +} + +export function updateSearchSource( + searchSource: ISearchSource, + anchorId: string, + sort: OpenSearchQuerySortValue[], + indexPattern: IndexPattern +) { + 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); + + return searchSource; +} diff --git a/src/plugins/discover/public/application/components/doc_views/context/api/context.ts b/src/plugins/discover/public/application/components/doc_views/context/api/context.ts new file mode 100644 index 00000000000..bab1ff282e5 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/api/context.ts @@ -0,0 +1,133 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { 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 enum SurrDocType { + SUCCESSORS = 'successors', + PREDECESSORS = 'predecessors', +} +export interface OpenSearchHitRecord { + fields: Record; + sort: number[]; + _source: Record; + _id: string; + isAnchor?: boolean; +} +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); + +/** + * 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} + */ + +export async function fetchSurroundingDocs( + type: SurrDocType, + indexPattern: IndexPattern, + anchor: OpenSearchHitRecord, + tieBreakerField: string, + sortDir: SortDirection, + size: number, + filters: Filter[] +) { + if (typeof anchor !== 'object' || anchor === null || !size) { + return []; + } + const timeField = indexPattern.timeFieldName!; + 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; +} + +export 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); +} diff --git a/src/plugins/discover/public/application/angular/context/api/utils/date_conversion.test.ts b/src/plugins/discover/public/application/components/doc_views/context/api/utils/date_conversion.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/date_conversion.test.ts rename to src/plugins/discover/public/application/components/doc_views/context/api/utils/date_conversion.test.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/date_conversion.ts b/src/plugins/discover/public/application/components/doc_views/context/api/utils/date_conversion.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/date_conversion.ts rename to src/plugins/discover/public/application/components/doc_views/context/api/utils/date_conversion.ts diff --git a/src/plugins/discover/public/application/components/doc_views/context/api/utils/fetch_hits_in_interval.ts b/src/plugins/discover/public/application/components/doc_views/context/api/utils/fetch_hits_in_interval.ts new file mode 100644 index 00000000000..4e2a5719f0b --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/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/public/application/components/doc_views/context/api/utils/generate_intervals.ts b/src/plugins/discover/public/application/components/doc_views/context/api/utils/generate_intervals.ts new file mode 100644 index 00000000000..7ae2f796ad9 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/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/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts b/src/plugins/discover/public/application/components/doc_views/context/api/utils/get_opensearch_query_search_after.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts rename to src/plugins/discover/public/application/components/doc_views/context/api/utils/get_opensearch_query_search_after.ts diff --git a/src/plugins/discover/public/application/components/doc_views/context/api/utils/get_opensearch_query_sort.ts b/src/plugins/discover/public/application/components/doc_views/context/api/utils/get_opensearch_query_sort.ts new file mode 100644 index 00000000000..dc87806b1d8 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/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/public/application/angular/context/api/utils/sorting.test.ts b/src/plugins/discover/public/application/components/doc_views/context/api/utils/sorting.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/sorting.test.ts rename to src/plugins/discover/public/application/components/doc_views/context/api/utils/sorting.test.ts diff --git a/src/plugins/discover/public/application/components/doc_views/context/api/utils/sorting.ts b/src/plugins/discover/public/application/components/doc_views/context/api/utils/sorting.ts new file mode 100644 index 00000000000..7d89e17e3fb --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/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/public/application/angular/context/components/action_bar/_action_bar.scss b/src/plugins/discover/public/application/components/doc_views/context/components/action_bar/_action_bar.scss similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/_action_bar.scss rename to src/plugins/discover/public/application/components/doc_views/context/components/action_bar/_action_bar.scss diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/_index.scss b/src/plugins/discover/public/application/components/doc_views/context/components/action_bar/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/_index.scss rename to src/plugins/discover/public/application/components/doc_views/context/components/action_bar/_index.scss diff --git a/src/plugins/discover/public/application/components/doc_views/context/components/action_bar/action_bar.test.tsx b/src/plugins/discover/public/application/components/doc_views/context/components/action_bar/action_bar.test.tsx new file mode 100644 index 00000000000..5529628abed --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/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 './action_bar'; + +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(type, 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(type, 123); + }); + + test(`${type}: Count input change submits on return`, () => { + input.simulate('change', { target: { valueAsNumber: 124 } }); + input.simulate('submit'); + expect(onChangeCount).toHaveBeenCalledWith(type, 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/public/application/components/doc_views/context/components/action_bar/action_bar.tsx b/src/plugins/discover/public/application/components/doc_views/context/components/action_bar/action_bar.tsx new file mode 100644 index 00000000000..470533b1c61 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/components/action_bar/action_bar.tsx @@ -0,0 +1,190 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { 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'; + +export const MAX_CONTEXT_SIZE = 10000; // OpenSearch's default maximum size limit +export const MIN_CONTEXT_SIZE = 0; + +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: (type: SurrDocType, 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(type, newDocCount); + } + }; + + useEffect(() => { + if (newDocCount !== docCount && newDocCount === 0) { + setNewDocCount(docCount); + } + }, [docCount, newDocCount]); + + return ( + +
+ {isSuccessor && } + {isSuccessor && showWarning && ( + + )} + {isSuccessor && showWarning && } + + + { + const value = newDocCount + defaultStepSize; + if (isValid(value)) { + setNewDocCount(value); + onChangeCount(type, value); + } + }} + flush="right" + > + + + + + + { + setNewDocCount(ev.target.valueAsNumber); + }} + onBlur={() => { + if (newDocCount !== docCount && isValid(newDocCount)) { + onChangeCount(type, newDocCount); + } + }} + type="number" + value={newDocCount >= 0 ? newDocCount : ''} + /> + + + + + {isSuccessor ? ( + + ) : ( + + )} + + + + {!isSuccessor && showWarning && ( + + )} + {!isSuccessor && } + +
+ ); +} diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_warning.tsx b/src/plugins/discover/public/application/components/doc_views/context/components/action_bar/action_bar_warning.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_warning.tsx rename to src/plugins/discover/public/application/components/doc_views/context/components/action_bar/action_bar_warning.tsx diff --git a/src/plugins/discover/public/application/components/doc_views/context/utils/context_query_state.ts b/src/plugins/discover/public/application/components/doc_views/context/utils/context_query_state.ts new file mode 100644 index 00000000000..1960d078634 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/utils/context_query_state.ts @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { OpenSearchHitRecord, OpenSearchHitRecordList } from '../api/context'; + +export interface ContextQueryState { + anchor: OpenSearchHitRecord; + anchorStatus: ContextQueryStatus; + predecessors: OpenSearchHitRecordList; + predecessorsStatus: ContextQueryStatus; + successors: OpenSearchHitRecordList; + successorsStatus: ContextQueryStatus; +} + +export interface ContextQueryStatus { + value: LOADING_STATUS; + reason?: FAILURE_REASONS; +} + +export enum LOADING_STATUS { + LOADING = 'loading', + LOADED = 'loaded', + FAILED = 'failed', + UNINITIALIZED = 'uninitialized', +} + +export enum FAILURE_REASONS { + UNKNOWN = 'unknown', + INVALID_TIEBREAKER = 'invalid_tiebreaker', +} diff --git a/src/plugins/discover/public/application/components/doc_views/context/utils/context_state.test.ts b/src/plugins/discover/public/application/components/doc_views/context/utils/context_state.test.ts new file mode 100644 index 00000000000..0f2930fc3f9 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/utils/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/public/application/components/doc_views/context/utils/context_state.ts b/src/plugins/discover/public/application/components/doc_views/context/utils/context_state.ts new file mode 100644 index 00000000000..f8173129e64 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/utils/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/public/application/components/doc_views/context/utils/use_context_state.ts b/src/plugins/discover/public/application/components/doc_views/context/utils/use_context_state.ts new file mode 100644 index 00000000000..02a41f044a1 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/utils/use_context_state.ts @@ -0,0 +1,69 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useEffect, useMemo } from 'react'; +import { cloneDeep } from 'lodash'; +import { CONTEXT_DEFAULT_SIZE_SETTING } from '../../../../../../common'; +import { DiscoverServices } from '../../../../../build_services'; +import { AppState, getState } from './context_state'; +import { IndexPattern } from '../../../../../opensearch_dashboards_services'; + +export interface Props { + services: DiscoverServices; + indexPattern: IndexPattern; +} + +export const useContextState = ({ services, indexPattern }: Props) => { + const { uiSettings, history, core, filterManager } = services; + + const { + appState: appStateContainer, + setAppState: setAppStateContainer, + startSync, + stopSync, + getFilters, + setFilters, + flushToUrl, + } = useMemo(() => { + return getState({ + defaultStepSize: uiSettings.get(CONTEXT_DEFAULT_SIZE_SETTING), + timeFieldName: indexPattern.timeFieldName, + storeInSessionStorage: uiSettings.get('state:storeInSessionStorage'), + history: history(), + toasts: core.notifications.toasts, + }); + }, [uiSettings, history, core.notifications.toasts, indexPattern.timeFieldName]); + + const [contextAppState, setContextState] = useState(appStateContainer.getState()); + + useEffect(() => { + filterManager.setFilters(cloneDeep(getFilters())); + + startSync(); + + const unsubscribeFromAppStateChanges = appStateContainer.subscribe((newState) => { + setContextState((currentState) => ({ ...currentState, ...newState })); + }); + + const filterObservable = filterManager.getUpdates$().subscribe(() => { + setFilters(filterManager); + }); + + return () => { + stopSync(); + unsubscribeFromAppStateChanges(); + filterObservable.unsubscribe(); + }; + }, [filterManager, getFilters, setFilters, startSync, stopSync, appStateContainer]); + + const setContextAppState = (newValues: Partial) => { + for (const [key, value] of Object.entries(newValues)) { + setAppStateContainer({ [key]: value }); + flushToUrl(true); + } + }; + + return { contextAppState, setContextAppState }; +}; diff --git a/src/plugins/discover/public/application/components/doc_views/context/utils/use_query_actions.ts b/src/plugins/discover/public/application/components/doc_views/context/utils/use_query_actions.ts new file mode 100644 index 00000000000..2b96707daf5 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context/utils/use_query_actions.ts @@ -0,0 +1,188 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { useMemo, useCallback, useState } from 'react'; + +import { Filter } from '../../../../../../../data/public'; +import { fetchAnchor } from '../api/anchor'; +import { OpenSearchHitRecord, fetchSurroundingDocs } from '../api/context'; +import { DiscoverServices } from '../../../../../build_services'; +import { useOpenSearchDashboards } from '../../../../../../../opensearch_dashboards_react/public'; +import { CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../../../../common'; +import { getFirstSortableField, SortDirection } from '../api/utils/sorting'; +import { SurrDocType } from '../api/context'; +import { IndexPattern } from '../../../../../opensearch_dashboards_services'; +import { ContextQueryState, FAILURE_REASONS, LOADING_STATUS } from './context_query_state'; + +const initialState: ContextQueryState = { + anchor: {} as OpenSearchHitRecord, + predecessors: [], + successors: [], + anchorStatus: { value: LOADING_STATUS.UNINITIALIZED }, + predecessorsStatus: { value: LOADING_STATUS.UNINITIALIZED }, + successorsStatus: { value: LOADING_STATUS.UNINITIALIZED }, +}; + +export function useQueryActions(anchorId: string, indexPattern: IndexPattern) { + const { services } = useOpenSearchDashboards(); + const { data, uiSettings, toastNotifications } = services; + const searchSource = useMemo(() => { + return data.search.searchSource.createEmpty(); + }, [data.search.searchSource]); + const tieBreakerField = useMemo( + () => getFirstSortableField(indexPattern, uiSettings.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING)), + [uiSettings, indexPattern] + ); + const [contextQueryState, setContextQueryState] = useState(initialState); + + const fetchAnchorRow = useCallback(async () => { + if (!tieBreakerField) { + setContextQueryState((prevState) => ({ + ...prevState, + anchorStatus: { + value: LOADING_STATUS.FAILED, + reason: FAILURE_REASONS.INVALID_TIEBREAKER, + }, + })); + return; + } + + try { + setContextQueryState((prevState) => ({ + ...prevState, + anchorStatus: { value: LOADING_STATUS.LOADING }, + })); + const sort = [ + { [indexPattern.timeFieldName!]: SortDirection.desc }, + { [tieBreakerField]: SortDirection.desc }, + ]; + const fetchAnchorResult = await fetchAnchor(anchorId, indexPattern, searchSource, sort); + setContextQueryState((prevState) => ({ + ...prevState, + anchor: fetchAnchorResult, + anchorStatus: { value: LOADING_STATUS.LOADED }, + })); + return fetchAnchorResult; + } catch (error) { + setContextQueryState((prevState) => ({ + ...prevState, + anchorStatus: { value: LOADING_STATUS.FAILED, reason: FAILURE_REASONS.UNKNOWN }, + })); + toastNotifications.addDanger({ + title: i18n.translate('discover.context.unableToLoadAnchorDocumentDescription', { + defaultMessage: 'Unable to fetch anchor document', + }), + text: 'fail', + }); + } + }, [anchorId, indexPattern, searchSource, tieBreakerField, toastNotifications]); + + const fetchSurroundingRows = useCallback( + async (type: SurrDocType, count: number, filters: Filter[], anchor?: OpenSearchHitRecord) => { + try { + if (type === SurrDocType.PREDECESSORS) { + setContextQueryState((prevState) => ({ + ...prevState, + predecessorsStatus: { value: LOADING_STATUS.LOADING }, + })); + } else { + setContextQueryState((prevState) => ({ + ...prevState, + successorsStatus: { value: LOADING_STATUS.LOADING }, + })); + } + const fetchedAchor = anchor || contextQueryState.anchor; + + const rows = await fetchSurroundingDocs( + type, + indexPattern, + fetchedAchor as OpenSearchHitRecord, + tieBreakerField, + SortDirection.desc, + count, + filters + ); + if (type === SurrDocType.PREDECESSORS) { + setContextQueryState((prevState) => ({ + ...prevState, + predecessors: rows, + predecessorsStatus: { value: LOADING_STATUS.LOADED }, + })); + } else { + setContextQueryState((prevState) => ({ + ...prevState, + successors: rows, + successorsStatus: { value: LOADING_STATUS.LOADED }, + })); + } + } catch (error) { + if (type === SurrDocType.PREDECESSORS) { + setContextQueryState((prevState) => ({ + ...prevState, + predecessorsStatus: { value: LOADING_STATUS.FAILED, reason: FAILURE_REASONS.UNKNOWN }, + })); + } else { + setContextQueryState((prevState) => ({ + ...prevState, + successorsStatus: { value: LOADING_STATUS.FAILED, reason: FAILURE_REASONS.UNKNOWN }, + })); + } + toastNotifications.addDanger({ + title: i18n.translate('discover.context.unableToLoadDocumentDescription', { + defaultMessage: 'Unable to fetch surrounding documents', + }), + text: 'fail', + }); + } + }, + [contextQueryState.anchor, indexPattern, tieBreakerField, toastNotifications] + ); + + const fetchContextRows = useCallback( + async ( + predecessorCount: number, + successorCount: number, + filters: Filter[], + anchor?: OpenSearchHitRecord + ) => + Promise.all([ + fetchSurroundingRows(SurrDocType.PREDECESSORS, predecessorCount, filters, anchor), + fetchSurroundingRows(SurrDocType.SUCCESSORS, successorCount, filters, anchor), + ]), + [fetchSurroundingRows] + ); + + const fetchAllRows = useCallback( + async (predecessorCount: number, successorCount: number, filters: Filter[]) => { + try { + await fetchAnchorRow().then( + (anchor) => anchor && fetchContextRows(predecessorCount, successorCount, filters, anchor) + ); + } catch (error) { + toastNotifications.addDanger({ + title: i18n.translate('discover.context.unableToLoadDocumentDescription', { + defaultMessage: 'Unable to fetch all documents', + }), + text: 'fail', + }); + } + }, + [fetchAnchorRow, fetchContextRows, toastNotifications] + ); + + const resetContextQueryState = useCallback(() => { + setContextQueryState(initialState); + }, []); + + return { + contextQueryState, + fetchAnchorRow, + fetchAllRows, + fetchContextRows, + fetchSurroundingRows, + resetContextQueryState, + }; +} diff --git a/src/plugins/discover/public/application/components/doc_views/context_app.tsx b/src/plugins/discover/public/application/components/doc_views/context_app.tsx new file mode 100644 index 00000000000..97df221d691 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/context_app.tsx @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useMemo, Fragment } from 'react'; +import { useCallback } from 'react'; +import { SurrDocType } from './context/api/context'; +import { ActionBar } from './context/components/action_bar/action_bar'; +import { CONTEXT_STEP_SETTING } from '../../../../common'; +import { DiscoverViewServices } from '../../../build_services'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { LOADING_STATUS } from './context/utils/context_query_state'; +import { SortDirection } from '../../../../../data/public'; +import { DataGridTable } from '../data_grid/data_grid_table'; +import { DocViewFilterFn } from '../../doc_views/doc_views_types'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { AppState } from './context/utils/context_state'; + +export interface Props { + onAddFilter: DocViewFilterFn; + rows: any[]; + indexPattern: IndexPattern; + setAppState: (state: Partial) => void; + onAddColumn?: (col: string) => void; + onRemoveColumn?: (col: string) => void; + onSetColumns?: (cols: string[]) => void; + onSetSort?: (s: Array<[string, string]>) => void; + contextQueryState: any; + appState: AppState; +} + +export function ContextApp({ + onAddFilter, + rows, + indexPattern, + setAppState, + contextQueryState, + appState, +}: Props) { + const { services } = useOpenSearchDashboards(); + const { uiSettings } = services; + const defaultStepSize = useMemo(() => parseInt(uiSettings.get(CONTEXT_STEP_SETTING), 10), [ + uiSettings, + ]); + const { columns, predecessorCount, successorCount } = appState; + const { + anchorStatus, + predecessorsStatus, + successorsStatus, + predecessors, + successors, + } = contextQueryState; + const isAnchorLoading = + anchorStatus.value === LOADING_STATUS.LOADING || + anchorStatus.value === LOADING_STATUS.UNINITIALIZED; + const isPredecessorLoading = + predecessorsStatus.value === LOADING_STATUS.LOADING || + predecessorsStatus.value === LOADING_STATUS.UNINITIALIZED; + const isSuccessorLoading = + successorsStatus.value === LOADING_STATUS.LOADING || + successorsStatus.value === LOADING_STATUS.UNINITIALIZED; + + const onChangeCount = useCallback( + (type: SurrDocType, count: number) => { + const countType = type === SurrDocType.SUCCESSORS ? 'successorCount' : 'predecessorCount'; + if (countType === 'successorCount') { + setAppState({ successorCount: count }); + } else { + setAppState({ predecessorCount: count }); + } + }, + [setAppState] + ); + + const sort = useMemo(() => { + return [[indexPattern.timeFieldName!, SortDirection.desc]]; + }, [indexPattern]); + + return ( + + +
+ {}} + onFilter={onAddFilter} + onRemoveColumn={() => {}} + onSetColumns={() => {}} + onSort={() => {}} + sort={sort} + rows={rows} + displayTimeColumn={true} + services={services} + isToolbarVisible={false} + isContextView={true} + /> +
+ +
+ ); +} diff --git a/src/plugins/discover/public/application/components/doc_views/doc_views_router.tsx b/src/plugins/discover/public/application/components/doc_views/doc_views_router.tsx new file mode 100644 index 00000000000..83838805609 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/doc_views_router.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { Switch, Router, Route, Redirect } from 'react-router-dom'; +import { History } from 'history'; +import { getServices } from '../../../opensearch_dashboards_services'; +import { OpenSearchDashboardsContextProvider } from '../../../../../opensearch_dashboards_react/public'; +import { SingleDocApp } from './single_doc_app'; +import { SurroundingDocsApp } from './surrounding_docs_app'; + +export const docViewsRouter = (history: History) => { + const services = getServices(); + if (services === undefined) return
{'loading...'}
; + + return ( + + + + + ( + + )} + /> + + + + + ); +}; diff --git a/src/plugins/discover/public/application/components/doc_views/generate_doc_views_url.ts b/src/plugins/discover/public/application/components/doc_views/generate_doc_views_url.ts new file mode 100644 index 00000000000..9da5536c90a --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/generate_doc_views_url.ts @@ -0,0 +1,30 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Generate an updated URL by removing the "data-explorer" segment + * from the current location and appending the provided URL. + * + * @param {string} url - The URL to append to the current location. + * @returns {string} The updated URL. + */ +export function generateDocViewsUrl(url: string) { + // split the current location into segments + const urlSegments = window.location.href.split('/'); + // find the index of the "data-explorer" segment + const indexOfDataExplorerInUrl = urlSegments.indexOf('data-explorer'); + // if "data-explorer" is found, remove it from the array + if (indexOfDataExplorerInUrl !== -1) { + urlSegments.splice(indexOfDataExplorerInUrl, 1); + } + // create a new URL object from the current location + const newUrl = urlSegments.join('/'); + const updatedUrlSegments = newUrl.split('#'); + // append the provided URL to the current location + updatedUrlSegments[1] = url; + // join the segments to form the doc views URL + const docViewsUrl = updatedUrlSegments.join(''); + return docViewsUrl; +} diff --git a/src/plugins/discover/public/application/components/doc_views/index.tsx b/src/plugins/discover/public/application/components/doc_views/index.tsx new file mode 100644 index 00000000000..57a7faa6147 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/index.tsx @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { toMountPoint } from '../../../../../opensearch_dashboards_react/public'; +import { docViewsRouter } from './doc_views_router'; +import { getServices } from '../../../opensearch_dashboards_services'; + +export const renderDocView = (element: HTMLElement) => { + const { history } = getServices(); + const unmount = toMountPoint(docViewsRouter(history()))(element); + return unmount; +}; diff --git a/src/plugins/discover/public/application/components/doc_views/single_doc_app.tsx b/src/plugins/discover/public/application/components/doc_views/single_doc_app.tsx new file mode 100644 index 00000000000..071e8758686 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/single_doc_app.tsx @@ -0,0 +1,88 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { useParams, useLocation } from 'react-router-dom'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { DiscoverServices } from '../../../build_services'; +import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { Doc } from '../doc/doc'; + +export interface SingleDocUrlParams { + index: string; + indexPatternId: string; +} + +function useQueryString() { + return new URLSearchParams(useLocation().search); +} + +export function SingleDocApp() { + const { + services: { chrome, timefilter, indexPatterns }, + } = useOpenSearchDashboards(); + + const { index, indexPatternId } = useParams(); + const [indexPattern, setIndexPattern] = useState(undefined); + const [error, setError] = useState(null); + + // get query string + const query = useQueryString(); + // get doc id from query string + const docId = query.get('id') || ''; + + useEffect(() => { + async function getIndexPatternById() { + try { + const ip = await indexPatterns.get(indexPatternId); + setIndexPattern(ip); + } catch (e) { + setError(e); + } + } + getIndexPatternById(); + }, [indexPatternId, indexPatterns]); + + useEffect(() => { + chrome.setBreadcrumbs([ + ...getRootBreadcrumbs(), + { + text: i18n.translate('discover.single.breadcrumb', { + defaultMessage: `${index}#${docId}`, + values: { + index, + docId, + }, + }), + }, + ]); + }, [chrome, index, docId]); + + useEffect(() => { + timefilter.disableAutoRefreshSelector(); + timefilter.disableTimeRangeSelector(); + }); + + if (error) { + return
Error fetching index pattern: {error.message}
; + } + + if (!indexPattern) { + return
Index pattern loading
; + } + + return ( +
+ +
+ ); +} diff --git a/src/plugins/discover/public/application/components/doc_views/surrounding_docs_app.tsx b/src/plugins/discover/public/application/components/doc_views/surrounding_docs_app.tsx new file mode 100644 index 00000000000..99b3cac1f5f --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/surrounding_docs_app.tsx @@ -0,0 +1,70 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { DiscoverServices } from '../../../build_services'; +import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; +import { SurroundingDocsView } from './surrounding_docs_view'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { PLUGIN_ID } from '../../../../common'; + +export interface SurroundingDocsUrlParams { + id: string; + indexPatternId: string; +} + +export function SurroundingDocsApp() { + const { + services: { + chrome, + indexPatterns, + core: { + application: { getUrlForApp }, + }, + }, + } = useOpenSearchDashboards(); + const baseUrl = getUrlForApp(PLUGIN_ID); + const { id, indexPatternId } = useParams(); + const [indexPattern, setIndexPattern] = useState(undefined); + const [error, setError] = useState(null); + + useEffect(() => { + async function getIndexPatternById() { + try { + const ip = await indexPatterns.get(indexPatternId); + setIndexPattern(ip); + } catch (e) { + setError(e); + } + } + getIndexPatternById(); + }, [indexPatternId, indexPatterns]); + + useEffect(() => { + chrome.setBreadcrumbs([ + ...getRootBreadcrumbs(baseUrl), + { + text: i18n.translate('discover.context.breadcrumb', { + defaultMessage: `Context of #{docId}`, + values: { + docId: id, + }, + }), + }, + ]); + }, [id, chrome, baseUrl]); + + if (error) { + return
Error fetching index pattern: {error.message}
; + } + + if (!indexPattern) { + return
Index pattern loading
; + } + return ; +} diff --git a/src/plugins/discover/public/application/components/doc_views/surrounding_docs_view.tsx b/src/plugins/discover/public/application/components/doc_views/surrounding_docs_view.tsx new file mode 100644 index 00000000000..f32dabf67c9 --- /dev/null +++ b/src/plugins/discover/public/application/components/doc_views/surrounding_docs_view.tsx @@ -0,0 +1,137 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { Fragment, useEffect, useRef, useCallback, useMemo } from 'react'; +import { EuiPageContent, EuiPage } from '@elastic/eui'; +import { cloneDeep } from 'lodash'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { DiscoverServices } from '../../../build_services'; +import { IndexPatternField, opensearchFilters } from '../../../../../data/public'; +import { AppState, isEqualFilters } from './context/utils/context_state'; +import { useContextState } from './context/utils/use_context_state'; +import { useQueryActions } from './context/utils/use_query_actions'; +import { ContextApp } from './context_app'; +import { LOADING_STATUS } from './context/utils/context_query_state'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { SurrDocType } from './context/api/context'; +import { DocViewFilterFn } from '../../doc_views/doc_views_types'; + +export interface SurroundingDocsViewParams { + id: string; + indexPattern: IndexPattern; +} + +export const SurroundingDocsView = ({ id, indexPattern }: SurroundingDocsViewParams) => { + const { services } = useOpenSearchDashboards(); + const { + navigation: { + ui: { TopNavMenu }, + }, + data: { + query: { filterManager }, + }, + } = services; + + const { contextAppState, setContextAppState } = useContextState({ services, indexPattern }); + const currentAppState = useRef(); + + const { + contextQueryState, + fetchContextRows, + fetchAllRows, + fetchSurroundingRows, + resetContextQueryState, + } = useQueryActions(id, indexPattern); + + // derive loading state + const isLoading = [ + contextQueryState.anchorStatus.value, + contextQueryState.predecessorsStatus.value, + contextQueryState.successorsStatus.value, + ].some((status) => status === LOADING_STATUS.LOADING || status === LOADING_STATUS.UNINITIALIZED); + + // derive rows + const rows = useMemo( + () => [ + ...(contextQueryState.predecessors || []), + ...(contextQueryState.anchor ? [contextQueryState.anchor] : []), + ...(contextQueryState.successors || []), + ], + [contextQueryState.predecessors, contextQueryState.anchor, contextQueryState.successors] + ); + + // data fetch logic + useEffect(() => { + if (currentAppState.current) { + currentAppState.current = undefined; + resetContextQueryState(); + } + }, [id, resetContextQueryState]); + + useEffect(() => { + const { predecessorCount, successorCount, filters } = contextAppState; + if (!currentAppState.current) { + fetchAllRows(predecessorCount, successorCount, filters); + } else if (currentAppState.current.predecessorCount !== predecessorCount) { + fetchSurroundingRows(SurrDocType.PREDECESSORS, predecessorCount, filters); + } else if (currentAppState.current.successorCount !== successorCount) { + fetchSurroundingRows(SurrDocType.SUCCESSORS, successorCount, filters); + } else if (!isEqualFilters(currentAppState.current.filters, filters)) { + fetchContextRows(contextAppState.predecessorCount, successorCount, filters); + } + + currentAppState.current = cloneDeep(contextAppState); + }, [contextAppState, id, fetchContextRows, fetchAllRows, fetchSurroundingRows]); + + // add filter logic + const onAddFilter = useCallback( + (field: IndexPatternField, values: string, operation: '+' | '-') => { + const newFilters = opensearchFilters.generateFilters( + filterManager, + field, + values, + operation, + indexPattern.id + ); + return filterManager.addFilters(newFilters); + }, + [filterManager, indexPattern] + ); + + // memoize context app + const contextAppMemoized = useMemo( + () => ( + + ), + [onAddFilter, rows, indexPattern, setContextAppState, contextQueryState, contextAppState] + ); + + return ( + !isLoading && ( + + + + + {contextAppMemoized} + + + + ) + ); +}; diff --git a/src/plugins/discover/public/application/components/help_menu/help_menu_util.ts b/src/plugins/discover/public/application/components/help_menu/help_menu_util.ts new file mode 100644 index 00000000000..47bf0dbdcf9 --- /dev/null +++ b/src/plugins/discover/public/application/components/help_menu/help_menu_util.ts @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@osd/i18n'; +import { ChromeStart } from 'opensearch-dashboards/public'; +import { getServices } from '../../../opensearch_dashboards_services'; + +const { docLinks } = getServices(); + +export function addHelpMenuToAppChrome(chrome: ChromeStart) { + chrome.setHelpExtension({ + appName: i18n.translate('discover.helpMenu.appName', { + defaultMessage: 'Discover', + }), + links: [ + { + linkType: 'documentation', + href: `${docLinks.links.opensearchDashboards.introduction}`, + }, + ], + }); +} diff --git a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.scss b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.scss new file mode 100644 index 00000000000..051ab642c1c --- /dev/null +++ b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.scss @@ -0,0 +1,4 @@ +.discoverNoResults { + display: flex; + align-items: center; +} diff --git a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx index 697c7a136d6..dc12ba4581f 100644 --- a/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx +++ b/src/plugins/discover/public/application/components/loading_spinner/loading_spinner.tsx @@ -28,20 +28,24 @@ * under the License. */ +import './loading_spinner.scss'; import React from 'react'; -import { EuiLoadingSpinner, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { EuiTitle, EuiPanel, EuiEmptyPrompt, EuiLoadingSpinner } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; export function LoadingSpinner() { return ( - <> - -

- -

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

+ +

+
+ } + /> +
); } diff --git a/src/plugins/discover/public/application/components/no_results/no_results.tsx b/src/plugins/discover/public/application/components/no_results/no_results.tsx new file mode 100644 index 00000000000..ad7bab0e81e --- /dev/null +++ b/src/plugins/discover/public/application/components/no_results/no_results.tsx @@ -0,0 +1,212 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { Fragment } from 'react'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; + +import { + EuiCallOut, + EuiCode, + EuiDescriptionList, + EuiLink, + EuiPanel, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { getServices } from '../../../opensearch_dashboards_services'; + +interface Props { + timeFieldName?: string; + queryLanguage?: string; +} + +export const DiscoverNoResults = ({ timeFieldName, queryLanguage }: Props) => { + let timeFieldMessage; + + if (timeFieldName) { + timeFieldMessage = ( + + + + +

+ +

+ +

+ +

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

+ +

+ +

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

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

- -

-
- -
    - {selectedFields.map((field: IndexPatternField) => { - return ( -
  • - -
  • - ); - })} -
-
- + + + {fields.length > 0 && ( + <> + +

+ +

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

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

+ +

+
+
+ + + } + savedObjectMetaData={[ + { + type: SAVED_OBJECT_TYPE, + getIconForSavedObject: () => 'search', + name: i18n.translate('discover.savedSearch.savedObjectName', { + defaultMessage: 'Saved search', + }), + }, + ]} + onChoose={(id) => { + setTimeout(() => { + window.location.assign(makeUrl(id)); + // TODO: figure out why a history push doesn't update the app state. The page reload is a hack around it + window.location.reload(); + onClose(); + }, 0); + }} + uiSettings={uiSettings} + savedObjects={savedObjects} + /> + + + + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + + + +
+ ); +} diff --git a/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx b/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx new file mode 100644 index 00000000000..5bc95b1b9cf --- /dev/null +++ b/src/plugins/discover/public/application/components/top_nav/show_open_search_panel.tsx @@ -0,0 +1,70 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { OpenSearchPanel } from './open_search_panel'; +import { I18nStart } from '../../../../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../../../../opensearch_dashboards_react/public'; +import { DiscoverViewServices } from '../../../build_services'; + +let isOpen = false; + +export function showOpenSearchPanel({ + makeUrl, + I18nContext, + services, +}: { + makeUrl: (id: string) => string; + I18nContext: I18nStart['Context']; + services: DiscoverViewServices; +}) { + if (isOpen) { + return; + } + + isOpen = true; + const container = document.createElement('div'); + const onClose = () => { + ReactDOM.unmountComponentAtNode(container); + document.body.removeChild(container); + isOpen = false; + }; + + document.body.appendChild(container); + const element = ( + + + + + + ); + ReactDOM.render(element, container); +} diff --git a/src/plugins/discover/public/application/angular/directives/uninitialized.tsx b/src/plugins/discover/public/application/components/uninitialized/uninitialized.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/directives/uninitialized.tsx rename to src/plugins/discover/public/application/components/uninitialized/uninitialized.tsx diff --git a/src/plugins/discover/public/application/components/utils/use_pagination.test.ts b/src/plugins/discover/public/application/components/utils/use_pagination.test.ts new file mode 100644 index 00000000000..6eda1f7b4ca --- /dev/null +++ b/src/plugins/discover/public/application/components/utils/use_pagination.test.ts @@ -0,0 +1,73 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, act } from '@testing-library/react-hooks'; +import { usePagination } from './use_pagination'; + +describe('usePagination', () => { + it('should initialize correctly with visParams and nRow', () => { + const nRow = 30; + const { result } = renderHook(() => usePagination(nRow)); + + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 100, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + pageSizeOptions: [25, 50, 100], + }); + }); + + it('should update pageSize correctly when calling onChangeItemsPerPage', () => { + const nRow = 30; + const { result } = renderHook(() => usePagination(nRow)); + + act(() => { + result.current?.onChangeItemsPerPage(20); + }); + + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 20, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + pageSizeOptions: [25, 50, 100], + }); + }); + + it('should update pageIndex correctly when calling onChangePage', () => { + const nRow = 30; + const { result } = renderHook(() => usePagination(nRow)); + + act(() => { + result.current?.onChangePage(1); + }); + + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 100, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + pageSizeOptions: [25, 50, 100], + }); + }); + + it('should correct pageIndex if it exceeds maximum page index after nRow or perPage change', () => { + const nRow = 300; + const { result } = renderHook(() => usePagination(nRow)); + + act(() => { + result.current?.onChangePage(4); + }); + + expect(result.current).toEqual({ + pageIndex: 0, + pageSize: 100, + onChangeItemsPerPage: expect.any(Function), + onChangePage: expect.any(Function), + pageSizeOptions: [25, 50, 100], + }); + }); +}); diff --git a/src/plugins/discover/public/application/components/utils/use_pagination.ts b/src/plugins/discover/public/application/components/utils/use_pagination.ts new file mode 100644 index 00000000000..98363e57ed9 --- /dev/null +++ b/src/plugins/discover/public/application/components/utils/use_pagination.ts @@ -0,0 +1,39 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useState, useMemo, useCallback } from 'react'; + +export const usePagination = (rowCount: number) => { + const [pagination, setPagination] = useState({ pageIndex: 0, pageSize: 100 }); + const pageCount = useMemo(() => Math.ceil(rowCount / pagination.pageSize), [ + rowCount, + pagination, + ]); + + const onChangeItemsPerPage = useCallback( + (pageSize: number) => setPagination((p) => ({ ...p, pageSize })), + [] + ); + + const onChangePage = useCallback( + (pageIndex: number) => setPagination((p) => ({ ...p, pageIndex })), + [] + ); + + return useMemo( + () => + pagination.pageSize + ? { + ...pagination, + onChangeItemsPerPage, + onChangePage, + pageIndex: pagination.pageIndex > pageCount - 1 ? 0 : pagination.pageIndex, + pageSize: pagination.pageSize, + pageSizeOptions: [25, 50, 100], // TODO: make this configurable + } + : undefined, + [pagination, onChangeItemsPerPage, onChangePage, pageCount] + ); +}; diff --git a/src/plugins/discover/public/application/doc_views/doc_views_registry.ts b/src/plugins/discover/public/application/doc_views/doc_views_registry.ts index 56f167b5f2c..904d3813cd6 100644 --- a/src/plugins/discover/public/application/doc_views/doc_views_registry.ts +++ b/src/plugins/discover/public/application/doc_views/doc_views_registry.ts @@ -28,32 +28,16 @@ * under the License. */ -import { auto } from 'angular'; -import { convertDirectiveToRenderFn } from './doc_views_helpers'; import { DocView, DocViewInput, OpenSearchSearchHit, DocViewInputFn } from './doc_views_types'; export class DocViewsRegistry { private docViews: DocView[] = []; - private angularInjectorGetter: (() => Promise) | null = null; - - setAngularInjectorGetter = (injectorGetter: () => Promise) => { - this.angularInjectorGetter = injectorGetter; - }; /** * Extends and adds the given doc view to the registry array */ addDocView(docViewRaw: DocViewInput | DocViewInputFn) { const docView = typeof docViewRaw === 'function' ? docViewRaw() : docViewRaw; - if (docView.directive) { - // convert angular directive to render function for backwards compatibility - docView.render = convertDirectiveToRenderFn(docView.directive, () => { - if (!this.angularInjectorGetter) { - throw new Error('Angular was not initialized'); - } - return this.angularInjectorGetter(); - }); - } if (typeof docView.shouldShow !== 'function') { docView.shouldShow = () => true; } diff --git a/src/plugins/discover/public/application/doc_views/doc_views_types.ts b/src/plugins/discover/public/application/doc_views/doc_views_types.ts index 961fc98516f..db9757d385b 100644 --- a/src/plugins/discover/public/application/doc_views/doc_views_types.ts +++ b/src/plugins/discover/public/application/doc_views/doc_views_types.ts @@ -29,17 +29,9 @@ */ import { ComponentType } from 'react'; -import { IScope } from 'angular'; import { SearchResponse } from 'elasticsearch'; import { IndexPattern } from '../../../../data/public'; -export interface AngularDirective { - controller: (...injectedServices: any[]) => void; - template: string; -} - -export type AngularScope = IScope; - export type OpenSearchSearchHit = SearchResponse['hits']['hits'][number]; export interface FieldMapping { @@ -72,7 +64,6 @@ export type DocViewRenderFn = ( export interface DocViewInput { component?: DocViewerComponent; - directive?: AngularDirective; order: number; render?: DocViewRenderFn; shouldShow?: (hit: OpenSearchSearchHit) => boolean; diff --git a/src/plugins/discover/public/application/helpers/breadcrumbs.ts b/src/plugins/discover/public/application/helpers/breadcrumbs.ts index e30f50206ae..5463c05c166 100644 --- a/src/plugins/discover/public/application/helpers/breadcrumbs.ts +++ b/src/plugins/discover/public/application/helpers/breadcrumbs.ts @@ -30,22 +30,13 @@ import { i18n } from '@osd/i18n'; -export function getRootBreadcrumbs() { +export function getRootBreadcrumbs(baseUrl: string) { return [ { text: i18n.translate('discover.rootBreadcrumb', { defaultMessage: 'Discover', }), - href: '#/', - }, - ]; -} - -export function getSavedSearchBreadcrumbs($route: any) { - return [ - ...getRootBreadcrumbs(), - { - text: $route.current.locals.savedObjects.savedSearch.id, + href: baseUrl, }, ]; } diff --git a/src/plugins/discover/public/application/utils/columns.test.ts b/src/plugins/discover/public/application/utils/columns.test.ts new file mode 100644 index 00000000000..43c4b055555 --- /dev/null +++ b/src/plugins/discover/public/application/utils/columns.test.ts @@ -0,0 +1,24 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { buildColumns } from './columns'; + +describe('buildColumns', () => { + it('returns ["_source"] if columns is empty', () => { + expect(buildColumns([])).toEqual(['_source']); + }); + + it('returns columns if there is only one column', () => { + expect(buildColumns(['foo'])).toEqual(['foo']); + }); + + it('removes "_source" if there are more than one columns', () => { + expect(buildColumns(['foo', '_source', 'bar'])).toEqual(['foo', 'bar']); + }); + + it('returns columns if there are more than one columns but no "_source"', () => { + expect(buildColumns(['foo', 'bar'])).toEqual(['foo', 'bar']); + }); +}); diff --git a/src/plugins/discover/public/application/utils/columns.ts b/src/plugins/discover/public/application/utils/columns.ts new file mode 100644 index 00000000000..062ca24e3ba --- /dev/null +++ b/src/plugins/discover/public/application/utils/columns.ts @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * Helper function to provide a fallback to a single _source column if the given array of columns + * is empty, and removes _source if there are more than 1 columns given + * @param columns + */ +export function buildColumns(columns: string[]) { + if (columns.length > 1 && columns.indexOf('_source') !== -1) { + return columns.filter((col) => col !== '_source'); + } else if (columns.length !== 0) { + return columns; + } + return ['_source']; +} diff --git a/src/plugins/discover/public/application/utils/state_management/common.test.ts b/src/plugins/discover/public/application/utils/state_management/common.test.ts new file mode 100644 index 00000000000..64a2dba99dd --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/common.test.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { addColumn, removeColumn, reorderColumn } from './common'; + +describe('commonUtils', () => { + it('should handle addColumn', () => { + expect(addColumn(['column1'], { column: 'column2' })).toEqual(['column1', 'column2']); + expect(addColumn(['column1'], { column: 'column2', index: 0 })).toEqual(['column2', 'column1']); + }); + + it('should handle removeColumn', () => { + expect(removeColumn(['column1', 'column2'], 'column1')).toEqual(['column2']); + }); + + it('should handle reorderColumn', () => { + expect(reorderColumn(['column1', 'column2', 'column3'], 0, 2)).toEqual([ + 'column2', + 'column3', + 'column1', + ]); + }); +}); diff --git a/src/plugins/discover/public/application/utils/state_management/common.ts b/src/plugins/discover/public/application/utils/state_management/common.ts new file mode 100644 index 00000000000..800753fb4a3 --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/common.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const addColumn = (columns: string[], action: { column: string; index?: number }) => { + const { column, index } = action; + const newColumns = [...(columns || [])]; + if (index !== undefined) newColumns.splice(index, 0, column); + else newColumns.push(column); + return newColumns; +}; + +export const removeColumn = (columns: string[], actionColumn: string) => { + return (columns || []).filter((column) => column !== actionColumn); +}; + +export const reorderColumn = (columns: string[], source: number, destination: number) => { + const newColumns = [...(columns || [])]; + const [removed] = newColumns.splice(source, 1); + newColumns.splice(destination, 0, removed); + return newColumns; +}; diff --git a/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx new file mode 100644 index 00000000000..cbfc9c3769b --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.test.tsx @@ -0,0 +1,85 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { discoverSlice, DiscoverState } from './discover_slice'; + +describe('discoverSlice', () => { + let initialState: DiscoverState; + + beforeEach(() => { + initialState = { + columns: [], + sort: [], + }; + }); + + it('should handle setState', () => { + const newState = { + columns: ['column1', 'column2'], + sort: [['field1', 'asc']], + }; + const action = { type: 'discover/setState', payload: newState }; + const result = discoverSlice.reducer(initialState, action); + expect(result).toEqual(newState); + }); + + it('should handle addColumn', () => { + const action1 = { type: 'discover/addColumn', payload: { column: 'column1' } }; + const result1 = discoverSlice.reducer(initialState, action1); + expect(result1.columns).toEqual(['column1']); + }); + + it('should handle removeColumn', () => { + initialState = { + columns: ['column1', 'column2'], + sort: [['column1', 'asc']], + }; + const action = { type: 'discover/removeColumn', payload: 'column1' }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column2']); + expect(result.sort).toEqual([]); + }); + + it('should handle reorderColumn', () => { + initialState = { + columns: ['column1', 'column2', 'column3'], + sort: [], + }; + const action = { + type: 'discover/reorderColumn', + payload: { source: 0, destination: 2 }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column2', 'column3', 'column1']); + }); + + it('should handle setColumns', () => { + const action = { + type: 'discover/setColumns', + payload: { columns: ['column1', 'column2'] }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.columns).toEqual(['column1', 'column2']); + }); + + it('should handle setSort', () => { + const action = { type: 'discover/setSort', payload: [['field1', 'asc']] }; + const result = discoverSlice.reducer(initialState, action); + expect(result.sort).toEqual([['field1', 'asc']]); + }); + + it('should handle updateState', () => { + initialState = { + columns: ['column1', 'column2'], + sort: [['field1', 'asc']], + }; + const action = { + type: 'discover/updateState', + payload: { sort: [['field2', 'desc']] }, + }; + const result = discoverSlice.reducer(initialState, action); + expect(result.sort).toEqual([['field2', 'desc']]); + }); +}); diff --git a/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx new file mode 100644 index 00000000000..f7910efde91 --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/discover_slice.tsx @@ -0,0 +1,172 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { matchPath } from 'react-router-dom'; +import { Filter, Query } from '../../../../../data/public'; +import { DiscoverServices } from '../../../build_services'; +import { RootState, DefaultViewState } from '../../../../../data_explorer/public'; +import { buildColumns } from '../columns'; +import * as utils from './common'; +import { SortOrder } from '../../../saved_searches/types'; + +export interface DiscoverState { + /** + * Columns displayed in the table + */ + columns: string[]; + /** + * Array of applied filters + */ + filters?: Filter[]; + /** + * Used interval of the histogram + */ + interval?: string; + /** + * Lucence or DQL query + */ + query?: Query; + /** + * Array of the used sorting [[field,direction],...] + */ + sort: SortOrder[]; + /** + * id of the used saved search + */ + savedSearch?: string; + /** + * dirty flag to indicate if the saved search has been modified + * since the last save + */ + isDirty: boolean; +} + +export interface DiscoverRootState extends RootState { + discover: DiscoverState; +} + +const initialState: DiscoverState = { + columns: ['_source'], + sort: [], + isDirty: false, +}; + +export const getPreloadedState = async ({ + getSavedSearchById, +}: DiscoverServices): Promise> => { + const preloadedState: DefaultViewState = { + state: { + ...initialState, + }, + }; + + const hashPath = window.location.hash.split('?')[0]; // hack to remove query params since matchPath considers them part of the id + const savedSearchId = matchPath<{ id?: string }>(hashPath, { + path: '#/view/:id', + })?.params.id; + + if (savedSearchId) { + const savedSearchInstance = await getSavedSearchById(savedSearchId); + + if (savedSearchInstance) { + preloadedState.state.columns = savedSearchInstance.columns; + preloadedState.state.sort = savedSearchInstance.sort; + preloadedState.state.savedSearch = savedSearchInstance.id; + const indexPatternId = savedSearchInstance.searchSource.getField('index')?.id; + preloadedState.root = { + metadata: { + indexPattern: indexPatternId, + }, + }; + + savedSearchInstance.destroy(); // this instance is no longer needed, will create another one later + } + } + + return preloadedState; +}; + +export const discoverSlice = createSlice({ + name: 'discover', + initialState, + reducers: { + setState(state, action: PayloadAction) { + return action.payload; + }, + addColumn(state, action: PayloadAction<{ column: string; index?: number }>) { + const columns = utils.addColumn(state.columns || [], action.payload); + return { ...state, columns: buildColumns(columns) }; + }, + removeColumn(state, action: PayloadAction) { + const columns = utils.removeColumn(state.columns, action.payload); + const sort = + state.sort && state.sort.length ? state.sort.filter((s) => s[0] !== action.payload) : []; + return { + ...state, + columns: buildColumns(columns), + sort, + isDirty: true, + }; + }, + reorderColumn(state, action: PayloadAction<{ source: number; destination: number }>) { + const columns = utils.reorderColumn( + state.columns, + action.payload.source, + action.payload.destination + ); + return { + ...state, + columns, + isDirty: true, + }; + }, + setColumns(state, action: PayloadAction<{ columns: string[] }>) { + return { + ...state, + columns: action.payload.columns, + }; + }, + setSort(state, action: PayloadAction) { + return { + ...state, + sort: action.payload, + }; + }, + setInterval(state, action: PayloadAction) { + return { + ...state, + interval: action.payload, + }; + }, + updateState(state, action: PayloadAction>) { + return { + ...state, + ...action.payload, + }; + }, + setSavedSearchId(state, action: PayloadAction) { + return { + ...state, + savedSearch: action.payload, + isDirty: false, + }; + }, + }, +}); + +// Exposing the state functions as generics +export const { + addColumn, + removeColumn, + reorderColumn, + setColumns, + setSort, + setInterval, + setState, + updateState, + setSavedSearchId, +} = discoverSlice.actions; +export const { reducer } = discoverSlice; diff --git a/src/plugins/discover/public/application/utils/state_management/index.ts b/src/plugins/discover/public/application/utils/state_management/index.ts new file mode 100644 index 00000000000..d72cc772e6c --- /dev/null +++ b/src/plugins/discover/public/application/utils/state_management/index.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { TypedUseSelectorHook } from 'react-redux'; +import { RootState, useTypedDispatch, useTypedSelector } from '../../../../../data_explorer/public'; +import { DiscoverState } from './discover_slice'; + +export * from './discover_slice'; + +export interface DiscoverRootState extends RootState { + discover: DiscoverState; +} + +export const useSelector: TypedUseSelectorHook = useTypedSelector; +export const useDispatch = useTypedDispatch; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.scss b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.scss new file mode 100644 index 00000000000..d0d0951c67b --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.scss @@ -0,0 +1,25 @@ +.dscResultCount { + padding-top: $euiSizeXS; +} + +.dscTimechart { + display: block; + position: relative; + + // SASSTODO: the visualizing component should have an option or a modifier + .series > rect { + fill-opacity: 0.5; + stroke-width: 1; + } +} + +.dscHistogram { + display: flex; + height: 200px; + padding: $euiSizeS; +} + +.dscHistogram__header--partial { + font-weight: $euiFontWeightRegular; + min-width: $euiSize * 12; +} diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx new file mode 100644 index 00000000000..db3ad027c9c --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/discover_chart_container.tsx @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import './discover_chart_container.scss'; +import React from 'react'; +import { DiscoverViewServices } from '../../../build_services'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { useDiscoverContext } from '../context'; +import { SearchData } from '../utils/use_search'; +import { DiscoverChart } from '../../components/chart/chart'; + +export const DiscoverChartContainer = ({ hits, bucketInterval, chartData }: SearchData) => { + const { services } = useOpenSearchDashboards(); + const { uiSettings, data } = services; + const { indexPattern, savedSearch } = useDiscoverContext(); + + const timeField = indexPattern?.timeFieldName; + + if (!hits || !bucketInterval || !chartData) { + // TODO: handle better + return null; + } + + return ( + { + window.location.href = `#/view/${savedSearch?.id}`; + window.location.reload(); + }} + services={services} + showResetButton={!!savedSearch && !!savedSearch.id} + /> + ); +}; diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx new file mode 100644 index 00000000000..b228110ec9f --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx @@ -0,0 +1,100 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { History } from 'history'; +import { DiscoverViewServices } from '../../../build_services'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { DataGridTable } from '../../components/data_grid/data_grid_table'; +import { useDiscoverContext } from '../context'; +import { + addColumn, + removeColumn, + setColumns, + setSort, + useDispatch, + useSelector, +} from '../../utils/state_management'; +import { ResultStatus, SearchData } from '../utils/use_search'; +import { IndexPatternField, opensearchFilters } from '../../../../../data/public'; +import { DocViewFilterFn } from '../../doc_views/doc_views_types'; +import { SortOrder } from '../../../saved_searches/types'; + +interface Props { + history: History; +} + +export const DiscoverTable = ({ history }: Props) => { + const { services } = useOpenSearchDashboards(); + const { filterManager } = services.data.query; + const { data$, refetch$, indexPattern } = useDiscoverContext(); + const [fetchState, setFetchState] = useState({ + status: data$.getValue().status, + rows: [], + }); + + const { columns, sort } = useSelector((state) => state.discover); + const dispatch = useDispatch(); + const onAddColumn = (col: string) => dispatch(addColumn({ column: col })); + const onRemoveColumn = (col: string) => dispatch(removeColumn(col)); + const onSetColumns = (cols: string[]) => dispatch(setColumns({ columns: cols })); + const onSetSort = (s: SortOrder[]) => { + dispatch(setSort(s)); + refetch$.next(); + }; + const onAddFilter = useCallback( + (field: IndexPatternField, values: string, operation: '+' | '-') => { + const newFilters = opensearchFilters.generateFilters( + filterManager, + field, + values, + operation, + indexPattern.id + ); + return filterManager.addFilters(newFilters); + }, + [filterManager, indexPattern] + ); + + const { rows } = fetchState || {}; + + useEffect(() => { + const subscription = data$.subscribe((next) => { + if (next.status === ResultStatus.LOADING) return; + if (next.status !== fetchState.status || (next.rows && next.rows !== fetchState.rows)) { + setFetchState({ ...fetchState, ...next }); + } + }); + return () => { + subscription.unsubscribe(); + }; + }, [data$, fetchState]); + + if (indexPattern === undefined) { + // TODO: handle better + return null; + } + + if (!rows || rows.length === 0) { + // TODO: handle better + return
{'loading...'}
; + } + + return ( + + ); +}; diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx new file mode 100644 index 00000000000..78f73456983 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -0,0 +1,109 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +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'; +import { DiscoverChartContainer } from './discover_chart_container'; +import { useDiscoverContext } from '../context'; +import { ResultStatus, SearchData } from '../utils/use_search'; +import { DiscoverNoResults } from '../../components/no_results/no_results'; +import { DiscoverUninitialized } from '../../components/uninitialized/uninitialized'; +import { LoadingSpinner } from '../../components/loading_spinner/loading_spinner'; + +// eslint-disable-next-line import/no-default-export +export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewProps) { + const { data$, refetch$, indexPattern } = useDiscoverContext(); + + const [fetchState, setFetchState] = useState({ + status: data$.getValue().status, + hits: 0, + bucketInterval: {}, + }); + + const [isCallOutVisible, setIsCallOutVisible] = useState(true); + const closeCallOut = () => setIsCallOutVisible(false); + + let callOut; + + if (isCallOutVisible) { + callOut = ( + + +

+ To provide feedback,{' '} + + open an issue + + . +

+
+
+ ); + } + + const { status } = fetchState; + + useEffect(() => { + const subscription = data$.subscribe((next) => { + if ( + next.status !== fetchState.status || + (next.hits && next.hits !== fetchState.hits) || + (next.bucketInterval && next.bucketInterval !== fetchState.bucketInterval) || + (next.chartData && next.chartData !== fetchState.chartData) + ) { + setFetchState({ ...fetchState, ...next }); + } + }); + return () => { + subscription.unsubscribe(); + }; + }, [data$, fetchState]); + + const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined; + + return ( + + + + + {status === ResultStatus.NO_RESULTS && ( + + + + )} + {status === ResultStatus.UNINITIALIZED && ( + refetch$.next()} /> + )} + {status === ResultStatus.LOADING && } + {status === ResultStatus.READY && ( + <> + {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 new file mode 100644 index 00000000000..41fea855b3e --- /dev/null +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -0,0 +1,108 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React, { useEffect, useState } from 'react'; +import { AppMountParameters } from '../../../../../../core/public'; +import { NEW_DISCOVER_APP, PLUGIN_ID } from '../../../../common'; +import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; +import { DiscoverViewServices } from '../../../build_services'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; +import { getTopNavLinks } from '../../components/top_nav/get_top_nav_links'; +import { useDiscoverContext } from '../context'; + +export interface TopNavProps { + opts: { + setHeaderActionMenu: AppMountParameters['setHeaderActionMenu']; + }; +} + +export const TopNav = ({ opts }: TopNavProps) => { + const { services } = useOpenSearchDashboards(); + const { inspectorAdapters, savedSearch } = useDiscoverContext(); + const [indexPatterns, setIndexPatterns] = useState(undefined); + + const { + uiSettings, + navigation: { + ui: { TopNavMenu }, + }, + core: { + application: { navigateToApp }, + }, + data, + chrome, + } = services; + + const topNavLinks = savedSearch ? getTopNavLinks(services, inspectorAdapters, savedSearch) : []; + + useEffect(() => { + if (uiSettings.get(NEW_DISCOVER_APP) === false) { + const path = window.location.hash; + navigateToApp('discoverLegacy', { + replace: true, + path, + }); + } + + return () => {}; + }, [navigateToApp, uiSettings]); + + useEffect(() => { + let isMounted = true; + const getDefaultIndexPattern = async () => { + await data.indexPatterns.ensureDefaultIndexPattern(); + const indexPattern = await data.indexPatterns.getDefault(); + + if (!isMounted) return; + + setIndexPatterns(indexPattern ? [indexPattern] : undefined); + }; + + getDefaultIndexPattern(); + + return () => { + isMounted = false; + }; + }, [data.indexPatterns]); + + useEffect(() => { + const pageTitleSuffix = savedSearch?.id && savedSearch.title ? `: ${savedSearch.title}` : ''; + chrome.docTitle.change(`Discover${pageTitleSuffix}`); + + if (savedSearch?.id) { + chrome.setBreadcrumbs([ + { + text: i18n.translate('discover.discoverBreadcrumbTitle', { + defaultMessage: 'Discover', + }), + href: '#/', + }, + { text: savedSearch.title }, + ]); + } else { + chrome.setBreadcrumbs([ + { + text: i18n.translate('discover.discoverBreadcrumbTitle', { + defaultMessage: 'Discover', + }), + href: '#/', + }, + ]); + } + }, [chrome, savedSearch?.id, savedSearch?.title]); + + return ( + + ); +}; diff --git a/src/plugins/discover/public/application/view_components/context/index.tsx b/src/plugins/discover/public/application/view_components/context/index.tsx new file mode 100644 index 00000000000..29daca73171 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/context/index.tsx @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { DataExplorerServices, ViewProps } from '../../../../../data_explorer/public'; +import { + OpenSearchDashboardsContextProvider, + useOpenSearchDashboards, +} from '../../../../../opensearch_dashboards_react/public'; +import { getServices } from '../../../opensearch_dashboards_services'; +import { useSearch, SearchContextValue } from '../utils/use_search'; +import { connectStorageToQueryState, opensearchFilters } from '../../../../../data/public'; + +const SearchContext = React.createContext({} as SearchContextValue); + +// eslint-disable-next-line import/no-default-export +export default function DiscoverContext({ children }: React.PropsWithChildren) { + const services = getServices(); + const searchParams = useSearch(services); + + const { + services: { osdUrlStateStorage }, + } = useOpenSearchDashboards(); + + // Connect the query service to the url state + useEffect(() => { + connectStorageToQueryState(services.data.query, osdUrlStateStorage, { + filters: opensearchFilters.FilterStateStore.APP_STATE, + query: true, + }); + }, [osdUrlStateStorage, services.data.query, services.uiSettings]); + + return ( + + {children} + + ); +} + +export const useDiscoverContext = () => React.useContext(SearchContext); diff --git a/src/plugins/discover/public/application/view_components/index.ts b/src/plugins/discover/public/application/view_components/index.ts new file mode 100644 index 00000000000..45fd68cf128 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './canvas'; +export * from './panel'; diff --git a/src/plugins/discover/public/application/view_components/panel/index.tsx b/src/plugins/discover/public/application/view_components/panel/index.tsx new file mode 100644 index 00000000000..6f2e04b9a9b --- /dev/null +++ b/src/plugins/discover/public/application/view_components/panel/index.tsx @@ -0,0 +1,67 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect, useState } from 'react'; +import { ViewProps } from '../../../../../data_explorer/public'; +import { + addColumn, + removeColumn, + reorderColumn, + useDispatch, + useSelector, +} from '../../utils/state_management'; +import { DiscoverSidebar } from '../../components/sidebar'; +import { useDiscoverContext } from '../context'; +import { ResultStatus, SearchData } from '../utils/use_search'; + +// eslint-disable-next-line import/no-default-export +export default function DiscoverPanel(props: ViewProps) { + const { data$, indexPattern } = useDiscoverContext(); + const [fetchState, setFetchState] = useState(data$.getValue()); + + const { columns } = useSelector((state) => ({ + columns: state.discover.columns, + })); + const dispatch = useDispatch(); + + useEffect(() => { + const subscription = data$.subscribe((next) => { + if (next.status === ResultStatus.LOADING) return; + setFetchState(next); + }); + return () => { + subscription.unsubscribe(); + }; + }, [data$, fetchState]); + + return ( + { + dispatch( + addColumn({ + column: fieldName, + index, + }) + ); + }} + onRemoveField={(fieldName) => { + dispatch(removeColumn(fieldName)); + }} + onReorderFields={(source, destination) => { + dispatch( + reorderColumn({ + source, + destination, + }) + ); + }} + selectedIndexPattern={indexPattern} + onAddFilter={() => {}} + /> + ); +} diff --git a/src/plugins/discover/public/application/view_components/utils/get_default_sort.ts b/src/plugins/discover/public/application/view_components/utils/get_default_sort.ts new file mode 100644 index 00000000000..584e47047c5 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/get_default_sort.ts @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IndexPattern } from '../../../opensearch_dashboards_services'; +// @ts-ignore +import { isSortable } from './get_sort'; + +export type SortOrder = [string, string]; + +/** + * use in case the user didn't manually sort. + * the default sort is returned depending of the index pattern + */ +export function getDefaultSort( + indexPattern: IndexPattern, + defaultSortOrder: string = 'desc' +): SortOrder[] { + if (indexPattern.timeFieldName && isSortable(indexPattern.timeFieldName, indexPattern)) { + return [[indexPattern.timeFieldName, defaultSortOrder]]; + } else { + return [['_score', defaultSortOrder]]; + } +} diff --git a/src/plugins/discover/public/application/view_components/utils/get_sort.test.ts b/src/plugins/discover/public/application/view_components/utils/get_sort.test.ts new file mode 100644 index 00000000000..f1b4861e4c9 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/get_sort.test.ts @@ -0,0 +1,107 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getSort, getSortArray } from './get_sort'; +// @ts-ignore +import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; + +describe('docTable', function () { + let indexPattern: IndexPattern; + + beforeEach(() => { + indexPattern = FixturesStubbedLogstashIndexPatternProvider() as IndexPattern; + }); + + describe('getSort function', function () { + test('should be a function', function () { + expect(typeof getSort === 'function').toBeTruthy(); + }); + + test('should return an array of objects', function () { + expect(getSort([['bytes', 'desc']], indexPattern)).toEqual([{ bytes: 'desc' }]); + + delete indexPattern.timeFieldName; + expect(getSort([['bytes', 'desc']], indexPattern)).toEqual([{ bytes: 'desc' }]); + }); + + test('should passthrough arrays of objects', () => { + expect(getSort([{ bytes: 'desc' }], indexPattern)).toEqual([{ bytes: 'desc' }]); + }); + + test('should return an empty array when passed an unsortable field', function () { + expect(getSort([['non-sortable', 'asc']], indexPattern)).toEqual([]); + expect(getSort([['lol_nope', 'asc']], indexPattern)).toEqual([]); + + delete indexPattern.timeFieldName; + expect(getSort([['non-sortable', 'asc']], indexPattern)).toEqual([]); + }); + + test('should return an empty array ', function () { + expect(getSort([], indexPattern)).toEqual([]); + expect(getSort([['foo', 'bar']], indexPattern)).toEqual([]); + expect(getSort([{ foo: 'bar' }], indexPattern)).toEqual([]); + }); + + test('should convert a legacy sort to an array of objects', function () { + expect(getSort(['foo', 'desc'], indexPattern)).toEqual([{ foo: 'desc' }]); + expect(getSort(['foo', 'asc'], indexPattern)).toEqual([{ foo: 'asc' }]); + }); + }); + + describe('getSortArray function', function () { + test('should have an array method', function () { + expect(getSortArray).toBeInstanceOf(Function); + }); + + test('should return an array of arrays for sortable fields', function () { + expect(getSortArray([['bytes', 'desc']], indexPattern)).toEqual([['bytes', 'desc']]); + }); + + test('should return an array of arrays from an array of elasticsearch sort objects', function () { + expect(getSortArray([{ bytes: 'desc' }], indexPattern)).toEqual([['bytes', 'desc']]); + }); + + test('should sort by an empty array when an unsortable field is given', function () { + expect(getSortArray([{ 'non-sortable': 'asc' }], indexPattern)).toEqual([]); + expect(getSortArray([{ lol_nope: 'asc' }], indexPattern)).toEqual([]); + + delete indexPattern.timeFieldName; + expect(getSortArray([{ 'non-sortable': 'asc' }], indexPattern)).toEqual([]); + }); + + test('should return an empty array when passed an empty sort array', () => { + expect(getSortArray([], indexPattern)).toEqual([]); + + delete indexPattern.timeFieldName; + expect(getSortArray([], indexPattern)).toEqual([]); + }); + }); +}); diff --git a/src/plugins/discover/public/application/view_components/utils/get_sort.ts b/src/plugins/discover/public/application/view_components/utils/get_sort.ts new file mode 100644 index 00000000000..32de9e352f8 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/get_sort.ts @@ -0,0 +1,92 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { IndexPattern } from '../../../opensearch_dashboards_services'; + +export type SortPairObj = Record; +export type SortPairArr = [string, string]; +export type SortPair = SortPairArr | SortPairObj; +export type SortInput = SortPair | SortPair[]; + +export function isSortable(fieldName: string, indexPattern: IndexPattern) { + const field = indexPattern.getFieldByName(fieldName); + return field && field.sortable; +} + +function createSortObject( + sortPair: SortInput, + indexPattern: IndexPattern +): SortPairObj | undefined { + if ( + Array.isArray(sortPair) && + sortPair.length === 2 && + isSortable(String(sortPair[0]), indexPattern) + ) { + const [field, direction] = sortPair as SortPairArr; + return { [field]: direction }; + } else if (_.isPlainObject(sortPair) && isSortable(Object.keys(sortPair)[0], indexPattern)) { + return sortPair as SortPairObj; + } +} + +export function isLegacySort(sort: SortPair[] | SortPair): sort is SortPair { + return ( + sort.length === 2 && typeof sort[0] === 'string' && (sort[1] === 'desc' || sort[1] === 'asc') + ); +} + +/** + * Take a sorting array and make it into an object + * @param {array} sort two dimensional array [[fieldToSort, directionToSort]] + * or an array of objects [{fieldToSort: directionToSort}] + * @param {object} indexPattern used for determining default sort + * @returns Array<{object}> an array of sort objects + */ +export function getSort(sort: SortPair[] | SortPair, indexPattern: IndexPattern): SortPairObj[] { + if (Array.isArray(sort)) { + if (isLegacySort(sort)) { + // To stay compatible with legacy sort, which just supported a single sort field + return [{ [sort[0]]: sort[1] }]; + } + return sort + .map((sortPair: SortPair) => createSortObject(sortPair, indexPattern)) + .filter((sortPairObj) => typeof sortPairObj === 'object') as SortPairObj[]; + } + return []; +} + +/** + * compared to getSort it doesn't return an array of objects, it returns an array of arrays + * [[fieldToSort: directionToSort]] + */ +export function getSortArray(sort: SortPair[], indexPattern: IndexPattern) { + return getSort(sort, indexPattern).map((sortPair) => Object.entries(sortPair).pop()); +} diff --git a/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts b/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts new file mode 100644 index 00000000000..b19128a432e --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/get_sort_for_search_source.ts @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { OpenSearchQuerySortValue, IndexPattern } from '../../../opensearch_dashboards_services'; +import { getSort } from './get_sort'; +import { getDefaultSort } from './get_default_sort'; + +export type SortOrder = [string, string]; + +/** + * Prepares sort for search source, that's sending the request to OpenSearch + * - Adds default sort if necessary + * - Handles the special case when there's sorting by date_nanos typed fields + * the addon of the numeric_type guarantees the right sort order + * when there are indices with date and indices with date_nanos field + */ +export function getSortForSearchSource( + sort?: SortOrder[], + indexPattern?: IndexPattern, + defaultDirection: string = 'desc' +): OpenSearchQuerySortValue[] { + if (!sort || !indexPattern) { + return []; + } else if (Array.isArray(sort) && sort.length === 0) { + sort = getDefaultSort(indexPattern, defaultDirection); + } + const { timeFieldName } = indexPattern; + return getSort(sort, indexPattern).map((sortPair: Record) => { + if (indexPattern.isTimeNanosBased() && timeFieldName && sortPair[timeFieldName]) { + return { + [timeFieldName]: { + order: sortPair[timeFieldName], + numeric_type: 'date_nanos', + }, + } as OpenSearchQuerySortValue; + } + return sortPair as OpenSearchQuerySortValue; + }); +} diff --git a/src/plugins/discover/public/application/view_components/utils/index_pattern_helper.ts b/src/plugins/discover/public/application/view_components/utils/index_pattern_helper.ts new file mode 100644 index 00000000000..d6285594367 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/index_pattern_helper.ts @@ -0,0 +1,114 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { SearchSource, IndexPattern } from 'src/plugins/data/public'; +import { SavedObject, ToastsStart } from 'opensearch-dashboards/public'; +import { redirectWhenMissing, getUrlTracker } from '../../../opensearch_dashboards_services'; +import { getIndexPatternId } from '../../helpers/get_index_pattern_id'; + +export type IndexPatternSavedObject = SavedObject & { title: string }; +export interface IndexPatternData { + loaded: IndexPattern; + stateVal: string; + stateValFound: boolean; +} + +export const fetchIndexPattern = async (data, config) => { + await data.indexPatterns.ensureDefaultIndexPattern(); + const indexPatternList = await data.indexPatterns.getCache(); + const id = getIndexPatternId('', indexPatternList, config.get('defaultIndex')); + const indexPatternData = await data.indexPatterns.get(id); + const ip: IndexPatternData = { + loaded: indexPatternData, + stateVal: '', // TODO: get stateVal from appStateContainer + stateValFound: false, // TODO: get stateValFound from appStateContainer + }; + return ip; +}; + +export const fetchSavedSearch = async ( + core, + basePath, + history, + savedSearchId, + services, + toastNotifications +) => { + try { + const savedSearch = await services.getSavedSearchById(savedSearchId); + return savedSearch; + } catch (error) { + // TODO: handle redirect with Data Explorer + redirectWhenMissing({ + history, + navigateToApp: core.application.navigateToApp, + basePath, + mapping: { + search: '/', + 'index-pattern': { + app: 'management', + path: `opensearch-dashboards/objects/savedSearches/${savedSearchId}`, + }, + }, + toastNotifications, + onBeforeRedirect() { + getUrlTracker().setTrackedUrl('/'); + }, + }); + } +}; + +export function resolveIndexPattern( + ip: IndexPatternData, + searchSource: SearchSource, + toastNotifications: ToastsStart +) { + const { loaded: loadedIndexPattern, stateVal, stateValFound } = ip; + + const ownIndexPattern = searchSource.getOwnField('index'); + + if (ownIndexPattern && !stateVal) { + return ownIndexPattern; + } + + if (stateVal && !stateValFound) { + const warningTitle = i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', { + defaultMessage: '{stateVal} is not a configured index pattern ID', + values: { + stateVal: `"${stateVal}"`, + }, + }); + + if (ownIndexPattern) { + toastNotifications.addWarning({ + title: warningTitle, + text: i18n.translate('discover.showingSavedIndexPatternWarningDescription', { + defaultMessage: + 'Showing the saved index pattern: "{ownIndexPatternTitle}" ({ownIndexPatternId})', + values: { + ownIndexPatternTitle: ownIndexPattern.title, + ownIndexPatternId: ownIndexPattern.id, + }, + }), + }); + return ownIndexPattern; + } + + toastNotifications.addWarning({ + title: warningTitle, + text: i18n.translate('discover.showingDefaultIndexPatternWarningDescription', { + defaultMessage: + 'Showing the default index pattern: "{loadedIndexPatternTitle}" ({loadedIndexPatternId})', + values: { + loadedIndexPatternTitle: loadedIndexPattern.title, + loadedIndexPatternId: loadedIndexPattern.id, + }, + }), + }); + } + + return loadedIndexPattern; +} diff --git a/src/plugins/discover/public/application/view_components/utils/update_search_source.ts b/src/plugins/discover/public/application/view_components/utils/update_search_source.ts new file mode 100644 index 00000000000..1404773eb9d --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/update_search_source.ts @@ -0,0 +1,72 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + IndexPattern, + ISearchSource, + indexPatterns as indexPatternUtils, + AggConfigs, +} from '../../../../../data/public'; +import { DiscoverServices } from '../../../build_services'; +import { SortOrder } from '../../../saved_searches/types'; +import { getSortForSearchSource } from './get_sort_for_search_source'; +import { SORT_DEFAULT_ORDER_SETTING, SAMPLE_SIZE_SETTING } from '../../../../common'; + +interface Props { + indexPattern: IndexPattern; + services: DiscoverServices; + sort: SortOrder[] | undefined; + searchSource?: ISearchSource; + histogramConfigs?: AggConfigs; +} + +export const updateSearchSource = async ({ + indexPattern, + services, + searchSource, + sort, + histogramConfigs, +}: Props) => { + const { uiSettings, data } = services; + const sortForSearchSource = getSortForSearchSource( + sort, + indexPattern, + uiSettings.get(SORT_DEFAULT_ORDER_SETTING) + ); + const size = uiSettings.get(SAMPLE_SIZE_SETTING); + const filters = data.query.filterManager.getFilters(); + + const searchSourceInstance = searchSource || (await data.search.searchSource.create()); + + // searchSource which applies time range + const timeRangeSearchSource = await data.search.searchSource.create(); + const { isDefault } = indexPatternUtils; + if (isDefault(indexPattern)) { + const timefilter = data.query.timefilter.timefilter; + + timeRangeSearchSource.setField('filter', () => { + return timefilter.createFilter(indexPattern); + }); + } + + searchSourceInstance.setParent(timeRangeSearchSource); + + searchSourceInstance.setFields({ + index: indexPattern, + sort: sortForSearchSource, + size, + query: data.query.queryString.getQuery() || null, + highlightAll: true, + version: true, + filter: filters, + }); + + if (histogramConfigs) { + const dslAggs = histogramConfigs.toDsl(); + searchSourceInstance.setField('aggs', dslAggs); + } + + return searchSourceInstance; +}; diff --git a/src/plugins/discover/public/application/view_components/utils/use_index_pattern.ts b/src/plugins/discover/public/application/view_components/utils/use_index_pattern.ts new file mode 100644 index 00000000000..87263910798 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/use_index_pattern.ts @@ -0,0 +1,51 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useEffect, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { IndexPattern } from '../../../../../data/public'; +import { useSelector } from '../../utils/state_management'; +import { DiscoverServices } from '../../../build_services'; + +export const useIndexPattern = (services: DiscoverServices) => { + const indexPatternId = useSelector((state) => state.metadata.indexPattern); + const [indexPattern, setIndexPattern] = useState(undefined); + const { data, toastNotifications } = services; + + useEffect(() => { + let isMounted = true; + if (!indexPatternId) return; + const indexPatternMissingWarning = i18n.translate( + 'discover.valueIsNotConfiguredIndexPatternIDWarningTitle', + { + defaultMessage: '{id} is not a configured index pattern ID', + values: { + id: `"${indexPatternId}"`, + }, + } + ); + + data.indexPatterns + .get(indexPatternId) + .then((result) => { + if (isMounted) { + setIndexPattern(result); + } + }) + .catch(() => { + if (isMounted) { + toastNotifications.addDanger({ + title: indexPatternMissingWarning, + }); + } + }); + + return () => { + isMounted = false; + }; + }, [indexPatternId, data.indexPatterns, toastNotifications]); + + return indexPattern; +}; diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts new file mode 100644 index 00000000000..44cf47f2c06 --- /dev/null +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -0,0 +1,270 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useMemo, useRef, useState } from 'react'; +import { BehaviorSubject, Subject, merge } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import { i18n } from '@osd/i18n'; +import { useEffect } from 'react'; +import { RequestAdapter } from '../../../../../inspector/public'; +import { DiscoverServices } from '../../../build_services'; +import { search } from '../../../../../data/public'; +import { validateTimeRange } from '../../helpers/validate_time_range'; +import { updateSearchSource } from './update_search_source'; +import { useIndexPattern } from './use_index_pattern'; +import { OpenSearchSearchHit } from '../../doc_views/doc_views_types'; +import { TimechartHeaderBucketInterval } from '../../components/chart/timechart_header'; +import { tabifyAggResponse } from '../../../opensearch_dashboards_services'; +import { + getDimensions, + buildPointSeriesData, + createHistogramConfigs, + Chart, +} from '../../components/chart/utils'; +import { SavedSearch } from '../../../saved_searches'; +import { useSelector } from '../../utils/state_management'; +import { + getRequestInspectorStats, + getResponseInspectorStats, +} from '../../../opensearch_dashboards_services'; +import { SEARCH_ON_PAGE_LOAD_SETTING } from '../../../../common'; +import { SortOrder } from '../../../saved_searches/types'; + +export enum ResultStatus { + UNINITIALIZED = 'uninitialized', + LOADING = 'loading', // initial data load + READY = 'ready', // results came back + NO_RESULTS = 'none', // no results came back +} + +export interface SearchData { + status: ResultStatus; + fetchCounter?: number; + fieldCounts?: Record; + hits?: number; + rows?: OpenSearchSearchHit[]; + bucketInterval?: TimechartHeaderBucketInterval | {}; + chartData?: Chart; +} + +export type SearchRefetch = 'refetch' | undefined; + +export type DataSubject = BehaviorSubject; +export type RefetchSubject = Subject; + +/** + * A hook that provides functionality for fetching and managing discover search data. + * @returns { data: DataSubject, refetch$: RefetchSubject, indexPattern: IndexPattern, savedSearch?: SavedSearch, inspectorAdapters } - data is a BehaviorSubject that emits the current search data, refetch$ is a Subject that can be used to trigger a refetch, savedSearch is the saved search object if it exists + * @example + * const { data$, refetch$ } = useSearch(); + * useEffect(() => { + * const subscription = data$.subscribe((d) => { + * // do something with the data + * }); + * return () => subscription.unsubscribe(); + * }, [data$]); + */ +export const useSearch = (services: DiscoverServices) => { + const [savedSearch, setSavedSearch] = useState(undefined); + const { savedSearch: savedSearchId, sort, interval } = useSelector((state) => state.discover); + const indexPattern = useIndexPattern(services); + const { data, filterManager, getSavedSearchById, core, toastNotifications } = services; + const timefilter = data.query.timefilter.timefilter; + const fetchStateRef = useRef<{ + abortController: AbortController | undefined; + fieldCounts: Record; + rows?: OpenSearchSearchHit[]; + }>({ + abortController: undefined, + fieldCounts: {}, + }); + const inspectorAdapters = { + requests: new RequestAdapter(), + }; + + const shouldSearchOnPageLoad = useCallback(() => { + // A saved search is created on every page load, so we check the ID to see if we're loading a + // previously saved search or if it is just transient + return ( + services.uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING) || + savedSearch?.id !== undefined || + timefilter.getRefreshInterval().pause === false + ); + }, [savedSearch, services.uiSettings, timefilter]); + + const data$ = useMemo( + () => + new BehaviorSubject({ + status: shouldSearchOnPageLoad() ? ResultStatus.LOADING : ResultStatus.UNINITIALIZED, + }), + [shouldSearchOnPageLoad] + ); + const refetch$ = useMemo(() => new Subject(), []); + + const fetch = useCallback(async () => { + if (!indexPattern) { + data$.next({ + status: shouldSearchOnPageLoad() ? ResultStatus.LOADING : ResultStatus.UNINITIALIZED, + }); + return; + } + + if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { + return data$.next({ + status: ResultStatus.NO_RESULTS, + rows: [], + }); + } + + // Abort any in-progress requests before fetching again + if (fetchStateRef.current.abortController) fetchStateRef.current.abortController.abort(); + fetchStateRef.current.abortController = new AbortController(); + const histogramConfigs = indexPattern.timeFieldName + ? createHistogramConfigs(indexPattern, interval || 'auto', data) + : undefined; + const searchSource = await updateSearchSource({ + indexPattern, + services, + sort, + searchSource: savedSearch?.searchSource, + histogramConfigs, + }); + + try { + // Only show loading indicator if we are fetching when the rows are empty + if (fetchStateRef.current.rows?.length === 0) { + data$.next({ status: ResultStatus.LOADING }); + } + + // Initialize inspect adapter for search source + inspectorAdapters.requests.reset(); + const title = i18n.translate('discover.inspectorRequestDataTitle', { + defaultMessage: 'data', + }); + const description = i18n.translate('discover.inspectorRequestDescription', { + defaultMessage: 'This request queries OpenSearch to fetch the data for the search.', + }); + const inspectorRequest = inspectorAdapters.requests.start(title, { description }); + inspectorRequest.stats(getRequestInspectorStats(searchSource)); + searchSource.getSearchRequestBody().then((body) => { + inspectorRequest.json(body); + }); + + // Execute the search + const fetchResp = await searchSource.fetch({ + abortSignal: fetchStateRef.current.abortController.signal, + }); + + inspectorRequest + .stats(getResponseInspectorStats(fetchResp, searchSource)) + .ok({ json: fetchResp }); + const hits = fetchResp.hits.total as number; + const rows = fetchResp.hits.hits; + let bucketInterval = {}; + let chartData; + for (const row of rows) { + const fields = Object.keys(indexPattern.flattenHit(row)); + for (const fieldName of fields) { + fetchStateRef.current.fieldCounts[fieldName] = + (fetchStateRef.current.fieldCounts[fieldName] || 0) + 1; + } + } + + if (histogramConfigs) { + const bucketAggConfig = histogramConfigs.aggs[1]; + const tabifiedData = tabifyAggResponse(histogramConfigs, fetchResp); + const dimensions = getDimensions(histogramConfigs, data); + if (dimensions) { + if (bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig)) { + bucketInterval = bucketAggConfig.buckets?.getInterval(); + } + // @ts-ignore tabifiedData is compatible but due to the way it is typed typescript complains + chartData = buildPointSeriesData(tabifiedData, dimensions); + } + } + + fetchStateRef.current.fieldCounts = fetchStateRef.current.fieldCounts!; + fetchStateRef.current.rows = rows; + data$.next({ + status: rows.length > 0 ? ResultStatus.READY : ResultStatus.NO_RESULTS, + fieldCounts: fetchStateRef.current.fieldCounts, + hits, + rows, + bucketInterval, + chartData, + }); + } catch (error) { + // If the request was aborted then no need to surface this error in the UI + if (error instanceof Error && error.name === 'AbortError') return; + + data$.next({ + status: ResultStatus.NO_RESULTS, + rows: [], + }); + + data.search.showError(error as Error); + } + }, [ + indexPattern, + interval, + timefilter, + toastNotifications, + data, + services, + savedSearch?.searchSource, + data$, + sort, + shouldSearchOnPageLoad, + inspectorAdapters.requests, + ]); + + useEffect(() => { + const fetch$ = merge( + refetch$, + filterManager.getFetches$(), + timefilter.getFetch$(), + timefilter.getTimeUpdate$(), + timefilter.getAutoRefreshFetch$(), + data.query.queryString.getUpdates$() + ).pipe(debounceTime(100)); + + const subscription = fetch$.subscribe(() => { + (async () => { + try { + await fetch(); + } catch (error) { + core.fatalErrors.add(error as Error); + } + })(); + }); + + // kick off initial fetch + refetch$.next(); + + return () => { + subscription.unsubscribe(); + }; + }, [data$, data.query.queryString, filterManager, refetch$, timefilter, fetch, core.fatalErrors]); + + // Get savedSearch if it exists + useEffect(() => { + (async () => { + const savedSearchInstance = await getSavedSearchById(savedSearchId || ''); + setSavedSearch(savedSearchInstance); + })(); + + return () => {}; + }, [getSavedSearchById, savedSearchId]); + + return { + data$, + refetch$, + indexPattern, + savedSearch, + inspectorAdapters, + }; +}; + +export type SearchContextValue = ReturnType; diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 3fdafcff0c4..ebe4e80a70c 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -57,6 +57,7 @@ import { getHistory } from './opensearch_dashboards_services'; import { OpenSearchDashboardsLegacyStart } from '../../opensearch_dashboards_legacy/public'; import { UrlForwardingStart } from '../../url_forwarding/public'; import { NavigationPublicPluginStart } from '../../navigation/public'; +import { DataExplorerServices } from '../../data_explorer/public'; export interface DiscoverServices { addBasePath: (path: string) => string; @@ -79,17 +80,15 @@ export interface DiscoverServices { toastNotifications: ToastsStart; getSavedSearchById: (id: string) => Promise; getSavedSearchUrlById: (id: string) => Promise; - getEmbeddableInjector: any; uiSettings: IUiSettingsClient; visualizations: VisualizationsStart; } -export async function buildServices( +export function buildServices( core: CoreStart, plugins: DiscoverStartPlugins, - context: PluginInitializerContext, - getEmbeddableInjector: any -): Promise { + context: PluginInitializerContext +): DiscoverServices { const services: SavedObjectOpenSearchDashboardsServices = { savedObjectsClient: core.savedObjects.client, indexPatterns: plugins.data.indexPatterns, @@ -108,7 +107,6 @@ export async function buildServices( docLinks: core.docLinks, theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, - getEmbeddableInjector, getSavedSearchById: async (id: string) => savedObjectService.get(id), getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id), history: getHistory, @@ -127,3 +125,6 @@ export async function buildServices( visualizations: plugins.visualizations, }; } + +// Any component inside the panel and canvas views has access to both these services. +export type DiscoverViewServices = DiscoverServices & DataExplorerServices; diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index 6c9ab46b656..3bc00991494 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -37,5 +37,7 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; -export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; +// TODO: Fix embeddable after removing Angular +// export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator'; +export { NEW_DISCOVER_APP } from '../common'; diff --git a/src/plugins/discover/public/migrate_state.ts b/src/plugins/discover/public/migrate_state.ts new file mode 100644 index 00000000000..2a3cd77d26b --- /dev/null +++ b/src/plugins/discover/public/migrate_state.ts @@ -0,0 +1,143 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { matchPath } from 'react-router-dom'; +import { getStateFromOsdUrl, setStateToOsdUrl } from '../../opensearch_dashboards_utils/public'; +import { Filter, Query } from '../../data/public'; + +interface CommonParams { + appState?: string; +} + +interface DiscoverParams extends CommonParams { + id?: string; +} + +interface ContextParams extends CommonParams { + indexPattern: string; + id: string; +} + +interface DocParams extends CommonParams { + indexPattern: string; + index: string; +} + +export interface LegacyDiscoverState { + /** + * Columns displayed in the table + */ + columns?: string[]; + /** + * Array of applied filters + */ + filters?: Filter[]; + /** + * id of the used index pattern + */ + index?: string; + /** + * Used interval of the histogram + */ + interval?: string; + /** + * Lucence or DQL query + */ + query?: Query; + /** + * Array of the used sorting [[field,direction],...] + */ + sort?: string[][]; + /** + * id of the used saved query + */ + savedQuery?: string; +} + +// TODO: Write unit tests once all routes have been migrated. +/** + * Migrates legacy URLs to the current URL format. + * @param oldPath The legacy hash that contains the state. + * @param newPath The new base path. + */ +export function migrateUrlState(oldPath: string, newPath = '/'): string { + let path = newPath; + const pathPatterns = [ + { + pattern: '#/context/:indexPattern/:id\\?:appState?', + extraState: { docView: 'context' }, + path: `context`, + }, + { + pattern: '#/doc/:indexPattern/:index\\?:appState?', + extraState: { docView: 'doc' }, + path: `doc`, + }, + { + pattern: '#/view/:id', + extraState: {}, + path: `savedSearch`, + }, + { pattern: '#/', extraState: {}, path: `discover` }, + ]; + + // Get the first matching path pattern. + const matchingPathPattern = pathPatterns.find((pathPattern) => + matchPath(oldPath, { path: pathPattern.pattern, strict: false }) + ); + + if (!matchingPathPattern) { + return path; + } + + // Migrate the path. + switch (matchingPathPattern.path) { + // doc and context views will use the same legacy path and state + // so we can use the saved legacy path in new discover + case `doc`: + case `context`: + path = oldPath; + case `discover`: + case `savedSearch`: + const params = matchPath(oldPath, { + path: matchingPathPattern.pattern, + })!.params; + + // if there is a saved search id, use the saved search path + if (params.id) { + path = `${path}#/view/${params.id}`; + } + + const appState = getStateFromOsdUrl('_a', oldPath); + + if (!appState) return path; + + const { columns, filters, index, interval, query, sort, savedQuery } = appState; + + const _q = { + query, + filters, + }; + + const _a = { + discover: { + columns, + interval, + sort, + savedQuery, + }, + metadata: { + indexPattern: index, + }, + }; + + path = setStateToOsdUrl('_a', _a, { useHash: false }, path); + path = setStateToOsdUrl('_q', _q, { useHash: false }, path); + + break; + } + + return path; +} diff --git a/src/plugins/discover/public/opensearch_dashboards_services.ts b/src/plugins/discover/public/opensearch_dashboards_services.ts index 8531564e0cc..7149454a34c 100644 --- a/src/plugins/discover/public/opensearch_dashboards_services.ts +++ b/src/plugins/discover/public/opensearch_dashboards_services.ts @@ -38,24 +38,9 @@ import { search } from '../../data/public'; import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; import { DocViewsLinksRegistry } from './application/doc_views_links/doc_views_links_registry'; -let angularModule: any = null; let services: DiscoverServices | null = null; let uiActions: UiActionsStart; -/** - * set bootstrapped inner angular module - */ -export function setAngularModule(module: any) { - angularModule = module; -} - -/** - * get boostrapped inner angular module - */ -export function getAngularModule() { - return angularModule; -} - export function getServices(): DiscoverServices { if (!services) { throw new Error('Discover services are not yet available'); @@ -74,11 +59,6 @@ export const [getHeaderActionMenuMounter, setHeaderActionMenuMounter] = createGe AppMountParameters['setHeaderActionMenu'] >('headerActionMenuMounter'); -export const [getUrlTracker, setUrlTracker] = createGetterSetter<{ - setTrackedUrl: (url: string) => void; - restorePreviousUrl: () => void; -}>('urlTracker'); - export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( 'DocViewsRegistry' ); @@ -86,6 +66,7 @@ export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter('DocViewsLinksRegistry'); + /** * Makes sure discover and context are using one instance of history. */ diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 62f6e6908ba..f1532b6f776 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -1,37 +1,10 @@ /* + * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 - * - * The OpenSearch Contributors require contributions made to - * this file be licensed under the Apache-2.0 license or a - * compatible open source license. - * - * Any modifications Copyright OpenSearch Contributors. See - * GitHub history for details. - */ - -/* - * Licensed to Elasticsearch B.V. under one or more contributor - * license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright - * ownership. Elasticsearch B.V. licenses this file to you under - * the Apache License, Version 2.0 (the "License"); you may - * not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, - * software distributed under the License is distributed on an - * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY - * KIND, either express or implied. See the License for the - * specific language governing permissions and limitations - * under the License. */ import { i18n } from '@osd/i18n'; -import angular, { auto } from 'angular'; import { BehaviorSubject } from 'rxjs'; -import { filter, map } from 'rxjs/operators'; import { AppMountParameters, @@ -56,12 +29,14 @@ import { HomePublicPluginSetup } from 'src/plugins/home/public'; import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; import { stringify } from 'query-string'; import rison from 'rison-node'; +import { lazy } from 'react'; import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; import { SavedObjectLoader } from '../../saved_objects/public'; -import { createOsdUrlTracker, url } from '../../opensearch_dashboards_utils/public'; +import { url } from '../../opensearch_dashboards_utils/public'; import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { UrlGeneratorState } from '../../share/public'; import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; +import { generateDocViewsUrl } from './application/components/doc_views/generate_doc_views_url'; import { DocViewLink } from './application/doc_views_links/doc_views_links_types'; import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; import { DocViewsLinksRegistry } from './application/doc_views_links/doc_views_links_registry'; @@ -70,25 +45,30 @@ import { JsonCodeBlock } from './application/components/json_code_block/json_cod import { setDocViewsRegistry, setDocViewsLinksRegistry, - setUrlTracker, - setAngularModule, setServices, setHeaderActionMenuMounter, setUiActions, setScopedHistory, - getScopedHistory, syncHistoryLocations, getServices, } from './opensearch_dashboards_services'; import { createSavedSearchesLoader } from './saved_searches'; -import { registerFeature } from './register_feature'; import { buildServices } from './build_services'; import { DiscoverUrlGeneratorState, DISCOVER_APP_URL_GENERATOR, DiscoverUrlGenerator, } from './url_generator'; -import { SearchEmbeddableFactory } from './application/embeddable'; +// import { SearchEmbeddableFactory } from './application/embeddable'; +import { NEW_DISCOVER_APP, PLUGIN_ID } from '../common'; +import { DataExplorerPluginSetup } from '../../data_explorer/public'; +import { registerFeature } from './register_feature'; +import { + DiscoverState, + discoverSlice, + getPreloadedState, +} from './application/utils/state_management/discover_slice'; +import { migrateUrlState } from './migrate_state'; declare module '../../share/public' { export interface UrlGeneratorStateMapping { @@ -103,7 +83,6 @@ export interface DiscoverSetup { docViews: { /** * Add new doc view shown along with table view and json view in the details of each document in Discover. - * Both react and angular doc views are supported. * @param docViewRaw */ addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; @@ -148,6 +127,7 @@ export interface DiscoverSetupPlugins { home?: HomePublicPluginSetup; visualizations: VisualizationsSetup; data: DataPublicPluginSetup; + dataExplorer: DataExplorerPluginSetup; } /** @@ -166,13 +146,9 @@ export interface DiscoverStartPlugins { visualizations: VisualizationsStart; } -const innerAngularName = 'app/discover'; -const embeddableAngularName = 'app/discoverEmbeddable'; - /** * Contains Discover, one of the oldest parts of OpenSearch Dashboards - * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular - * Discover provides embeddables, those contain a slimmer Angular + * Discover provides embeddables for Dashboards */ export class DiscoverPlugin implements Plugin { @@ -181,18 +157,10 @@ export class DiscoverPlugin private appStateUpdater = new BehaviorSubject(() => ({})); private docViewsRegistry: DocViewsRegistry | null = null; private docViewsLinksRegistry: DocViewsLinksRegistry | null = null; - private embeddableInjector: auto.IInjectorService | null = null; private stopUrlTracking: (() => void) | undefined = undefined; private servicesInitialized: boolean = false; - private innerAngularInitialized: boolean = false; private urlGenerator?: DiscoverStart['urlGenerator']; - - /** - * why are those functions public? they are needed for some mocha tests - * can be removed once all is Jest - */ - public initializeInnerAngular?: () => void; - public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; + private initializeServices?: () => { core: CoreStart; plugins: DiscoverStartPlugins }; setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { const baseUrl = core.http.basePath.prepend('/app/discover'); @@ -248,10 +216,12 @@ export class DiscoverPlugin { encode: false, sort: false } ); + const contextUrl = `#/context/${encodeURIComponent( + renderProps.indexPattern.id + )}/${encodeURIComponent(renderProps.hit._id)}?${hash}`; + return { - url: `#/context/${encodeURIComponent(renderProps.indexPattern.id)}/${encodeURIComponent( - renderProps.hit._id - )}?${hash}`, + url: generateDocViewsUrl(contextUrl), hide: !renderProps.indexPattern.isTimeBased(), }; }, @@ -262,53 +232,19 @@ export class DiscoverPlugin label: i18n.translate('discover.docTable.tableRow.viewSingleDocumentLinkText', { defaultMessage: 'View single document', }), - generateCb: (renderProps) => ({ - url: `#/doc/${renderProps.indexPattern.id}/${ + generateCb: (renderProps) => { + const docUrl = `#/doc/${renderProps.indexPattern.id}/${ renderProps.hit._index - }?id=${encodeURIComponent(renderProps.hit._id)}`, - }), + }?id=${encodeURIComponent(renderProps.hit._id)}`; + return { + url: generateDocViewsUrl(docUrl), + }; + }, order: 2, }); - const { - appMounted, - appUnMounted, - stop: stopUrlTracker, - setActiveUrl: setTrackedUrl, - restorePreviousUrl, - } = createOsdUrlTracker({ - // we pass getter here instead of plain `history`, - // so history is lazily created (when app is mounted) - // this prevents redundant `#` when not in discover app - getHistory: getScopedHistory, - baseUrl, - defaultSubUrl: '#/', - storageKey: `lastUrl:${core.http.basePath.get()}:discover`, - navLinkUpdater$: this.appStateUpdater, - toastNotifications: core.notifications.toasts, - stateParams: [ - { - osdUrlKey: '_g', - stateUpdate$: plugins.data.query.state$.pipe( - filter( - ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) - ), - map(({ state }) => ({ - ...state, - filters: state.filters?.filter(opensearchFilters.isFilterPinned), - })) - ), - }, - ], - }); - setUrlTracker({ setTrackedUrl, restorePreviousUrl }); - this.stopUrlTracking = () => { - stopUrlTracker(); - }; - - this.docViewsRegistry.setAngularInjectorGetter(this.getEmbeddableInjector); core.application.register({ - id: 'discover', + id: PLUGIN_ID, title: 'Discover', updater$: this.appStateUpdater.asObservable(), order: 1000, @@ -319,58 +255,100 @@ export class DiscoverPlugin if (!this.initializeServices) { throw Error('Discover plugin method initializeServices is undefined'); } - if (!this.initializeInnerAngular) { - throw Error('Discover plugin method initializeInnerAngular is undefined'); - } setScopedHistory(params.history); setHeaderActionMenuMounter(params.setHeaderActionMenu); syncHistoryLocations(); - appMounted(); const { - plugins: { data: dataStart }, + core: { + application: { navigateToApp }, + }, } = await this.initializeServices(); - await this.initializeInnerAngular(); - - // make sure the index pattern list is up to date - await dataStart.indexPatterns.clearCache(); - const { renderApp } = await import('./application/application'); - params.element.classList.add('dscAppWrapper'); - const unmount = await renderApp(innerAngularName, params.element); - return () => { - params.element.classList.remove('dscAppWrapper'); - unmount(); - appUnMounted(); - }; + + // This is for instances where the user navigates to the app from the application nav menu + const path = window.location.hash; + const v2Enabled = await core.uiSettings.get(NEW_DISCOVER_APP); + + if (!v2Enabled) { + navigateToApp('discoverLegacy', { + replace: true, + path, + }); + } else { + const newPath = migrateUrlState(path); + 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); } - this.registerEmbeddable(core, plugins); + plugins.dataExplorer.registerView({ + id: PLUGIN_ID, + title: 'Discover', + defaultPath: '#/', + appExtentions: { + savedObject: { + docTypes: ['search'], + toListItem: (obj) => ({ + id: obj.id, + label: obj.title, + }), + }, + }, + ui: { + defaults: async () => { + this.initializeServices?.(); + const services = getServices(); + return await getPreloadedState(services); + }, + slice: discoverSlice, + }, + shouldShow: () => true, + // ViewComponent + Canvas: lazy(() => import('./application/view_components/canvas')), + Panel: lazy(() => import('./application/view_components/panel')), + Context: lazy(() => import('./application/view_components/context')), + }); + + // this.registerEmbeddable(core, plugins); return { docViews: { @@ -383,44 +361,21 @@ export class DiscoverPlugin } start(core: CoreStart, plugins: DiscoverStartPlugins) { - // we need to register the application service at setup, but to render it - // there are some start dependencies necessary, for this reason - // initializeInnerAngular + initializeServices are assigned at start and used - // when the application/embeddable is mounted - this.initializeInnerAngular = async () => { - if (this.innerAngularInitialized) { - return; - } - // this is used by application mount and tests - const { getInnerAngularModule } = await import('./get_inner_angular'); - const module = getInnerAngularModule( - innerAngularName, - core, - plugins, - this.initializerContext - ); - setAngularModule(module); - this.innerAngularInitialized = true; - }; - setUiActions(plugins.uiActions); - this.initializeServices = async () => { + this.initializeServices = () => { if (this.servicesInitialized) { return { core, plugins }; } - const services = await buildServices( - core, - plugins, - this.initializerContext, - this.getEmbeddableInjector - ); + const services = buildServices(core, plugins, this.initializerContext); setServices(services); this.servicesInitialized = true; return { core, plugins }; }; + this.initializeServices(); + return { urlGenerator: this.urlGenerator, savedSearchLoader: createSavedSearchesLoader({ @@ -439,14 +394,11 @@ export class DiscoverPlugin } } + // TODO: Use this registration when legacy discover is removed /** * register embeddable with a slimmer embeddable version of inner angular */ private registerEmbeddable(core: CoreSetup, plugins: DiscoverSetupPlugins) { - if (!this.getEmbeddableInjector) { - throw Error('Discover plugin method getEmbeddableInjector is undefined'); - } - const getStartServices = async () => { const [coreStart, deps] = await core.getStartServices(); return { @@ -455,23 +407,8 @@ export class DiscoverPlugin }; }; - const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector); - plugins.embeddable.registerEmbeddableFactory(factory.type, factory); + // TODO: Refactor to remove angular + // const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector); + // plugins.embeddable.registerEmbeddableFactory(factory.type, factory); } - - private getEmbeddableInjector = async () => { - if (!this.embeddableInjector) { - if (!this.initializeServices) { - throw Error('Discover plugin getEmbeddableInjector: initializeServices is undefined'); - } - const { core, plugins } = await this.initializeServices(); - getServices().opensearchDashboardsLegacy.loadFontAwesome(); - const { getInnerAngularModuleEmbeddable } = await import('./get_inner_angular'); - getInnerAngularModuleEmbeddable(embeddableAngularName, core, plugins); - const mountpoint = document.createElement('div'); - this.embeddableInjector = angular.bootstrap(mountpoint, [embeddableAngularName]); - } - - return this.embeddableInjector; - }; } diff --git a/src/plugins/discover/public/saved_searches/_saved_search.ts b/src/plugins/discover/public/saved_searches/_saved_search.ts index 55cd59104ec..9b43e3a8920 100644 --- a/src/plugins/discover/public/saved_searches/_saved_search.ts +++ b/src/plugins/discover/public/saved_searches/_saved_search.ts @@ -34,6 +34,8 @@ import { SavedObjectOpenSearchDashboardsServices, } from '../../../saved_objects/public'; +export const SAVED_OBJECT_TYPE = 'search'; + export function createSavedSearchClass(services: SavedObjectOpenSearchDashboardsServices) { const SavedObjectClass = createSavedObjectClass(services); diff --git a/src/plugins/discover/public/saved_searches/types.ts b/src/plugins/discover/public/saved_searches/types.ts index e02fd65e689..112d7d998c9 100644 --- a/src/plugins/discover/public/saved_searches/types.ts +++ b/src/plugins/discover/public/saved_searches/types.ts @@ -28,18 +28,16 @@ * under the License. */ +import { SavedObject } from '../../../saved_objects/public'; import { ISearchSource } from '../../../data/public'; -export type SortOrder = [string, string]; -export interface SavedSearch { - readonly id: string; - title: string; - searchSource: ISearchSource; +export type SortOrder = [string, 'asc' | 'desc']; +export interface SavedSearch + extends Pick { + searchSource: ISearchSource; // This is optional in SavedObject, but required for SavedSearch description?: string; columns: string[]; sort: SortOrder[]; - destroy: () => void; - lastSavedTitle?: string; } export interface SavedSearchLoader { get: (id: string) => Promise; diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 70eab306e7f..2b35384c2e5 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 00000000000..a914d651eef --- /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 00000000000..371442385bb --- /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 00000000000..6a4259a41d7 --- /dev/null +++ b/src/plugins/discover_legacy/opensearch_dashboards.json @@ -0,0 +1,27 @@ +{ + "id": "discoverLegacy", + "version": "opensearchDashboards", + "server": false, + "ui": true, + "requiredPlugins": [ + "charts", + "data", + "embeddable", + "inspector", + "opensearchDashboardsLegacy", + "urlForwarding", + "navigation", + "uiActions", + "visualizations" + ], + "optionalPlugins": [ + "home", + "share" + ], + "requiredBundles": [ + "opensearchDashboardsUtils", + "savedObjects", + "opensearchDashboardsReact", + "discover" + ] +} \ No newline at end of file diff --git a/src/plugins/discover/public/application/_discover.scss b/src/plugins/discover_legacy/public/application/_discover.scss similarity index 100% rename from src/plugins/discover/public/application/_discover.scss rename to src/plugins/discover_legacy/public/application/_discover.scss diff --git a/src/plugins/discover/public/application/angular/_index.scss b/src/plugins/discover_legacy/public/application/angular/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/_index.scss rename to src/plugins/discover_legacy/public/application/angular/_index.scss diff --git a/src/plugins/discover/public/application/angular/context.html b/src/plugins/discover_legacy/public/application/angular/context.html similarity index 100% rename from src/plugins/discover/public/application/angular/context.html rename to src/plugins/discover_legacy/public/application/angular/context.html diff --git a/src/plugins/discover/public/application/angular/context.js b/src/plugins/discover_legacy/public/application/angular/context.js similarity index 100% rename from src/plugins/discover/public/application/angular/context.js rename to src/plugins/discover_legacy/public/application/angular/context.js diff --git a/src/plugins/discover/public/application/angular/context/NOTES.md b/src/plugins/discover_legacy/public/application/angular/context/NOTES.md similarity index 100% rename from src/plugins/discover/public/application/angular/context/NOTES.md rename to src/plugins/discover_legacy/public/application/angular/context/NOTES.md diff --git a/src/plugins/discover_legacy/public/application/angular/context/_index.scss b/src/plugins/discover_legacy/public/application/angular/context/_index.scss new file mode 100644 index 00000000000..4ac09e25eb9 --- /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/public/application/angular/context/api/_stubs.js b/src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/_stubs.js rename to src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js diff --git a/src/plugins/discover/public/application/angular/context/api/anchor.js b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/anchor.js rename to src/plugins/discover_legacy/public/application/angular/context/api/anchor.js diff --git a/src/plugins/discover/public/application/angular/context/api/anchor.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/anchor.test.js rename to src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js diff --git a/src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/context.predecessors.test.js rename to src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js diff --git a/src/plugins/discover/public/application/angular/context/api/context.successors.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/context.successors.test.js rename to src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js diff --git a/src/plugins/discover/public/application/angular/context/api/context.ts b/src/plugins/discover_legacy/public/application/angular/context/api/context.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/context.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/context.ts diff --git a/src/plugins/discover_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 00000000000..fe1a18bf938 --- /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 00000000000..8f4bfb30375 --- /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/public/application/angular/context/api/utils/fetch_hits_in_interval.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/fetch_hits_in_interval.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts diff --git a/src/plugins/discover/public/application/angular/context/api/utils/generate_intervals.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/generate_intervals.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts diff --git a/src/plugins/discover_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 00000000000..eb6a5af565b --- /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/public/application/angular/context/api/utils/get_opensearch_query_sort.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/get_opensearch_query_sort.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts diff --git a/src/plugins/discover_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 00000000000..6944591d40c --- /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/public/application/angular/context/api/utils/sorting.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/api/utils/sorting.ts rename to src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts diff --git a/src/plugins/discover_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 00000000000..da0911c3a45 --- /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 00000000000..40a44622057 --- /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/public/application/angular/context/components/action_bar/action_bar.test.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.test.tsx rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/action_bar.tsx rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx diff --git a/src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_directive.ts b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/action_bar_directive.ts rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts diff --git a/src/plugins/discover_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 00000000000..cfdc3cc0c8c --- /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/public/application/angular/context/components/action_bar/index.ts b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/components/action_bar/index.ts rename to src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts diff --git a/src/plugins/discover/public/application/angular/context/helpers/call_after_bindings_workaround.js b/src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/helpers/call_after_bindings_workaround.js rename to src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js diff --git a/src/plugins/discover/public/application/angular/context/query/actions.js b/src/plugins/discover_legacy/public/application/angular/context/query/actions.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query/actions.js rename to src/plugins/discover_legacy/public/application/angular/context/query/actions.js diff --git a/src/plugins/discover/public/application/angular/context/query/constants.js b/src/plugins/discover_legacy/public/application/angular/context/query/constants.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query/constants.js rename to src/plugins/discover_legacy/public/application/angular/context/query/constants.js diff --git a/src/plugins/discover/public/application/angular/context/query/index.js b/src/plugins/discover_legacy/public/application/angular/context/query/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query/index.js rename to src/plugins/discover_legacy/public/application/angular/context/query/index.js diff --git a/src/plugins/discover/public/application/angular/context/query/state.js b/src/plugins/discover_legacy/public/application/angular/context/query/state.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query/state.js rename to src/plugins/discover_legacy/public/application/angular/context/query/state.js diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/actions.js b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/actions.js rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/actions.test.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/actions.test.ts rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/constants.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/constants.ts rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/index.js b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/index.js rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js diff --git a/src/plugins/discover/public/application/angular/context/query_parameters/state.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context/query_parameters/state.ts rename to src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts diff --git a/src/plugins/discover/public/application/angular/context_app.html b/src/plugins/discover_legacy/public/application/angular/context_app.html similarity index 100% rename from src/plugins/discover/public/application/angular/context_app.html rename to src/plugins/discover_legacy/public/application/angular/context_app.html diff --git a/src/plugins/discover/public/application/angular/context_app.js b/src/plugins/discover_legacy/public/application/angular/context_app.js similarity index 100% rename from src/plugins/discover/public/application/angular/context_app.js rename to src/plugins/discover_legacy/public/application/angular/context_app.js diff --git a/src/plugins/discover/public/application/angular/context_state.test.ts b/src/plugins/discover_legacy/public/application/angular/context_state.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context_state.test.ts rename to src/plugins/discover_legacy/public/application/angular/context_state.test.ts diff --git a/src/plugins/discover/public/application/angular/context_state.ts b/src/plugins/discover_legacy/public/application/angular/context_state.ts similarity index 100% rename from src/plugins/discover/public/application/angular/context_state.ts rename to src/plugins/discover_legacy/public/application/angular/context_state.ts diff --git a/src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap b/src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap similarity index 100% rename from src/plugins/discover/public/application/angular/directives/__snapshots__/no_results.test.js.snap rename to src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap diff --git a/src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss b/src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss new file mode 100644 index 00000000000..1e625fa064e --- /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/public/application/angular/directives/_index.scss b/src/plugins/discover_legacy/public/application/angular/directives/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/directives/_index.scss rename to src/plugins/discover_legacy/public/application/angular/directives/_index.scss diff --git a/src/plugins/discover/public/application/angular/directives/_no_results.scss b/src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss similarity index 100% rename from src/plugins/discover/public/application/angular/directives/_no_results.scss rename to src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss diff --git a/src/plugins/discover/public/application/angular/directives/debounce/debounce.js b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/debounce/debounce.js rename to src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js diff --git a/src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/directives/debounce/debounce.test.ts rename to src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts diff --git a/src/plugins/discover/public/application/angular/directives/debounce/index.js b/src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/debounce/index.js rename to src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js diff --git a/src/plugins/discover/public/application/angular/directives/fixed_scroll.js b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/fixed_scroll.js rename to src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js diff --git a/src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/fixed_scroll.test.js rename to src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.test.js diff --git a/src/plugins/discover/public/application/angular/directives/histogram.tsx b/src/plugins/discover_legacy/public/application/angular/directives/histogram.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/directives/histogram.tsx rename to src/plugins/discover_legacy/public/application/angular/directives/histogram.tsx diff --git a/src/plugins/discover/public/application/angular/directives/index.js b/src/plugins/discover_legacy/public/application/angular/directives/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/index.js rename to src/plugins/discover_legacy/public/application/angular/directives/index.js diff --git a/src/plugins/discover/public/application/angular/directives/no_results.js b/src/plugins/discover_legacy/public/application/angular/directives/no_results.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/no_results.js rename to src/plugins/discover_legacy/public/application/angular/directives/no_results.js diff --git a/src/plugins/discover/public/application/angular/directives/no_results.test.js b/src/plugins/discover_legacy/public/application/angular/directives/no_results.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/directives/no_results.test.js rename to src/plugins/discover_legacy/public/application/angular/directives/no_results.test.js diff --git a/src/plugins/discover/public/application/angular/directives/render_complete.ts b/src/plugins/discover_legacy/public/application/angular/directives/render_complete.ts similarity index 100% rename from src/plugins/discover/public/application/angular/directives/render_complete.ts rename to src/plugins/discover_legacy/public/application/angular/directives/render_complete.ts diff --git a/src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx b/src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx new file mode 100644 index 00000000000..9cc47b034d1 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx @@ -0,0 +1,78 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; + +import { EuiButton, EuiEmptyPrompt, EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; + +interface Props { + onRefresh: () => void; +} + +export const DiscoverUninitialized = ({ onRefresh }: Props) => { + return ( + + + + + + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> +
+
+
+
+ ); +}; diff --git a/src/plugins/discover/public/application/angular/discover.js b/src/plugins/discover_legacy/public/application/angular/discover.js similarity index 98% rename from src/plugins/discover/public/application/angular/discover.js rename to src/plugins/discover_legacy/public/application/angular/discover.js index de244e3c44b..f8a96928784 100644 --- a/src/plugins/discover/public/application/angular/discover.js +++ b/src/plugins/discover_legacy/public/application/angular/discover.js @@ -93,6 +93,7 @@ import { DOC_HIDE_TIME_COLUMN_SETTING, MODIFY_COLUMNS_ON_SWITCH, } from '../../../common'; +import { NEW_DISCOVER_APP } from '../../../../discover/public'; const fetchStatuses = { UNINITIALIZED: 'uninitialized', @@ -480,7 +481,24 @@ function discoverController($element, $route, $scope, $timeout, $window, Promise }, }; + const newDiscover = { + id: 'discover-new', + label: i18n.translate('discover.localMenu.newDiscoverTitle', { + defaultMessage: 'New Discover', + }), + description: i18n.translate('discover.localMenu.newDiscoverDescription', { + defaultMessage: 'New Discover Experience', + }), + testId: 'discoverNewButton', + run: async function () { + await getServices().uiSettings.set(NEW_DISCOVER_APP, true); + window.location.reload(); + }, + type: 'toggle', + }; + return [ + newDiscover, newSearch, ...(uiCapabilities.discover.save ? [saveSearch] : []), openSearch, diff --git a/src/plugins/discover/public/application/angular/discover_legacy.html b/src/plugins/discover_legacy/public/application/angular/discover_legacy.html similarity index 100% rename from src/plugins/discover/public/application/angular/discover_legacy.html rename to src/plugins/discover_legacy/public/application/angular/discover_legacy.html diff --git a/src/plugins/discover/public/application/angular/discover_state.test.ts b/src/plugins/discover_legacy/public/application/angular/discover_state.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/discover_state.test.ts rename to src/plugins/discover_legacy/public/application/angular/discover_state.test.ts diff --git a/src/plugins/discover/public/application/angular/discover_state.ts b/src/plugins/discover_legacy/public/application/angular/discover_state.ts similarity index 100% rename from src/plugins/discover/public/application/angular/discover_state.ts rename to src/plugins/discover_legacy/public/application/angular/discover_state.ts diff --git a/src/plugins/discover/public/application/angular/doc.html b/src/plugins/discover_legacy/public/application/angular/doc.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc.html rename to src/plugins/discover_legacy/public/application/angular/doc.html diff --git a/src/plugins/discover/public/application/angular/doc.ts b/src/plugins/discover_legacy/public/application/angular/doc.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc.ts rename to src/plugins/discover_legacy/public/application/angular/doc.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/_doc_table.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/_doc_table.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/_doc_table.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/_doc_table.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/actions/columns.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/actions/columns.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/actions/columns.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/actions/columns.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/components/_index.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/_index.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/_index.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/_table_header.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/_table_header.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/_table_header.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/_table_header.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/index.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/index.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/index.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js b/src/plugins/discover_legacy/public/application/angular/doc_table/components/row_headers.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/row_headers.test.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/row_headers.test.js diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/helpers.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/helpers.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/helpers.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/helpers.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.test.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.test.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.test.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header_column.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header_column.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_header/table_header_column.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header_column.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/_cell.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_cell.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/_cell.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_cell.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/_details.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_details.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/_details.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_details.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/_index.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/_index.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_index.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/_open.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/_open.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/cell.html b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/cell.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/cell.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/cell.html diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/details.html b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/details.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/details.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/details.html diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/open.html b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/open.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/open.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/open.html diff --git a/src/plugins/discover/public/application/angular/doc_table/components/table_row/truncate_by_height.html b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/truncate_by_height.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/components/table_row/truncate_by_height.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/truncate_by_height.html diff --git a/src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx b/src/plugins/discover_legacy/public/application/angular/doc_table/create_doc_table_react.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/create_doc_table_react.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_table/create_doc_table_react.tsx diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.html b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.html similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/doc_table.html rename to src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.html diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.test.js b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.test.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/doc_table.test.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.test.js diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/doc_table.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/doc_table_strings.js b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table_strings.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/doc_table_strings.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/doc_table_strings.js diff --git a/src/plugins/discover/public/application/angular/doc_table/index.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/index.scss similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/index.scss rename to src/plugins/discover_legacy/public/application/angular/doc_table/index.scss diff --git a/src/plugins/discover/public/application/angular/doc_table/index.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/index.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/index.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/infinite_scroll.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/infinite_scroll.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/infinite_scroll.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/infinite_scroll.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_default_sort.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/get_default_sort.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_default_sort.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.test.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/get_sort.test.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.test.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/get_sort.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort_for_search_source.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/get_sort_for_search_source.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort_for_search_source.ts diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/pager/index.js b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/index.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/pager/index.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/index.js diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/pager/pager.js b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager.js similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/pager/pager.js rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager.js diff --git a/src/plugins/discover/public/application/angular/doc_table/lib/pager/pager_factory.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager_factory.ts similarity index 100% rename from src/plugins/discover/public/application/angular/doc_table/lib/pager/pager_factory.ts rename to src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager_factory.ts diff --git a/src/plugins/discover/public/application/angular/doc_viewer.tsx b/src/plugins/discover_legacy/public/application/angular/doc_viewer.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_viewer.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_viewer.tsx diff --git a/src/plugins/discover/public/application/angular/doc_viewer_links.tsx b/src/plugins/discover_legacy/public/application/angular/doc_viewer_links.tsx similarity index 100% rename from src/plugins/discover/public/application/angular/doc_viewer_links.tsx rename to src/plugins/discover_legacy/public/application/angular/doc_viewer_links.tsx diff --git a/src/plugins/discover/public/application/angular/helpers/index.ts b/src/plugins/discover_legacy/public/application/angular/helpers/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/helpers/index.ts rename to src/plugins/discover_legacy/public/application/angular/helpers/index.ts diff --git a/src/plugins/discover/public/application/angular/helpers/point_series.ts b/src/plugins/discover_legacy/public/application/angular/helpers/point_series.ts similarity index 100% rename from src/plugins/discover/public/application/angular/helpers/point_series.ts rename to src/plugins/discover_legacy/public/application/angular/helpers/point_series.ts diff --git a/src/plugins/discover/public/application/angular/index.ts b/src/plugins/discover_legacy/public/application/angular/index.ts similarity index 100% rename from src/plugins/discover/public/application/angular/index.ts rename to src/plugins/discover_legacy/public/application/angular/index.ts diff --git a/src/plugins/discover/public/application/angular/redirect.ts b/src/plugins/discover_legacy/public/application/angular/redirect.ts similarity index 100% rename from src/plugins/discover/public/application/angular/redirect.ts rename to src/plugins/discover_legacy/public/application/angular/redirect.ts diff --git a/src/plugins/discover/public/application/angular/response_handler.js b/src/plugins/discover_legacy/public/application/angular/response_handler.js similarity index 100% rename from src/plugins/discover/public/application/angular/response_handler.js rename to src/plugins/discover_legacy/public/application/angular/response_handler.js diff --git a/src/plugins/discover/public/application/application.ts b/src/plugins/discover_legacy/public/application/application.ts similarity index 100% rename from src/plugins/discover/public/application/application.ts rename to src/plugins/discover_legacy/public/application/application.ts diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx b/src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.test.tsx similarity index 100% rename from src/plugins/discover/public/application/components/context_error_message/context_error_message.test.tsx rename to src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.test.tsx diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx b/src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.tsx similarity index 100% rename from src/plugins/discover/public/application/components/context_error_message/context_error_message.tsx rename to src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.tsx diff --git a/src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts b/src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message_directive.ts similarity index 100% rename from src/plugins/discover/public/application/components/context_error_message/context_error_message_directive.ts rename to src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message_directive.ts diff --git a/src/plugins/discover/public/application/components/context_error_message/index.ts b/src/plugins/discover_legacy/public/application/components/context_error_message/index.ts similarity index 100% rename from src/plugins/discover/public/application/components/context_error_message/index.ts rename to src/plugins/discover_legacy/public/application/components/context_error_message/index.ts diff --git a/src/plugins/discover/public/application/components/create_discover_legacy_directive.ts b/src/plugins/discover_legacy/public/application/components/create_discover_legacy_directive.ts similarity index 100% rename from src/plugins/discover/public/application/components/create_discover_legacy_directive.ts rename to src/plugins/discover_legacy/public/application/components/create_discover_legacy_directive.ts diff --git a/src/plugins/discover/public/application/components/discover_legacy.tsx b/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx similarity index 89% rename from src/plugins/discover/public/application/components/discover_legacy.tsx rename to src/plugins/discover_legacy/public/application/components/discover_legacy.tsx index 3e6e96fc612..73580c9441a 100644 --- a/src/plugins/discover/public/application/components/discover_legacy.tsx +++ b/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx @@ -30,7 +30,7 @@ import React, { useState, useCallback, useEffect } from 'react'; import classNames from 'classnames'; -import { EuiButtonEmpty, EuiButtonIcon } from '@elastic/eui'; +import { EuiButtonEmpty, EuiButtonIcon, EuiCallOut, EuiLink } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; import { IUiSettingsClient, MountPoint } from 'opensearch-dashboards/public'; @@ -132,6 +132,7 @@ export function DiscoverLegacy({ vis, }: DiscoverLegacyProps) { const [isSidebarClosed, setIsSidebarClosed] = useState(false); + const [isCallOutVisible, setIsCallOutVisible] = useState(true); const { TopNavMenu } = getServices().navigation.ui; const { savedSearch, indexPatternList } = opts; const bucketAggConfig = vis?.data?.aggs?.aggs[1]; @@ -141,6 +142,31 @@ export function DiscoverLegacy({ : undefined; const [fixedScrollEl, setFixedScrollEl] = useState(); + const closeCallOut = () => setIsCallOutVisible(false); + + let callOut; + + if (isCallOutVisible) { + callOut = ( +
+ +

+ To provide feedback,{' '} + + open an issue + + . +

+
+
+ ); + } + useEffect(() => (fixedScrollEl ? opts.fixedScroll(fixedScrollEl) : undefined), [ fixedScrollEl, opts, @@ -166,21 +192,23 @@ export function DiscoverLegacy({

{savedSearch.title}

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

+ +

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

+ +

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

+ +

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

+ +

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