From 098ae96419c2d7e5b3d7b217a0df27da3d6826ad Mon Sep 17 00:00:00 2001 From: Miki Date: Tue, 3 Oct 2023 22:02:59 -0700 Subject: [PATCH] Revert "[Manual backport 2.x][Deangular] OSD without angular (#5086) (#5177)" This reverts commit ab84ea13a0531406e8fc488e3b30b6e509939238. --- DEVELOPER_GUIDE.md | 24 + package.json | 12 +- packages/osd-i18n/GUIDELINE.md | 11 + packages/osd-i18n/README.md | 101 +- packages/osd-i18n/angular/package.json | 5 + .../__snapshots__/directive.test.ts.snap | 69 + .../osd-i18n/src/angular/directive.test.ts | 172 +++ packages/osd-i18n/src/angular/directive.ts | 126 ++ packages/osd-i18n/src/angular/filter.test.ts | 68 + packages/osd-i18n/src/angular/filter.ts | 42 + packages/osd-i18n/src/angular/index.ts | 38 + .../osd-i18n/src/angular/provider.test.ts | 69 + packages/osd-i18n/src/angular/provider.ts | 48 + .../config/global_selectors.json | 4 +- .../local_nav/_local_date_picker.scss | 13 + packages/osd-ui-shared-deps/entry.js | 3 + packages/osd-ui-shared-deps/index.js | 2 + packages/osd-ui-shared-deps/package.json | 3 +- src/dev/i18n/README.md | 29 +- .../test_plugin_1/test_file_4.html | 8 + .../test_plugin_2/test_file.html | 1 + .../test_plugin_2/test_file.jsx | 6 - .../test_plugin_4/test_file_4.jsx | 18 - .../extract_default_translations.test.js.snap | 45 +- .../i18n/extract_default_translations.test.js | 17 +- src/plugins/dashboard/public/plugin.tsx | 4 + .../common/search/aggs/utils/prop_filter.ts | 2 +- .../search/aggs/utils/to_angular_json.ts | 1 - .../public/components/sidebar/index.tsx | 1 - src/plugins/discover/common/index.ts | 1 + .../components/data_grid/data_grid_table.tsx | 15 +- .../data_grid/data_grid_table_flyout.tsx | 11 +- .../doc_viewer_links.test.tsx.snap | 4 +- .../doc_viewer_links/doc_viewer_links.tsx | 4 +- .../components/sidebar/discover_field.scss | 10 - .../components/sidebar/discover_field.tsx | 5 +- .../components/top_nav/get_top_nav_links.tsx | 24 +- .../canvas/discover_canvas.scss | 5 - .../view_components/canvas/discover_table.tsx | 5 +- .../view_components/canvas/index.tsx | 42 +- .../view_components/canvas/top_nav.tsx | 17 +- .../view_components/utils/use_search.ts | 22 +- src/plugins/discover/public/build_services.ts | 4 +- .../search_embeddable_component.tsx | 65 - src/plugins/discover/public/index.ts | 5 +- src/plugins/discover/public/plugin.ts | 86 +- 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 | 164 +++ .../public/application/angular/_index.scss | 2 + .../public/application/angular/context.html | 9 + .../public/application/angular/context.js | 122 ++ .../application/angular/context/NOTES.md | 95 ++ .../application/angular/context/_index.scss | 8 + .../application/angular/context/api/_stubs.js | 112 ++ .../application/angular/context/api/anchor.js | 71 + .../angular/context/api/anchor.test.js | 158 +++ .../context/api/context.predecessors.test.js | 241 ++++ .../context/api/context.successors.test.js | 245 ++++ .../angular/context/api/context.ts | 137 ++ .../context/api/utils/date_conversion.test.ts | 43 + .../context/api/utils/date_conversion.ts | 76 ++ .../api/utils/fetch_hits_in_interval.ts | 107 ++ .../context/api/utils/generate_intervals.ts | 66 + .../get_opensearch_query_search_after.ts | 58 + .../api/utils/get_opensearch_query_sort.ts | 49 + .../angular/context/api/utils/sorting.test.ts | 38 + .../angular/context/api/utils/sorting.ts | 63 + .../components/action_bar/_action_bar.scss | 10 + .../context/components/action_bar/_index.scss | 1 + .../components/action_bar/action_bar.test.tsx | 104 ++ .../components/action_bar/action_bar.tsx | 182 +++ .../action_bar/action_bar_directive.ts | 36 + .../action_bar/action_bar_warning.tsx | 84 ++ .../context/components/action_bar/index.ts | 31 + .../helpers/call_after_bindings_workaround.js | 74 ++ .../angular/context/query/actions.js | 203 +++ .../angular/context/query/constants.js | 41 + .../angular/context/query/index.js | 33 + .../angular/context/query/state.js | 39 + .../context/query_parameters/actions.js | 86 ++ .../context/query_parameters/actions.test.ts | 168 +++ .../context/query_parameters/constants.ts | 35 + .../angular/context/query_parameters/index.js | 33 + .../angular/context/query_parameters/state.ts | 47 + .../application/angular/context_app.html | 55 + .../public/application/angular/context_app.js | 151 +++ .../application/angular/context_state.test.ts | 204 +++ .../application/angular/context_state.ts | 307 +++++ .../__snapshots__/no_results.test.js.snap | 230 ++++ .../angular/directives/_histogram.scss | 11 + .../angular/directives/_index.scss | 2 + .../angular/directives/_no_results.scss | 3 + .../angular/directives/debounce/debounce.js | 79 ++ .../directives/debounce/debounce.test.ts | 148 +++ .../angular/directives/debounce/index.js | 31 + .../angular/directives/fixed_scroll.js | 166 +++ .../angular/directives/fixed_scroll.test.js | 274 ++++ .../angular/directives/histogram.tsx | 362 ++++++ .../application/angular/directives/index.js | 42 + .../angular/directives/no_results.js | 225 ++++ .../angular/directives/no_results.test.js | 76 ++ .../angular/directives/render_complete.ts | 42 + .../angular/directives/uninitialized.tsx | 78 ++ .../public/application/angular/discover.js | 1156 +++++++++++++++++ .../application/angular/discover_legacy.html | 36 + .../angular/discover_state.test.ts | 116 ++ .../application/angular/discover_state.ts | 256 ++++ .../public/application/angular/doc.html | 8 + .../public/application/angular/doc.ts | 81 ++ .../angular/doc_table/_doc_table.scss | 158 +++ .../angular/doc_table/actions/columns.ts | 67 + .../angular/doc_table/components/_index.scss | 2 + .../doc_table/components/_table_header.scss | 17 + .../tool_bar_pager_buttons.test.tsx.snap | 30 + .../tool_bar_pager_text.test.tsx.snap | 10 + .../doc_table/components/pager/index.ts | 40 + .../pager/tool_bar_pager_buttons.test.tsx | 73 ++ .../pager/tool_bar_pager_buttons.tsx | 74 ++ .../pager/tool_bar_pager_text.test.tsx | 43 + .../components/pager/tool_bar_pager_text.tsx | 52 + .../doc_table/components/row_headers.test.js | 508 ++++++++ .../doc_table/components/table_header.ts | 58 + .../__snapshots__/table_header.test.tsx.snap | 221 ++++ .../components/table_header/helpers.tsx | 92 ++ .../table_header/table_header.test.tsx | 214 +++ .../components/table_header/table_header.tsx | 82 ++ .../table_header/table_header_column.tsx | 205 +++ .../angular/doc_table/components/table_row.ts | 238 ++++ .../doc_table/components/table_row/_cell.scss | 34 + .../components/table_row/_details.scss | 23 + .../components/table_row/_index.scss | 3 + .../doc_table/components/table_row/_open.scss | 40 + .../doc_table/components/table_row/cell.html | 24 + .../components/table_row/details.html | 37 + .../doc_table/components/table_row/open.html | 7 + .../table_row/truncate_by_height.html | 3 + .../doc_table/create_doc_table_react.tsx | 143 ++ .../angular/doc_table/doc_table.html | 67 + .../angular/doc_table/doc_table.test.js | 158 +++ .../angular/doc_table/doc_table.ts | 113 ++ .../angular/doc_table/doc_table_strings.js | 43 + .../application/angular/doc_table/index.scss | 2 + .../application/angular/doc_table/index.ts | 33 + .../angular/doc_table/infinite_scroll.ts | 79 ++ .../angular/doc_table/lib/get_default_sort.ts | 49 + .../angular/doc_table/lib/get_sort.test.ts | 107 ++ .../angular/doc_table/lib/get_sort.ts | 92 ++ .../lib/get_sort_for_search_source.ts | 65 + .../angular/doc_table/lib/pager/index.js | 32 + .../angular/doc_table/lib/pager/pager.js | 88 ++ .../doc_table/lib/pager/pager_factory.ts | 40 + .../public/application/angular/doc_viewer.tsx | 59 + .../application/angular/doc_viewer_links.tsx | 28 + .../application/angular/helpers/index.ts | 31 + .../angular/helpers/point_series.ts | 122 ++ .../public/application/angular/index.ts | 41 + .../public/application/angular/redirect.ts | 49 + .../application/angular/response_handler.js | 131 ++ .../public/application/application.ts | 56 + .../context_error_message.test.tsx | 65 + .../context_error_message.tsx | 76 ++ .../context_error_message_directive.ts | 38 + .../components/context_error_message/index.ts | 32 + .../create_discover_legacy_directive.ts | 67 + .../components/discover_legacy.tsx | 368 ++++++ .../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 | 47 + .../hits_counter/hits_counter.test.tsx | 80 ++ .../components/hits_counter/hits_counter.tsx | 95 ++ .../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 | 3 + .../sidebar/change_indexpattern.tsx | 131 ++ .../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 | 6 + .../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 | 111 ++ .../sidebar/discover_index_pattern.tsx | 104 ++ .../sidebar/discover_index_pattern_title.tsx | 95 ++ .../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 | 31 + .../timechart_header.test.tsx | 110 ++ .../timechart_header/timechart_header.tsx | 183 +++ .../open_search_panel.test.js.snap | 69 + .../components/top_nav/open_search_panel.js | 122 ++ .../top_nav/open_search_panel.test.js | 48 + .../top_nav/show_open_search_panel.js | 57 + .../doc_views/doc_views_helpers.tsx | 106 ++ .../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 | 12 + .../embeddable/search_embeddable.ts} | 239 ++-- .../embeddable/search_embeddable_factory.ts} | 43 +- .../embeddable/search_template.html | 20 + .../public/application}/embeddable/types.ts | 4 +- .../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 | 101 ++ .../get_switch_index_pattern_app_state.ts | 58 + .../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 | 2 + .../discover_legacy/public/build_services.ts | 129 ++ .../public/get_inner_angular.ts | 208 +++ 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/angular/angular_config.tsx | 380 ++++++ .../public/angular/index.ts | 38 + .../public/angular/osd_top_nav.js | 140 ++ .../public/angular/promises.js | 140 ++ .../angular/subscribe_with_scope.test.ts | 208 +++ .../public/angular/subscribe_with_scope.ts | 96 ++ .../public/angular/watch_multi.js | 159 +++ .../angular_bootstrap/bind_html/bind_html.js | 28 + .../public/angular_bootstrap/index.ts | 61 + .../angular_bootstrap/tooltip/position.js | 178 +++ .../angular_bootstrap/tooltip/tooltip.js | 434 +++++++ .../tooltip/tooltip_html_unsafe_popup.html | 4 + .../tooltip/tooltip_popup.html | 4 + .../public/index.ts | 4 + .../public/notify/lib/add_fatal_error.ts | 16 +- .../notify/lib/format_angular_http_error.ts | 69 + .../public/notify/lib/index.ts | 5 + .../public/paginate/_paginate.scss | 58 + .../public/paginate/paginate.d.ts | 32 + .../public/paginate/paginate.js | 242 ++++ .../public/paginate/paginate_controls.html | 98 ++ .../public/plugin.ts | 2 +- .../public/utils/index.ts | 37 + .../public/utils/osd_accessible_click.js | 82 ++ .../public/utils/private.d.ts | 31 + .../public/utils/private.js | 214 +++ .../utils/register_listen_event_listener.js | 47 + .../public/utils/system_api.ts | 62 + src/plugins/tile_map/public/plugin.ts | 2 + .../apps/context/_context_navigation.js | 34 +- test/functional/apps/context/_date_nanos.js | 31 +- .../context/_date_nanos_custom_timestamp.js | 1 + .../apps/context/_discover_navigation.js | 46 +- test/functional/apps/context/_filters.js | 42 +- test/functional/apps/context/_size.js | 12 +- test/functional/apps/context/index.js | 1 + .../apps/dashboard/dashboard_filter_bar.js | 7 +- .../apps/dashboard/dashboard_filtering.js | 4 +- .../apps/dashboard/dashboard_query_bar.js | 3 +- .../apps/dashboard/dashboard_state.js | 30 +- .../apps/dashboard/dashboard_time_picker.js | 5 +- .../apps/dashboard/panel_context_menu.ts | 4 + test/functional/apps/discover/_date_nanos.js | 69 + .../apps/discover/_date_nanos_mixed.js | 72 + test/functional/apps/discover/_discover.js | 376 ++++++ .../apps/discover/_discover_histogram.ts | 92 ++ .../apps/discover/_doc_navigation.js | 104 ++ test/functional/apps/discover/_doc_table.ts | 173 +++ test/functional/apps/discover/_errors.ts | 63 + test/functional/apps/discover/_field_data.js | 115 ++ .../apps/discover/_field_visualize.ts | 182 +++ .../apps/discover/_filter_editor.js | 81 ++ .../discover/_indexpattern_with_encoded_id.ts | 65 + .../_indexpattern_without_timefield.ts | 71 + test/functional/apps/discover/_inspector.js | 82 ++ .../functional/apps/discover/_large_string.js | 103 ++ .../apps/discover/_saved_queries.js | 186 +++ .../functional/apps/discover/_shared_links.js | 194 +++ test/functional/apps/discover/_sidebar.js | 91 ++ .../apps/discover/_source_filters.js | 72 + test/functional/apps/discover/index.js | 65 + test/functional/apps/home/_navigation.ts | 10 +- test/functional/apps/home/_sample_data.ts | 3 +- .../apps/management/_handle_alias.js | 5 +- .../management/_index_pattern_results_sort.js | 24 +- .../_opensearch_dashboards_settings.js | 2 +- .../apps/management/_scripted_fields.js | 182 +-- .../management/_scripted_fields_preview.js | 3 +- test/functional/apps/visualize/_lab_mode.js | 6 + test/functional/apps/visualize/index.ts | 1 + test/functional/config.js | 3 +- test/functional/page_objects/discover_page.ts | 103 +- .../services/dashboard/expectations.ts | 15 +- .../services/dashboard/visualizations.ts | 6 + test/functional/services/data_grid.ts | 102 -- yarn.lock | 54 + 364 files changed, 28896 insertions(+), 859 deletions(-) create mode 100644 packages/osd-i18n/angular/package.json create mode 100644 packages/osd-i18n/src/angular/__snapshots__/directive.test.ts.snap create mode 100644 packages/osd-i18n/src/angular/directive.test.ts create mode 100644 packages/osd-i18n/src/angular/directive.ts create mode 100644 packages/osd-i18n/src/angular/filter.test.ts create mode 100644 packages/osd-i18n/src/angular/filter.ts create mode 100644 packages/osd-i18n/src/angular/index.ts create mode 100644 packages/osd-i18n/src/angular/provider.test.ts create mode 100644 packages/osd-i18n/src/angular/provider.ts create mode 100644 src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html create mode 100644 src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html delete mode 100644 src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx delete mode 100644 src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_4/test_file_4.jsx delete mode 100644 src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss delete mode 100644 src/plugins/discover/public/embeddable/search_embeddable_component.tsx 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 create mode 100644 src/plugins/discover_legacy/public/application/_discover.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/_index.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/context.html create mode 100644 src/plugins/discover_legacy/public/application/angular/context.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/NOTES.md create mode 100644 src/plugins/discover_legacy/public/application/angular/context/_index.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/anchor.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/context.ts 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 create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts 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 create mode 100644 src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/query/actions.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/query/constants.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/query/index.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/query/state.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context_app.html create mode 100644 src/plugins/discover_legacy/public/application/angular/context_app.js create mode 100644 src/plugins/discover_legacy/public/application/angular/context_state.test.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/context_state.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/_index.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.test.js create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/histogram.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/index.js create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/no_results.js create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/no_results.test.js create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/render_complete.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/discover.js create mode 100644 src/plugins/discover_legacy/public/application/angular/discover_legacy.html create mode 100644 src/plugins/discover_legacy/public/application/angular/discover_state.test.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/discover_state.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc.html create mode 100644 src/plugins/discover_legacy/public/application/angular/doc.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/_doc_table.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/actions/columns.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/_index.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/_table_header.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/index.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/row_headers.test.js create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/helpers.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/table_header_column.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_cell.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_details.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_index.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/cell.html create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/details.html create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/open.html create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/truncate_by_height.html create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/create_doc_table_react.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.html create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.test.js create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/doc_table_strings.js create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/index.scss create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/index.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/infinite_scroll.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_default_sort.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.test.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort_for_search_source.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/index.js create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager.js create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager_factory.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_viewer.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/doc_viewer_links.tsx create mode 100644 src/plugins/discover_legacy/public/application/angular/helpers/index.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/helpers/point_series.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/index.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/redirect.ts create mode 100644 src/plugins/discover_legacy/public/application/angular/response_handler.js create mode 100644 src/plugins/discover_legacy/public/application/application.ts create mode 100644 src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message_directive.ts create mode 100644 src/plugins/discover_legacy/public/application/components/context_error_message/index.ts create mode 100644 src/plugins/discover_legacy/public/application/components/create_discover_legacy_directive.ts create mode 100644 src/plugins/discover_legacy/public/application/components/discover_legacy.tsx 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 create mode 100644 src/plugins/discover_legacy/public/application/components/help_menu/help_menu_util.js create mode 100644 src/plugins/discover_legacy/public/application/components/hits_counter/hits_counter.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/hits_counter/hits_counter.tsx 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 create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/change_indexpattern.tsx 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 create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.scss 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 create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern_title.tsx 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 create mode 100644 src/plugins/discover_legacy/public/application/components/timechart_header/index.ts create mode 100644 src/plugins/discover_legacy/public/application/components/timechart_header/timechart_header.test.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/timechart_header/timechart_header.tsx create mode 100644 src/plugins/discover_legacy/public/application/components/top_nav/__snapshots__/open_search_panel.test.js.snap create mode 100644 src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.js create mode 100644 src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.test.js create mode 100644 src/plugins/discover_legacy/public/application/components/top_nav/show_open_search_panel.js create mode 100644 src/plugins/discover_legacy/public/application/doc_views/doc_views_helpers.tsx 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/public => discover_legacy/public/application}/embeddable/constants.ts (100%) rename src/plugins/{discover/public => discover_legacy/public/application}/embeddable/index.ts (100%) create mode 100644 src/plugins/discover_legacy/public/application/embeddable/search_embeddable.scss rename src/plugins/{discover/public/embeddable/search_embeddable.tsx => discover_legacy/public/application/embeddable/search_embeddable.ts} (56%) rename src/plugins/{discover/public/embeddable/search_embeddable_factory.tsx => discover_legacy/public/application/embeddable/search_embeddable_factory.ts} (71%) create mode 100644 src/plugins/discover_legacy/public/application/embeddable/search_template.html rename src/plugins/{discover/public => discover_legacy/public/application}/embeddable/types.ts (93%) 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 create mode 100644 src/plugins/discover_legacy/public/application/helpers/get_switch_index_pattern_app_state.test.ts create mode 100644 src/plugins/discover_legacy/public/application/helpers/get_switch_index_pattern_app_state.ts 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 create mode 100644 src/plugins/discover_legacy/public/application/index.scss create mode 100644 src/plugins/discover_legacy/public/build_services.ts create mode 100644 src/plugins/discover_legacy/public/get_inner_angular.ts 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/plugins/opensearch_dashboards_legacy/public/angular/angular_config.tsx create mode 100644 src/plugins/opensearch_dashboards_legacy/public/angular/index.ts create mode 100644 src/plugins/opensearch_dashboards_legacy/public/angular/osd_top_nav.js create mode 100644 src/plugins/opensearch_dashboards_legacy/public/angular/promises.js create mode 100644 src/plugins/opensearch_dashboards_legacy/public/angular/subscribe_with_scope.test.ts create mode 100644 src/plugins/opensearch_dashboards_legacy/public/angular/subscribe_with_scope.ts create mode 100644 src/plugins/opensearch_dashboards_legacy/public/angular/watch_multi.js create mode 100755 src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/bind_html/bind_html.js create mode 100644 src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/index.ts create mode 100755 src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/position.js create mode 100755 src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip.js create mode 100644 src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip_html_unsafe_popup.html create mode 100644 src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip_popup.html create mode 100644 src/plugins/opensearch_dashboards_legacy/public/notify/lib/format_angular_http_error.ts create mode 100644 src/plugins/opensearch_dashboards_legacy/public/paginate/_paginate.scss create mode 100644 src/plugins/opensearch_dashboards_legacy/public/paginate/paginate.d.ts create mode 100644 src/plugins/opensearch_dashboards_legacy/public/paginate/paginate.js create mode 100644 src/plugins/opensearch_dashboards_legacy/public/paginate/paginate_controls.html create mode 100644 src/plugins/opensearch_dashboards_legacy/public/utils/index.ts create mode 100644 src/plugins/opensearch_dashboards_legacy/public/utils/osd_accessible_click.js create mode 100644 src/plugins/opensearch_dashboards_legacy/public/utils/private.d.ts create mode 100644 src/plugins/opensearch_dashboards_legacy/public/utils/private.js create mode 100644 src/plugins/opensearch_dashboards_legacy/public/utils/register_listen_event_listener.js create mode 100644 src/plugins/opensearch_dashboards_legacy/public/utils/system_api.ts create mode 100644 test/functional/apps/discover/_date_nanos.js create mode 100644 test/functional/apps/discover/_date_nanos_mixed.js create mode 100644 test/functional/apps/discover/_discover.js create mode 100644 test/functional/apps/discover/_discover_histogram.ts create mode 100644 test/functional/apps/discover/_doc_navigation.js create mode 100644 test/functional/apps/discover/_doc_table.ts create mode 100644 test/functional/apps/discover/_errors.ts create mode 100644 test/functional/apps/discover/_field_data.js create mode 100644 test/functional/apps/discover/_field_visualize.ts create mode 100644 test/functional/apps/discover/_filter_editor.js create mode 100644 test/functional/apps/discover/_indexpattern_with_encoded_id.ts create mode 100644 test/functional/apps/discover/_indexpattern_without_timefield.ts create mode 100644 test/functional/apps/discover/_inspector.js create mode 100644 test/functional/apps/discover/_large_string.js create mode 100644 test/functional/apps/discover/_saved_queries.js create mode 100644 test/functional/apps/discover/_shared_links.js create mode 100644 test/functional/apps/discover/_sidebar.js create mode 100644 test/functional/apps/discover/_source_filters.js create mode 100644 test/functional/apps/discover/index.js diff --git a/DEVELOPER_GUIDE.md b/DEVELOPER_GUIDE.md index 289cc3c5a656..8040c80e7918 100644 --- a/DEVELOPER_GUIDE.md +++ b/DEVELOPER_GUIDE.md @@ -921,6 +921,30 @@ license. The following developer guide rules are specific for working with the React framework. +#### Prefer reactDirective over react-component + +When using `ngReact` to embed your react components inside Angular HTML, prefer the +`reactDirective` service over the `react-component` directive. +You can read more about these two ngReact methods [here](https://github.com/ngReact/ngReact#features). + +Using `react-component` means adding a bunch of components into angular, while `reactDirective` keeps them isolated, and is also a more succinct syntax. + +**Good:** + +```html + +``` + +**Bad:** + +```html + +``` + #### Name action functions and prop functions appropriately Name action functions in the form of a strong verb and passed properties in the form of on. E.g: diff --git a/package.json b/package.json index e6caa50578dd..ffee2f70974d 100644 --- a/package.json +++ b/package.json @@ -163,6 +163,9 @@ "JSONStream": "1.3.5", "abortcontroller-polyfill": "^1.4.0", "ajv": "^8.11.0", + "angular": "^1.8.2", + "angular-elastic": "^2.5.1", + "angular-sanitize": "^1.8.0", "bluebird": "3.5.5", "chalk": "^4.1.0", "chokidar": "^3.4.2", @@ -264,6 +267,8 @@ "@testing-library/jest-dom": "^5.16.2", "@testing-library/react": "^12.1.2", "@testing-library/react-hooks": "^7.0.2", + "@types/angular": "^1.8.4", + "@types/angular-mocks": "^1.7.1", "@types/archiver": "^5.3.1", "@types/babel__core": "^7.1.17", "@types/bluebird": "^3.1.1", @@ -344,6 +349,10 @@ "@types/zen-observable": "^0.8.0", "@typescript-eslint/eslint-plugin": "^3.10.0", "@typescript-eslint/parser": "^3.10.0", + "angular-aria": "^1.8.0", + "angular-mocks": "^1.8.2", + "angular-recursion": "^1.0.5", + "angular-route": "^1.8.0", "archiver": "^5.3.0", "axe-core": "^4.0.2", "babel-eslint": "^10.0.3", @@ -420,6 +429,7 @@ "ms-chromium-edge-driver": "^0.4.3", "murmurhash3js": "3.0.1", "mutation-observer": "^1.0.3", + "ngreact": "^0.5.1", "nock": "12.0.3", "node-stream-zip": "^1.15.0", "normalize-path": "^3.0.0", @@ -472,4 +482,4 @@ "node": ">=14.20.1 <19", "yarn": "^1.22.10" } -} \ No newline at end of file +} diff --git a/packages/osd-i18n/GUIDELINE.md b/packages/osd-i18n/GUIDELINE.md index ac2fb0047c9b..ae5b2b5ca298 100644 --- a/packages/osd-i18n/GUIDELINE.md +++ b/packages/osd-i18n/GUIDELINE.md @@ -92,6 +92,17 @@ The long term plan is to rely on using `FormattedMessage` and `i18n.translate()` Currently, we support the following ReactJS `i18n` tools, but they will be removed in future releases: - Usage of `props.intl.formatmessage()` (where `intl` is passed to `props` by `injectI18n` HOC). +#### In AngularJS + +The long term plan is to rely on using `i18n.translate()` by statically importing `i18n` from the `@osd/i18n` package. **Avoid using the `i18n` filter and the `i18n` service injected in controllers, directives, services.** + +- Call JS function `i18n.translate()` from the `@osd/i18n` package. +- Use `i18nId` directive in template. + +Currently, we support the following AngluarJS `i18n` tools, but they will be removed in future releases: +- Usage of `i18n` service in controllers, directives, services by injecting it. +- Usage of `i18n` filter in template for attribute translation. Note: Use one-time binding ("{{:: ... }}") in filters wherever it's possible to prevent unnecessary expression re-evaluation. + #### In JavaScript - Use `i18n.translate()` in NodeJS or any other framework agnostic code, where `i18n` is the I18n engine from `@osd/i18n` package. diff --git a/packages/osd-i18n/README.md b/packages/osd-i18n/README.md index a58a15ab4fd9..16e740e925d5 100644 --- a/packages/osd-i18n/README.md +++ b/packages/osd-i18n/README.md @@ -1,13 +1,14 @@ # I18n -OpenSearch Dashboards relies on UI frameworks (ReactJS) and +OpenSearch Dashboards relies on several UI frameworks (ReactJS and AngularJS) and requires localization in different environments (browser and NodeJS). Internationalization engine is framework agnostic and consumable in -all parts of OpenSearch Dashboards (ReactJS and NodeJS). In order to simplify +all parts of OpenSearch Dashboards (ReactJS, AngularJS and NodeJS). In order to simplify internationalization in UI frameworks, the additional abstractions are -built around the I18n engine: `react-intl` for React. [React-intl](https://github.com/yahoo/react-intl) +built around the I18n engine: `react-intl` for React and custom +components for AngularJS. [React-intl](https://github.com/yahoo/react-intl) is built around [intl-messageformat](https://github.com/yahoo/intl-messageformat), -so the React framework uses the same engine and the same +so both React and AngularJS frameworks use the same engine and the same message syntax. ## Localization files @@ -342,6 +343,98 @@ export const MyComponent = injectI18n( ); ``` +## AngularJS + +The long term plan is to rely on using `i18n.translate()` by statically importing `i18n` from the `@osd/i18n` package. **Avoid using the `i18n` filter and the `i18n` service injected in controllers, directives, services.** + +AngularJS wrapper has 4 entities: translation `provider`, `service`, `directive` +and `filter`. Both the directive and the filter use the translation `service` +with i18n engine under the hood. + +The translation `provider` is used for `service` configuration and +has the following methods: +- `addMessages(messages: Map, [locale: string])` - provides a way to register +translations with the library +- `setLocale(locale: string)` - tells the library which language to use by given +language key +- `getLocale()` - returns the current locale +- `setDefaultLocale(locale: string)` - tells the library which language to fallback +when missing translations +- `getDefaultLocale()` - returns the default locale +- `setFormats(formats: object)` - supplies a set of options to the underlying formatter +- `getFormats()` - returns current formats +- `getRegisteredLocales()` - returns array of locales having translations +- `init(messages: Map)` - initializes the engine + +The translation `service` provides only one method: +- `i18n(id: string, { values: object, defaultMessage: string, description: string })` – +translate message by id + +The translation `filter` is used for attributes translation and has +the following syntax: +``` +{{ ::'translationId' | i18n: { values: object, defaultMessage: string, description: string } }} +``` + +Where: +- `translationId` - translation id to be translated +- `values` - values to pass into translation +- `defaultMessage` - will be used unless translation was successful (the final + fallback in english, will be used for generating `en.json`) +- `description` - optional context comment that will be extracted by i18n tools +and added as a comment next to translation message at `defaultMessages.json` + +The translation `directive` has the following syntax: +```html + +``` + +Where: +- `i18n-id` - translation id to be translated +- `i18n-default-message` - will be used unless translation was successful +- `i18n-values` - values to pass into translation +- `i18n-description` - optional context comment that will be extracted by i18n tools +and added as a comment next to translation message at `defaultMessages.json` + +If HTML rendering in `i18n-values` is required then value key in `i18n-values` object +should have `html_` prefix. Otherwise the value will be inserted to the message without +HTML rendering.\ +Example: +```html +

+``` + +Angular `I18n` module is placed into `autoload` module, so it will be +loaded automatically. After that we can use i18n directive in Angular templates: +```html + +``` + +In order to translate attributes in AngularJS we should use `i18nFilter`: +```html + +``` + ## I18n tools In order to simplify localization process, some additional tools were implemented: diff --git a/packages/osd-i18n/angular/package.json b/packages/osd-i18n/angular/package.json new file mode 100644 index 000000000000..1979e988fa7c --- /dev/null +++ b/packages/osd-i18n/angular/package.json @@ -0,0 +1,5 @@ +{ + "browser": "../target/web/angular", + "main": "../target/node/angular", + "types": "../target/types/angular/index.d.ts" +} diff --git a/packages/osd-i18n/src/angular/__snapshots__/directive.test.ts.snap b/packages/osd-i18n/src/angular/__snapshots__/directive.test.ts.snap new file mode 100644 index 000000000000..44bb96399432 --- /dev/null +++ b/packages/osd-i18n/src/angular/__snapshots__/directive.test.ts.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`i18nDirective doesn't render html in result message with text-only values 1`] = ` +
+ Default <span onclick=alert(1) >Press</span> message +
+`; + +exports[`i18nDirective doesn't render html in text-only value 1`] = ` +
+ Default <strong>message</strong> +
+`; + +exports[`i18nDirective inserts correct translation html content with values 1`] = `"default-message word"`; + +exports[`i18nDirective inserts correct translation html content with values 2`] = `"default-message anotherWord"`; + +exports[`i18nDirective sanitizes message before inserting it to DOM 1`] = ` +
' }" +> + Default message, +
+
+`; + +exports[`i18nDirective sanitizes onclick attribute 1`] = ` +
+ Default + + Press + + message +
+`; + +exports[`i18nDirective sanitizes onmouseover attribute 1`] = ` +
Press' }" +> + Default + + Press + + message +
+`; diff --git a/packages/osd-i18n/src/angular/directive.test.ts b/packages/osd-i18n/src/angular/directive.test.ts new file mode 100644 index 000000000000..35dd43001335 --- /dev/null +++ b/packages/osd-i18n/src/angular/directive.test.ts @@ -0,0 +1,172 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 angular from 'angular'; +import 'angular-mocks'; +import 'angular-sanitize'; + +import { i18nDirective } from './directive'; +import { I18nProvider } from './provider'; + +angular + .module('app', ['ngSanitize']) + .provider('i18n', I18nProvider) + .directive('i18nId', i18nDirective); + +describe('i18nDirective', () => { + let compile: angular.ICompileService; + let scope: angular.IRootScopeService & { word?: string }; + + beforeEach(angular.mock.module('app')); + beforeEach( + angular.mock.inject( + ($compile: angular.ICompileService, $rootScope: angular.IRootScopeService) => { + compile = $compile; + scope = $rootScope.$new(); + scope.word = 'word'; + } + ) + ); + + test('inserts correct translation html content', () => { + const id = 'id'; + const defaultMessage = 'default-message'; + + const element = angular.element( + `
` + ); + + compile(element)(scope); + scope.$digest(); + + expect(element.html()).toEqual(defaultMessage); + }); + + test('inserts correct translation html content with values', () => { + const id = 'id'; + const defaultMessage = 'default-message {word}'; + + const element = angular.element( + `
` + ); + + compile(element)(scope); + scope.$digest(); + + expect(element.html()).toMatchSnapshot(); + + scope.word = 'anotherWord'; + scope.$digest(); + + expect(element.html()).toMatchSnapshot(); + }); + + test('sanitizes message before inserting it to DOM', () => { + const element = angular.element( + `
` + ); + + compile(element)(scope); + scope.$digest(); + + expect(element[0]).toMatchSnapshot(); + }); + + test(`doesn't render html in result message with text-only values`, () => { + const element = angular.element( + `
` + ); + + compile(element)(scope); + scope.$digest(); + + expect(element[0]).toMatchSnapshot(); + }); + + test('sanitizes onclick attribute', () => { + const element = angular.element( + `
` + ); + + compile(element)(scope); + scope.$digest(); + + expect(element[0]).toMatchSnapshot(); + }); + + test('sanitizes onmouseover attribute', () => { + const element = angular.element( + `
` + ); + + compile(element)(scope); + scope.$digest(); + + expect(element[0]).toMatchSnapshot(); + }); + + test(`doesn't render html in text-only value`, () => { + const element = angular.element( + `
` + ); + + compile(element)(scope); + scope.$digest(); + + expect(element[0]).toMatchSnapshot(); + }); +}); diff --git a/packages/osd-i18n/src/angular/directive.ts b/packages/osd-i18n/src/angular/directive.ts new file mode 100644 index 000000000000..790357da7d46 --- /dev/null +++ b/packages/osd-i18n/src/angular/directive.ts @@ -0,0 +1,126 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { IDirective, IRootElementService, IScope } from 'angular'; + +import { I18nServiceType } from './provider'; + +interface I18nScope extends IScope { + values?: Record; + defaultMessage: string; + id: string; +} + +const HTML_KEY_PREFIX = 'html_'; +const PLACEHOLDER_SEPARATOR = '@I18N@'; + +export const i18nDirective: [string, string, typeof i18nDirectiveFn] = [ + 'i18n', + '$sanitize', + i18nDirectiveFn, +]; + +function i18nDirectiveFn( + i18n: I18nServiceType, + $sanitize: (html: string) => string +): IDirective { + return { + restrict: 'A', + scope: { + id: '@i18nId', + defaultMessage: '@i18nDefaultMessage', + values: ' { + setContent($element, $scope, $sanitize, i18n); + }); + } else { + setContent($element, $scope, $sanitize, i18n); + } + }, + }; +} + +function setContent( + $element: IRootElementService, + $scope: I18nScope, + $sanitize: (html: string) => string, + i18n: I18nServiceType +) { + const originalValues = $scope.values; + const valuesWithPlaceholders = {} as Record; + let hasValuesWithPlaceholders = false; + + // If we have values with the keys that start with HTML_KEY_PREFIX we should replace + // them with special placeholders that later on will be inserted as HTML + // into the DOM, the rest of the content will be treated as text. We don't + // sanitize values at this stage as some of the values can be excluded from + // the translated string (e.g. not used by ICU conditional statements). + if (originalValues) { + for (const [key, value] of Object.entries(originalValues)) { + if (key.startsWith(HTML_KEY_PREFIX)) { + valuesWithPlaceholders[ + key.slice(HTML_KEY_PREFIX.length) + ] = `${PLACEHOLDER_SEPARATOR}${key}${PLACEHOLDER_SEPARATOR}`; + + hasValuesWithPlaceholders = true; + } else { + valuesWithPlaceholders[key] = value; + } + } + } + + const label = i18n($scope.id, { + values: valuesWithPlaceholders, + defaultMessage: $scope.defaultMessage, + }); + + // If there are no placeholders to replace treat everything as text, otherwise + // insert label piece by piece replacing every placeholder with corresponding + // sanitized HTML content. + if (!hasValuesWithPlaceholders) { + $element.text(label); + } else { + $element.empty(); + for (const contentOrPlaceholder of label.split(PLACEHOLDER_SEPARATOR)) { + if (!contentOrPlaceholder) { + continue; + } + + $element.append( + originalValues!.hasOwnProperty(contentOrPlaceholder) + ? $sanitize(originalValues![contentOrPlaceholder]) + : document.createTextNode(contentOrPlaceholder) + ); + } + } +} diff --git a/packages/osd-i18n/src/angular/filter.test.ts b/packages/osd-i18n/src/angular/filter.test.ts new file mode 100644 index 000000000000..bc236e711d4c --- /dev/null +++ b/packages/osd-i18n/src/angular/filter.test.ts @@ -0,0 +1,68 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +jest.mock('../core/i18n', () => ({ + translate: jest.fn().mockImplementation(() => 'translation'), +})); + +import angular from 'angular'; +import 'angular-mocks'; + +import * as i18n from '../core/i18n'; +import { i18nFilter as angularI18nFilter } from './filter'; +import { I18nProvider, I18nServiceType } from './provider'; + +angular.module('app', []).provider('i18n', I18nProvider).filter('i18n', angularI18nFilter); + +describe('i18nFilter', () => { + let filter: I18nServiceType; + + beforeEach(angular.mock.module('app')); + beforeEach( + angular.mock.inject((i18nFilter) => { + filter = i18nFilter; + }) + ); + afterEach(() => { + jest.resetAllMocks(); + }); + + test('provides wrapper around i18n engine', () => { + const id = 'id'; + const defaultMessage = 'default-message'; + const values = {}; + + const result = filter(id, { defaultMessage, values }); + + expect(result).toEqual('translation'); + expect(i18n.translate).toHaveBeenCalledTimes(1); + expect(i18n.translate).toHaveBeenCalledWith(id, { defaultMessage, values }); + }); +}); diff --git a/packages/osd-i18n/src/angular/filter.ts b/packages/osd-i18n/src/angular/filter.ts new file mode 100644 index 000000000000..4ffa5dd3ef4e --- /dev/null +++ b/packages/osd-i18n/src/angular/filter.ts @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { I18nServiceType } from './provider'; + +export const i18nFilter: [string, typeof i18nFilterFn] = ['i18n', i18nFilterFn]; + +function i18nFilterFn(i18n: I18nServiceType) { + return (id: string, { defaultMessage = '', values = {} } = {}) => { + return i18n(id, { + values, + defaultMessage, + }); + }; +} diff --git a/packages/osd-i18n/src/angular/index.ts b/packages/osd-i18n/src/angular/index.ts new file mode 100644 index 000000000000..04f7d66eb12a --- /dev/null +++ b/packages/osd-i18n/src/angular/index.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. + */ + +export { I18nProvider } from './provider'; + +export { i18nFilter } from './filter'; +export { i18nDirective } from './directive'; + +// re-export types: https://github.com/babel/babel-loader/issues/603 +import { I18nServiceType as _I18nServiceType } from './provider'; +export type I18nServiceType = _I18nServiceType; diff --git a/packages/osd-i18n/src/angular/provider.test.ts b/packages/osd-i18n/src/angular/provider.test.ts new file mode 100644 index 000000000000..e612e0bef3d8 --- /dev/null +++ b/packages/osd-i18n/src/angular/provider.test.ts @@ -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 angular from 'angular'; +import 'angular-mocks'; + +import * as i18nCore from '../core/i18n'; +import { I18nProvider, I18nServiceType } from './provider'; + +angular.module('app', []).provider('i18n', I18nProvider); + +describe('i18nProvider', () => { + let provider: I18nProvider; + let service: I18nServiceType; + + beforeEach( + angular.mock.module('app', [ + 'i18nProvider', + (i18n: I18nProvider) => { + provider = i18n; + }, + ]) + ); + beforeEach( + angular.mock.inject((i18n: I18nServiceType) => { + service = i18n; + }) + ); + + test('provides wrapper around i18n engine', () => { + expect(service).toEqual(i18nCore.translate); + }); + + test('provides service wrapper around i18n engine', () => { + const serviceMethodNames = Object.keys(provider); + const pluginMethodNames = Object.keys(i18nCore); + + expect([...serviceMethodNames, 'translate'].sort()).toEqual( + [...pluginMethodNames, '$get'].sort() + ); + }); +}); diff --git a/packages/osd-i18n/src/angular/provider.ts b/packages/osd-i18n/src/angular/provider.ts new file mode 100644 index 000000000000..bd02a30cef45 --- /dev/null +++ b/packages/osd-i18n/src/angular/provider.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 * as i18n from '../core'; + +export type I18nServiceType = ReturnType; + +export class I18nProvider implements angular.IServiceProvider { + public addTranslation = i18n.addTranslation; + public getTranslation = i18n.getTranslation; + public setLocale = i18n.setLocale; + public getLocale = i18n.getLocale; + public setDefaultLocale = i18n.setDefaultLocale; + public getDefaultLocale = i18n.getDefaultLocale; + public setFormats = i18n.setFormats; + public getFormats = i18n.getFormats; + public getRegisteredLocales = i18n.getRegisteredLocales; + public init = i18n.init; + public load = i18n.load; + public $get = () => i18n.translate; +} diff --git a/packages/osd-stylelint-config/config/global_selectors.json b/packages/osd-stylelint-config/config/global_selectors.json index d89561a04cbc..760717c8dab5 100644 --- a/packages/osd-stylelint-config/config/global_selectors.json +++ b/packages/osd-stylelint-config/config/global_selectors.json @@ -23,7 +23,9 @@ "src/plugins/vis_builder/public/application/components/searchable_dropdown.scss", "src/plugins/vis_builder/public/application/components/side_nav.scss", "packages/osd-ui-framework/src/components/button/button_group/_button_group.scss", + "src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss", + "src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/_open.scss", "src/plugins/discover/public/application/components/data_grid/data_grid_table_cell_value.scss" ] } -} \ No newline at end of file +} diff --git a/packages/osd-ui-framework/src/components/local_nav/_local_date_picker.scss b/packages/osd-ui-framework/src/components/local_nav/_local_date_picker.scss index ec60ffb4a918..0a38a115c4a0 100644 --- a/packages/osd-ui-framework/src/components/local_nav/_local_date_picker.scss +++ b/packages/osd-ui-framework/src/components/local_nav/_local_date_picker.scss @@ -54,6 +54,19 @@ .kuiDatePickerRowCell { padding: 0; text-align: center; + + /** + * This state class exists to support weird angular-bootstrap datepicker functionality, + * in which you can't select a day on the "From" calendar if it falls after the selected day in + * the "To" calendar (and vice versa, you can't select a "To" day if it is before the "From" day). + */ + &.kuiDatePickerRowCell-isBlocked { + cursor: not-allowed; + + .kuiDatePickerRowCellContent { + pointer-events: none; + } + } } /** diff --git a/packages/osd-ui-shared-deps/entry.js b/packages/osd-ui-shared-deps/entry.js index 8afb2b08da6c..813fe512a554 100644 --- a/packages/osd-ui-shared-deps/entry.js +++ b/packages/osd-ui-shared-deps/entry.js @@ -30,13 +30,16 @@ require('./polyfills'); +// must load before angular export const Jquery = require('jquery'); window.$ = window.jQuery = Jquery; require('./flot_charts'); // stateful deps export const OsdI18n = require('@osd/i18n'); +export const OsdI18nAngular = require('@osd/i18n/angular'); export const OsdI18nReact = require('@osd/i18n/react'); +export const Angular = require('angular'); export const Moment = require('moment'); export const MomentTimezone = require('moment-timezone/moment-timezone'); export const OsdMonaco = require('@osd/monaco'); diff --git a/packages/osd-ui-shared-deps/index.js b/packages/osd-ui-shared-deps/index.js index 36218a28d4eb..1ebd54a55a97 100644 --- a/packages/osd-ui-shared-deps/index.js +++ b/packages/osd-ui-shared-deps/index.js @@ -40,7 +40,9 @@ exports.darkCssDistFilename = 'osd-ui-shared-deps.v7.dark.css'; exports.darkV8CssDistFilename = 'osd-ui-shared-deps.v8.dark.css'; exports.externals = { // stateful deps + angular: '__osdSharedDeps__.Angular', '@osd/i18n': '__osdSharedDeps__.OsdI18n', + '@osd/i18n/angular': '__osdSharedDeps__.OsdI18nAngular', '@osd/i18n/react': '__osdSharedDeps__.OsdI18nReact', jquery: '__osdSharedDeps__.Jquery', moment: '__osdSharedDeps__.Moment', diff --git a/packages/osd-ui-shared-deps/package.json b/packages/osd-ui-shared-deps/package.json index ca6028d56acc..fca9abd7c537 100644 --- a/packages/osd-ui-shared-deps/package.json +++ b/packages/osd-ui-shared-deps/package.json @@ -16,6 +16,7 @@ "@osd/i18n": "1.0.0", "@osd/monaco": "1.0.0", "abortcontroller-polyfill": "^1.4.0", + "angular": "^1.8.2", "axios": "^0.27.2", "compression-webpack-plugin": "npm:@amoo-miki/compression-webpack-plugin@4.0.1-rc.1", "core-js": "^3.6.5", @@ -51,4 +52,4 @@ "val-loader": "^2.1.2", "webpack": "npm:@amoo-miki/webpack@4.46.0-rc.2" } -} \ No newline at end of file +} diff --git a/src/dev/i18n/README.md b/src/dev/i18n/README.md index 7cfb938d38ec..4a9e3f45eff0 100644 --- a/src/dev/i18n/README.md +++ b/src/dev/i18n/README.md @@ -4,7 +4,7 @@ ### Description -The tool is used to extract default messages from all `*.{js, ts, jsx, tsx }` files in provided plugins directories to a JSON file. +The tool is used to extract default messages from all `*.{js, ts, jsx, tsx, html }` files in provided plugins directories to a JSON file. It uses Babel to parse code and build an AST for each file or a single JS expression if whole file parsing is impossible. The tool is able to validate, extract and match IDs, default messages and descriptions only if they are defined statically and together, otherwise it will fail with detailed explanation. That means one can't define ID in one place and default message in another, or use function call to dynamically create default message etc. @@ -18,6 +18,33 @@ The `defaultMessage` must contain ICU references to all keys in the `values` and The `description` is optional, `values` is optional too unless `defaultMessage` references to it. +* **Angular (.html)** + + * **Filter** + + ``` + {{ ::'pluginNamespace.messageId' | i18n: { + defaultMessage: 'Default message string literal, {key}', + values: { key: 'value' }, + description: 'Message context or description' + } }} + ``` + + * Don't break `| i18n: {` with line breaks, and don't skip whitespaces around `i18n:`. + * `::` operator is optional. Omit it if you need data binding for the `values`. + + * **Directive** + + ```html +

+ ``` + + * `html_` prefixes will be removed from `i18n-values` keys before validation. * **React (.jsx, .tsx)** diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html new file mode 100644 index 000000000000..f725fa288405 --- /dev/null +++ b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_1/test_file_4.html @@ -0,0 +1,8 @@ + +
+
+
+
diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html new file mode 100644 index 000000000000..c12843602b13 --- /dev/null +++ b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.html @@ -0,0 +1 @@ +

{{ ::'plugin_2.message-id' | i18n: { defaultMessage: 'Message text' } }}

diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx deleted file mode 100644 index b3f0c8d2b9c1..000000000000 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -i18n('plugin_2.message-id', { defaultMessage: 'Message text' }); diff --git a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_4/test_file_4.jsx b/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_4/test_file_4.jsx deleted file mode 100644 index 5ce7b916bcd4..000000000000 --- a/src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_4/test_file_4.jsx +++ /dev/null @@ -1,18 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -/* eslint-disable */ -class Component extends PureComponent { - render() { - return ( -
- -
- ); - } -} \ No newline at end of file diff --git a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap index cf91d6252d05..b19b366a8db7 100644 --- a/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap +++ b/src/dev/i18n/__snapshots__/extract_default_translations.test.js.snap @@ -30,52 +30,11 @@ Array [ "message": "Message 4", }, ], -] -`; - -exports[`dev/i18n/extract_default_translations extracts messages from path to map 2`] = ` -Array [ - Array [ - "plugin_2.message-id", - Object { - "description": undefined, - "message": "Message text", - }, - ], -] -`; - -exports[`dev/i18n/extract_default_translations extracts messages from path to map 3`] = ` -Array [ - Array [ - "plugin_3.duplicate_id", - Object { - "description": undefined, - "message": "Message 1", - }, - ], -] -`; - -exports[`dev/i18n/extract_default_translations extracts messages from path to map 4`] = ` -Array [ Array [ - "plugin_3.duplicate_id", + "plugin_1.id_7", Object { "description": undefined, - "message": "Message 1", - }, - ], -] -`; - -exports[`dev/i18n/extract_default_translations extracts messages from path to map 5`] = ` -Array [ - Array [ - "plugin_4.id_1", - Object { - "description": undefined, - "message": "Message 4", + "message": "Message 7", }, ], ] diff --git a/src/dev/i18n/extract_default_translations.test.js b/src/dev/i18n/extract_default_translations.test.js index c995ec562735..ea4754f3645e 100644 --- a/src/dev/i18n/extract_default_translations.test.js +++ b/src/dev/i18n/extract_default_translations.test.js @@ -42,7 +42,6 @@ const pluginsPaths = [ path.join(fixturesPath, 'test_plugin_2'), path.join(fixturesPath, 'test_plugin_3'), path.join(fixturesPath, 'test_plugin_3_additional_path'), - path.join(fixturesPath, 'test_plugin_4'), ]; const config = { @@ -53,19 +52,17 @@ const config = { 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3', 'src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_3_additional_path', ], - plugin_4: ['src/dev/i18n/__fixtures__/extract_default_translations/test_plugin_4'], }, exclude: [], }; describe('dev/i18n/extract_default_translations', () => { test('extracts messages from path to map', async () => { - for (const pluginPath of pluginsPaths) { - const resultMap = new Map(); + const [pluginPath] = pluginsPaths; + const resultMap = new Map(); - await extractMessagesFromPathToMap(pluginPath, resultMap, config, new ErrorReporter()); - expect([...resultMap].sort()).toMatchSnapshot(); - } + await extractMessagesFromPathToMap(pluginPath, resultMap, config, new ErrorReporter()); + expect([...resultMap].sort()).toMatchSnapshot(); }); test('throws on id collision', async () => { @@ -91,11 +88,11 @@ describe('dev/i18n/extract_default_translations', () => { const id = 'plugin_3.message-id'; const filePath1 = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_3/test_file.jsx' + '__fixtures__/extract_default_translations/test_plugin_3/test_file.html' ); const filePath2 = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.jsx' + '__fixtures__/extract_default_translations/test_plugin_3_additional_path/test_file.html' ); expect(() => validateMessageNamespace(id, filePath1, config.paths)).not.toThrow(); expect(() => validateMessageNamespace(id, filePath2, config.paths)).not.toThrow(); @@ -106,7 +103,7 @@ describe('dev/i18n/extract_default_translations', () => { const id = 'wrong_plugin_namespace.message-id'; const filePath = path.resolve( __dirname, - '__fixtures__/extract_default_translations/test_plugin_2/test_file.jsx' + '__fixtures__/extract_default_translations/test_plugin_2/test_file.html' ); expect(() => validateMessageNamespace(id, filePath, config.paths, { report })).not.toThrow(); diff --git a/src/plugins/dashboard/public/plugin.tsx b/src/plugins/dashboard/public/plugin.tsx index dd874d3419f2..956bb5a7a836 100644 --- a/src/plugins/dashboard/public/plugin.tsx +++ b/src/plugins/dashboard/public/plugin.tsx @@ -80,6 +80,7 @@ import { withNotifyOnErrors, } from '../../opensearch_dashboards_utils/public'; import { + initAngularBootstrap, OpenSearchDashboardsLegacySetup, OpenSearchDashboardsLegacyStart, } from '../../opensearch_dashboards_legacy/public'; @@ -451,6 +452,9 @@ export class DashboardPlugin }, }; + // TODO: delete this when discover de-angular is completed + initAngularBootstrap(); + core.application.register(app); urlForwarding.forwardApp( DashboardConstants.DASHBOARDS_ID, diff --git a/src/plugins/data/common/search/aggs/utils/prop_filter.ts b/src/plugins/data/common/search/aggs/utils/prop_filter.ts index 2670e3d26b82..341032e47bf6 100644 --- a/src/plugins/data/common/search/aggs/utils/prop_filter.ts +++ b/src/plugins/data/common/search/aggs/utils/prop_filter.ts @@ -37,7 +37,7 @@ type FilterFunc

= (item: T[P]) => boolean; * - fieldType filters a list of fields by their type property * - aggFilter filters a list of aggs by their name property * - * @returns the filter function + * @returns the filter function which can be registered with angular */ export function propFilter

(prop: P) { /** diff --git a/src/plugins/data/common/search/aggs/utils/to_angular_json.ts b/src/plugins/data/common/search/aggs/utils/to_angular_json.ts index 3eac6a1fcfe4..0efafa7884a1 100644 --- a/src/plugins/data/common/search/aggs/utils/to_angular_json.ts +++ b/src/plugins/data/common/search/aggs/utils/to_angular_json.ts @@ -33,7 +33,6 @@ * https://github.com/angular/angular.js/blob/master/src/Angular.js#L1312 * * @internal - * @deprecated This function will be removed in the next major version. */ export function toAngularJSON(obj: any, pretty?: any): string { if (obj === undefined) return ''; diff --git a/src/plugins/data_explorer/public/components/sidebar/index.tsx b/src/plugins/data_explorer/public/components/sidebar/index.tsx index 42abed642e36..921e3894983b 100644 --- a/src/plugins/data_explorer/public/components/sidebar/index.tsx +++ b/src/plugins/data_explorer/public/components/sidebar/index.tsx @@ -76,7 +76,6 @@ export const Sidebar: FC = ({ children }) => { { const [inspectedHit, setInspectedHit] = useState(); const rowCount = useMemo(() => (rows ? rows.length : 0), [rows]); @@ -172,12 +166,7 @@ export const DataGridTable = ({ indexPattern, }} > -

+ <> {table} @@ -194,7 +183,7 @@ export const DataGridTable = ({ onClose={() => setInspectedHit(undefined)} /> )} -
+ ); }; diff --git a/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx b/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx index 315d5ca9c006..8cfeaf9795af 100644 --- a/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx +++ b/src/plugins/discover/public/application/components/data_grid/data_grid_table_flyout.tsx @@ -13,15 +13,14 @@ import { EuiFlexItem, EuiTitle, } from '@elastic/eui'; -import { FormattedMessage } from '@osd/i18n/react'; import { DocViewer } from '../doc_viewer/doc_viewer'; import { IndexPattern } from '../../../opensearch_dashboards_services'; -import { DocViewFilterFn, OpenSearchSearchHit } from '../../doc_views/doc_views_types'; +import { DocViewFilterFn } from '../../doc_views/doc_views_types'; import { DocViewerLinks } from '../doc_viewer_links/doc_viewer_links'; interface Props { columns: string[]; - hit: OpenSearchSearchHit; + hit: any; indexPattern: IndexPattern; onAddColumn: (column: string) => void; onClose: () => void; @@ -41,12 +40,10 @@ export function DataGridFlyout({ // TODO: replace EuiLink with doc_view_links registry // TODO: Also move the flyout higher in the react tree to prevent redrawing the table component and slowing down page performance return ( - + -

- -

+

Document Details

diff --git a/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap b/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap index 52dfa61e07f3..5da67e79a7c7 100644 --- a/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap +++ b/src/plugins/discover/public/application/components/doc_viewer_links/__snapshots__/doc_viewer_links.test.tsx.snap @@ -17,7 +17,7 @@ exports[`Render with 2 different links 1`] = ` key="0" > with 2 different links 1`] = ` key="1" > { const { generateCb, href, ...props } = item; const listItem: EuiListGroupItemProps = { - 'data-test-subj': `docTableRowAction`, + 'data-test-subj': 'docTableRowAction', ...props, href: generateCb ? generateCb(renderProps).url : href, }; @@ -31,7 +31,7 @@ export function DocViewerLinks(renderProps: DocViewLinkRenderProps) { href={item.href} target="_blank" style={{ fontWeight: 'normal' }} - data-test-subj={`${item['data-test-subj']}-${index}`} + data-test-subj={item['data-test-subj']} > {item.label} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.scss b/src/plugins/discover/public/application/components/sidebar/discover_field.scss index 643f74b809c2..8e1dd41f66ab 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.scss +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.scss @@ -2,13 +2,3 @@ min-width: 260px; max-width: 300px; } - -.dscSidebarField__actionButton { - opacity: 0; - transition: opacity $ouiAnimSpeedExtraFast; - - &:hover, - &:focus { - opacity: 1; - } -} diff --git a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx index a924191c88f1..73dc40a262e0 100644 --- a/src/plugins/discover/public/application/components/sidebar/discover_field.tsx +++ b/src/plugins/discover/public/application/components/sidebar/discover_field.tsx @@ -40,7 +40,7 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { DiscoverFieldDetails } from './discover_field_details'; -import { FieldIcon } from '../../../../../opensearch_dashboards_react/public'; +import { FieldIcon, FieldButton } from '../../../../../opensearch_dashboards_react/public'; import { FieldDetails } from './types'; import { IndexPatternField, IndexPattern } from '../../../../../data/public'; import { shortenDottedString } from '../../helpers'; @@ -163,7 +163,6 @@ export const DiscoverField = ({ }} data-test-subj={`fieldToggle-${field.name}`} aria-label={addLabelAria} - className="dscSidebarField__actionButton" /> ); @@ -188,7 +187,6 @@ export const DiscoverField = ({ }} data-test-subj={`fieldToggle-${field.name}`} aria-label={removeLabelAria} - className="dscSidebarField__actionButton" /> ); @@ -221,7 +219,6 @@ export const DiscoverField = ({ onClick={() => setOpen((state) => !state)} aria-label={infoLabelAria} data-test-subj={`field-${field.name}-showDetails`} - className="dscSidebarField__actionButton" /> } panelClassName="dscSidebarItem__fieldPopoverPanel" diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx index 3a0cdd17d238..8c19d38eb20d 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx @@ -8,6 +8,7 @@ import React from 'react'; import { DiscoverViewServices } from '../../../build_services'; import { showOpenSearchPanel } from './show_open_search_panel'; import { SavedSearch } from '../../../saved_searches'; +import { NEW_DISCOVER_APP } from '../../../../common'; import { Adapters } from '../../../../../inspector/public'; import { TopNavMenuData } from '../../../../../navigation/public'; import { ISearchSource, unhashUrl } from '../../../opensearch_dashboards_services'; @@ -29,6 +30,7 @@ export const getTopNavLinks = ( history, inspector, core, + uiSettings, capabilities, share, toastNotifications, @@ -192,7 +194,7 @@ export const getTopNavLinks = ( share?.toggleShareContextMenu({ anchorElement, allowEmbed: false, - allowShortUrl: capabilities.discover?.createShortUrl as boolean, + allowShortUrl: capabilities.discover.createShortUrl as boolean, shareableUrl: unhashUrl(window.location.href), objectId: savedSearch.id, objectType: 'search', @@ -221,9 +223,27 @@ export const getTopNavLinks = ( }, }; + const legacyDiscover: TopNavMenuData = { + id: 'discover-new', + label: i18n.translate('discover.localMenu.newDiscoverTitle', { + defaultMessage: 'New Discover', + }), + description: i18n.translate('discover.localMenu.newDiscoverDescription', { + defaultMessage: 'New Discover Experience', + }), + testId: 'discoverNewButton', + run: async () => { + await uiSettings.set(NEW_DISCOVER_APP, false); + window.location.reload(); + }, + type: 'toggle' as const, + emphasize: true, + }; + return [ + legacyDiscover, newSearch, - ...(capabilities.discover?.save ? [saveSearch] : []), + ...(capabilities.discover.save ? [saveSearch] : []), openSearch, ...(share ? [shareSearch] : []), // Show share option only if share plugin is available inspectSearch, diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss b/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss deleted file mode 100644 index e1a21bcf201c..000000000000 --- a/src/plugins/discover/public/application/view_components/canvas/discover_canvas.scss +++ /dev/null @@ -1,5 +0,0 @@ -.dscCanvas { - @include euiYScrollWithShadows; - - height: 100%; -} diff --git a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx index dcac343c4ce8..8d9966af0ec0 100644 --- a/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/discover_table.tsx @@ -17,7 +17,7 @@ import { useDispatch, useSelector, } from '../../utils/state_management'; -import { ResultStatus, SearchData, useSearch } from '../utils/use_search'; +import { ResultStatus, SearchData } from '../utils/use_search'; import { IndexPatternField, opensearchFilters } from '../../../../../data/public'; import { DocViewFilterFn } from '../../doc_views/doc_views_types'; import { SortOrder } from '../../../saved_searches/types'; @@ -71,7 +71,6 @@ export const DiscoverTable = ({ history }: Props) => { ); const { rows } = fetchState || {}; - const { savedSearch } = useSearch(services); useEffect(() => { const subscription = data$.subscribe((next) => { @@ -108,8 +107,6 @@ export const DiscoverTable = ({ history }: Props) => { rows={rows} displayTimeColumn={displayTimeColumn} services={services} - title={savedSearch?.id ? savedSearch.title : ''} - description={savedSearch?.id ? savedSearch.description : ''} /> ); }; diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index 3c25e5a221dc..b5adc9596321 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -4,7 +4,7 @@ */ import React, { useEffect, useState, useRef } from 'react'; -import { EuiFlexGroup, EuiFlexItem, EuiPanel } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiPanel, EuiCallOut, EuiLink } from '@elastic/eui'; import { TopNav } from './top_nav'; import { ViewProps } from '../../../../../data_explorer/public'; import { DiscoverTable } from './discover_table'; @@ -19,7 +19,9 @@ import { DiscoverViewServices } from '../../../build_services'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; import { filterColumns } from '../utils/filter_columns'; import { DEFAULT_COLUMNS_SETTING } from '../../../../common'; -import './discover_canvas.scss'; + +const KEY_SHOW_NOTICE = 'discover:deprecation-notice:show'; + // eslint-disable-next-line import/no-default-export export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewProps) { const { data$, refetch$, indexPattern } = useDiscoverContext(); @@ -41,6 +43,39 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro bucketInterval: {}, }); + const [isCallOutVisible, setIsCallOutVisible] = useState( + localStorage.getItem(KEY_SHOW_NOTICE) !== 'false' + ); + const closeCallOut = () => { + localStorage.setItem(KEY_SHOW_NOTICE, 'false'); + setIsCallOutVisible(false); + }; + + let callOut; + + if (isCallOutVisible) { + callOut = ( + + + +

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

+
+
+
+ ); + } + const { status } = fetchState; useEffect(() => { @@ -69,7 +104,7 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history }: ViewPro const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined; return ( - + } {status === ResultStatus.READY && ( <> + {callOut} diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx index 4a8ebfb1bf29..7a777142d2f0 100644 --- a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -5,7 +5,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { AppMountParameters } from '../../../../../../core/public'; -import { PLUGIN_ID } from '../../../../common'; +import { NEW_DISCOVER_APP, PLUGIN_ID } from '../../../../common'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; import { DiscoverViewServices } from '../../../build_services'; import { IndexPattern } from '../../../opensearch_dashboards_services'; @@ -25,11 +25,12 @@ export const TopNav = ({ opts }: TopNavProps) => { const [indexPatterns, setIndexPatterns] = useState(undefined); const { + uiSettings, navigation: { ui: { TopNavMenu }, }, core: { - application: { getUrlForApp }, + application: { navigateToApp, getUrlForApp }, }, data, chrome, @@ -37,6 +38,18 @@ export const TopNav = ({ opts }: TopNavProps) => { const topNavLinks = savedSearch ? getTopNavLinks(services, inspectorAdapters, savedSearch) : []; + useEffect(() => { + if (uiSettings.get(NEW_DISCOVER_APP) === false) { + const path = window.location.hash; + navigateToApp('discoverLegacy', { + replace: true, + path, + }); + } + + return () => {}; + }, [navigateToApp, uiSettings]); + useEffect(() => { let isMounted = true; const getDefaultIndexPattern = async () => { diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts index 6414d0328715..b3fbefcdfad1 100644 --- a/src/plugins/discover/public/application/view_components/utils/use_search.ts +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -67,7 +67,6 @@ export type RefetchSubject = Subject; * }, [data$]); */ export const useSearch = (services: DiscoverServices) => { - const initalSearchComplete = useRef(false); const [savedSearch, setSavedSearch] = useState(undefined); const { savedSearch: savedSearchId, sort, interval } = useSelector((state) => state.discover); const { data, filterManager, getSavedSearchById, core, toastNotifications, store } = services; @@ -206,8 +205,6 @@ export const useSearch = (services: DiscoverServices) => { }); data.search.showError(error as Error); - } finally { - initalSearchComplete.current = true; } }, [ indexPattern, @@ -243,29 +240,18 @@ export const useSearch = (services: DiscoverServices) => { })(); }); - // kick off initial refetch on page load - if (shouldSearchOnPageLoad() || initalSearchComplete.current === true) { - refetch$.next(); - } + // kick off initial fetch + refetch$.next(); return () => { subscription.unsubscribe(); }; - }, [ - data$, - data.query.queryString, - filterManager, - refetch$, - timefilter, - fetch, - core.fatalErrors, - shouldSearchOnPageLoad, - ]); + }, [data$, data.query.queryString, filterManager, refetch$, timefilter, fetch, core.fatalErrors]); // Get savedSearch if it exists useEffect(() => { (async () => { - const savedSearchInstance = await getSavedSearchById(savedSearchId); + const savedSearchInstance = await getSavedSearchById(savedSearchId || ''); setSavedSearch(savedSearchInstance); // sync initial app filters from savedObject to filterManager diff --git a/src/plugins/discover/public/build_services.ts b/src/plugins/discover/public/build_services.ts index 785e72536417..ebe4e80a70c5 100644 --- a/src/plugins/discover/public/build_services.ts +++ b/src/plugins/discover/public/build_services.ts @@ -78,7 +78,7 @@ export interface DiscoverServices { urlForwarding: UrlForwardingStart; timefilter: TimefilterContract; toastNotifications: ToastsStart; - getSavedSearchById: (id?: string) => Promise; + getSavedSearchById: (id: string) => Promise; getSavedSearchUrlById: (id: string) => Promise; uiSettings: IUiSettingsClient; visualizations: VisualizationsStart; @@ -107,7 +107,7 @@ export function buildServices( docLinks: core.docLinks, theme: plugins.charts.theme, filterManager: plugins.data.query.filterManager, - getSavedSearchById: async (id?: string) => savedObjectService.get(id), + getSavedSearchById: async (id: string) => savedObjectService.get(id), getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id), history: getHistory, indexPatterns: plugins.data.indexPatterns, diff --git a/src/plugins/discover/public/embeddable/search_embeddable_component.tsx b/src/plugins/discover/public/embeddable/search_embeddable_component.tsx deleted file mode 100644 index c8ae54a16429..000000000000 --- a/src/plugins/discover/public/embeddable/search_embeddable_component.tsx +++ /dev/null @@ -1,65 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import React from 'react'; -import { I18nProvider } from '@osd/i18n/react'; -import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; -import { SearchProps } from './search_embeddable'; -import { - DataGridTable, - DataGridTableProps, -} from '../application/components/data_grid/data_grid_table'; -import { VisualizationNoResults } from '../../../visualizations/public'; - -interface SearchEmbeddableProps { - searchProps: SearchProps; -} -export interface DiscoverEmbeddableProps extends DataGridTableProps { - totalHitCount: number; -} - -export const DataGridTableMemoized = React.memo((props: DataGridTableProps) => ( - -)); - -export function SearchEmbeddableComponent({ searchProps }: SearchEmbeddableProps) { - const discoverEmbeddableProps = { - columns: searchProps.columns, - indexPattern: searchProps.indexPattern, - onAddColumn: searchProps.onAddColumn, - onFilter: searchProps.onFilter, - onRemoveColumn: searchProps.onRemoveColumn, - onSort: searchProps.onSort, - rows: searchProps.rows, - onSetColumns: searchProps.onSetColumns, - sort: searchProps.sort, - displayTimeColumn: searchProps.displayTimeColumn, - services: searchProps.services, - totalHitCount: searchProps.totalHitCount, - title: searchProps.title, - description: searchProps.description, - } as DiscoverEmbeddableProps; - - return ( - - - {discoverEmbeddableProps.totalHitCount !== 0 ? ( - - - - ) : ( - - - - )} - - - ); -} diff --git a/src/plugins/discover/public/index.ts b/src/plugins/discover/public/index.ts index 164aea1fb5bc..3bc009914940 100644 --- a/src/plugins/discover/public/index.ts +++ b/src/plugins/discover/public/index.ts @@ -37,6 +37,7 @@ export function plugin(initializerContext: PluginInitializerContext) { } export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; - -export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './embeddable'; +// TODO: Fix embeddable after removing Angular +// export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator'; +export { NEW_DISCOVER_APP } from '../common'; diff --git a/src/plugins/discover/public/plugin.ts b/src/plugins/discover/public/plugin.ts index 4d28083b8892..f1532b6f776b 100644 --- a/src/plugins/discover/public/plugin.ts +++ b/src/plugins/discover/public/plugin.ts @@ -59,8 +59,8 @@ import { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGenerator, } from './url_generator'; -import { SearchEmbeddableFactory } from './embeddable'; -import { PLUGIN_ID } from '../common'; +// import { SearchEmbeddableFactory } from './application/embeddable'; +import { NEW_DISCOVER_APP, PLUGIN_ID } from '../common'; import { DataExplorerPluginSetup } from '../../data_explorer/public'; import { registerFeature } from './register_feature'; import { @@ -266,45 +266,55 @@ export class DiscoverPlugin // This is for instances where the user navigates to the app from the application nav menu const path = window.location.hash; - const newPath = migrateUrlState(path); - if (newPath.startsWith('#/context') || newPath.startsWith('#/doc')) { - const { renderDocView } = await import('./application/components/doc_views'); - const unmount = renderDocView(params.element); - return () => { - unmount(); - }; - } else { - navigateToApp('data-explorer', { + const v2Enabled = await core.uiSettings.get(NEW_DISCOVER_APP); + + if (!v2Enabled) { + navigateToApp('discoverLegacy', { replace: true, - path: `/${PLUGIN_ID}${newPath}`, + path, }); + } else { + const newPath = migrateUrlState(path); + if (newPath.startsWith('#/context') || newPath.startsWith('#/doc')) { + const { renderDocView } = await import('./application/components/doc_views'); + const unmount = renderDocView(params.element); + return () => { + unmount(); + }; + } else { + navigateToApp('data-explorer', { + replace: true, + path: `/${PLUGIN_ID}${newPath}`, + }); + } } return () => {}; }, }); - plugins.urlForwarding.forwardApp('doc', 'discover', (path) => { - return `#${path}`; - }); - plugins.urlForwarding.forwardApp('context', 'discover', (path) => { - const urlParts = path.split('/'); - // take care of urls containing legacy url, those split in the following way - // ["", "context", indexPatternId, _type, id + params] - if (urlParts[4]) { - // remove _type part - const newPath = [...urlParts.slice(0, 3), ...urlParts.slice(4)].join('/'); - return `#${newPath}`; - } - return `#${path}`; - }); - plugins.urlForwarding.forwardApp('discover', 'discover', (path) => { - const [, id, tail] = /discover\/([^\?]+)(.*)/.exec(path) || []; - if (!id) { - return `#${path.replace('/discover', '') || '/'}`; - } - return `#/view/${id}${tail || ''}`; - }); + // TODO: These routes need to be handled for Discover 2.0 to support legacy saved URLS's + // plugins.urlForwarding.forwardApp('doc', 'discover', (path) => { + // return `#${path}`; + // }); + // plugins.urlForwarding.forwardApp('context', 'discover', (path) => { + // const urlParts = path.split('/'); + // // take care of urls containing legacy url, those split in the following way + // // ["", "context", indexPatternId, _type, id + params] + // if (urlParts[4]) { + // // remove _type part + // const newPath = [...urlParts.slice(0, 3), ...urlParts.slice(4)].join('/'); + // return `#${newPath}`; + // } + // return `#${path}`; + // }); + // plugins.urlForwarding.forwardApp('discover', 'discover', (path) => { + // const [, id, tail] = /discover\/([^\?]+)(.*)/.exec(path) || []; + // if (!id) { + // return `#${path.replace('/discover', '') || '/'}`; + // } + // return `#/view/${id}${tail || ''}`; + // }); if (plugins.home) { registerFeature(plugins.home); @@ -338,7 +348,7 @@ export class DiscoverPlugin Context: lazy(() => import('./application/view_components/context')), }); - this.registerEmbeddable(core, plugins); + // this.registerEmbeddable(core, plugins); return { docViews: { @@ -384,8 +394,9 @@ export class DiscoverPlugin } } + // TODO: Use this registration when legacy discover is removed /** - * register embeddable + * register embeddable with a slimmer embeddable version of inner angular */ private registerEmbeddable(core: CoreSetup, plugins: DiscoverSetupPlugins) { const getStartServices = async () => { @@ -396,7 +407,8 @@ export class DiscoverPlugin }; }; - const factory = new SearchEmbeddableFactory(getStartServices); - plugins.embeddable.registerEmbeddableFactory(factory.type, factory); + // TODO: Refactor to remove angular + // const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector); + // plugins.embeddable.registerEmbeddableFactory(factory.type, factory); } } diff --git a/src/plugins/discover/server/ui_settings.ts b/src/plugins/discover/server/ui_settings.ts index 70eab306e7fb..2b35384c2e5c 100644 --- a/src/plugins/discover/server/ui_settings.ts +++ b/src/plugins/discover/server/ui_settings.ts @@ -33,6 +33,7 @@ import { schema } from '@osd/config-schema'; import { UiSettingsParams } from 'opensearch-dashboards/server'; import { + NEW_DISCOVER_APP, DEFAULT_COLUMNS_SETTING, SAMPLE_SIZE_SETTING, AGGS_TERMS_SIZE_SETTING, @@ -47,6 +48,17 @@ import { } from '../common'; export const uiSettings: Record = { + [NEW_DISCOVER_APP]: { + name: i18n.translate('discover.advancedSettings.legacyToggleTitle', { + defaultMessage: 'Enable new discover app', + }), + value: true, + description: i18n.translate('discover.advancedSettings.legacyToggleText', { + defaultMessage: 'Disabling the new discover app will redirect to the legacy app.', + }), + category: ['discover'], + schema: schema.boolean(), + }, [DEFAULT_COLUMNS_SETTING]: { name: i18n.translate('discover.advancedSettings.defaultColumnsTitle', { defaultMessage: 'Default columns', diff --git a/src/plugins/discover_legacy/README.md b/src/plugins/discover_legacy/README.md new file mode 100644 index 000000000000..a914d651eef3 --- /dev/null +++ b/src/plugins/discover_legacy/README.md @@ -0,0 +1 @@ +Contains the Discover application and the saved search embeddable. \ No newline at end of file diff --git a/src/plugins/discover_legacy/common/index.ts b/src/plugins/discover_legacy/common/index.ts new file mode 100644 index 000000000000..371442385bbf --- /dev/null +++ b/src/plugins/discover_legacy/common/index.ts @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const DEFAULT_COLUMNS_SETTING = 'defaultColumns'; +export const SAMPLE_SIZE_SETTING = 'discover:sampleSize'; +export const AGGS_TERMS_SIZE_SETTING = 'discover:aggs:terms:size'; +export const SORT_DEFAULT_ORDER_SETTING = 'discover:sort:defaultOrder'; +export const SEARCH_ON_PAGE_LOAD_SETTING = 'discover:searchOnPageLoad'; +export const DOC_HIDE_TIME_COLUMN_SETTING = 'doc_table:hideTimeColumn'; +export const FIELDS_LIMIT_SETTING = 'fields:popularLimit'; +export const CONTEXT_DEFAULT_SIZE_SETTING = 'context:defaultSize'; +export const CONTEXT_STEP_SETTING = 'context:step'; +export const CONTEXT_TIE_BREAKER_FIELDS_SETTING = 'context:tieBreakerFields'; +export const MODIFY_COLUMNS_ON_SWITCH = 'discover:modifyColumnsOnSwitch'; diff --git a/src/plugins/discover_legacy/opensearch_dashboards.json b/src/plugins/discover_legacy/opensearch_dashboards.json new file mode 100644 index 000000000000..6a4259a41d75 --- /dev/null +++ b/src/plugins/discover_legacy/opensearch_dashboards.json @@ -0,0 +1,27 @@ +{ + "id": "discoverLegacy", + "version": "opensearchDashboards", + "server": false, + "ui": true, + "requiredPlugins": [ + "charts", + "data", + "embeddable", + "inspector", + "opensearchDashboardsLegacy", + "urlForwarding", + "navigation", + "uiActions", + "visualizations" + ], + "optionalPlugins": [ + "home", + "share" + ], + "requiredBundles": [ + "opensearchDashboardsUtils", + "savedObjects", + "opensearchDashboardsReact", + "discover" + ] +} \ No newline at end of file diff --git a/src/plugins/discover_legacy/public/application/_discover.scss b/src/plugins/discover_legacy/public/application/_discover.scss new file mode 100644 index 000000000000..f574357c5ff4 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/_discover.scss @@ -0,0 +1,164 @@ +.dscAppWrapper { + display: flex; + flex-direction: column; + flex-grow: 1; + overflow: hidden; +} + +.dscAppContainer { + > * { + position: relative; + } +} + +discover-app { + flex-grow: 1; +} + +.dscHistogram { + display: flex; + height: 200px; + padding: $euiSizeS; +} + +// SASSTODO: replace the z-index value with a variable +.dscWrapper { + padding-left: $euiSizeXL; + padding-right: $euiSizeS; + z-index: 1; + + @include euiBreakpoint("xs", "s", "m") { + padding-left: $euiSizeS; + } +} + +@include euiPanel(".dscWrapper__content"); + +.dscWrapper__content { + padding-top: $euiSizeXS; + background-color: $euiColorEmptyShade; + + .osd-table { + margin-bottom: 0; + } +} + +.dscTimechart { + display: block; + position: relative; + + // SASSTODO: the visualizing component should have an option or a modifier + .series > rect { + fill-opacity: 0.5; + stroke-width: 1; + } +} + +.dscResultCount { + padding-top: $euiSizeXS; +} + +.dscTimechart__header { + display: flex; + justify-content: center; + min-height: $euiSizeXXL; + padding: $euiSizeXS 0; +} + +.dscOverlay { + position: absolute; + top: 0; + right: 0; + bottom: 0; + left: 0; + z-index: 20; + padding-top: $euiSizeM; + opacity: 0.75; + text-align: center; + background-color: transparent; +} + +.dscTable { + overflow: auto; + + // SASSTODO: add a monospace modifier to the doc-table component + .osdDocTable__row { + font-family: $euiCodeFontFamily; + font-size: $euiFontSizeXS; + } +} + +// SASSTODO: replace the padding value with a variable +.dscTable__footer { + background-color: $euiColorLightShade; + padding: 5px 10px; + text-align: center; +} + +.dscResults { + h3 { + margin: -20px 0 10px; + text-align: center; + } +} + +.dscResults__interval { + display: inline-block; + width: auto; +} + +.dscSkipButton { + position: absolute; + right: $euiSizeM; + top: $euiSizeXS; +} + +.dscTableFixedScroll { + overflow-x: auto; + padding-bottom: 0; + + + .dscTableFixedScroll__scroller { + position: fixed; + bottom: 0; + overflow-x: auto; + overflow-y: hidden; + } +} + +.dscCollapsibleSidebar { + position: relative; + z-index: $euiZLevel1; + + .dscCollapsibleSidebar__collapseButton { + position: absolute; + top: 0; + right: -$euiSizeXL + 4; + cursor: pointer; + z-index: -1; + min-height: $euiSizeM; + min-width: $euiSizeM; + padding: $euiSizeXS * 0.5; + } + + &.closed { + width: 0 !important; + border-right-width: 0; + border-left-width: 0; + + .dscCollapsibleSidebar__collapseButton { + right: -$euiSizeL + 4; + } + } +} + +@include euiBreakpoint("xs", "s", "m") { + .dscCollapsibleSidebar { + &.closed { + display: none; + } + + .dscCollapsibleSidebar__collapseButton { + display: none; + } + } +} diff --git a/src/plugins/discover_legacy/public/application/angular/_index.scss b/src/plugins/discover_legacy/public/application/angular/_index.scss new file mode 100644 index 000000000000..acc755e4a170 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/_index.scss @@ -0,0 +1,2 @@ +@import "directives/index"; +@import "context/index"; diff --git a/src/plugins/discover_legacy/public/application/angular/context.html b/src/plugins/discover_legacy/public/application/angular/context.html new file mode 100644 index 000000000000..2c8e9a2a5d6f --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context.html @@ -0,0 +1,9 @@ + diff --git a/src/plugins/discover_legacy/public/application/angular/context.js b/src/plugins/discover_legacy/public/application/angular/context.js new file mode 100644 index 000000000000..4757102315c0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context.js @@ -0,0 +1,122 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { i18n } from '@osd/i18n'; +import { CONTEXT_DEFAULT_SIZE_SETTING } from '../../../common'; +import { getAngularModule, getServices } from '../../opensearch_dashboards_services'; +import './context_app'; +import { getState } from './context_state'; +import contextAppRouteTemplate from './context.html'; +import { getRootBreadcrumbs } from '../helpers/breadcrumbs'; + +const k7Breadcrumbs = ($route) => { + const { indexPattern } = $route.current.locals; + const { id } = $route.current.params; + + return [ + ...getRootBreadcrumbs(), + { + text: i18n.translate('discover.context.breadcrumb', { + defaultMessage: 'Context of {indexPatternTitle}#{docId}', + values: { + indexPatternTitle: indexPattern.title, + docId: id, + }, + }), + }, + ]; +}; + +getAngularModule().config(($routeProvider) => { + $routeProvider.when('/context/:indexPatternId/:id*', { + controller: ContextAppRouteController, + k7Breadcrumbs, + controllerAs: 'contextAppRoute', + resolve: { + indexPattern: ($route, Promise) => { + const indexPattern = getServices().indexPatterns.get($route.current.params.indexPatternId); + return Promise.props({ ip: indexPattern }); + }, + }, + template: contextAppRouteTemplate, + }); +}); + +function ContextAppRouteController($routeParams, $scope, $route) { + const filterManager = getServices().filterManager; + const indexPattern = $route.current.locals.indexPattern.ip; + const { + startSync: startStateSync, + stopSync: stopStateSync, + appState, + getFilters, + setFilters, + setAppState, + flushToUrl, + } = getState({ + defaultStepSize: getServices().uiSettings.get(CONTEXT_DEFAULT_SIZE_SETTING), + timeFieldName: indexPattern.timeFieldName, + storeInSessionStorage: getServices().uiSettings.get('state:storeInSessionStorage'), + history: getServices().history(), + toasts: getServices().core.notifications.toasts, + }); + this.state = { ...appState.getState() }; + this.anchorId = decodeURIComponent($routeParams.id); + this.indexPattern = indexPattern; + filterManager.setFilters(_.cloneDeep(getFilters())); + startStateSync(); + + // take care of parameter changes in UI + $scope.$watchGroup( + [ + 'contextAppRoute.state.columns', + 'contextAppRoute.state.predecessorCount', + 'contextAppRoute.state.successorCount', + ], + (newValues) => { + const [columns, predecessorCount, successorCount] = newValues; + if (Array.isArray(columns) && predecessorCount >= 0 && successorCount >= 0) { + setAppState({ columns, predecessorCount, successorCount }); + flushToUrl(true); + } + } + ); + // take care of parameter filter changes + const filterObservable = filterManager.getUpdates$().subscribe(() => { + setFilters(filterManager); + $route.reload(); + }); + + $scope.$on('$destroy', () => { + stopStateSync(); + filterObservable.unsubscribe(); + }); +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/NOTES.md b/src/plugins/discover_legacy/public/application/angular/context/NOTES.md new file mode 100644 index 000000000000..046e15fab327 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/NOTES.md @@ -0,0 +1,95 @@ +# Discover Context App Implementation Notes + +The implementation of this app is intended to exhibit certain desirable +properties by adhering to a set of *principles*. This document aims to explain +those and the *concepts* employed to achieve that. + + +## Principles + +**Single Source of Truth**: A good user experience depends on the UI displaying +consistent information across the whole page. To achieve this, there should +always be a single source of truth for the application's state. In this +application this is the `ContextAppController::state` object. + +**Unidirectional Data Flow**: While a single state promotes rendering +consistency, it does little to make the state changes easier to reason about. +To avoid having state mutations scattered all over the code, this app +implements a unidirectional data flow architecture. That means that the state +is treated as immutable throughout the application except for actions, which +may modify it to cause angular to re-render and watches to trigger. + +**Unit-Testability**: Creating unit tests for large parts of the UI code is +made easy by expressing the as much of the logic as possible as +side-effect-free functions. The only place where side-effects are allowed are +actions. Due to the nature of AngularJS a certain amount of impure code must be +employed in some cases, e.g. when dealing with the isolate scope bindings in +`ContextAppController`. + +**Loose Coupling**: An attempt was made to couple the parts that make up this +app as loosely as possible. This means using pure functions whenever possible +and isolating the angular directives diligently. To that end, the app has been +implemented as the independent `ContextApp` directive in [app.js](app.js). It +does not access the OpenSearch Dashboards `AppState` directly but communicates only via its +directive properties. The binding of these attributes to the state and thereby +to the route is performed by the `CreateAppRouteController`in +[index.js](index.js). Similarly, the `SizePicker` directive only communicates +with its parent via the passed properties. + + +## Concepts + +To adhere to the principles mentioned above, this app borrows some concepts +from the redux architecture that forms a ciruclar unidirectional data flow: + +``` + + |* create initial state + v + +->+ + | v + | |* state + | v + | |* angular templates render state + | v + | |* angular calls actions in response to user action/system events + | v + | |* actions modify state + | v + +--+ + +``` + +**State**: The state is the single source of truth at +`ContextAppController::state` and may only be modified by actions. + +**Action**: Actions are functions that are called in response to user or system +actions and may modified the state the are bound to via their closure. + + +## Directory Structure + +**index.js**: Defines the route and renders the `` directive, +binding it to the `AppState`. + +**app.js**: Defines the `` directive, that is at the root of the +application. Creates the store, reducer and bound actions/selectors. + +**query**: Exports the actions, reducers and selectors related to the +query status and results. + +**query_parameters**: Exports the actions, reducers and selectors related to +the parameters used to construct the query. + +**components/action_bar**: Defines the `` +directive including its respective styles. + + +**api/anchor.js**: Exports `fetchAnchor()` that creates and executes the +query for the anchor document. + +**api/context.js**: Exports `fetchPredecessors()`, `fetchSuccessors()`, `fetchSurroundingDocs()` that +create and execute the queries for the preceeding and succeeding documents. + +**api/utils**: Exports various functions used to create and transform +queries. diff --git a/src/plugins/discover_legacy/public/application/angular/context/_index.scss b/src/plugins/discover_legacy/public/application/angular/context/_index.scss new file mode 100644 index 000000000000..4ac09e25eb9c --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/_index.scss @@ -0,0 +1,8 @@ +// Prefix all styles with "cxt" to avoid conflicts. +// Examples +// cxtChart +// cxtChart__legend +// cxtChart__legend--small +// cxtChart__legend-isLoading + +@import "components/action_bar/index"; diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js b/src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js new file mode 100644 index 000000000000..99b531edfc0b --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/_stubs.js @@ -0,0 +1,112 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import sinon from 'sinon'; +import moment from 'moment'; + +export function createIndexPatternsStub() { + return { + get: sinon.spy((indexPatternId) => + Promise.resolve({ + id: indexPatternId, + isTimeNanosBased: () => false, + popularizeField: () => {}, + }) + ), + }; +} + +/** + * A stubbed search source with a `fetch` method that returns all of `_stubHits`. + */ +export function createSearchSourceStub(hits, timeField) { + const searchSourceStub = { + _stubHits: hits, + _stubTimeField: timeField, + _createStubHit: (timestamp, tiebreaker = 0) => ({ + [searchSourceStub._stubTimeField]: timestamp, + sort: [timestamp, tiebreaker], + }), + }; + + searchSourceStub.setParent = sinon.spy(() => searchSourceStub); + searchSourceStub.setField = sinon.spy(() => searchSourceStub); + + searchSourceStub.getField = sinon.spy((key) => { + const previousSetCall = searchSourceStub.setField.withArgs(key).lastCall; + return previousSetCall ? previousSetCall.args[1] : null; + }); + + searchSourceStub.fetch = sinon.spy(() => + Promise.resolve({ + hits: { + hits: searchSourceStub._stubHits, + total: searchSourceStub._stubHits.length, + }, + }) + ); + + return searchSourceStub; +} + +/** + * A stubbed search source with a `fetch` method that returns a filtered set of `_stubHits`. + */ +export function createContextSearchSourceStub(hits, timeField = '@timestamp') { + const searchSourceStub = createSearchSourceStub(hits, timeField); + + searchSourceStub.fetch = sinon.spy(() => { + const timeField = searchSourceStub._stubTimeField; + const lastQuery = searchSourceStub.setField.withArgs('query').lastCall.args[1]; + const timeRange = lastQuery.query.bool.must.constant_score.filter.range[timeField]; + const lastSort = searchSourceStub.setField.withArgs('sort').lastCall.args[1]; + const sortDirection = lastSort[0][timeField]; + const sortFunction = + sortDirection === 'asc' + ? (first, second) => first[timeField] - second[timeField] + : (first, second) => second[timeField] - first[timeField]; + const filteredHits = searchSourceStub._stubHits + .filter( + (hit) => + moment(hit[timeField]).isSameOrAfter(timeRange.gte) && + moment(hit[timeField]).isSameOrBefore(timeRange.lte) + ) + .sort(sortFunction); + + return Promise.resolve({ + hits: { + hits: filteredHits, + total: filteredHits.length, + }, + }); + }); + + return searchSourceStub; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/anchor.js b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.js new file mode 100644 index 000000000000..599379e128b0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.js @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { i18n } from '@osd/i18n'; + +export function fetchAnchorProvider(indexPatterns, searchSource) { + return async function fetchAnchor(indexPatternId, anchorId, sort) { + const indexPattern = await indexPatterns.get(indexPatternId); + searchSource + .setParent(undefined) + .setField('index', indexPattern) + .setField('version', true) + .setField('size', 1) + .setField('query', { + query: { + constant_score: { + filter: { + ids: { + values: [anchorId], + }, + }, + }, + }, + language: 'lucene', + }) + .setField('sort', sort); + + const response = await searchSource.fetch(); + + if (_.get(response, ['hits', 'total'], 0) < 1) { + throw new Error( + i18n.translate('discover.context.failedToLoadAnchorDocumentErrorDescription', { + defaultMessage: 'Failed to load anchor document.', + }) + ); + } + + return { + ..._.get(response, ['hits', 'hits', 0]), + $$_isAnchor: true, + }; + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js new file mode 100644 index 000000000000..676aabb5c3b8 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/anchor.test.js @@ -0,0 +1,158 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createIndexPatternsStub, createSearchSourceStub } from './_stubs'; + +import { fetchAnchorProvider } from './anchor'; + +describe('context app', function () { + describe('function fetchAnchor', function () { + let fetchAnchor; + let searchSourceStub; + + beforeEach(() => { + searchSourceStub = createSearchSourceStub([{ _id: 'hit1' }]); + fetchAnchor = fetchAnchorProvider(createIndexPatternsStub(), searchSourceStub); + }); + + it('should use the `fetch` method of the SearchSource', function () { + return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + { '@timestamp': 'desc' }, + { _doc: 'desc' }, + ]).then(() => { + expect(searchSourceStub.fetch.calledOnce).toBe(true); + }); + }); + + it('should configure the SearchSource to not inherit from the implicit root', function () { + return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + { '@timestamp': 'desc' }, + { _doc: 'desc' }, + ]).then(() => { + const setParentSpy = searchSourceStub.setParent; + expect(setParentSpy.calledOnce).toBe(true); + expect(setParentSpy.firstCall.args[0]).toBe(undefined); + }); + }); + + it('should set the SearchSource index pattern', function () { + return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + { '@timestamp': 'desc' }, + { _doc: 'desc' }, + ]).then(() => { + const setFieldSpy = searchSourceStub.setField; + expect(setFieldSpy.firstCall.args[1].id).toEqual('INDEX_PATTERN_ID'); + }); + }); + + it('should set the SearchSource version flag to true', function () { + return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + { '@timestamp': 'desc' }, + { _doc: 'desc' }, + ]).then(() => { + const setVersionSpy = searchSourceStub.setField.withArgs('version'); + expect(setVersionSpy.calledOnce).toBe(true); + expect(setVersionSpy.firstCall.args[1]).toEqual(true); + }); + }); + + it('should set the SearchSource size to 1', function () { + return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + { '@timestamp': 'desc' }, + { _doc: 'desc' }, + ]).then(() => { + const setSizeSpy = searchSourceStub.setField.withArgs('size'); + expect(setSizeSpy.calledOnce).toBe(true); + expect(setSizeSpy.firstCall.args[1]).toEqual(1); + }); + }); + + it('should set the SearchSource query to an ids query', function () { + return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + { '@timestamp': 'desc' }, + { _doc: 'desc' }, + ]).then(() => { + const setQuerySpy = searchSourceStub.setField.withArgs('query'); + expect(setQuerySpy.calledOnce).toBe(true); + expect(setQuerySpy.firstCall.args[1]).toEqual({ + query: { + constant_score: { + filter: { + ids: { + values: ['id'], + }, + }, + }, + }, + language: 'lucene', + }); + }); + }); + + it('should set the SearchSource sort order', function () { + return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + { '@timestamp': 'desc' }, + { _doc: 'desc' }, + ]).then(() => { + const setSortSpy = searchSourceStub.setField.withArgs('sort'); + expect(setSortSpy.calledOnce).toBe(true); + expect(setSortSpy.firstCall.args[1]).toEqual([{ '@timestamp': 'desc' }, { _doc: 'desc' }]); + }); + }); + + it('should reject with an error when no hits were found', function () { + searchSourceStub._stubHits = []; + + return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + { '@timestamp': 'desc' }, + { _doc: 'desc' }, + ]).then( + () => { + expect().fail('expected the promise to be rejected'); + }, + (error) => { + expect(error).toBeInstanceOf(Error); + } + ); + }); + + it('should return the first hit after adding an anchor marker', function () { + searchSourceStub._stubHits = [{ property1: 'value1' }, { property2: 'value2' }]; + + return fetchAnchor('INDEX_PATTERN_ID', 'id', [ + { '@timestamp': 'desc' }, + { _doc: 'desc' }, + ]).then((anchorDocument) => { + expect(anchorDocument).toHaveProperty('property1', 'value1'); + expect(anchorDocument).toHaveProperty('$$_isAnchor', true); + }); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js new file mode 100644 index 000000000000..52ddc2978ad8 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/context.predecessors.test.js @@ -0,0 +1,241 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; +import { get, last } from 'lodash'; +import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs'; +import { fetchContextProvider } from './context'; +import { setServices } from '../../../../opensearch_dashboards_services'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const ANCHOR_TIMESTAMP = new Date(MS_PER_DAY).toJSON(); +const ANCHOR_TIMESTAMP_3 = new Date(MS_PER_DAY * 3).toJSON(); +const ANCHOR_TIMESTAMP_1000 = new Date(MS_PER_DAY * 1000).toJSON(); +const ANCHOR_TIMESTAMP_3000 = new Date(MS_PER_DAY * 3000).toJSON(); + +describe('context app', function () { + describe('function fetchPredecessors', function () { + let fetchPredecessors; + let mockSearchSource; + + beforeEach(() => { + mockSearchSource = createContextSearchSourceStub([], '@timestamp', MS_PER_DAY * 8); + + setServices({ + data: { + search: { + searchSource: { + create: jest.fn().mockImplementation(() => mockSearchSource), + }, + }, + }, + }); + + fetchPredecessors = ( + indexPatternId, + timeField, + sortDir, + timeValIso, + timeValNr, + tieBreakerField, + tieBreakerValue, + size + ) => { + const anchor = { + _source: { + [timeField]: timeValIso, + }, + sort: [timeValNr, tieBreakerValue], + }; + + return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs( + 'predecessors', + indexPatternId, + anchor, + timeField, + tieBreakerField, + sortDir, + size, + [] + ); + }; + }); + + it('should perform exactly one query when enough hits are returned', function () { + mockSearchSource._stubHits = [ + mockSearchSource._createStubHit(MS_PER_DAY * 3000 + 2), + mockSearchSource._createStubHit(MS_PER_DAY * 3000 + 1), + mockSearchSource._createStubHit(MS_PER_DAY * 3000), + mockSearchSource._createStubHit(MS_PER_DAY * 2000), + mockSearchSource._createStubHit(MS_PER_DAY * 1000), + ]; + + return fetchPredecessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_3000, + MS_PER_DAY * 3000, + '_doc', + 0, + 3, + [] + ).then((hits) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + }); + }); + + it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () { + mockSearchSource._stubHits = [ + mockSearchSource._createStubHit(MS_PER_DAY * 3010), + mockSearchSource._createStubHit(MS_PER_DAY * 3002), + mockSearchSource._createStubHit(MS_PER_DAY * 3000), + mockSearchSource._createStubHit(MS_PER_DAY * 2998), + mockSearchSource._createStubHit(MS_PER_DAY * 2990), + ]; + + return fetchPredecessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_3000, + MS_PER_DAY * 3000, + '_doc', + 0, + 6, + [] + ).then((hits) => { + const intervals = mockSearchSource.setField.args + .filter(([property]) => property === 'query') + .map(([, { query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + expect( + intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) + ).toBe(true); + // should have started at the given time + expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have ended with a half-open interval + expect(Object.keys(last(intervals))).toEqual(['format', 'gte']); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 3)); + }); + }); + + it('should perform multiple queries until the expected hit count is returned', function () { + mockSearchSource._stubHits = [ + mockSearchSource._createStubHit(MS_PER_DAY * 1700), + mockSearchSource._createStubHit(MS_PER_DAY * 1200), + mockSearchSource._createStubHit(MS_PER_DAY * 1100), + mockSearchSource._createStubHit(MS_PER_DAY * 1000), + ]; + + return fetchPredecessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_1000, + MS_PER_DAY * 1000, + '_doc', + 0, + 3, + [] + ).then((hits) => { + const intervals = mockSearchSource.setField.args + .filter(([property]) => property === 'query') + .map(([, { query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + // should have started at the given time + expect(intervals[0].gte).toEqual(moment(MS_PER_DAY * 1000).toISOString()); + // should have stopped before reaching MS_PER_DAY * 1700 + expect(moment(last(intervals).lte).valueOf()).toBeLessThan(MS_PER_DAY * 1700); + expect(intervals.length).toBeGreaterThan(1); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + }); + }); + + it('should return an empty array when no hits were found', function () { + return fetchPredecessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_3, + MS_PER_DAY * 3, + '_doc', + 0, + 3, + [] + ).then((hits) => { + expect(hits).toEqual([]); + }); + }); + + it('should configure the SearchSource to not inherit from the implicit root', function () { + return fetchPredecessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_3, + MS_PER_DAY * 3, + '_doc', + 0, + 3, + [] + ).then(() => { + const setParentSpy = mockSearchSource.setParent; + expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true); + expect(setParentSpy.called).toBe(true); + }); + }); + + it('should set the tiebreaker sort order to the opposite as the time field', function () { + return fetchPredecessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP, + MS_PER_DAY, + '_doc', + 0, + 3, + [] + ).then(() => { + expect( + mockSearchSource.setField.calledWith('sort', [{ '@timestamp': 'asc' }, { _doc: 'asc' }]) + ).toBe(true); + }); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js b/src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js new file mode 100644 index 000000000000..7086495e29e0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/context.successors.test.js @@ -0,0 +1,245 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; +import { get, last } from 'lodash'; + +import { createIndexPatternsStub, createContextSearchSourceStub } from './_stubs'; +import { setServices } from '../../../../opensearch_dashboards_services'; + +import { fetchContextProvider } from './context'; + +const MS_PER_DAY = 24 * 60 * 60 * 1000; +const ANCHOR_TIMESTAMP = new Date(MS_PER_DAY).toJSON(); +const ANCHOR_TIMESTAMP_3 = new Date(MS_PER_DAY * 3).toJSON(); +const ANCHOR_TIMESTAMP_3000 = new Date(MS_PER_DAY * 3000).toJSON(); + +describe('context app', function () { + describe('function fetchSuccessors', function () { + let fetchSuccessors; + let mockSearchSource; + + beforeEach(() => { + mockSearchSource = createContextSearchSourceStub([], '@timestamp'); + + setServices({ + data: { + search: { + searchSource: { + create: jest.fn().mockImplementation(() => mockSearchSource), + }, + }, + }, + }); + + fetchSuccessors = ( + indexPatternId, + timeField, + sortDir, + timeValIso, + timeValNr, + tieBreakerField, + tieBreakerValue, + size + ) => { + const anchor = { + _source: { + [timeField]: timeValIso, + }, + sort: [timeValNr, tieBreakerValue], + }; + + return fetchContextProvider(createIndexPatternsStub()).fetchSurroundingDocs( + 'successors', + indexPatternId, + anchor, + timeField, + tieBreakerField, + sortDir, + size, + [] + ); + }; + }); + + it('should perform exactly one query when enough hits are returned', function () { + mockSearchSource._stubHits = [ + mockSearchSource._createStubHit(MS_PER_DAY * 5000), + mockSearchSource._createStubHit(MS_PER_DAY * 4000), + mockSearchSource._createStubHit(MS_PER_DAY * 3000), + mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 1), + mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2), + ]; + + return fetchSuccessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_3000, + MS_PER_DAY * 3000, + '_doc', + 0, + 3, + [] + ).then((hits) => { + expect(mockSearchSource.fetch.calledOnce).toBe(true); + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + }); + }); + + it('should perform multiple queries with the last being unrestricted when too few hits are returned', function () { + mockSearchSource._stubHits = [ + mockSearchSource._createStubHit(MS_PER_DAY * 3010), + mockSearchSource._createStubHit(MS_PER_DAY * 3002), + mockSearchSource._createStubHit(MS_PER_DAY * 3000), + mockSearchSource._createStubHit(MS_PER_DAY * 2998), + mockSearchSource._createStubHit(MS_PER_DAY * 2990), + ]; + + return fetchSuccessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_3000, + MS_PER_DAY * 3000, + '_doc', + 0, + 6, + [] + ).then((hits) => { + const intervals = mockSearchSource.setField.args + .filter(([property]) => property === 'query') + .map(([, { query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + expect( + intervals.every(({ gte, lte }) => (gte && lte ? moment(gte).isBefore(lte) : true)) + ).toBe(true); + // should have started at the given time + expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have ended with a half-open interval + expect(Object.keys(last(intervals))).toEqual(['format', 'lte']); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(-3)); + }); + }); + + it('should perform multiple queries until the expected hit count is returned', function () { + mockSearchSource._stubHits = [ + mockSearchSource._createStubHit(MS_PER_DAY * 3000), + mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 1), + mockSearchSource._createStubHit(MS_PER_DAY * 3000 - 2), + mockSearchSource._createStubHit(MS_PER_DAY * 2800), + mockSearchSource._createStubHit(MS_PER_DAY * 2200), + mockSearchSource._createStubHit(MS_PER_DAY * 1000), + ]; + + return fetchSuccessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_3000, + MS_PER_DAY * 3000, + '_doc', + 0, + 4, + [] + ).then((hits) => { + const intervals = mockSearchSource.setField.args + .filter(([property]) => property === 'query') + .map(([, { query }]) => + get(query, ['bool', 'must', 'constant_score', 'filter', 'range', '@timestamp']) + ); + + // should have started at the given time + expect(intervals[0].lte).toEqual(moment(MS_PER_DAY * 3000).toISOString()); + // should have stopped before reaching MS_PER_DAY * 2200 + expect(moment(last(intervals).gte).valueOf()).toBeGreaterThan(MS_PER_DAY * 2200); + expect(intervals.length).toBeGreaterThan(1); + + expect(hits).toEqual(mockSearchSource._stubHits.slice(0, 4)); + }); + }); + + it('should return an empty array when no hits were found', function () { + return fetchSuccessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_3, + MS_PER_DAY * 3, + '_doc', + 0, + 3, + [] + ).then((hits) => { + expect(hits).toEqual([]); + }); + }); + + it('should configure the SearchSource to not inherit from the implicit root', function () { + return fetchSuccessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP_3, + MS_PER_DAY * 3, + '_doc', + 0, + 3, + [] + ).then(() => { + const setParentSpy = mockSearchSource.setParent; + expect(setParentSpy.alwaysCalledWith(undefined)).toBe(true); + expect(setParentSpy.called).toBe(true); + }); + }); + + it('should set the tiebreaker sort order to the same as the time field', function () { + return fetchSuccessors( + 'INDEX_PATTERN_ID', + '@timestamp', + 'desc', + ANCHOR_TIMESTAMP, + MS_PER_DAY, + '_doc', + 0, + 3, + [] + ).then(() => { + expect( + mockSearchSource.setField.calledWith('sort', [{ '@timestamp': 'desc' }, { _doc: 'desc' }]) + ).toBe(true); + }); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/context.ts b/src/plugins/discover_legacy/public/application/angular/context/api/context.ts new file mode 100644 index 000000000000..046438f08339 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/context.ts @@ -0,0 +1,137 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { Filter, IndexPatternsContract, IndexPattern } from 'src/plugins/data/public'; +import { reverseSortDir, SortDirection } from './utils/sorting'; +import { extractNanos, convertIsoToMillis } from './utils/date_conversion'; +import { fetchHitsInInterval } from './utils/fetch_hits_in_interval'; +import { generateIntervals } from './utils/generate_intervals'; +import { getOpenSearchQuerySearchAfter } from './utils/get_opensearch_query_search_after'; +import { getOpenSearchQuerySort } from './utils/get_opensearch_query_sort'; +import { getServices } from '../../../../opensearch_dashboards_services'; + +export type SurrDocType = 'successors' | 'predecessors'; +export interface OpenSearchHitRecord { + fields: Record; + sort: number[]; + _source: Record; + _id: string; +} +export type OpenSearchHitRecordList = OpenSearchHitRecord[]; + +const DAY_MILLIS = 24 * 60 * 60 * 1000; + +// look from 1 day up to 10000 days into the past and future +const LOOKUP_OFFSETS = [0, 1, 7, 30, 365, 10000].map((days) => days * DAY_MILLIS); + +function fetchContextProvider(indexPatterns: IndexPatternsContract) { + return { + fetchSurroundingDocs, + }; + + /** + * Fetch successor or predecessor documents of a given anchor document + * + * @param {SurrDocType} type - `successors` or `predecessors` + * @param {string} indexPatternId + * @param {OpenSearchHitRecord} anchor - anchor record + * @param {string} timeField - name of the timefield, that's sorted on + * @param {string} tieBreakerField - name of the tie breaker, the 2nd sort field + * @param {SortDirection} sortDir - direction of sorting + * @param {number} size - number of records to retrieve + * @param {Filter[]} filters - to apply in the query + * @returns {Promise} + */ + async function fetchSurroundingDocs( + type: SurrDocType, + indexPatternId: string, + anchor: OpenSearchHitRecord, + timeField: string, + tieBreakerField: string, + sortDir: SortDirection, + size: number, + filters: Filter[] + ) { + if (typeof anchor !== 'object' || anchor === null || !size) { + return []; + } + const indexPattern = await indexPatterns.get(indexPatternId); + const searchSource = await createSearchSource(indexPattern, filters); + const sortDirToApply = type === 'successors' ? sortDir : reverseSortDir(sortDir); + + const nanos = indexPattern.isTimeNanosBased() ? extractNanos(anchor._source[timeField]) : ''; + const timeValueMillis = + nanos !== '' ? convertIsoToMillis(anchor._source[timeField]) : anchor.sort[0]; + + const intervals = generateIntervals(LOOKUP_OFFSETS, timeValueMillis, type, sortDir); + let documents: OpenSearchHitRecordList = []; + + for (const interval of intervals) { + const remainingSize = size - documents.length; + + if (remainingSize <= 0) { + break; + } + + const searchAfter = getOpenSearchQuerySearchAfter(type, documents, timeField, anchor, nanos); + + const sort = getOpenSearchQuerySort(timeField, tieBreakerField, sortDirToApply); + + const hits = await fetchHitsInInterval( + searchSource, + timeField, + sort, + sortDirToApply, + interval, + searchAfter, + remainingSize, + nanos, + anchor._id + ); + + documents = + type === 'successors' ? [...documents, ...hits] : [...hits.slice().reverse(), ...documents]; + } + + return documents; + } + + async function createSearchSource(indexPattern: IndexPattern, filters: Filter[]) { + const { data } = getServices(); + + const searchSource = await data.search.searchSource.create(); + return searchSource + .setParent(undefined) + .setField('index', indexPattern) + .setField('filter', filters); + } +} + +export { fetchContextProvider }; diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.test.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.test.ts new file mode 100644 index 000000000000..fe1a18bf938f --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.test.ts @@ -0,0 +1,43 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { extractNanos } from './date_conversion'; + +describe('function extractNanos', function () { + test('extract nanos of 2014-01-01', function () { + expect(extractNanos('2014-01-01')).toBe('000000000'); + }); + test('extract nanos of 2014-01-01T12:12:12.234Z', function () { + expect(extractNanos('2014-01-01T12:12:12.234Z')).toBe('234000000'); + }); + test('extract nanos of 2014-01-01T12:12:12.234123321Z', function () { + expect(extractNanos('2014-01-01T12:12:12.234123321Z')).toBe('234123321'); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.ts new file mode 100644 index 000000000000..8f4bfb30375d --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/date_conversion.ts @@ -0,0 +1,76 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import moment from 'moment'; +/** + * extract nanoseconds if available in ISO timestamp + * returns the nanos as string like this: + * 9ns -> 000000009 + * 10000ns -> 0000010000 + * returns 000000000 for invalid timestamps or timestamps with just date + **/ +export function extractNanos(timeFieldValue: string = ''): string { + const fieldParts = timeFieldValue.split('.'); + const fractionSeconds = fieldParts.length === 2 ? fieldParts[1].replace('Z', '') : ''; + return fractionSeconds.length !== 9 ? fractionSeconds.padEnd(9, '0') : fractionSeconds; +} + +/** + * convert an iso formatted string to number of milliseconds since + * 1970-01-01T00:00:00.000Z + * @param {string} isoValue + * @returns {number} + */ +export function convertIsoToMillis(isoValue: string): number { + const date = new Date(isoValue); + return date.getTime(); +} +/** + * the given time value in milliseconds is converted to a ISO formatted string + * if nanosValue is provided, the given value replaces the fractional seconds part + * of the formated string since moment.js doesn't support formatting timestamps + * with a higher precision then microseconds + * The browser rounds date nanos values: + * 2019-09-18T06:50:12.999999999 -> browser rounds to 1568789413000000000 + * 2019-09-18T06:50:59.999999999 -> browser rounds to 1568789460000000000 + * 2017-12-31T23:59:59.999999999 -> browser rounds 1514761199999999999 to 1514761200000000000 + */ +export function convertTimeValueToIso(timeValueMillis: number, nanosValue: string): string | null { + if (!timeValueMillis) { + return null; + } + const isoString = moment(timeValueMillis).toISOString(); + if (!isoString) { + return null; + } else if (nanosValue !== '') { + return `${isoString.substring(0, isoString.length - 4)}${nanosValue}Z`; + } + return isoString; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts new file mode 100644 index 000000000000..262b64ba8c15 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/fetch_hits_in_interval.ts @@ -0,0 +1,107 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + ISearchSource, + OpenSearchQuerySortValue, + SortDirection, +} from '../../../../../../../data/public'; +import { convertTimeValueToIso } from './date_conversion'; +import { OpenSearchHitRecordList, OpenSearchHitRecord } from '../context'; +import { IntervalValue } from './generate_intervals'; +import { OpenSearchQuerySearchAfter } from './get_opensearch_query_search_after'; + +interface RangeQuery { + format: string; + lte?: string | null; + gte?: string | null; +} + +/** + * Fetch the hits between a given `interval` up to a maximum of `maxCount` documents. + * The documents are sorted by `sort` + * + * The `searchSource` is assumed to have the appropriate index pattern + * and filters set. + */ +export async function fetchHitsInInterval( + searchSource: ISearchSource, + timeField: string, + sort: [OpenSearchQuerySortValue, OpenSearchQuerySortValue], + sortDir: SortDirection, + interval: IntervalValue[], + searchAfter: OpenSearchQuerySearchAfter, + maxCount: number, + nanosValue: string, + anchorId: string +): Promise { + const range: RangeQuery = { + format: 'strict_date_optional_time', + }; + const [start, stop] = interval; + + if (start) { + range[sortDir === SortDirection.asc ? 'gte' : 'lte'] = convertTimeValueToIso(start, nanosValue); + } + + if (stop) { + range[sortDir === SortDirection.asc ? 'lte' : 'gte'] = convertTimeValueToIso(stop, nanosValue); + } + const response = await searchSource + .setField('size', maxCount) + .setField('query', { + query: { + bool: { + must: { + constant_score: { + filter: { + range: { + [timeField]: range, + }, + }, + }, + }, + must_not: { + ids: { + values: [anchorId], + }, + }, + }, + }, + language: 'lucene', + }) + .setField('searchAfter', searchAfter) + .setField('sort', sort) + .setField('version', true) + .fetch(); + + // TODO: There's a difference in the definition of SearchResponse and OpenSearchHitRecord + return ((response.hits?.hits as unknown) as OpenSearchHitRecord[]) || []; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts new file mode 100644 index 000000000000..fda2e23eb234 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/generate_intervals.ts @@ -0,0 +1,66 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SortDirection } from '../../../../../../../data/public'; + +export type IntervalValue = number | null; + +/** + * Generate a sequence of pairs from the iterable that looks like + * `[[x_0, x_1], [x_1, x_2], [x_2, x_3], ..., [x_(n-1), x_n]]`. + */ +export function* asPairs(iterable: Iterable): IterableIterator { + let currentPair: IntervalValue[] = []; + for (const value of iterable) { + currentPair = [...currentPair, value].slice(-2); + if (currentPair.length === 2) { + yield currentPair; + } + } +} + +/** + * Returns a iterable containing intervals `[start,end]` for OpenSearch date range queries + * depending on type (`successors` or `predecessors`) and sort (`asc`, `desc`) these are ascending or descending intervals. + */ +export function generateIntervals( + offsets: number[], + startTime: number, + type: string, + sort: SortDirection +): IterableIterator { + const offsetSign = + (sort === SortDirection.asc && type === 'successors') || + (sort === SortDirection.desc && type === 'predecessors') + ? 1 + : -1; + // ending with `null` opens the last interval + return asPairs([...offsets.map((offset) => startTime + offset * offsetSign), null]); +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts new file mode 100644 index 000000000000..eb6a5af565ba --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_search_after.ts @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { SurrDocType, OpenSearchHitRecordList, OpenSearchHitRecord } from '../context'; + +export type OpenSearchQuerySearchAfter = [string | number, string | number]; + +/** + * Get the searchAfter query value for opensearch + * When there are already documents available, which means successors or predecessors + * were already fetched, the new searchAfter for the next fetch has to be the sort value + * of the first (prececessor), or last (successor) of the list + */ +export function getOpenSearchQuerySearchAfter( + type: SurrDocType, + documents: OpenSearchHitRecordList, + timeFieldName: string, + anchor: OpenSearchHitRecord, + nanoSeconds: string +): OpenSearchQuerySearchAfter { + if (documents.length) { + // already surrounding docs -> first or last record is used + const afterTimeRecIdx = type === 'successors' && documents.length ? documents.length - 1 : 0; + const afterTimeDoc = documents[afterTimeRecIdx]; + const afterTimeValue = nanoSeconds ? afterTimeDoc._source[timeFieldName] : afterTimeDoc.sort[0]; + return [afterTimeValue, afterTimeDoc.sort[1]]; + } + // if data_nanos adapt timestamp value for sorting, since numeric value was rounded by browser + // OpenSearch search_after also works when number is provided as string + return [nanoSeconds ? anchor._source[timeFieldName] : anchor.sort[0], anchor.sort[1]]; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts new file mode 100644 index 000000000000..30c4888fa438 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/get_opensearch_query_sort.ts @@ -0,0 +1,49 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + OpenSearchQuerySortValue, + SortDirection, +} from '../../../../../opensearch_dashboards_services'; + +/** + * Returns `OpenSearchQuerySort` which is used to sort records in the OpenSearch query + * https://opensearch.org/docs/latest/opensearch/ux/#sort-results + * @param timeField + * @param tieBreakerField + * @param sortDir + */ +export function getOpenSearchQuerySort( + timeField: string, + tieBreakerField: string, + sortDir: SortDirection +): [OpenSearchQuerySortValue, OpenSearchQuerySortValue] { + return [{ [timeField]: sortDir }, { [tieBreakerField]: sortDir }]; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts new file mode 100644 index 000000000000..6944591d40cd --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.test.ts @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { reverseSortDir, SortDirection } from './sorting'; + +describe('function reverseSortDir', function () { + test('reverse a given sort direction', function () { + expect(reverseSortDir(SortDirection.asc)).toBe(SortDirection.desc); + expect(reverseSortDir(SortDirection.desc)).toBe(SortDirection.asc); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts new file mode 100644 index 000000000000..52b6df12e467 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/api/utils/sorting.ts @@ -0,0 +1,63 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IndexPattern } from '../../../../../opensearch_dashboards_services'; + +export enum SortDirection { + asc = 'asc', + desc = 'desc', +} + +/** + * The list of field names that are allowed for sorting, but not included in + * index pattern fields. + */ +const META_FIELD_NAMES: string[] = ['_seq_no', '_doc', '_uid']; + +/** + * Returns a field from the intersection of the set of sortable fields in the + * given index pattern and a given set of candidate field names. + */ +export function getFirstSortableField(indexPattern: IndexPattern, fieldNames: string[]) { + const sortableFields = fieldNames.filter( + (fieldName) => + META_FIELD_NAMES.includes(fieldName) || + // @ts-ignore + (indexPattern.fields.getByName(fieldName) || { sortable: false }).sortable + ); + return sortableFields[0]; +} + +/** + * Return the reversed sort direction. + */ +export function reverseSortDir(sortDirection: SortDirection) { + return sortDirection === SortDirection.asc ? SortDirection.desc : SortDirection.asc; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_action_bar.scss b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_action_bar.scss new file mode 100644 index 000000000000..da0911c3a452 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_action_bar.scss @@ -0,0 +1,10 @@ +.cxtSizePicker { + text-align: center; + width: $euiSize * 5; + + &::-webkit-outer-spin-button, + &::-webkit-inner-spin-button { + appearance: none; // Hide increment and decrement buttons for type="number" input. + margin: 0; + } +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_index.scss b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_index.scss new file mode 100644 index 000000000000..40a446220577 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/_index.scss @@ -0,0 +1 @@ +@import "action_bar"; diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx new file mode 100644 index 000000000000..2f7cc40b7d9a --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.test.tsx @@ -0,0 +1,104 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ActionBar, ActionBarProps } from './action_bar'; +import { findTestSubject } from 'test_utils/helpers'; +import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from '../../query_parameters/constants'; + +describe('Test Discover Context ActionBar for successor | predecessor records', () => { + ['successors', 'predecessors'].forEach((type) => { + const onChangeCount = jest.fn(); + const props = { + defaultStepSize: 5, + docCount: 20, + docCountAvailable: 0, + isDisabled: false, + isLoading: false, + onChangeCount, + type, + } as ActionBarProps; + const wrapper = mountWithIntl(); + + const input = findTestSubject(wrapper, `${type}CountPicker`); + const btn = findTestSubject(wrapper, `${type}LoadMoreButton`); + + test(`${type}: Load button click`, () => { + btn.simulate('click'); + expect(onChangeCount).toHaveBeenCalledWith(25); + }); + + test(`${type}: Load button click doesnt submit when MAX_CONTEXT_SIZE was reached`, () => { + onChangeCount.mockClear(); + input.simulate('change', { target: { valueAsNumber: MAX_CONTEXT_SIZE } }); + btn.simulate('click'); + expect(onChangeCount).toHaveBeenCalledTimes(0); + }); + + test(`${type}: Count input change submits on blur`, () => { + input.simulate('change', { target: { valueAsNumber: 123 } }); + input.simulate('blur'); + expect(onChangeCount).toHaveBeenCalledWith(123); + }); + + test(`${type}: Count input change submits on return`, () => { + input.simulate('change', { target: { valueAsNumber: 124 } }); + input.simulate('submit'); + expect(onChangeCount).toHaveBeenCalledWith(124); + }); + + test(`${type}: Count input doesnt submits values higher than MAX_CONTEXT_SIZE `, () => { + onChangeCount.mockClear(); + input.simulate('change', { target: { valueAsNumber: MAX_CONTEXT_SIZE + 1 } }); + input.simulate('submit'); + expect(onChangeCount).toHaveBeenCalledTimes(0); + }); + + test(`${type}: Count input doesnt submits values lower than MIN_CONTEXT_SIZE `, () => { + onChangeCount.mockClear(); + input.simulate('change', { target: { valueAsNumber: MIN_CONTEXT_SIZE - 1 } }); + input.simulate('submit'); + expect(onChangeCount).toHaveBeenCalledTimes(0); + }); + + test(`${type}: Warning about limitation of additional records`, () => { + if (type === 'predecessors') { + expect(findTestSubject(wrapper, 'predecessorsWarningMsg').text()).toBe( + 'No documents newer than the anchor could be found.' + ); + } else { + expect(findTestSubject(wrapper, 'successorsWarningMsg').text()).toBe( + 'No documents older than the anchor could be found.' + ); + } + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx new file mode 100644 index 000000000000..8a4b0b308047 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar.tsx @@ -0,0 +1,182 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { + EuiButtonEmpty, + EuiFieldNumber, + EuiFlexGroup, + EuiFlexItem, + EuiFormRow, + EuiSpacer, +} from '@elastic/eui'; +import { ActionBarWarning } from './action_bar_warning'; +import { SurrDocType } from '../../api/context'; +import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE } from '../../query_parameters/constants'; + +export interface ActionBarProps { + /** + * the number of documents fetched initially and added when the load button is clicked + */ + defaultStepSize: number; + /** + * the number of docs to be displayed + */ + docCount: number; + /** + * the number of documents that are available + * display warning when it's lower than docCount + */ + docCountAvailable: number; + /** + * is true while the anchor record is fetched + */ + isDisabled: boolean; + /** + * is true when list entries are fetched + */ + isLoading: boolean; + /** + * is triggered when the input containing count is changed + * @param count + */ + onChangeCount: (count: number) => void; + /** + * can be `predecessors` or `successors`, usage in context: + * predecessors action bar + records (these are newer records) + * anchor record + * successors records + action bar (these are older records) + */ + type: SurrDocType; +} + +export function ActionBar({ + defaultStepSize, + docCount, + docCountAvailable, + isDisabled, + isLoading, + onChangeCount, + type, +}: ActionBarProps) { + const showWarning = !isDisabled && !isLoading && docCountAvailable < docCount; + const isSuccessor = type === 'successors'; + const [newDocCount, setNewDocCount] = useState(docCount); + const isValid = (value: number) => value >= MIN_CONTEXT_SIZE && value <= MAX_CONTEXT_SIZE; + const onSubmit = (ev: React.FormEvent) => { + ev.preventDefault(); + if (newDocCount !== docCount && isValid(newDocCount)) { + onChangeCount(newDocCount); + } + }; + + return ( + +
+ {isSuccessor && } + {isSuccessor && showWarning && ( + + )} + {isSuccessor && showWarning && } + + + { + const value = newDocCount + defaultStepSize; + if (isValid(value)) { + setNewDocCount(value); + onChangeCount(value); + } + }} + flush="right" + > + + + + + + { + setNewDocCount(ev.target.valueAsNumber); + }} + onBlur={() => { + if (newDocCount !== docCount && isValid(newDocCount)) { + onChangeCount(newDocCount); + } + }} + type="number" + value={newDocCount >= 0 ? newDocCount : ''} + /> + + + + + {isSuccessor ? ( + + ) : ( + + )} + + + + {!isSuccessor && showWarning && ( + + )} + {!isSuccessor && } + +
+ ); +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts new file mode 100644 index 000000000000..3aa62e72353e --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_directive.ts @@ -0,0 +1,36 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getAngularModule } from '../../../../../opensearch_dashboards_services'; +import { ActionBar } from './action_bar'; + +getAngularModule().directive('contextActionBar', function (reactDirective: any) { + return reactDirective(ActionBar); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx new file mode 100644 index 000000000000..cfdc3cc0c8cc --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/action_bar_warning.tsx @@ -0,0 +1,84 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { EuiCallOut } from '@elastic/eui'; +import { SurrDocType } from '../../api/context'; + +export function ActionBarWarning({ docCount, type }: { docCount: number; type: SurrDocType }) { + if (type === 'predecessors') { + return ( + + ) : ( + + ) + } + size="s" + /> + ); + } + + return ( + + ) : ( + + ) + } + size="s" + /> + ); +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts new file mode 100644 index 000000000000..1e3799d2121a --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/components/action_bar/index.ts @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './action_bar_directive'; diff --git a/src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js b/src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js new file mode 100644 index 000000000000..455cc57b3d4d --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/helpers/call_after_bindings_workaround.js @@ -0,0 +1,74 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * WHAT NEEDS THIS WORKAROUND? + * =========================== + * Any directive that meets all of the following criteria: + * - uses isolate scope bindings + * - sets `bindToController: true` + * - synchronously accesses the bound values in the controller constructor + * + * + * + * HOW DO I GET RID OF IT? + * ======================= + * The quick band-aid solution: + * Wrap your constructor logic so it doesn't access bound values + * synchronously. This can have subtle bugs which is why I didn't + * just wrap all of the offenders in $timeout() and made this + * workaround instead. + * + * The more complete solution: + * Use the new component lifecycle methods, like `$onInit()`, to access + * bindings immediately after the constructor is called, which shouldn't + * have any observable effect outside of the constructor. + * + * NOTE: `$onInit()` is not dependency injected, if you need controller specific + * dependencies like `$scope` then you're probably using watchers and should + * take a look at the new one-way data flow facilities available to + * directives/components: + * + * https://docs.angularjs.org/guide/component#component-based-application-architecture + * + */ + +export function callAfterBindingsWorkaround(constructor) { + return function InitAfterBindingsWrapper($injector, $attrs, $element, $scope, $transclude) { + this.$onInit = () => { + $injector.invoke(constructor, this, { + $attrs, + $element, + $scope, + $transclude, + }); + }; + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/query/actions.js b/src/plugins/discover_legacy/public/application/angular/context/query/actions.js new file mode 100644 index 000000000000..d4b4f9ba9977 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/query/actions.js @@ -0,0 +1,203 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { i18n } from '@osd/i18n'; +import React from 'react'; +import { getServices } from '../../../../opensearch_dashboards_services'; + +import { fetchAnchorProvider } from '../api/anchor'; +import { fetchContextProvider } from '../api/context'; +import { getQueryParameterActions } from '../query_parameters'; +import { FAILURE_REASONS, LOADING_STATUS } from './constants'; +import { MarkdownSimple } from '../../../../../../opensearch_dashboards_react/public'; + +export function QueryActionsProvider(Promise) { + const { filterManager, indexPatterns, data } = getServices(); + const fetchAnchor = fetchAnchorProvider(indexPatterns, data.search.searchSource.createEmpty()); + const { fetchSurroundingDocs } = fetchContextProvider(indexPatterns); + const { setPredecessorCount, setQueryParameters, setSuccessorCount } = getQueryParameterActions( + filterManager, + indexPatterns + ); + + const setFailedStatus = (state) => (subject, details = {}) => + (state.loadingStatus[subject] = { + status: LOADING_STATUS.FAILED, + reason: FAILURE_REASONS.UNKNOWN, + ...details, + }); + + const setLoadedStatus = (state) => (subject) => + (state.loadingStatus[subject] = { + status: LOADING_STATUS.LOADED, + }); + + const setLoadingStatus = (state) => (subject) => + (state.loadingStatus[subject] = { + status: LOADING_STATUS.LOADING, + }); + + const fetchAnchorRow = (state) => () => { + const { + queryParameters: { indexPatternId, anchorId, sort, tieBreakerField }, + } = state; + + if (!tieBreakerField) { + return Promise.reject( + setFailedStatus(state)('anchor', { + reason: FAILURE_REASONS.INVALID_TIEBREAKER, + }) + ); + } + + setLoadingStatus(state)('anchor'); + + return Promise.try(() => + fetchAnchor(indexPatternId, anchorId, [_.fromPairs([sort]), { [tieBreakerField]: sort[1] }]) + ).then( + (anchorDocument) => { + setLoadedStatus(state)('anchor'); + state.rows.anchor = anchorDocument; + return anchorDocument; + }, + (error) => { + setFailedStatus(state)('anchor', { error }); + getServices().toastNotifications.addDanger({ + title: i18n.translate('discover.context.unableToLoadAnchorDocumentDescription', { + defaultMessage: 'Unable to load the anchor document', + }), + text: {error.message}, + }); + throw error; + } + ); + }; + + const fetchSurroundingRows = (type, state) => { + const { + queryParameters: { indexPatternId, sort, tieBreakerField }, + rows: { anchor }, + } = state; + const filters = getServices().filterManager.getFilters(); + + const count = + type === 'successors' + ? state.queryParameters.successorCount + : state.queryParameters.predecessorCount; + + if (!tieBreakerField) { + return Promise.reject( + setFailedStatus(state)(type, { + reason: FAILURE_REASONS.INVALID_TIEBREAKER, + }) + ); + } + + setLoadingStatus(state)(type); + const [sortField, sortDir] = sort; + + return Promise.try(() => + fetchSurroundingDocs( + type, + indexPatternId, + anchor, + sortField, + tieBreakerField, + sortDir, + count, + filters + ) + ).then( + (documents) => { + setLoadedStatus(state)(type); + state.rows[type] = documents; + return documents; + }, + (error) => { + setFailedStatus(state)(type, { error }); + getServices().toastNotifications.addDanger({ + title: i18n.translate('discover.context.unableToLoadDocumentDescription', { + defaultMessage: 'Unable to load documents', + }), + text: {error.message}, + }); + throw error; + } + ); + }; + + const fetchContextRows = (state) => () => + Promise.all([ + fetchSurroundingRows('predecessors', state), + fetchSurroundingRows('successors', state), + ]); + + const fetchAllRows = (state) => () => + Promise.try(fetchAnchorRow(state)).then(fetchContextRows(state)); + + const fetchContextRowsWithNewQueryParameters = (state) => (queryParameters) => { + setQueryParameters(state)(queryParameters); + return fetchContextRows(state)(); + }; + + const fetchAllRowsWithNewQueryParameters = (state) => (queryParameters) => { + setQueryParameters(state)(queryParameters); + return fetchAllRows(state)(); + }; + + const fetchGivenPredecessorRows = (state) => (count) => { + setPredecessorCount(state)(count); + return fetchSurroundingRows('predecessors', state); + }; + + const fetchGivenSuccessorRows = (state) => (count) => { + setSuccessorCount(state)(count); + return fetchSurroundingRows('successors', state); + }; + + const setAllRows = (state) => (predecessorRows, anchorRow, successorRows) => + (state.rows.all = [ + ...(predecessorRows || []), + ...(anchorRow ? [anchorRow] : []), + ...(successorRows || []), + ]); + + return { + fetchAllRows, + fetchAllRowsWithNewQueryParameters, + fetchAnchorRow, + fetchContextRows, + fetchContextRowsWithNewQueryParameters, + fetchGivenPredecessorRows, + fetchGivenSuccessorRows, + setAllRows, + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/query/constants.js b/src/plugins/discover_legacy/public/application/angular/context/query/constants.js new file mode 100644 index 000000000000..99039d463b5b --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/query/constants.js @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export const FAILURE_REASONS = { + UNKNOWN: 'unknown', + INVALID_TIEBREAKER: 'invalid_tiebreaker', +}; + +export const LOADING_STATUS = { + FAILED: 'failed', + LOADED: 'loaded', + LOADING: 'loading', + UNINITIALIZED: 'uninitialized', +}; diff --git a/src/plugins/discover_legacy/public/application/angular/context/query/index.js b/src/plugins/discover_legacy/public/application/angular/context/query/index.js new file mode 100644 index 000000000000..cbb0a7484ea7 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/query/index.js @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { QueryActionsProvider } from './actions'; +export { FAILURE_REASONS, LOADING_STATUS } from './constants'; +export { createInitialLoadingStatusState } from './state'; diff --git a/src/plugins/discover_legacy/public/application/angular/context/query/state.js b/src/plugins/discover_legacy/public/application/angular/context/query/state.js new file mode 100644 index 000000000000..7a38ea8ebe3a --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/query/state.js @@ -0,0 +1,39 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { LOADING_STATUS } from './constants'; + +export function createInitialLoadingStatusState() { + return { + anchor: LOADING_STATUS.UNINITIALIZED, + predecessors: LOADING_STATUS.UNINITIALIZED, + successors: LOADING_STATUS.UNINITIALIZED, + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js new file mode 100644 index 000000000000..f191d7c0d5a2 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.js @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { opensearchFilters } from '../../../../../../data/public'; +import { popularizeField } from '../../../helpers/popularize_field'; + +import { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE, QUERY_PARAMETER_KEYS } from './constants'; + +export function getQueryParameterActions(filterManager, indexPatterns) { + const setPredecessorCount = (state) => (predecessorCount) => + (state.queryParameters.predecessorCount = clamp( + MIN_CONTEXT_SIZE, + MAX_CONTEXT_SIZE, + predecessorCount + )); + + const setSuccessorCount = (state) => (successorCount) => + (state.queryParameters.successorCount = clamp( + MIN_CONTEXT_SIZE, + MAX_CONTEXT_SIZE, + successorCount + )); + + const setQueryParameters = (state) => (queryParameters) => + Object.assign(state.queryParameters, _.pick(queryParameters, QUERY_PARAMETER_KEYS)); + + const updateFilters = () => (filters) => { + filterManager.setFilters(filters); + }; + + const addFilter = (state) => async (field, values, operation) => { + const indexPatternId = state.queryParameters.indexPatternId; + const newFilters = opensearchFilters.generateFilters( + filterManager, + field, + values, + operation, + indexPatternId + ); + filterManager.addFilters(newFilters); + if (indexPatterns) { + const indexPattern = await indexPatterns.get(indexPatternId); + await popularizeField(indexPattern, field.name, indexPatterns); + } + }; + + return { + addFilter, + updateFilters, + setPredecessorCount, + setQueryParameters, + setSuccessorCount, + }; +} + +function clamp(minimum, maximum, value) { + return Math.max(Math.min(maximum, value), minimum); +} diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts new file mode 100644 index 000000000000..2e3c69a32ff6 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/actions.test.ts @@ -0,0 +1,168 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// @ts-ignore +import { getQueryParameterActions } from './actions'; +import { FilterManager } from '../../../../../../data/public'; +import { coreMock } from '../../../../../../../core/public/mocks'; +const setupMock = coreMock.createSetup(); + +let state: { + queryParameters: { + defaultStepSize: number; + indexPatternId: string; + predecessorCount: number; + successorCount: number; + }; +}; +let filterManager: FilterManager; +let filterManagerSpy: jest.SpyInstance; + +beforeEach(() => { + filterManager = new FilterManager(setupMock.uiSettings); + filterManagerSpy = jest.spyOn(filterManager, 'addFilters'); + + state = { + queryParameters: { + defaultStepSize: 3, + indexPatternId: 'INDEX_PATTERN_ID', + predecessorCount: 10, + successorCount: 10, + }, + }; +}); + +describe('context query_parameter actions', function () { + describe('action addFilter', () => { + it('should pass the given arguments to the filterManager', () => { + const { addFilter } = getQueryParameterActions(filterManager); + + addFilter(state)('FIELD_NAME', 'FIELD_VALUE', 'FILTER_OPERATION'); + + // get the generated filter + const generatedFilter = filterManagerSpy.mock.calls[0][0][0]; + const queryKeys = Object.keys(generatedFilter.query.match_phrase); + expect(filterManagerSpy.mock.calls.length).toBe(1); + expect(queryKeys[0]).toBe('FIELD_NAME'); + expect(generatedFilter.query.match_phrase[queryKeys[0]]).toBe('FIELD_VALUE'); + }); + + it('should pass the index pattern id to the filterManager', () => { + const { addFilter } = getQueryParameterActions(filterManager); + addFilter(state)('FIELD_NAME', 'FIELD_VALUE', 'FILTER_OPERATION'); + const generatedFilter = filterManagerSpy.mock.calls[0][0][0]; + expect(generatedFilter.meta.index).toBe('INDEX_PATTERN_ID'); + }); + }); + describe('action setPredecessorCount', () => { + it('should set the predecessorCount to the given value', () => { + const { setPredecessorCount } = getQueryParameterActions(filterManager); + setPredecessorCount(state)(20); + expect(state.queryParameters.predecessorCount).toBe(20); + }); + + it('should limit the predecessorCount to 0 as a lower bound', () => { + const { setPredecessorCount } = getQueryParameterActions(filterManager); + setPredecessorCount(state)(-1); + expect(state.queryParameters.predecessorCount).toBe(0); + }); + + it('should limit the predecessorCount to 10000 as an upper bound', () => { + const { setPredecessorCount } = getQueryParameterActions(filterManager); + setPredecessorCount(state)(20000); + expect(state.queryParameters.predecessorCount).toBe(10000); + }); + }); + describe('action setSuccessorCount', () => { + it('should set the successorCount to the given value', function () { + const { setSuccessorCount } = getQueryParameterActions(filterManager); + setSuccessorCount(state)(20); + + expect(state.queryParameters.successorCount).toBe(20); + }); + + it('should limit the successorCount to 0 as a lower bound', () => { + const { setSuccessorCount } = getQueryParameterActions(filterManager); + setSuccessorCount(state)(-1); + expect(state.queryParameters.successorCount).toBe(0); + }); + + it('should limit the successorCount to 10000 as an upper bound', () => { + const { setSuccessorCount } = getQueryParameterActions(filterManager); + setSuccessorCount(state)(20000); + expect(state.queryParameters.successorCount).toBe(10000); + }); + }); + describe('action setQueryParameters', function () { + const { setQueryParameters } = getQueryParameterActions(filterManager); + + it('should update the queryParameters with valid properties from the given object', function () { + const newState = { + ...state, + queryParameters: { + additionalParameter: 'ADDITIONAL_PARAMETER', + }, + }; + + const actualState = setQueryParameters(newState)({ + anchorId: 'ANCHOR_ID', + columns: ['column'], + defaultStepSize: 3, + filters: ['filter'], + indexPatternId: 'INDEX_PATTERN', + predecessorCount: 100, + successorCount: 100, + sort: ['field'], + }); + + expect(actualState).toEqual({ + additionalParameter: 'ADDITIONAL_PARAMETER', + anchorId: 'ANCHOR_ID', + columns: ['column'], + defaultStepSize: 3, + filters: ['filter'], + indexPatternId: 'INDEX_PATTERN', + predecessorCount: 100, + successorCount: 100, + sort: ['field'], + }); + }); + + it('should ignore invalid properties', function () { + const newState = { ...state }; + + setQueryParameters(newState)({ + additionalParameter: 'ADDITIONAL_PARAMETER', + }); + + expect(state.queryParameters).toEqual(newState.queryParameters); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts new file mode 100644 index 000000000000..a6dcc5653c87 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/constants.ts @@ -0,0 +1,35 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { createInitialQueryParametersState } from './state'; + +export const MAX_CONTEXT_SIZE = 10000; // OpenSearch's default maximum size limit +export const MIN_CONTEXT_SIZE = 0; +export const QUERY_PARAMETER_KEYS = Object.keys(createInitialQueryParametersState()); diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js new file mode 100644 index 000000000000..03f172bd12cb --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/index.js @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { getQueryParameterActions } from './actions'; +export { MAX_CONTEXT_SIZE, MIN_CONTEXT_SIZE, QUERY_PARAMETER_KEYS } from './constants'; +export { createInitialQueryParametersState } from './state'; diff --git a/src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts new file mode 100644 index 000000000000..a9f44a0f7bef --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context/query_parameters/state.ts @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function createInitialQueryParametersState( + defaultStepSize: number = 5, + tieBreakerField: string = '_doc' +) { + return { + anchorType: null, + anchorId: null, + columns: [], + defaultStepSize, + filters: [], + indexPatternId: null, + predecessorCount: 0, + successorCount: 0, + sort: [], + tieBreakerField, + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context_app.html b/src/plugins/discover_legacy/public/application/angular/context_app.html new file mode 100644 index 000000000000..1d3971a41132 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context_app.html @@ -0,0 +1,55 @@ + + + + + + + + +
+ + + + + +
+
+
+ + +
+
+ +
+
+ + + +
\ No newline at end of file diff --git a/src/plugins/discover_legacy/public/application/angular/context_app.js b/src/plugins/discover_legacy/public/application/angular/context_app.js new file mode 100644 index 000000000000..fa487c726612 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context_app.js @@ -0,0 +1,151 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { CONTEXT_STEP_SETTING, CONTEXT_TIE_BREAKER_FIELDS_SETTING } from '../../../common'; +import { getAngularModule, getServices } from '../../opensearch_dashboards_services'; +import contextAppTemplate from './context_app.html'; +import './context/components/action_bar'; +import { getFirstSortableField } from './context/api/utils/sorting'; +import { + createInitialQueryParametersState, + getQueryParameterActions, + QUERY_PARAMETER_KEYS, +} from './context/query_parameters'; +import { + createInitialLoadingStatusState, + FAILURE_REASONS, + LOADING_STATUS, + QueryActionsProvider, +} from './context/query'; +import { callAfterBindingsWorkaround } from './context/helpers/call_after_bindings_workaround'; + +getAngularModule().directive('contextApp', function ContextApp() { + return { + bindToController: true, + controller: callAfterBindingsWorkaround(ContextAppController), + controllerAs: 'contextApp', + restrict: 'E', + scope: { + anchorId: '=', + columns: '=', + indexPattern: '=', + filters: '=', + predecessorCount: '=', + successorCount: '=', + sort: '=', + }, + template: contextAppTemplate, + }; +}); + +function ContextAppController($scope, Private) { + const { filterManager, indexPatterns, uiSettings } = getServices(); + const queryParameterActions = getQueryParameterActions(filterManager, indexPatterns); + const queryActions = Private(QueryActionsProvider); + this.state = createInitialState( + parseInt(uiSettings.get(CONTEXT_STEP_SETTING), 10), + getFirstSortableField(this.indexPattern, uiSettings.get(CONTEXT_TIE_BREAKER_FIELDS_SETTING)) + ); + + this.actions = _.mapValues( + { + ...queryParameterActions, + ...queryActions, + }, + (action) => (...args) => action(this.state)(...args) + ); + + this.constants = { + FAILURE_REASONS, + LOADING_STATUS, + }; + + $scope.$watchGroup( + [ + () => this.state.rows.predecessors, + () => this.state.rows.anchor, + () => this.state.rows.successors, + ], + (newValues) => this.actions.setAllRows(...newValues) + ); + + /** + * Sync properties to state + */ + $scope.$watchCollection( + () => ({ + ..._.pick(this, QUERY_PARAMETER_KEYS), + indexPatternId: this.indexPattern.id, + }), + (newQueryParameters) => { + const { queryParameters } = this.state; + if ( + newQueryParameters.indexPatternId !== queryParameters.indexPatternId || + newQueryParameters.anchorId !== queryParameters.anchorId || + !_.isEqual(newQueryParameters.sort, queryParameters.sort) + ) { + this.actions.fetchAllRowsWithNewQueryParameters(_.cloneDeep(newQueryParameters)); + } else if ( + newQueryParameters.predecessorCount !== queryParameters.predecessorCount || + newQueryParameters.successorCount !== queryParameters.successorCount || + !_.isEqual(newQueryParameters.filters, queryParameters.filters) + ) { + this.actions.fetchContextRowsWithNewQueryParameters(_.cloneDeep(newQueryParameters)); + } + } + ); + + /** + * Sync state to properties + */ + $scope.$watchCollection( + () => ({ + predecessorCount: this.state.queryParameters.predecessorCount, + successorCount: this.state.queryParameters.successorCount, + }), + (newParameters) => { + _.assign(this, newParameters); + } + ); +} + +function createInitialState(defaultStepSize, tieBreakerField) { + return { + queryParameters: createInitialQueryParametersState(defaultStepSize, tieBreakerField), + rows: { + all: [], + anchor: null, + predecessors: [], + successors: [], + }, + loadingStatus: createInitialLoadingStatusState(), + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/context_state.test.ts b/src/plugins/discover_legacy/public/application/angular/context_state.test.ts new file mode 100644 index 000000000000..23d4581a158b --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context_state.test.ts @@ -0,0 +1,204 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { getState } from './context_state'; +import { createBrowserHistory, History } from 'history'; +import { FilterManager, Filter } from '../../../../data/public'; +import { coreMock } from '../../../../../core/public/mocks'; +const setupMock = coreMock.createSetup(); + +describe('Test Discover Context State', () => { + let history: History; + let state: any; + const getCurrentUrl = () => history.createHref(history.location); + beforeEach(async () => { + history = createBrowserHistory(); + history.push('/'); + state = await getState({ + defaultStepSize: '4', + timeFieldName: 'time', + history, + }); + state.startSync(); + }); + afterEach(() => { + state.stopSync(); + }); + test('getState function default return', () => { + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "filters": Array [], + "predecessorCount": 4, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 4, + } + `); + expect(state.globalState.getState()).toMatchInlineSnapshot(`null`); + expect(state.startSync).toBeDefined(); + expect(state.stopSync).toBeDefined(); + expect(state.getFilters()).toStrictEqual([]); + }); + test('getState -> setAppState syncing to url', async () => { + state.setAppState({ predecessorCount: 10 }); + state.flushToUrl(); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_a=(columns:!(_source),filters:!(),predecessorCount:10,sort:!(time,desc),successorCount:4)"` + ); + }); + test('getState -> url to appState syncing', async () => { + history.push( + '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' + ); + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "predecessorCount": 1, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 1, + } + `); + }); + test('getState -> url to appState syncing with return to a url without state', async () => { + history.push( + '/#?_a=(columns:!(_source),predecessorCount:1,sort:!(time,desc),successorCount:1)' + ); + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "predecessorCount": 1, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 1, + } + `); + history.push('/'); + expect(state.appState.getState()).toMatchInlineSnapshot(` + Object { + "columns": Array [ + "_source", + ], + "predecessorCount": 1, + "sort": Array [ + "time", + "desc", + ], + "successorCount": 1, + } + `); + }); + + test('getState -> filters', async () => { + const filterManager = new FilterManager(setupMock.uiSettings); + const filterGlobal = { + query: { match: { extension: { query: 'jpg', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: false, disabled: false, alias: null }, + } as Filter; + filterManager.setGlobalFilters([filterGlobal]); + const filterApp = { + query: { match: { extension: { query: 'png', type: 'phrase' } } }, + meta: { index: 'logstash-*', negate: true, disabled: false, alias: null }, + } as Filter; + filterManager.setAppFilters([filterApp]); + state.setFilters(filterManager); + expect(state.getFilters()).toMatchInlineSnapshot(` + Array [ + Object { + "$state": Object { + "store": "globalState", + }, + "meta": Object { + "alias": null, + "disabled": false, + "index": "logstash-*", + "key": "extension", + "negate": false, + "params": Object { + "query": "jpg", + }, + "type": "phrase", + "value": [Function], + }, + "query": Object { + "match": Object { + "extension": Object { + "query": "jpg", + "type": "phrase", + }, + }, + }, + }, + Object { + "$state": Object { + "store": "appState", + }, + "meta": Object { + "alias": null, + "disabled": false, + "index": "logstash-*", + "key": "extension", + "negate": true, + "params": Object { + "query": "png", + }, + "type": "phrase", + "value": [Function], + }, + "query": Object { + "match": Object { + "extension": Object { + "query": "png", + "type": "phrase", + }, + }, + }, + }, + ] + `); + state.flushToUrl(); + expect(getCurrentUrl()).toMatchInlineSnapshot( + `"/#?_g=(filters:!(('$state':(store:globalState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!f,params:(query:jpg),type:phrase),query:(match:(extension:(query:jpg,type:phrase))))))&_a=(columns:!(_source),filters:!(('$state':(store:appState),meta:(alias:!n,disabled:!f,index:'logstash-*',key:extension,negate:!t,params:(query:png),type:phrase),query:(match:(extension:(query:png,type:phrase))))),predecessorCount:4,sort:!(time,desc),successorCount:4)"` + ); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/context_state.ts b/src/plugins/discover_legacy/public/application/angular/context_state.ts new file mode 100644 index 000000000000..1b19b1d43e78 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/context_state.ts @@ -0,0 +1,307 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { History } from 'history'; +import { NotificationsStart } from 'opensearch-dashboards/public'; +import { + createStateContainer, + createOsdUrlStateStorage, + syncStates, + BaseStateContainer, + withNotifyOnErrors, +} from '../../../../opensearch_dashboards_utils/public'; +import { opensearchFilters, FilterManager, Filter, Query } from '../../../../data/public'; + +export interface AppState { + /** + * Columns displayed in the table, cannot be changed by UI, just in discover's main app + */ + columns: string[]; + /** + * Array of filters + */ + filters: Filter[]; + /** + * Number of records to be fetched before anchor records (newer records) + */ + predecessorCount: number; + /** + * Sorting of the records to be fetched, assumed to be a legacy parameter + */ + sort: string[]; + /** + * Number of records to be fetched after the anchor records (older records) + */ + successorCount: number; + query?: Query; +} + +interface GlobalState { + /** + * Array of filters + */ + filters: Filter[]; +} + +interface GetStateParams { + /** + * Number of records to be fetched when 'Load' link/button is clicked + */ + defaultStepSize: string; + /** + * The timefield used for sorting + */ + timeFieldName: string; + /** + * Determins the use of long vs. short/hashed urls + */ + storeInSessionStorage?: boolean; + /** + * History instance to use + */ + history: History; + + /** + * Core's notifications.toasts service + * In case it is passed in, + * osdUrlStateStorage will use it notifying about inner errors + */ + toasts?: NotificationsStart['toasts']; +} + +interface GetStateReturn { + /** + * Global state, the _g part of the URL + */ + globalState: BaseStateContainer; + /** + * App state, the _a part of the URL + */ + appState: BaseStateContainer; + /** + * Start sync between state and URL + */ + startSync: () => void; + /** + * Stop sync between state and URL + */ + stopSync: () => void; + /** + * Set app state to with a partial new app state + */ + setAppState: (newState: Partial) => void; + /** + * Get all filters, global and app state + */ + getFilters: () => Filter[]; + /** + * Set global state and app state filters by the given FilterManager instance + * @param filterManager + */ + setFilters: (filterManager: FilterManager) => void; + /** + * sync state to URL, used for testing + */ + flushToUrl: (replace?: boolean) => void; +} +const GLOBAL_STATE_URL_KEY = '_g'; +const APP_STATE_URL_KEY = '_a'; + +/** + * Builds and returns appState and globalState containers + * provides helper functions to start/stop syncing with URL + */ +export function getState({ + defaultStepSize, + timeFieldName, + storeInSessionStorage = false, + history, + toasts, +}: GetStateParams): GetStateReturn { + const stateStorage = createOsdUrlStateStorage({ + useHash: storeInSessionStorage, + history, + ...(toasts && withNotifyOnErrors(toasts)), + }); + + const globalStateInitial = stateStorage.get(GLOBAL_STATE_URL_KEY) as GlobalState; + const globalStateContainer = createStateContainer(globalStateInitial); + + const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState; + const appStateInitial = createInitialAppState(defaultStepSize, timeFieldName, appStateFromUrl); + const appStateContainer = createStateContainer(appStateInitial); + + const { start, stop } = syncStates([ + { + storageKey: GLOBAL_STATE_URL_KEY, + stateContainer: { + ...globalStateContainer, + ...{ + set: (value: GlobalState | null) => { + if (value) { + globalStateContainer.set(value); + } + }, + }, + }, + stateStorage, + }, + { + storageKey: APP_STATE_URL_KEY, + stateContainer: { + ...appStateContainer, + ...{ + set: (value: AppState | null) => { + if (value) { + appStateContainer.set(value); + } + }, + }, + }, + stateStorage, + }, + ]); + + return { + globalState: globalStateContainer, + appState: appStateContainer, + startSync: start, + stopSync: stop, + setAppState: (newState: Partial) => { + const oldState = appStateContainer.getState(); + const mergedState = { ...oldState, ...newState }; + + if (!isEqualState(oldState, mergedState)) { + appStateContainer.set(mergedState); + } + }, + getFilters: () => [ + ...getFilters(globalStateContainer.getState()), + ...getFilters(appStateContainer.getState()), + ], + setFilters: (filterManager: FilterManager) => { + // global state filters + const globalFilters = filterManager.getGlobalFilters(); + const globalFilterChanged = !isEqualFilters( + globalFilters, + getFilters(globalStateContainer.getState()) + ); + if (globalFilterChanged) { + globalStateContainer.set({ filters: globalFilters }); + } + // app state filters + const appFilters = filterManager.getAppFilters(); + const appFilterChanged = !isEqualFilters( + appFilters, + getFilters(appStateContainer.getState()) + ); + if (appFilterChanged) { + appStateContainer.set({ ...appStateContainer.getState(), ...{ filters: appFilters } }); + } + }, + // helper function just needed for testing + flushToUrl: (replace?: boolean) => stateStorage.flush({ replace }), + }; +} + +/** + * Helper function to compare 2 different filter states + */ +export function isEqualFilters(filtersA: Filter[], filtersB: Filter[]) { + if (!filtersA && !filtersB) { + return true; + } else if (!filtersA || !filtersB) { + return false; + } + return opensearchFilters.compareFilters( + filtersA, + filtersB, + opensearchFilters.COMPARE_ALL_OPTIONS + ); +} + +/** + * Helper function to compare 2 different states, is needed since comparing filters + * works differently, doesn't work with _.isEqual + */ +function isEqualState(stateA: AppState | GlobalState, stateB: AppState | GlobalState) { + if (!stateA && !stateB) { + return true; + } else if (!stateA || !stateB) { + return false; + } + const { filters: stateAFilters = [], ...stateAPartial } = stateA; + const { filters: stateBFilters = [], ...stateBPartial } = stateB; + return ( + _.isEqual(stateAPartial, stateBPartial) && + opensearchFilters.compareFilters( + stateAFilters, + stateBFilters, + opensearchFilters.COMPARE_ALL_OPTIONS + ) + ); +} + +/** + * Helper function to return array of filter object of a given state + */ +function getFilters(state: AppState | GlobalState): Filter[] { + if (!state || !Array.isArray(state.filters)) { + return []; + } + return state.filters; +} + +/** + * Helper function to return the initial app state, which is a merged object of url state and + * default state. The default size is the default number of successor/predecessor records to fetch + */ +function createInitialAppState( + defaultSize: string, + timeFieldName: string, + urlState: AppState +): AppState { + const defaultState = { + columns: ['_source'], + filters: [], + predecessorCount: parseInt(defaultSize, 10), + sort: [timeFieldName, 'desc'], + successorCount: parseInt(defaultSize, 10), + }; + if (typeof urlState !== 'object') { + return defaultState; + } + + return { + ...defaultState, + ...urlState, + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap b/src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap new file mode 100644 index 000000000000..f2abb590008c --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/__snapshots__/no_results.test.js.snap @@ -0,0 +1,230 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DiscoverNoResults props queryLanguage supports lucene and renders doc link 1`] = ` +Array [ +
, +
+
+
+
+
+
+
+
+

+ Refine your query +

+

+ The search bar at the top uses OpenSearch’s support for Lucene + + Query String syntax + + + (opens in a new tab or window) + + + . Here are some examples of how you can search for web server logs that have been parsed into a few fields. +

+
+
+
+
+
+ + Find requests that contain the number 200, in any field + +
+
+
+ + + 200 + + +
+
+
+ + Find 200 in the status field + +
+
+
+ + + status:200 + + +
+
+
+ + Find all status codes between 400-499 + +
+
+
+ + + status:[400 TO 499] + + +
+
+
+ + Find status codes 400-499 with the extension php + +
+
+
+ + + status:[400 TO 499] AND extension:PHP + + +
+
+
+ + Find status codes 400-499 with the extension php or html + +
+
+
+ + + status:[400 TO 499] AND (extension:php OR extension:html) + + +
+
+
+
+
, +] +`; + +exports[`DiscoverNoResults props timeFieldName renders time range feedback 1`] = ` +Array [ +
, +
+
+
+
+
+
+
+
+

+ Expand your time range +

+

+ One or more of the indices you’re looking at contains a date field. Your query may not match anything in the current time range, or there may not be any data at all in the currently selected time range. You can try changing the time range to one which contains data. +

+
+
+
, +] +`; diff --git a/src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss b/src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss new file mode 100644 index 000000000000..1e625fa064e2 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/_histogram.scss @@ -0,0 +1,11 @@ +.dscHistogram__header--partial { + font-weight: $euiFontWeightRegular; + min-width: $euiSize * 12; +} + +// Temporary override to inlined styles provided by ElasticCharts theming +// Will be unnecessary when we migrate the histogram to a different rendering library: +// https: //github.com/opensearch-project/OpenSearch-Dashboards/issues/4643 +.dscHistogram .echChartBackground { + background-color: inherit !important; +} diff --git a/src/plugins/discover_legacy/public/application/angular/directives/_index.scss b/src/plugins/discover_legacy/public/application/angular/directives/_index.scss new file mode 100644 index 000000000000..01f5bbb6fd57 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/_index.scss @@ -0,0 +1,2 @@ +@import "no_results"; +@import "histogram"; diff --git a/src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss b/src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss new file mode 100644 index 000000000000..7ea945e820bf --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/_no_results.scss @@ -0,0 +1,3 @@ +.dscNoResults { + max-width: 1000px; +} diff --git a/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js new file mode 100644 index 000000000000..da0f4893b909 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.js @@ -0,0 +1,79 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +// Debounce service, angularized version of lodash debounce +// borrowed heavily from https://github.com/shahata/angular-debounce + +export function createDebounceProviderTimeout($timeout) { + return function (func, wait, options) { + let timeout; + let args; + let self; + let result; + options = _.defaults(options || {}, { + leading: false, + trailing: true, + invokeApply: true, + }); + + function debounce() { + self = this; + args = arguments; + + const later = function () { + timeout = null; + if (!options.leading || options.trailing) { + result = func.apply(self, args); + } + }; + + const callNow = options.leading && !timeout; + + if (timeout) { + $timeout.cancel(timeout); + } + timeout = $timeout(later, wait, options.invokeApply); + + if (callNow) { + result = func.apply(self, args); + } + + return result; + } + + debounce.cancel = function () { + $timeout.cancel(timeout); + timeout = null; + }; + + return debounce; + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts new file mode 100644 index 000000000000..635ed560df40 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/debounce/debounce.test.ts @@ -0,0 +1,148 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import sinon, { SinonSpy } from 'sinon'; +import angular, { auto, ITimeoutService } from 'angular'; +import 'angular-mocks'; +import 'angular-sanitize'; +import 'angular-route'; + +// @ts-ignore +import { createDebounceProviderTimeout } from './debounce'; +import { coreMock } from '../../../../../../../core/public/mocks'; +import { initializeInnerAngularModule } from '../../../../get_inner_angular'; +import { navigationPluginMock } from '../../../../../../navigation/public/mocks'; +import { dataPluginMock } from '../../../../../../data/public/mocks'; +import { initAngularBootstrap } from '../../../../../../opensearch_dashboards_legacy/public'; + +describe('debounce service', function () { + let debounce: (fn: () => void, timeout: number, options?: any) => any; + let $timeout: ITimeoutService; + let spy: SinonSpy; + + beforeEach(() => { + spy = sinon.spy(); + + initAngularBootstrap(); + + initializeInnerAngularModule( + 'app/discover', + coreMock.createStart(), + navigationPluginMock.createStartContract(), + dataPluginMock.createStartContract() + ); + + angular.mock.module('app/discover'); + + angular.mock.inject(($injector: auto.IInjectorService, _$timeout_: ITimeoutService) => { + $timeout = _$timeout_; + + debounce = createDebounceProviderTimeout($timeout); + }); + }); + + it('should have a cancel method', function () { + const bouncer = debounce(() => {}, 100); + + expect(bouncer).toHaveProperty('cancel'); + }); + + describe('delayed execution', function () { + const sandbox = sinon.createSandbox(); + + beforeEach(() => sandbox.useFakeTimers()); + afterEach(() => sandbox.restore()); + + it('should delay execution', function () { + const bouncer = debounce(spy, 100); + + bouncer(); + sinon.assert.notCalled(spy); + $timeout.flush(); + sinon.assert.calledOnce(spy); + + spy.resetHistory(); + }); + + it('should fire on leading edge', function () { + const bouncer = debounce(spy, 100, { leading: true }); + + bouncer(); + sinon.assert.calledOnce(spy); + $timeout.flush(); + sinon.assert.calledTwice(spy); + + spy.resetHistory(); + }); + + it('should only fire on leading edge', function () { + const bouncer = debounce(spy, 100, { leading: true, trailing: false }); + + bouncer(); + sinon.assert.calledOnce(spy); + $timeout.flush(); + sinon.assert.calledOnce(spy); + + spy.resetHistory(); + }); + + it('should reset delayed execution', function () { + const cancelSpy = sinon.spy($timeout, 'cancel'); + const bouncer = debounce(spy, 100); + + bouncer(); + sandbox.clock.tick(1); + + bouncer(); + sinon.assert.notCalled(spy); + $timeout.flush(); + sinon.assert.calledOnce(spy); + sinon.assert.calledOnce(cancelSpy); + + spy.resetHistory(); + cancelSpy.resetHistory(); + }); + }); + + describe('cancel', function () { + it('should cancel the $timeout', function () { + const cancelSpy = sinon.spy($timeout, 'cancel'); + const bouncer = debounce(spy, 100); + + bouncer(); + bouncer.cancel(); + sinon.assert.calledOnce(cancelSpy); + // throws if pending timeouts + $timeout.verifyNoPendingTasks(); + + cancelSpy.resetHistory(); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js b/src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js new file mode 100644 index 000000000000..6acd0d680d4d --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/debounce/index.js @@ -0,0 +1,31 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { createDebounceProviderTimeout } from './debounce'; diff --git a/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js new file mode 100644 index 000000000000..6b80caaa1f71 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.js @@ -0,0 +1,166 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import $ from 'jquery'; +import _ from 'lodash'; +import { createDebounceProviderTimeout } from './debounce'; + +const SCROLLER_HEIGHT = 20; + +/** + * This directive adds a fixed horizontal scrollbar to the bottom of the window that proxies its scroll events + * to the target element's real scrollbar. This is useful when the target element's horizontal scrollbar + * might be waaaay down the page, like the doc table on Discover. + */ +export function FixedScrollProvider($timeout) { + return { + restrict: 'A', + link: function ($scope, $el) { + return createFixedScroll($scope, $timeout)($el); + }, + }; +} + +export function createFixedScroll($scope, $timeout) { + const debounce = createDebounceProviderTimeout($timeout); + return function (el) { + const $el = typeof el.css === 'function' ? el : $(el); + let $window = $(window); + let $scroller = $('
').height(SCROLLER_HEIGHT); + + /** + * Remove the listeners bound in listen() + * @type {function} + */ + let unlisten = _.noop; + + /** + * Listen for scroll events on the $scroller and the $el, sets unlisten() + * + * unlisten must be called before calling or listen() will throw an Error + * + * Since the browser emits "scroll" events after setting scrollLeft + * the listeners also prevent tug-of-war + * + * @throws {Error} If unlisten was not called first + * @return {undefined} + */ + function listen() { + if (unlisten !== _.noop) { + throw new Error('fixedScroll listeners were not cleaned up properly before re-listening!'); + } + + let blockTo; + function bind($from, $to) { + function handler() { + if (blockTo === $to) return (blockTo = null); + $to.scrollLeft((blockTo = $from).scrollLeft()); + } + + $from.on('scroll', handler); + return function () { + $from.off('scroll', handler); + }; + } + + unlisten = _.flow(bind($el, $scroller), bind($scroller, $el), function () { + unlisten = _.noop; + }); + } + + /** + * Revert DOM changes and event listeners + * @return {undefined} + */ + function cleanUp() { + unlisten(); + $scroller.detach(); + $el.css('padding-bottom', 0); + } + + /** + * Modify the DOM and attach event listeners based on need. + * Is called many times to re-setup, must be idempotent + * @return {undefined} + */ + function setup() { + cleanUp(); + + const containerWidth = $el.width(); + const contentWidth = $el.prop('scrollWidth'); + const containerHorizOverflow = contentWidth - containerWidth; + + const elTop = $el.offset().top - $window.scrollTop(); + const elBottom = elTop + $el.height(); + const windowVertOverflow = elBottom - $window.height(); + + const requireScroller = containerHorizOverflow > 0 && windowVertOverflow > 0; + if (!requireScroller) return; + + // push the content away from the scroller + $el.css('padding-bottom', SCROLLER_HEIGHT); + + // fill the scroller with a dummy element that mimics the content + $scroller + .width(containerWidth) + .html($('
').css({ width: contentWidth, height: SCROLLER_HEIGHT })) + .insertAfter($el); + + // listen for scroll events + listen(); + } + + let width; + let scrollWidth; + function checkWidth() { + const newScrollWidth = $el.prop('scrollWidth'); + const newWidth = $el.width(); + + if (scrollWidth !== newScrollWidth || width !== newWidth) { + $scope.$apply(setup); + + scrollWidth = newScrollWidth; + width = newWidth; + } + } + + const debouncedCheckWidth = debounce(checkWidth, 100, { + invokeApply: false, + }); + $scope.$watch(debouncedCheckWidth); + + function destroy() { + cleanUp(); + debouncedCheckWidth.cancel(); + $scroller = $window = null; + } + return destroy; + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.test.js b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.test.js new file mode 100644 index 000000000000..cf0e6d9f331c --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/fixed_scroll.test.js @@ -0,0 +1,274 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 angular from 'angular'; +import 'angular-mocks'; +import $ from 'jquery'; + +import sinon from 'sinon'; + +import { initAngularBootstrap } from '../../../../../opensearch_dashboards_legacy/public'; +import { FixedScrollProvider } from './fixed_scroll'; + +const testModuleName = 'fixedScroll'; + +angular.module(testModuleName, []).directive('fixedScroll', FixedScrollProvider); + +describe('FixedScroll directive', function () { + const sandbox = sinon.createSandbox(); + let mockWidth; + let mockHeight; + let currentWidth = 120; + let currentHeight = 120; + let currentJqLiteWidth = 120; + let spyScrollWidth; + + let compile; + let flushPendingTasks; + const trash = []; + + beforeAll(() => { + mockWidth = jest.spyOn($.prototype, 'width').mockImplementation(function (width) { + if (width === undefined) { + return currentWidth; + } else { + currentWidth = width; + return this; + } + }); + mockHeight = jest.spyOn($.prototype, 'height').mockImplementation(function (height) { + if (height === undefined) { + return currentHeight; + } else { + currentHeight = height; + return this; + } + }); + angular.element.prototype.width = jest.fn(function (width) { + if (width === undefined) { + return currentJqLiteWidth; + } else { + currentJqLiteWidth = width; + return this; + } + }); + angular.element.prototype.offset = jest.fn(() => ({ top: 0 })); + }); + + beforeEach(() => { + currentJqLiteWidth = 120; + initAngularBootstrap(); + + angular.mock.module(testModuleName); + angular.mock.inject(($compile, $rootScope, $timeout) => { + flushPendingTasks = function flushPendingTasks() { + $rootScope.$digest(); + $timeout.flush(); + }; + + compile = function (ratioY, ratioX) { + if (ratioX == null) ratioX = ratioY; + + // since the directive works at the sibling level we create a + // parent for everything to happen in + const $parent = $('
').css({ + position: 'fixed', + top: 0, + left: 0, + right: 0, + bottom: 0, + }); + + $parent.appendTo(document.body); + trash.push($parent); + + const $el = $('
') + .css({ + 'overflow-x': 'auto', + width: $parent.width(), + }) + .appendTo($parent); + + spyScrollWidth = jest.spyOn(window.HTMLElement.prototype, 'scrollWidth', 'get'); + spyScrollWidth.mockReturnValue($parent.width() * ratioX); + angular.element.prototype.height = jest.fn(() => $parent.height() * ratioY); + + const $content = $('
') + .css({ + width: $parent.width() * ratioX, + height: $parent.height() * ratioY, + }) + .appendTo($el); + + $compile($parent)($rootScope); + flushPendingTasks(); + + return { + $container: $el, + $content: $content, + $scroller: $parent.find('.dscTableFixedScroll__scroller'), + }; + }; + }); + }); + + afterEach(function () { + trash.splice(0).forEach(function ($el) { + $el.remove(); + }); + + sandbox.restore(); + spyScrollWidth.mockRestore(); + }); + + afterAll(() => { + mockWidth.mockRestore(); + mockHeight.mockRestore(); + delete angular.element.prototype.width; + delete angular.element.prototype.height; + delete angular.element.prototype.offset; + }); + + test('does nothing when not needed', function () { + let els = compile(0.5, 1.5); + expect(els.$scroller).toHaveLength(0); + + els = compile(1.5, 0.5); + expect(els.$scroller).toHaveLength(0); + }); + + test('attaches a scroller below the element when the content is larger then the container', function () { + const els = compile(1.5); + expect(els.$scroller.length).toBe(1); + }); + + test('copies the width of the container', function () { + const els = compile(1.5); + expect(els.$scroller.width()).toBe(els.$container.width()); + }); + + test('mimics the scrollWidth of the element', function () { + const els = compile(1.5); + expect(els.$scroller.prop('scrollWidth')).toBe(els.$container.prop('scrollWidth')); + }); + + describe('scroll event handling / tug of war prevention', function () { + test('listens when needed, unlistens when not needed', function (done) { + const on = sandbox.spy($.fn, 'on'); + const off = sandbox.spy($.fn, 'off'); + const jqLiteOn = sandbox.spy(angular.element.prototype, 'on'); + const jqLiteOff = sandbox.spy(angular.element.prototype, 'off'); + + const els = compile(1.5); + expect(on.callCount).toBe(1); + expect(jqLiteOn.callCount).toBe(1); + checkThisVals('$.fn.on', on, jqLiteOn); + + expect(off.callCount).toBe(0); + expect(jqLiteOff.callCount).toBe(0); + currentJqLiteWidth = els.$container.prop('scrollWidth'); + flushPendingTasks(); + expect(off.callCount).toBe(1); + expect(jqLiteOff.callCount).toBe(1); + checkThisVals('$.fn.off', off, jqLiteOff); + done(); + + function checkThisVals(namejQueryFn, spyjQueryFn, spyjqLiteFn) { + // the this values should be different + expect(spyjQueryFn.thisValues[0].is(spyjqLiteFn.thisValues[0])).toBeFalsy(); + // but they should be either $scroller or $container + const el = spyjQueryFn.thisValues[0]; + + if (el.is(els.$scroller) || el.is(els.$container)) return; + + done.fail('expected ' + namejQueryFn + ' to be called with $scroller or $container'); + } + }); + + // Turn off this row because tests failed. + // Scroll event is not catched in fixed_scroll. + // As container is jquery element in test but inside fixed_scroll it's a jqLite element. + // it would need jquery in jest to make this work. + [ + //{ from: '$container', to: '$scroller' }, + { from: '$scroller', to: '$container' }, + ].forEach(function (names) { + describe('scroll events ' + JSON.stringify(names), function () { + let spyJQueryScrollLeft; + let spyJQLiteScrollLeft; + let els; + let $from; + let $to; + + beforeEach(function () { + spyJQueryScrollLeft = sandbox.spy($.fn, 'scrollLeft'); + spyJQLiteScrollLeft = sandbox.stub(); + angular.element.prototype.scrollLeft = spyJQLiteScrollLeft; + els = compile(1.5); + $from = els[names.from]; + $to = els[names.to]; + }); + + test('transfers the scrollLeft', function () { + expect(spyJQueryScrollLeft.callCount).toBe(0); + expect(spyJQLiteScrollLeft.callCount).toBe(0); + $from.scroll(); + expect(spyJQueryScrollLeft.callCount).toBe(1); + expect(spyJQLiteScrollLeft.callCount).toBe(1); + + // first call should read the scrollLeft from the $container + const firstCall = spyJQueryScrollLeft.getCall(0); + expect(firstCall.args).toEqual([]); + + // second call should be setting the scrollLeft on the $scroller + const secondCall = spyJQLiteScrollLeft.getCall(0); + expect(secondCall.args).toEqual([firstCall.returnValue]); + }); + + /** + * In practice, calling $el.scrollLeft() causes the "scroll" event to trigger, + * but the browser seems to be very careful about triggering the event too much + * and I can't reliably recreate the browsers behavior in a test. So... faking it! + */ + test('prevents tug of war by ignoring echo scroll events', function () { + $from.scroll(); + expect(spyJQueryScrollLeft.callCount).toBe(1); + expect(spyJQLiteScrollLeft.callCount).toBe(1); + + spyJQueryScrollLeft.resetHistory(); + spyJQLiteScrollLeft.resetHistory(); + $to.scroll(); + expect(spyJQueryScrollLeft.callCount).toBe(0); + expect(spyJQLiteScrollLeft.callCount).toBe(0); + }); + }); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/directives/histogram.tsx b/src/plugins/discover_legacy/public/application/angular/directives/histogram.tsx new file mode 100644 index 000000000000..4c1263192322 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/histogram.tsx @@ -0,0 +1,362 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { getServices } from '../../../opensearch_dashboards_services'; +import { Chart as IChart } from '../helpers/point_series'; + +export interface DiscoverHistogramProps { + chartData: IChart; + timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; +} + +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; + public state = { + chartsTheme: getServices().theme.chartsDefaultTheme, + chartsBaseTheme: getServices().theme.chartsDefaultBaseTheme, + }; + + componentDidMount() { + this.subscription = combineLatest( + getServices().theme.chartsTheme$, + getServices().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 uiSettings = getServices().uiSettings; + const timeZone = getTimezone(uiSettings); + const { chartData } = this.props; + const { chartsTheme, chartsBaseTheme } = this.state; + + 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_legacy/public/application/angular/directives/index.js b/src/plugins/discover_legacy/public/application/angular/directives/index.js new file mode 100644 index 000000000000..d81152164c42 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/index.js @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { DiscoverNoResults } from './no_results'; +import { DiscoverUninitialized } from './uninitialized'; +import { DiscoverHistogram } from './histogram'; +import { getAngularModule } from '../../../opensearch_dashboards_services'; + +const app = getAngularModule(); + +app.directive('discoverNoResults', (reactDirective) => reactDirective(DiscoverNoResults)); + +app.directive('discoverUninitialized', (reactDirective) => reactDirective(DiscoverUninitialized)); + +app.directive('discoverHistogram', (reactDirective) => reactDirective(DiscoverHistogram)); diff --git a/src/plugins/discover_legacy/public/application/angular/directives/no_results.js b/src/plugins/discover_legacy/public/application/angular/directives/no_results.js new file mode 100644 index 000000000000..1495296e9dbb --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/no_results.js @@ -0,0 +1,225 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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, { Component, Fragment } from 'react'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import PropTypes from 'prop-types'; + +import { + EuiCallOut, + EuiCode, + EuiDescriptionList, + EuiFlexGroup, + EuiFlexItem, + EuiLink, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { getServices } from '../../../opensearch_dashboards_services'; + +// eslint-disable-next-line react/prefer-stateless-function +export class DiscoverNoResults extends Component { + static propTypes = { + timeFieldName: PropTypes.string, + queryLanguage: PropTypes.string, + }; + + render() { + const { timeFieldName, queryLanguage } = this.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_legacy/public/application/angular/directives/no_results.test.js b/src/plugins/discover_legacy/public/application/angular/directives/no_results.test.js new file mode 100644 index 000000000000..e6b1c6b73eb3 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/no_results.test.js @@ -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 React from 'react'; +import { renderWithIntl } from 'test_utils/enzyme_helpers'; + +import { DiscoverNoResults } from './no_results'; + +jest.mock('../../../opensearch_dashboards_services', () => { + return { + getServices: () => ({ + docLinks: { + links: { + opensearch: { + queryDSL: { + base: 'documentation-link', + }, + }, + }, + }, + }), + }; +}); + +beforeEach(() => { + jest.clearAllMocks(); +}); + +describe('DiscoverNoResults', () => { + describe('props', () => { + describe('timeFieldName', () => { + test('renders time range feedback', () => { + const component = renderWithIntl(); + + expect(component).toMatchSnapshot(); + }); + }); + + describe('queryLanguage', () => { + test('supports lucene and renders doc link', () => { + const component = renderWithIntl( + 'documentation-link'} /> + ); + + expect(component).toMatchSnapshot(); + }); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/directives/render_complete.ts b/src/plugins/discover_legacy/public/application/angular/directives/render_complete.ts new file mode 100644 index 000000000000..4409910a8e5c --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/render_complete.ts @@ -0,0 +1,42 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { IScope } from 'angular'; +import { RenderCompleteListener } from '../../../../../opensearch_dashboards_utils/public'; + +export function createRenderCompleteDirective() { + return { + controller($scope: IScope, $element: JQLite) { + const el = $element[0]; + const renderCompleteListener = new RenderCompleteListener(el); + $scope.$on('$destroy', renderCompleteListener.destroy); + }, + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx b/src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx new file mode 100644 index 000000000000..9cc47b034d1e --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/directives/uninitialized.tsx @@ -0,0 +1,78 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; + +import { EuiButton, EuiEmptyPrompt, EuiPage, EuiPageBody, EuiPageContent } from '@elastic/eui'; + +interface Props { + onRefresh: () => void; +} + +export const DiscoverUninitialized = ({ onRefresh }: Props) => { + return ( + + + + + + + + } + body={ +

+ +

+ } + actions={ + + + + } + /> +
+
+
+
+ ); +}; diff --git a/src/plugins/discover_legacy/public/application/angular/discover.js b/src/plugins/discover_legacy/public/application/angular/discover.js new file mode 100644 index 000000000000..f8a96928784c --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/discover.js @@ -0,0 +1,1156 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 React from 'react'; +import { Subscription, Subject, merge } from 'rxjs'; +import { debounceTime } from 'rxjs/operators'; +import moment from 'moment'; +import dateMath from '@elastic/datemath'; +import { i18n } from '@osd/i18n'; +import { getState, splitState } from './discover_state'; + +import { RequestAdapter } from '../../../../inspector/public'; +import { + opensearchFilters, + indexPatterns as indexPatternsUtils, + connectToQueryState, + syncQueryStateWithUrl, +} from '../../../../data/public'; +import { SavedObjectSaveModal, showSaveModal } from '../../../../saved_objects/public'; +import { getSortArray, getSortForSearchSource } from './doc_table'; +import { createFixedScroll } from './directives/fixed_scroll'; +import * as columnActions from './doc_table/actions/columns'; +import indexTemplateLegacy from './discover_legacy.html'; +import { showOpenSearchPanel } from '../components/top_nav/show_open_search_panel'; +import { addHelpMenuToAppChrome } from '../components/help_menu/help_menu_util'; +import { discoverResponseHandler } from './response_handler'; +import { + getRequestInspectorStats, + getResponseInspectorStats, + getServices, + getHeaderActionMenuMounter, + getUrlTracker, + unhashUrl, + subscribeWithScope, + tabifyAggResponse, + getAngularModule, + redirectWhenMissing, +} from '../../opensearch_dashboards_services'; + +const { + core, + chrome, + data, + history: getHistory, + indexPatterns, + filterManager, + share, + timefilter, + toastNotifications, + uiSettings: config, + visualizations, +} = getServices(); + +import { getRootBreadcrumbs, getSavedSearchBreadcrumbs } from '../helpers/breadcrumbs'; +import { validateTimeRange } from '../helpers/validate_time_range'; +import { popularizeField } from '../helpers/popularize_field'; +import { getSwitchIndexPatternAppState } from '../helpers/get_switch_index_pattern_app_state'; +import { getIndexPatternId } from '../helpers/get_index_pattern_id'; +import { addFatalError } from '../../../../opensearch_dashboards_legacy/public'; +import { + DEFAULT_COLUMNS_SETTING, + SAMPLE_SIZE_SETTING, + SORT_DEFAULT_ORDER_SETTING, + SEARCH_ON_PAGE_LOAD_SETTING, + DOC_HIDE_TIME_COLUMN_SETTING, + MODIFY_COLUMNS_ON_SWITCH, +} from '../../../common'; +import { NEW_DISCOVER_APP } from '../../../../discover/public'; + +const fetchStatuses = { + UNINITIALIZED: 'uninitialized', + LOADING: 'loading', + COMPLETE: 'complete', +}; + +const app = getAngularModule(); + +app.config(($routeProvider) => { + const defaults = { + requireDefaultIndex: true, + requireUICapability: 'discover.show', + k7Breadcrumbs: ($route, $injector) => + $injector.invoke($route.current.params.id ? getSavedSearchBreadcrumbs : getRootBreadcrumbs), + badge: (uiCapabilities) => { + if (uiCapabilities.discover.save) { + return undefined; + } + + return { + text: i18n.translate('discover.badge.readOnly.text', { + defaultMessage: 'Read only', + }), + tooltip: i18n.translate('discover.badge.readOnly.tooltip', { + defaultMessage: 'Unable to save searches', + }), + iconType: 'glasses', + }; + }, + }; + const discoverRoute = { + ...defaults, + template: indexTemplateLegacy, + reloadOnSearch: false, + resolve: { + savedObjects: function ($route, Promise) { + const history = getHistory(); + const savedSearchId = $route.current.params.id; + return data.indexPatterns.ensureDefaultIndexPattern(history).then(() => { + const { appStateContainer } = getState({ history }); + const { index } = appStateContainer.getState(); + return Promise.props({ + ip: indexPatterns.getCache().then((indexPatternList) => { + /** + * In making the indexPattern modifiable it was placed in appState. Unfortunately, + * the load order of AppState conflicts with the load order of many other things + * so in order to get the name of the index we should use, and to switch to the + * default if necessary, we parse the appState with a temporary State object and + * then destroy it immediatly after we're done + * + * @type {State} + */ + const id = getIndexPatternId(index, indexPatternList, config.get('defaultIndex')); + return Promise.props({ + list: indexPatternList, + loaded: indexPatterns.get(id), + stateVal: index, + stateValFound: !!index && id === index, + }); + }), + savedSearch: getServices() + .getSavedSearchById(savedSearchId) + .then((savedSearch) => { + if (savedSearchId) { + chrome.recentlyAccessed.add( + savedSearch.getFullPath(), + savedSearch.title, + savedSearchId + ); + } + return savedSearch; + }) + .catch( + redirectWhenMissing({ + history, + navigateToApp: core.application.navigateToApp, + mapping: { + search: '/', + 'index-pattern': { + app: 'management', + path: `opensearch-dashboards/objects/savedSearches/${$route.current.params.id}`, + }, + }, + toastNotifications, + onBeforeRedirect() { + getUrlTracker().setTrackedUrl('/'); + }, + }) + ), + }); + }); + }, + }, + }; + + $routeProvider.when('/view/:id?', discoverRoute); + $routeProvider.when('/', discoverRoute); +}); + +app.directive('discoverApp', function () { + return { + restrict: 'E', + controllerAs: 'discoverApp', + controller: discoverController, + }; +}); + +function discoverController($element, $route, $scope, $timeout, $window, Promise, uiCapabilities) { + const { isDefault: isDefaultType } = indexPatternsUtils; + const subscriptions = new Subscription(); + const refetch$ = new Subject(); + let inspectorRequest; + const savedSearch = $route.current.locals.savedObjects.savedSearch; + $scope.searchSource = savedSearch.searchSource; + $scope.indexPattern = resolveIndexPatternLoading(); + //used for functional testing + $scope.fetchCounter = 0; + + const getTimeField = () => { + return isDefaultType($scope.indexPattern) ? $scope.indexPattern.timeFieldName : undefined; + }; + + const history = getHistory(); + + const { + appStateContainer, + startSync: startStateSync, + stopSync: stopStateSync, + setAppState, + replaceUrlAppState, + isAppStateDirty, + osdUrlStateStorage, + getPreviousAppState, + resetInitialAppState, + } = getState({ + defaultAppState: getStateDefaults(), + storeInSessionStorage: config.get('state:storeInSessionStorage'), + history, + toasts: core.notifications.toasts, + }); + if (appStateContainer.getState().index !== $scope.indexPattern.id) { + //used index pattern is different than the given by url/state which is invalid + setAppState({ index: $scope.indexPattern.id }); + } + $scope.state = { ...appStateContainer.getState() }; + + // syncs `_g` portion of url with query services + const { stop: stopSyncingGlobalStateWithUrl } = syncQueryStateWithUrl( + data.query, + osdUrlStateStorage + ); + + // sync initial app filters from state to filterManager + filterManager.setAppFilters(_.cloneDeep(appStateContainer.getState().filters)); + data.query.queryString.setQuery(appStateContainer.getState().query); + + const stopSyncingQueryAppStateWithStateContainer = connectToQueryState( + data.query, + appStateContainer, + { + filters: opensearchFilters.FilterStateStore.APP_STATE, + query: true, + } + ); + + const appStateUnsubscribe = appStateContainer.subscribe(async (newState) => { + const { state: newStatePartial } = splitState(newState); + const { state: oldStatePartial } = splitState(getPreviousAppState()); + + if (!_.isEqual(newStatePartial, oldStatePartial)) { + $scope.$evalAsync(async () => { + if (oldStatePartial.index !== newStatePartial.index) { + //in case of index switch the route has currently to be reloaded, legacy + return; + } + + $scope.state = { ...newState }; + + // detect changes that should trigger fetching of new data + const changes = ['interval', 'sort'].filter( + (prop) => !_.isEqual(newStatePartial[prop], oldStatePartial[prop]) + ); + + if (changes.length) { + refetch$.next(); + } + }); + } + }); + + // this listener is waiting for such a path http://localhost:5601/app/discover#/ + // which could be set through pressing "New" button in top nav or go to "Discover" plugin from the sidebar + // to reload the page in a right way + const unlistenHistoryBasePath = history.listen(({ pathname, search, hash }) => { + if (!search && !hash && pathname === '/') { + $route.reload(); + } + }); + + $scope.setIndexPattern = async (id) => { + const nextIndexPattern = await indexPatterns.get(id); + if (nextIndexPattern) { + const nextAppState = getSwitchIndexPatternAppState( + $scope.indexPattern, + nextIndexPattern, + $scope.state.columns, + $scope.state.sort, + config.get(MODIFY_COLUMNS_ON_SWITCH) + ); + await replaceUrlAppState(nextAppState); + $route.reload(); + } + }; + + // update data source when filters update + subscriptions.add( + subscribeWithScope( + $scope, + filterManager.getUpdates$(), + { + next: () => { + $scope.state.filters = filterManager.getAppFilters(); + $scope.updateDataSource(); + }, + }, + (error) => addFatalError(core.fatalErrors, error) + ) + ); + + const inspectorAdapters = { + requests: new RequestAdapter(), + }; + + $scope.timefilterUpdateHandler = (ranges) => { + timefilter.setTime({ + from: moment(ranges.from).toISOString(), + to: moment(ranges.to).toISOString(), + mode: 'absolute', + }); + }; + $scope.minimumVisibleRows = 50; + $scope.fetchStatus = fetchStatuses.UNINITIALIZED; + $scope.showSaveQuery = uiCapabilities.discover.saveQuery; + + let abortController; + $scope.$on('$destroy', () => { + if (abortController) abortController.abort(); + savedSearch.destroy(); + subscriptions.unsubscribe(); + appStateUnsubscribe(); + stopStateSync(); + stopSyncingGlobalStateWithUrl(); + stopSyncingQueryAppStateWithStateContainer(); + unlistenHistoryBasePath(); + }); + + const getTopNavLinks = () => { + const newSearch = { + id: 'new', + label: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { + defaultMessage: 'New', + }), + description: i18n.translate('discover.localMenu.newSearchDescription', { + defaultMessage: 'New Search', + }), + run: function () { + $scope.$evalAsync(() => { + history.push('/'); + }); + }, + testId: 'discoverNewButton', + }; + + const saveSearch = { + 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 = ({ + newTitle, + newCopyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }) => { + const currentTitle = savedSearch.title; + savedSearch.title = newTitle; + savedSearch.copyOnSave = newCopyOnSave; + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + return saveDataSource(saveOptions).then((response) => { + // If the save wasn't successful, put the original values back. + if (!response.id || response.error) { + savedSearch.title = currentTitle; + } else { + resetInitialAppState(); + } + return response; + }); + }; + + 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, + }); + }, + }; + + const shareSearch = { + 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 sharingData = await this.getSharingData(); + share.toggleShareContextMenu({ + anchorElement, + allowEmbed: false, + allowShortUrl: uiCapabilities.discover.createShortUrl, + shareableUrl: unhashUrl(window.location.href), + objectId: savedSearch.id, + objectType: 'search', + sharingData: { + ...sharingData, + title: savedSearch.title, + }, + isDirty: !savedSearch.id || isAppStateDirty(), + }); + }, + }; + + 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() { + getServices().inspector.open(inspectorAdapters, { + title: savedSearch.title, + }); + }, + }; + + 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, + shareSearch, + inspectSearch, + ]; + }; + $scope.topNavMenu = getTopNavLinks(); + + $scope.searchSource + .setField('index', $scope.indexPattern) + .setField('highlightAll', true) + .setField('version', true); + + // Even when searching rollups, we want to use the default strategy so that we get back a + // document-like response. + $scope.searchSource.setPreferredSearchStrategyId('default'); + + // searchSource which applies time range + const timeRangeSearchSource = savedSearch.searchSource.create(); + + if (isDefaultType($scope.indexPattern)) { + timeRangeSearchSource.setField('filter', () => { + return timefilter.createFilter($scope.indexPattern); + }); + } + + $scope.searchSource.setParent(timeRangeSearchSource); + + const pageTitleSuffix = savedSearch.id && savedSearch.title ? `: ${savedSearch.title}` : ''; + chrome.docTitle.change(`Discover${pageTitleSuffix}`); + const discoverBreadcrumbsTitle = i18n.translate('discover.discoverBreadcrumbTitle', { + defaultMessage: 'Discover', + }); + + if (savedSearch.id && savedSearch.title) { + chrome.setBreadcrumbs([ + { + text: discoverBreadcrumbsTitle, + href: '#/', + }, + { text: savedSearch.title }, + ]); + } else { + chrome.setBreadcrumbs([ + { + text: discoverBreadcrumbsTitle, + }, + ]); + } + + 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, timeFieldName, hideTimeColumn) => { + if (selectedFields.length === 1 && selectedFields[0] === '_source') { + const fieldCounts = await getFieldCounts(); + return { + searchFields: null, + selectFields: _.keys(fieldCounts).sort(), + }; + } + + const fields = + timeFieldName && !hideTimeColumn ? [timeFieldName, ...selectedFields] : selectedFields; + return { + searchFields: fields, + selectFields: fields, + }; + }; + + this.getSharingData = async () => { + const searchSource = $scope.searchSource.createCopy(); + + const { searchFields, selectFields } = await getSharingDataFields( + $scope.state.columns, + $scope.indexPattern.timeFieldName, + config.get(DOC_HIDE_TIME_COLUMN_SETTING) + ); + searchSource.setField('fields', searchFields); + searchSource.setField( + 'sort', + getSortForSearchSource( + $scope.state.sort, + $scope.indexPattern, + config.get(SORT_DEFAULT_ORDER_SETTING) + ) + ); + searchSource.setField('highlight', null); + searchSource.setField('highlightAll', null); + searchSource.setField('aggs', null); + searchSource.setField('size', null); + + const body = await searchSource.getSearchRequestBody(); + return { + searchRequest: { + index: searchSource.getField('index').title, + body, + }, + fields: selectFields, + metaFields: $scope.indexPattern.metaFields, + conflictedTypesFields: $scope.indexPattern.fields + .filter((f) => f.type === 'conflict') + .map((f) => f.name), + indexPatternId: searchSource.getField('index').id, + }; + }; + + function getStateDefaults() { + const query = $scope.searchSource.getField('query') || data.query.queryString.getDefaultQuery(); + return { + query, + sort: getSortArray(savedSearch.sort, $scope.indexPattern), + columns: + savedSearch.columns.length > 0 + ? savedSearch.columns + : config.get(DEFAULT_COLUMNS_SETTING).slice(), + index: $scope.indexPattern.id, + interval: 'auto', + filters: _.cloneDeep($scope.searchSource.getOwnField('filter')), + }; + } + + $scope.state.index = $scope.indexPattern.id; + $scope.state.sort = getSortArray($scope.state.sort, $scope.indexPattern); + + $scope.opts = { + // number of records to fetch, then paginate through + sampleSize: config.get(SAMPLE_SIZE_SETTING), + timefield: getTimeField(), + savedSearch: savedSearch, + indexPatternList: $route.current.locals.savedObjects.ip.list, + config: config, + fixedScroll: createFixedScroll($scope, $timeout), + setHeaderActionMenu: getHeaderActionMenuMounter(), + }; + + const shouldSearchOnPageLoad = () => { + // 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 ( + config.get(SEARCH_ON_PAGE_LOAD_SETTING) || + savedSearch.id !== undefined || + timefilter.getRefreshInterval().pause === false + ); + }; + + const init = _.once(() => { + $scope.updateDataSource().then(async () => { + const fetch$ = merge( + refetch$, + filterManager.getFetches$(), + timefilter.getFetch$(), + timefilter.getAutoRefreshFetch$(), + data.query.queryString.getUpdates$() + ).pipe(debounceTime(100)); + + subscriptions.add( + subscribeWithScope( + $scope, + fetch$, + { + next: $scope.fetch, + }, + (error) => addFatalError(core.fatalErrors, error) + ) + ); + subscriptions.add( + subscribeWithScope( + $scope, + timefilter.getTimeUpdate$(), + { + next: () => { + $scope.updateTime(); + }, + }, + (error) => addFatalError(core.fatalErrors, error) + ) + ); + + $scope.changeInterval = (interval) => { + if (interval) { + setAppState({ interval }); + } + }; + + $scope.$watchMulti( + ['rows', 'fetchStatus'], + (function updateResultState() { + let prev = {}; + const status = { + UNINITIALIZED: 'uninitialized', + LOADING: 'loading', // initial data load + READY: 'ready', // results came back + NO_RESULTS: 'none', // no results came back + }; + + function pick(rows, oldRows, fetchStatus) { + // initial state, pretend we're already loading if we're about to execute a search so + // that the uninitilized message doesn't flash on screen + if (rows == null && oldRows == null && shouldSearchOnPageLoad()) { + return status.LOADING; + } + + if (fetchStatus === fetchStatuses.UNINITIALIZED) { + return status.UNINITIALIZED; + } + + const rowsEmpty = _.isEmpty(rows); + if (rowsEmpty && fetchStatus === fetchStatuses.LOADING) return status.LOADING; + else if (!rowsEmpty) return status.READY; + else return status.NO_RESULTS; + } + + return function () { + const current = { + rows: $scope.rows, + fetchStatus: $scope.fetchStatus, + }; + + $scope.resultState = pick( + current.rows, + prev.rows, + current.fetchStatus, + prev.fetchStatus + ); + + prev = current; + }; + })() + ); + + if (getTimeField()) { + setupVisualization(); + $scope.updateTime(); + } + + init.complete = true; + if (shouldSearchOnPageLoad()) { + refetch$.next(); + } + }); + }); + + async function saveDataSource(saveOptions) { + await $scope.updateDataSource(); + + savedSearch.columns = $scope.state.columns; + savedSearch.sort = $scope.state.sort; + + try { + const id = await savedSearch.save(saveOptions); + $scope.$evalAsync(() => { + if (id) { + toastNotifications.addSuccess({ + title: i18n.translate('discover.notifications.savedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was saved`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + 'data-test-subj': 'saveSearchSuccess', + }); + + if (savedSearch.id !== $route.current.params.id) { + history.push(`/view/${encodeURIComponent(savedSearch.id)}`); + } else { + // Update defaults so that "reload saved query" functions correctly + setAppState(getStateDefaults()); + chrome.docTitle.change(savedSearch.lastSavedTitle); + chrome.setBreadcrumbs([ + { + text: discoverBreadcrumbsTitle, + href: '#/', + }, + { text: savedSearch.title }, + ]); + } + } + }); + return { id }; + } catch (saveError) { + toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.notSavedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was not saved.`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + text: saveError.message, + }); + return { error: saveError }; + } + } + + $scope.opts.fetch = $scope.fetch = function () { + // ignore requests to fetch before the app inits + if (!init.complete) return; + $scope.fetchCounter++; + $scope.fetchError = undefined; + $scope.minimumVisibleRows = 50; + if (!validateTimeRange(timefilter.getTime(), toastNotifications)) { + $scope.resultState = 'none'; + return; + } + + // Abort any in-progress requests before fetching again + if (abortController) abortController.abort(); + abortController = new AbortController(); + + $scope + .updateDataSource() + .then(setupVisualization) + .then(function () { + $scope.fetchStatus = fetchStatuses.LOADING; + logInspectorRequest(); + return $scope.searchSource.fetch({ + abortSignal: abortController.signal, + }); + }) + .then(onResults) + .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; + + $scope.fetchStatus = fetchStatuses.NO_RESULTS; + $scope.rows = []; + + data.search.showError(error); + }); + }; + + $scope.handleRefresh = function (_payload, isUpdate) { + if (isUpdate === false) { + refetch$.next(); + } + }; + + $scope.updateSavedQueryId = (newSavedQueryId) => { + if (newSavedQueryId) { + setAppState({ savedQuery: newSavedQueryId }); + } else { + // remove savedQueryId from state + const state = { + ...appStateContainer.getState(), + }; + delete state.savedQuery; + appStateContainer.set(state); + } + }; + + function getDimensions(aggs, timeRange) { + const [metric, agg] = aggs; + agg.params.timeRange = timeRange; + const bounds = agg.params.timeRange ? timefilter.calculateBounds(agg.params.timeRange) : null; + agg.buckets.setBounds(bounds); + + const { opensearchUnit, opensearchValue } = agg.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: agg.buckets.getScaledDateFormat(), + bounds: agg.buckets.getBounds(), + }, + }, + y: { + accessor: 1, + format: metric.toSerializedFieldFormat(), + label: metric.makeLabel(), + }, + }; + } + + function onResults(resp) { + inspectorRequest.stats(getResponseInspectorStats(resp, $scope.searchSource)).ok({ json: resp }); + + if (getTimeField()) { + const tabifiedData = tabifyAggResponse($scope.vis.data.aggs, resp); + $scope.searchSource.rawResponse = resp; + $scope.histogramData = discoverResponseHandler( + tabifiedData, + getDimensions($scope.vis.data.aggs.aggs, $scope.timeRange) + ); + $scope.updateTime(); + } + + $scope.hits = resp.hits.total; + $scope.rows = resp.hits.hits; + + // if we haven't counted yet, reset the counts + const counts = ($scope.fieldCounts = $scope.fieldCounts || {}); + + $scope.rows.forEach((hit) => { + const fields = Object.keys($scope.indexPattern.flattenHit(hit)); + fields.forEach((fieldName) => { + counts[fieldName] = (counts[fieldName] || 0) + 1; + }); + }); + + $scope.fetchStatus = fetchStatuses.COMPLETE; + } + + function logInspectorRequest() { + 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.', + }); + inspectorRequest = inspectorAdapters.requests.start(title, { description }); + inspectorRequest.stats(getRequestInspectorStats($scope.searchSource)); + $scope.searchSource.getSearchRequestBody().then((body) => { + inspectorRequest.json(body); + }); + } + + $scope.updateTime = function () { + const { from, to } = timefilter.getTime(); + // this is the timerange for the histogram, should be refactored + $scope.timeRange = { + from: dateMath.parse(from), + to: dateMath.parse(to, { roundUp: true }), + }; + }; + + $scope.toMoment = function (datetime) { + if (!datetime) { + return; + } + return moment(datetime).format(config.get('dateFormat')); + }; + + $scope.resetQuery = function () { + history.push( + $route.current.params.id ? `/view/${encodeURIComponent($route.current.params.id)}` : '/' + ); + $route.reload(); + }; + + $scope.onSkipBottomButtonClick = function () { + // show all the Rows + $scope.minimumVisibleRows = $scope.hits; + + // delay scrolling to after the rows have been rendered + const bottomMarker = $element.find('#discoverBottomMarker'); + $timeout(() => { + bottomMarker.focus(); + // The anchor tag is not technically empty (it's a hack to make Safari scroll) + // so the browser will show a highlight: remove the focus once scrolled + $timeout(() => { + bottomMarker.blur(); + }, 0); + }, 0); + }; + + $scope.newQuery = function () { + history.push('/'); + }; + + $scope.updateDataSource = () => { + const { indexPattern, searchSource } = $scope; + searchSource + .setField('index', $scope.indexPattern) + .setField('size', $scope.opts.sampleSize) + .setField( + 'sort', + getSortForSearchSource( + $scope.state.sort, + indexPattern, + config.get(SORT_DEFAULT_ORDER_SETTING) + ) + ) + .setField('query', data.query.queryString.getQuery() || null) + .setField('filter', filterManager.getFilters()); + return Promise.resolve(); + }; + + $scope.setSortOrder = function setSortOrder(sort) { + setAppState({ sort }); + }; + + // TODO: On array fields, negating does not negate the combination, rather all terms + $scope.filterQuery = function (field, values, operation) { + const { indexPattern } = $scope; + + popularizeField(indexPattern, field.name, indexPatterns); + const newFilters = opensearchFilters.generateFilters( + filterManager, + field, + values, + operation, + $scope.indexPattern.id + ); + return filterManager.addFilters(newFilters); + }; + + $scope.addColumn = function addColumn(columnName) { + if (uiCapabilities.discover.save) { + const { indexPattern } = $scope; + popularizeField(indexPattern, columnName, indexPatterns); + } + const columns = columnActions.addColumn($scope.state.columns, columnName); + setAppState({ columns }); + }; + + $scope.removeColumn = function removeColumn(columnName) { + if (uiCapabilities.discover.save) { + const { indexPattern } = $scope; + popularizeField(indexPattern, columnName, indexPatterns); + } + const columns = columnActions.removeColumn($scope.state.columns, columnName); + // The state's sort property is an array of [sortByColumn,sortDirection] + const sort = $scope.state.sort.length + ? $scope.state.sort.filter((subArr) => subArr[0] !== columnName) + : []; + setAppState({ columns, sort }); + }; + + $scope.moveColumn = function moveColumn(columnName, newIndex) { + const columns = columnActions.moveColumn($scope.state.columns, columnName, newIndex); + setAppState({ columns }); + }; + + $scope.scrollToTop = function () { + $window.scrollTo(0, 0); + }; + + async function setupVisualization() { + // If no timefield has been specified we don't create a histogram of messages + if (!getTimeField()) return; + const { interval: histogramInterval } = $scope.state; + + const visStateAggs = [ + { + type: 'count', + schema: 'metric', + }, + { + type: 'date_histogram', + schema: 'segment', + params: { + field: getTimeField(), + interval: histogramInterval, + timeRange: timefilter.getTime(), + }, + }, + ]; + + $scope.vis = await visualizations.createVis('histogram', { + title: savedSearch.title, + params: { + addLegend: false, + addTimeMarker: true, + }, + data: { + aggs: visStateAggs, + searchSource: $scope.searchSource.getSerializedFields(), + }, + }); + + $scope.searchSource.onRequestStart((searchSource, options) => { + if (!$scope.vis) return; + return $scope.vis.data.aggs.onSearchRequestStart(searchSource, options); + }); + + $scope.searchSource.setField('aggs', function () { + if (!$scope.vis) return; + return $scope.vis.data.aggs.toDsl(); + }); + } + + function getIndexPatternWarning(index) { + return i18n.translate('discover.valueIsNotConfiguredIndexPatternIDWarningTitle', { + defaultMessage: '{stateVal} is not a configured index pattern ID', + values: { + stateVal: `"${index}"`, + }, + }); + } + + function resolveIndexPatternLoading() { + const { + loaded: loadedIndexPattern, + stateVal, + stateValFound, + } = $route.current.locals.savedObjects.ip; + + const ownIndexPattern = $scope.searchSource.getOwnField('index'); + + if (ownIndexPattern && !stateVal) { + return ownIndexPattern; + } + + if (stateVal && !stateValFound) { + const warningTitle = getIndexPatternWarning(); + + 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; + } + + addHelpMenuToAppChrome(chrome); + + init(); + // Propagate current app state to url, then start syncing + replaceUrlAppState().then(() => startStateSync()); +} diff --git a/src/plugins/discover_legacy/public/application/angular/discover_legacy.html b/src/plugins/discover_legacy/public/application/angular/discover_legacy.html new file mode 100644 index 000000000000..8582f71c0cb8 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/discover_legacy.html @@ -0,0 +1,36 @@ + + + + diff --git a/src/plugins/discover_legacy/public/application/angular/discover_state.test.ts b/src/plugins/discover_legacy/public/application/angular/discover_state.test.ts new file mode 100644 index 000000000000..96225d4a2a53 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/discover_state.test.ts @@ -0,0 +1,116 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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, GetStateReturn } from './discover_state'; +import { createBrowserHistory, History } from 'history'; + +let history: History; +let state: GetStateReturn; +const getCurrentUrl = () => history.createHref(history.location); + +describe('Test discover state', () => { + beforeEach(async () => { + history = createBrowserHistory(); + history.push('/'); + state = getState({ + defaultAppState: { index: 'test' }, + history, + }); + await state.replaceUrlAppState({}); + await state.startSync(); + }); + afterEach(() => { + state.stopSync(); + }); + test('setting app state and syncing to URL', async () => { + state.setAppState({ index: 'modified' }); + state.flushToUrl(); + expect(getCurrentUrl()).toMatchInlineSnapshot(`"/#?_a=(index:modified)"`); + }); + + test('changing URL to be propagated to appState', async () => { + history.push('/#?_a=(index:modified)'); + expect(state.appStateContainer.getState()).toMatchInlineSnapshot(` + Object { + "index": "modified", + } + `); + }); + test('URL navigation to url without _a, state should not change', async () => { + history.push('/#?_a=(index:modified)'); + history.push('/'); + expect(state.appStateContainer.getState()).toMatchInlineSnapshot(` + Object { + "index": "modified", + } + `); + }); + + test('isAppStateDirty returns whether the current state has changed', async () => { + state.setAppState({ index: 'modified' }); + expect(state.isAppStateDirty()).toBeTruthy(); + state.resetInitialAppState(); + expect(state.isAppStateDirty()).toBeFalsy(); + }); + + test('getPreviousAppState returns the state before the current', async () => { + state.setAppState({ index: 'first' }); + const stateA = state.appStateContainer.getState(); + state.setAppState({ index: 'second' }); + expect(state.getPreviousAppState()).toEqual(stateA); + }); +}); + +describe('Test discover state with legacy migration', () => { + test('migration of legacy query ', async () => { + history = createBrowserHistory(); + history.push( + "/#?_a=(query:(query_string:(analyze_wildcard:!t,query:'type:nice%20name:%22yeah%22')))" + ); + state = getState({ + defaultAppState: { index: 'test' }, + history, + }); + expect(state.appStateContainer.getState()).toMatchInlineSnapshot(` + Object { + "index": "test", + "query": Object { + "language": "lucene", + "query": Object { + "query_string": Object { + "analyze_wildcard": true, + "query": "type:nice name:\\"yeah\\"", + }, + }, + }, + } + `); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/discover_state.ts b/src/plugins/discover_legacy/public/application/angular/discover_state.ts new file mode 100644 index 000000000000..0570b64d24d9 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/discover_state.ts @@ -0,0 +1,256 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { isEqual } from 'lodash'; +import { History } from 'history'; +import { NotificationsStart } from 'opensearch-dashboards/public'; +import { + createStateContainer, + createOsdUrlStateStorage, + syncState, + ReduxLikeStateContainer, + IOsdUrlStateStorage, + withNotifyOnErrors, +} from '../../../../opensearch_dashboards_utils/public'; +import { opensearchFilters, Filter, Query } from '../../../../data/public'; +import { migrateLegacyQuery } from '../helpers/migrate_legacy_query'; + +export interface AppState { + /** + * 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; +} + +interface GetStateParams { + /** + * Default state used for merging with with URL state to get the initial state + */ + defaultAppState?: AppState; + /** + * Determins the use of long vs. short/hashed urls + */ + storeInSessionStorage?: boolean; + /** + * Browser history + */ + history: History; + + /** + * Core's notifications.toasts service + * In case it is passed in, + * osdUrlStateStorage will use it notifying about inner errors + */ + toasts?: NotificationsStart['toasts']; +} + +export interface GetStateReturn { + /** + * osdUrlStateStorage + */ + osdUrlStateStorage: IOsdUrlStateStorage; + /** + * App state, the _a part of the URL + */ + appStateContainer: ReduxLikeStateContainer; + /** + * 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; + /** + * Set state in Url using history.replace + */ + replaceUrlAppState: (newState: Partial) => Promise; + /** + * Sync state to URL, used for testing + */ + flushToUrl: () => void; + /** + * Reset initial state to the current app state + */ + resetInitialAppState: () => void; + /** + * Return the Appstate before the current app state, useful for diffing changes + */ + getPreviousAppState: () => AppState; + /** + * Returns whether the current app state is different to the initial state + */ + isAppStateDirty: () => void; +} +const APP_STATE_URL_KEY = '_a'; + +/** + * Builds and returns appState and globalState containers and helper functions + * Used to sync URL with UI state + */ +export function getState({ + defaultAppState = {}, + storeInSessionStorage = false, + history, + toasts, +}: GetStateParams): GetStateReturn { + const stateStorage = createOsdUrlStateStorage({ + useHash: storeInSessionStorage, + history, + ...(toasts && withNotifyOnErrors(toasts)), + }); + + const appStateFromUrl = stateStorage.get(APP_STATE_URL_KEY) as AppState; + + if (appStateFromUrl && appStateFromUrl.query && !appStateFromUrl.query.language) { + appStateFromUrl.query = migrateLegacyQuery(appStateFromUrl.query); + } + + let initialAppState = { + ...defaultAppState, + ...appStateFromUrl, + }; + let previousAppState: AppState; + const appStateContainer = createStateContainer(initialAppState); + + const appStateContainerModified = { + ...appStateContainer, + set: (value: AppState | null) => { + if (value) { + previousAppState = appStateContainer.getState(); + appStateContainer.set(value); + } + }, + }; + + const { start, stop } = syncState({ + storageKey: APP_STATE_URL_KEY, + stateContainer: appStateContainerModified, + stateStorage, + }); + + return { + osdUrlStateStorage: stateStorage, + appStateContainer: appStateContainerModified, + startSync: start, + stopSync: stop, + setAppState: (newPartial: AppState) => setState(appStateContainerModified, newPartial), + replaceUrlAppState: async (newPartial: AppState = {}) => { + const state = { ...appStateContainer.getState(), ...newPartial }; + await stateStorage.set(APP_STATE_URL_KEY, state, { replace: true }); + }, + resetInitialAppState: () => { + initialAppState = appStateContainer.getState(); + }, + getPreviousAppState: () => previousAppState, + flushToUrl: () => stateStorage.flush(), + isAppStateDirty: () => !isEqualState(initialAppState, appStateContainer.getState()), + }; +} + +/** + * Helper function to merge a given new state with the existing state and to set the given state + * container + */ +export function setState(stateContainer: ReduxLikeStateContainer, newState: AppState) { + const oldState = stateContainer.getState(); + const mergedState = { ...oldState, ...newState }; + if (!isEqualState(oldState, mergedState)) { + stateContainer.set(mergedState); + } +} + +/** + * 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 extract filters of the given state + * returns a state object without filters and an array of filters + */ +export function splitState(state: AppState = {}) { + const { filters = [], ...statePartial } = state; + return { filters, state: statePartial }; +} + +/** + * Helper function to compare 2 different state, is needed since comparing filters + * works differently + */ +export function isEqualState(stateA: AppState, stateB: AppState) { + 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) && isEqualFilters(stateAFilters, stateBFilters); +} diff --git a/src/plugins/discover_legacy/public/application/angular/doc.html b/src/plugins/discover_legacy/public/application/angular/doc.html new file mode 100644 index 000000000000..dcd5760eff15 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc.html @@ -0,0 +1,8 @@ +
+ +
diff --git a/src/plugins/discover_legacy/public/application/angular/doc.ts b/src/plugins/discover_legacy/public/application/angular/doc.ts new file mode 100644 index 000000000000..584815be9342 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc.ts @@ -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 { getAngularModule, getServices } from '../../opensearch_dashboards_services'; +// @ts-ignore +import { getRootBreadcrumbs } from '../helpers/breadcrumbs'; +import html from './doc.html'; +import { Doc } from '../components/doc/doc'; + +interface LazyScope extends ng.IScope { + [key: string]: any; +} + +const { timefilter } = getServices(); +const app = getAngularModule(); +app.directive('discoverDoc', function (reactDirective: any) { + return reactDirective( + Doc, + [ + ['id', { watchDepth: 'value' }], + ['index', { watchDepth: 'value' }], + ['indexPatternId', { watchDepth: 'reference' }], + ['indexPatternService', { watchDepth: 'reference' }], + ], + { restrict: 'E' } + ); +}); + +app.config(($routeProvider: any) => { + $routeProvider + .when('/doc/:indexPattern/:index/:type', { + redirectTo: '/doc/:indexPattern/:index', + }) + // the new route, opensearch 7 deprecated types, opensearch 8 removed them + .when('/doc/:indexPattern/:index', { + // have to be written as function expression, because it's not compiled in dev mode + // eslint-disable-next-line object-shorthand + controller: function ($scope: LazyScope, $route: any) { + timefilter.disableAutoRefreshSelector(); + timefilter.disableTimeRangeSelector(); + $scope.id = $route.current.params.id; + $scope.index = $route.current.params.index; + $scope.indexPatternId = $route.current.params.indexPattern; + $scope.indexPatternService = getServices().indexPatterns; + }, + template: html, + k7Breadcrumbs: ($route: any) => [ + ...getRootBreadcrumbs(), + { + text: `${$route.current.params.index}#${$route.current.params.id}`, + }, + ], + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/_doc_table.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/_doc_table.scss new file mode 100644 index 000000000000..bc75d4171004 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/_doc_table.scss @@ -0,0 +1,158 @@ +/** + * 1. Stack content vertically so the table can scroll when its constrained by a fixed container height. + */ +doc-table { + @include euiScrollBar; + + overflow: auto; + flex: 1 1 100%; + flex-direction: column; /* 1 */ + + th { + text-align: left; + font-weight: bold; + } + + .spinner { + position: absolute; + top: 40%; + left: 0; + right: 0; + z-index: $euiZLevel1; + opacity: 0.5; + } +} + +.osdDocTable__container.loading { + opacity: 0.5; +} + +.osdDocTable { + font-size: $euiFontSizeXS; + + th { + white-space: nowrap; + padding-right: $euiSizeS; + + .fa { + font-size: 1.1em; + } + } +} + +.osd-table, +.osdDocTable { + /** + * Style OpenSearch document _source in table view
key:
value
+ * Use alpha so this will stand out against non-white backgrounds, e.g. the highlighted + * row in the Context Log. + */ + + dl.source { + margin-bottom: 0; + line-height: 2em; + word-break: break-word; + + dt, + dd { + display: inline; + } + + dt { + background-color: tintOrShade($euiColorPrimary, 90%, 70%); + color: $euiTextColor; + padding: ($euiSizeXS / 2) $euiSizeXS; + margin-right: $euiSizeXS; + word-break: normal; + border-radius: $euiBorderRadius; + } + } +} + +.osdDocTable__row { + td { + position: relative; + + &:hover { + .osdDocTableRowFilterButton { + opacity: 1; + } + } + } +} + +.osdDocTable__row--highlight { + td, + .osdDocTableRowFilterButton { + background-color: tintOrShade($euiColorPrimary, 90%, 70%); + } +} + +.osdDocTable__bar { + margin: $euiSizeXS $euiSizeXS 0; +} + +.osdDocTable__bar--footer { + position: relative; + margin: -($euiSize * 3) $euiSizeXS 0; +} + +.osdDocTable__padBottom { + padding-bottom: $euiSizeXL; +} + +.osdDocTable__error { + display: flex; + flex-direction: column; + justify-content: center; + flex: 1 0 100%; + text-align: center; +} + +.truncate-by-height { + overflow: hidden; +} + +.table { + // Nesting + .table { + background-color: $euiColorEmptyShade; + } +} + +.osd-table { + // sub tables should not have a leading border + .table .table { + margin-bottom: 0; + + tr:first-child > td { + border-top: none; + } + + td.field-name { + font-weight: $euiFontWeightBold; + } + } +} + +table { + th { + i.fa-sort { + color: $euiColorLightShade; + } + + button.fa-sort-asc, + button.fa-sort-down, + i.fa-sort-asc, + i.fa-sort-down { + color: $euiColorPrimary; + } + + button.fa-sort-desc, + button.fa-sort-up, + i.fa-sort-desc, + i.fa-sort-up { + color: $euiColorPrimary; + } + } +} diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/actions/columns.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/actions/columns.ts new file mode 100644 index 000000000000..877ee7db8f55 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/actions/columns.ts @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 + */ +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']; +} + +export function addColumn(columns: string[], columnName: string) { + if (columns.includes(columnName)) { + return columns; + } + return buildColumns([...columns, columnName]); +} + +export function removeColumn(columns: string[], columnName: string) { + if (!columns.includes(columnName)) { + return columns; + } + return buildColumns(columns.filter((col) => col !== columnName)); +} + +export function moveColumn(columns: string[], columnName: string, newIndex: number) { + if (newIndex < 0 || newIndex >= columns.length || !columns.includes(columnName)) { + return columns; + } + const modifiedColumns = [...columns]; + modifiedColumns.splice(modifiedColumns.indexOf(columnName), 1); // remove at old index + modifiedColumns.splice(newIndex, 0, columnName); // insert before new index + return modifiedColumns; +} diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/components/_index.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/_index.scss new file mode 100644 index 000000000000..25065a969b9b --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/_index.scss @@ -0,0 +1,2 @@ +@import "table_header"; +@import "table_row/index"; diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/components/_table_header.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/components/_table_header.scss new file mode 100644 index 000000000000..7b4c14f6a856 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/_table_header.scss @@ -0,0 +1,17 @@ +.osdDocTableHeader { + white-space: nowrap; +} + +.osdDocTableHeader button { + margin-left: $euiSizeXS; +} + +.osdDocTableHeader__move, +.osdDocTableHeader__sortChange { + opacity: 0; + + &:focus, + th:hover & { + opacity: 1; + } +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..20e503fd5ff9 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_buttons.test.tsx.snap @@ -0,0 +1,30 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders ToolBarPagerButtons 1`] = ` +
+ + +
+`; diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..fe168c013cb1 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/__snapshots__/tool_bar_pager_text.test.tsx.snap @@ -0,0 +1,10 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`it renders ToolBarPagerText without crashing 1`] = ` +
+ 1–2 of 3 +
+`; diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/index.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/index.ts new file mode 100644 index 000000000000..a0f5278d7881 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/index.ts @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { ToolBarPagerText } from './tool_bar_pager_text'; +import { ToolBarPagerButtons } from './tool_bar_pager_buttons'; + +export function createToolBarPagerTextDirective(reactDirective: any) { + return reactDirective(ToolBarPagerText); +} + +export function createToolBarPagerButtonsDirective(reactDirective: any) { + return reactDirective(ToolBarPagerButtons); +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..2ac06b2b6ebf --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.test.tsx @@ -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 React from 'react'; +import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { ToolBarPagerButtons } from './tool_bar_pager_buttons'; +import { findTestSubject } from 'test_utils/helpers'; + +test('it renders ToolBarPagerButtons', () => { + const props = { + hasPreviousPage: true, + hasNextPage: true, + onPageNext: jest.fn(), + onPagePrevious: jest.fn(), + }; + const wrapper = shallowWithIntl(); + expect(wrapper).toMatchSnapshot(); +}); + +test('it renders ToolBarPagerButtons with clickable next and previous button', () => { + const props = { + hasPreviousPage: true, + hasNextPage: true, + onPageNext: jest.fn(), + onPagePrevious: jest.fn(), + }; + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'btnPrevPage').simulate('click'); + expect(props.onPagePrevious).toHaveBeenCalledTimes(1); + findTestSubject(wrapper, 'btnNextPage').simulate('click'); + expect(props.onPageNext).toHaveBeenCalledTimes(1); +}); + +test('it renders ToolBarPagerButtons with disabled next and previous button', () => { + const props = { + hasPreviousPage: false, + hasNextPage: false, + onPageNext: jest.fn(), + onPagePrevious: jest.fn(), + }; + const wrapper = mountWithIntl(); + findTestSubject(wrapper, 'btnPrevPage').simulate('click'); + expect(props.onPagePrevious).toHaveBeenCalledTimes(0); + findTestSubject(wrapper, 'btnNextPage').simulate('click'); + expect(props.onPageNext).toHaveBeenCalledTimes(0); +}); diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..04956583291c --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_buttons.tsx @@ -0,0 +1,74 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; + +interface Props { + hasPreviousPage: boolean; + hasNextPage: boolean; + onPageNext: () => void; + onPagePrevious: () => void; +} + +export function ToolBarPagerButtons(props: Props) { + return ( +
+ + +
+ ); +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..4b13de634f04 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.test.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. + */ + +import React from 'react'; +import { renderWithIntl } from 'test_utils/enzyme_helpers'; +import { ToolBarPagerText } from './tool_bar_pager_text'; + +test('it renders ToolBarPagerText without crashing', () => { + const props = { + startItem: 1, + endItem: 2, + totalItems: 3, + }; + const wrapper = renderWithIntl(); + expect(wrapper).toMatchSnapshot(); +}); diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..28110891a2f9 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/pager/tool_bar_pager_text.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 { FormattedMessage, I18nProvider } from '@osd/i18n/react'; + +interface Props { + startItem: number; + endItem: number; + totalItems: number; +} + +export function ToolBarPagerText({ startItem, endItem, totalItems }: Props) { + return ( + +
+ +
+
+ ); +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..1b0e03653d0d --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/row_headers.test.js @@ -0,0 +1,508 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 angular from 'angular'; +import 'angular-mocks'; +import 'angular-sanitize'; +import 'angular-route'; +import _ from 'lodash'; +import sinon from 'sinon'; +import { getFakeRow } from 'fixtures/fake_row'; +import $ from 'jquery'; +import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import { + setScopedHistory, + setServices, + setDocViewsRegistry, + setDocViewsLinksRegistry, +} from '../../../../opensearch_dashboards_services'; +import { coreMock } from '../../../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../../../data/public/mocks'; +import { navigationPluginMock } from '../../../../../../navigation/public/mocks'; +import { getInnerAngularModule } from '../../../../get_inner_angular'; +import { createBrowserHistory } from 'history'; + +const fakeRowVals = { + time: 'time_formatted', + bytes: 'bytes_formatted', + '@timestamp': '@timestamp_formatted', + request_body: 'request_body_formatted', +}; + +describe('Doc Table', () => { + const core = coreMock.createStart(); + const dataMock = dataPluginMock.createStartContract(); + let $parentScope; + let $scope; + let $elementScope; + let timeout; + let registry = []; + + // Stub out a minimal mapping of 4 fields + let mapping; + + beforeAll(() => setScopedHistory(createBrowserHistory())); + beforeEach(() => { + angular.element.prototype.slice = jest.fn(function (index) { + return $(this).slice(index); + }); + angular.element.prototype.filter = jest.fn(function (condition) { + return $(this).filter(condition); + }); + angular.element.prototype.toggle = jest.fn(function (name) { + return $(this).toggle(name); + }); + angular.element.prototype.is = jest.fn(function (name) { + return $(this).is(name); + }); + setServices({ + uiSettings: core.uiSettings, + filterManager: dataMock.query.filterManager, + }); + + setDocViewsRegistry({ + addDocView(view) { + registry.push(view); + }, + getDocViewsSorted() { + return registry; + }, + resetRegistry: () => { + registry = []; + }, + }); + + setDocViewsLinksRegistry({ + addDocViewLink(view) { + registry.push(view); + }, + getDocViewsLinksSorted() { + return registry; + }, + resetRegistry: () => { + registry = []; + }, + }); + + getInnerAngularModule( + 'app/discover', + core, + { + data: dataMock, + navigation: navigationPluginMock.createStartContract(), + }, + coreMock.createPluginInitializerContext() + ); + angular.mock.module('app/discover'); + }); + beforeEach( + angular.mock.inject(function ($rootScope, Private, $timeout) { + $parentScope = $rootScope; + timeout = $timeout; + $parentScope.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); + mapping = $parentScope.indexPattern.fields; + + // Stub `getConverterFor` for a field in the indexPattern to return mock data. + + const convertFn = (value, type, options) => { + const fieldName = _.get(options, 'field.name', null); + return fakeRowVals[fieldName] || ''; + }; + $parentScope.indexPattern.getFormatterForField = () => ({ + convert: convertFn, + getConverterFor: () => convertFn, + }); + }) + ); + + afterEach(() => { + delete angular.element.prototype.slice; + delete angular.element.prototype.filter; + delete angular.element.prototype.toggle; + delete angular.element.prototype.is; + }); + + // Sets up the directive, take an element, and a list of properties to attach to the parent scope. + const init = function ($elem, props) { + angular.mock.inject(function ($compile) { + _.assign($parentScope, props); + const el = $compile($elem)($parentScope); + $elementScope = el.scope(); + el.scope().$digest(); + $scope = el.isolateScope(); + }); + }; + + const destroy = () => { + $scope.$destroy(); + $parentScope.$destroy(); + }; + + // For testing column removing/adding for the header and the rows + const columnTests = function (elemType, parentElem) { + test('should create a time column if the timefield is defined', () => { + const childElems = parentElem.find(elemType); + expect(childElems.length).toBe(1); + }); + + test('should be able to add and remove columns', () => { + let childElems; + + // Should include a column for toggling and the time column by default + $parentScope.columns = ['bytes']; + $elementScope.$digest(); + childElems = parentElem.find(elemType); + expect(childElems.length).toBe(2); + expect($(childElems[1]).text()).toContain('bytes'); + + $parentScope.columns = ['bytes', 'request_body']; + $elementScope.$digest(); + childElems = parentElem.find(elemType); + expect(childElems.length).toBe(3); + expect($(childElems[2]).text()).toContain('request_body'); + + $parentScope.columns = ['request_body']; + $elementScope.$digest(); + childElems = parentElem.find(elemType); + expect(childElems.length).toBe(2); + expect($(childElems[1]).text()).toContain('request_body'); + }); + + test('should create only the toggle column if there is no timeField', () => { + delete $scope.indexPattern.timeFieldName; + $scope.$digest(); + timeout.flush(); + + const childElems = parentElem.find(elemType); + expect(childElems.length).toBe(0); + }); + }; + + describe('osdTableRow', () => { + const $elem = $( + '' + ); + let row; + + beforeEach(() => { + row = getFakeRow(0, mapping); + + init($elem, { + row, + columns: [], + sorting: [], + filter: sinon.spy(), + maxLength: 50, + }); + }); + afterEach(() => { + destroy(); + }); + + describe('adding and removing columns', () => { + columnTests('[data-test-subj~="docTableField"]', $elem); + }); + + describe('details row', () => { + test('should be an empty tr by default', () => { + expect($elem.next().is('tr')).toBe(true); + expect($elem.next().text()).toBe(''); + }); + + test('should expand the detail row when the toggle arrow is clicked', () => { + $elem.children(':first-child').click(); + expect($elem.next().text()).not.toBe(''); + }); + + describe('expanded', () => { + let $details; + beforeEach(() => { + // Open the row + $scope.toggleRow(); + timeout.flush(); + $details = $elem.next(); + }); + afterEach(() => { + // Close the row + $scope.toggleRow(); + }); + + test('should be a tr with something in it', () => { + expect($details.is('tr')).toBe(true); + expect($details.text()).toBeTruthy(); + }); + }); + }); + }); + + describe('osdTableRow meta', () => { + const $elem = angular.element( + '' + ); + let row; + + beforeEach(() => { + row = getFakeRow(0, mapping); + + init($elem, { + row: row, + columns: [], + sorting: [], + filtering: sinon.spy(), + maxLength: 50, + }); + + // Open the row + $scope.toggleRow(); + $scope.$digest(); + timeout.flush(); + $elem.next(); + }); + + afterEach(() => { + destroy(); + }); + + /** this no longer works with the new plugin approach + test('should render even when the row source contains a field with the same name as a meta field', () => { + setTimeout(() => { + //this should be overridden by later changes + }, 100); + expect($details.find('tr').length).toBe(_.keys($parentScope.indexPattern.flattenHit($scope.row)).length); + }); */ + }); + + describe('row diffing', () => { + let $row; + let $scope; + let $root; + let $before; + + beforeEach( + angular.mock.inject(function ($rootScope, $compile, Private) { + $root = $rootScope; + $root.row = getFakeRow(0, mapping); + $root.columns = ['_source']; + $root.sorting = []; + $root.filtering = sinon.spy(); + $root.maxLength = 50; + $root.mapping = mapping; + $root.indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); + + $row = $('').attr({ + 'osd-table-row': 'row', + columns: 'columns', + sorting: 'sorting', + filtering: 'filtering', + 'index-pattern': 'indexPattern', + }); + + $scope = $root.$new(); + $compile($row)($scope); + $root.$apply(); + + $before = $row.find('td'); + expect($before).toHaveLength(3); + expect($before.eq(0).text().trim()).toBe(''); + expect($before.eq(1).text().trim()).toMatch(/^time_formatted/); + }) + ); + + afterEach(() => { + $row.remove(); + }); + + test('handles a new column', () => { + $root.columns.push('bytes'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(4); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($before[2].outerHTML); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + }); + + test('handles two new columns at once', () => { + $root.columns.push('bytes'); + $root.columns.push('request_body'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(5); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($before[2].outerHTML); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + expect($after.eq(4).text().trim()).toMatch(/^request_body_formatted/); + }); + + test('handles three new columns in odd places', () => { + $root.columns = ['@timestamp', 'bytes', '_source', 'request_body']; + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(6); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after.eq(2).text().trim()).toMatch(/^@timestamp_formatted/); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + expect($after[4].outerHTML).toBe($before[2].outerHTML); + expect($after.eq(5).text().trim()).toMatch(/^request_body_formatted/); + }); + + test('handles a removed column', () => { + _.pull($root.columns, '_source'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(2); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + }); + + test('handles two removed columns', () => { + // first add a column + $root.columns.push('@timestamp'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).toHaveLength(4); + + $root.columns.pop(); + $root.columns.pop(); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(2); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + }); + + test('handles three removed random columns', () => { + // first add two column + $root.columns.push('@timestamp', 'bytes'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).toHaveLength(5); + + $root.columns[0] = false; // _source + $root.columns[2] = false; // bytes + $root.columns = $root.columns.filter(Boolean); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(3); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after.eq(2).text().trim()).toMatch(/^@timestamp_formatted/); + }); + + test('handles two columns with the same content', () => { + const tempVal = fakeRowVals.request_body; + fakeRowVals.request_body = 'bytes_formatted'; + + $root.columns.length = 0; + $root.columns.push('bytes'); + $root.columns.push('request_body'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(4); + expect($after.eq(2).text().trim()).toMatch(/^bytes_formatted/); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + fakeRowVals.request_body = tempVal; + }); + + test('handles two columns swapping position', () => { + $root.columns.push('bytes'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).toHaveLength(4); + + $root.columns.reverse(); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(4); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($mid[3].outerHTML); + expect($after[3].outerHTML).toBe($mid[2].outerHTML); + }); + + test('handles four columns all reversing position', () => { + $root.columns.push('bytes', 'response', '@timestamp'); + $root.$apply(); + + const $mid = $row.find('td'); + expect($mid).toHaveLength(6); + + $root.columns.reverse(); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(6); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($mid[5].outerHTML); + expect($after[3].outerHTML).toBe($mid[4].outerHTML); + expect($after[4].outerHTML).toBe($mid[3].outerHTML); + expect($after[5].outerHTML).toBe($mid[2].outerHTML); + }); + + test('handles multiple columns with the same name', () => { + $root.columns.push('bytes', 'bytes', 'bytes'); + $root.$apply(); + + const $after = $row.find('td'); + expect($after).toHaveLength(6); + expect($after[0].outerHTML).toBe($before[0].outerHTML); + expect($after[1].outerHTML).toBe($before[1].outerHTML); + expect($after[2].outerHTML).toBe($before[2].outerHTML); + expect($after.eq(3).text().trim()).toMatch(/^bytes_formatted/); + expect($after.eq(4).text().trim()).toMatch(/^bytes_formatted/); + expect($after.eq(5).text().trim()).toMatch(/^bytes_formatted/); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header.ts new file mode 100644 index 000000000000..1c60685d279e --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header.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 { TableHeader } from './table_header/table_header'; +import { getServices } from '../../../../opensearch_dashboards_services'; +import { SORT_DEFAULT_ORDER_SETTING, DOC_HIDE_TIME_COLUMN_SETTING } from '../../../../../common'; +import { UI_SETTINGS } from '../../../../../../data/public'; + +export function createTableHeaderDirective(reactDirective: any) { + const { uiSettings: config } = getServices(); + + return reactDirective( + TableHeader, + [ + ['columns', { watchDepth: 'collection' }], + ['hideTimeColumn', { watchDepth: 'value' }], + ['indexPattern', { watchDepth: 'reference' }], + ['isShortDots', { watchDepth: 'value' }], + ['onChangeSortOrder', { watchDepth: 'reference' }], + ['onMoveColumn', { watchDepth: 'reference' }], + ['onRemoveColumn', { watchDepth: 'reference' }], + ['sortOrder', { watchDepth: 'collection' }], + ], + { restrict: 'A' }, + { + hideTimeColumn: config.get(DOC_HIDE_TIME_COLUMN_SETTING, false), + isShortDots: config.get(UI_SETTINGS.SHORT_DOTS_ENABLE), + defaultSortOrder: config.get(SORT_DEFAULT_ORDER_SETTING, 'desc'), + } + ); +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..2aeb8951a60a --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_header/__snapshots__/table_header.test.tsx.snap @@ -0,0 +1,221 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TableHeader with time column renders correctly 1`] = ` + + + + + Time + + + + <% } %> + + \ No newline at end of file diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..a28f1b9906cb --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/details.html @@ -0,0 +1,37 @@ + +
+
+
+
+ +
+
+

+
+
+
+
+ +
+
+
+ +
+ + diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..d9e15c37f02c --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/open.html @@ -0,0 +1,7 @@ + + + diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..cf13f10e7006 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/components/table_row/truncate_by_height.html @@ -0,0 +1,3 @@ +
+ <%= body %> +
\ No newline at end of file diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..8997179781d0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/create_doc_table_react.tsx @@ -0,0 +1,143 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 angular, { auto, ICompileService, IScope } from 'angular'; +import { render } from 'react-dom'; +import React, { useRef, useEffect } from 'react'; +import { getServices, IIndexPattern } from '../../../opensearch_dashboards_services'; +import { IndexPatternField } from '../../../../../data/common/index_patterns'; +export type AngularScope = IScope; + +export interface AngularDirective { + template: string; +} + +/** + * Compiles and injects the give angular template into the given dom node + * returns a function to cleanup the injected angular element + */ +export async function injectAngularElement( + domNode: Element, + template: string, + scopeProps: any, + getInjector: () => Promise +): Promise<() => void> { + const $injector = await getInjector(); + const rootScope: AngularScope = $injector.get('$rootScope'); + const $compile: ICompileService = $injector.get('$compile'); + const newScope = Object.assign(rootScope.$new(), scopeProps); + + const $target = angular.element(domNode); + const $element = angular.element(template); + + newScope.$apply(() => { + const linkFn = $compile($element); + $target.empty().append($element); + linkFn(newScope); + }); + + return () => { + newScope.$destroy(); + }; +} + +/** + * Converts a given legacy angular directive to a render function + * for usage in a react component. Note that the rendering is async + */ +export function convertDirectiveToRenderFn( + directive: AngularDirective, + getInjector: () => Promise +) { + return (domNode: Element, props: any) => { + let rejected = false; + + const cleanupFnPromise = injectAngularElement(domNode, directive.template, props, getInjector); + cleanupFnPromise.catch(() => { + rejected = true; + render(
error
, domNode); + }); + + return () => { + if (!rejected) { + // for cleanup + // http://roubenmeschian.com/rubo/?p=51 + cleanupFnPromise.then((cleanup) => cleanup()); + } + }; + }; +} + +export interface DocTableLegacyProps { + columns: string[]; + searchDescription?: string; + searchTitle?: string; + onFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + rows: Array>; + indexPattern: IIndexPattern; + minimumVisibleRows: number; + onAddColumn: (column: string) => void; + onSort: (sort: string[][]) => void; + onMoveColumn: (columns: string, newIdx: number) => void; + onRemoveColumn: (column: string) => void; + sort?: string[][]; +} + +export function DocTableLegacy(renderProps: DocTableLegacyProps) { + const renderFn = convertDirectiveToRenderFn( + { + template: ``, + }, + () => getServices().getEmbeddableInjector() + ); + const ref = useRef(null); + useEffect(() => { + if (ref && ref.current) { + return renderFn(ref.current, renderProps); + } + }, [renderFn, renderProps]); + return
; +} diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.html b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.html new file mode 100644 index 000000000000..e7a66ce50299 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.html @@ -0,0 +1,67 @@ +
+
+
+
+
+ {{ limitedResultsWarning }} +
+ + + + +
+
+
+ + + + + + +
+
+ + +
+ + + + + + +
+ +
+ +
+
+ + +
+ +

+

+
+
\ No newline at end of file diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.test.js b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.test.js new file mode 100644 index 000000000000..bd087ac3547f --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.test.js @@ -0,0 +1,158 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import angular from 'angular'; +import _ from 'lodash'; +import 'angular-mocks'; +import 'angular-sanitize'; +import 'angular-route'; +import { createBrowserHistory } from 'history'; +import FixturesStubbedLogstashIndexPatternProvider from 'fixtures/stubbed_logstash_index_pattern'; +import hits from 'fixtures/real_hits'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { dataPluginMock } from '../../../../../data/public/mocks'; +import { navigationPluginMock } from '../../../../../navigation/public/mocks'; +import { setScopedHistory, setServices } from '../../../opensearch_dashboards_services'; +import { getInnerAngularModule } from '../../../get_inner_angular'; + +let $parentScope; + +let $scope; + +let $timeout; + +let indexPattern; + +const init = function ($elem, props) { + angular.mock.inject(function ($rootScope, $compile, _$timeout_) { + $timeout = _$timeout_; + $parentScope = $rootScope; + _.assign($parentScope, props); + + $compile($elem)($parentScope); + + // I think the prereq requires this? + $timeout(() => { + $elem.scope().$digest(); + }, 0); + + $scope = $elem.isolateScope(); + }); +}; + +const destroy = () => { + $scope.$destroy(); + $parentScope.$destroy(); +}; + +describe('docTable', () => { + const core = coreMock.createStart(); + let $elem; + + beforeAll(() => setScopedHistory(createBrowserHistory())); + beforeEach(() => { + angular.element.prototype.slice = jest.fn(() => { + return null; + }); + angular.element.prototype.filter = jest.fn(() => { + return { + remove: jest.fn(), + }; + }); + setServices({ + uiSettings: core.uiSettings, + }); + getInnerAngularModule( + 'app/discover', + core, + { + data: dataPluginMock.createStartContract(), + navigation: navigationPluginMock.createStartContract(), + }, + coreMock.createPluginInitializerContext() + ); + angular.mock.module('app/discover'); + }); + beforeEach(() => { + $elem = angular.element(` + + `); + angular.mock.inject(function (Private) { + indexPattern = Private(FixturesStubbedLogstashIndexPatternProvider); + }); + init($elem, { + indexPattern, + hits: [...hits], + totalHitCount: hits.length, + columns: [], + sorting: ['@timestamp', 'desc'], + }); + $scope.$digest(); + }); + + afterEach(() => { + delete angular.element.prototype.slice; + delete angular.element.prototype.filter; + destroy(); + }); + + test('should compile', () => { + expect($elem.text()).toBeTruthy(); + }); + + test('should have an addRows function that increases the row count', () => { + expect($scope.addRows).toBeInstanceOf(Function); + $scope.$digest(); + expect($scope.limit).toBe(50); + $scope.addRows(); + expect($scope.limit).toBe(100); + }); + + test('should reset the row limit when results are received', () => { + $scope.limit = 100; + expect($scope.limit).toBe(100); + $scope.hits = [...hits]; + $scope.$digest(); + expect($scope.limit).toBe(50); + }); + + test('should have a header and a table element', () => { + $scope.$digest(); + + expect($elem.find('thead').length).toBe(1); + expect($elem.find('table').length).toBe(1); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.ts new file mode 100644 index 000000000000..2c68ad4a89ff --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table.ts @@ -0,0 +1,113 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 html from './doc_table.html'; +import { dispatchRenderComplete } from '../../../../../opensearch_dashboards_utils/public'; +import { SAMPLE_SIZE_SETTING } from '../../../../common'; +// @ts-ignore +import { getLimitedSearchResultsMessage } from './doc_table_strings'; +import { getServices } from '../../../opensearch_dashboards_services'; +import './index.scss'; + +export interface LazyScope extends ng.IScope { + [key: string]: any; +} + +export function createDocTableDirective(pagerFactory: any, $filter: any) { + return { + restrict: 'E', + template: html, + scope: { + sorting: '=', + columns: '=', + hits: '=', + totalHitCount: '=', + indexPattern: '=', + isLoading: '=?', + infiniteScroll: '=?', + filter: '=?', + minimumVisibleRows: '=?', + onAddColumn: '=?', + onChangeSortOrder: '=?', + onMoveColumn: '=?', + onRemoveColumn: '=?', + inspectorAdapters: '=?', + }, + link: ($scope: LazyScope, $el: JQuery) => { + $scope.persist = { + sorting: $scope.sorting, + columns: $scope.columns, + }; + + const limitTo = $filter('limitTo'); + const calculateItemsOnPage = () => { + $scope.pager.setTotalItems($scope.hits.length); + $scope.pageOfItems = limitTo($scope.hits, $scope.pager.pageSize, $scope.pager.startIndex); + }; + + $scope.limitedResultsWarning = getLimitedSearchResultsMessage( + getServices().uiSettings.get(SAMPLE_SIZE_SETTING, 500) + ); + + $scope.addRows = function () { + $scope.limit += 50; + }; + + $scope.$watch('hits', (hits: any) => { + if (!hits) return; + + // Reset infinite scroll limit + $scope.limit = $scope.minimumVisibleRows || 50; + + if (hits.length === 0) { + dispatchRenderComplete($el[0]); + } + + if ($scope.infiniteScroll) return; + $scope.pager = pagerFactory.create(hits.length, 50, 1); + calculateItemsOnPage(); + }); + + $scope.pageOfItems = []; + $scope.onPageNext = () => { + $scope.pager.nextPage(); + calculateItemsOnPage(); + }; + + $scope.onPagePrevious = () => { + $scope.pager.previousPage(); + calculateItemsOnPage(); + }; + + $scope.shouldShowLimitedResultsWarning = () => + !$scope.pager.hasNextPage && $scope.pager.totalItems < $scope.totalHitCount; + }, + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table_strings.js b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table_strings.js new file mode 100644 index 000000000000..4a0457638faa --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/doc_table_strings.js @@ -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 { i18n } from '@osd/i18n'; + +/** + * A message letting the user know the results that have been retrieved is limited + * to a certain size. + * @param resultCount {Number} + */ +export function getLimitedSearchResultsMessage(resultCount) { + return i18n.translate('discover.docTable.limitedSearchResultLabel', { + defaultMessage: 'Limited to {resultCount} results. Refine your search.', + values: { resultCount }, + }); +} diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/index.scss b/src/plugins/discover_legacy/public/application/angular/doc_table/index.scss new file mode 100644 index 000000000000..5b9dc82adb16 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/index.scss @@ -0,0 +1,2 @@ +@import "doc_table"; +@import "components/index"; diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/index.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/index.ts new file mode 100644 index 000000000000..c51ce33cc28f --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/index.ts @@ -0,0 +1,33 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { createDocTableDirective } from './doc_table'; +export { getSort, getSortArray } from './lib/get_sort'; +export { getSortForSearchSource } from './lib/get_sort_for_search_source'; diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/infinite_scroll.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/infinite_scroll.ts new file mode 100644 index 000000000000..2a59d94aa5c4 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/infinite_scroll.ts @@ -0,0 +1,79 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import $ from 'jquery'; + +interface LazyScope extends ng.IScope { + [key: string]: any; +} + +export function createInfiniteScrollDirective() { + return { + restrict: 'E', + scope: { + more: '=', + }, + link: ($scope: LazyScope, $element: JQuery) => { + const $window = $(window); + let checkTimer: any; + + function onScroll() { + if (!$scope.more) return; + + const winHeight = Number($window.height()); + const winBottom = Number(winHeight) + Number($window.scrollTop()); + const offset = $element.offset(); + const elTop = offset ? offset.top : 0; + const remaining = elTop - winBottom; + + if (remaining <= winHeight * 0.5) { + $scope[$scope.$$phase ? '$eval' : '$apply'](function () { + $scope.more(); + }); + } + } + + function scheduleCheck() { + if (checkTimer) return; + checkTimer = setTimeout(function () { + checkTimer = null; + onScroll(); + }, 50); + } + + $window.on('scroll', scheduleCheck); + $scope.$on('$destroy', function () { + clearTimeout(checkTimer); + $window.off('scroll', scheduleCheck); + }); + scheduleCheck(); + }, + }; +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..d22c963d219c --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_default_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 { IndexPattern } from '../../../../opensearch_dashboards_services'; +// @ts-ignore +import { isSortable } from './get_sort'; +import { SortOrder } from '../components/table_header/helpers'; + +/** + * 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_legacy/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 new file mode 100644 index 000000000000..30c302bccd72 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/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_legacy/public/application/angular/doc_table/lib/get_sort.ts b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort.ts new file mode 100644 index 000000000000..2a65d7be5de1 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/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 '../../../../../../data/public'; + +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_legacy/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 new file mode 100644 index 000000000000..f83c764565c5 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/get_sort_for_search_source.ts @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { SortOrder } from '../components/table_header/helpers'; +import { getSort } from './get_sort'; +import { getDefaultSort } from './get_default_sort'; + +/** + * 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_legacy/public/application/angular/doc_table/lib/pager/index.js b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/index.js new file mode 100644 index 000000000000..e400b3b52226 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/index.js @@ -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. + */ + +import './pager_factory'; +export { Pager } from './pager'; diff --git a/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager.js b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager.js new file mode 100644 index 000000000000..55b8c670317a --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager.js @@ -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. + */ + +function clamp(val, min, max) { + return Math.min(Math.max(min, val), max); +} + +export class Pager { + constructor(totalItems, pageSize, startingPage) { + this.currentPage = startingPage; + this.totalItems = totalItems; + this.pageSize = pageSize; + this.startIndex = 0; + this.updateMeta(); + } + + get pageCount() { + return Math.ceil(this.totalItems / this.pageSize); + } + + get hasNextPage() { + return this.currentPage < this.totalPages; + } + + get hasPreviousPage() { + return this.currentPage > 1; + } + + nextPage() { + this.currentPage += 1; + this.updateMeta(); + } + + previousPage() { + this.currentPage -= 1; + this.updateMeta(); + } + + setTotalItems(count) { + this.totalItems = count; + this.updateMeta(); + } + + setPageSize(count) { + this.pageSize = count; + this.updateMeta(); + } + + updateMeta() { + this.totalPages = Math.ceil(this.totalItems / this.pageSize); + this.currentPage = clamp(this.currentPage, 1, this.totalPages); + + this.startItem = (this.currentPage - 1) * this.pageSize + 1; + this.startItem = clamp(this.startItem, 0, this.totalItems); + + this.endItem = this.startItem - 1 + this.pageSize; + this.endItem = clamp(this.endItem, 0, this.totalItems); + + this.startIndex = this.startItem - 1; + } +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..febb6428e53b --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_table/lib/pager/pager_factory.ts @@ -0,0 +1,40 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { Pager } from './pager'; + +export function createPagerFactory() { + return { + create(...args: unknown[]) { + return new Pager(...args); + }, + }; +} diff --git a/src/plugins/discover_legacy/public/application/angular/doc_viewer.tsx b/src/plugins/discover_legacy/public/application/angular/doc_viewer.tsx new file mode 100644 index 000000000000..9072594bf478 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_viewer.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 { DocViewer } from '../components/doc_viewer/doc_viewer'; + +export function createDocViewerDirective(reactDirective: any) { + return reactDirective( + (props: any) => { + return ; + }, + [ + 'hit', + ['indexPattern', { watchDepth: 'reference' }], + ['filter', { watchDepth: 'reference' }], + ['columns', { watchDepth: 'collection' }], + ['onAddColumn', { watchDepth: 'reference' }], + ['onRemoveColumn', { watchDepth: 'reference' }], + ], + { + restrict: 'E', + scope: { + hit: '=', + indexPattern: '=', + filter: '=?', + columns: '=?', + onAddColumn: '=?', + onRemoveColumn: '=?', + }, + } + ); +} diff --git a/src/plugins/discover_legacy/public/application/angular/doc_viewer_links.tsx b/src/plugins/discover_legacy/public/application/angular/doc_viewer_links.tsx new file mode 100644 index 000000000000..763a75e51300 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/doc_viewer_links.tsx @@ -0,0 +1,28 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { DocViewerLinks } from '../components/doc_viewer_links/doc_viewer_links'; + +export function createDocViewerLinksDirective(reactDirective: any) { + return reactDirective( + (props: any) => { + return ; + }, + [ + 'hit', + ['indexPattern', { watchDepth: 'reference' }], + ['columns', { watchDepth: 'collection' }], + ], + { + restrict: 'E', + scope: { + hit: '=', + indexPattern: '=', + columns: '=?', + }, + } + ); +} diff --git a/src/plugins/discover_legacy/public/application/angular/helpers/index.ts b/src/plugins/discover_legacy/public/application/angular/helpers/index.ts new file mode 100644 index 000000000000..eb569f6985d6 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/helpers/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 { buildPointSeriesData } from './point_series'; diff --git a/src/plugins/discover_legacy/public/application/angular/helpers/point_series.ts b/src/plugins/discover_legacy/public/application/angular/helpers/point_series.ts new file mode 100644 index 000000000000..34b35453cb55 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/helpers/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_legacy/public/application/angular/index.ts b/src/plugins/discover_legacy/public/application/angular/index.ts new file mode 100644 index 000000000000..f9b905759c06 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/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. + */ + +// required for i18nIdDirective +import 'angular-sanitize'; +// required for ngRoute +import 'angular-route'; + +import './discover'; +import './doc'; +import './context'; +import './doc_viewer'; +import './redirect'; +import './directives'; diff --git a/src/plugins/discover_legacy/public/application/angular/redirect.ts b/src/plugins/discover_legacy/public/application/angular/redirect.ts new file mode 100644 index 000000000000..d8c03e42abb7 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/redirect.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 { getAngularModule, getServices, getUrlTracker } from '../../opensearch_dashboards_services'; + +getAngularModule().config(($routeProvider: any) => { + $routeProvider.otherwise({ + resolveRedirectTo: ($rootScope: any) => { + const path = window.location.hash.substr(1); + getUrlTracker().restorePreviousUrl(); + $rootScope.$applyAsync(() => { + const { urlForwarding } = getServices(); + const { navigated } = urlForwarding.navigateToLegacyOpenSearchDashboardsUrl(path); + if (!navigated) { + urlForwarding.navigateToDefaultApp(); + } + }); + // prevent angular from completing the navigation + return new Promise(() => {}); + }, + }); +}); diff --git a/src/plugins/discover_legacy/public/application/angular/response_handler.js b/src/plugins/discover_legacy/public/application/angular/response_handler.js new file mode 100644 index 000000000000..853de0086168 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/angular/response_handler.js @@ -0,0 +1,131 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { getServices } from '../../opensearch_dashboards_services'; +import { buildPointSeriesData } from './helpers'; + +function tableResponseHandler(table, dimensions) { + const converted = { tables: [] }; + const split = dimensions.splitColumn || dimensions.splitRow; + + if (split) { + converted.direction = dimensions.splitRow ? 'row' : 'column'; + const splitColumnIndex = split[0].accessor; + const splitColumnFormatter = getServices().data.fieldFormats.deserialize(split[0].format); + const splitColumn = table.columns[splitColumnIndex]; + const splitMap = {}; + let splitIndex = 0; + + table.rows.forEach((row, rowIndex) => { + const splitValue = row[splitColumn.id]; + + if (!splitMap.hasOwnProperty(splitValue)) { + splitMap[splitValue] = splitIndex++; + const tableGroup = { + $parent: converted, + title: `${splitColumnFormatter.convert(splitValue)}: ${splitColumn.name}`, + name: splitColumn.name, + key: splitValue, + column: splitColumnIndex, + row: rowIndex, + table, + tables: [], + }; + tableGroup.tables.push({ + $parent: tableGroup, + columns: table.columns, + rows: [], + }); + + converted.tables.push(tableGroup); + } + + const tableIndex = splitMap[splitValue]; + converted.tables[tableIndex].tables[0].rows.push(row); + }); + } else { + converted.tables.push({ + columns: table.columns, + rows: table.rows, + }); + } + + return converted; +} + +function convertTableGroup(tableGroup, convertTable) { + const tables = tableGroup.tables; + + if (!tables.length) return; + + const firstChild = tables[0]; + if (firstChild.columns) { + const chart = convertTable(firstChild); + // if chart is within a split, assign group title to its label + if (tableGroup.$parent) { + chart.label = tableGroup.title; + } + return chart; + } + + const out = {}; + let outList; + + tables.forEach(function (table) { + if (!outList) { + const direction = tableGroup.direction === 'row' ? 'rows' : 'columns'; + outList = out[direction] = []; + } + + let output; + if ((output = convertTableGroup(table, convertTable))) { + outList.push(output); + } + }); + + return out; +} + +export const discoverResponseHandler = (response, dimensions) => { + const tableGroup = tableResponseHandler(response, dimensions); + + let converted = convertTableGroup(tableGroup, (table) => { + return buildPointSeriesData(table, dimensions); + }); + if (!converted) { + // mimic a row of tables that doesn't have any tables + // https://github.com/opensearch-project/OpenSearch-Dashboards/blob/7bfb68cd24ed42b1b257682f93c50cd8d73e2520/src/kibana/components/vislib/components/zero_injection/inject_zeros.js#L32 + converted = { rows: [] }; + } + + converted.hits = response.rows.length; + + return converted; +}; diff --git a/src/plugins/discover_legacy/public/application/application.ts b/src/plugins/discover_legacy/public/application/application.ts new file mode 100644 index 000000000000..49fd743d76ef --- /dev/null +++ b/src/plugins/discover_legacy/public/application/application.ts @@ -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 './index.scss'; +import angular from 'angular'; +import { getServices } from '../opensearch_dashboards_services'; + +/** + * Here's where Discover's inner angular is mounted and rendered + */ +export async function renderApp(moduleName: string, element: HTMLElement) { + // do not wait for fontawesome + getServices().opensearchDashboardsLegacy.loadFontAwesome(); + await import('./angular'); + const $injector = mountDiscoverApp(moduleName, element); + return () => $injector.get('$rootScope').$destroy(); +} + +function mountDiscoverApp(moduleName: string, element: HTMLElement) { + const mountpoint = document.createElement('div'); + const appWrapper = document.createElement('div'); + appWrapper.setAttribute('ng-view', ''); + mountpoint.appendChild(appWrapper); + // bootstrap angular into detached element and attach it later to + // make angular-within-angular possible + const $injector = angular.bootstrap(mountpoint, [moduleName]); + element.appendChild(mountpoint); + return $injector; +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..a1ef06b81cf2 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.test.tsx @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { ContextErrorMessage } from './context_error_message'; +// @ts-ignore +import { FAILURE_REASONS, LOADING_STATUS } from '../../angular/context/query'; +import { findTestSubject } from 'test_utils/helpers'; + +describe('loading spinner', function () { + let component: ReactWrapper; + + it('ContextErrorMessage does not render on loading', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(0); + }); + + it('ContextErrorMessage does not render on success loading', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(0); + }); + + it('ContextErrorMessage renders just the title if the reason is not specifically handled', () => { + component = mountWithIntl(); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(1); + expect(findTestSubject(component, 'contextErrorMessageBody').text()).toBe(''); + }); + + it('ContextErrorMessage renders the reason for unknown errors', () => { + component = mountWithIntl( + + ); + expect(findTestSubject(component, 'contextErrorMessageTitle').length).toBe(1); + expect(findTestSubject(component, 'contextErrorMessageBody').length).toBe(1); + }); +}); diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..8fe36f592275 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message.tsx @@ -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 React from 'react'; +import { EuiCallOut, EuiText } from '@elastic/eui'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +// @ts-ignore +import { FAILURE_REASONS, LOADING_STATUS } from '../../angular/context/query'; + +export interface ContextErrorMessageProps { + /** + * the status of the loading action + */ + status: string; + /** + * the reason of the error + */ + reason?: string; +} + +export function ContextErrorMessage({ status, reason }: ContextErrorMessageProps) { + if (status !== LOADING_STATUS.FAILED) { + return null; + } + return ( + + + } + color="danger" + iconType="alert" + data-test-subj="contextErrorMessageTitle" + > + + {reason === FAILURE_REASONS.UNKNOWN && ( + + )} + + + + ); +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..077682c67d06 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/context_error_message/context_error_message_directive.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 { ContextErrorMessage } from './context_error_message'; + +export function createContextErrorMessageDirective(reactDirective: any) { + return reactDirective(ContextErrorMessage, [ + ['status', { watchDepth: 'reference' }], + ['reason', { watchDepth: 'reference' }], + ]); +} diff --git a/src/plugins/discover_legacy/public/application/components/context_error_message/index.ts b/src/plugins/discover_legacy/public/application/components/context_error_message/index.ts new file mode 100644 index 000000000000..99cd662ecca3 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/context_error_message/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 { ContextErrorMessage } from './context_error_message'; +export { createContextErrorMessageDirective } from './context_error_message_directive'; diff --git a/src/plugins/discover_legacy/public/application/components/create_discover_legacy_directive.ts b/src/plugins/discover_legacy/public/application/components/create_discover_legacy_directive.ts new file mode 100644 index 000000000000..09cc33964862 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/create_discover_legacy_directive.ts @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { DiscoverLegacy } from './discover_legacy'; + +export function createDiscoverLegacyDirective(reactDirective: any) { + return reactDirective(DiscoverLegacy, [ + ['addColumn', { watchDepth: 'reference' }], + ['fetch', { watchDepth: 'reference' }], + ['fetchCounter', { watchDepth: 'reference' }], + ['fetchError', { watchDepth: 'reference' }], + ['fieldCounts', { watchDepth: 'reference' }], + ['histogramData', { watchDepth: 'reference' }], + ['hits', { watchDepth: 'reference' }], + ['indexPattern', { watchDepth: 'reference' }], + ['minimumVisibleRows', { watchDepth: 'reference' }], + ['onAddFilter', { watchDepth: 'reference' }], + ['onChangeInterval', { watchDepth: 'reference' }], + ['onMoveColumn', { watchDepth: 'reference' }], + ['onRemoveColumn', { watchDepth: 'reference' }], + ['onSetColumns', { watchDepth: 'reference' }], + ['onSkipBottomButtonClick', { watchDepth: 'reference' }], + ['onSort', { watchDepth: 'reference' }], + ['opts', { watchDepth: 'reference' }], + ['resetQuery', { watchDepth: 'reference' }], + ['resultState', { watchDepth: 'reference' }], + ['rows', { watchDepth: 'reference' }], + ['savedSearch', { watchDepth: 'reference' }], + ['searchSource', { watchDepth: 'reference' }], + ['setIndexPattern', { watchDepth: 'reference' }], + ['showSaveQuery', { watchDepth: 'reference' }], + ['state', { watchDepth: 'reference' }], + ['timefilterUpdateHandler', { watchDepth: 'reference' }], + ['timeRange', { watchDepth: 'reference' }], + ['topNavMenu', { watchDepth: 'reference' }], + ['updateQuery', { watchDepth: 'reference' }], + ['updateSavedQueryId', { watchDepth: 'reference' }], + ['vis', { watchDepth: 'reference' }], + ]); +} diff --git a/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx b/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx new file mode 100644 index 000000000000..d4a3d235a188 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/discover_legacy.tsx @@ -0,0 +1,368 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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, useCallback, useEffect } from 'react'; +import classNames from 'classnames'; +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'; +import { HitsCounter } from './hits_counter'; +import { TimechartHeader } from './timechart_header'; +import { DiscoverSidebar } from './sidebar'; +import { getServices, IndexPattern } from '../../opensearch_dashboards_services'; +// @ts-ignore +import { DiscoverNoResults } from '../angular/directives/no_results'; +import { DiscoverUninitialized } from '../angular/directives/uninitialized'; +import { DiscoverHistogram } from '../angular/directives/histogram'; +import { LoadingSpinner } from './loading_spinner/loading_spinner'; +import { DocTableLegacy } from '../angular/doc_table/create_doc_table_react'; +import { SkipBottomButton } from './skip_bottom_button'; +import { + IndexPatternField, + search, + ISearchSource, + TimeRange, + Query, + IndexPatternAttributes, +} from '../../../../data/public'; +import { Chart } from '../angular/helpers/point_series'; +import { AppState } from '../angular/discover_state'; +import { SavedSearch } from '../../saved_searches'; + +import { SavedObject } from '../../../../../core/types'; +import { Vis } from '../../../../visualizations/public'; +import { TopNavMenuData } from '../../../../navigation/public'; + +export interface DiscoverLegacyProps { + addColumn: (column: string) => void; + fetch: () => void; + fetchCounter: number; + fieldCounts: Record; + histogramData: Chart; + hits: number; + indexPattern: IndexPattern; + minimumVisibleRows: number; + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + onChangeInterval: (interval: string) => void; + onMoveColumn: (columns: string, newIdx: number) => void; + onRemoveColumn: (column: string) => void; + onSetColumns: (columns: string[]) => void; + onSkipBottomButtonClick: () => void; + onSort: (sort: string[][]) => void; + opts: { + savedSearch: SavedSearch; + config: IUiSettingsClient; + indexPatternList: Array>; + timefield: string; + sampleSize: number; + fixedScroll: (el: HTMLElement) => void; + setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + }; + resetQuery: () => void; + resultState: string; + rows: Array>; + searchSource: ISearchSource; + setIndexPattern: (id: string) => void; + showSaveQuery: boolean; + state: AppState; + timefilterUpdateHandler: (ranges: { from: number; to: number }) => void; + timeRange?: { from: string; to: string }; + topNavMenu: TopNavMenuData[]; + updateQuery: (payload: { dateRange: TimeRange; query?: Query }, isUpdate?: boolean) => void; + updateSavedQueryId: (savedQueryId?: string) => void; + vis?: Vis; +} + +const KEY_SHOW_NOTICE = 'discover:deprecation-notice:show'; + +export function DiscoverLegacy({ + addColumn, + fetch, + fetchCounter, + fieldCounts, + histogramData, + hits, + indexPattern, + minimumVisibleRows, + onAddFilter, + onChangeInterval, + onMoveColumn, + onRemoveColumn, + onSkipBottomButtonClick, + onSort, + opts, + resetQuery, + resultState, + rows, + searchSource, + setIndexPattern, + showSaveQuery, + state, + timefilterUpdateHandler, + timeRange, + topNavMenu, + updateQuery, + updateSavedQueryId, + vis, +}: DiscoverLegacyProps) { + const [isSidebarClosed, setIsSidebarClosed] = useState(false); + const [isCallOutVisible, setIsCallOutVisible] = useState( + localStorage.getItem(KEY_SHOW_NOTICE) !== 'false' + ); + const { TopNavMenu } = getServices().navigation.ui; + const { savedSearch, indexPatternList } = opts; + const bucketAggConfig = vis?.data?.aggs?.aggs[1]; + const bucketInterval = + bucketAggConfig && search.aggs.isDateHistogramBucketAggConfig(bucketAggConfig) + ? bucketAggConfig.buckets?.getInterval() + : undefined; + const [fixedScrollEl, setFixedScrollEl] = useState(); + + const closeCallOut = () => { + localStorage.setItem(KEY_SHOW_NOTICE, 'false'); + setIsCallOutVisible(false); + }; + + let callOut; + + if (isCallOutVisible) { + callOut = ( +
+ +

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

+
+
+ ); + } + + useEffect(() => (fixedScrollEl ? opts.fixedScroll(fixedScrollEl) : undefined), [ + fixedScrollEl, + opts, + ]); + const fixedScrollRef = useCallback( + (node: HTMLElement) => { + if (node !== null) { + setFixedScrollEl(node); + } + }, + [setFixedScrollEl] + ); + const sidebarClassName = classNames({ + closed: isSidebarClosed, + }); + + const mainSectionClassName = classNames({ + 'col-md-10': !isSidebarClosed, + 'col-md-12': isSidebarClosed, + }); + + return ( + +
+

{savedSearch.title}

+
+ +
+
+
+
+ {!isSidebarClosed && ( +
+ +
+ )} + setIsSidebarClosed(!isSidebarClosed)} + data-test-subj="collapseSideBarButton" + aria-controls="discover-sidebar" + aria-expanded={isSidebarClosed ? 'false' : 'true'} + aria-label="Toggle sidebar" + className="dscCollapsibleSidebar__collapseButton euiButtonIcon--auto" + /> +
+
+ {callOut} + {resultState === 'none' && ( + + )} + {resultState === 'uninitialized' && } + {/* @TODO: Solved in the Angular way to satisfy functional test - should be improved*/} + +
+ +
+
+ {resultState === 'ready' && ( +
+ + 0 ? hits : 0} + showResetButton={!!(savedSearch && savedSearch.id)} + onResetQuery={resetQuery} + /> + {opts.timefield && ( + + )} + + {opts.timefield && ( +
+ {vis && rows.length !== 0 && ( +
+ +
+ )} +
+ )} + +
+
+

+ +

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

+ +

+
+ + + + ); +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..42c11152e263 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/__snapshots__/discover_index_pattern.test.tsx.snap @@ -0,0 +1,3 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DiscoverIndexPattern Invalid props dont cause an exception: "" 1`] = `""`; diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/change_indexpattern.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/change_indexpattern.tsx new file mode 100644 index 000000000000..553031f06721 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/change_indexpattern.tsx @@ -0,0 +1,131 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { + EuiButtonEmpty, + EuiPopover, + EuiPopoverTitle, + EuiSelectable, + EuiButtonEmptyProps, +} from '@elastic/eui'; +import { EuiSelectableProps } from '@elastic/eui/src/components/selectable/selectable'; +import { IndexPatternRef } from './types'; + +export type ChangeIndexPatternTriggerProps = EuiButtonEmptyProps & { + label: string; + title?: string; +}; + +export function ChangeIndexPattern({ + indexPatternRefs, + indexPatternId, + onChangeIndexPattern, + trigger, + selectableProps, +}: { + trigger: ChangeIndexPatternTriggerProps; + indexPatternRefs: IndexPatternRef[]; + onChangeIndexPattern: (newId: string) => void; + indexPatternId?: string; + selectableProps?: EuiSelectableProps; +}) { + const [isPopoverOpen, setPopoverIsOpen] = useState(false); + + const createTrigger = function () { + const { label, title, ...rest } = trigger; + return ( + setPopoverIsOpen(!isPopoverOpen)} + {...rest} + > + {label} + + ); + }; + + return ( + setPopoverIsOpen(false)} + className="eui-textTruncate" + anchorClassName="eui-textTruncate" + display="block" + panelPaddingSize="s" + ownFocus + > +
+ + {i18n.translate('discover.fieldChooser.indexPattern.changeIndexPatternTitle', { + defaultMessage: 'Change index pattern', + })} + + ({ + label: title, + key: id, + value: id, + checked: id === indexPatternId ? 'on' : undefined, + }))} + onChange={(choices) => { + const choice = (choices.find(({ checked }) => checked) as unknown) as { + value: string; + }; + onChangeIndexPattern(choice.value); + setPopoverIsOpen(false); + }} + searchProps={{ + compressed: true, + ...(selectableProps ? selectableProps.searchProps : undefined), + }} + > + {(list, search) => ( + <> + {search} + {list} + + )} + +
+
+ ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.scss b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.scss new file mode 100644 index 000000000000..8e1dd41f66ab --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.scss @@ -0,0 +1,4 @@ +.dscSidebarItem__fieldPopoverPanel { + min-width: 260px; + max-width: 300px; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.test.tsx new file mode 100644 index 000000000000..1b384a4b5550 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.test.tsx @@ -0,0 +1,152 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +// @ts-ignore +import { findTestSubject } from '@elastic/eui/lib/test'; +// @ts-ignore +import stubbedLogstashFields from 'fixtures/logstash_fields'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { DiscoverField } from './discover_field'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { IndexPatternField } from '../../../../../data/public'; +import { getStubIndexPattern } from '../../../../../data/public/test_utils'; + +jest.mock('../../../opensearch_dashboards_services', () => ({ + getServices: () => ({ + history: () => ({ + location: { + search: '', + }, + }), + capabilities: { + visualize: { + show: true, + }, + }, + uiSettings: { + get: (key: string) => { + if (key === 'fields:popularLimit') { + return 5; + } else if (key === 'shortDots:enable') { + return false; + } + }, + }, + }), +})); + +function getComponent({ + selected = false, + showDetails = false, + useShortDots = false, + field, +}: { + selected?: boolean; + showDetails?: boolean; + useShortDots?: boolean; + field?: IndexPatternField; +}) { + const indexPattern = getStubIndexPattern( + 'logstash-*', + (cfg: any) => cfg, + 'time', + stubbedLogstashFields(), + coreMock.createSetup() + ); + + const finalField = + field ?? + new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + + const props = { + indexPattern, + columns: [], + field: finalField, + getDetails: jest.fn(() => ({ buckets: [], error: '', exists: 1, total: 1 })), + onAddFilter: jest.fn(), + onAddField: jest.fn(), + onRemoveField: jest.fn(), + showDetails, + selected, + useShortDots, + }; + const comp = mountWithIntl(); + return { comp, props }; +} + +describe('discover sidebar field', function () { + it('should allow selecting fields', function () { + const { comp, props } = getComponent({}); + findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + expect(props.onAddField).toHaveBeenCalledWith('bytes'); + }); + it('should allow deselecting fields', function () { + const { comp, props } = getComponent({ selected: true }); + findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + expect(props.onRemoveField).toHaveBeenCalledWith('bytes'); + }); + it('should trigger getDetails', function () { + const { comp, props } = getComponent({ selected: true }); + findTestSubject(comp, 'field-bytes-showDetails').simulate('click'); + expect(props.getDetails).toHaveBeenCalledWith(props.field); + }); + it('should not allow clicking on _source', function () { + const field = new IndexPatternField( + { + name: '_source', + type: '_source', + esTypes: ['_source'], + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + '_source' + ); + const { comp, props } = getComponent({ + selected: true, + field, + }); + findTestSubject(comp, 'field-_source-showDetails').simulate('click'); + expect(props.getDetails).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.tsx new file mode 100644 index 000000000000..e807267435eb --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field.tsx @@ -0,0 +1,245 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState } from 'react'; +import { EuiPopover, EuiPopoverTitle, EuiButtonIcon, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { DiscoverFieldDetails } from './discover_field_details'; +import { FieldIcon, FieldButton } from '../../../../../opensearch_dashboards_react/public'; +import { FieldDetails } from './types'; +import { IndexPatternField, IndexPattern } from '../../../../../data/public'; +import { shortenDottedString } from '../../helpers'; +import { getFieldTypeName } from './lib/get_field_type_name'; +import './discover_field.scss'; + +export interface DiscoverFieldProps { + /** + * the selected columns displayed in the doc table in discover + */ + columns: string[]; + /** + * The displayed field + */ + field: IndexPatternField; + /** + * The currently selected index pattern + */ + indexPattern: IndexPattern; + /** + * Callback to add/select the field + */ + onAddField: (fieldName: string) => void; + /** + * Callback to add a filter to filter bar + */ + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + /** + * Callback to remove/deselect a the field + * @param fieldName + */ + onRemoveField: (fieldName: string) => void; + /** + * Retrieve details data for the field + */ + getDetails: (field: IndexPatternField) => FieldDetails; + /** + * Determines whether the field is selected + */ + selected?: boolean; + /** + * Determines whether the field name is shortened test.sub1.sub2 = t.s.sub2 + */ + useShortDots?: boolean; +} + +export function DiscoverField({ + columns, + field, + indexPattern, + onAddField, + onRemoveField, + onAddFilter, + getDetails, + selected, + useShortDots, +}: DiscoverFieldProps) { + const addLabelAria = i18n.translate('discover.fieldChooser.discoverField.addButtonAriaLabel', { + defaultMessage: 'Add {field} to table', + values: { field: field.name }, + }); + const removeLabelAria = i18n.translate( + 'discover.fieldChooser.discoverField.removeButtonAriaLabel', + { + defaultMessage: 'Remove {field} from table', + values: { field: field.name }, + } + ); + + const [infoIsOpen, setOpen] = useState(false); + + const toggleDisplay = (f: IndexPatternField) => { + if (selected) { + onRemoveField(f.name); + } else { + onAddField(f.name); + } + }; + + function togglePopover() { + setOpen(!infoIsOpen); + } + + function wrapOnDot(str?: string) { + // u200B is a non-width white-space character, which allows + // the browser to efficiently word-wrap right after the dot + // without us having to draw a lot of extra DOM elements, etc + return str ? str.replace(/\./g, '.\u200B') : ''; + } + + const dscFieldIcon = ( + + ); + + const fieldName = ( + + {useShortDots ? wrapOnDot(shortenDottedString(field.name)) : wrapOnDot(field.displayName)} + + ); + + let actionButton; + if (field.name !== '_source' && !selected) { + actionButton = ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={addLabelAria} + /> + + ); + } else if (field.name !== '_source' && selected) { + actionButton = ( + + ) => { + if (ev.type === 'click') { + ev.currentTarget.focus(); + } + ev.preventDefault(); + ev.stopPropagation(); + toggleDisplay(field); + }} + data-test-subj={`fieldToggle-${field.name}`} + aria-label={removeLabelAria} + /> + + ); + } + + if (field.type === '_source') { + return ( + + ); + } + + return ( + { + togglePopover(); + }} + dataTestSubj={`field-${field.name}-showDetails`} + fieldIcon={dscFieldIcon} + fieldAction={actionButton} + fieldName={fieldName} + /> + } + isOpen={infoIsOpen} + closePopover={() => setOpen(false)} + anchorPosition="rightUp" + panelClassName="dscSidebarItem__fieldPopoverPanel" + > + + {' '} + {i18n.translate('discover.fieldChooser.discoverField.fieldTopValuesLabel', { + defaultMessage: 'Top 5 values', + })} + + {infoIsOpen && ( + + )} + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.scss b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.scss new file mode 100644 index 000000000000..90b645f70084 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.scss @@ -0,0 +1,4 @@ +.dscFieldDetails__barContainer { + // Constrains value to the flex item, and allows for truncation when necessary + min-width: 0; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.tsx new file mode 100644 index 000000000000..6a4dbe295e50 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_bucket.tsx @@ -0,0 +1,133 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { EuiText, EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { StringFieldProgressBar } from './string_progress_bar'; +import { Bucket } from './types'; +import { IndexPatternField } from '../../../../../data/public'; +import './discover_field_bucket.scss'; + +interface Props { + bucket: Bucket; + field: IndexPatternField; + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; +} + +export function DiscoverFieldBucket({ field, bucket, onAddFilter }: Props) { + const emptyTxt = i18n.translate('discover.fieldChooser.detailViews.emptyStringText', { + defaultMessage: 'Empty string', + }); + const addLabel = i18n.translate('discover.fieldChooser.detailViews.filterValueButtonAriaLabel', { + defaultMessage: 'Filter for {field}: "{value}"', + values: { value: bucket.value, field: field.name }, + }); + const removeLabel = i18n.translate( + 'discover.fieldChooser.detailViews.filterOutValueButtonAriaLabel', + { + defaultMessage: 'Filter out {field}: "{value}"', + values: { value: bucket.value, field: field.name }, + } + ); + + return ( + <> + + + + + + {bucket.display === '' ? emptyTxt : bucket.display} + + + + + {bucket.percent.toFixed(1)}% + + + + + + {field.filterable && ( + +
+ onAddFilter(field, bucket.value, '+')} + aria-label={addLabel} + data-test-subj={`plus-${field.name}-${bucket.value}`} + style={{ + minHeight: 'auto', + minWidth: 'auto', + paddingRight: 2, + paddingLeft: 2, + paddingTop: 0, + paddingBottom: 0, + }} + className={'euiButtonIcon--auto'} + /> + onAddFilter(field, bucket.value, '-')} + aria-label={removeLabel} + data-test-subj={`minus-${field.name}-${bucket.value}`} + style={{ + minHeight: 'auto', + minWidth: 'auto', + paddingTop: 0, + paddingBottom: 0, + paddingRight: 2, + paddingLeft: 2, + }} + className={'euiButtonIcon--auto'} + /> +
+
+ )} +
+ + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.scss b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.scss new file mode 100644 index 000000000000..7bf0892d0148 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.scss @@ -0,0 +1,6 @@ +.dscFieldDetails__visualizeBtn { + @include euiFontSizeXS; + + height: $euiSizeL !important; + min-width: $euiSize * 4; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.test.tsx new file mode 100644 index 000000000000..63d5c7ace303 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.test.tsx @@ -0,0 +1,312 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +// @ts-ignore +import { findTestSubject } from '@elastic/eui/lib/test'; +import { act } from '@testing-library/react'; +// @ts-ignore +import stubbedLogstashFields from 'fixtures/logstash_fields'; +import { mountWithIntl, nextTick } from 'test_utils/enzyme_helpers'; +import { DiscoverFieldDetails } from './discover_field_details'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { IndexPatternField } from '../../../../../data/public'; +import { getStubIndexPattern } from '../../../../../data/public/test_utils'; + +const mockGetHref = jest.fn(); +const mockGetTriggerCompatibleActions = jest.fn(); + +jest.mock('../../../opensearch_dashboards_services', () => ({ + getUiActions: () => ({ + getTriggerCompatibleActions: mockGetTriggerCompatibleActions, + }), +})); + +const indexPattern = getStubIndexPattern( + 'logstash-*', + (cfg: any) => cfg, + 'time', + stubbedLogstashFields(), + coreMock.createSetup() +); + +describe('discover sidebar field details', function () { + const defaultProps = { + columns: [], + details: { buckets: [], error: '', exists: 1, total: 1 }, + indexPattern, + onAddFilter: jest.fn(), + }; + + beforeEach(() => { + mockGetHref.mockReturnValue('/foo/bar'); + mockGetTriggerCompatibleActions.mockReturnValue([ + { + getHref: mockGetHref, + }, + ]); + }); + + function mountComponent(field: IndexPatternField, props?: Record) { + const compProps = { ...defaultProps, ...props, field }; + return mountWithIntl(); + } + + it('should render buckets if they exist', async function () { + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const buckets = [1, 2, 3].map((n) => ({ + display: `display-${n}`, + value: `value-${n}`, + percent: 25, + count: 100, + })); + const comp = mountComponent(visualizableField, { + details: { ...defaultProps.details, buckets }, + }); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').children().length).toBe( + buckets.length + ); + // Visualize link should not be rendered until async hook update + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(0); + + // Complete async hook + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').children().length).toBe( + buckets.length + ); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(1); + }); + + it('should only render buckets if they exist', async function () { + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(visualizableField); + expect(findTestSubject(comp, 'fieldVisualizeContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(0); + + await act(async () => { + await nextTick(); + comp.update(); + }); + + expect(findTestSubject(comp, 'fieldVisualizeContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(1); + }); + + it('should render a details error', async function () { + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const errText = 'Some error'; + const comp = mountComponent(visualizableField, { + details: { ...defaultProps.details, error: errText }, + }); + expect(findTestSubject(comp, 'fieldVisualizeContainer').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeBucketContainer').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualizeError').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualizeError').text()).toBe(errText); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(1); + }); + + it('should handle promise rejection from isFieldVisualizable', async function () { + mockGetTriggerCompatibleActions.mockRejectedValue(new Error('Async error')); + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(visualizableField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(0); + }); + + it('should handle promise rejection from getVisualizeHref', async function () { + mockGetHref.mockRejectedValue(new Error('Async error')); + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(visualizableField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(0); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(0); + }); + + it('should enable the visualize link for a number field', async function () { + const visualizableField = new IndexPatternField( + { + name: 'bytes', + type: 'number', + esTypes: ['long'], + count: 10, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'bytes' + ); + const comp = mountComponent(visualizableField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualizeLink').length).toBe(1); + expect(findTestSubject(comp, 'fieldVisualize-bytes').length).toBe(1); + }); + + it('should disable the visualize link for an _id field', async function () { + expect.assertions(1); + const conflictField = new IndexPatternField( + { + name: '_id', + type: 'string', + esTypes: ['_id'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'test' + ); + const comp = mountComponent(conflictField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualize-_id').length).toBe(0); + }); + + it('should disable the visualize link for an unknown field', async function () { + const unknownField = new IndexPatternField( + { + name: 'test', + type: 'unknown', + esTypes: ['double'], + count: 0, + scripted: false, + searchable: true, + aggregatable: true, + readFromDocValues: true, + }, + 'test' + ); + const comp = mountComponent(unknownField); + + await act(async () => { + await nextTick(); + comp.update(); + }); + expect(findTestSubject(comp, 'fieldVisualize-test').length).toBe(0); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.tsx new file mode 100644 index 000000000000..906c173ed07d --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_details.tsx @@ -0,0 +1,153 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { useState, useEffect } from 'react'; +import { EuiLink, EuiIconTip, EuiText, EuiPopoverFooter, EuiButton, EuiSpacer } from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; +import { DiscoverFieldBucket } from './discover_field_bucket'; +import { getWarnings } from './lib/get_warnings'; +import { + triggerVisualizeActions, + isFieldVisualizable, + getVisualizeHref, +} from './lib/visualize_trigger_utils'; +import { Bucket, FieldDetails } from './types'; +import { IndexPatternField, IndexPattern } from '../../../../../data/public'; +import './discover_field_details.scss'; + +interface DiscoverFieldDetailsProps { + columns: string[]; + details: FieldDetails; + field: IndexPatternField; + indexPattern: IndexPattern; + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; +} + +export function DiscoverFieldDetails({ + columns, + details, + field, + indexPattern, + onAddFilter, +}: DiscoverFieldDetailsProps) { + const warnings = getWarnings(field); + const [showVisualizeLink, setShowVisualizeLink] = useState(false); + const [visualizeLink, setVisualizeLink] = useState(''); + + useEffect(() => { + const checkIfVisualizable = async () => { + const visualizable = await isFieldVisualizable(field, indexPattern.id, columns).catch( + () => false + ); + + setShowVisualizeLink(visualizable); + if (visualizable) { + const href = await getVisualizeHref(field, indexPattern.id, columns).catch(() => ''); + setVisualizeLink(href || ''); + } + }; + checkIfVisualizable(); + }, [field, indexPattern.id, columns]); + + const handleVisualizeLinkClick = (event: React.MouseEvent) => { + // regular link click. let the uiActions code handle the navigation and show popup if needed + event.preventDefault(); + triggerVisualizeActions(field, indexPattern.id, columns); + }; + + return ( + <> +
+ {details.error && ( + + {details.error} + + )} + + {!details.error && details.buckets.length > 0 && ( +
+ {details.buckets.map((bucket: Bucket, idx: number) => ( + + ))} +
+ )} + + {showVisualizeLink && visualizeLink && ( +
+ + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + handleVisualizeLinkClick(e)} + href={visualizeLink} + size="s" + className="dscFieldDetails__visualizeBtn" + data-test-subj={`fieldVisualize-${field.name}`} + > + + + {warnings.length > 0 && ( + + )} +
+ )} +
+ {!details.error && ( + + + {!indexPattern.metaFields.includes(field.name) && !field.scripted ? ( + onAddFilter('_exists_', field.name, '+')}> + {' '} + {details.exists} + + ) : ( + {details.exists} + )}{' '} + / {details.total}{' '} + + + + )} + + ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.test.tsx new file mode 100644 index 000000000000..f78505e11f1e --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.test.tsx @@ -0,0 +1,160 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { act } from 'react-dom/test-utils'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { findTestSubject } from 'test_utils/helpers'; +import { DiscoverFieldSearch, Props } from './discover_field_search'; +import { EuiButtonGroupProps, EuiPopover } from '@elastic/eui'; +import { ReactWrapper } from 'enzyme'; + +describe('DiscoverFieldSearch', () => { + const defaultProps = { + onChange: jest.fn(), + value: 'test', + types: ['any', 'string', '_source'], + }; + + function mountComponent(props?: Props) { + const compProps = props || defaultProps; + return mountWithIntl(); + } + + function findButtonGroup(component: ReactWrapper, id: string) { + return component.find(`[data-test-subj="${id}ButtonGroup"]`).first(); + } + + test('enter value', () => { + const component = mountComponent(); + const input = findTestSubject(component, 'fieldFilterSearchInput'); + input.simulate('change', { target: { value: 'new filter' } }); + expect(defaultProps.onChange).toBeCalledTimes(1); + }); + + test('change in active filters should change facet selection and call onChange', () => { + const onChange = jest.fn(); + const component = mountComponent({ ...defaultProps, ...{ onChange } }); + let btn = findTestSubject(component, 'toggleFieldFilterButton'); + expect(btn.hasClass('euiFacetButton--isSelected')).toBeFalsy(); + btn.simulate('click'); + const aggregatableButtonGroup = findButtonGroup(component, 'aggregatable'); + act(() => { + // @ts-ignore + (aggregatableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null); + }); + component.update(); + btn = findTestSubject(component, 'toggleFieldFilterButton'); + expect(btn.hasClass('euiFacetButton--isSelected')).toBe(true); + expect(onChange).toBeCalledWith('aggregatable', true); + }); + + test('change in active filters should change filters count', () => { + const component = mountComponent(); + let btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + btn = findTestSubject(component, 'toggleFieldFilterButton'); + const badge = btn.find('.euiNotificationBadge'); + // no active filters + expect(badge.text()).toEqual('0'); + // change value of aggregatable select + const aggregatableButtonGroup = findButtonGroup(component, 'aggregatable'); + act(() => { + // @ts-ignore + (aggregatableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null); + }); + component.update(); + expect(badge.text()).toEqual('1'); + // change value of searchable select + const searchableButtonGroup = findButtonGroup(component, 'searchable'); + act(() => { + // @ts-ignore + (searchableButtonGroup.props() as EuiButtonGroupProps).onChange('searchable-true', null); + }); + component.update(); + expect(badge.text()).toEqual('2'); + // change value of searchable select + act(() => { + // @ts-ignore + (searchableButtonGroup.props() as EuiButtonGroupProps).onChange('searchable-any', null); + }); + component.update(); + expect(badge.text()).toEqual('1'); + }); + + test('change in missing fields switch should not change filter count', () => { + const component = mountComponent(); + const btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + const badge = btn.find('.euiNotificationBadge'); + expect(badge.text()).toEqual('0'); + const missingSwitch = findTestSubject(component, 'missingSwitch'); + missingSwitch.simulate('change', { target: { value: false } }); + expect(badge.text()).toEqual('0'); + }); + + test('change in filters triggers onChange', () => { + const onChange = jest.fn(); + const component = mountComponent({ ...defaultProps, ...{ onChange } }); + const btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + const aggregtableButtonGroup = findButtonGroup(component, 'aggregatable'); + const missingSwitch = findTestSubject(component, 'missingSwitch'); + act(() => { + // @ts-ignore + (aggregtableButtonGroup.props() as EuiButtonGroupProps).onChange('aggregatable-true', null); + }); + missingSwitch.simulate('click'); + expect(onChange).toBeCalledTimes(2); + }); + + test('change in type filters triggers onChange with appropriate value', () => { + const onChange = jest.fn(); + const component = mountComponent({ ...defaultProps, ...{ onChange } }); + const btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + const typeSelector = findTestSubject(component, 'typeSelect'); + typeSelector.simulate('change', { target: { value: 'string' } }); + expect(onChange).toBeCalledWith('type', 'string'); + typeSelector.simulate('change', { target: { value: 'any' } }); + expect(onChange).toBeCalledWith('type', 'any'); + }); + + test('click on filter button should open and close popover', () => { + const component = mountComponent(); + const btn = findTestSubject(component, 'toggleFieldFilterButton'); + btn.simulate('click'); + let popover = component.find(EuiPopover); + expect(popover.prop('isOpen')).toBe(true); + btn.simulate('click'); + popover = component.find(EuiPopover); + expect(popover.prop('isOpen')).toBe(false); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.tsx new file mode 100644 index 000000000000..4a1390cb1955 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_field_search.tsx @@ -0,0 +1,313 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { OptionHTMLAttributes, ReactNode, useState } from 'react'; +import { i18n } from '@osd/i18n'; +import { + EuiFacetButton, + EuiFieldSearch, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiPopoverFooter, + EuiPopoverTitle, + EuiSelect, + EuiSwitch, + EuiSwitchEvent, + EuiForm, + EuiFormRow, + EuiButtonGroup, + EuiOutsideClickDetector, +} from '@elastic/eui'; +import { FormattedMessage } from '@osd/i18n/react'; + +export interface State { + searchable: string; + aggregatable: string; + type: string; + missing: boolean; + [index: string]: string | boolean; +} + +export interface Props { + /** + * triggered on input of user into search field + */ + onChange: (field: string, value: string | boolean | undefined) => void; + + /** + * the input value of the user + */ + value?: string; + + /** + * types for the type filter + */ + types: string[]; +} + +/** + * Component is Discover's side bar to search of available fields + * Additionally there's a button displayed that allows the user to show/hide more filter fields + */ +export function DiscoverFieldSearch({ onChange, value, types }: Props) { + const searchPlaceholder = i18n.translate('discover.fieldChooser.searchPlaceHolder', { + defaultMessage: 'Search field names', + }); + const aggregatableLabel = i18n.translate('discover.fieldChooser.filter.aggregatableLabel', { + defaultMessage: 'Aggregatable', + }); + const searchableLabel = i18n.translate('discover.fieldChooser.filter.searchableLabel', { + defaultMessage: 'Searchable', + }); + const typeLabel = i18n.translate('discover.fieldChooser.filter.typeLabel', { + defaultMessage: 'Type', + }); + const typeOptions = types + ? types.map((type) => { + return { value: type, text: type }; + }) + : [{ value: 'any', text: 'any' }]; + + const [activeFiltersCount, setActiveFiltersCount] = useState(0); + const [isPopoverOpen, setPopoverOpen] = useState(false); + const [values, setValues] = useState({ + searchable: 'any', + aggregatable: 'any', + type: 'any', + missing: true, + }); + + if (typeof value !== 'string') { + // at initial rendering value is undefined (angular related), this catches the warning + // should be removed once all is react + return null; + } + + const filterBtnAriaLabel = isPopoverOpen + ? i18n.translate('discover.fieldChooser.toggleFieldFilterButtonHideAriaLabel', { + defaultMessage: 'Hide field filter settings', + }) + : i18n.translate('discover.fieldChooser.toggleFieldFilterButtonShowAriaLabel', { + defaultMessage: 'Show field filter settings', + }); + + const handleFacetButtonClicked = () => { + setPopoverOpen(!isPopoverOpen); + }; + + const applyFilterValue = (id: string, filterValue: string | boolean) => { + switch (filterValue) { + case 'any': + if (id !== 'type') { + onChange(id, undefined); + } else { + onChange(id, filterValue); + } + break; + case 'true': + onChange(id, true); + break; + case 'false': + onChange(id, false); + break; + default: + onChange(id, filterValue); + } + }; + + const isFilterActive = (name: string, filterValue: string | boolean) => { + return name !== 'missing' && filterValue !== 'any'; + }; + + const handleValueChange = (name: string, filterValue: string | boolean) => { + const previousValue = values[name]; + updateFilterCount(name, previousValue, filterValue); + const updatedValues = { ...values }; + updatedValues[name] = filterValue; + setValues(updatedValues); + applyFilterValue(name, filterValue); + }; + + const updateFilterCount = ( + name: string, + previousValue: string | boolean, + currentValue: string | boolean + ) => { + const previouslyFilterActive = isFilterActive(name, previousValue); + const filterActive = isFilterActive(name, currentValue); + const diff = Number(filterActive) - Number(previouslyFilterActive); + setActiveFiltersCount(activeFiltersCount + diff); + }; + + const handleMissingChange = (e: EuiSwitchEvent) => { + const missingValue = e.target.checked; + handleValueChange('missing', missingValue); + }; + + const buttonContent = ( + } + isSelected={activeFiltersCount > 0} + quantity={activeFiltersCount} + onClick={handleFacetButtonClicked} + > + + + ); + + const select = ( + id: string, + selectOptions: Array<{ text: ReactNode } & OptionHTMLAttributes>, + selectValue: string + ) => { + return ( + ) => + handleValueChange(id, e.target.value) + } + aria-label={i18n.translate('discover.fieldChooser.filter.fieldSelectorLabel', { + defaultMessage: 'Selection of {id} filter options', + values: { id }, + })} + data-test-subj={`${id}Select`} + compressed + /> + ); + }; + + const toggleButtons = (id: string) => { + return [ + { + id: `${id}-any`, + label: 'any', + }, + { + id: `${id}-true`, + label: 'yes', + }, + { + id: `${id}-false`, + label: 'no', + }, + ]; + }; + + const buttonGroup = (id: string, legend: string) => { + return ( + handleValueChange(id, optionId.replace(`${id}-`, ''))} + buttonSize="compressed" + isFullWidth + data-test-subj={`${id}ButtonGroup`} + /> + ); + }; + + const selectionPanel = ( +
+ + + {buttonGroup('aggregatable', aggregatableLabel)} + + + {buttonGroup('searchable', searchableLabel)} + + + {select('type', typeOptions, values.type)} + + +
+ ); + + return ( + + + + onChange('name', event.currentTarget.value)} + placeholder={searchPlaceholder} + value={value} + /> + + +
+ {}} isDisabled={!isPopoverOpen}> + { + setPopoverOpen(false); + }} + button={buttonContent} + > + + {i18n.translate('discover.fieldChooser.filter.filterByTypeLabel', { + defaultMessage: 'Filter by type', + })} + + {selectionPanel} + + + + + +
+
+ ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.test.tsx new file mode 100644 index 000000000000..9298aef92cf0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.test.tsx @@ -0,0 +1,111 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import { shallowWithIntl as shallow } from 'test_utils/enzyme_helpers'; + +// @ts-ignore +import { ShallowWrapper } from 'enzyme'; +import { ChangeIndexPattern } from './change_indexpattern'; +import { SavedObject } from 'opensearch-dashboards/server'; +import { DiscoverIndexPattern } from './discover_index_pattern'; +import { EuiSelectable } from '@elastic/eui'; +import { IIndexPattern } from 'src/plugins/data/public'; + +const indexPattern = { + id: 'test1', + title: 'test1 title', +} as IIndexPattern; + +const indexPattern1 = { + id: 'test1', + attributes: { + title: 'test1 titleToDisplay', + }, +} as SavedObject; + +const indexPattern2 = { + id: 'test2', + attributes: { + title: 'test2 titleToDisplay', + }, +} as SavedObject; + +const defaultProps = { + indexPatternList: [indexPattern1, indexPattern2], + selectedIndexPattern: indexPattern, + setIndexPattern: jest.fn(async () => {}), +}; + +function getIndexPatternPickerList(instance: ShallowWrapper) { + return instance.find(ChangeIndexPattern).first().dive().find(EuiSelectable); +} + +function getIndexPatternPickerOptions(instance: ShallowWrapper) { + return getIndexPatternPickerList(instance).prop('options'); +} + +function selectIndexPatternPickerOption(instance: ShallowWrapper, selectedLabel: string) { + const options: Array<{ label: string; checked?: 'on' | 'off' }> = getIndexPatternPickerOptions( + instance + ).map((option: any) => + option.label === selectedLabel + ? { ...option, checked: 'on' } + : { ...option, checked: undefined } + ); + return getIndexPatternPickerList(instance).prop('onChange')!(options); +} + +describe('DiscoverIndexPattern', () => { + test('Invalid props dont cause an exception', () => { + const props = { + indexPatternList: null, + selectedIndexPattern: null, + setIndexPattern: jest.fn(), + } as any; + + expect(shallow()).toMatchSnapshot(`""`); + }); + test('should list all index patterns', () => { + const instance = shallow(); + + expect(getIndexPatternPickerOptions(instance)!.map((option: any) => option.label)).toEqual([ + 'test1 titleToDisplay', + 'test2 titleToDisplay', + ]); + }); + + test('should switch data panel to target index pattern', () => { + const instance = shallow(); + + selectIndexPatternPickerOption(instance, 'test2 titleToDisplay'); + expect(defaultProps.setIndexPattern).toHaveBeenCalledWith('test2'); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.tsx new file mode 100644 index 000000000000..95154bec1939 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern.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, { useState, useEffect } from 'react'; +import { SavedObject } from 'opensearch-dashboards/public'; +import { IIndexPattern, IndexPatternAttributes } from 'src/plugins/data/public'; +import { I18nProvider } from '@osd/i18n/react'; + +import { IndexPatternRef } from './types'; +import { ChangeIndexPattern } from './change_indexpattern'; +export interface DiscoverIndexPatternProps { + /** + * list of available index patterns, if length > 1, component offers a "change" link + */ + indexPatternList: Array>; + /** + * currently selected index pattern, due to angular issues it's undefined at first rendering + */ + selectedIndexPattern: IIndexPattern; + /** + * triggered when user selects a new index pattern + */ + setIndexPattern: (id: string) => void; +} + +/** + * Component allows you to select an index pattern in discovers side bar + */ +export function DiscoverIndexPattern({ + indexPatternList, + selectedIndexPattern, + setIndexPattern, +}: DiscoverIndexPatternProps) { + const options: IndexPatternRef[] = (indexPatternList || []).map((entity) => ({ + id: entity.id, + title: entity.attributes!.title, + })); + const { id: selectedId, title: selectedTitle } = selectedIndexPattern || {}; + + const [selected, setSelected] = useState({ + id: selectedId, + title: selectedTitle || '', + }); + useEffect(() => { + const { id, title } = selectedIndexPattern; + const indexPattern = indexPatternList.find((pattern) => pattern.id === id); + const titleToDisplay = indexPattern ? indexPattern.attributes!.title : title; + setSelected({ id, title: titleToDisplay }); + }, [indexPatternList, selectedIndexPattern]); + if (!selectedId) { + return null; + } + + return ( +
+ + { + const indexPattern = options.find((pattern) => pattern.id === id); + if (indexPattern) { + setIndexPattern(id); + setSelected(indexPattern); + } + }} + /> + +
+ ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern_title.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern_title.tsx new file mode 100644 index 000000000000..30b50a9006c8 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_index_pattern_title.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 { EuiToolTip, EuiFlexItem, EuiFlexGroup, EuiTitle, EuiButtonEmpty } from '@elastic/eui'; + +import { FormattedMessage } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +export interface DiscoverIndexPatternTitleProps { + /** + * determines whether the change link is displayed + */ + isChangeable: boolean; + /** + * function triggered when the change link is clicked + */ + onChange: () => void; + /** + * title of the current index pattern + */ + title: string; +} + +/** + * Component displaying the title of the current selected index pattern + * and if changeable is true, a link is provided to change the index pattern + */ +export function DiscoverIndexPatternTitle({ + isChangeable, + onChange, + title, +}: DiscoverIndexPatternTitleProps) { + return ( + + + + +

{title}

+
+
+
+ {isChangeable && ( + + + } + > + onChange()} + iconSide="right" + iconType="arrowDown" + color="text" + /> + + + )} +
+ ); +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss new file mode 100644 index 000000000000..9c80e0afa600 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.scss @@ -0,0 +1,99 @@ +.dscSidebar__container { + padding-left: 0 !important; + padding-right: 0 !important; + background-color: transparent; + border-right-color: transparent; + border-bottom-color: transparent; +} + +.dscIndexPattern__container { + display: flex; + align-items: center; + height: $euiSize * 3; + margin-top: -$euiSizeS; +} + +.dscIndexPattern__triggerButton { + @include euiTitle("xs"); + + line-height: $euiSizeXXL; +} + +.dscFieldList { + list-style: none; + margin-bottom: 0; +} + +.dscFieldListHeader { + padding: $euiSizeS $euiSizeS 0 $euiSizeS; + background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade); +} + +.dscFieldList--popular { + background-color: lightOrDarkTheme(tint($euiColorPrimary, 90%), $euiColorLightShade); +} + +.dscFieldChooser { + padding-left: $euiSize; +} + +.dscFieldChooser__toggle { + color: $euiColorMediumShade; + margin-left: $euiSizeS !important; +} + +.dscSidebarItem { + &:hover, + &:focus-within, + &[class*="-isActive"] { + .dscSidebarItem__action { + opacity: 1; + } + } +} + +/** + * 1. Only visually hide the action, so that it's still accessible to screen readers. + * 2. When tabbed to, this element needs to be visible for keyboard accessibility. + */ +.dscSidebarItem__action { + opacity: 0; /* 1 */ + transition: none; + + &:focus { + opacity: 1; /* 2 */ + } + + font-size: $euiFontSizeXS; + padding: 2px 6px !important; + height: 22px !important; + min-width: auto !important; + + .euiButton__content { + padding: 0 4px; + } +} + +.dscFieldSearch { + padding: $euiSizeS; +} + +.dscFieldSearch__toggleButton { + width: calc(100% - #{$euiSizeS}); + color: $euiColorPrimary; + padding-left: $euiSizeXS; + margin-left: $euiSizeXS; +} + +.dscFieldSearch__filterWrapper { + flex-grow: 0; +} + +.dscFieldSearch__formWrapper { + padding: $euiSizeM; +} + +.dscFieldDetails { + color: $euiTextColor; + margin-bottom: $euiSizeS; +} diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.test.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.test.tsx new file mode 100644 index 000000000000..fa692ca22b5b --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.test.tsx @@ -0,0 +1,148 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { ReactWrapper } from 'enzyme'; +import { findTestSubject } from 'test_utils/helpers'; +// @ts-ignore +import realHits from 'fixtures/real_hits.js'; +// @ts-ignore +import stubbedLogstashFields from 'fixtures/logstash_fields'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import React from 'react'; +import { DiscoverSidebar, DiscoverSidebarProps } from './discover_sidebar'; +import { coreMock } from '../../../../../../core/public/mocks'; +import { IndexPatternAttributes } from '../../../../../data/common'; +import { getStubIndexPattern } from '../../../../../data/public/test_utils'; +import { SavedObject } from '../../../../../../core/types'; + +jest.mock('../../../opensearch_dashboards_services', () => ({ + getServices: () => ({ + history: () => ({ + location: { + search: '', + }, + }), + capabilities: { + visualize: { + show: true, + }, + discover: { + save: false, + }, + }, + uiSettings: { + get: (key: string) => { + if (key === 'fields:popularLimit') { + return 5; + } else if (key === 'shortDots:enable') { + return false; + } + }, + }, + }), +})); + +jest.mock('./lib/get_index_pattern_field_list', () => ({ + getIndexPatternFieldList: jest.fn((indexPattern) => indexPattern.fields), +})); + +function getCompProps() { + const indexPattern = getStubIndexPattern( + 'logstash-*', + (cfg: any) => cfg, + 'time', + stubbedLogstashFields(), + coreMock.createSetup() + ); + + // @ts-expect-error _.each() is passing additional args to flattenHit + const hits = _.each(_.cloneDeep(realHits), indexPattern.flattenHit) as Array< + Record + >; + + const indexPatternList = [ + { id: '0', attributes: { title: 'b' } } as SavedObject, + { id: '1', attributes: { title: 'a' } } as SavedObject, + { id: '2', attributes: { title: 'c' } } as SavedObject, + ]; + + const fieldCounts: Record = {}; + + for (const hit of hits) { + for (const key of Object.keys(indexPattern.flattenHit(hit))) { + fieldCounts[key] = (fieldCounts[key] || 0) + 1; + } + } + return { + columns: ['extension'], + fieldCounts, + hits, + indexPatternList, + onAddFilter: jest.fn(), + onAddField: jest.fn(), + onRemoveField: jest.fn(), + selectedIndexPattern: indexPattern, + setIndexPattern: jest.fn(), + state: {}, + }; +} + +describe('discover sidebar', function () { + let props: DiscoverSidebarProps; + let comp: ReactWrapper; + + beforeAll(() => { + props = getCompProps(); + comp = mountWithIntl(); + }); + + it('should have Selected Fields and Available Fields with Popular Fields sections', function () { + const popular = findTestSubject(comp, 'fieldList-popular'); + const selected = findTestSubject(comp, 'fieldList-selected'); + const unpopular = findTestSubject(comp, 'fieldList-unpopular'); + expect(popular.children().length).toBe(1); + expect(unpopular.children().length).toBe(7); + expect(selected.children().length).toBe(1); + }); + it('should allow selecting fields', function () { + findTestSubject(comp, 'fieldToggle-bytes').simulate('click'); + expect(props.onAddField).toHaveBeenCalledWith('bytes'); + }); + it('should allow deselecting fields', function () { + findTestSubject(comp, 'fieldToggle-extension').simulate('click'); + expect(props.onRemoveField).toHaveBeenCalledWith('extension'); + }); + it('should allow adding filters', function () { + findTestSubject(comp, 'field-extension-showDetails').simulate('click'); + findTestSubject(comp, 'plus-extension-gif').simulate('click'); + expect(props.onAddFilter).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.tsx b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.tsx new file mode 100644 index 000000000000..865aff590286 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/sidebar/discover_sidebar.tsx @@ -0,0 +1,326 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import './discover_sidebar.scss'; +import React, { useCallback, useEffect, useState, useMemo } from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiButtonIcon, EuiTitle, EuiSpacer } from '@elastic/eui'; +import { sortBy } from 'lodash'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { DiscoverField } from './discover_field'; +import { DiscoverIndexPattern } from './discover_index_pattern'; +import { DiscoverFieldSearch } from './discover_field_search'; +import { IndexPatternAttributes } from '../../../../../data/common'; +import { SavedObject } from '../../../../../../core/types'; +import { FIELDS_LIMIT_SETTING } from '../../../../common'; +import { groupFields } from './lib/group_fields'; +import { IndexPatternField, IndexPattern, UI_SETTINGS } from '../../../../../data/public'; +import { getDetails } from './lib/get_details'; +import { getDefaultFieldFilter, setFieldFilterProp } from './lib/field_filter'; +import { getIndexPatternFieldList } from './lib/get_index_pattern_field_list'; +import { getServices } from '../../../opensearch_dashboards_services'; + +export interface DiscoverSidebarProps { + /** + * the selected columns displayed in the doc table in discover + */ + columns: string[]; + /** + * a statistics of the distribution of fields in the given hits + */ + fieldCounts: Record; + /** + * hits fetched from OpenSearch, displayed in the doc table + */ + hits: Array>; + /** + * List of available index patterns + */ + indexPatternList: Array>; + /** + * Callback function when selecting a field + */ + onAddField: (fieldName: string) => void; + /** + * Callback function when adding a filter from sidebar + */ + onAddFilter: (field: IndexPatternField | string, value: string, type: '+' | '-') => void; + /** + * Callback function when removing a field + * @param fieldName + */ + onRemoveField: (fieldName: string) => void; + /** + * Currently selected index pattern + */ + selectedIndexPattern?: IndexPattern; + /** + * Callback function to select another index pattern + */ + setIndexPattern: (id: string) => void; +} + +export function DiscoverSidebar({ + columns, + fieldCounts, + hits, + indexPatternList, + onAddField, + onAddFilter, + onRemoveField, + selectedIndexPattern, + setIndexPattern, +}: DiscoverSidebarProps) { + const [showFields, setShowFields] = useState(false); + const [fields, setFields] = useState(null); + const [fieldFilterState, setFieldFilterState] = useState(getDefaultFieldFilter()); + const services = useMemo(() => getServices(), []); + + useEffect(() => { + const newFields = getIndexPatternFieldList(selectedIndexPattern, fieldCounts); + setFields(newFields); + }, [selectedIndexPattern, fieldCounts, hits, services]); + + const onChangeFieldSearch = useCallback( + (field: string, value: string | boolean | undefined) => { + const newState = setFieldFilterProp(fieldFilterState, field, value); + setFieldFilterState(newState); + }, + [fieldFilterState] + ); + + const getDetailsByField = useCallback( + (ipField: IndexPatternField) => getDetails(ipField, hits, selectedIndexPattern), + [hits, selectedIndexPattern] + ); + + const popularLimit = services.uiSettings.get(FIELDS_LIMIT_SETTING); + const useShortDots = services.uiSettings.get(UI_SETTINGS.SHORT_DOTS_ENABLE); + + const { + selected: selectedFields, + popular: popularFields, + unpopular: unpopularFields, + } = useMemo(() => groupFields(fields, columns, popularLimit, fieldCounts, fieldFilterState), [ + fields, + columns, + popularLimit, + fieldCounts, + fieldFilterState, + ]); + + const fieldTypes = useMemo(() => { + const result = ['any']; + if (Array.isArray(fields)) { + for (const field of fields) { + if (result.indexOf(field.type) === -1) { + result.push(field.type); + } + } + } + return result; + }, [fields]); + + if (!selectedIndexPattern || !fields) { + return null; + } + + return ( + +
+ o.attributes.title)} + /> +
+
+ + +
+
+ {fields.length > 0 && ( + <> + +

+ +

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

+ +

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

+ +

+
+
+ + + } + onChoose={[Function]} + savedObjectMetaData={ + Array [ + Object { + "getIconForSavedObject": [Function], + "name": "Saved search", + "type": "search", + }, + ] + } + savedObjects={Object {}} + uiSettings={Object {}} + /> + + + + + + + + + + +
+`; diff --git a/src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.js b/src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.js new file mode 100644 index 000000000000..f575b8dee625 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.js @@ -0,0 +1,122 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React from 'react'; +import PropTypes from 'prop-types'; +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 { getServices } from '../../../opensearch_dashboards_services'; + +const SEARCH_OBJECT_TYPE = 'search'; + +export function OpenSearchPanel(props) { + const { + core: { uiSettings, savedObjects }, + addBasePath, + } = getServices(); + + return ( + + + +

+ +

+
+
+ + + } + savedObjectMetaData={[ + { + type: SEARCH_OBJECT_TYPE, + getIconForSavedObject: () => 'search', + name: i18n.translate('discover.savedSearch.savedObjectName', { + defaultMessage: 'Saved search', + }), + }, + ]} + onChoose={(id) => { + window.location.assign(props.makeUrl(id)); + props.onClose(); + }} + uiSettings={uiSettings} + savedObjects={savedObjects} + /> + + + + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + + + +
+ ); +} + +OpenSearchPanel.propTypes = { + onClose: PropTypes.func.isRequired, + makeUrl: PropTypes.func.isRequired, +}; diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..6316471e9a40 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/top_nav/open_search_panel.test.js @@ -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 { shallow } from 'enzyme'; + +jest.mock('../../../opensearch_dashboards_services', () => { + return { + getServices: () => ({ + core: { uiSettings: {}, savedObjects: {} }, + addBasePath: (path) => path, + }), + }; +}); + +import { OpenSearchPanel } from './open_search_panel'; + +test('render', () => { + const component = shallow( {}} makeUrl={() => {}} />); + expect(component).toMatchSnapshot(); +}); diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..8cb550f49f16 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/components/top_nav/show_open_search_panel.js @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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'; + +let isOpen = false; + +export function showOpenSearchPanel({ makeUrl, I18nContext }) { + 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_legacy/public/application/doc_views/doc_views_helpers.tsx b/src/plugins/discover_legacy/public/application/doc_views/doc_views_helpers.tsx new file mode 100644 index 000000000000..3ec87455e837 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/doc_views/doc_views_helpers.tsx @@ -0,0 +1,106 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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, IController } from 'angular'; +import React from 'react'; +import { render } from 'react-dom'; +import angular, { ICompileService } from 'angular'; +import { DocViewRenderProps, AngularScope, AngularDirective } from './doc_views_types'; +import { DocViewerError } from '../components/doc_viewer/doc_viewer_render_error'; + +/** + * Compiles and injects the give angular template into the given dom node + * returns a function to cleanup the injected angular element + */ +export async function injectAngularElement( + domNode: Element, + template: string, + scopeProps: DocViewRenderProps, + Controller: IController, + getInjector: () => Promise +): Promise<() => void> { + const $injector = await getInjector(); + const rootScope: AngularScope = $injector.get('$rootScope'); + const $compile: ICompileService = $injector.get('$compile'); + const newScope = Object.assign(rootScope.$new(), scopeProps); + + if (typeof Controller === 'function') { + // when a controller is defined, expose the value it produces to the view as `$ctrl` + // see: https://docs.angularjs.org/api/ng/provider/$compileProvider#component + (newScope as any).$ctrl = $injector.instantiate(Controller, { + $scope: newScope, + }); + } + + const $target = angular.element(domNode); + const $element = angular.element(template); + + newScope.$apply(() => { + const linkFn = $compile($element); + $target.empty().append($element); + linkFn(newScope); + }); + + return () => { + newScope.$destroy(); + }; +} +/** + * Converts a given legacy angular directive to a render function + * for usage in a react component. Note that the rendering is async + */ +export function convertDirectiveToRenderFn( + directive: AngularDirective, + getInjector: () => Promise +) { + return (domNode: Element, props: DocViewRenderProps) => { + let rejected = false; + + const cleanupFnPromise = injectAngularElement( + domNode, + directive.template, + props, + directive.controller, + getInjector + ); + cleanupFnPromise.catch((e) => { + rejected = true; + render(, domNode); + }); + + return () => { + if (!rejected) { + // for cleanup + // http://roubenmeschian.com/rubo/?p=51 + cleanupFnPromise.then((cleanup) => cleanup()); + } + }; + }; +} diff --git a/src/plugins/discover_legacy/public/application/doc_views/doc_views_registry.ts b/src/plugins/discover_legacy/public/application/doc_views/doc_views_registry.ts new file mode 100644 index 000000000000..56f167b5f2cc --- /dev/null +++ b/src/plugins/discover_legacy/public/application/doc_views/doc_views_registry.ts @@ -0,0 +1,70 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { auto } from 'angular'; +import { convertDirectiveToRenderFn } from './doc_views_helpers'; +import { DocView, DocViewInput, OpenSearchSearchHit, DocViewInputFn } from './doc_views_types'; + +export class DocViewsRegistry { + private docViews: DocView[] = []; + private angularInjectorGetter: (() => Promise) | null = null; + + setAngularInjectorGetter = (injectorGetter: () => Promise) => { + this.angularInjectorGetter = injectorGetter; + }; + + /** + * Extends and adds the given doc view to the registry array + */ + addDocView(docViewRaw: DocViewInput | DocViewInputFn) { + const docView = typeof docViewRaw === 'function' ? docViewRaw() : docViewRaw; + if (docView.directive) { + // convert angular directive to render function for backwards compatibility + docView.render = convertDirectiveToRenderFn(docView.directive, () => { + if (!this.angularInjectorGetter) { + throw new Error('Angular was not initialized'); + } + return this.angularInjectorGetter(); + }); + } + if (typeof docView.shouldShow !== 'function') { + docView.shouldShow = () => true; + } + this.docViews.push(docView as DocView); + } + /** + * Returns a sorted array of doc_views for rendering tabs + */ + getDocViewsSorted(hit: OpenSearchSearchHit) { + return this.docViews + .filter((docView) => docView.shouldShow(hit)) + .sort((a, b) => (Number(a.order) > Number(b.order) ? 1 : -1)); + } +} diff --git a/src/plugins/discover_legacy/public/application/doc_views/doc_views_types.ts b/src/plugins/discover_legacy/public/application/doc_views/doc_views_types.ts new file mode 100644 index 000000000000..961fc98516f6 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/doc_views/doc_views_types.ts @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ComponentType } from 'react'; +import { IScope } from 'angular'; +import { SearchResponse } from 'elasticsearch'; +import { IndexPattern } from '../../../../data/public'; + +export interface AngularDirective { + controller: (...injectedServices: any[]) => void; + template: string; +} + +export type AngularScope = IScope; + +export type OpenSearchSearchHit = SearchResponse['hits']['hits'][number]; + +export interface FieldMapping { + filterable?: boolean; + scripted?: boolean; + rowCount?: number; + type: string; + name: string; +} + +export type DocViewFilterFn = ( + mapping: FieldMapping | string | undefined, + value: unknown, + mode: '+' | '-' +) => void; + +export interface DocViewRenderProps { + columns?: string[]; + filter?: DocViewFilterFn; + hit: OpenSearchSearchHit; + indexPattern: IndexPattern; + onAddColumn?: (columnName: string) => void; + onRemoveColumn?: (columnName: string) => void; +} +export type DocViewerComponent = ComponentType; +export type DocViewRenderFn = ( + domeNode: HTMLDivElement, + renderProps: DocViewRenderProps +) => () => void; + +export interface DocViewInput { + component?: DocViewerComponent; + directive?: AngularDirective; + order: number; + render?: DocViewRenderFn; + shouldShow?: (hit: OpenSearchSearchHit) => boolean; + title: string; +} + +export interface DocView extends DocViewInput { + shouldShow: (hit: OpenSearchSearchHit) => boolean; +} + +export type DocViewInputFn = () => DocViewInput; diff --git a/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_registry.ts b/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_registry.ts new file mode 100644 index 000000000000..16653f5d5377 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_registry.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DocViewLink } from './doc_views_links_types'; + +export class DocViewsLinksRegistry { + private docViewsLinks: DocViewLink[] = []; + + addDocViewLink(docViewLink: DocViewLink) { + this.docViewsLinks.push(docViewLink); + } + + getDocViewsLinksSorted() { + return this.docViewsLinks.sort((a, b) => (Number(a.order) > Number(b.order) ? 1 : -1)); + } +} diff --git a/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_types.ts b/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_types.ts new file mode 100644 index 000000000000..bbc5caadafcd --- /dev/null +++ b/src/plugins/discover_legacy/public/application/doc_views_links/doc_views_links_types.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiListGroupItemProps } from '@elastic/eui'; +import { OpenSearchSearchHit } from '../doc_views/doc_views_types'; +import { IndexPattern } from '../../../../data/public'; + +export interface DocViewLink extends EuiListGroupItemProps { + href?: string; + order: number; + generateCb?( + renderProps: any + ): { + url: string; + hide?: boolean; + }; +} + +export interface DocViewLinkRenderProps { + columns?: string[]; + hit: OpenSearchSearchHit; + indexPattern: IndexPattern; +} diff --git a/src/plugins/discover/public/embeddable/constants.ts b/src/plugins/discover_legacy/public/application/embeddable/constants.ts similarity index 100% rename from src/plugins/discover/public/embeddable/constants.ts rename to src/plugins/discover_legacy/public/application/embeddable/constants.ts diff --git a/src/plugins/discover/public/embeddable/index.ts b/src/plugins/discover_legacy/public/application/embeddable/index.ts similarity index 100% rename from src/plugins/discover/public/embeddable/index.ts rename to src/plugins/discover_legacy/public/application/embeddable/index.ts diff --git a/src/plugins/discover_legacy/public/application/embeddable/search_embeddable.scss b/src/plugins/discover_legacy/public/application/embeddable/search_embeddable.scss new file mode 100644 index 000000000000..e953968495e7 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/embeddable/search_embeddable.scss @@ -0,0 +1,12 @@ +/** + * 1. We want the osdDocTable__container to scroll only when embedded in an embeddable panel + * 2. Force a better looking scrollbar + */ +.embPanel { + .osdDocTable__container { + @include euiScrollBar; /* 2 */ + + flex: 1 1 0; /* 1 */ + overflow: auto; /* 1 */ + } +} diff --git a/src/plugins/discover/public/embeddable/search_embeddable.tsx b/src/plugins/discover_legacy/public/application/embeddable/search_embeddable.ts similarity index 56% rename from src/plugins/discover/public/embeddable/search_embeddable.tsx rename to src/plugins/discover_legacy/public/application/embeddable/search_embeddable.ts index 6a0fd097aeef..933f807f8153 100644 --- a/src/plugins/discover/public/embeddable/search_embeddable.tsx +++ b/src/plugins/discover_legacy/public/application/embeddable/search_embeddable.ts @@ -28,14 +28,14 @@ * under the License. */ -import { isEqual } from 'lodash'; +import './search_embeddable.scss'; +import angular from 'angular'; +import _ from 'lodash'; import * as Rx from 'rxjs'; import { Subscription } from 'rxjs'; -import React from 'react'; -import ReactDOM from 'react-dom'; import { i18n } from '@osd/i18n'; -import { UiActionsStart, APPLY_FILTER_TRIGGER } from '../../../ui_actions/public'; -import { RequestAdapter, Adapters } from '../../../inspector/public'; +import { UiActionsStart, APPLY_FILTER_TRIGGER } from '../../../../ui_actions/public'; +import { RequestAdapter, Adapters } from '../../../../inspector/public'; import { opensearchFilters, Filter, @@ -44,74 +44,66 @@ import { getTime, Query, IFieldType, -} from '../../../data/public'; -import { Container, Embeddable } from '../../../embeddable/public'; +} from '../../../../data/public'; +import { Container, Embeddable } from '../../../../embeddable/public'; +import * as columnActions from '../angular/doc_table/actions/columns'; +import searchTemplate from './search_template.html'; import { ISearchEmbeddable, SearchInput, SearchOutput } from './types'; -import { getDefaultSort } from '../application/view_components/utils/get_default_sort'; -import { getSortForSearchSource } from '../application/view_components/utils/get_sort_for_search_source'; +import { SortOrder } from '../angular/doc_table/components/table_header/helpers'; +import { getSortForSearchSource } from '../angular/doc_table'; import { getRequestInspectorStats, getResponseInspectorStats, getServices, IndexPattern, ISearchSource, -} from '../opensearch_dashboards_services'; +} from '../../opensearch_dashboards_services'; import { SEARCH_EMBEDDABLE_TYPE } from './constants'; -import { SortOrder } from '../saved_searches/types'; -import { SavedSearch } from '../saved_searches'; -import { - SAMPLE_SIZE_SETTING, - SORT_DEFAULT_ORDER_SETTING, - DOC_HIDE_TIME_COLUMN_SETTING, -} from '../../common'; -import { SearchEmbeddableComponent } from './search_embeddable_component'; -import { DiscoverServices } from '../build_services'; -import * as columnActions from '../application/utils/state_management/common'; -import { buildColumns } from '../application/utils/columns'; - -export interface SearchProps { +import { SavedSearch } from '../..'; +import { SAMPLE_SIZE_SETTING, SORT_DEFAULT_ORDER_SETTING } from '../../../common'; + +interface SearchScope extends ng.IScope { columns?: string[]; description?: string; sort?: SortOrder[]; - onSort?: (sort: SortOrder[]) => void; sharedItemTitle?: string; inspectorAdapters?: Adapters; - onSetColumns?: (columns: string[]) => void; - onRemoveColumn?: (column: string) => void; - onAddColumn?: (column: string) => void; - onMoveColumn?: (column: string, index: number) => void; - onFilter?: (field: IFieldType, value: string[], operator: string) => void; - rows?: any[]; + setSortOrder?: (sortPair: SortOrder[]) => void; + removeColumn?: (column: string) => void; + addColumn?: (column: string) => void; + moveColumn?: (column: string, index: number) => void; + filter?: (field: IFieldType, value: string[], operator: string) => void; + hits?: any[]; indexPattern?: IndexPattern; totalHitCount?: number; isLoading?: boolean; - displayTimeColumn?: boolean; - services: DiscoverServices; - title?: string; } interface SearchEmbeddableConfig { + $rootScope: ng.IRootScopeService; + $compile: ng.ICompileService; savedSearch: SavedSearch; editUrl: string; editPath: string; indexPatterns?: IndexPattern[]; editable: boolean; filterManager: FilterManager; - services: DiscoverServices; } export class SearchEmbeddable extends Embeddable implements ISearchEmbeddable { private readonly savedSearch: SavedSearch; + private $rootScope: ng.IRootScopeService; + private $compile: ng.ICompileService; private inspectorAdaptors: Adapters; - private searchProps?: SearchProps; + private searchScope?: SearchScope; private panelTitle: string = ''; private filtersSearchSource?: ISearchSource; + private searchInstance?: JQLite; private autoRefreshFetchSubscription?: Subscription; private subscription?: Subscription; public readonly type = SEARCH_EMBEDDABLE_TYPE; - private services: DiscoverServices; private filterManager: FilterManager; private abortController?: AbortController; @@ -119,17 +111,16 @@ export class SearchEmbeddable private prevFilters?: Filter[]; private prevQuery?: Query; - private node?: HTMLElement; - constructor( { + $rootScope, + $compile, savedSearch, editUrl, editPath, indexPatterns, editable, filterManager, - services, }: SearchEmbeddableConfig, initialInput: SearchInput, private readonly executeTriggerActions: UiActionsStart['executeTriggerActions'], @@ -148,13 +139,14 @@ export class SearchEmbeddable parent ); - this.services = services; this.filterManager = filterManager; this.savedSearch = savedSearch; + this.$rootScope = $rootScope; + this.$compile = $compile; this.inspectorAdaptors = { requests: new RequestAdapter(), }; - this.initializeSearchProps(); + this.initializeSearchScope(); this.autoRefreshFetchSubscription = getServices() .timefilter.getAutoRefreshFetch$() @@ -163,8 +155,8 @@ export class SearchEmbeddable this.subscription = Rx.merge(this.getOutput$(), this.getInput$()).subscribe(() => { this.panelTitle = this.output.title || ''; - if (this.searchProps) { - this.pushContainerStateParamsToProps(this.searchProps); + if (this.searchScope) { + this.pushContainerStateParamsToScope(this.searchScope); } }); } @@ -181,62 +173,48 @@ export class SearchEmbeddable * * @param {Element} domNode */ - public render(node: HTMLElement) { - if (!this.searchProps) { + public render(domNode: HTMLElement) { + if (!this.searchScope) { throw new Error('Search scope not defined'); } - if (this.node) { - ReactDOM.unmountComponentAtNode(this.node); - } - this.node = node; + this.searchInstance = this.$compile(searchTemplate)(this.searchScope); + const rootNode = angular.element(domNode); + rootNode.append(this.searchInstance); + + this.pushContainerStateParamsToScope(this.searchScope); } public destroy() { super.destroy(); - if (this.searchProps) { - delete this.searchProps; + this.savedSearch.destroy(); + if (this.searchInstance) { + this.searchInstance.remove(); + } + if (this.searchScope) { + this.searchScope.$destroy(); + delete this.searchScope; } if (this.subscription) { this.subscription.unsubscribe(); } - if (this.node) { - ReactDOM.unmountComponentAtNode(this.node); - } if (this.autoRefreshFetchSubscription) { this.autoRefreshFetchSubscription.unsubscribe(); } if (this.abortController) this.abortController.abort(); } - private initializeSearchProps() { - const { searchSource } = this.savedSearch; - const indexPattern = searchSource.getField('index'); - if (!indexPattern) { - return; - } + private initializeSearchScope() { + const searchScope: SearchScope = (this.searchScope = this.$rootScope.$new()); - const sort = getDefaultSort( - indexPattern, - this.services.uiSettings.get(SORT_DEFAULT_ORDER_SETTING, 'desc') - ); - this.savedSearch.sort = sort; - - const searchProps: SearchProps = { - columns: this.savedSearch.columns, - sort: [], - inspectorAdapters: this.inspectorAdaptors, - rows: [], - description: this.savedSearch.description, - title: this.savedSearch.title, - services: this.services, - indexPattern, - isLoading: false, - displayTimeColumn: !this.services.uiSettings.get(DOC_HIDE_TIME_COLUMN_SETTING, false), - }; + searchScope.description = this.savedSearch.description; + searchScope.inspectorAdapters = this.inspectorAdaptors; + + const { searchSource } = this.savedSearch; + const indexPattern = (searchScope.indexPattern = searchSource.getField('index'))!; const timeRangeSearchSource = searchSource.create(); timeRangeSearchSource.setField('filter', () => { - if (!this.searchProps || !this.input.timeRange) return; + if (!this.searchScope || !this.input.timeRange) return; return getTime(indexPattern, this.input.timeRange); }); @@ -245,47 +223,37 @@ export class SearchEmbeddable searchSource.setParent(this.filtersSearchSource); - searchProps.onSort = (newSort) => { - this.updateInput({ sort: newSort }); + this.pushContainerStateParamsToScope(searchScope); + + searchScope.setSortOrder = (sort) => { + this.updateInput({ sort }); }; - searchProps.onAddColumn = (columnName: string) => { - if (!searchProps.columns) { + searchScope.addColumn = (columnName: string) => { + if (!searchScope.columns) { return; } - const updatedColumns = buildColumns( - columnActions.addColumn(searchProps.columns, { column: columnName }) - ); - this.updateInput({ columns: updatedColumns }); + const columns = columnActions.addColumn(searchScope.columns, columnName); + this.updateInput({ columns }); }; - searchProps.onRemoveColumn = (columnName: string) => { - if (!searchProps.columns) { + searchScope.removeColumn = (columnName: string) => { + if (!searchScope.columns) { return; } - const updatedColumns = columnActions.removeColumn(searchProps.columns, columnName); - const updatedSort = - searchProps.sort && searchProps.sort.length - ? searchProps.sort.filter((s) => s[0] !== columnName) - : []; - this.updateInput({ sort: updatedSort, columns: updatedColumns }); + const columns = columnActions.removeColumn(searchScope.columns, columnName); + this.updateInput({ columns }); }; - searchProps.onMoveColumn = (columnName, newIndex: number) => { - if (!searchProps.columns) { + searchScope.moveColumn = (columnName, newIndex: number) => { + if (!searchScope.columns) { return; } - const oldIndex = searchProps.columns.indexOf(columnName); - const updatedColumns = columnActions.reorderColumn(searchProps.columns, oldIndex, newIndex); - this.updateInput({ columns: updatedColumns }); - }; - - searchProps.onSetColumns = (columnNames: string[]) => { - const columns = buildColumns(columnNames); + const columns = columnActions.moveColumn(searchScope.columns, columnName, newIndex); this.updateInput({ columns }); }; - searchProps.onFilter = async (field, value, operator) => { + searchScope.filter = async (field, value, operator) => { let filters = opensearchFilters.generateFilters( this.filterManager, field, @@ -303,18 +271,14 @@ export class SearchEmbeddable filters, }); }; - - this.pushContainerStateParamsToProps(searchProps); } public reload() { - if (this.searchProps) { - this.pushContainerStateParamsToProps(this.searchProps); - } + this.fetch(); } private fetch = async () => { - if (!this.searchProps) return; + if (!this.searchScope) return; const { searchSource } = this.savedSearch; @@ -326,8 +290,8 @@ export class SearchEmbeddable searchSource.setField( 'sort', getSortForSearchSource( - this.searchProps.sort, - this.searchProps.indexPattern, + this.searchScope.sort, + this.searchScope.indexPattern, getServices().uiSettings.get(SORT_DEFAULT_ORDER_SETTING) ) ); @@ -346,7 +310,6 @@ export class SearchEmbeddable inspectorRequest.json(body); }); this.updateOutput({ loading: true, error: undefined }); - this.searchProps!.isLoading = true; try { // Make the request @@ -358,53 +321,41 @@ export class SearchEmbeddable // Log response to inspector inspectorRequest.stats(getResponseInspectorStats(resp, searchSource)).ok({ json: resp }); - this.searchProps!.rows = resp.hits.hits; - this.searchProps!.totalHitCount = resp.hits.total; - this.searchProps!.isLoading = false; + // Apply the changes to the angular scope + this.searchScope.$apply(() => { + this.searchScope!.hits = resp.hits.hits; + this.searchScope!.totalHitCount = resp.hits.total; + }); } catch (error) { this.updateOutput({ loading: false, error }); - this.searchProps!.isLoading = false; } }; - private renderComponent(node: HTMLElement, searchProps: SearchProps) { - if (!this.searchProps) { - return; - } - const props = { - searchProps, - }; - ReactDOM.render(, node); - } - - private async pushContainerStateParamsToProps(searchProps: SearchProps) { + private pushContainerStateParamsToScope(searchScope: SearchScope) { const isFetchRequired = !opensearchFilters.onlyDisabledFiltersChanged(this.input.filters, this.prevFilters) || - !isEqual(this.prevQuery, this.input.query) || - !isEqual(this.prevTimeRange, this.input.timeRange) || - !isEqual(searchProps.sort, this.input.sort || this.savedSearch.sort); + !_.isEqual(this.prevQuery, this.input.query) || + !_.isEqual(this.prevTimeRange, this.input.timeRange) || + !_.isEqual(searchScope.sort, this.input.sort || this.savedSearch.sort); // If there is column or sort data on the panel, that means the original columns or sort settings have // been overridden in a dashboard. - searchProps.columns = this.input.columns || this.savedSearch.columns; - searchProps.sort = this.input.sort || this.savedSearch.sort; - searchProps.sharedItemTitle = this.panelTitle; + searchScope.columns = this.input.columns || this.savedSearch.columns; + searchScope.sort = this.input.sort || this.savedSearch.sort; + searchScope.sharedItemTitle = this.panelTitle; if (isFetchRequired) { this.filtersSearchSource!.setField('filter', this.input.filters); this.filtersSearchSource!.setField('query', this.input.query); + + this.fetch(); + this.prevFilters = this.input.filters; this.prevQuery = this.input.query; this.prevTimeRange = this.input.timeRange; - this.searchProps = searchProps; - - await this.fetch(); - } else if (this.searchProps) { - this.searchProps = searchProps; - } - - if (this.node) { - this.renderComponent(this.node, this.searchProps!); + } else if (this.searchScope) { + // trigger a digest cycle to make sure non-fetch relevant changes are propagated + this.searchScope.$applyAsync(); } } } diff --git a/src/plugins/discover/public/embeddable/search_embeddable_factory.tsx b/src/plugins/discover_legacy/public/application/embeddable/search_embeddable_factory.ts similarity index 71% rename from src/plugins/discover/public/embeddable/search_embeddable_factory.tsx rename to src/plugins/discover_legacy/public/application/embeddable/search_embeddable_factory.ts index 8d99b87fbeb2..9b8c540713ce 100644 --- a/src/plugins/discover/public/embeddable/search_embeddable_factory.tsx +++ b/src/plugins/discover_legacy/public/application/embeddable/search_embeddable_factory.ts @@ -28,17 +28,19 @@ * under the License. */ +import { auto } from 'angular'; import { i18n } from '@osd/i18n'; import { UiActionsStart } from 'src/plugins/ui_actions/public'; -import { getServices } from '../opensearch_dashboards_services'; +import { getServices } from '../../opensearch_dashboards_services'; import { EmbeddableFactoryDefinition, Container, ErrorEmbeddable, -} from '../../../embeddable/public'; -import { TimeRange } from '../../../data/public'; -import { SearchEmbeddable } from './search_embeddable'; -import { SearchInput, SearchOutput } from './types'; +} from '../../../../embeddable/public'; + +import { TimeRange } from '../../../../data/public'; + +import { SearchInput, SearchOutput, SearchEmbeddable } from './types'; import { SEARCH_EMBEDDABLE_TYPE } from './constants'; interface StartServices { @@ -49,6 +51,8 @@ interface StartServices { export class SearchEmbeddableFactory implements EmbeddableFactoryDefinition { public readonly type = SEARCH_EMBEDDABLE_TYPE; + private $injector: auto.IInjectorService | null; + private getInjector: () => Promise | null; public readonly savedObjectMetaData = { name: i18n.translate('discover.savedSearch.savedObjectName', { defaultMessage: 'Saved search', @@ -57,7 +61,13 @@ export class SearchEmbeddableFactory getIconForSavedObject: () => 'search', }; - constructor(private getStartServices: () => Promise) {} + constructor( + private getStartServices: () => Promise, + getInjector: () => Promise + ) { + this.$injector = null; + this.getInjector = getInjector; + } public canCreateNew() { return false; @@ -78,25 +88,32 @@ export class SearchEmbeddableFactory input: Partial & { id: string; timeRange: TimeRange }, parent?: Container ): Promise => { - const services = getServices(); - const filterManager = services.filterManager; - const url = await services.getSavedSearchUrlById(savedObjectId); - const editUrl = services.addBasePath(`/app/data-explorer/discover${url}`); + if (!this.$injector) { + this.$injector = await this.getInjector(); + } + const $injector = this.$injector as auto.IInjectorService; + + const $compile = $injector.get('$compile'); + const $rootScope = $injector.get('$rootScope'); + const filterManager = getServices().filterManager; + const url = await getServices().getSavedSearchUrlById(savedObjectId); + const editUrl = getServices().addBasePath(`/app/discover${url}`); try { - const savedObject = await services.getSavedSearchById(savedObjectId); + const savedObject = await getServices().getSavedSearchById(savedObjectId); const indexPattern = savedObject.searchSource.getField('index'); const { executeTriggerActions } = await this.getStartServices(); const { SearchEmbeddable: SearchEmbeddableClass } = await import('./search_embeddable'); return new SearchEmbeddableClass( { savedSearch: savedObject, + $rootScope, + $compile, editUrl, editPath: url, filterManager, - editable: services.capabilities.discover.save as boolean, + editable: getServices().capabilities.discover.save as boolean, indexPatterns: indexPattern ? [indexPattern] : [], - services, }, input, executeTriggerActions, diff --git a/src/plugins/discover_legacy/public/application/embeddable/search_template.html b/src/plugins/discover_legacy/public/application/embeddable/search_template.html new file mode 100644 index 000000000000..e188d230ea30 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/embeddable/search_template.html @@ -0,0 +1,20 @@ + + diff --git a/src/plugins/discover/public/embeddable/types.ts b/src/plugins/discover_legacy/public/application/embeddable/types.ts similarity index 93% rename from src/plugins/discover/public/embeddable/types.ts rename to src/plugins/discover_legacy/public/application/embeddable/types.ts index 24a1aac92b49..864f954588dd 100644 --- a/src/plugins/discover/public/embeddable/types.ts +++ b/src/plugins/discover_legacy/public/application/embeddable/types.ts @@ -34,9 +34,9 @@ import { EmbeddableOutput, IEmbeddable, } from 'src/plugins/embeddable/public'; +import { SortOrder } from '../angular/doc_table/components/table_header/helpers'; import { Filter, IIndexPattern, TimeRange, Query } from '../../../../data/public'; -import { SortOrder } from '../saved_searches/types'; -import { SavedSearch } from '../saved_searches'; +import { SavedSearch } from '../..'; export interface SearchInput extends EmbeddableInput { timeRange: TimeRange; diff --git a/src/plugins/discover_legacy/public/application/helpers/breadcrumbs.ts b/src/plugins/discover_legacy/public/application/helpers/breadcrumbs.ts new file mode 100644 index 000000000000..e30f50206aef --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/breadcrumbs.ts @@ -0,0 +1,51 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@osd/i18n'; + +export function getRootBreadcrumbs() { + return [ + { + text: i18n.translate('discover.rootBreadcrumb', { + defaultMessage: 'Discover', + }), + href: '#/', + }, + ]; +} + +export function getSavedSearchBreadcrumbs($route: any) { + return [ + ...getRootBreadcrumbs(), + { + text: $route.current.locals.savedObjects.savedSearch.id, + }, + ]; +} diff --git a/src/plugins/discover_legacy/public/application/helpers/format_number_with_commas.ts b/src/plugins/discover_legacy/public/application/helpers/format_number_with_commas.ts new file mode 100644 index 000000000000..b1b3c96e0958 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/format_number_with_commas.ts @@ -0,0 +1,38 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const COMMA_SEPARATOR_RE = /(\d)(?=(\d{3})+(?!\d))/g; + +/** + * Converts a number to a string and adds commas + * as thousands separators + */ +export const formatNumWithCommas = (input: number) => + String(input).replace(COMMA_SEPARATOR_RE, '$1,'); diff --git a/src/plugins/discover_legacy/public/application/helpers/get_index_pattern_id.ts b/src/plugins/discover_legacy/public/application/helpers/get_index_pattern_id.ts new file mode 100644 index 000000000000..dfb02c0b0740 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/get_index_pattern_id.ts @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IIndexPattern } from '../../../../data/common/index_patterns'; + +export function findIndexPatternById( + indexPatterns: IIndexPattern[], + id: string +): IIndexPattern | undefined { + if (!Array.isArray(indexPatterns) || !id) { + return; + } + return indexPatterns.find((o) => o.id === id); +} + +/** + * Checks if the given defaultIndex exists and returns + * the first available index pattern id if not + */ +export function getFallbackIndexPatternId( + indexPatterns: IIndexPattern[], + defaultIndex: string = '' +): string { + if (defaultIndex && findIndexPatternById(indexPatterns, defaultIndex)) { + return defaultIndex; + } + return !indexPatterns || !indexPatterns.length || !indexPatterns[0].id ? '' : indexPatterns[0].id; +} + +/** + * A given index pattern id is checked for existence and a fallback is provided if it doesn't exist + * The provided defaultIndex is usually configured in Advanced Settings, if it's also invalid + * the first entry of the given list of Indexpatterns is used + */ +export function getIndexPatternId( + id: string = '', + indexPatterns: IIndexPattern[], + defaultIndex: string = '' +): string { + if (!id || !findIndexPatternById(indexPatterns, id)) { + return getFallbackIndexPatternId(indexPatterns, defaultIndex); + } + return id; +} diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..c1a8acb04ac3 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/get_switch_index_pattern_app_state.test.ts @@ -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 { getSwitchIndexPatternAppState } from './get_switch_index_pattern_app_state'; +import { IIndexPatternFieldList, IndexPattern } from '../../../../data/common/index_patterns'; + +const currentIndexPattern: IndexPattern = { + id: 'prev', + getFieldByName(name) { + return this.fields.getByName(name); + }, + fields: { + getByName: (name: string) => { + const fields = [ + { name: 'category', sortable: true }, + { name: 'name', sortable: true }, + ] as IIndexPatternFieldList; + return fields.find((field) => field.name === name); + }, + }, +} as IndexPattern; + +const nextIndexPattern = { + id: 'next', + getFieldByName(name) { + return this.fields.getByName(name); + }, + fields: { + getByName: (name: string) => { + const fields = [{ name: 'category', sortable: true }] as IIndexPatternFieldList; + return fields.find((field) => field.name === name); + }, + }, +} as IndexPattern; + +describe('Discover getSwitchIndexPatternAppState', () => { + test('removing fields that are not part of the next index pattern, keeping unknown fields ', async () => { + const result = getSwitchIndexPatternAppState( + currentIndexPattern, + nextIndexPattern, + ['category', 'name', 'unknown'], + [['category', 'desc']] + ); + expect(result.columns).toEqual(['category', 'unknown']); + expect(result.sort).toEqual([['category', 'desc']]); + }); + test('removing sorted by fields that are not part of the next index pattern', async () => { + const result = getSwitchIndexPatternAppState( + currentIndexPattern, + nextIndexPattern, + ['name'], + [ + ['category', 'desc'], + ['name', 'asc'], + ] + ); + expect(result.columns).toEqual(['_source']); + expect(result.sort).toEqual([['category', 'desc']]); + }); + test('removing sorted by fields that without modifying columns', async () => { + const result = getSwitchIndexPatternAppState( + currentIndexPattern, + nextIndexPattern, + ['name'], + [ + ['category', 'desc'], + ['name', 'asc'], + ], + false + ); + expect(result.columns).toEqual(['name']); + expect(result.sort).toEqual([['category', 'desc']]); + }); +}); diff --git a/src/plugins/discover_legacy/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 new file mode 100644 index 000000000000..51835910a402 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/get_switch_index_pattern_app_state.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 { getSortArray } from '../angular/doc_table'; +import { SortPairArr } from '../angular/doc_table/lib/get_sort'; +import { IndexPattern } from '../../opensearch_dashboards_services'; + +/** + * Helper function to remove or adapt the currently selected columns/sort to be valid with the next + * index pattern, returns a new state object + */ +export function getSwitchIndexPatternAppState( + currentIndexPattern: IndexPattern, + nextIndexPattern: IndexPattern, + currentColumns: string[], + currentSort: SortPairArr[], + modifyColumns: boolean = true +) { + const nextColumns = modifyColumns + ? currentColumns.filter( + (column) => + nextIndexPattern.fields.getByName(column) || !currentIndexPattern.fields.getByName(column) + ) + : currentColumns; + const nextSort = getSortArray(currentSort, nextIndexPattern); + return { + index: nextIndexPattern.id, + columns: nextColumns.length ? nextColumns : ['_source'], + sort: nextSort, + }; +} diff --git a/src/plugins/discover_legacy/public/application/helpers/index.ts b/src/plugins/discover_legacy/public/application/helpers/index.ts new file mode 100644 index 000000000000..d765fdf60cee --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/index.ts @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { shortenDottedString } from './shorten_dotted_string'; +export { formatNumWithCommas } from './format_number_with_commas'; diff --git a/src/plugins/discover_legacy/public/application/helpers/migrate_legacy_query.ts b/src/plugins/discover_legacy/public/application/helpers/migrate_legacy_query.ts new file mode 100644 index 000000000000..90458c135b98 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/migrate_legacy_query.ts @@ -0,0 +1,48 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { has } from 'lodash'; +import { Query } from 'src/plugins/data/public'; + +/** + * Creates a standardized query object from old queries that were either strings or pure OpenSearch query DSL + * + * @param query - a legacy query, what used to be stored in SearchSource's query property + * @return Object + */ + +export function migrateLegacyQuery(query: Query | { [key: string]: any } | string): Query { + // Lucene was the only option before, so language-less queries are all lucene + if (!has(query, 'language')) { + return { query, language: 'lucene' }; + } + + return query as Query; +} diff --git a/src/plugins/discover_legacy/public/application/helpers/popularize_field.test.ts b/src/plugins/discover_legacy/public/application/helpers/popularize_field.test.ts new file mode 100644 index 000000000000..cdd49c0f77f1 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/popularize_field.test.ts @@ -0,0 +1,93 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IndexPattern, IndexPatternsService } from '../../../../data/public'; +import { popularizeField } from './popularize_field'; + +describe('Popularize field', () => { + test('returns undefined if index pattern lacks id', async () => { + const indexPattern = ({} as unknown) as IndexPattern; + const fieldName = '@timestamp'; + const indexPatternsService = ({} as unknown) as IndexPatternsService; + const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + expect(result).toBeUndefined(); + }); + + test('returns undefined if field not found', async () => { + const indexPattern = ({ + fields: { + getByName: () => {}, + }, + } as unknown) as IndexPattern; + const fieldName = '@timestamp'; + const indexPatternsService = ({} as unknown) as IndexPatternsService; + const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + expect(result).toBeUndefined(); + }); + + test('returns undefined if successful', async () => { + const field = { + count: 0, + }; + const indexPattern = ({ + id: 'id', + fields: { + getByName: () => field, + }, + } as unknown) as IndexPattern; + const fieldName = '@timestamp'; + const indexPatternsService = ({ + updateSavedObject: async () => {}, + } as unknown) as IndexPatternsService; + const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + expect(result).toBeUndefined(); + expect(field.count).toEqual(1); + }); + + test('hides errors', async () => { + const field = { + count: 0, + }; + const indexPattern = ({ + id: 'id', + fields: { + getByName: () => field, + }, + } as unknown) as IndexPattern; + const fieldName = '@timestamp'; + const indexPatternsService = ({ + updateSavedObject: async () => { + throw new Error('unknown error'); + }, + } as unknown) as IndexPatternsService; + const result = await popularizeField(indexPattern, fieldName, indexPatternsService); + expect(result).toBeUndefined(); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/helpers/popularize_field.ts b/src/plugins/discover_legacy/public/application/helpers/popularize_field.ts new file mode 100644 index 000000000000..e7c4b900fa19 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/popularize_field.ts @@ -0,0 +1,52 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { IndexPattern, IndexPatternsService } from '../../../../data/public'; + +async function popularizeField( + indexPattern: IndexPattern, + fieldName: string, + indexPatternsService: IndexPatternsService +) { + if (!indexPattern.id) return; + const field = indexPattern.fields.getByName(fieldName); + if (!field) { + return; + } + + field.count++; + // Catch 409 errors caused by user adding columns in a higher frequency that the changes can be persisted to OpenSearch + try { + await indexPatternsService.updateSavedObject(indexPattern, 0, true); + // eslint-disable-next-line no-empty + } catch {} +} + +export { popularizeField }; diff --git a/src/plugins/discover_legacy/public/application/helpers/shorten_dotted_string.ts b/src/plugins/discover_legacy/public/application/helpers/shorten_dotted_string.ts new file mode 100644 index 000000000000..39450f8c82c0 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/shorten_dotted_string.ts @@ -0,0 +1,37 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +const DOT_PREFIX_RE = /(.).+?\./g; + +/** + * Convert a dot.notated.string into a short + * version (d.n.string) + */ +export const shortenDottedString = (input: string) => input.replace(DOT_PREFIX_RE, '$1.'); diff --git a/src/plugins/discover_legacy/public/application/helpers/validate_time_range.test.ts b/src/plugins/discover_legacy/public/application/helpers/validate_time_range.test.ts new file mode 100644 index 000000000000..902f3d8a4b62 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/validate_time_range.test.ts @@ -0,0 +1,58 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { validateTimeRange } from './validate_time_range'; +import { notificationServiceMock } from '../../../../../core/public/mocks'; + +describe('Discover validateTimeRange', () => { + test('validates given time ranges correctly', async () => { + const { toasts } = notificationServiceMock.createStartContract(); + [ + { from: '', to: '', result: false }, + { from: 'now', to: 'now+1h', result: true }, + { from: 'now', to: 'lala+1h', result: false }, + { from: '', to: 'now', result: false }, + { from: 'now', to: '', result: false }, + { from: ' 2020-06-02T13:36:13.689Z', to: 'now', result: true }, + { from: ' 2020-06-02T13:36:13.689Z', to: '2020-06-02T13:36:13.690Z', result: true }, + ].map((test) => { + expect(validateTimeRange({ from: test.from, to: test.to }, toasts)).toEqual(test.result); + }); + }); + + test('displays a toast when invalid data is entered', async () => { + const { toasts } = notificationServiceMock.createStartContract(); + expect(validateTimeRange({ from: 'now', to: 'null' }, toasts)).toEqual(false); + expect(toasts.addDanger).toHaveBeenCalledWith({ + title: 'Invalid time range', + text: "The provided time range is invalid. (from: 'now', to: 'null')", + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/application/helpers/validate_time_range.ts b/src/plugins/discover_legacy/public/application/helpers/validate_time_range.ts new file mode 100644 index 000000000000..d23a84aabb14 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/helpers/validate_time_range.ts @@ -0,0 +1,61 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import dateMath from '@elastic/datemath'; +import { i18n } from '@osd/i18n'; +import { ToastsStart } from 'opensearch-dashboards/public'; + +/** + * Validates a given time filter range, provided by URL or UI + * Unless valid, it returns false and displays a notification + */ +export function validateTimeRange( + { from, to }: { from: string; to: string }, + toastNotifications: ToastsStart +): boolean { + const fromMoment = dateMath.parse(from); + const toMoment = dateMath.parse(to); + if (!fromMoment || !toMoment || !fromMoment.isValid() || !toMoment.isValid()) { + toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.invalidTimeRangeTitle', { + defaultMessage: `Invalid time range`, + }), + text: i18n.translate('discover.notifications.invalidTimeRangeText', { + defaultMessage: `The provided time range is invalid. (from: '{from}', to: '{to}')`, + values: { + from, + to, + }, + }), + }); + return false; + } + return true; +} diff --git a/src/plugins/discover_legacy/public/application/index.scss b/src/plugins/discover_legacy/public/application/index.scss new file mode 100644 index 000000000000..b9f191ac6d69 --- /dev/null +++ b/src/plugins/discover_legacy/public/application/index.scss @@ -0,0 +1,2 @@ +@import "angular/index"; +@import "discover"; diff --git a/src/plugins/discover_legacy/public/build_services.ts b/src/plugins/discover_legacy/public/build_services.ts new file mode 100644 index 000000000000..3fdafcff0c40 --- /dev/null +++ b/src/plugins/discover_legacy/public/build_services.ts @@ -0,0 +1,129 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { History } from 'history'; + +import { + Capabilities, + ChromeStart, + CoreStart, + DocLinksStart, + ToastsStart, + IUiSettingsClient, + PluginInitializerContext, +} from 'opensearch-dashboards/public'; +import { + FilterManager, + TimefilterContract, + IndexPatternsContract, + DataPublicPluginStart, +} from 'src/plugins/data/public'; +import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; +import { SharePluginStart } from 'src/plugins/share/public'; +import { ChartsPluginStart } from 'src/plugins/charts/public'; +import { VisualizationsStart } from 'src/plugins/visualizations/public'; +import { SavedObjectOpenSearchDashboardsServices } from 'src/plugins/saved_objects/public'; + +import { DiscoverStartPlugins } from './plugin'; +import { createSavedSearchesLoader, SavedSearch } from './saved_searches'; +import { getHistory } from './opensearch_dashboards_services'; +import { OpenSearchDashboardsLegacyStart } from '../../opensearch_dashboards_legacy/public'; +import { UrlForwardingStart } from '../../url_forwarding/public'; +import { NavigationPublicPluginStart } from '../../navigation/public'; + +export interface DiscoverServices { + addBasePath: (path: string) => string; + capabilities: Capabilities; + chrome: ChromeStart; + core: CoreStart; + data: DataPublicPluginStart; + docLinks: DocLinksStart; + history: () => History; + theme: ChartsPluginStart['theme']; + filterManager: FilterManager; + indexPatterns: IndexPatternsContract; + inspector: InspectorPublicPluginStart; + metadata: { branch: string }; + navigation: NavigationPublicPluginStart; + share?: SharePluginStart; + opensearchDashboardsLegacy: OpenSearchDashboardsLegacyStart; + urlForwarding: UrlForwardingStart; + timefilter: TimefilterContract; + toastNotifications: ToastsStart; + getSavedSearchById: (id: string) => Promise; + getSavedSearchUrlById: (id: string) => Promise; + getEmbeddableInjector: any; + uiSettings: IUiSettingsClient; + visualizations: VisualizationsStart; +} + +export async function buildServices( + core: CoreStart, + plugins: DiscoverStartPlugins, + context: PluginInitializerContext, + getEmbeddableInjector: any +): Promise { + const services: SavedObjectOpenSearchDashboardsServices = { + savedObjectsClient: core.savedObjects.client, + indexPatterns: plugins.data.indexPatterns, + search: plugins.data.search, + chrome: core.chrome, + overlays: core.overlays, + }; + const savedObjectService = createSavedSearchesLoader(services); + + return { + addBasePath: core.http.basePath.prepend, + capabilities: core.application.capabilities, + chrome: core.chrome, + core, + data: plugins.data, + docLinks: core.docLinks, + theme: plugins.charts.theme, + filterManager: plugins.data.query.filterManager, + getEmbeddableInjector, + getSavedSearchById: async (id: string) => savedObjectService.get(id), + getSavedSearchUrlById: async (id: string) => savedObjectService.urlFor(id), + history: getHistory, + indexPatterns: plugins.data.indexPatterns, + inspector: plugins.inspector, + metadata: { + branch: context.env.packageInfo.branch, + }, + navigation: plugins.navigation, + share: plugins.share, + opensearchDashboardsLegacy: plugins.opensearchDashboardsLegacy, + urlForwarding: plugins.urlForwarding, + timefilter: plugins.data.query.timefilter.timefilter, + toastNotifications: core.notifications.toasts, + uiSettings: core.uiSettings, + visualizations: plugins.visualizations, + }; +} diff --git a/src/plugins/discover_legacy/public/get_inner_angular.ts b/src/plugins/discover_legacy/public/get_inner_angular.ts new file mode 100644 index 000000000000..b4a7a17357ab --- /dev/null +++ b/src/plugins/discover_legacy/public/get_inner_angular.ts @@ -0,0 +1,208 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// inner angular imports +// these are necessary to bootstrap the local angular. +// They can stay even after NP cutover +import './application/index.scss'; +import angular from 'angular'; +// required for `ngSanitize` angular module +import 'angular-sanitize'; +import { EuiIcon } from '@elastic/eui'; +import { i18nDirective, i18nFilter, I18nProvider } from '@osd/i18n/angular'; +import { CoreStart, PluginInitializerContext } from 'opensearch-dashboards/public'; +import { DataPublicPluginStart } from '../../data/public'; +import { Storage } from '../../opensearch_dashboards_utils/public'; +import { NavigationPublicPluginStart as NavigationStart } from '../../navigation/public'; +import { createDocTableDirective } from './application/angular/doc_table'; +import { createTableHeaderDirective } from './application/angular/doc_table/components/table_header'; +import { + createToolBarPagerButtonsDirective, + createToolBarPagerTextDirective, +} from './application/angular/doc_table/components/pager'; +import { createTableRowDirective } from './application/angular/doc_table/components/table_row'; +import { createPagerFactory } from './application/angular/doc_table/lib/pager/pager_factory'; +import { createInfiniteScrollDirective } from './application/angular/doc_table/infinite_scroll'; +import { createDocViewerDirective } from './application/angular/doc_viewer'; +import { createDocViewerLinksDirective } from './application/angular/doc_viewer_links'; +import { createRenderCompleteDirective } from './application/angular/directives/render_complete'; +import { + initAngularBootstrap, + configureAppAngularModule, + PrivateProvider, + PromiseServiceCreator, + registerListenEventListener, + watchMultiDecorator, + createTopNavDirective, + createTopNavHelper, +} from '../../opensearch_dashboards_legacy/public'; +import { createContextErrorMessageDirective } from './application/components/context_error_message'; +import { DiscoverStartPlugins } from './plugin'; +import { getScopedHistory } from './opensearch_dashboards_services'; +import { createDiscoverLegacyDirective } from './application/components/create_discover_legacy_directive'; + +/** + * returns the main inner angular module, it contains all the parts of Angular Discover + * needs to render, so in the end the current 'opensearchDashboards' angular module is no longer necessary + */ +export function getInnerAngularModule( + name: string, + core: CoreStart, + deps: DiscoverStartPlugins, + context: PluginInitializerContext +) { + initAngularBootstrap(); + const module = initializeInnerAngularModule(name, core, deps.navigation, deps.data); + configureAppAngularModule(module, { core, env: context.env }, true, getScopedHistory); + return module; +} + +/** + * returns a slimmer inner angular module for embeddable rendering + */ +export function getInnerAngularModuleEmbeddable( + name: string, + core: CoreStart, + deps: DiscoverStartPlugins +) { + return initializeInnerAngularModule(name, core, deps.navigation, deps.data, true); +} + +let initialized = false; + +export function initializeInnerAngularModule( + name = 'app/discover', + core: CoreStart, + navigation: NavigationStart, + data: DataPublicPluginStart, + embeddable = false +) { + if (!initialized) { + createLocalI18nModule(); + createLocalPrivateModule(); + createLocalPromiseModule(); + createLocalTopNavModule(navigation); + createLocalStorageModule(); + createPagerFactoryModule(); + createDocTableModule(); + initialized = true; + } + + if (embeddable) { + return angular + .module(name, [ + 'ngSanitize', + 'react', + 'ui.bootstrap', + 'discoverI18n', + 'discoverPrivate', + 'discoverDocTable', + 'discoverPagerFactory', + 'discoverPromise', + ]) + .config(watchMultiDecorator) + .directive('icon', (reactDirective) => reactDirective(EuiIcon)) + .directive('renderComplete', createRenderCompleteDirective); + } + + return angular + .module(name, [ + 'ngSanitize', + 'ngRoute', + 'react', + 'ui.bootstrap', + 'discoverI18n', + 'discoverPrivate', + 'discoverPromise', + 'discoverTopNav', + 'discoverLocalStorageProvider', + 'discoverDocTable', + 'discoverPagerFactory', + ]) + .config(watchMultiDecorator) + .run(registerListenEventListener) + .directive('icon', (reactDirective) => reactDirective(EuiIcon)) + .directive('renderComplete', createRenderCompleteDirective) + .directive('discoverLegacy', createDiscoverLegacyDirective) + .directive('contextErrorMessage', createContextErrorMessageDirective); +} + +function createLocalPromiseModule() { + angular.module('discoverPromise', []).service('Promise', PromiseServiceCreator); +} + +function createLocalPrivateModule() { + angular.module('discoverPrivate', []).provider('Private', PrivateProvider); +} + +function createLocalTopNavModule(navigation: NavigationStart) { + angular + .module('discoverTopNav', ['react']) + .directive('osdTopNav', createTopNavDirective) + .directive('osdTopNavHelper', createTopNavHelper(navigation.ui)); +} + +function createLocalI18nModule() { + angular + .module('discoverI18n', []) + .provider('i18n', I18nProvider) + .filter('i18n', i18nFilter) + .directive('i18nId', i18nDirective); +} + +function createLocalStorageModule() { + angular + .module('discoverLocalStorageProvider', ['discoverPrivate']) + .service('localStorage', createLocalStorageService('localStorage')) + .service('sessionStorage', createLocalStorageService('sessionStorage')); +} + +const createLocalStorageService = function (type: string) { + return function ($window: any) { + return new Storage($window[type]); + }; +}; + +function createPagerFactoryModule() { + angular.module('discoverPagerFactory', []).factory('pagerFactory', createPagerFactory); +} + +function createDocTableModule() { + angular + .module('discoverDocTable', ['discoverPagerFactory', 'react']) + .directive('docTable', createDocTableDirective) + .directive('osdTableHeader', createTableHeaderDirective) + .directive('toolBarPagerText', createToolBarPagerTextDirective) + .directive('osdTableRow', createTableRowDirective) + .directive('toolBarPagerButtons', createToolBarPagerButtonsDirective) + .directive('osdInfiniteScroll', createInfiniteScrollDirective) + .directive('docViewer', createDocViewerDirective) + .directive('docViewerLinks', createDocViewerLinksDirective); +} diff --git a/src/plugins/discover_legacy/public/index.ts b/src/plugins/discover_legacy/public/index.ts new file mode 100644 index 000000000000..6c9ab46b656e --- /dev/null +++ b/src/plugins/discover_legacy/public/index.ts @@ -0,0 +1,41 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { PluginInitializerContext } from 'opensearch-dashboards/public'; +import { DiscoverPlugin } from './plugin'; + +export { DiscoverSetup, DiscoverStart } from './plugin'; +export function plugin(initializerContext: PluginInitializerContext) { + return new DiscoverPlugin(initializerContext); +} + +export { SavedSearch, SavedSearchLoader, createSavedSearchesLoader } from './saved_searches'; +export { ISearchEmbeddable, SEARCH_EMBEDDABLE_TYPE, SearchInput } from './application/embeddable'; +export { DISCOVER_APP_URL_GENERATOR, DiscoverUrlGeneratorState } from './url_generator'; diff --git a/src/plugins/discover_legacy/public/mocks.ts b/src/plugins/discover_legacy/public/mocks.ts new file mode 100644 index 000000000000..4724ced290ff --- /dev/null +++ b/src/plugins/discover_legacy/public/mocks.ts @@ -0,0 +1,61 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DiscoverSetup, DiscoverStart } from '.'; + +export type Setup = jest.Mocked; +export type Start = jest.Mocked; + +const createSetupContract = (): Setup => { + const setupContract: Setup = { + docViews: { + addDocView: jest.fn(), + }, + docViewsLinks: { + addDocViewLink: jest.fn(), + }, + }; + return setupContract; +}; + +const createStartContract = (): Start => { + const startContract: Start = { + savedSearchLoader: {} as any, + urlGenerator: { + createUrl: jest.fn(), + } as any, + }; + return startContract; +}; + +export const discoverPluginMock = { + createSetupContract, + createStartContract, +}; diff --git a/src/plugins/discover_legacy/public/opensearch_dashboards_services.ts b/src/plugins/discover_legacy/public/opensearch_dashboards_services.ts new file mode 100644 index 000000000000..8531564e0cc7 --- /dev/null +++ b/src/plugins/discover_legacy/public/opensearch_dashboards_services.ts @@ -0,0 +1,129 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { createHashHistory } from 'history'; +import { ScopedHistory, AppMountParameters } from 'opensearch-dashboards/public'; +import { UiActionsStart } from 'src/plugins/ui_actions/public'; +import { DiscoverServices } from './build_services'; +import { createGetterSetter } from '../../opensearch_dashboards_utils/public'; +import { search } from '../../data/public'; +import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; +import { DocViewsLinksRegistry } from './application/doc_views_links/doc_views_links_registry'; + +let angularModule: any = null; +let services: DiscoverServices | null = null; +let uiActions: UiActionsStart; + +/** + * set bootstrapped inner angular module + */ +export function setAngularModule(module: any) { + angularModule = module; +} + +/** + * get boostrapped inner angular module + */ +export function getAngularModule() { + return angularModule; +} + +export function getServices(): DiscoverServices { + if (!services) { + throw new Error('Discover services are not yet available'); + } + return services; +} + +export function setServices(newServices: any) { + services = newServices; +} + +export const setUiActions = (pluginUiActions: UiActionsStart) => (uiActions = pluginUiActions); +export const getUiActions = () => uiActions; + +export const [getHeaderActionMenuMounter, setHeaderActionMenuMounter] = createGetterSetter< + AppMountParameters['setHeaderActionMenu'] +>('headerActionMenuMounter'); + +export const [getUrlTracker, setUrlTracker] = createGetterSetter<{ + setTrackedUrl: (url: string) => void; + restorePreviousUrl: () => void; +}>('urlTracker'); + +export const [getDocViewsRegistry, setDocViewsRegistry] = createGetterSetter( + 'DocViewsRegistry' +); + +export const [getDocViewsLinksRegistry, setDocViewsLinksRegistry] = createGetterSetter< + DocViewsLinksRegistry +>('DocViewsLinksRegistry'); +/** + * Makes sure discover and context are using one instance of history. + */ +export const getHistory = _.once(() => createHashHistory()); + +/** + * Discover currently uses two `history` instances: one from OpenSearch Dashboards Platform and + * another from `history` package. Below function is used every time Discover + * app is loaded to synchronize both instances. + * + * This helper is temporary until https://github.com/elastic/kibana/issues/65161 is resolved. + */ +export const syncHistoryLocations = () => { + const h = getHistory(); + Object.assign(h.location, createHashHistory().location); + return h; +}; + +export const [getScopedHistory, setScopedHistory] = createGetterSetter( + 'scopedHistory' +); + +export const { getRequestInspectorStats, getResponseInspectorStats, tabifyAggResponse } = search; +export { unhashUrl, redirectWhenMissing } from '../../opensearch_dashboards_utils/public'; +export { + formatMsg, + formatStack, + subscribeWithScope, +} from '../../opensearch_dashboards_legacy/public'; + +// EXPORT types +export { + IndexPatternsContract, + IIndexPattern, + IndexPattern, + indexPatterns, + IFieldType, + ISearchSource, + OpenSearchQuerySortValue, + SortDirection, +} from '../../data/public'; diff --git a/src/plugins/discover_legacy/public/plugin.ts b/src/plugins/discover_legacy/public/plugin.ts new file mode 100644 index 000000000000..7e855b707891 --- /dev/null +++ b/src/plugins/discover_legacy/public/plugin.ts @@ -0,0 +1,487 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { i18n } from '@osd/i18n'; +import angular, { auto } from 'angular'; +import { BehaviorSubject } from 'rxjs'; +import { filter, map } from 'rxjs/operators'; + +import { + AppMountParameters, + AppUpdater, + CoreSetup, + CoreStart, + Plugin, + PluginInitializerContext, +} from 'opensearch-dashboards/public'; +import { UiActionsStart, UiActionsSetup } from 'src/plugins/ui_actions/public'; +import { EmbeddableStart, EmbeddableSetup } from 'src/plugins/embeddable/public'; +import { ChartsPluginStart } from 'src/plugins/charts/public'; +import { NavigationPublicPluginStart as NavigationStart } from 'src/plugins/navigation/public'; +import { SharePluginStart, SharePluginSetup, UrlGeneratorContract } from 'src/plugins/share/public'; +import { VisualizationsStart, VisualizationsSetup } from 'src/plugins/visualizations/public'; +import { + OpenSearchDashboardsLegacySetup, + OpenSearchDashboardsLegacyStart, +} from 'src/plugins/opensearch_dashboards_legacy/public'; +import { UrlForwardingSetup, UrlForwardingStart } from 'src/plugins/url_forwarding/public'; +import { HomePublicPluginSetup } from 'src/plugins/home/public'; +import { Start as InspectorPublicPluginStart } from 'src/plugins/inspector/public'; +import { stringify } from 'query-string'; +import rison from 'rison-node'; +import { NEW_DISCOVER_APP } from '../../discover/public'; +import { DataPublicPluginStart, DataPublicPluginSetup, opensearchFilters } from '../../data/public'; +import { SavedObjectLoader } from '../../saved_objects/public'; +import { createOsdUrlTracker, url } from '../../opensearch_dashboards_utils/public'; +import { UrlGeneratorState } from '../../share/public'; +import { DocViewInput, DocViewInputFn } from './application/doc_views/doc_views_types'; +import { DocViewLink } from './application/doc_views_links/doc_views_links_types'; +import { DocViewsRegistry } from './application/doc_views/doc_views_registry'; +import { DocViewsLinksRegistry } from './application/doc_views_links/doc_views_links_registry'; +import { DocViewTable } from './application/components/table/table'; +import { JsonCodeBlock } from './application/components/json_code_block/json_code_block'; +import { + setDocViewsRegistry, + setDocViewsLinksRegistry, + setUrlTracker, + setAngularModule, + setServices, + setHeaderActionMenuMounter, + setUiActions, + setScopedHistory, + getScopedHistory, + syncHistoryLocations, + getServices, +} from './opensearch_dashboards_services'; +import { createSavedSearchesLoader } from './saved_searches'; +import { buildServices } from './build_services'; +import { + DiscoverUrlGeneratorState, + DISCOVER_APP_URL_GENERATOR, + DiscoverUrlGenerator, +} from './url_generator'; +import { SearchEmbeddableFactory } from './application/embeddable'; +import { AppNavLinkStatus } from '../../../core/public'; +import { ViewRedirectParams } from '../../data_explorer/public'; + +declare module '../../share/public' { + export interface UrlGeneratorStateMapping { + [DISCOVER_APP_URL_GENERATOR]: UrlGeneratorState; + } +} + +/** + * @public + */ +export interface DiscoverSetup { + docViews: { + /** + * Add new doc view shown along with table view and json view in the details of each document in Discover. + * Both react and angular doc views are supported. + * @param docViewRaw + */ + addDocView(docViewRaw: DocViewInput | DocViewInputFn): void; + }; + + docViewsLinks: { + addDocViewLink(docViewLinkRaw: DocViewLink): void; + }; +} + +export interface DiscoverStart { + savedSearchLoader: SavedObjectLoader; + + /** + * `share` plugin URL generator for Discover app. Use it to generate links into + * Discover application, example: + * + * ```ts + * const url = await plugins.discover.urlGenerator.createUrl({ + * savedSearchId: '571aaf70-4c88-11e8-b3d7-01146121b73d', + * indexPatternId: 'c367b774-a4c2-11ea-bb37-0242ac130002', + * timeRange: { + * to: 'now', + * from: 'now-15m', + * mode: 'relative', + * }, + * }); + * ``` + */ + readonly urlGenerator: undefined | UrlGeneratorContract<'DISCOVER_APP_URL_GENERATOR'>; +} + +/** + * @internal + */ +export interface DiscoverSetupPlugins { + share?: SharePluginSetup; + uiActions: UiActionsSetup; + embeddable: EmbeddableSetup; + opensearchDashboardsLegacy: OpenSearchDashboardsLegacySetup; + urlForwarding: UrlForwardingSetup; + home?: HomePublicPluginSetup; + visualizations: VisualizationsSetup; + data: DataPublicPluginSetup; +} + +/** + * @internal + */ +export interface DiscoverStartPlugins { + uiActions: UiActionsStart; + embeddable: EmbeddableStart; + navigation: NavigationStart; + charts: ChartsPluginStart; + data: DataPublicPluginStart; + share?: SharePluginStart; + opensearchDashboardsLegacy: OpenSearchDashboardsLegacyStart; + urlForwarding: UrlForwardingStart; + inspector: InspectorPublicPluginStart; + visualizations: VisualizationsStart; +} + +const innerAngularName = 'app/discover'; +const embeddableAngularName = 'app/discoverEmbeddable'; + +/** + * Contains Discover, one of the oldest parts of OpenSearch Dashboards + * There are 2 kinds of Angular bootstrapped for rendering, additionally to the main Angular + * Discover provides embeddables, those contain a slimmer Angular + */ +export class DiscoverPlugin + implements Plugin { + constructor(private readonly initializerContext: PluginInitializerContext) {} + + private appStateUpdater = new BehaviorSubject(() => ({})); + private docViewsRegistry: DocViewsRegistry | null = null; + private docViewsLinksRegistry: DocViewsLinksRegistry | null = null; + private embeddableInjector: auto.IInjectorService | null = null; + private stopUrlTracking: (() => void) | undefined = undefined; + private servicesInitialized: boolean = false; + private innerAngularInitialized: boolean = false; + private urlGenerator?: DiscoverStart['urlGenerator']; + + /** + * why are those functions public? they are needed for some mocha tests + * can be removed once all is Jest + */ + public initializeInnerAngular?: () => void; + public initializeServices?: () => Promise<{ core: CoreStart; plugins: DiscoverStartPlugins }>; + + setup(core: CoreSetup, plugins: DiscoverSetupPlugins) { + const baseUrl = core.http.basePath.prepend('/app/discover'); + + if (plugins.share) { + this.urlGenerator = plugins.share.urlGenerators.registerUrlGenerator( + new DiscoverUrlGenerator({ + appBasePath: baseUrl, + useHash: core.uiSettings.get('state:storeInSessionStorage'), + }) + ); + } + + this.docViewsRegistry = new DocViewsRegistry(); + setDocViewsRegistry(this.docViewsRegistry); + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.table.tableTitle', { + defaultMessage: 'Table', + }), + order: 10, + component: DocViewTable, + }); + + this.docViewsRegistry.addDocView({ + title: i18n.translate('discover.docViews.json.jsonTitle', { + defaultMessage: 'JSON', + }), + order: 20, + component: JsonCodeBlock, + }); + + this.docViewsLinksRegistry = new DocViewsLinksRegistry(); + setDocViewsLinksRegistry(this.docViewsLinksRegistry); + + this.docViewsLinksRegistry.addDocViewLink({ + label: i18n.translate('discover.docTable.tableRow.viewSurroundingDocumentsLinkText', { + defaultMessage: 'View surrounding documents', + }), + generateCb: (renderProps: any) => { + const globalFilters: any = getServices().filterManager.getGlobalFilters(); + const appFilters: any = getServices().filterManager.getAppFilters(); + + const hash = stringify( + url.encodeQuery({ + _g: rison.encode({ + filters: globalFilters || [], + }), + _a: rison.encode({ + columns: renderProps.columns, + filters: (appFilters || []).map(opensearchFilters.disableFilter), + }), + }), + { encode: false, sort: false } + ); + + return { + url: `#/context/${encodeURIComponent(renderProps.indexPattern.id)}/${encodeURIComponent( + renderProps.hit._id + )}?${hash}`, + hide: !renderProps.indexPattern.isTimeBased(), + }; + }, + order: 1, + }); + + this.docViewsLinksRegistry.addDocViewLink({ + label: i18n.translate('discover.docTable.tableRow.viewSingleDocumentLinkText', { + defaultMessage: 'View single document', + }), + generateCb: (renderProps) => ({ + url: `#/doc/${renderProps.indexPattern.id}/${ + renderProps.hit._index + }?id=${encodeURIComponent(renderProps.hit._id)}`, + }), + order: 2, + }); + + const { + appMounted, + appUnMounted, + stop: stopUrlTracker, + setActiveUrl: setTrackedUrl, + restorePreviousUrl, + } = createOsdUrlTracker({ + // we pass getter here instead of plain `history`, + // so history is lazily created (when app is mounted) + // this prevents redundant `#` when not in discover app + getHistory: getScopedHistory, + baseUrl, + defaultSubUrl: '#/', + storageKey: `lastUrl:${core.http.basePath.get()}:discover_legacy`, + navLinkUpdater$: this.appStateUpdater, + toastNotifications: core.notifications.toasts, + stateParams: [ + { + osdUrlKey: '_g', + stateUpdate$: plugins.data.query.state$.pipe( + filter( + ({ changes }) => !!(changes.globalFilters || changes.time || changes.refreshInterval) + ), + map(({ state }) => ({ + ...state, + filters: state.filters?.filter(opensearchFilters.isFilterPinned), + })) + ), + }, + ], + }); + setUrlTracker({ setTrackedUrl, restorePreviousUrl }); + this.stopUrlTracking = () => { + stopUrlTracker(); + }; + + this.docViewsRegistry.setAngularInjectorGetter(this.getEmbeddableInjector); + core.application.register({ + id: 'discoverLegacy', + title: 'Discover Legacy', + defaultPath: '#/', + navLinkStatus: AppNavLinkStatus.hidden, + mount: async (params: AppMountParameters) => { + if (!this.initializeServices) { + throw Error('Discover plugin method initializeServices is undefined'); + } + if (!this.initializeInnerAngular) { + throw Error('Discover plugin method initializeInnerAngular is undefined'); + } + + // If a user explicitly tries to access the legacy app URL + const { + core: { + application: { navigateToApp }, + }, + } = await this.initializeServices(); + const path = window.location.hash; + + const v2Enabled = core.uiSettings.get(NEW_DISCOVER_APP); + if (v2Enabled) { + navigateToApp('discover', { + replace: true, + path, + }); + } + setScopedHistory(params.history); + setHeaderActionMenuMounter(params.setHeaderActionMenu); + syncHistoryLocations(); + appMounted(); + const { + plugins: { data: dataStart }, + } = await this.initializeServices(); + await this.initializeInnerAngular(); + + // make sure the index pattern list is up to date + await dataStart.indexPatterns.clearCache(); + const { renderApp } = await import('./application/application'); + params.element.classList.add('dscAppWrapper'); + const unmount = await renderApp(innerAngularName, params.element); + return () => { + params.element.classList.remove('dscAppWrapper'); + unmount(); + appUnMounted(); + }; + }, + }); + + plugins.urlForwarding.forwardApp('doc', 'discoverLegacy', (path) => { + return `#${path}`; + }); + plugins.urlForwarding.forwardApp('context', 'discoverLegacy', (path) => { + const urlParts = path.split('/'); + // take care of urls containing legacy url, those split in the following way + // ["", "context", indexPatternId, _type, id + params] + if (urlParts[4]) { + // remove _type part + const newPath = [...urlParts.slice(0, 3), ...urlParts.slice(4)].join('/'); + return `#${newPath}`; + } + return `#${path}`; + }); + plugins.urlForwarding.forwardApp('discover', 'discoverLegacy', (path) => { + const [, id, tail] = /discover\/([^\?]+)(.*)/.exec(path) || []; + if (!id) { + return `#${path.replace('/discover', '') || '/'}`; + } + return `#/view/${id}${tail || ''}`; + }); + + this.registerEmbeddable(core, plugins); + + return { + docViews: { + addDocView: this.docViewsRegistry.addDocView.bind(this.docViewsRegistry), + }, + docViewsLinks: { + addDocViewLink: this.docViewsLinksRegistry.addDocViewLink.bind(this.docViewsLinksRegistry), + }, + }; + } + + start(core: CoreStart, plugins: DiscoverStartPlugins) { + // we need to register the application service at setup, but to render it + // there are some start dependencies necessary, for this reason + // initializeInnerAngular + initializeServices are assigned at start and used + // when the application/embeddable is mounted + this.initializeInnerAngular = async () => { + if (this.innerAngularInitialized) { + return; + } + // this is used by application mount and tests + const { getInnerAngularModule } = await import('./get_inner_angular'); + const module = getInnerAngularModule( + innerAngularName, + core, + plugins, + this.initializerContext + ); + setAngularModule(module); + this.innerAngularInitialized = true; + }; + + setUiActions(plugins.uiActions); + + this.initializeServices = async () => { + if (this.servicesInitialized) { + return { core, plugins }; + } + const services = await buildServices( + core, + plugins, + this.initializerContext, + this.getEmbeddableInjector + ); + setServices(services); + this.servicesInitialized = true; + + return { core, plugins }; + }; + + return { + urlGenerator: this.urlGenerator, + savedSearchLoader: createSavedSearchesLoader({ + savedObjectsClient: core.savedObjects.client, + indexPatterns: plugins.data.indexPatterns, + search: plugins.data.search, + chrome: core.chrome, + overlays: core.overlays, + }), + }; + } + + stop() { + if (this.stopUrlTracking) { + this.stopUrlTracking(); + } + } + + /** + * register embeddable with a slimmer embeddable version of inner angular + */ + private registerEmbeddable(core: CoreSetup, plugins: DiscoverSetupPlugins) { + if (!this.getEmbeddableInjector) { + throw Error('Discover plugin method getEmbeddableInjector is undefined'); + } + + const getStartServices = async () => { + const [coreStart, deps] = await core.getStartServices(); + return { + executeTriggerActions: deps.uiActions.executeTriggerActions, + isEditable: () => coreStart.application.capabilities.discover.save as boolean, + }; + }; + + const factory = new SearchEmbeddableFactory(getStartServices, this.getEmbeddableInjector); + plugins.embeddable.registerEmbeddableFactory(factory.type, factory); + } + + private getEmbeddableInjector = async () => { + if (!this.embeddableInjector) { + if (!this.initializeServices) { + throw Error('Discover plugin getEmbeddableInjector: initializeServices is undefined'); + } + const { core, plugins } = await this.initializeServices(); + getServices().opensearchDashboardsLegacy.loadFontAwesome(); + const { getInnerAngularModuleEmbeddable } = await import('./get_inner_angular'); + getInnerAngularModuleEmbeddable(embeddableAngularName, core, plugins); + const mountpoint = document.createElement('div'); + this.embeddableInjector = angular.bootstrap(mountpoint, [embeddableAngularName]); + } + + return this.embeddableInjector; + }; +} diff --git a/src/plugins/discover_legacy/public/saved_searches/_saved_search.ts b/src/plugins/discover_legacy/public/saved_searches/_saved_search.ts new file mode 100644 index 000000000000..55cd59104ecb --- /dev/null +++ b/src/plugins/discover_legacy/public/saved_searches/_saved_search.ts @@ -0,0 +1,86 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + createSavedObjectClass, + SavedObject, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; + +export function createSavedSearchClass(services: SavedObjectOpenSearchDashboardsServices) { + const SavedObjectClass = createSavedObjectClass(services); + + class SavedSearch extends SavedObjectClass { + public static type: string = 'search'; + public static mapping = { + title: 'text', + description: 'text', + hits: 'integer', + columns: 'keyword', + sort: 'keyword', + version: 'integer', + }; + // Order these fields to the top, the rest are alphabetical + public static fieldOrder = ['title', 'description']; + public static searchSource = true; + + public id: string; + public showInRecentlyAccessed: boolean; + + constructor(id: string) { + super({ + id, + type: 'search', + mapping: { + title: 'text', + description: 'text', + hits: 'integer', + columns: 'keyword', + sort: 'keyword', + version: 'integer', + }, + searchSource: true, + defaults: { + title: '', + description: '', + columns: [], + hits: 0, + sort: [], + version: 1, + }, + }); + this.showInRecentlyAccessed = true; + this.id = id; + this.getFullPath = () => `/app/discover#/view/${String(id)}`; + } + } + + return SavedSearch as new (id: string) => SavedObject; +} diff --git a/src/plugins/discover_legacy/public/saved_searches/index.ts b/src/plugins/discover_legacy/public/saved_searches/index.ts new file mode 100644 index 000000000000..f576a9a9377a --- /dev/null +++ b/src/plugins/discover_legacy/public/saved_searches/index.ts @@ -0,0 +1,32 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export { createSavedSearchesLoader } from './saved_searches'; +export { SavedSearch, SavedSearchLoader } from './types'; diff --git a/src/plugins/discover_legacy/public/saved_searches/saved_searches.ts b/src/plugins/discover_legacy/public/saved_searches/saved_searches.ts new file mode 100644 index 000000000000..dd3243568159 --- /dev/null +++ b/src/plugins/discover_legacy/public/saved_searches/saved_searches.ts @@ -0,0 +1,50 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + SavedObjectLoader, + SavedObjectOpenSearchDashboardsServices, +} from '../../../saved_objects/public'; +import { createSavedSearchClass } from './_saved_search'; + +export function createSavedSearchesLoader(services: SavedObjectOpenSearchDashboardsServices) { + const SavedSearchClass = createSavedSearchClass(services); + const savedSearchLoader = new SavedObjectLoader(SavedSearchClass, services.savedObjectsClient); + // Customize loader properties since adding an 's' on type doesn't work for type 'search' . + savedSearchLoader.loaderProperties = { + name: 'searches', + noun: 'Saved Search', + nouns: 'saved searches', + }; + + savedSearchLoader.urlFor = (id: string) => (id ? `#/view/${encodeURIComponent(id)}` : '#/'); + + return savedSearchLoader; +} diff --git a/src/plugins/discover_legacy/public/saved_searches/types.ts b/src/plugins/discover_legacy/public/saved_searches/types.ts new file mode 100644 index 000000000000..e02fd65e6899 --- /dev/null +++ b/src/plugins/discover_legacy/public/saved_searches/types.ts @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { ISearchSource } from '../../../data/public'; + +export type SortOrder = [string, string]; +export interface SavedSearch { + readonly id: string; + title: string; + searchSource: ISearchSource; + description?: string; + columns: string[]; + sort: SortOrder[]; + destroy: () => void; + lastSavedTitle?: string; +} +export interface SavedSearchLoader { + get: (id: string) => Promise; + urlFor: (id: string) => string; +} diff --git a/src/plugins/discover_legacy/public/url_generator.test.ts b/src/plugins/discover_legacy/public/url_generator.test.ts new file mode 100644 index 000000000000..c352dd5133a4 --- /dev/null +++ b/src/plugins/discover_legacy/public/url_generator.test.ts @@ -0,0 +1,269 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { DiscoverUrlGenerator } from './url_generator'; +import { hashedItemStore, getStatesFromOsdUrl } from '../../opensearch_dashboards_utils/public'; +import { mockStorage } from '../../opensearch_dashboards_utils/public/storage/hashed_item_store/mock'; +import { FilterStateStore } from '../../data/common'; + +const appBasePath: string = 'xyz/app/discover'; +const indexPatternId: string = 'c367b774-a4c2-11ea-bb37-0242ac130002'; +const savedSearchId: string = '571aaf70-4c88-11e8-b3d7-01146121b73d'; + +interface SetupParams { + useHash?: boolean; +} + +const setup = async ({ useHash = false }: SetupParams = {}) => { + const generator = new DiscoverUrlGenerator({ + appBasePath, + useHash, + }); + + return { + generator, + }; +}; + +beforeEach(() => { + // @ts-ignore + hashedItemStore.storage = mockStorage; +}); + +describe('Discover url generator', () => { + test('can create a link to Discover with no state and no saved search', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({}); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(url.startsWith(appBasePath)).toBe(true); + expect(_a).toEqual({}); + expect(_g).toEqual({}); + }); + + test('can create a link to a saved search in Discover', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ savedSearchId }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(url.startsWith(`${appBasePath}#/${savedSearchId}`)).toBe(true); + expect(_a).toEqual({}); + expect(_g).toEqual({}); + }); + + test('can specify specific index pattern', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + indexPatternId, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({ + index: indexPatternId, + }); + expect(_g).toEqual({}); + }); + + test('can specify specific time range', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + timeRange: { to: 'now', from: 'now-15m', mode: 'relative' }, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + time: { + from: 'now-15m', + mode: 'relative', + to: 'now', + }, + }); + }); + + test('can specify query', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({ + query: { + language: 'kuery', + query: 'foo', + }, + }); + expect(_g).toEqual({}); + }); + + test('can specify local and global filters', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + filters: [ + { + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.APP_STATE, + }, + }, + { + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + $state: { + store: FilterStateStore.GLOBAL_STATE, + }, + }, + ], + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({ + filters: [ + { + $state: { + store: 'appState', + }, + meta: { + alias: 'foo', + disabled: false, + negate: false, + }, + }, + ], + }); + expect(_g).toEqual({ + filters: [ + { + $state: { + store: 'globalState', + }, + meta: { + alias: 'bar', + disabled: false, + negate: false, + }, + }, + ], + }); + }); + + test('can set refresh interval', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + refreshInterval: { + pause: false, + value: 666, + }, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + refreshInterval: { + pause: false, + value: 666, + }, + }); + }); + + test('can set time range', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + timeRange: { + from: 'now-3h', + to: 'now', + }, + }); + const { _a, _g } = getStatesFromOsdUrl(url, ['_a', '_g']); + + expect(_a).toEqual({}); + expect(_g).toEqual({ + time: { + from: 'now-3h', + to: 'now', + }, + }); + }); + + describe('useHash property', () => { + describe('when default useHash is set to false', () => { + test('when using default, sets index pattern ID in the generated URL', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + indexPatternId, + }); + + expect(url.indexOf(indexPatternId) > -1).toBe(true); + }); + + test('when enabling useHash, does not set index pattern ID in the generated URL', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + useHash: true, + indexPatternId, + }); + + expect(url.indexOf(indexPatternId) > -1).toBe(false); + }); + }); + + describe('when default useHash is set to true', () => { + test('when using default, does not set index pattern ID in the generated URL', async () => { + const { generator } = await setup({ useHash: true }); + const url = await generator.createUrl({ + indexPatternId, + }); + + expect(url.indexOf(indexPatternId) > -1).toBe(false); + }); + + test('when disabling useHash, sets index pattern ID in the generated URL', async () => { + const { generator } = await setup(); + const url = await generator.createUrl({ + useHash: false, + indexPatternId, + }); + + expect(url.indexOf(indexPatternId) > -1).toBe(true); + }); + }); + }); +}); diff --git a/src/plugins/discover_legacy/public/url_generator.ts b/src/plugins/discover_legacy/public/url_generator.ts new file mode 100644 index 000000000000..25e8517c8c9d --- /dev/null +++ b/src/plugins/discover_legacy/public/url_generator.ts @@ -0,0 +1,127 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { + TimeRange, + Filter, + Query, + opensearchFilters, + QueryState, + RefreshInterval, +} from '../../data/public'; +import { setStateToOsdUrl } from '../../opensearch_dashboards_utils/public'; +import { UrlGeneratorsDefinition } from '../../share/public'; + +export const DISCOVER_APP_URL_GENERATOR = 'DISCOVER_APP_URL_GENERATOR'; + +export interface DiscoverUrlGeneratorState { + /** + * Optionally set saved search ID. + */ + savedSearchId?: string; + + /** + * Optionally set index pattern ID. + */ + indexPatternId?: string; + + /** + * Optionally set the time range in the time picker. + */ + timeRange?: TimeRange; + + /** + * Optionally set the refresh interval. + */ + refreshInterval?: RefreshInterval; + + /** + * Optionally apply filers. + */ + filters?: Filter[]; + + /** + * Optionally set a query. NOTE: if given and used in conjunction with `dashboardId`, and the + * saved dashboard has a query saved with it, this will _replace_ that query. + */ + query?: Query; + + /** + * If not given, will use the uiSettings configuration for `storeInSessionStorage`. useHash determines + * whether to hash the data in the url to avoid url length issues. + */ + useHash?: boolean; +} + +interface Params { + appBasePath: string; + useHash: boolean; +} + +export class DiscoverUrlGenerator + implements UrlGeneratorsDefinition { + constructor(private readonly params: Params) {} + + public readonly id = DISCOVER_APP_URL_GENERATOR; + + public readonly createUrl = async ({ + filters, + indexPatternId, + query, + refreshInterval, + savedSearchId, + timeRange, + useHash = this.params.useHash, + }: DiscoverUrlGeneratorState): Promise => { + const savedSearchPath = savedSearchId ? encodeURIComponent(savedSearchId) : ''; + const appState: { + query?: Query; + filters?: Filter[]; + index?: string; + } = {}; + const queryState: QueryState = {}; + + if (query) appState.query = query; + if (filters && filters.length) + appState.filters = filters?.filter((f) => !opensearchFilters.isFilterPinned(f)); + if (indexPatternId) appState.index = indexPatternId; + + if (timeRange) queryState.time = timeRange; + if (filters && filters.length) + queryState.filters = filters?.filter((f) => opensearchFilters.isFilterPinned(f)); + if (refreshInterval) queryState.refreshInterval = refreshInterval; + + let url = `${this.params.appBasePath}#/${savedSearchPath}`; + url = setStateToOsdUrl('_g', queryState, { useHash }, url); + url = setStateToOsdUrl('_a', appState, { useHash }, url); + + return url; + }; +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular/angular_config.tsx b/src/plugins/opensearch_dashboards_legacy/public/angular/angular_config.tsx new file mode 100644 index 000000000000..fbe36a289d70 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular/angular_config.tsx @@ -0,0 +1,380 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { + ICompileProvider, + IHttpProvider, + IHttpService, + ILocationProvider, + IModule, + IRootScopeService, +} from 'angular'; +import $ from 'jquery'; +import { set } from '@elastic/safer-lodash-set'; +import { get } from 'lodash'; +import * as Rx from 'rxjs'; +import { ChromeBreadcrumb, EnvironmentMode, PackageInfo } from 'opensearch-dashboards/public'; +import { History } from 'history'; + +import { CoreStart } from 'opensearch-dashboards/public'; +import { isSystemApiRequest } from '../utils'; +import { formatAngularHttpError, isAngularHttpError } from '../notify/lib'; + +export interface RouteConfiguration { + controller?: string | ((...args: any[]) => void); + redirectTo?: string; + resolveRedirectTo?: (...args: any[]) => void; + reloadOnSearch?: boolean; + reloadOnUrl?: boolean; + outerAngularWrapperRoute?: boolean; + resolve?: object; + template?: string; + k7Breadcrumbs?: (...args: any[]) => ChromeBreadcrumb[]; + requireUICapability?: string; +} + +/** + * Detects whether a given angular route is a dummy route that doesn't + * require any action. There are two ways this can happen: + * If `outerAngularWrapperRoute` is set on the route config object, + * it means the local application service set up this route on the outer angular + * and the internal routes will handle the hooks. + * + * If angular did not detect a route and it is the local angular, we are currently + * navigating away from a URL controlled by a local angular router and the + * application will get unmounted. In this case the outer router will handle + * the hooks. + * @param $route Injected $route dependency + * @param isLocalAngular Flag whether this is the local angular router + */ +function isDummyRoute($route: any, isLocalAngular: boolean) { + return ( + ($route.current && $route.current.$$route && $route.current.$$route.outerAngularWrapperRoute) || + (!$route.current && isLocalAngular) + ); +} + +export const configureAppAngularModule = ( + angularModule: IModule, + newPlatform: { + core: CoreStart; + readonly env: { + mode: Readonly; + packageInfo: Readonly; + }; + }, + isLocalAngular: boolean, + getHistory?: () => History +) => { + const core = 'core' in newPlatform ? newPlatform.core : newPlatform; + const packageInfo = newPlatform.env.packageInfo; + + angularModule + .value('osdVersion', packageInfo.version) + .value('buildNum', packageInfo.buildNum) + .value('buildSha', packageInfo.buildSha) + .value('opensearchUrl', getOpenSearchUrl(core)) + .value('uiCapabilities', core.application.capabilities) + .config(setupCompileProvider(newPlatform.env.mode.dev)) + .config(setupLocationProvider()) + .config($setupXsrfRequestInterceptor(packageInfo.version)) + .run(capture$httpLoadingCount(core)) + .run(digestOnHashChange(getHistory)) + .run($setupBreadcrumbsAutoClear(core, isLocalAngular)) + .run($setupBadgeAutoClear(core, isLocalAngular)) + .run($setupHelpExtensionAutoClear(core, isLocalAngular)) + .run($setupUICapabilityRedirect(core)); +}; + +const getOpenSearchUrl = (newPlatform: CoreStart) => { + const a = document.createElement('a'); + a.href = newPlatform.http.basePath.prepend('/opensearch'); + const protocolPort = /https/.test(a.protocol) ? 443 : 80; + const port = a.port || protocolPort; + return { + host: a.hostname, + port, + protocol: a.protocol, + pathname: a.pathname, + }; +}; + +const digestOnHashChange = (getHistory?: () => History) => ($rootScope: IRootScopeService) => { + if (!getHistory) return; + const unlisten = getHistory().listen(() => { + // dispatch synthetic hash change event to update hash history objects and angular routing + // this is necessary because hash updates triggered by using popState won't trigger this event naturally. + // this has to happen in the next tick to not change the existing timing of angular digest cycles. + setTimeout(() => { + window.dispatchEvent(new HashChangeEvent('hashchange')); + }, 0); + }); + $rootScope.$on('$destroy', unlisten); +}; + +const setupCompileProvider = (devMode: boolean) => ($compileProvider: ICompileProvider) => { + if (!devMode) { + $compileProvider.debugInfoEnabled(false); + } +}; + +const setupLocationProvider = () => ($locationProvider: ILocationProvider) => { + $locationProvider.html5Mode({ + enabled: false, + requireBase: false, + rewriteLinks: false, + }); + + $locationProvider.hashPrefix(''); +}; + +export const $setupXsrfRequestInterceptor = (version: string) => { + // Configure jQuery prefilter + $.ajaxPrefilter(({ osdXsrfToken = true }: any, originalOptions, jqXHR) => { + if (osdXsrfToken) { + jqXHR.setRequestHeader('osd-xsrf', 'osd-legacy'); + // ToDo: Remove next; `osd-version` incorrectly used for satisfying XSRF protection + jqXHR.setRequestHeader('osd-version', version); + } + }); + + return ($httpProvider: IHttpProvider) => { + // Configure $httpProvider interceptor + $httpProvider.interceptors.push(() => { + return { + request(opts) { + const { osdXsrfToken = true } = opts as any; + if (osdXsrfToken) { + set(opts, ['headers', 'osd-xsrf'], 'osd-legacy'); + // ToDo: Remove next; `osd-version` incorrectly used for satisfying XSRF protection + set(opts, ['headers', 'osd-version'], version); + } + return opts; + }, + }; + }); + }; +}; + +/** + * Injected into angular module by ui/chrome angular integration + * and adds a root-level watcher that will capture the count of + * active $http requests on each digest loop and expose the count to + * the core.loadingCount api + */ +const capture$httpLoadingCount = (newPlatform: CoreStart) => ( + $rootScope: IRootScopeService, + $http: IHttpService +) => { + newPlatform.http.addLoadingCountSource( + new Rx.Observable((observer) => { + const unwatch = $rootScope.$watch(() => { + const reqs = $http.pendingRequests || []; + observer.next(reqs.filter((req) => !isSystemApiRequest(req)).length); + }); + + return unwatch; + }) + ); +}; + +/** + * integrates with angular to automatically redirect to home if required + * capability is not met + */ +const $setupUICapabilityRedirect = (newPlatform: CoreStart) => ( + $rootScope: IRootScopeService, + $injector: any +) => { + const isOpenSearchDashboardsAppRoute = window.location.pathname.endsWith( + '/app/opensearch-dashboards' + ); + // this feature only works within opensearch dashboards app for now after everything is + // switched to the application service, this can be changed to handle all + // apps. + if (!isOpenSearchDashboardsAppRoute) { + return; + } + $rootScope.$on( + '$routeChangeStart', + (event, { $$route: route }: { $$route?: RouteConfiguration } = {}) => { + if (!route || !route.requireUICapability) { + return; + } + + if (!get(newPlatform.application.capabilities, route.requireUICapability)) { + $injector.get('$location').url('/home'); + event.preventDefault(); + } + } + ); +}; + +/** + * internal angular run function that will be called when angular bootstraps and + * lets us integrate with the angular router so that we can automatically clear + * the breadcrumbs if we switch to a OpenSearch Dashboards app that does not use breadcrumbs correctly + */ +const $setupBreadcrumbsAutoClear = (newPlatform: CoreStart, isLocalAngular: boolean) => ( + $rootScope: IRootScopeService, + $injector: any +) => { + // A flag used to determine if we should automatically + // clear the breadcrumbs between angular route changes. + let breadcrumbSetSinceRouteChange = false; + const $route = $injector.has('$route') ? $injector.get('$route') : {}; + + // reset breadcrumbSetSinceRouteChange any time the breadcrumbs change, even + // if it was done directly through the new platform + newPlatform.chrome.getBreadcrumbs$().subscribe({ + next() { + breadcrumbSetSinceRouteChange = true; + }, + }); + + $rootScope.$on('$routeChangeStart', () => { + breadcrumbSetSinceRouteChange = false; + }); + + $rootScope.$on('$routeChangeSuccess', () => { + if (isDummyRoute($route, isLocalAngular)) { + return; + } + const current = $route.current || {}; + + if (breadcrumbSetSinceRouteChange || (current.$$route && current.$$route.redirectTo)) { + return; + } + + const k7BreadcrumbsProvider = current.k7Breadcrumbs; + if (!k7BreadcrumbsProvider) { + newPlatform.chrome.setBreadcrumbs([]); + return; + } + + try { + newPlatform.chrome.setBreadcrumbs($injector.invoke(k7BreadcrumbsProvider)); + } catch (error) { + if (isAngularHttpError(error)) { + error = formatAngularHttpError(error); + } + newPlatform.fatalErrors.add(error, 'location'); + } + }); +}; + +/** + * internal angular run function that will be called when angular bootstraps and + * lets us integrate with the angular router so that we can automatically clear + * the badge if we switch to a OpenSearch Dashboards app that does not use the badge correctly + */ +const $setupBadgeAutoClear = (newPlatform: CoreStart, isLocalAngular: boolean) => ( + $rootScope: IRootScopeService, + $injector: any +) => { + // A flag used to determine if we should automatically + // clear the badge between angular route changes. + let badgeSetSinceRouteChange = false; + const $route = $injector.has('$route') ? $injector.get('$route') : {}; + + $rootScope.$on('$routeChangeStart', () => { + badgeSetSinceRouteChange = false; + }); + + $rootScope.$on('$routeChangeSuccess', () => { + if (isDummyRoute($route, isLocalAngular)) { + return; + } + const current = $route.current || {}; + + if (badgeSetSinceRouteChange || (current.$$route && current.$$route.redirectTo)) { + return; + } + + const badgeProvider = current.badge; + if (!badgeProvider) { + newPlatform.chrome.setBadge(undefined); + return; + } + + try { + newPlatform.chrome.setBadge($injector.invoke(badgeProvider)); + } catch (error) { + if (isAngularHttpError(error)) { + error = formatAngularHttpError(error); + } + newPlatform.fatalErrors.add(error, 'location'); + } + }); +}; + +/** + * internal angular run function that will be called when angular bootstraps and + * lets us integrate with the angular router so that we can automatically clear + * the helpExtension if we switch to a OpenSearch Dashboards app that does not set its own + * helpExtension + */ +const $setupHelpExtensionAutoClear = (newPlatform: CoreStart, isLocalAngular: boolean) => ( + $rootScope: IRootScopeService, + $injector: any +) => { + /** + * reset helpExtensionSetSinceRouteChange any time the helpExtension changes, even + * if it was done directly through the new platform + */ + let helpExtensionSetSinceRouteChange = false; + newPlatform.chrome.getHelpExtension$().subscribe({ + next() { + helpExtensionSetSinceRouteChange = true; + }, + }); + + const $route = $injector.has('$route') ? $injector.get('$route') : {}; + + $rootScope.$on('$routeChangeStart', () => { + if (isDummyRoute($route, isLocalAngular)) { + return; + } + helpExtensionSetSinceRouteChange = false; + }); + + $rootScope.$on('$routeChangeSuccess', () => { + if (isDummyRoute($route, isLocalAngular)) { + return; + } + const current = $route.current || {}; + + if (helpExtensionSetSinceRouteChange || (current.$$route && current.$$route.redirectTo)) { + return; + } + + newPlatform.chrome.setHelpExtension(current.helpExtension); + }); +}; diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular/index.ts b/src/plugins/opensearch_dashboards_legacy/public/angular/index.ts new file mode 100644 index 000000000000..c492de510093 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular/index.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. + */ + +// @ts-ignore +export { PromiseServiceCreator } from './promises'; +// @ts-ignore +export { watchMultiDecorator } from './watch_multi'; +export * from './angular_config'; +// @ts-ignore +export { createTopNavDirective, createTopNavHelper, loadOsdTopNavDirectives } from './osd_top_nav'; +export { subscribeWithScope } from './subscribe_with_scope'; diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular/osd_top_nav.js b/src/plugins/opensearch_dashboards_legacy/public/angular/osd_top_nav.js new file mode 100644 index 000000000000..11835005b60c --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular/osd_top_nav.js @@ -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 angular from 'angular'; +import 'ngreact'; + +export function createTopNavDirective() { + return { + restrict: 'E', + template: '', + compile: (elem) => { + const child = document.createElement('osd-top-nav-helper'); + + // Copy attributes to the child directive + for (const attr of elem[0].attributes) { + child.setAttribute(attr.name, attr.value); + } + + // Add a special attribute that will change every time that one + // of the config array's disableButton function return value changes. + child.setAttribute('disabled-buttons', 'disabledButtons'); + + // Append helper directive + elem.append(child); + + const linkFn = ($scope, _, $attr) => { + // Watch config changes + $scope.$watch( + () => { + const config = $scope.$eval($attr.config) || []; + return config.map((item) => { + // Copy key into id, as it's a reserved react propery. + // This is done for Angular directive backward compatibility. + // In React only id is recognized. + if (item.key && !item.id) { + item.id = item.key; + } + + // Watch the disableButton functions + if (typeof item.disableButton === 'function') { + return item.disableButton(); + } + return item.disableButton; + }); + }, + (newVal) => { + $scope.disabledButtons = newVal; + }, + true + ); + }; + + return linkFn; + }, + }; +} + +export const createTopNavHelper = ({ TopNavMenu }) => (reactDirective) => { + return reactDirective(TopNavMenu, [ + ['config', { watchDepth: 'value' }], + ['setMenuMountPoint', { watchDepth: 'reference' }], + ['disabledButtons', { watchDepth: 'reference' }], + + ['query', { watchDepth: 'reference' }], + ['savedQuery', { watchDepth: 'reference' }], + ['intl', { watchDepth: 'reference' }], + + ['onQuerySubmit', { watchDepth: 'reference' }], + ['onFiltersUpdated', { watchDepth: 'reference' }], + ['onRefreshChange', { watchDepth: 'reference' }], + ['onClearSavedQuery', { watchDepth: 'reference' }], + ['onSaved', { watchDepth: 'reference' }], + ['onSavedQueryUpdated', { watchDepth: 'reference' }], + ['onSavedQueryIdChange', { watchDepth: 'reference' }], + + ['indexPatterns', { watchDepth: 'collection' }], + ['filters', { watchDepth: 'collection' }], + + // All modifiers default to true. + // Set to false to hide subcomponents. + 'showSearchBar', + 'showQueryBar', + 'showQueryInput', + 'showSaveQuery', + 'showDatePicker', + 'showFilterBar', + + 'appName', + 'screenTitle', + 'dateRangeFrom', + 'dateRangeTo', + 'savedQueryId', + 'isRefreshPaused', + 'refreshInterval', + 'disableAutoFocus', + 'showAutoRefreshOnly', + + // temporary flag to use the stateful components + 'useDefaultBehaviors', + ]); +}; + +let isLoaded = false; + +export function loadOsdTopNavDirectives(navUi) { + if (!isLoaded) { + isLoaded = true; + angular + .module('opensearchDashboards') + .directive('osdTopNav', createTopNavDirective) + .directive('osdTopNavHelper', createTopNavHelper(navUi)); + } +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular/promises.js b/src/plugins/opensearch_dashboards_legacy/public/angular/promises.js new file mode 100644 index 000000000000..690bc5489d10 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular/promises.js @@ -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 _ from 'lodash'; + +export function PromiseServiceCreator($q, $timeout) { + function Promise(fn) { + if (typeof this === 'undefined') + throw new Error('Promise constructor must be called with "new"'); + + const defer = $q.defer(); + try { + fn(defer.resolve, defer.reject); + } catch (e) { + defer.reject(e); + } + return defer.promise; + } + + Promise.all = Promise.props = $q.all; + Promise.resolve = function (val) { + const defer = $q.defer(); + defer.resolve(val); + return defer.promise; + }; + Promise.reject = function (reason) { + const defer = $q.defer(); + defer.reject(reason); + return defer.promise; + }; + Promise.cast = $q.when; + Promise.delay = function (ms) { + return $timeout(_.noop, ms); + }; + Promise.method = function (fn) { + return function () { + const args = Array.prototype.slice.call(arguments); + return Promise.try(fn, args, this); + }; + }; + Promise.nodeify = function (promise, cb) { + promise.then(function (val) { + cb(void 0, val); + }, cb); + }; + Promise.map = function (arr, fn) { + return Promise.all( + arr.map(function (i, el, list) { + return Promise.try(fn, [i, el, list]); + }) + ); + }; + Promise.each = function (arr, fn) { + const queue = arr.slice(0); + let i = 0; + return (function next() { + if (!queue.length) return arr; + return Promise.try(fn, [arr.shift(), i++]).then(next); + })(); + }; + Promise.is = function (obj) { + // $q doesn't create instances of any constructor, promises are just objects with a then function + // https://github.com/angular/angular.js/blob/58f5da86645990ef984353418cd1ed83213b111e/src/ng/q.js#L335 + return obj && typeof obj.then === 'function'; + }; + Promise.halt = _.once(function () { + const promise = new Promise(() => {}); + promise.then = _.constant(promise); + promise.catch = _.constant(promise); + return promise; + }); + Promise.try = function (fn, args, ctx) { + if (typeof fn !== 'function') { + return Promise.reject(new TypeError('fn must be a function')); + } + + let value; + + if (Array.isArray(args)) { + try { + value = fn.apply(ctx, args); + } catch (e) { + return Promise.reject(e); + } + } else { + try { + value = fn.call(ctx, args); + } catch (e) { + return Promise.reject(e); + } + } + + return Promise.resolve(value); + }; + Promise.fromNode = function (takesCbFn) { + return new Promise(function (resolve, reject) { + takesCbFn(function (err, ...results) { + if (err) reject(err); + else if (results.length > 1) resolve(results); + else resolve(results[0]); + }); + }); + }; + Promise.race = function (iterable) { + return new Promise((resolve, reject) => { + for (const i of iterable) { + Promise.resolve(i).then(resolve, reject); + } + }); + }; + + return Promise; +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular/subscribe_with_scope.test.ts b/src/plugins/opensearch_dashboards_legacy/public/angular/subscribe_with_scope.test.ts new file mode 100644 index 000000000000..3784988fc818 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular/subscribe_with_scope.test.ts @@ -0,0 +1,208 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 * as Rx from 'rxjs'; +import { subscribeWithScope } from './subscribe_with_scope'; + +// eslint-disable-next-line prefer-const +let $rootScope: Scope; + +class Scope { + public $$phase?: string; + public $root = $rootScope; + public $apply = jest.fn((fn: () => void) => fn()); +} + +$rootScope = new Scope(); + +afterEach(() => { + jest.clearAllMocks(); +}); + +it('subscribes to the passed observable, returns subscription', () => { + const $scope = new Scope(); + + const unsubSpy = jest.fn(); + const subSpy = jest.fn(() => unsubSpy); + const observable = new Rx.Observable(subSpy); + + const subscription = subscribeWithScope($scope as any, observable); + expect(subSpy).toHaveBeenCalledTimes(1); + expect(unsubSpy).not.toHaveBeenCalled(); + + subscription.unsubscribe(); + + expect(subSpy).toHaveBeenCalledTimes(1); + expect(unsubSpy).toHaveBeenCalledTimes(1); +}); + +it('calls observer.next() if already in a digest cycle, wraps in $scope.$apply if not', () => { + const subject = new Rx.Subject(); + const nextSpy = jest.fn(); + const $scope = new Scope(); + + subscribeWithScope($scope as any, subject, { next: nextSpy }); + + subject.next(); + expect($scope.$apply).toHaveBeenCalledTimes(1); + expect(nextSpy).toHaveBeenCalledTimes(1); + + jest.clearAllMocks(); + + $rootScope.$$phase = '$digest'; + subject.next(); + expect($scope.$apply).not.toHaveBeenCalled(); + expect(nextSpy).toHaveBeenCalledTimes(1); +}); + +it('reports fatalError if observer.next() throws', () => { + const fatalError = jest.fn(); + const $scope = new Scope(); + subscribeWithScope( + $scope as any, + Rx.of(undefined), + { + next() { + throw new Error('foo bar'); + }, + }, + fatalError + ); + + expect(fatalError.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + [Error: foo bar], + ], +] +`); +}); + +it('reports fatal error if observer.error is not defined and observable errors', () => { + const fatalError = jest.fn(); + const $scope = new Scope(); + const error = new Error('foo'); + error.stack = `${error.message}\n---stack trace ---`; + subscribeWithScope($scope as any, Rx.throwError(error), undefined, fatalError); + + expect(fatalError.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + [Error: Uncaught error in subscribeWithScope(): foo +---stack trace ---], + ], +] +`); +}); + +it('reports fatal error if observer.error throws', () => { + const fatalError = jest.fn(); + const $scope = new Scope(); + subscribeWithScope( + $scope as any, + Rx.throwError(new Error('foo')), + { + error: () => { + throw new Error('foo'); + }, + }, + fatalError + ); + + expect(fatalError.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + [Error: foo], + ], +] +`); +}); + +it('does not report fatal error if observer.error handles the error', () => { + const fatalError = jest.fn(); + const $scope = new Scope(); + subscribeWithScope( + $scope as any, + Rx.throwError(new Error('foo')), + { + error: () => { + // noop, swallow error + }, + }, + fatalError + ); + + expect(fatalError.mock.calls).toEqual([]); +}); + +it('reports fatal error if observer.complete throws', () => { + const fatalError = jest.fn(); + const $scope = new Scope(); + subscribeWithScope( + $scope as any, + Rx.EMPTY, + { + complete: () => { + throw new Error('foo'); + }, + }, + fatalError + ); + + expect(fatalError.mock.calls).toMatchInlineSnapshot(` +Array [ + Array [ + [Error: foo], + ], +] +`); +}); + +it('preserves the context of the observer functions', () => { + const $scope = new Scope(); + const observer = { + next() { + expect(this).toBe(observer); + }, + complete() { + expect(this).toBe(observer); + }, + }; + + subscribeWithScope($scope as any, Rx.of([1, 2, 3]), observer); + + const observer2 = { + error() { + expect(this).toBe(observer); + }, + }; + + subscribeWithScope($scope as any, Rx.throwError(new Error('foo')), observer2); +}); diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular/subscribe_with_scope.ts b/src/plugins/opensearch_dashboards_legacy/public/angular/subscribe_with_scope.ts new file mode 100644 index 000000000000..f8cb102379b6 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular/subscribe_with_scope.ts @@ -0,0 +1,96 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { IScope } from 'angular'; +import * as Rx from 'rxjs'; +import { AngularHttpError } from '../notify/lib'; + +type FatalErrorFn = (error: AngularHttpError | Error | string, location?: string) => void; + +function callInDigest($scope: IScope, fn: () => void, fatalError?: FatalErrorFn) { + try { + // this is terrible, but necessary to synchronously deliver subscription values + // to angular scopes. This is required by some APIs, like the `config` service, + // and beneficial for root level directives where additional digest cycles make + // opensearch dashboards sluggish to load. + // + // If you copy this code elsewhere you better have a good reason :) + if ($scope.$root.$$phase) { + fn(); + } else { + $scope.$apply(() => fn()); + } + } catch (error) { + if (fatalError) { + fatalError(error); + } + } +} + +/** + * Subscribe to an observable at a $scope, ensuring that the digest cycle + * is run for subscriber hooks and routing errors to fatalError if not handled. + */ +export function subscribeWithScope( + $scope: IScope, + observable: Rx.Observable, + observer?: Rx.PartialObserver, + fatalError?: FatalErrorFn +) { + return observable.subscribe({ + next(value) { + if (observer && observer.next) { + callInDigest($scope, () => observer.next!(value), fatalError); + } + }, + error(error) { + callInDigest( + $scope, + () => { + if (observer && observer.error) { + observer.error(error); + } else { + throw new Error( + `Uncaught error in subscribeWithScope(): ${ + error ? error.stack || error.message : error + }` + ); + } + }, + fatalError + ); + }, + complete() { + if (observer && observer.complete) { + callInDigest($scope, () => observer.complete!(), fatalError); + } + }, + }); +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular/watch_multi.js b/src/plugins/opensearch_dashboards_legacy/public/angular/watch_multi.js new file mode 100644 index 000000000000..8dfcb0f59420 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular/watch_multi.js @@ -0,0 +1,159 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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'; + +export function watchMultiDecorator($provide) { + $provide.decorator('$rootScope', function ($delegate) { + /** + * Watch multiple expressions with a single callback. Along + * with making code simpler it also merges all of the watcher + * handlers within a single tick. + * + * # expression format + * expressions can be specified in one of the following ways: + * 1. string that evaluates to a value on scope. Creates a regular $watch + * expression. + * 'someScopeValue.prop' === $scope.$watch('someScopeValue.prop', fn); + * + * 2. #1 prefixed with '[]', which uses $watchCollection rather than $watch. + * '[]expr' === $scope.$watchCollection('expr', fn); + * + * 3. #1 prefixed with '=', which uses $watch with objectEquality turned on + * '=expr' === $scope.$watch('expr', fn, true); + * + * 4. a function that will be called, like a normal function water + * + * 5. an object with any of the properties: + * `get`: the getter called on each iteration + * `deep`: a flag to turn on objectEquality in $watch + * `fn`: the watch registration function ($scope.$watch or $scope.$watchCollection) + * + * @param {array[string|function|obj]} expressions - the list of expressions to $watch + * @param {Function} fn - the callback function + * @return {Function} - an unwatch function, just like the return value of $watch + */ + $delegate.constructor.prototype.$watchMulti = function (expressions, fn) { + if (!Array.isArray(expressions)) { + throw new TypeError('expected an array of expressions to watch'); + } + + if (!_.isFunction(fn)) { + throw new TypeError('expected a function that is triggered on each watch'); + } + const $scope = this; + const vals = new Array(expressions.length); + const prev = new Array(expressions.length); + let fire = false; + let init = 0; + const neededInits = expressions.length; + + // first, register all of the multi-watchers + const unwatchers = expressions.map(function (expr, i) { + expr = normalizeExpression($scope, expr); + if (!expr) return; + + return expr.fn.call( + $scope, + expr.get, + function (newVal, oldVal) { + if (newVal === oldVal) { + init += 1; + } + + vals[i] = newVal; + prev[i] = oldVal; + fire = true; + }, + expr.deep + ); + }); + + // then, the watcher that checks to see if any of + // the other watchers triggered this cycle + let flip = false; + unwatchers.push( + $scope.$watch( + function () { + if (init < neededInits) return init; + + if (fire) { + fire = false; + flip = !flip; + } + return flip; + }, + function () { + if (init < neededInits) return false; + + fn(vals.slice(0), prev.slice(0)); + vals.forEach(function (v, i) { + prev[i] = v; + }); + } + ) + ); + + return function () { + unwatchers.forEach((listener) => listener()); + }; + }; + + function normalizeExpression($scope, expr) { + if (!expr) return; + const norm = { + fn: $scope.$watch, + deep: false, + }; + + if (_.isFunction(expr)) return _.assign(norm, { get: expr }); + if (_.isObject(expr)) return _.assign(norm, expr); + if (!_.isString(expr)) return; + + if (expr.substr(0, 2) === '[]') { + return _.assign(norm, { + fn: $scope.$watchCollection, + get: expr.substr(2), + }); + } + + if (expr.charAt(0) === '=') { + return _.assign(norm, { + deep: true, + get: expr.substr(1), + }); + } + + return _.assign(norm, { get: expr }); + } + + return $delegate; + }); +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/bind_html/bind_html.js b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/bind_html/bind_html.js new file mode 100755 index 000000000000..5e6f2edea608 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/bind_html/bind_html.js @@ -0,0 +1,28 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* eslint-disable */ + +import angular from 'angular'; + +export function initBindHtml() { + angular + .module('ui.bootstrap.bindHtml', []) + + .directive('bindHtmlUnsafe', function() { + return function(scope, element, attr) { + element.addClass('ng-binding').data('$binding', attr.bindHtmlUnsafe); + scope.$watch(attr.bindHtmlUnsafe, function bindHtmlUnsafeWatchAction(value) { + element.html(value || ''); + }); + }; + }); +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/index.ts b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/index.ts new file mode 100644 index 000000000000..63b0431ebb29 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/index.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. + */ + +/* eslint-disable */ + +import { once } from 'lodash'; +import angular from 'angular'; + +// @ts-ignore +import { initBindHtml } from './bind_html/bind_html'; +// @ts-ignore +import { initBootstrapTooltip } from './tooltip/tooltip'; + +import tooltipPopup from './tooltip/tooltip_popup.html'; + +import tooltipUnsafePopup from './tooltip/tooltip_html_unsafe_popup.html'; + +export const initAngularBootstrap = once(() => { + /* + * angular-ui-bootstrap + * http://angular-ui.github.io/bootstrap/ + + * Version: 0.12.1 - 2015-02-20 + * License: MIT + */ + angular.module('ui.bootstrap', [ + 'ui.bootstrap.tpls', + 'ui.bootstrap.bindHtml', + 'ui.bootstrap.tooltip', + ]); + + angular.module('ui.bootstrap.tpls', [ + 'template/tooltip/tooltip-html-unsafe-popup.html', + 'template/tooltip/tooltip-popup.html', + ]); + + initBindHtml(); + initBootstrapTooltip(); + + angular.module('template/tooltip/tooltip-html-unsafe-popup.html', []).run([ + '$templateCache', + function($templateCache: any) { + $templateCache.put('template/tooltip/tooltip-html-unsafe-popup.html', tooltipUnsafePopup); + }, + ]); + + angular.module('template/tooltip/tooltip-popup.html', []).run([ + '$templateCache', + function($templateCache: any) { + $templateCache.put('template/tooltip/tooltip-popup.html', tooltipPopup); + }, + ]); +}); diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/position.js b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/position.js new file mode 100755 index 000000000000..2f322e2b42e2 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/position.js @@ -0,0 +1,178 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* eslint-disable */ + +import angular from 'angular'; + +export function initBootstrapPosition() { + angular + .module('ui.bootstrap.position', []) + + /** + * A set of utility methods that can be use to retrieve position of DOM elements. + * It is meant to be used where we need to absolute-position DOM elements in + * relation to other, existing elements (this is the case for tooltips, popovers, + * typeahead suggestions etc.). + */ + .factory('$position', [ + '$document', + '$window', + function($document, $window) { + function getStyle(el, cssprop) { + if (el.currentStyle) { + //IE + return el.currentStyle[cssprop]; + } else if ($window.getComputedStyle) { + return $window.getComputedStyle(el)[cssprop]; + } + // finally try and get inline style + return el.style[cssprop]; + } + + /** + * Checks if a given element is statically positioned + * @param element - raw DOM element + */ + function isStaticPositioned(element) { + return (getStyle(element, 'position') || 'static') === 'static'; + } + + /** + * returns the closest, non-statically positioned parentOffset of a given element + * @param element + */ + const parentOffsetEl = function(element) { + const docDomEl = $document[0]; + let offsetParent = element.offsetParent || docDomEl; + while (offsetParent && offsetParent !== docDomEl && isStaticPositioned(offsetParent)) { + offsetParent = offsetParent.offsetParent; + } + return offsetParent || docDomEl; + }; + + return { + /** + * Provides read-only equivalent of jQuery's position function: + * http://api.jquery.com/position/ + */ + position: function(element) { + const elBCR = this.offset(element); + let offsetParentBCR = { top: 0, left: 0 }; + const offsetParentEl = parentOffsetEl(element[0]); + if (offsetParentEl != $document[0]) { + offsetParentBCR = this.offset(angular.element(offsetParentEl)); + offsetParentBCR.top += offsetParentEl.clientTop - offsetParentEl.scrollTop; + offsetParentBCR.left += offsetParentEl.clientLeft - offsetParentEl.scrollLeft; + } + + const boundingClientRect = element[0].getBoundingClientRect(); + return { + width: boundingClientRect.width || element.prop('offsetWidth'), + height: boundingClientRect.height || element.prop('offsetHeight'), + top: elBCR.top - offsetParentBCR.top, + left: elBCR.left - offsetParentBCR.left, + }; + }, + + /** + * Provides read-only equivalent of jQuery's offset function: + * http://api.jquery.com/offset/ + */ + offset: function(element) { + const boundingClientRect = element[0].getBoundingClientRect(); + return { + width: boundingClientRect.width || element.prop('offsetWidth'), + height: boundingClientRect.height || element.prop('offsetHeight'), + top: + boundingClientRect.top + + ($window.pageYOffset || $document[0].documentElement.scrollTop), + left: + boundingClientRect.left + + ($window.pageXOffset || $document[0].documentElement.scrollLeft), + }; + }, + + /** + * Provides coordinates for the targetEl in relation to hostEl + */ + positionElements: function(hostEl, targetEl, positionStr, appendToBody) { + const positionStrParts = positionStr.split('-'); + const pos0 = positionStrParts[0]; + const pos1 = positionStrParts[1] || 'center'; + + let hostElPos; + let targetElWidth; + let targetElHeight; + let targetElPos; + + hostElPos = appendToBody ? this.offset(hostEl) : this.position(hostEl); + + targetElWidth = targetEl.prop('offsetWidth'); + targetElHeight = targetEl.prop('offsetHeight'); + + const shiftWidth = { + center: function() { + return hostElPos.left + hostElPos.width / 2 - targetElWidth / 2; + }, + left: function() { + return hostElPos.left; + }, + right: function() { + return hostElPos.left + hostElPos.width; + }, + }; + + const shiftHeight = { + center: function() { + return hostElPos.top + hostElPos.height / 2 - targetElHeight / 2; + }, + top: function() { + return hostElPos.top; + }, + bottom: function() { + return hostElPos.top + hostElPos.height; + }, + }; + + switch (pos0) { + case 'right': + targetElPos = { + top: shiftHeight[pos1](), + left: shiftWidth[pos0](), + }; + break; + case 'left': + targetElPos = { + top: shiftHeight[pos1](), + left: hostElPos.left - targetElWidth, + }; + break; + case 'bottom': + targetElPos = { + top: shiftHeight[pos0](), + left: shiftWidth[pos1](), + }; + break; + default: + targetElPos = { + top: hostElPos.top - targetElHeight, + left: shiftWidth[pos1](), + }; + break; + } + + return targetElPos; + }, + }; + }, + ]); +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip.js b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip.js new file mode 100755 index 000000000000..086fa6a7d6df --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip.js @@ -0,0 +1,434 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* eslint-disable */ + +import angular from 'angular'; + +import { initBootstrapPosition } from './position'; + +export function initBootstrapTooltip() { + initBootstrapPosition(); + /** + * The following features are still outstanding: animation as a + * function, placement as a function, inside, support for more triggers than + * just mouse enter/leave, html tooltips, and selector delegation. + */ + angular + .module('ui.bootstrap.tooltip', ['ui.bootstrap.position']) + + /** + * The $tooltip service creates tooltip- and popover-like directives as well as + * houses global options for them. + */ + .provider('$tooltip', function() { + // The default options tooltip and popover. + const defaultOptions = { + placement: 'top', + animation: true, + popupDelay: 0, + }; + + // Default hide triggers for each show trigger + const triggerMap = { + mouseenter: 'mouseleave', + click: 'click', + focus: 'blur', + }; + + // The options specified to the provider globally. + const globalOptions = {}; + + /** + * `options({})` allows global configuration of all tooltips in the + * application. + * + * var app = angular.module( 'App', ['ui.bootstrap.tooltip'], function( $tooltipProvider ) { + * // place tooltips left instead of top by default + * $tooltipProvider.options( { placement: 'left' } ); + * }); + */ + this.options = function(value) { + angular.extend(globalOptions, value); + }; + + /** + * This allows you to extend the set of trigger mappings available. E.g.: + * + * $tooltipProvider.setTriggers( 'openTrigger': 'closeTrigger' ); + */ + this.setTriggers = function setTriggers(triggers) { + angular.extend(triggerMap, triggers); + }; + + /** + * This is a helper function for translating camel-case to snake-case. + */ + function snake_case(name) { + const regexp = /[A-Z]/g; + const separator = '-'; + return name.replace(regexp, function(letter, pos) { + return (pos ? separator : '') + letter.toLowerCase(); + }); + } + + /** + * Returns the actual instance of the $tooltip service. + * TODO support multiple triggers + */ + this.$get = [ + '$window', + '$compile', + '$timeout', + '$document', + '$position', + '$interpolate', + function($window, $compile, $timeout, $document, $position, $interpolate) { + return function $tooltip(type, prefix, defaultTriggerShow) { + const options = angular.extend({}, defaultOptions, globalOptions); + + /** + * Returns an object of show and hide triggers. + * + * If a trigger is supplied, + * it is used to show the tooltip; otherwise, it will use the `trigger` + * option passed to the `$tooltipProvider.options` method; else it will + * default to the trigger supplied to this directive factory. + * + * The hide trigger is based on the show trigger. If the `trigger` option + * was passed to the `$tooltipProvider.options` method, it will use the + * mapped trigger from `triggerMap` or the passed trigger if the map is + * undefined; otherwise, it uses the `triggerMap` value of the show + * trigger; else it will just use the show trigger. + */ + function getTriggers(trigger) { + const show = trigger || options.trigger || defaultTriggerShow; + const hide = triggerMap[show] || show; + return { + show: show, + hide: hide, + }; + } + + const directiveName = snake_case(type); + + const startSym = $interpolate.startSymbol(); + const endSym = $interpolate.endSymbol(); + const template = + '
' + + '
'; + + return { + restrict: 'EA', + compile: function(tElem, tAttrs) { + const tooltipLinker = $compile(template); + + return function link(scope, element, attrs) { + let tooltip; + let tooltipLinkedScope; + let transitionTimeout; + let popupTimeout; + let appendToBody = angular.isDefined(options.appendToBody) + ? options.appendToBody + : false; + let triggers = getTriggers(undefined); + const hasEnableExp = angular.isDefined(attrs[prefix + 'Enable']); + let ttScope = scope.$new(true); + + const positionTooltip = function() { + const ttPosition = $position.positionElements( + element, + tooltip, + ttScope.placement, + appendToBody + ); + ttPosition.top += 'px'; + ttPosition.left += 'px'; + + // Now set the calculated positioning. + tooltip.css(ttPosition); + }; + + // By default, the tooltip is not open. + // TODO add ability to start tooltip opened + ttScope.isOpen = false; + + function toggleTooltipBind() { + if (!ttScope.isOpen) { + showTooltipBind(); + } else { + hideTooltipBind(); + } + } + + // Show the tooltip with delay if specified, otherwise show it immediately + function showTooltipBind() { + if (hasEnableExp && !scope.$eval(attrs[prefix + 'Enable'])) { + return; + } + + prepareTooltip(); + + if (ttScope.popupDelay) { + // Do nothing if the tooltip was already scheduled to pop-up. + // This happens if show is triggered multiple times before any hide is triggered. + if (!popupTimeout) { + popupTimeout = $timeout(show, ttScope.popupDelay, false); + popupTimeout + .then(reposition => reposition()) + .catch(error => { + // if the timeout is canceled then the string `canceled` is thrown. To prevent + // this from triggering an 'unhandled promise rejection' in angular 1.5+ the + // $timeout service explicitly tells $q that the promise it generated is "handled" + // but that does not include down chain promises like the one created by calling + // `popupTimeout.then()`. Because of this we need to ignore the "canceled" string + // and only propagate real errors + if (error !== 'canceled') { + throw error; + } + }); + } + } else { + show()(); + } + } + + function hideTooltipBind() { + scope.$evalAsync(function() { + hide(); + }); + } + + // Show the tooltip popup element. + function show() { + popupTimeout = null; + + // If there is a pending remove transition, we must cancel it, lest the + // tooltip be mysteriously removed. + if (transitionTimeout) { + $timeout.cancel(transitionTimeout); + transitionTimeout = null; + } + + // Don't show empty tooltips. + if (!ttScope.content) { + return angular.noop; + } + + createTooltip(); + + // Set the initial positioning. + tooltip.css({ top: 0, left: 0, display: 'block' }); + ttScope.$digest(); + + positionTooltip(); + + // And show the tooltip. + ttScope.isOpen = true; + ttScope.$digest(); // digest required as $apply is not called + + // Return positioning function as promise callback for correct + // positioning after draw. + return positionTooltip; + } + + // Hide the tooltip popup element. + function hide() { + // First things first: we don't show it anymore. + ttScope.isOpen = false; + + //if tooltip is going to be shown after delay, we must cancel this + $timeout.cancel(popupTimeout); + popupTimeout = null; + + // And now we remove it from the DOM. However, if we have animation, we + // need to wait for it to expire beforehand. + // FIXME: this is a placeholder for a port of the transitions library. + if (ttScope.animation) { + if (!transitionTimeout) { + transitionTimeout = $timeout(removeTooltip, 500); + } + } else { + removeTooltip(); + } + } + + function createTooltip() { + // There can only be one tooltip element per directive shown at once. + if (tooltip) { + removeTooltip(); + } + tooltipLinkedScope = ttScope.$new(); + tooltip = tooltipLinker(tooltipLinkedScope, function(tooltip) { + if (appendToBody) { + $document.find('body').append(tooltip); + } else { + element.after(tooltip); + } + }); + } + + function removeTooltip() { + transitionTimeout = null; + if (tooltip) { + tooltip.remove(); + tooltip = null; + } + if (tooltipLinkedScope) { + tooltipLinkedScope.$destroy(); + tooltipLinkedScope = null; + } + } + + function prepareTooltip() { + prepPlacement(); + prepPopupDelay(); + } + + /** + * Observe the relevant attributes. + */ + attrs.$observe(type, function(val) { + ttScope.content = val; + + if (!val && ttScope.isOpen) { + hide(); + } + }); + + attrs.$observe(prefix + 'Title', function(val) { + ttScope.title = val; + }); + + function prepPlacement() { + const val = attrs[prefix + 'Placement']; + ttScope.placement = angular.isDefined(val) ? val : options.placement; + } + + function prepPopupDelay() { + const val = attrs[prefix + 'PopupDelay']; + const delay = parseInt(val, 10); + ttScope.popupDelay = !isNaN(delay) ? delay : options.popupDelay; + } + + const unregisterTriggers = function() { + element.unbind(triggers.show, showTooltipBind); + element.unbind(triggers.hide, hideTooltipBind); + }; + + function prepTriggers() { + const val = attrs[prefix + 'Trigger']; + unregisterTriggers(); + + triggers = getTriggers(val); + + if (triggers.show === triggers.hide) { + element.bind(triggers.show, toggleTooltipBind); + } else { + element.bind(triggers.show, showTooltipBind); + element.bind(triggers.hide, hideTooltipBind); + } + } + + prepTriggers(); + + const animation = scope.$eval(attrs[prefix + 'Animation']); + ttScope.animation = angular.isDefined(animation) + ? !!animation + : options.animation; + + const appendToBodyVal = scope.$eval(attrs[prefix + 'AppendToBody']); + appendToBody = angular.isDefined(appendToBodyVal) + ? appendToBodyVal + : appendToBody; + + // if a tooltip is attached to we need to remove it on + // location change as its parent scope will probably not be destroyed + // by the change. + if (appendToBody) { + scope.$on( + '$locationChangeSuccess', + function closeTooltipOnLocationChangeSuccess() { + if (ttScope.isOpen) { + hide(); + } + } + ); + } + + // Make sure tooltip is destroyed and removed. + scope.$on('$destroy', function onDestroyTooltip() { + $timeout.cancel(transitionTimeout); + $timeout.cancel(popupTimeout); + unregisterTriggers(); + removeTooltip(); + ttScope = null; + }); + }; + }, + }; + }; + }, + ]; + }) + + .directive('tooltip', [ + '$tooltip', + function($tooltip) { + return $tooltip('tooltip', 'tooltip', 'mouseenter'); + }, + ]) + + .directive('tooltipPopup', function() { + return { + restrict: 'EA', + replace: true, + scope: { content: '@', placement: '@', animation: '&', isOpen: '&' }, + templateUrl: 'template/tooltip/tooltip-popup.html', + }; + }) + + .directive('tooltipHtmlUnsafe', [ + '$tooltip', + function($tooltip) { + return $tooltip('tooltipHtmlUnsafe', 'tooltip', 'mouseenter'); + }, + ]) + + .directive('tooltipHtmlUnsafePopup', function() { + return { + restrict: 'EA', + replace: true, + scope: { content: '@', placement: '@', animation: '&', isOpen: '&' }, + templateUrl: 'template/tooltip/tooltip-html-unsafe-popup.html', + }; + }); +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip_html_unsafe_popup.html b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip_html_unsafe_popup.html new file mode 100644 index 000000000000..b48bf7049890 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip_html_unsafe_popup.html @@ -0,0 +1,4 @@ +
+
+
+
\ No newline at end of file diff --git a/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip_popup.html b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip_popup.html new file mode 100644 index 000000000000..eed4ca7d9301 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/angular_bootstrap/tooltip/tooltip_popup.html @@ -0,0 +1,4 @@ +
+
+
+
\ No newline at end of file diff --git a/src/plugins/opensearch_dashboards_legacy/public/index.ts b/src/plugins/opensearch_dashboards_legacy/public/index.ts index cdbdb7eee6a9..67cd6def9f0f 100644 --- a/src/plugins/opensearch_dashboards_legacy/public/index.ts +++ b/src/plugins/opensearch_dashboards_legacy/public/index.ts @@ -36,4 +36,8 @@ export const plugin = (initializerContext: PluginInitializerContext) => export * from './plugin'; +export { initAngularBootstrap } from './angular_bootstrap'; +export { PaginateDirectiveProvider, PaginateControlsDirectiveProvider } from './paginate/paginate'; +export * from './angular'; export * from './notify'; +export * from './utils'; diff --git a/src/plugins/opensearch_dashboards_legacy/public/notify/lib/add_fatal_error.ts b/src/plugins/opensearch_dashboards_legacy/public/notify/lib/add_fatal_error.ts index e04ce9928ed3..beb6f81e3ec7 100644 --- a/src/plugins/opensearch_dashboards_legacy/public/notify/lib/add_fatal_error.ts +++ b/src/plugins/opensearch_dashboards_legacy/public/notify/lib/add_fatal_error.ts @@ -29,15 +29,21 @@ */ import { FatalErrorsSetup } from '../../../../../core/public'; +import { + AngularHttpError, + formatAngularHttpError, + isAngularHttpError, +} from './format_angular_http_error'; -/** - * Adds an error to the list of fatal errors. - * @deprecated Use `core.fatalErrors.add` instead - */ export function addFatalError( fatalErrors: FatalErrorsSetup, - error: Error | string, + error: AngularHttpError | Error | string, location?: string ) { + // add support for angular http errors to newPlatformFatalErrors + if (isAngularHttpError(error)) { + error = formatAngularHttpError(error); + } + fatalErrors.add(error, location); } diff --git a/src/plugins/opensearch_dashboards_legacy/public/notify/lib/format_angular_http_error.ts b/src/plugins/opensearch_dashboards_legacy/public/notify/lib/format_angular_http_error.ts new file mode 100644 index 000000000000..68b3701814b1 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/notify/lib/format_angular_http_error.ts @@ -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 { i18n } from '@osd/i18n'; +import { IHttpResponse } from 'angular'; + +export type AngularHttpError = IHttpResponse<{ message: string }>; + +export function isAngularHttpError(error: any): error is AngularHttpError { + return ( + error && + typeof error.status === 'number' && + typeof error.statusText === 'string' && + error.data && + typeof error.data.message === 'string' + ); +} + +export function formatAngularHttpError(error: AngularHttpError) { + // is an Angular $http "error object" + if (error.status === -1) { + // status = -1 indicates that the request was failed to reach the server + return i18n.translate( + 'opensearch_dashboards_legacy.notify.fatalError.unavailableServerErrorMessage', + { + defaultMessage: + 'An HTTP request has failed to connect. ' + + 'Please check if the OpenSearch Dashboards server is running and that your browser has a working connection, ' + + 'or contact your system administrator.', + } + ); + } + + return i18n.translate('opensearch_dashboards_legacy.notify.fatalError.errorStatusMessage', { + defaultMessage: 'Error {errStatus} {errStatusText}: {errMessage}', + values: { + errStatus: error.status, + errStatusText: error.statusText, + errMessage: error.data.message, + }, + }); +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/notify/lib/index.ts b/src/plugins/opensearch_dashboards_legacy/public/notify/lib/index.ts index 28bdec0d1884..22a8631dfee3 100644 --- a/src/plugins/opensearch_dashboards_legacy/public/notify/lib/index.ts +++ b/src/plugins/opensearch_dashboards_legacy/public/notify/lib/index.ts @@ -31,4 +31,9 @@ export { formatOpenSearchMsg } from './format_opensearch_msg'; export { formatMsg } from './format_msg'; export { formatStack } from './format_stack'; +export { + isAngularHttpError, + formatAngularHttpError, + AngularHttpError, +} from './format_angular_http_error'; export { addFatalError } from './add_fatal_error'; diff --git a/src/plugins/opensearch_dashboards_legacy/public/paginate/_paginate.scss b/src/plugins/opensearch_dashboards_legacy/public/paginate/_paginate.scss new file mode 100644 index 000000000000..ec346aa843c1 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/paginate/_paginate.scss @@ -0,0 +1,58 @@ +/* stylelint-disable-next-line selector-type-no-unknown */ +paginate { + display: block; + + /* stylelint-disable-next-line selector-type-no-unknown */ + paginate-controls { + display: flex; + align-items: center; + padding: $euiSizeXS $euiSizeXS $euiSizeS; + text-align: center; + + .pagination-other-pages { + flex: 1 0 auto; + display: flex; + justify-content: center; + } + + .pagination-other-pages-list { + flex: 0 0 auto; + display: flex; + justify-content: center; + padding: 0; + margin: 0; + list-style: none; + + > li { + flex: 0 0 auto; + user-select: none; + + a { + text-decoration: none; + background-color: $euiColorLightestShade; + margin-left: $euiSizeXS / 2; + padding: $euiSizeS $euiSizeM; + } + + a:hover { + text-decoration: underline; + } + + &.active a { + text-decoration: none !important; + font-weight: $euiFontWeightBold; + color: $euiColorDarkShade; + cursor: default; + } + } + } + + .pagination-size { + flex: 0 0 auto; + + input[type="number"] { + width: 3em; + } + } + } +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/paginate/paginate.d.ts b/src/plugins/opensearch_dashboards_legacy/public/paginate/paginate.d.ts new file mode 100644 index 000000000000..60f462aa5ffe --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/paginate/paginate.d.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 function PaginateDirectiveProvider($parse: any, $compile: any): any; +export function PaginateControlsDirectiveProvider(): any; diff --git a/src/plugins/opensearch_dashboards_legacy/public/paginate/paginate.js b/src/plugins/opensearch_dashboards_legacy/public/paginate/paginate.js new file mode 100644 index 000000000000..1f78a7f715d9 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/paginate/paginate.js @@ -0,0 +1,242 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import _ from 'lodash'; +import { i18n } from '@osd/i18n'; +import './_paginate.scss'; +import paginateControlsTemplate from './paginate_controls.html'; + +export function PaginateDirectiveProvider($parse, $compile) { + return { + restrict: 'E', + scope: true, + link: { + pre: function ($scope, $el, attrs) { + if (_.isUndefined(attrs.bottomControls)) attrs.bottomControls = true; + if ($el.find('paginate-controls.paginate-bottom').length === 0 && attrs.bottomControls) { + $el.append($compile('')($scope)); + } + }, + post: function ($scope, $el, attrs) { + if (_.isUndefined(attrs.topControls)) attrs.topControls = false; + if ($el.find('paginate-controls.paginate-top').length === 0 && attrs.topControls) { + $el.prepend($compile('')($scope)); + } + + const paginate = $scope.paginate; + + // add some getters to the controller powered by attributes + paginate.getList = $parse(attrs.list); + paginate.perPageProp = attrs.perPageProp; + + if (attrs.perPage) { + paginate.perPage = attrs.perPage; + $scope.showSelector = false; + } else { + $scope.showSelector = true; + } + + paginate.otherWidthGetter = $parse(attrs.otherWidth); + + paginate.init(); + }, + }, + controllerAs: 'paginate', + controller: function ($scope, $document) { + const self = this; + const ALL = 0; + const allSizeTitle = i18n.translate( + 'opensearch_dashboards_legacy.paginate.size.allDropDownOptionLabel', + { + defaultMessage: 'All', + } + ); + + self.sizeOptions = [ + { title: '10', value: 10 }, + { title: '25', value: 25 }, + { title: '100', value: 100 }, + { title: allSizeTitle, value: ALL }, + ]; + + // setup the watchers, called in the post-link function + self.init = function () { + self.perPage = _.parseInt(self.perPage) || $scope[self.perPageProp]; + + $scope.$watchMulti(['paginate.perPage', self.perPageProp, self.otherWidthGetter], function ( + vals, + oldVals + ) { + const intChanges = vals[0] !== oldVals[0]; + + if (intChanges) { + if (!setPerPage(self.perPage)) { + // if we are not able to set the external value, + // render now, otherwise wait for the external value + // to trigger the watcher again + self.renderList(); + } + return; + } + + self.perPage = _.parseInt(self.perPage) || $scope[self.perPageProp]; + if (self.perPage == null) { + self.perPage = ALL; + return; + } + + self.renderList(); + }); + + $scope.$watch('page', self.changePage); + $scope.$watchCollection(self.getList, function (list) { + $scope.list = list; + self.renderList(); + }); + }; + + self.goToPage = function (number) { + if (number) { + if (number.hasOwnProperty('number')) number = number.number; + $scope.page = $scope.pages[number - 1] || $scope.pages[0]; + } + }; + + self.goToTop = function goToTop() { + $document.scrollTop(0); + }; + + self.renderList = function () { + $scope.pages = []; + if (!$scope.list) return; + + const perPage = _.parseInt(self.perPage); + const count = perPage ? Math.ceil($scope.list.length / perPage) : 1; + + _.times(count, function (i) { + let page; + + if (perPage) { + const start = perPage * i; + page = $scope.list.slice(start, start + perPage); + } else { + page = $scope.list.slice(0); + } + + page.number = i + 1; + page.i = i; + + page.count = count; + page.first = page.number === 1; + page.last = page.number === count; + page.firstItem = (page.number - 1) * perPage + 1; + page.lastItem = Math.min(page.number * perPage, $scope.list.length); + + page.prev = $scope.pages[i - 1]; + if (page.prev) page.prev.next = page; + + $scope.pages.push(page); + }); + + // set the new page, or restore the previous page number + if ($scope.page && $scope.page.i < $scope.pages.length) { + $scope.page = $scope.pages[$scope.page.i]; + } else { + $scope.page = $scope.pages[0]; + } + + if ($scope.page && $scope.onPageChanged) { + $scope.onPageChanged($scope.page); + } + }; + + self.changePage = function (page) { + if (!page) { + $scope.otherPages = null; + return; + } + + // setup the list of the other pages to link to + $scope.otherPages = []; + const width = +self.otherWidthGetter($scope) || 5; + let left = page.i - Math.round((width - 1) / 2); + let right = left + width - 1; + + // shift neg count from left to right + if (left < 0) { + right += 0 - left; + left = 0; + } + + // shift extra right nums to left + const lastI = page.count - 1; + if (right > lastI) { + right = lastI; + left = right - width + 1; + } + + for (let i = left; i <= right; i++) { + const other = $scope.pages[i]; + + if (!other) continue; + + $scope.otherPages.push(other); + if (other.last) $scope.otherPages.containsLast = true; + if (other.first) $scope.otherPages.containsFirst = true; + } + + if ($scope.onPageChanged) { + $scope.onPageChanged($scope.page); + } + }; + + function setPerPage(val) { + let $ppParent = $scope; + + while ($ppParent && !_.has($ppParent, self.perPageProp)) { + $ppParent = $ppParent.$parent; + } + + if ($ppParent) { + $ppParent[self.perPageProp] = val; + return true; + } + } + }, + }; +} + +export function PaginateControlsDirectiveProvider() { + // this directive is automatically added by paginate if not found within it's $el + return { + restrict: 'E', + template: paginateControlsTemplate, + }; +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/paginate/paginate_controls.html b/src/plugins/opensearch_dashboards_legacy/public/paginate/paginate_controls.html new file mode 100644 index 000000000000..3cabb2c61ede --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/paginate/paginate_controls.html @@ -0,0 +1,98 @@ + + +
+
    +
  • + +
  • +
  • + +
  • + +
  • + + ... +
  • + +
  • + +
  • + +
  • + ... + +
  • + +
  • + +
  • +
  • + +
  • +
+
+ +
+
+ + +
+
diff --git a/src/plugins/opensearch_dashboards_legacy/public/plugin.ts b/src/plugins/opensearch_dashboards_legacy/public/plugin.ts index 5f6a3e2a4961..473c10b47079 100644 --- a/src/plugins/opensearch_dashboards_legacy/public/plugin.ts +++ b/src/plugins/opensearch_dashboards_legacy/public/plugin.ts @@ -47,7 +47,7 @@ export class OpenSearchDashboardsLegacyPlugin { * Used to power dashboard mode. Should be removed when dashboard mode is removed eventually. * @deprecated */ - dashboardConfig: getDashboardConfig(!application.capabilities?.dashboard?.showWriteControls), + dashboardConfig: getDashboardConfig(!application.capabilities.dashboard.showWriteControls), /** * Loads the font-awesome icon font. Should be removed once the last consumer has migrated to EUI * @deprecated diff --git a/src/plugins/opensearch_dashboards_legacy/public/utils/index.ts b/src/plugins/opensearch_dashboards_legacy/public/utils/index.ts new file mode 100644 index 000000000000..6313548a1be1 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/utils/index.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. + */ + +export * from './system_api'; +// @ts-ignore +export { OsdAccessibleClickProvider } from './osd_accessible_click'; +// @ts-ignore +export { PrivateProvider, IPrivate } from './private'; +// @ts-ignore +export { registerListenEventListener } from './register_listen_event_listener'; diff --git a/src/plugins/opensearch_dashboards_legacy/public/utils/osd_accessible_click.js b/src/plugins/opensearch_dashboards_legacy/public/utils/osd_accessible_click.js new file mode 100644 index 000000000000..6c49ff8de4bb --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/utils/osd_accessible_click.js @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { accessibleClickKeys, keys } from '@elastic/eui'; + +export function OsdAccessibleClickProvider() { + return { + restrict: 'A', + controller: ($element) => { + $element.on('keydown', (e) => { + // Prevent a scroll from occurring if the user has hit space. + if (e.key === keys.SPACE) { + e.preventDefault(); + } + }); + }, + link: (scope, element, attrs) => { + // The whole point of this directive is to hack in functionality that native buttons provide + // by default. + const elementType = element.prop('tagName'); + + if (elementType === 'BUTTON') { + throw new Error(`osdAccessibleClick doesn't need to be used on a button.`); + } + + if (elementType === 'A' && attrs.href !== undefined) { + throw new Error( + `osdAccessibleClick doesn't need to be used on a link if it has a href attribute.` + ); + } + + // We're emulating a click action, so we should already have a regular click handler defined. + if (!attrs.ngClick) { + throw new Error('osdAccessibleClick requires ng-click to be defined on its element.'); + } + + // If the developer hasn't already specified attributes required for accessibility, add them. + if (attrs.tabindex === undefined) { + element.attr('tabindex', '0'); + } + + if (attrs.role === undefined) { + element.attr('role', 'button'); + } + + element.on('keyup', (e) => { + // Support keyboard accessibility by emulating mouse click on ENTER or SPACE keypress. + if (accessibleClickKeys[e.key]) { + // Delegate to the click handler on the element (assumed to be ng-click). + element.click(); + } + }); + }, + }; +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/utils/private.d.ts b/src/plugins/opensearch_dashboards_legacy/public/utils/private.d.ts new file mode 100644 index 000000000000..fe264fc193fc --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/utils/private.d.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 type IPrivate = (provider: (...injectable: any[]) => T) => T; diff --git a/src/plugins/opensearch_dashboards_legacy/public/utils/private.js b/src/plugins/opensearch_dashboards_legacy/public/utils/private.js new file mode 100644 index 000000000000..1a3a0a596559 --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/utils/private.js @@ -0,0 +1,214 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +/** + * # `Private()` + * Private module loader, used to merge angular and require js dependency styles + * by allowing a require.js module to export a single provider function that will + * create a value used within an angular application. This provider can declare + * angular dependencies by listing them as arguments, and can be require additional + * Private modules. + * + * ## Define a private module provider: + * ```js + * export default function PingProvider($http) { + * this.ping = function () { + * return $http.head('/health-check'); + * }; + * }; + * ``` + * + * ## Require a private module: + * ```js + * export default function ServerHealthProvider(Private, Promise) { + * let ping = Private(require('ui/ping')); + * return { + * check: Promise.method(function () { + * let attempts = 0; + * return (function attempt() { + * attempts += 1; + * return ping.ping() + * .catch(function (err) { + * if (attempts < 3) return attempt(); + * }) + * }()) + * .then(function () { + * return true; + * }) + * .catch(function () { + * return false; + * }); + * }) + * } + * }; + * ``` + * + * # `Private.stub(provider, newInstance)` + * `Private.stub()` replaces the instance of a module with another value. This is all we have needed until now. + * + * ```js + * beforeEach(inject(function ($injector, Private) { + * Private.stub( + * // since this module just exports a function, we need to change + * // what Private returns in order to modify it's behavior + * require('ui/agg_response/hierarchical/_build_split'), + * sinon.stub().returns(fakeSplit) + * ); + * })); + * ``` + * + * # `Private.swap(oldProvider, newProvider)` + * This new method does an 1-for-1 swap of module providers, unlike `stub()` which replaces a modules instance. + * Pass the module you want to swap out, and the one it should be replaced with, then profit. + * + * Note: even though this example shows `swap()` being called in a config + * function, it can be called from anywhere. It is particularly useful + * in this scenario though. + * + * ```js + * beforeEach(module('opensearchDashboards', function (PrivateProvider) { + * PrivateProvider.swap( + * function StubbedRedirectProvider($decorate) { + * // $decorate is a function that will instantiate the original module when called + * return sinon.spy($decorate()); + * } + * ); + * })); + * ``` + * + * @param {[type]} prov [description] + */ +import _ from 'lodash'; + +const nextId = _.partial(_.uniqueId, 'privateProvider#'); + +function name(fn) { + return fn.name || fn.toString().split('\n').shift(); +} + +export function PrivateProvider() { + const provider = this; + + // one cache/swaps per Provider + const cache = {}; + const swaps = {}; + + // return the uniq id for this function + function identify(fn) { + if (typeof fn !== 'function') { + throw new TypeError('Expected private module "' + fn + '" to be a function'); + } + + if (fn.$$id) return fn.$$id; + else return (fn.$$id = nextId()); + } + + provider.stub = function (fn, instance) { + cache[identify(fn)] = instance; + return instance; + }; + + provider.swap = function (fn, prov) { + const id = identify(fn); + swaps[id] = prov; + }; + + provider.$get = [ + '$injector', + function PrivateFactory($injector) { + // prevent circular deps by tracking where we came from + const privPath = []; + const pathToString = function () { + return privPath.map(name).join(' -> '); + }; + + // call a private provider and return the instance it creates + function instantiate(prov, locals) { + if (~privPath.indexOf(prov)) { + throw new Error( + 'Circular reference to "' + + name(prov) + + '"' + + ' found while resolving private deps: ' + + pathToString() + ); + } + + privPath.push(prov); + + const context = {}; + let instance = $injector.invoke(prov, context, locals); + if (!_.isObject(instance)) instance = context; + + privPath.pop(); + return instance; + } + + // retrieve an instance from cache or create and store on + function get(id, prov, $delegateId, $delegateProv) { + if (cache[id]) return cache[id]; + + let instance; + + if ($delegateId != null && $delegateProv != null) { + instance = instantiate(prov, { + $decorate: _.partial(get, $delegateId, $delegateProv), + }); + } else { + instance = instantiate(prov); + } + + return (cache[id] = instance); + } + + // main api, get the appropriate instance for a provider + function Private(prov) { + let id = identify(prov); + let $delegateId; + let $delegateProv; + + if (swaps[id]) { + $delegateId = id; + $delegateProv = prov; + + prov = swaps[$delegateId]; + id = identify(prov); + } + + return get(id, prov, $delegateId, $delegateProv); + } + + Private.stub = provider.stub; + Private.swap = provider.swap; + + return Private; + }, + ]; +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/utils/register_listen_event_listener.js b/src/plugins/opensearch_dashboards_legacy/public/utils/register_listen_event_listener.js new file mode 100644 index 000000000000..19652d94cf4f --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/utils/register_listen_event_listener.js @@ -0,0 +1,47 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +export function registerListenEventListener($rootScope) { + /** + * Helper that registers an event listener, and removes that listener when + * the $scope is destroyed. + * + * @param {EventEmitter} emitter - the event emitter to listen to + * @param {string} eventName - the event name + * @param {Function} handler - the event handler + * @return {undefined} + */ + $rootScope.constructor.prototype.$listen = function (emitter, eventName, handler) { + emitter.on(eventName, handler); + this.$on('$destroy', function () { + emitter.off(eventName, handler); + }); + }; +} diff --git a/src/plugins/opensearch_dashboards_legacy/public/utils/system_api.ts b/src/plugins/opensearch_dashboards_legacy/public/utils/system_api.ts new file mode 100644 index 000000000000..2675bbc084fb --- /dev/null +++ b/src/plugins/opensearch_dashboards_legacy/public/utils/system_api.ts @@ -0,0 +1,62 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 { IRequestConfig } from 'angular'; + +const SYSTEM_REQUEST_HEADER_NAME = 'osd-system-request'; +const LEGACY_SYSTEM_API_HEADER_NAME = 'osd-system-api'; + +/** + * Adds a custom header designating request as system API + * @param originalHeaders Object representing set of headers + * @return Object representing set of headers, with system API header added in + */ +export function addSystemApiHeader(originalHeaders: Record) { + const systemApiHeaders = { + [SYSTEM_REQUEST_HEADER_NAME]: true, + }; + return { + ...originalHeaders, + ...systemApiHeaders, + }; +} + +/** + * Returns true if request is a system API request; false otherwise + * + * @param request Object Request object created by $http service + * @return true if request is a system API request; false otherwise + */ +export function isSystemApiRequest(request: IRequestConfig) { + const { headers } = request; + return ( + headers && (!!headers[SYSTEM_REQUEST_HEADER_NAME] || !!headers[LEGACY_SYSTEM_API_HEADER_NAME]) + ); +} diff --git a/src/plugins/tile_map/public/plugin.ts b/src/plugins/tile_map/public/plugin.ts index 220c6870ef3e..4b6b76ae193d 100644 --- a/src/plugins/tile_map/public/plugin.ts +++ b/src/plugins/tile_map/public/plugin.ts @@ -37,6 +37,8 @@ import { } from 'opensearch-dashboards/public'; import { Plugin as ExpressionsPublicPlugin } from '../../expressions/public'; import { VisualizationsSetup } from '../../visualizations/public'; +// TODO: Determine why visualizations don't populate without this +import 'angular-sanitize'; // @ts-ignore import { createTileMapFn } from './tile_map_fn'; diff --git a/test/functional/apps/context/_context_navigation.js b/test/functional/apps/context/_context_navigation.js index 5e722a85d099..70627ab11ff5 100644 --- a/test/functional/apps/context/_context_navigation.js +++ b/test/functional/apps/context/_context_navigation.js @@ -39,7 +39,7 @@ const TEST_FILTER_COLUMN_NAMES = [ export default function ({ getService, getPageObjects }) { const retry = getService('retry'); const browser = getService('browser'); - const testSubjects = getService('testSubjects'); + const docTable = getService('docTable'); const PageObjects = getPageObjects(['common', 'context', 'discover', 'timePicker']); describe('discover - context - back navigation', function contextSize() { @@ -47,29 +47,25 @@ export default function ({ getService, getPageObjects }) { await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); await PageObjects.common.navigateToApp('discover'); for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) { - await PageObjects.discover.clickFieldListItemDetails(columnName); + await PageObjects.discover.clickFieldListItem(columnName); await PageObjects.discover.clickFieldListPlusFilter(columnName, value); } }); - it('should open a new tab after loading surrounding documents', async function () { - await retry.waitFor('user navigating to context', async () => { + it('should go back after loading', async function () { + await retry.waitFor('user navigating to context and returning to discover', async () => { + // navigate to the context view const initialHitCount = await PageObjects.discover.getHitCount(); - - // click inspect row - await testSubjects.click('docTableExpandToggleColumn-0'); - // click view surrounding documents - await testSubjects.click('docTableRowAction-0'); - - //navigate to the new window - await testSubjects.exists('docTable'); - await browser.switchTab(1); - - //close the new tab and get back to the old tab - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await testSubjects.click('euiFlyoutCloseButton'); + await docTable.clickRowToggle({ rowIndex: 0 }); + const rowActions = await docTable.getRowActions({ rowIndex: 0 }); + await rowActions[0].click(); + await PageObjects.context.waitUntilContextLoadingHasFinished(); + await PageObjects.context.clickSuccessorLoadMoreButton(); + await PageObjects.context.clickSuccessorLoadMoreButton(); + await PageObjects.context.clickSuccessorLoadMoreButton(); + await PageObjects.context.waitUntilContextLoadingHasFinished(); + await browser.goBack(); + await PageObjects.discover.waitForDocTableLoadingComplete(); const hitCount = await PageObjects.discover.getHitCount(); return initialHitCount === hitCount; }); diff --git a/test/functional/apps/context/_date_nanos.js b/test/functional/apps/context/_date_nanos.js index ac45d86555d7..23350c81b18f 100644 --- a/test/functional/apps/context/_date_nanos.js +++ b/test/functional/apps/context/_date_nanos.js @@ -36,7 +36,7 @@ const TEST_STEP_SIZE = 3; export default function ({ getService, getPageObjects }) { const opensearchDashboardsServer = getService('opensearchDashboardsServer'); - const dataGrid = getService('dataGrid'); + const docTable = getService('docTable'); const security = getService('security'); const PageObjects = getPageObjects(['common', 'context', 'timePicker', 'discover']); const opensearchArchiver = getService('opensearchArchiver'); @@ -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, }); }); @@ -62,11 +63,11 @@ export default function ({ getService, getPageObjects }) { it('displays predessors - anchor - successors in right order ', async function () { await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, 'AU_x3-TaGFA8no6Qj999Z'); - const actualRowsText = await dataGrid.getDataGridTableColumn('date'); + const actualRowsText = await docTable.getRowsText(); const expectedRowsText = [ - 'Sep 18, 2019 @ 06:50:13.000000000', - 'Sep 18, 2019 @ 06:50:12.999999999', - 'Sep 19, 2015 @ 06:50:13.000100001', + 'Sep 18, 2019 @ 06:50:13.000000000-2', + 'Sep 18, 2019 @ 06:50:12.999999999-3', + 'Sep 19, 2015 @ 06:50:13.0001000011', ]; expect(actualRowsText).to.eql(expectedRowsText); }); @@ -75,17 +76,17 @@ export default function ({ getService, getPageObjects }) { await PageObjects.context.navigateTo(TEST_INDEX_PATTERN, 'AU_x3-TaGFA8no6Qjisd'); await PageObjects.context.clickPredecessorLoadMoreButton(); await PageObjects.context.clickSuccessorLoadMoreButton(); - const actualRowsText = await dataGrid.getDataGridTableColumn('date'); + const actualRowsText = await docTable.getRowsText(); const expectedRowsText = [ - 'Sep 22, 2019 @ 23:50:13.253123345', - 'Sep 18, 2019 @ 06:50:13.000000104', - 'Sep 18, 2019 @ 06:50:13.000000103', - 'Sep 18, 2019 @ 06:50:13.000000102', - 'Sep 18, 2019 @ 06:50:13.000000101', - 'Sep 18, 2019 @ 06:50:13.000000001', - 'Sep 18, 2019 @ 06:50:13.000000000', - 'Sep 18, 2019 @ 06:50:12.999999999', - 'Sep 19, 2015 @ 06:50:13.000100001', + 'Sep 22, 2019 @ 23:50:13.2531233455', + 'Sep 18, 2019 @ 06:50:13.0000001044', + 'Sep 18, 2019 @ 06:50:13.0000001032', + 'Sep 18, 2019 @ 06:50:13.0000001021', + 'Sep 18, 2019 @ 06:50:13.0000001010', + 'Sep 18, 2019 @ 06:50:13.000000001-1', + 'Sep 18, 2019 @ 06:50:13.000000000-2', + 'Sep 18, 2019 @ 06:50:12.999999999-3', + 'Sep 19, 2015 @ 06:50:13.0001000011', ]; expect(actualRowsText).to.eql(expectedRowsText); }); diff --git a/test/functional/apps/context/_date_nanos_custom_timestamp.js b/test/functional/apps/context/_date_nanos_custom_timestamp.js index 2c6bef3a366b..52864a0d7ea3 100644 --- a/test/functional/apps/context/_date_nanos_custom_timestamp.js +++ b/test/functional/apps/context/_date_nanos_custom_timestamp.js @@ -52,6 +52,7 @@ export default function ({ getService, getPageObjects }) { await opensearchDashboardsServer.uiSettings.update({ 'context:defaultSize': `${TEST_DEFAULT_CONTEXT_SIZE}`, 'context:step': `${TEST_STEP_SIZE}`, + 'discover:v2': false, }); }); diff --git a/test/functional/apps/context/_discover_navigation.js b/test/functional/apps/context/_discover_navigation.js index 5680b28921a7..87bbaccd0dd8 100644 --- a/test/functional/apps/context/_discover_navigation.js +++ b/test/functional/apps/context/_discover_navigation.js @@ -37,11 +37,10 @@ const TEST_FILTER_COLUMN_NAMES = [ ]; export default function ({ getService, getPageObjects }) { - const browser = getService('browser'); - const dataGrid = getService('dataGrid'); + const retry = getService('retry'); + const docTable = getService('docTable'); const filterBar = getService('filterBar'); const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); - const testSubjects = getService('testSubjects'); describe('context link in discover', () => { before(async () => { @@ -53,7 +52,7 @@ export default function ({ getService, getPageObjects }) { } for (const [columnName, value] of TEST_FILTER_COLUMN_NAMES) { - await PageObjects.discover.clickFieldListItemDetails(columnName); + await PageObjects.discover.clickFieldListItem(columnName); await PageObjects.discover.clickFieldListPlusFilter(columnName, value); } }); @@ -62,27 +61,25 @@ export default function ({ getService, getPageObjects }) { }); it('should open the context view with the selected document as anchor', async () => { - // get the timestamps - const dataGridTableTimeStamps = await dataGrid.getDataGridTableColumn('date'); - - // click inspect row - await testSubjects.click('docTableExpandToggleColumn-10'); - - // click view surrounding documents - await testSubjects.click('docTableRowAction-0'); - - //navigate to the new window and get the new timestamp - await testSubjects.exists('docTable'); - await browser.switchTab(1); - const surroundingTableTimeStamps = await dataGrid.getDataGridTableColumn('date'); - - return dataGridTableTimeStamps[10] === surroundingTableTimeStamps[5]; + // check the anchor timestamp in the context view + await retry.waitFor('selected document timestamp matches anchor timestamp ', async () => { + // get the timestamp of the first row + const discoverFields = await docTable.getFields(); + const firstTimestamp = discoverFields[0][0]; + + // navigate to the context view + await docTable.clickRowToggle({ rowIndex: 0 }); + const rowActions = await docTable.getRowActions({ rowIndex: 0 }); + await rowActions[0].click(); + const contextFields = await docTable.getFields({ isAnchorRow: true }); + const anchorTimestamp = contextFields[0][0]; + return anchorTimestamp === firstTimestamp; + }); }); it('should open the context view with the same columns', async () => { - const data = await dataGrid.getDataGridTableData(); - - expect(data.columns).to.eql(['', 'Time (@timestamp)', ...TEST_COLUMN_NAMES]); + const columnNames = await docTable.getHeaderFields(); + expect(columnNames).to.eql(['Time', ...TEST_COLUMN_NAMES]); }); it('should open the context view with the filters disabled', async () => { @@ -93,11 +90,6 @@ export default function ({ getService, getPageObjects }) { } } expect(disabledFilterCounter).to.be(TEST_FILTER_COLUMN_NAMES.length); - //close the new tab and get back to the old tab - await browser.closeCurrentWindow(); - await browser.switchTab(0); - - await testSubjects.click('euiFlyoutCloseButton'); }); }); } diff --git a/test/functional/apps/context/_filters.js b/test/functional/apps/context/_filters.js index 077a8376aa7b..17219a836230 100644 --- a/test/functional/apps/context/_filters.js +++ b/test/functional/apps/context/_filters.js @@ -35,16 +35,19 @@ const TEST_ANCHOR_FILTER_VALUE = 'IN'; const TEST_COLUMN_NAMES = ['extension', 'geo.src']; export default function ({ getService, getPageObjects }) { - const dataGrid = getService('dataGrid'); + const docTable = getService('docTable'); const filterBar = getService('filterBar'); const retry = getService('retry'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const browser = getService('browser'); - const testSubjects = getService('testSubjects'); 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, @@ -53,19 +56,18 @@ export default function ({ getService, getPageObjects }) { it('inclusive filter should be addable via expanded doc table rows', async function () { await retry.waitFor(`filter ${TEST_ANCHOR_FILTER_FIELD} in filterbar`, async () => { - // expand anchor row - await testSubjects.click('docTableExpandToggleColumn-5'); - - // add inclusive filter - await testSubjects.click( - `tableDocViewRow-${TEST_ANCHOR_FILTER_FIELD} > addInclusiveFilterButton` - ); + await docTable.toggleRowExpanded({ isAnchorRow: true }); + const anchorDetailsRow = await docTable.getAnchorDetailsRow(); + await docTable.addInclusiveFilter(anchorDetailsRow, TEST_ANCHOR_FILTER_FIELD); + await PageObjects.context.waitUntilContextLoadingHasFinished(); return await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, TEST_ANCHOR_FILTER_VALUE, true); }); await retry.waitFor(`filter matching docs in docTable`, async () => { - const fields = await dataGrid.getDataGridTableColumn('lastColumn'); - return fields.every((fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE); + const fields = await docTable.getFields(); + return fields + .map((row) => row[2]) + .every((fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE); }); }); @@ -81,23 +83,21 @@ export default function ({ getService, getPageObjects }) { }); await retry.waitFor('filters are disabled', async () => { - const fields = await dataGrid.getDataGridTableColumn('lastColumn'); - const hasOnlyFilteredRows = fields.every( - (fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE - ); + const fields = await docTable.getFields(); + const hasOnlyFilteredRows = fields + .map((row) => row[2]) + .every((fieldContent) => fieldContent === TEST_ANCHOR_FILTER_VALUE); return hasOnlyFilteredRows === false; }); }); it('filter for presence should be addable via expanded doc table rows', async function () { - // expand anchor row - await testSubjects.click('docTableExpandToggleColumn-5'); + await docTable.toggleRowExpanded({ isAnchorRow: true }); await retry.waitFor('an exists filter in the filterbar', async () => { - // add inclusive filter - await testSubjects.click( - `tableDocViewRow-${TEST_ANCHOR_FILTER_FIELD} > addExistsFilterButton` - ); + const anchorDetailsRow = await docTable.getAnchorDetailsRow(); + await docTable.addExistsFilter(anchorDetailsRow, TEST_ANCHOR_FILTER_FIELD); + await PageObjects.context.waitUntilContextLoadingHasFinished(); return await filterBar.hasFilter(TEST_ANCHOR_FILTER_FIELD, 'exists', true); }); }); diff --git a/test/functional/apps/context/_size.js b/test/functional/apps/context/_size.js index bd44b159bca4..1727aa40136a 100644 --- a/test/functional/apps/context/_size.js +++ b/test/functional/apps/context/_size.js @@ -36,7 +36,7 @@ const TEST_STEP_SIZE = 2; export default function ({ getService, getPageObjects }) { const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const retry = getService('retry'); - const dataGrid = getService('dataGrid'); + const docTable = getService('docTable'); const PageObjects = getPageObjects(['context']); let expectedRowLength = 2 * TEST_DEFAULT_CONTEXT_SIZE + 1; @@ -53,7 +53,7 @@ export default function ({ getService, getPageObjects }) { await retry.waitFor( `number of rows displayed initially is ${expectedRowLength}`, async function () { - const rows = await dataGrid.getDataGridTableColumn('date'); + const rows = await docTable.getRowsText(); return rows.length === expectedRowLength; } ); @@ -74,8 +74,8 @@ export default function ({ getService, getPageObjects }) { await retry.waitFor( `number of rows displayed after clicking load more predecessors is ${expectedRowLength}`, async function () { - const data = await dataGrid.getDataGridTableColumn('date'); - return data.length === expectedRowLength; + const rows = await docTable.getRowsText(); + return rows.length === expectedRowLength; } ); }); @@ -87,8 +87,8 @@ export default function ({ getService, getPageObjects }) { await retry.waitFor( `number of rows displayed after clicking load more successors is ${expectedRowLength}`, async function () { - const data = await dataGrid.getDataGridTableColumn('date'); - return data.length === expectedRowLength; + const rows = await docTable.getRowsText(); + return rows.length === expectedRowLength; } ); }); diff --git a/test/functional/apps/context/index.js b/test/functional/apps/context/index.js index 7c59b18668b8..07fbfe00ac2b 100644 --- a/test/functional/apps/context/index.js +++ b/test/functional/apps/context/index.js @@ -43,6 +43,7 @@ export default function ({ getService, getPageObjects, loadTestFile }) { await opensearchArchiver.load('visualize'); await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'logstash-*', + 'discover:v2': false, }); await PageObjects.common.navigateToApp('discover'); }); diff --git a/test/functional/apps/dashboard/dashboard_filter_bar.js b/test/functional/apps/dashboard/dashboard_filter_bar.js index 9a0ce6a9042a..0d19c84a5089 100644 --- a/test/functional/apps/dashboard/dashboard_filter_bar.js +++ b/test/functional/apps/dashboard/dashboard_filter_bar.js @@ -193,12 +193,7 @@ export default function ({ getService, getPageObjects }) { it('are added when a cell magnifying glass is clicked', async function () { await dashboardAddPanel.addSavedSearch('Rendering-Test:-saved-search'); await PageObjects.dashboard.waitForRenderComplete(); - - // Expand a doc row - await testSubjects.click('docTableExpandToggleColumn-0'); - - // Add a field filter - await testSubjects.click('tableDocViewRow-@message > addInclusiveFilterButton'); + await testSubjects.click('docTableCellFilter'); const filterCount = await filterBar.getFilterCount(); expect(filterCount).to.equal(1); diff --git a/test/functional/apps/dashboard/dashboard_filtering.js b/test/functional/apps/dashboard/dashboard_filtering.js index e934169513f6..b3ff62c8b9da 100644 --- a/test/functional/apps/dashboard/dashboard_filtering.js +++ b/test/functional/apps/dashboard/dashboard_filtering.js @@ -123,7 +123,7 @@ export default function ({ getService, getPageObjects }) { }); it('saved search is filtered', async () => { - await testSubjects.missingOrFail('euiDataGrid'); + await dashboardExpect.savedSearchRowCount(0); }); it('vega is filtered', async () => { @@ -171,7 +171,7 @@ export default function ({ getService, getPageObjects }) { }); it('saved search is filtered', async () => { - await testSubjects.missingOrFail('euiDataGrid'); + await dashboardExpect.savedSearchRowCount(0); }); it('vega is filtered', async () => { diff --git a/test/functional/apps/dashboard/dashboard_query_bar.js b/test/functional/apps/dashboard/dashboard_query_bar.js index 91885a282b15..f1c2893268a9 100644 --- a/test/functional/apps/dashboard/dashboard_query_bar.js +++ b/test/functional/apps/dashboard/dashboard_query_bar.js @@ -49,8 +49,7 @@ export default function ({ getService, getPageObjects }) { await PageObjects.dashboard.loadSavedDashboard('dashboard with filter'); }); - // https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5116 - it.skip('causes panels to reload when refresh is clicked', async () => { + it('causes panels to reload when refresh is clicked', async () => { await opensearchArchiver.unload('dashboard/current/data'); await queryBar.clickQuerySubmitButton(); diff --git a/test/functional/apps/dashboard/dashboard_state.js b/test/functional/apps/dashboard/dashboard_state.js index edb2002624f5..e23a2caba0c7 100644 --- a/test/functional/apps/dashboard/dashboard_state.js +++ b/test/functional/apps/dashboard/dashboard_state.js @@ -53,11 +53,16 @@ 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(); }); @@ -90,11 +95,8 @@ export default function ({ getService, getPageObjects }) { expect(colorChoiceRetained).to.be(true); }); - // TODO: Revert the following changes on the following 3 saved search tests - // once issue https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5071 is resolved. - // The issue causes the previously saved object to not load automatically when navigating back to discover from the dashboard. - // Currently, we need to re-open the saved search in discover. - // The expected behavior is for the saved object to persist and load as it did in previous versions of discover. + // 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(); @@ -114,12 +116,11 @@ 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 + // 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.timePicker.setHistoricalDataRange(); - await PageObjects.discover.clickFieldListItemAdd('agent'); - await PageObjects.discover.saveSearch('my search', false); + await PageObjects.discover.saveSearch('my search'); await PageObjects.header.waitUntilLoadingHasFinished(); await PageObjects.header.clickDashboard(); @@ -137,9 +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 + // 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.timePicker.setHistoricalDataRange(); await PageObjects.discover.clickFieldListItemAdd('clientip'); await PageObjects.discover.saveSearch('my search'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -156,10 +157,9 @@ export default function ({ getService, getPageObjects }) { const currentQuery = await queryBar.getQueryString(); expect(currentQuery).to.equal(''); const currentUrl = await browser.getCurrentUrl(); - - // due to previous re-open saved search, history is changed. - // query is in both _g and _a. We need to change query in _a. - const newUrl = currentUrl.replace(/query:%27%27/g, 'query:%27abc12345678910%27'); + const newUrl = currentUrl.replace('query:%27%27', 'query:%27abc12345678910%27'); + // Don't add the timestamp to the url or it will cause a hard refresh and we want to test a + // soft refresh. await browser.get(newUrl.toString(), false); 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 b1e57fbe8e5e..5c97872cdb7e 100644 --- a/test/functional/apps/dashboard/dashboard_time_picker.js +++ b/test/functional/apps/dashboard/dashboard_time_picker.js @@ -67,15 +67,14 @@ export default function ({ getService, getPageObjects }) { name: 'saved search', fields: ['bytes', 'agent'], }); - // Current data grid loads 100 rows per page by default with inspect button and time range - await dashboardExpect.dataGridTableCellCount(400); + await dashboardExpect.docTableFieldCount(150); // Set to time range with no data await PageObjects.timePicker.setAbsoluteRange( 'Jan 1, 2000 @ 00:00:00.000', 'Jan 1, 2000 @ 01:00:00.000' ); - await dashboardExpect.dataGridTableCellCount(0); + await dashboardExpect.docTableFieldCount(0); }); it('Timepicker start, end, interval values are set by url', async () => { diff --git a/test/functional/apps/dashboard/panel_context_menu.ts b/test/functional/apps/dashboard/panel_context_menu.ts index ddef33b265bb..07af447d4e15 100644 --- a/test/functional/apps/dashboard/panel_context_menu.ts +++ b/test/functional/apps/dashboard/panel_context_menu.ts @@ -38,6 +38,7 @@ 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', @@ -125,6 +126,9 @@ 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(); diff --git a/test/functional/apps/discover/_date_nanos.js b/test/functional/apps/discover/_date_nanos.js new file mode 100644 index 000000000000..e96d507087fe --- /dev/null +++ b/test/functional/apps/discover/_date_nanos.js @@ -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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const opensearchArchiver = getService('opensearchArchiver'); + const PageObjects = getPageObjects(['common', 'timePicker', 'discover']); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const security = getService('security'); + const fromTime = 'Sep 22, 2019 @ 20:31:44.000'; + const toTime = 'Sep 23, 2019 @ 03:31:44.000'; + + describe('date_nanos', function () { + before(async function () { + await opensearchArchiver.loadIfNeeded('date_nanos'); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'date-nanos', + 'discover:v2': false, + }); + await security.testUser.setRoles([ + 'opensearch_dashboards_admin', + 'opensearch_dashboards_date_nanos', + ]); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + }); + + after(async function unloadMakelogs() { + await security.testUser.restoreDefaults(); + await opensearchArchiver.unload('date_nanos'); + }); + + it('should show a timestamp with nanoseconds in the first result row', async function () { + const time = await PageObjects.timePicker.getTimeConfig(); + expect(time.start).to.be(fromTime); + expect(time.end).to.be(toTime); + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData.startsWith('Sep 22, 2019 @ 23:50:13.253123345')).to.be.ok(); + }); + }); +} diff --git a/test/functional/apps/discover/_date_nanos_mixed.js b/test/functional/apps/discover/_date_nanos_mixed.js new file mode 100644 index 000000000000..05b94d3d1d67 --- /dev/null +++ b/test/functional/apps/discover/_date_nanos_mixed.js @@ -0,0 +1,72 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const opensearchArchiver = getService('opensearchArchiver'); + const PageObjects = getPageObjects(['common', 'timePicker', 'discover']); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const security = getService('security'); + const fromTime = 'Jan 1, 2019 @ 00:00:00.000'; + const toTime = 'Jan 1, 2019 @ 23:59:59.999'; + + describe('date_nanos_mixed', function () { + before(async function () { + await opensearchArchiver.loadIfNeeded('date_nanos_mixed'); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'timestamp-*', + 'discover:v2': false, + }); + await security.testUser.setRoles([ + 'opensearch_dashboards_admin', + 'opensearch_dashboards_date_nanos_mixed', + ]); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + opensearchArchiver.unload('date_nanos_mixed'); + }); + + it('shows a list of records of indices with date & date_nanos fields in the right order', async function () { + const rowData1 = await PageObjects.discover.getDocTableIndex(1); + expect(rowData1.startsWith('Jan 1, 2019 @ 12:10:30.124000000')).to.be.ok(); + const rowData2 = await PageObjects.discover.getDocTableIndex(3); + expect(rowData2.startsWith('Jan 1, 2019 @ 12:10:30.123498765')).to.be.ok(); + const rowData3 = await PageObjects.discover.getDocTableIndex(5); + expect(rowData3.startsWith('Jan 1, 2019 @ 12:10:30.123456789')).to.be.ok(); + const rowData4 = await PageObjects.discover.getDocTableIndex(7); + expect(rowData4.startsWith('Jan 1, 2019 @ 12:10:30.123000000')).to.be.ok(); + }); + }); +} diff --git a/test/functional/apps/discover/_discover.js b/test/functional/apps/discover/_discover.js new file mode 100644 index 000000000000..d132454a090e --- /dev/null +++ b/test/functional/apps/discover/_discover.js @@ -0,0 +1,376 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const browser = getService('browser'); + const log = getService('log'); + const retry = getService('retry'); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const queryBar = getService('queryBar'); + const inspector = getService('inspector'); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); + const defaultSettings = { + defaultIndex: 'logstash-*', + 'discover:v2': false, + }; + + describe('discover app', function describeIndexTests() { + before(async function () { + 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'); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + describe('query', function () { + const queryName1 = 'Query # 1'; + + it('should show correct time range string by timepicker', async function () { + const time = await PageObjects.timePicker.getTimeConfig(); + expect(time.start).to.be(PageObjects.timePicker.defaultStartTime); + expect(time.end).to.be(PageObjects.timePicker.defaultEndTime); + }); + + it('save query should show toast message and display query name', async function () { + await PageObjects.discover.saveSearch(queryName1); + const actualQueryNameString = await PageObjects.discover.getCurrentQueryName(); + expect(actualQueryNameString).to.be(queryName1); + }); + + it('load query should show query name', async function () { + await PageObjects.discover.loadSavedSearch(queryName1); + + await retry.try(async function () { + expect(await PageObjects.discover.getCurrentQueryName()).to.be(queryName1); + }); + }); + + it('renaming a saved query should modify name in breadcrumb', async function () { + const queryName2 = 'Modified Query # 1'; + await PageObjects.discover.loadSavedSearch(queryName1); + await PageObjects.discover.saveSearch(queryName2); + + await retry.try(async function () { + expect(await PageObjects.discover.getCurrentQueryName()).to.be(queryName2); + }); + }); + + it('should show the correct hit count', async function () { + const expectedHitCount = '14,004'; + await retry.try(async function () { + expect(await PageObjects.discover.getHitCount()).to.be(expectedHitCount); + }); + }); + + it('should show correct time range string in chart', async function () { + const actualTimeString = await PageObjects.discover.getChartTimespan(); + const expectedTimeString = `${PageObjects.timePicker.defaultStartTime} - ${PageObjects.timePicker.defaultEndTime}`; + expect(actualTimeString).to.be(expectedTimeString); + }); + + it('should modify the time range when a bar is clicked', async function () { + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.discover.clickHistogramBar(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + const time = await PageObjects.timePicker.getTimeConfig(); + expect(time.start).to.be('Sep 21, 2015 @ 09:00:00.000'); + expect(time.end).to.be('Sep 21, 2015 @ 12:00:00.000'); + await retry.waitFor('doc table to contain the right search result', async () => { + const rowData = await PageObjects.discover.getDocTableField(1); + log.debug(`The first timestamp value in doc table: ${rowData}`); + return rowData.includes('Sep 21, 2015 @ 11:59:22.316'); + }); + }); + + it('should modify the time range when the histogram is brushed', async function () { + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.discover.brushHistogram(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + const newDurationHours = await PageObjects.timePicker.getTimeDurationInHours(); + expect(Math.round(newDurationHours)).to.be(24); + + await retry.waitFor('doc table to contain the right search result', async () => { + const rowData = await PageObjects.discover.getDocTableField(1); + log.debug(`The first timestamp value in doc table: ${rowData}`); + const dateParsed = Date.parse(rowData); + //compare against the parsed date of Sep 20, 2015 @ 17:30:00.000 and Sep 20, 2015 @ 23:30:00.000 + return dateParsed >= 1442770200000 && dateParsed <= 1442791800000; + }); + }); + + it('should show correct initial chart interval of Auto', async function () { + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + const actualInterval = await PageObjects.discover.getChartInterval(); + + const expectedInterval = 'Auto'; + expect(actualInterval).to.be(expectedInterval); + }); + + it('should show Auto chart interval', async function () { + const expectedChartInterval = 'Auto'; + + const actualInterval = await PageObjects.discover.getChartInterval(); + expect(actualInterval).to.be(expectedChartInterval); + }); + + it('should not show "no results"', async () => { + const isVisible = await PageObjects.discover.hasNoResults(); + expect(isVisible).to.be(false); + }); + + it('should reload the saved search with persisted query to show the initial hit count', async function () { + // apply query some changes + await queryBar.setQuery('test'); + await queryBar.submitQuery(); + await retry.try(async function () { + expect(await PageObjects.discover.getHitCount()).to.be('22'); + }); + + // reset to persisted state + await PageObjects.discover.clickResetSavedSearchButton(); + const expectedHitCount = '14,004'; + await retry.try(async function () { + expect(await queryBar.getQueryString()).to.be(''); + expect(await PageObjects.discover.getHitCount()).to.be(expectedHitCount); + }); + }); + }); + + describe('query #2, which has an empty time range', () => { + const fromTime = 'Jun 11, 1999 @ 09:22:11.000'; + const toTime = 'Jun 12, 1999 @ 11:21:04.000'; + + before(async () => { + log.debug('setAbsoluteRangeForAnotherQuery'); + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.waitUntilSearchingHasFinished(); + }); + + it('should show "no results"', async () => { + const isVisible = await PageObjects.discover.hasNoResults(); + expect(isVisible).to.be(true); + }); + + it('should suggest a new time range is picked', async () => { + const isVisible = await PageObjects.discover.hasNoResultsTimepicker(); + expect(isVisible).to.be(true); + }); + }); + + describe('nested query', () => { + before(async () => { + log.debug('setAbsoluteRangeForAnotherQuery'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await PageObjects.discover.waitUntilSearchingHasFinished(); + }); + + it('should support querying on nested fields', async function () { + await queryBar.setQuery('nestedField:{ child: nestedValue }'); + await queryBar.submitQuery(); + await retry.try(async function () { + expect(await PageObjects.discover.getHitCount()).to.be('1'); + }); + }); + }); + + describe('data-shared-item', function () { + it('should have correct data-shared-item title and description', async () => { + const expected = { + title: 'A Saved Search', + description: 'A Saved Search Description', + }; + + await retry.try(async () => { + await PageObjects.discover.loadSavedSearch(expected.title); + const { + title, + description, + } = await PageObjects.common.getSharedItemTitleAndDescription(); + expect(title).to.eql(expected.title); + expect(description).to.eql(expected.description); + }); + }); + }); + + describe('time zone switch', () => { + // skipping this until we get an elastic-chart alternative to check the ticks value + it.skip('should show ticks in the correct time zone after switching', async function () { + await opensearchDashboardsServer.uiSettings.replace({ 'dateFormat:tz': 'America/Phoenix' }); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.awaitOpenSearchDashboardsChrome(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + + const maxTicks = [ + '2015-09-20 00:00', + '2015-09-20 12:00', + '2015-09-21 00:00', + '2015-09-21 12:00', + '2015-09-22 00:00', + '2015-09-22 12:00', + '2015-09-23 00:00', + '2015-09-23 12:00', + ]; + + await retry.try(async function () { + for (const tick of await PageObjects.discover.getBarChartXTicks()) { + if (!maxTicks.includes(tick)) { + throw new Error(`unexpected x-axis tick "${tick}"`); + } + } + }); + }); + it('should show bars in the correct time zone after switching', async function () { + await opensearchDashboardsServer.uiSettings.replace({ + 'dateFormat:tz': 'America/Phoenix', + 'discover:v2': false, + }); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.awaitOpenSearchDashboardsChrome(); + await queryBar.clearQuery(); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + + log.debug( + 'check that the newest doc timestamp is now -7 hours from the UTC time in the first test' + ); + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData.startsWith('Sep 22, 2015 @ 16:50:13.253')).to.be.ok(); + }); + }); + 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, + 'discover:v2': false, + }); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.awaitOpenSearchDashboardsChrome(); + + expect(await PageObjects.discover.getNrOfFetches()).to.be(0); + }); + + it('should not fetch data from OpenSearch initially when discover:searchOnPageLoad is true', async function () { + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:searchOnPageLoad': true, + 'discover:v2': false, + }); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.header.awaitOpenSearchDashboardsChrome(); + + expect(await PageObjects.discover.getNrOfFetches()).to.be(1); + }); + }); + + describe('invalid time range in URL', function () { + it('should get the default timerange', async function () { + const prevTime = await PageObjects.timePicker.getTimeConfig(); + await PageObjects.common.navigateToUrl('discover', '#/?_g=(time:(from:now-15m,to:null))', { + useActualUrl: true, + }); + await PageObjects.header.awaitOpenSearchDashboardsChrome(); + const time = await PageObjects.timePicker.getTimeConfig(); + expect(time.start).to.be(prevTime.start); + expect(time.end).to.be(prevTime.end); + }); + }); + + describe('empty query', function () { + 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(); + const initialTimeString = await PageObjects.discover.getChartTimespan(); + await queryBar.submitQuery(); + const refreshedTimeString = await PageObjects.discover.getChartTimespan(); + expect(refreshedTimeString).not.to.be(initialTimeString); + }); + }); + + describe('managing fields', function () { + it('should add a field, sort by it, remove it and also sorting by it', async function () { + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.discover.clickFieldListItemAdd('_score'); + await PageObjects.discover.clickFieldSort('_score'); + const currentUrlWithScore = await browser.getCurrentUrl(); + expect(currentUrlWithScore).to.contain('_score'); + await PageObjects.discover.clickFieldListItemAdd('_score'); + const currentUrlWithoutScore = await browser.getCurrentUrl(); + expect(currentUrlWithoutScore).not.to.contain('_score'); + }); + }); + + describe('refresh interval', function () { + it('should refetch when autofresh is enabled', async () => { + const intervalS = 5; + await PageObjects.timePicker.startAutoRefresh(intervalS); + + // check inspector panel request stats for timestamp + await inspector.open(); + + const getRequestTimestamp = async () => { + const requestStats = await inspector.getTableData(); + const requestTimestamp = requestStats.filter((r) => + r[0].includes('Request timestamp') + )[0][1]; + return requestTimestamp; + }; + + const requestTimestampBefore = await getRequestTimestamp(); + await retry.waitFor('refetch because of refresh interval', async () => { + const requestTimestampAfter = await getRequestTimestamp(); + log.debug( + `Timestamp before: ${requestTimestampBefore}, Timestamp after: ${requestTimestampAfter}` + ); + return requestTimestampBefore !== requestTimestampAfter; + }); + }); + + after(async () => { + await inspector.close(); + await PageObjects.timePicker.pauseAutoRefresh(); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_discover_histogram.ts b/test/functional/apps/discover/_discover_histogram.ts new file mode 100644 index 000000000000..f32a85add6fa --- /dev/null +++ b/test/functional/apps/discover/_discover_histogram.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 expect from '@osd/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchChart = getService('opensearchChart'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const security = getService('security'); + const PageObjects = getPageObjects(['settings', 'common', 'discover', 'header', 'timePicker']); + const defaultSettings = { + defaultIndex: 'long-window-logstash-*', + 'dateFormat:tz': 'Europe/Berlin', + 'discover:v2': false, + }; + + describe('discover histogram', function describeIndexTests() { + before(async () => { + await opensearchArchiver.loadIfNeeded('logstash_functional'); + await opensearchArchiver.load('long_window_logstash'); + await opensearchArchiver.load('long_window_logstash_index_pattern'); + await security.testUser.setRoles(['opensearch_dashboards_admin', 'long_window_logstash']); + await opensearchDashboardsServer.uiSettings.replace(defaultSettings); + await PageObjects.common.navigateToApp('discover'); + }); + after(async () => { + await opensearchArchiver.unload('long_window_logstash'); + await opensearchArchiver.unload('long_window_logstash_index_pattern'); + await security.testUser.restoreDefaults(); + }); + + async function prepareTest(fromTime: string, toTime: string, interval: string) { + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.waitUntilSearchingHasFinished(); + await PageObjects.discover.setChartInterval(interval); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + + it('should visualize monthly data with different day intervals', async () => { + const fromTime = 'Nov 01, 2017 @ 00:00:00.000'; + const toTime = 'Mar 21, 2018 @ 00:00:00.000'; + await prepareTest(fromTime, toTime, 'Month'); + const chartCanvasExist = await opensearchChart.canvasExists(); + expect(chartCanvasExist).to.be(true); + }); + it('should visualize weekly data with within DST changes', async () => { + const fromTime = 'Mar 01, 2018 @ 00:00:00.000'; + const toTime = 'May 01, 2018 @ 00:00:00.000'; + await prepareTest(fromTime, toTime, 'Week'); + const chartCanvasExist = await opensearchChart.canvasExists(); + expect(chartCanvasExist).to.be(true); + }); + it('should visualize monthly data with different years scaled to 30 days', async () => { + const fromTime = 'Jan 01, 2010 @ 00:00:00.000'; + const toTime = 'Mar 21, 2019 @ 00:00:00.000'; + await prepareTest(fromTime, toTime, 'Day'); + const chartCanvasExist = await opensearchChart.canvasExists(); + expect(chartCanvasExist).to.be(true); + const chartIntervalIconTip = await PageObjects.discover.getChartIntervalWarningIcon(); + expect(chartIntervalIconTip).to.be(true); + }); + }); +} diff --git a/test/functional/apps/discover/_doc_navigation.js b/test/functional/apps/discover/_doc_navigation.js new file mode 100644 index 000000000000..6978c363689b --- /dev/null +++ b/test/functional/apps/discover/_doc_navigation.js @@ -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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const log = getService('log'); + const docTable = getService('docTable'); + const filterBar = getService('filterBar'); + 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() { + beforeEach(async function () { + log.debug('load opensearch-dashboards index with default index pattern'); + 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(); + }); + + it('should open the doc view of the selected document', async function () { + // navigate to the doc view + await docTable.clickRowToggle({ rowIndex: 0 }); + + // click the open action + await retry.try(async () => { + const rowActions = await docTable.getRowActions({ rowIndex: 0 }); + if (!rowActions.length) { + throw new Error('row actions empty, trying again'); + } + await rowActions[1].click(); + }); + + const hasDocHit = await testSubjects.exists('doc-hit'); + expect(hasDocHit).to.be(true); + }); + + it('add filter should create an exists filter if value is null (#7189)', async function () { + await PageObjects.discover.waitUntilSearchingHasFinished(); + // Filter special document + await filterBar.addFilter('agent', 'is', 'Missing/Fields'); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + await retry.try(async () => { + // navigate to the doc view + await docTable.clickRowToggle({ rowIndex: 0 }); + + const details = await docTable.getDetailsRow(); + await docTable.addInclusiveFilter(details, 'referer'); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + const hasInclusiveFilter = await filterBar.hasFilter( + 'referer', + 'exists', + true, + false, + true + ); + expect(hasInclusiveFilter).to.be(true); + + await docTable.removeInclusiveFilter(details, 'referer'); + await PageObjects.discover.waitUntilSearchingHasFinished(); + const hasExcludeFilter = await filterBar.hasFilter('referer', 'exists', true, false, false); + expect(hasExcludeFilter).to.be(true); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_doc_table.ts b/test/functional/apps/discover/_doc_table.ts new file mode 100644 index 000000000000..166aa954c364 --- /dev/null +++ b/test/functional/apps/discover/_doc_table.ts @@ -0,0 +1,173 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 expect from '@osd/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const log = getService('log'); + const retry = getService('retry'); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const docTable = getService('docTable'); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); + const defaultSettings = { + defaultIndex: 'logstash-*', + 'discover:v2': false, + }; + + describe('discover doc table', function describeIndexTests() { + const defaultRowsLimit = 50; + const rowsHardLimit = 500; + + before(async function () { + 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'); + await opensearchDashboardsServer.uiSettings.replace(defaultSettings); + log.debug('discover doc table'); + await PageObjects.common.navigateToApp('discover'); + }); + + beforeEach(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + it('should show the first 50 rows by default', async function () { + // with the default range the number of hits is ~14000 + const rows = await PageObjects.discover.getDocTableRows(); + expect(rows.length).to.be(defaultRowsLimit); + }); + + it('should refresh the table content when changing time window', async function () { + const initialRows = await PageObjects.discover.getDocTableRows(); + + const fromTime = 'Sep 20, 2015 @ 23:00:00.000'; + const toTime = 'Sep 20, 2015 @ 23:14:00.000'; + + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + await PageObjects.discover.waitUntilSearchingHasFinished(); + + const finalRows = await PageObjects.discover.getDocTableRows(); + expect(finalRows.length).to.be.below(initialRows.length); + }); + + it(`should load up to ${rowsHardLimit} rows when scrolling at the end of the table`, async function () { + const initialRows = await PageObjects.discover.getDocTableRows(); + // click the Skip to the end of the table + await PageObjects.discover.skipToEndOfDocTable(); + // now count the rows + const finalRows = await PageObjects.discover.getDocTableRows(); + expect(finalRows.length).to.be.above(initialRows.length); + expect(finalRows.length).to.be(rowsHardLimit); + }); + + it('should go the end of the table when using the accessible Skip button', async function () { + // click the Skip to the end of the table + await PageObjects.discover.skipToEndOfDocTable(); + // now check the footer text content + const footer = await PageObjects.discover.getDocTableFooter(); + log.debug(await footer.getVisibleText()); + expect(await footer.getVisibleText()).to.have.string(rowsHardLimit); + }); + + describe('expand a document row', function () { + const rowToInspect = 1; + beforeEach(async function () { + // close the toggle if open + const details = await docTable.getDetailsRows(); + if (details.length) { + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + } + }); + + it('should expand the detail row when the toggle arrow is clicked', async function () { + await retry.try(async function () { + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + const detailsEl = await docTable.getDetailsRows(); + const defaultMessageEl = await detailsEl[0].findByTestSubject('docTableRowDetailsTitle'); + expect(defaultMessageEl).to.be.ok(); + }); + }); + + it('should show the detail panel actions', async function () { + await retry.try(async function () { + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + // const detailsEl = await PageObjects.discover.getDocTableRowDetails(rowToInspect); + const [surroundingActionEl, singleActionEl] = await docTable.getRowActions({ + isAnchorRow: false, + rowIndex: rowToInspect - 1, + }); + expect(surroundingActionEl).to.be.ok(); + expect(singleActionEl).to.be.ok(); + // TODO: test something more meaninful here? + }); + }); + }); + + describe('add and remove columns', function () { + const extraColumns = ['phpmemory', 'ip']; + + afterEach(async function () { + for (const column of extraColumns) { + await PageObjects.discover.clickFieldListItemRemove(column); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + }); + + it('should add more columns to the table', async function () { + const [column] = extraColumns; + await PageObjects.discover.findFieldByName(column); + log.debug(`add a ${column} column`); + await PageObjects.discover.clickFieldListItemAdd(column); + await PageObjects.header.waitUntilLoadingHasFinished(); + // test the header now + expect(await PageObjects.discover.getDocHeader()).to.have.string(column); + }); + + it('should remove columns from the table', async function () { + for (const column of extraColumns) { + await PageObjects.discover.clearFieldSearchInput(); + await PageObjects.discover.findFieldByName(column); + log.debug(`add a ${column} column`); + await PageObjects.discover.clickFieldListItemAdd(column); + await PageObjects.header.waitUntilLoadingHasFinished(); + } + // remove the second column + await PageObjects.discover.clickFieldListItemAdd(extraColumns[1]); + await PageObjects.header.waitUntilLoadingHasFinished(); + // test that the second column is no longer there + expect(await PageObjects.discover.getDocHeader()).to.not.have.string(extraColumns[1]); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_errors.ts b/test/functional/apps/discover/_errors.ts new file mode 100644 index 000000000000..3251b9215e1a --- /dev/null +++ b/test/functional/apps/discover/_errors.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 expect from '@osd/expect'; +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']); + + describe('errors', function describeIndexTests() { + 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'); + }); + + after(async function () { + await opensearchArchiver.unload('invalid_scripted_field'); + }); + + describe('invalid scripted field error', () => { + it('is rendered', async () => { + const toast = await toasts.getToastElement(1); + const painlessStackTrace = await toast.findByTestSubject('painlessStackTrace'); + expect(painlessStackTrace).not.to.be(undefined); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_field_data.js b/test/functional/apps/discover/_field_data.js new file mode 100644 index 000000000000..90de964dc4ec --- /dev/null +++ b/test/functional/apps/discover/_field_data.js @@ -0,0 +1,115 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const retry = getService('retry'); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const toasts = getService('toasts'); + const queryBar = getService('queryBar'); + const PageObjects = getPageObjects(['common', 'header', 'discover', 'visualize', 'timePicker']); + + // FLAKY: https://github.com/elastic/kibana/issues/78689 + describe.skip('discover tab', function describeIndexTests() { + this.tags('includeFirefox'); + before(async function () { + await opensearchArchiver.loadIfNeeded('logstash_functional'); + await opensearchArchiver.load('discover'); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + 'discover:v2': false, + }); + await PageObjects.timePicker.setDefaultAbsoluteRangeViaUiSettings(); + await PageObjects.common.navigateToApp('discover'); + }); + describe('field data', function () { + it('search php should show the correct hit count', async function () { + const expectedHitCount = '445'; + await retry.try(async function () { + await queryBar.setQuery('php'); + await queryBar.submitQuery(); + const hitCount = await PageObjects.discover.getHitCount(); + expect(hitCount).to.be(expectedHitCount); + }); + }); + + it('the search term should be highlighted in the field data', async function () { + // marks is the style that highlights the text in yellow + const marks = await PageObjects.discover.getMarks(); + expect(marks.length).to.be(50); + expect(marks.indexOf('php')).to.be(0); + }); + + it('search type:apache should show the correct hit count', async function () { + const expectedHitCount = '11,156'; + await queryBar.setQuery('type:apache'); + await queryBar.submitQuery(); + await retry.try(async function tryingForTime() { + const hitCount = await PageObjects.discover.getHitCount(); + expect(hitCount).to.be(expectedHitCount); + }); + }); + + it('doc view should show Time and _source columns', async function () { + const expectedHeader = 'Time _source'; + const Docheader = await PageObjects.discover.getDocHeader(); + expect(Docheader).to.be(expectedHeader); + }); + + it('doc view should sort ascending', async function () { + const expectedTimeStamp = 'Sep 20, 2015 @ 00:00:00.000'; + await PageObjects.discover.clickDocSortDown(); + + // we don't technically need this sleep here because the tryForTime will retry and the + // results will match on the 2nd or 3rd attempt, but that debug output is huge in this + // case and it can be avoided with just a few seconds sleep. + await PageObjects.common.sleep(2000); + await retry.try(async function tryingForTime() { + const rowData = await PageObjects.discover.getDocTableIndex(1); + + expect(rowData.startsWith(expectedTimeStamp)).to.be.ok(); + }); + }); + + it('a bad syntax query should show an error message', async function () { + const expectedError = + 'Expected ":", "<", "<=", ">", ">=", AND, OR, end of input, ' + + 'whitespace but "(" found.'; + await queryBar.setQuery('xxx(yyy))'); + await queryBar.submitQuery(); + const { message } = await toasts.getErrorToast(); + expect(message).to.contain(expectedError); + await toasts.dismissToast(); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_field_visualize.ts b/test/functional/apps/discover/_field_visualize.ts new file mode 100644 index 000000000000..50ecb54d27ff --- /dev/null +++ b/test/functional/apps/discover/_field_visualize.ts @@ -0,0 +1,182 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import expect from '@osd/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const opensearchArchiver = getService('opensearchArchiver'); + const filterBar = getService('filterBar'); + const inspector = getService('inspector'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const log = getService('log'); + const queryBar = getService('queryBar'); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker', 'visualize']); + const defaultSettings = { + defaultIndex: 'logstash-*', + 'discover:v2': false, + }; + + describe('discover field visualize button', function () { + // unskipped on cloud as these tests test the navigation + // from Discover to Visualize which happens only on OSS + this.tags(['skipCloud']); + before(async function () { + 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'); + await opensearchDashboardsServer.uiSettings.replace(defaultSettings); + }); + + beforeEach(async () => { + log.debug('go to discover'); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + it('should be able to visualize a field and save the visualization', async () => { + await PageObjects.discover.findFieldByName('type'); + log.debug('visualize a type field'); + await PageObjects.discover.clickFieldListItemVisualize('type'); + await PageObjects.visualize.saveVisualizationExpectSuccess('Top 5 server types'); + }); + + it('should visualize a field in area chart', async () => { + await PageObjects.discover.findFieldByName('phpmemory'); + log.debug('visualize a phpmemory field'); + await PageObjects.discover.clickFieldListItemVisualize('phpmemory'); + await PageObjects.header.waitUntilLoadingHasFinished(); + const expectedTableData = [ + ['0', '10'], + ['58,320', '2'], + ['171,080', '2'], + ['3,240', '1'], + ['3,520', '1'], + ['3,880', '1'], + ['4,120', '1'], + ['4,640', '1'], + ['4,760', '1'], + ['5,680', '1'], + ['7,160', '1'], + ['7,400', '1'], + ['8,400', '1'], + ['8,800', '1'], + ['8,960', '1'], + ['9,400', '1'], + ['10,280', '1'], + ['10,840', '1'], + ['13,080', '1'], + ['13,360', '1'], + ]; + await inspector.open(); + await inspector.expectTableData(expectedTableData); + await inspector.close(); + }); + + it('should not show the "Visualize" button for geo field', async () => { + await PageObjects.discover.findFieldByName('geo.coordinates'); + log.debug('visualize a geo field'); + await PageObjects.discover.expectMissingFieldListItemVisualize('geo.coordinates'); + }); + + it('should preserve app filters in visualize', async () => { + await filterBar.addFilter('bytes', 'is between', '3500', '4000'); + await PageObjects.discover.findFieldByName('geo.src'); + log.debug('visualize a geo.src field with filter applied'); + await PageObjects.discover.clickFieldListItemVisualize('geo.src'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + expect(await filterBar.hasFilter('bytes', '3,500 to 4,000')).to.be(true); + const expectedTableData = [ + ['CN', '133'], + ['IN', '120'], + ['US', '58'], + ['ID', '28'], + ['BD', '25'], + ['BR', '22'], + ['EG', '14'], + ['NG', '14'], + ['PK', '13'], + ['IR', '12'], + ['PH', '12'], + ['JP', '11'], + ['RU', '11'], + ['DE', '8'], + ['FR', '8'], + ['MX', '8'], + ['TH', '8'], + ['TR', '8'], + ['CA', '6'], + ['SA', '6'], + ]; + await inspector.open(); + await inspector.expectTableData(expectedTableData); + await inspector.close(); + }); + + it('should preserve query in visualize', async () => { + await queryBar.setQuery('machine.os : ios'); + await queryBar.submitQuery(); + await PageObjects.discover.findFieldByName('geo.dest'); + log.debug('visualize a geo.dest field with query applied'); + await PageObjects.discover.clickFieldListItemVisualize('geo.dest'); + await PageObjects.header.waitUntilLoadingHasFinished(); + + expect(await queryBar.getQueryString()).to.equal('machine.os : ios'); + const expectedTableData = [ + ['CN', '519'], + ['IN', '495'], + ['US', '275'], + ['ID', '82'], + ['PK', '75'], + ['BR', '71'], + ['NG', '54'], + ['BD', '51'], + ['JP', '47'], + ['MX', '47'], + ['IR', '44'], + ['PH', '44'], + ['RU', '42'], + ['ET', '33'], + ['TH', '33'], + ['EG', '32'], + ['VN', '32'], + ['DE', '31'], + ['FR', '30'], + ['GB', '30'], + ]; + await inspector.open(); + await inspector.expectTableData(expectedTableData); + await inspector.close(); + }); + }); +} diff --git a/test/functional/apps/discover/_filter_editor.js b/test/functional/apps/discover/_filter_editor.js new file mode 100644 index 000000000000..7692d7e6148b --- /dev/null +++ b/test/functional/apps/discover/_filter_editor.js @@ -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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const log = getService('log'); + const retry = getService('retry'); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const filterBar = getService('filterBar'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + const defaultSettings = { + defaultIndex: 'logstash-*', + 'discover:v2': false, + }; + + describe('discover filter editor', function describeIndexTests() { + before(async function () { + log.debug('load opensearch-dashboards index with default index pattern'); + await opensearchArchiver.loadIfNeeded('discover'); + + // and load a set of makelogs data + await opensearchArchiver.loadIfNeeded('logstash_functional'); + await opensearchDashboardsServer.uiSettings.replace(defaultSettings); + log.debug('discover filter editor'); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + describe('filter editor', function () { + it('should add a phrases filter', async function () { + await filterBar.addFilter('extension.raw', 'is one of', 'jpg'); + expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(true); + }); + + it('should show the phrases if you re-open a phrases filter', async function () { + await filterBar.clickEditFilter('extension.raw', 'jpg'); + const phrases = await filterBar.getFilterEditorSelectedPhrases(); + expect(phrases.length).to.be(1); + expect(phrases[0]).to.be('jpg'); + await filterBar.ensureFieldEditorModalIsClosed(); + }); + + it('should support filtering on nested fields', async () => { + await filterBar.addFilter('nestedField.child', 'is', 'nestedValue'); + expect(await filterBar.hasFilter('nestedField.child', 'nestedValue')).to.be(true); + await retry.try(async function () { + expect(await PageObjects.discover.getHitCount()).to.be('1'); + }); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_indexpattern_with_encoded_id.ts b/test/functional/apps/discover/_indexpattern_with_encoded_id.ts new file mode 100644 index 000000000000..f2cb85fb9280 --- /dev/null +++ b/test/functional/apps/discover/_indexpattern_with_encoded_id.ts @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from '@osd/expect'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const retry = getService('retry'); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const docTable = getService('docTable'); + const PageObjects = getPageObjects(['common', 'discover', 'header', 'timePicker']); + + describe('indexpattern with encoded id', () => { + before(async () => { + await opensearchArchiver.loadIfNeeded('index_pattern_with_encoded_id'); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'with-encoded-id', + 'discover:v2': false, + }); + await PageObjects.common.navigateToApp('discover'); + }); + + beforeEach(async function () { + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + after(async () => { + await opensearchArchiver.unload('index_pattern_with_encoded_id'); + }); + + describe('expand a document row', function () { + const rowToInspect = 1; + beforeEach(async function () { + const details = await docTable.getDetailsRows(); + if (details.length) { + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + } + }); + + it('should expand the detail row when the toggle arrow is clicked', async function () { + await retry.try(async function () { + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + const detailsEl = await docTable.getDetailsRows(); + const defaultMessageEl = await detailsEl[0].findByTestSubject('docTableRowDetailsTitle'); + expect(defaultMessageEl).to.be.ok(); + }); + }); + + it('should show the detail panel actions', async function () { + await retry.try(async function () { + await docTable.clickRowToggle({ isAnchorRow: false, rowIndex: rowToInspect - 1 }); + const [surroundingActionEl, singleActionEl] = await docTable.getRowActions({ + isAnchorRow: false, + rowIndex: rowToInspect - 1, + }); + expect(surroundingActionEl).to.be.ok(); + expect(singleActionEl).to.be.ok(); + }); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_indexpattern_without_timefield.ts b/test/functional/apps/discover/_indexpattern_without_timefield.ts new file mode 100644 index 000000000000..1f89753c0336 --- /dev/null +++ b/test/functional/apps/discover/_indexpattern_without_timefield.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 { FtrProviderContext } from '../../ftr_provider_context'; + +export default function ({ getService, getPageObjects }: FtrProviderContext) { + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'timePicker', 'discover']); + + describe('indexpattern without timefield', () => { + before(async () => { + await security.testUser.setRoles([ + 'opensearch_dashboards_admin', + 'opensearch_dashboards_timefield', + ]); + await opensearchArchiver.loadIfNeeded('index_pattern_without_timefield'); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'without-timefield', + 'discover:v2': false, + }); + await PageObjects.common.navigateToApp('discover'); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + await opensearchArchiver.unload('index_pattern_without_timefield'); + }); + + it('should not display a timepicker', async () => { + if (await PageObjects.timePicker.timePickerExists()) { + throw new Error('Expected timepicker not to exist'); + } + }); + + it('should display a timepicker after switching to an index pattern with timefield', async () => { + await PageObjects.discover.selectIndexPattern('with-timefield'); + if (!(await PageObjects.timePicker.timePickerExists())) { + throw new Error('Expected timepicker to exist'); + } + }); + }); +} diff --git a/test/functional/apps/discover/_inspector.js b/test/functional/apps/discover/_inspector.js new file mode 100644 index 000000000000..5f4c2438fce1 --- /dev/null +++ b/test/functional/apps/discover/_inspector.js @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const PageObjects = getPageObjects(['common', 'visualize', 'timePicker']); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const inspector = getService('inspector'); + + const STATS_ROW_NAME_INDEX = 0; + const STATS_ROW_VALUE_INDEX = 1; + function getHitCount(requestStats) { + const hitsCountStatsRow = requestStats.find((statsRow) => { + return statsRow[STATS_ROW_NAME_INDEX] === 'Hits (total)'; + }); + return hitsCountStatsRow[STATS_ROW_VALUE_INDEX]; + } + + // FLAKY: https://github.com/elastic/kibana/issues/39842 + describe.skip('inspect', () => { + before(async () => { + await opensearchArchiver.loadIfNeeded('logstash_functional'); + await opensearchArchiver.load('discover'); + // delete .kibana index and update configDoc + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'logstash-*', + 'discover:v2': false, + }); + + await PageObjects.common.navigateToApp('discover'); + }); + + afterEach(async () => { + await inspector.close(); + }); + + it('should display request stats with no results', async () => { + await inspector.open(); + const requestStats = await inspector.getTableData(); + + expect(getHitCount(requestStats)).to.be('0'); + }); + + it('should display request stats with results', async () => { + await PageObjects.timePicker.setDefaultAbsoluteRange(); + + await inspector.open(); + const requestStats = await inspector.getTableData(); + + expect(getHitCount(requestStats)).to.be('14004'); + }); + }); +} diff --git a/test/functional/apps/discover/_large_string.js b/test/functional/apps/discover/_large_string.js new file mode 100644 index 000000000000..2cad2b0daf90 --- /dev/null +++ b/test/functional/apps/discover/_large_string.js @@ -0,0 +1,103 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const opensearchArchiver = getService('opensearchArchiver'); + const log = getService('log'); + const retry = getService('retry'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const queryBar = getService('queryBar'); + const security = getService('security'); + const PageObjects = getPageObjects(['common', 'home', 'settings', 'discover']); + + describe('test large strings', function () { + before(async function () { + await security.testUser.setRoles([ + 'opensearch_dashboards_admin', + 'opensearch_dashboards_large_strings', + ]); + await opensearchArchiver.load('empty_opensearch_dashboards'); + await opensearchArchiver.loadIfNeeded('hamlet'); + await opensearchDashboardsServer.uiSettings.replace({ + defaultIndex: 'testlargestring', + 'discover:v2': false, + }); + }); + + it('verify the large string book present', async function () { + const ExpectedDoc = + 'mybook:Project Gutenberg EBook of Hamlet, by William Shakespeare' + + ' This eBook is for the use of anyone anywhere in the United States' + + ' and most other parts of the world at no cost and with almost no restrictions whatsoever.' + + ' You may copy it, give it away or re-use it under the terms of the' + + ' Project Gutenberg License included with this eBook or online at www.gutenberg.org.' + + ' If you are not located in the United States,' + + ' you’ll have to check the laws of the country where you are' + + ' located before using this ebook.' + + ' Title: Hamlet Author: William Shakespeare Release Date: November 1998 [EBook #1524]' + + ' Last Updated: December 30, 2017 Language: English Character set encoding:'; + + let rowData; + await PageObjects.common.navigateToApp('discover'); + await retry.try(async function tryingForTime() { + rowData = await PageObjects.discover.getDocTableIndex(1); + log.debug('rowData.length=' + rowData.length); + expect(rowData.substring(0, 200)).to.be(ExpectedDoc.substring(0, 200)); + }); + }); + + describe('test large data', function () { + it('search Newsletter should show the correct hit count', async function () { + const expectedHitCount = '1'; + await queryBar.setQuery('Newsletter'); + await queryBar.submitQuery(); + await retry.try(async function tryingForTime() { + const hitCount = await PageObjects.discover.getHitCount(); + expect(hitCount).to.be(expectedHitCount); + log.debug('test Newsletter keyword is searched'); + }); + }); + + it('the search term Newsletter should be highlighted in the field data', async function () { + // marks is the style that highlights the text in yellow + const marks = await PageObjects.discover.getMarks(); + expect(marks.length).to.be(1); + log.debug('Newsletter appears only once'); + }); + }); + + after(async () => { + await security.testUser.restoreDefaults(); + await opensearchArchiver.unload('hamlet'); + }); + }); +} diff --git a/test/functional/apps/discover/_saved_queries.js b/test/functional/apps/discover/_saved_queries.js new file mode 100644 index 000000000000..c51850eac00d --- /dev/null +++ b/test/functional/apps/discover/_saved_queries.js @@ -0,0 +1,186 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const retry = getService('retry'); + const log = getService('log'); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + 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'); + const savedQueryManagementComponent = getService('savedQueryManagementComponent'); + const testSubjects = getService('testSubjects'); + + describe('saved queries saved objects', function describeIndexTests() { + before(async function () { + 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'); + await opensearchDashboardsServer.uiSettings.replace(defaultSettings); + + log.debug('discover'); + await PageObjects.common.navigateToApp('discover'); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + describe('saved query management component functionality', function () { + before(async function () { + // set up a query with filters and a time filter + log.debug('set up a query with filters to save'); + await queryBar.setQuery('response:200'); + await filterBar.addFilter('extension.raw', 'is one of', 'jpg'); + const fromTime = 'Sep 20, 2015 @ 08:00:00.000'; + const toTime = 'Sep 21, 2015 @ 08:00:00.000'; + await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); + }); + + it('should show the saved query management component when there are no saved queries', async () => { + await savedQueryManagementComponent.openSavedQueryManagementComponent(); + const descriptionText = await testSubjects.getVisibleText('saved-query-management-popover'); + expect(descriptionText).to.eql( + 'SAVED QUERIES\nThere are no saved queries. Save query text and filters that you want to use again.\nSave current query' + ); + }); + + it('should allow a query to be saved via the saved objects management component', async () => { + await savedQueryManagementComponent.saveNewQuery( + 'OkResponse', + '200 responses for .jpg over 24 hours', + true, + true + ); + await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); + await savedQueryManagementComponent.savedQueryTextExist('response:200'); + }); + + it('reinstates filters and the time filter when a saved query has filters and a time filter included', async () => { + await PageObjects.timePicker.setDefaultAbsoluteRange(); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + const timePickerValues = await PageObjects.timePicker.getTimeConfigAsAbsoluteTimes(); + expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(true); + expect(timePickerValues.start).to.not.eql(PageObjects.timePicker.defaultStartTime); + expect(timePickerValues.end).to.not.eql(PageObjects.timePicker.defaultEndTime); + }); + + it('preserves the currently loaded query when the page is reloaded', async () => { + await browser.refresh(); + const timePickerValues = await PageObjects.timePicker.getTimeConfigAsAbsoluteTimes(); + expect(await filterBar.hasFilter('extension.raw', 'jpg')).to.be(true); + expect(timePickerValues.start).to.not.eql(PageObjects.timePicker.defaultStartTime); + expect(timePickerValues.end).to.not.eql(PageObjects.timePicker.defaultEndTime); + await retry.waitFor( + 'the right hit count', + async () => (await PageObjects.discover.getHitCount()) === '2,792' + ); + expect(await savedQueryManagementComponent.getCurrentlyLoadedQueryID()).to.be('OkResponse'); + }); + + it('allows saving changes to a currently loaded query via the saved query management component', async () => { + await queryBar.setQuery('response:404'); + await savedQueryManagementComponent.updateCurrentlyLoadedQuery( + 'OkResponse', + '404 responses', + false, + false + ); + await savedQueryManagementComponent.savedQueryExistOrFail('OkResponse'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + expect(await queryBar.getQueryString()).to.eql(''); + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + expect(await queryBar.getQueryString()).to.eql('response:404'); + }); + + it('allows saving the currently loaded query as a new query', async () => { + await savedQueryManagementComponent.saveCurrentlyLoadedAsNewQuery( + 'OkResponseCopy', + '200 responses', + false, + false + ); + await savedQueryManagementComponent.savedQueryExistOrFail('OkResponseCopy'); + }); + + it('allows deleting the currently loaded saved query in the saved query management component and clears the query', async () => { + await savedQueryManagementComponent.deleteSavedQuery('OkResponseCopy'); + await savedQueryManagementComponent.savedQueryMissingOrFail('OkResponseCopy'); + expect(await queryBar.getQueryString()).to.eql(''); + }); + + it('does not allow saving a query with a non-unique name', async () => { + await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse'); + }); + + it('does not allow saving a query with leading or trailing whitespace in the name', async () => { + await savedQueryManagementComponent.saveNewQueryWithNameError('OkResponse '); + }); + + it('resets any changes to a loaded query on reloading the same saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await queryBar.setQuery('response:503'); + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + expect(await queryBar.getQueryString()).to.eql('response:404'); + }); + + it('allows clearing the currently loaded saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + expect(await queryBar.getQueryString()).to.eql(''); + }); + + it('allows clearing if non default language was remembered in localstorage', async () => { + await queryBar.switchQueryLanguage('lucene'); + await PageObjects.common.navigateToApp('discover'); // makes sure discovered is reloaded without any state in url + await queryBar.expectQueryLanguageOrFail('lucene'); // make sure lucene is remembered after refresh (comes from localstorage) + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await queryBar.expectQueryLanguageOrFail('dql'); + await savedQueryManagementComponent.clearCurrentlyLoadedQuery(); + await queryBar.expectQueryLanguageOrFail('lucene'); + }); + + it('changing language removes saved query', async () => { + await savedQueryManagementComponent.loadSavedQuery('OkResponse'); + await queryBar.switchQueryLanguage('lucene'); + expect(await queryBar.getQueryString()).to.eql(''); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_shared_links.js b/test/functional/apps/discover/_shared_links.js new file mode 100644 index 000000000000..d18481011560 --- /dev/null +++ b/test/functional/apps/discover/_shared_links.js @@ -0,0 +1,194 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const retry = getService('retry'); + const log = getService('log'); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const PageObjects = getPageObjects(['common', 'discover', 'share', 'timePicker']); + const browser = getService('browser'); + const toasts = getService('toasts'); + const deployment = getService('deployment'); + + describe('shared links', function describeIndexTests() { + let baseUrl; + + async function setup({ storeStateInSessionStorage }) { + baseUrl = deployment.getHostPort(); + log.debug('baseUrl = ' + baseUrl); + // browsers don't show the ':port' if it's 80 or 443 so we have to + // remove that part so we can get a match in the tests. + baseUrl = baseUrl.replace(':80', '').replace(':443', ''); + log.debug('New baseUrl = ' + baseUrl); + + // 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'); + await opensearchArchiver.loadIfNeeded('logstash_functional'); + + await opensearchDashboardsServer.uiSettings.replace({ + 'state:storeInSessionStorage': storeStateInSessionStorage, + 'discover:v2': false, + }); + + log.debug('discover'); + await PageObjects.common.navigateToApp('discover'); + + await PageObjects.timePicker.setDefaultAbsoluteRange(); + + //After hiding the time picker, we need to wait for + //the refresh button to hide before clicking the share button + await PageObjects.common.sleep(1000); + + await PageObjects.share.clickShareTopNavButton(); + + return async () => { + await opensearchDashboardsServer.uiSettings.replace({ + 'state:storeInSessionStorage': undefined, + }); + }; + } + + describe('shared links with state in query', async () => { + let teardown; + before(async function () { + teardown = await setup({ storeStateInSessionStorage: false }); + }); + + after(async function () { + await teardown(); + }); + + describe('permalink', function () { + it('should allow for copying the snapshot URL', async function () { + const expectedUrl = + baseUrl + + '/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-" + + "*',interval:auto,query:(language:kuery,query:'')" + + ',sort:!())'; + const actualUrl = await PageObjects.share.getSharedUrl(); + // strip the timestamp out of each URL + expect(actualUrl.replace(/_t=\d{13}/, '_t=TIMESTAMP')).to.be( + expectedUrl.replace(/_t=\d{13}/, '_t=TIMESTAMP') + ); + }); + + it('should allow for copying the snapshot URL as a short URL', async function () { + const re = new RegExp(baseUrl + '/goto/[0-9a-f]{32}$'); + await PageObjects.share.checkShortenUrl(); + await retry.try(async () => { + const actualUrl = await PageObjects.share.getSharedUrl(); + expect(actualUrl).to.match(re); + }); + }); + + it('should allow for copying the saved object URL', async function () { + const expectedUrl = + baseUrl + + '/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" + + "to%3A'2015-09-23T18%3A31%3A44.000Z'))"; + await PageObjects.discover.loadSavedSearch('A Saved Search'); + await PageObjects.share.clickShareTopNavButton(); + await PageObjects.share.exportAsSavedObject(); + const actualUrl = await PageObjects.share.getSharedUrl(); + expect(actualUrl).to.be(expectedUrl); + }); + }); + }); + + describe('shared links with state in sessionStorage', async () => { + let teardown; + before(async function () { + teardown = await setup({ storeStateInSessionStorage: true }); + }); + + after(async function () { + await teardown(); + }); + + it('should allow for copying the snapshot URL as a short URL and should open it', async function () { + const re = new RegExp(baseUrl + '/goto/[0-9a-f]{32}$'); + await PageObjects.share.checkShortenUrl(); + let actualUrl; + await retry.try(async () => { + actualUrl = await PageObjects.share.getSharedUrl(); + expect(actualUrl).to.match(re); + }); + + const actualTime = await PageObjects.timePicker.getTimeConfig(); + + await browser.clearSessionStorage(); + await browser.get(actualUrl, false); + await retry.waitFor('shortUrl resolves and opens', async () => { + const resolvedUrl = await browser.getCurrentUrl(); + 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); + return true; + }); + }); + + it("sharing hashed url shouldn't crash the app", async () => { + const currentUrl = await browser.getCurrentUrl(); + const timeBeforeReload = await PageObjects.timePicker.getTimeConfig(); + await browser.clearSessionStorage(); + await browser.get(currentUrl, false); + await retry.waitFor('discover to open', async () => { + const resolvedUrl = await browser.getCurrentUrl(); + 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.' + ); + await toasts.dismissAllToasts(); + const timeAfterReload = await PageObjects.timePicker.getTimeConfig(); + expect(timeBeforeReload.start).not.to.be(timeAfterReload.start); + expect(timeBeforeReload.end).not.to.be(timeAfterReload.end); + await PageObjects.timePicker.setDefaultAbsoluteRange(); + return true; + }); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_sidebar.js b/test/functional/apps/discover/_sidebar.js new file mode 100644 index 000000000000..5d6bcb5134e6 --- /dev/null +++ b/test/functional/apps/discover/_sidebar.js @@ -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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const log = getService('log'); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const PageObjects = getPageObjects(['common', 'discover', 'timePicker']); + + describe('discover sidebar', function describeIndexTests() { + before(async function () { + 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'); + + await PageObjects.timePicker.setDefaultAbsoluteRange(); + }); + + describe('field filtering', function () { + it('should reveal and hide the filter form when the toggle is clicked', async function () { + await PageObjects.discover.openSidebarFieldFilter(); + await PageObjects.discover.closeSidebarFieldFilter(); + }); + }); + + describe('collapse expand', function () { + it('should initially be expanded', async function () { + const width = await PageObjects.discover.getSidebarWidth(); + log.debug('expanded sidebar width = ' + width); + expect(width > 20).to.be(true); + }); + + it('should collapse when clicked', async function () { + await PageObjects.discover.toggleSidebarCollapse(); + log.debug('PageObjects.discover.getSidebarWidth()'); + const width = await PageObjects.discover.getSidebarWidth(); + log.debug('collapsed sidebar width = ' + width); + expect(width < 20).to.be(true); + }); + + it('should expand when clicked', async function () { + await PageObjects.discover.toggleSidebarCollapse(); + + log.debug('PageObjects.discover.getSidebarWidth()'); + const width = await PageObjects.discover.getSidebarWidth(); + log.debug('expanded sidebar width = ' + width); + expect(width > 20).to.be(true); + }); + }); + }); +} diff --git a/test/functional/apps/discover/_source_filters.js b/test/functional/apps/discover/_source_filters.js new file mode 100644 index 000000000000..1f425a4e341f --- /dev/null +++ b/test/functional/apps/discover/_source_filters.js @@ -0,0 +1,72 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 expect from '@osd/expect'; + +export default function ({ getService, getPageObjects }) { + const log = getService('log'); + const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); + const PageObjects = getPageObjects(['common', 'timePicker', 'discover']); + + describe('source filters', function describeIndexTests() { + before(async function () { + 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'); + + await PageObjects.timePicker.setDefaultAbsoluteRange(); + + //After hiding the time picker, we need to wait for + //the refresh button to hide before clicking the share button + await PageObjects.common.sleep(1000); + }); + + it('should not get the field referer', async function () { + const fieldNames = await PageObjects.discover.getAllFieldNames(); + expect(fieldNames).to.not.contain('referer'); + const relatedContentFields = fieldNames.filter( + (fieldName) => fieldName.indexOf('relatedContent') === 0 + ); + expect(relatedContentFields).to.have.length(0); + }); + }); +} diff --git a/test/functional/apps/discover/index.js b/test/functional/apps/discover/index.js new file mode 100644 index 000000000000..b7dbe3240f7c --- /dev/null +++ b/test/functional/apps/discover/index.js @@ -0,0 +1,65 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT 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 default function ({ getService, loadTestFile }) { + const opensearchArchiver = getService('opensearchArchiver'); + const browser = getService('browser'); + + describe('discover app', function () { + this.tags('ciGroup6'); + + before(function () { + return browser.setWindowSize(1300, 800); + }); + + after(function unloadMakelogs() { + return opensearchArchiver.unload('logstash_functional'); + }); + + loadTestFile(require.resolve('./_saved_queries')); + loadTestFile(require.resolve('./_discover')); + loadTestFile(require.resolve('./_discover_histogram')); + loadTestFile(require.resolve('./_doc_table')); + loadTestFile(require.resolve('./_field_visualize')); + loadTestFile(require.resolve('./_filter_editor')); + loadTestFile(require.resolve('./_errors')); + loadTestFile(require.resolve('./_field_data')); + loadTestFile(require.resolve('./_shared_links')); + loadTestFile(require.resolve('./_sidebar')); + loadTestFile(require.resolve('./_source_filters')); + loadTestFile(require.resolve('./_large_string')); + loadTestFile(require.resolve('./_inspector')); + loadTestFile(require.resolve('./_doc_navigation')); + loadTestFile(require.resolve('./_date_nanos')); + loadTestFile(require.resolve('./_date_nanos_mixed')); + loadTestFile(require.resolve('./_indexpattern_without_timefield')); + loadTestFile(require.resolve('./_indexpattern_with_encoded_id')); + }); +} diff --git a/test/functional/apps/home/_navigation.ts b/test/functional/apps/home/_navigation.ts index 733888f59711..36f8e50ea543 100644 --- a/test/functional/apps/home/_navigation.ts +++ b/test/functional/apps/home/_navigation.ts @@ -33,7 +33,7 @@ import { FtrProviderContext } from '../../ftr_provider_context'; export default function ({ getService, getPageObjects }: FtrProviderContext) { const browser = getService('browser'); - const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker', 'discover']); + const PageObjects = getPageObjects(['common', 'header', 'home', 'timePicker']); const appsMenu = getService('appsMenu'); const opensearchArchiver = getService('opensearchArchiver'); const opensearchDashboardsServer = getService('opensearchDashboardsServer'); @@ -42,9 +42,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { before(async () => { await opensearchArchiver.loadIfNeeded('discover'); await opensearchArchiver.loadIfNeeded('logstash_functional'); - await opensearchDashboardsServer.uiSettings.replace({ - defaultIndex: 'logstash-*', - }); + + await opensearchDashboardsServer.uiSettings.replace({ 'discover:v2': false }); }); it('detect navigate back issues', async () => { @@ -55,9 +54,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { const homeUrl = await browser.getCurrentUrl(); // Navigate to discover app - await PageObjects.common.navigateToApp('discover'); + await appsMenu.clickLink('Discover'); const discoverUrl = await browser.getCurrentUrl(); - await PageObjects.timePicker.setDefaultAbsoluteRange(); const modifiedTimeDiscoverUrl = await browser.getCurrentUrl(); diff --git a/test/functional/apps/home/_sample_data.ts b/test/functional/apps/home/_sample_data.ts index 4c1cf4f69ce1..9f78c5eec4e6 100644 --- a/test/functional/apps/home/_sample_data.ts +++ b/test/functional/apps/home/_sample_data.ts @@ -125,9 +125,8 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await pieChart.expectPieSliceCount(4); log.debug('Checking area, bar and heatmap charts rendered'); await dashboardExpect.seriesElementCount(15); - // The saved search of data explorer now renders 100 lines max log.debug('Checking saved searches rendered'); - await dashboardExpect.savedSearchRowCount(100); + await dashboardExpect.savedSearchRowCount(50); log.debug('Checking input controls rendered'); await dashboardExpect.inputControlItemCount(3); log.debug('Checking tag cloud rendered'); diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 1b0908fd102a..203345e38f91 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -32,6 +32,7 @@ import expect from '@osd/expect'; export default function ({ getService, getPageObjects }) { const opensearchArchiver = getService('opensearchArchiver'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const opensearch = getService('legacyOpenSearch'); const retry = getService('retry'); const security = getService('security'); @@ -42,6 +43,9 @@ export default function ({ getService, getPageObjects }) { await security.testUser.setRoles(['opensearch_dashboards_admin', 'test_alias_reader']); await opensearchArchiver.loadIfNeeded('alias'); await opensearchArchiver.load('empty_opensearch_dashboards'); + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); await opensearch.indices.updateAliases({ body: { actions: [ @@ -67,7 +71,6 @@ export default function ({ getService, getPageObjects }) { it('should be able to discover and verify no of hits for alias1', async function () { const expectedHitCount = '4'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.selectIndexPattern('alias1*'); await retry.try(async function () { expect(await PageObjects.discover.getHitCount()).to.be(expectedHitCount); }); diff --git a/test/functional/apps/management/_index_pattern_results_sort.js b/test/functional/apps/management/_index_pattern_results_sort.js index f5c1b7114d9c..1823c678b2bb 100644 --- a/test/functional/apps/management/_index_pattern_results_sort.js +++ b/test/functional/apps/management/_index_pattern_results_sort.js @@ -39,12 +39,6 @@ export default function ({ getService, getPageObjects }) { before(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); - await PageObjects.settings.createIndexPattern(); - }); - - after(async function () { - return await PageObjects.settings.removeIndexPattern(); }); const columns = [ @@ -70,6 +64,14 @@ export default function ({ getService, getPageObjects }) { columns.forEach(function (col) { describe('sort by heading - ' + col.heading, function indexPatternCreation() { + before(async function () { + await PageObjects.settings.createIndexPattern(); + }); + + after(async function () { + return await PageObjects.settings.removeIndexPattern(); + }); + it('should sort ascending', async function () { await PageObjects.settings.sortBy(col.heading); const rowText = await col.selector(); @@ -83,9 +85,17 @@ export default function ({ getService, getPageObjects }) { }); }); }); - describe('field list pagination', function () { const EXPECTED_FIELD_COUNT = 86; + + before(async function () { + await PageObjects.settings.createIndexPattern(); + }); + + after(async function () { + return await PageObjects.settings.removeIndexPattern(); + }); + it('makelogs data should have expected number of fields', async function () { await retry.try(async function () { const TabCount = await PageObjects.settings.getFieldsTabCount(); diff --git a/test/functional/apps/management/_opensearch_dashboards_settings.js b/test/functional/apps/management/_opensearch_dashboards_settings.js index 0e310953e8a2..98cda687e23b 100644 --- a/test/functional/apps/management/_opensearch_dashboards_settings.js +++ b/test/functional/apps/management/_opensearch_dashboards_settings.js @@ -39,8 +39,8 @@ export default function ({ getService, getPageObjects }) { before(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await PageObjects.settings.createIndexPattern('logstash-*'); + await PageObjects.settings.navigateTo(); }); after(async function afterAll() { diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 87c31a26f8c3..0a6df08a998e 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -71,6 +71,9 @@ export default function ({ getService, getPageObjects }) { before(async function () { await browser.setWindowSize(1200, 800); await opensearchArchiver.load('discover'); + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); await opensearchDashboardsServer.uiSettings.update({}); }); @@ -161,7 +164,6 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 06:31:44.000'; const toTime = 'Sep 18, 2015 @ 18:31:44.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName); @@ -170,35 +172,35 @@ export default function ({ getService, getPageObjects }) { }); await PageObjects.header.waitUntilLoadingHasFinished(); - const rowData = await PageObjects.discover.getDataGridTableValues(); - expect(rowData[0][0]).to.be('Sep 18, 2015 @ 18:20:57.916'); - expect(rowData[0][1]).to.be('18'); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 18, 2015 @ 18:20:57.916\n18'); + }); }); //add a test to sort numeric scripted field it('should sort scripted field value in Discover', async function () { - await testSubjects.click(`dataGridHeaderCell-${scriptedPainlessFieldName}`); - await PageObjects.discover.clickTableHeaderListItem(scriptedPainlessFieldName, 'Sort A-Z'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click('dataGridHeaderCell-@timestamp'); - await PageObjects.discover.clickTableHeaderListItem('@timestamp', 'Sort A-Z'); + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName}`); + // after the first click on the scripted field, it becomes secondary sort after time. + // click on the timestamp twice to make it be the secondary sort key. + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); await PageObjects.header.waitUntilLoadingHasFinished(); - const sortedDataByTimeField = await PageObjects.discover.getDataGridTableValues(); - expect(sortedDataByTimeField[0][0]).contain('Sep 17, 2015 @ 10:53:14.181'); - expect(sortedDataByTimeField[0][1]).contain('-1'); - - // click the column sorting button to remove painless field sort - // should sort only by time field - await testSubjects.click('dataGridColumnSortingButton'); - await PageObjects.discover.removeSort(`${scriptedPainlessFieldName}`); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 17, 2015 @ 10:53:14.181\n-1'); + }); + + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName}`); await PageObjects.header.waitUntilLoadingHasFinished(); - const sortedDataByPainlessField = await PageObjects.discover.getDataGridTableValues(); - expect(sortedDataByPainlessField[0][0]).contain('Sep 17, 2015 @ 06:32:29.479'); - expect(sortedDataByPainlessField[0][1]).contain('20'); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 17, 2015 @ 06:32:29.479\n20'); + }); }); it('should filter by scripted field value in Discover', async function () { - await PageObjects.discover.clickFieldListItemDetails(scriptedPainlessFieldName); + await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName); await log.debug('filter by the first value (14) in the expanded scripted field list'); await PageObjects.discover.clickFieldListPlusFilter(scriptedPainlessFieldName, '14'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -279,7 +281,6 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 06:31:44.000'; const toTime = 'Sep 18, 2015 @ 18:31:44.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); @@ -288,35 +289,35 @@ export default function ({ getService, getPageObjects }) { }); await PageObjects.header.waitUntilLoadingHasFinished(); - const rowData = await PageObjects.discover.getDataGridTableValues(); - expect(rowData[0][0]).to.be('Sep 18, 2015 @ 18:20:57.916'); - expect(rowData[0][1]).to.be('good'); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 18, 2015 @ 18:20:57.916\ngood'); + }); }); //add a test to sort string scripted field it('should sort scripted field value in Discover', async function () { - await testSubjects.click(`dataGridHeaderCell-${scriptedPainlessFieldName2}`); - await PageObjects.discover.clickTableHeaderListItem(scriptedPainlessFieldName2, 'Sort A-Z'); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click('dataGridHeaderCell-@timestamp'); - await PageObjects.discover.clickTableHeaderListItem('@timestamp', 'Sort A-Z'); + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + // after the first click on the scripted field, it becomes secondary sort after time. + // click on the timestamp twice to make it be the secondary sort key. + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); await PageObjects.header.waitUntilLoadingHasFinished(); - const sortedDataByTimeField = await PageObjects.discover.getDataGridTableValues(); - expect(sortedDataByTimeField[0][0]).contain('Sep 17, 2015 @ 09:48:40.594'); - expect(sortedDataByTimeField[0][1]).contain('bad'); - - // click the column sorting button to remove painless field sort - // should sort only by time field - await testSubjects.click('dataGridColumnSortingButton'); - await PageObjects.discover.removeSort(`${scriptedPainlessFieldName2}`); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 17, 2015 @ 09:48:40.594\nbad'); + }); + + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); await PageObjects.header.waitUntilLoadingHasFinished(); - const sortedDataByPainlessField = await PageObjects.discover.getDataGridTableValues(); - expect(sortedDataByPainlessField[0][0]).contain('Sep 17, 2015 @ 06:32:29.479'); - expect(sortedDataByPainlessField[0][1]).contain('good'); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 17, 2015 @ 06:32:29.479\ngood'); + }); }); it('should filter by scripted field value in Discover', async function () { - await PageObjects.discover.clickFieldListItemDetails(scriptedPainlessFieldName2); + await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); await log.debug('filter by "bad" in the expanded scripted field list'); await PageObjects.discover.clickFieldListPlusFilter(scriptedPainlessFieldName2, 'bad'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -375,7 +376,6 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 06:31:44.000'; const toTime = 'Sep 18, 2015 @ 18:31:44.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); @@ -384,37 +384,14 @@ export default function ({ getService, getPageObjects }) { }); await PageObjects.header.waitUntilLoadingHasFinished(); - const rowData = await PageObjects.discover.getDataGridTableValues(); - expect(rowData[0][0]).to.be('Sep 18, 2015 @ 18:20:57.916'); - expect(rowData[0][1]).to.be('true'); - }); - - // existing bug: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5126 hence the issue is skipped - // TODO: replace updateExpectedResultHere with actual data value once bug is fixed - it.skip('should sort scripted field value in Discover', async function () { - await testSubjects.click(`dataGridHeaderCell-${scriptedPainlessFieldName2}`); - await PageObjects.discover.clickTableHeaderListItem( - scriptedPainlessFieldName2, - 'Sort True-False' - ); - await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click('dataGridHeaderCell-@timestamp'); - await PageObjects.discover.clickTableHeaderListItem('@timestamp', 'Sort A-Z'); - await PageObjects.header.waitUntilLoadingHasFinished(); - const sortedDataByTimeField = await PageObjects.discover.getDataGridTableValues(); - expect(sortedDataByTimeField[0][0]).contain('updateExpectedResultHere'); - expect(sortedDataByTimeField[0][1]).contain('true'); - - await testSubjects.click('dataGridColumnSortingButton'); - await PageObjects.discover.removeSort(`${scriptedPainlessFieldName2}`); - await PageObjects.header.waitUntilLoadingHasFinished(); - const sortedDataByPainlessField = await PageObjects.discover.getDataGridTableValues(); - expect(sortedDataByPainlessField[0][0]).contain('updateExpectedResultHere'); - expect(sortedDataByPainlessField[0][1]).contain('false'); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 18, 2015 @ 18:20:57.916\ntrue'); + }); }); it('should filter by scripted field value in Discover', async function () { - await PageObjects.discover.clickFieldListItemDetails(scriptedPainlessFieldName2); + await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); await log.debug('filter by "true" in the expanded scripted field list'); await PageObjects.discover.clickFieldListPlusFilter(scriptedPainlessFieldName2, 'true'); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -425,6 +402,28 @@ export default function ({ getService, getPageObjects }) { await filterBar.removeAllFilters(); }); + //add a test to sort boolean + //existing bug: https://github.com/elastic/kibana/issues/75519 hence the issue is skipped. + it.skip('should sort scripted field value in Discover', async function () { + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + // after the first click on the scripted field, it becomes secondary sort after time. + // click on the timestamp twice to make it be the secondary sort key. + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('updateExpectedResultHere\ntrue'); + }); + + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + await PageObjects.header.waitUntilLoadingHasFinished(); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('updateExpectedResultHere\nfalse'); + }); + }); + it('should visualize scripted field in vertical bar chart', async function () { await PageObjects.discover.clickFieldListItemVisualize(scriptedPainlessFieldName2); await PageObjects.header.waitUntilLoadingHasFinished(); @@ -473,7 +472,6 @@ export default function ({ getService, getPageObjects }) { const fromTime = 'Sep 17, 2015 @ 19:22:00.000'; const toTime = 'Sep 18, 2015 @ 07:00:00.000'; await PageObjects.common.navigateToApp('discover'); - await PageObjects.discover.selectIndexPattern('logstash-*'); await PageObjects.timePicker.setAbsoluteRange(fromTime, toTime); await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); @@ -482,34 +480,36 @@ export default function ({ getService, getPageObjects }) { }); await PageObjects.header.waitUntilLoadingHasFinished(); - const rowData = await PageObjects.discover.getDataGridTableValues(); - expect(rowData[0][0]).to.be('Sep 18, 2015 @ 06:52:55.953'); - expect(rowData[0][1]).to.be('2015-09-18 07:00'); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('Sep 18, 2015 @ 06:52:55.953\n2015-09-18 07:00'); + }); }); - // existing bug: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5127 hence the issue is skipped - // TODO: replace updateExpectedResultHere with actual data value once bug is fixed + //add a test to sort date scripted field + //https://github.com/elastic/kibana/issues/75711 it.skip('should sort scripted field value in Discover', async function () { - await testSubjects.click(`dataGridHeaderCell-${scriptedPainlessFieldName2}`); - await PageObjects.discover.clickTableHeaderListItem(scriptedPainlessFieldName2, 'Sort A-Z'); + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); + // after the first click on the scripted field, it becomes secondary sort after time. + // click on the timestamp twice to make it be the secondary sort key. + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); + await testSubjects.click('docTableHeaderFieldSort_@timestamp'); await PageObjects.header.waitUntilLoadingHasFinished(); - await testSubjects.click('dataGridHeaderCell-@timestamp'); - await PageObjects.discover.clickTableHeaderListItem('@timestamp', 'Sort A-Z'); - await PageObjects.header.waitUntilLoadingHasFinished(); - const sortedDataByTimeField = await PageObjects.discover.getDataGridTableValues(); - expect(sortedDataByTimeField[0][0]).contain('updateExpectedResultHere'); - expect(sortedDataByTimeField[0][1]).contain('2015-09-18 07:00'); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('updateExpectedResultHere\n2015-09-18 07:00'); + }); - await testSubjects.click('dataGridColumnSortingButton'); - await PageObjects.discover.removeSort(`${scriptedPainlessFieldName2}`); + await testSubjects.click(`docTableHeaderFieldSort_${scriptedPainlessFieldName2}`); await PageObjects.header.waitUntilLoadingHasFinished(); - const sortedDataByPainlessField = await PageObjects.discover.getDataGridTableValues(); - expect(sortedDataByPainlessField[0][0]).contain('updateExpectedResultHere'); - expect(sortedDataByPainlessField[0][1]).contain('2015-09-18 07:00'); + await retry.try(async function () { + const rowData = await PageObjects.discover.getDocTableIndex(1); + expect(rowData).to.be('updateExpectedResultHere\n2015-09-18 07:00'); + }); }); it('should filter by scripted field value in Discover', async function () { - await PageObjects.discover.clickFieldListItemDetails(scriptedPainlessFieldName2); + await PageObjects.discover.clickFieldListItem(scriptedPainlessFieldName2); await log.debug('filter by "Sep 18, 2015 @ 7:52" in the expanded scripted field list'); await PageObjects.discover.clickFieldListPlusFilter( scriptedPainlessFieldName2, diff --git a/test/functional/apps/management/_scripted_fields_preview.js b/test/functional/apps/management/_scripted_fields_preview.js index 304f757d006a..adeefa118345 100644 --- a/test/functional/apps/management/_scripted_fields_preview.js +++ b/test/functional/apps/management/_scripted_fields_preview.js @@ -38,8 +38,9 @@ export default function ({ getService, getPageObjects }) { describe('scripted fields preview', () => { before(async function () { await browser.setWindowSize(1200, 800); - await PageObjects.settings.navigateTo(); await PageObjects.settings.createIndexPattern(); + + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); diff --git a/test/functional/apps/visualize/_lab_mode.js b/test/functional/apps/visualize/_lab_mode.js index 82ecbcb2a655..d852ac484eaa 100644 --- a/test/functional/apps/visualize/_lab_mode.js +++ b/test/functional/apps/visualize/_lab_mode.js @@ -34,8 +34,14 @@ import { VISUALIZE_ENABLE_LABS_SETTING } from '../../../../src/plugins/visualiza export default function ({ getService, getPageObjects }) { const log = getService('log'); const PageObjects = getPageObjects(['common', 'header', 'discover', 'settings']); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); describe('visualize lab mode', () => { + before(async () => { + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); + }); it('disabling does not break loading saved searches', async () => { await PageObjects.common.navigateToUrl('discover', '', { useActualUrl: true }); await PageObjects.discover.saveSearch('visualize_lab_mode_test'); diff --git a/test/functional/apps/visualize/index.ts b/test/functional/apps/visualize/index.ts index fb7e721db7de..2bdc5990b928 100644 --- a/test/functional/apps/visualize/index.ts +++ b/test/functional/apps/visualize/index.ts @@ -49,6 +49,7 @@ export default function ({ getService, loadTestFile }: FtrProviderContext) { await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: 'logstash-*', [UI_SETTINGS.FORMAT_BYTES_DEFAULT_PATTERN]: '0,0.[000]b', + 'discover:v2': false, }); isOss = await deployment.isOss(); }); diff --git a/test/functional/config.js b/test/functional/config.js index 87d4302b2a15..b862208276bf 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -40,6 +40,7 @@ export default async function ({ readConfigFile }) { require.resolve('./apps/console'), require.resolve('./apps/context'), require.resolve('./apps/dashboard'), + require.resolve('./apps/discover'), require.resolve('./apps/getting_started'), require.resolve('./apps/home'), require.resolve('./apps/management'), @@ -79,7 +80,7 @@ export default async function ({ readConfigFile }) { pathname: '/status', }, discover: { - pathname: '/app/data-explorer/discover', + pathname: '/app/discoverLegacy', hash: '/', }, context: { diff --git a/test/functional/page_objects/discover_page.ts b/test/functional/page_objects/discover_page.ts index f81c287a478f..2deec8cd44e9 100644 --- a/test/functional/page_objects/discover_page.ts +++ b/test/functional/page_objects/discover_page.ts @@ -42,13 +42,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider const defaultFindTimeout = config.get('timeouts.find'); const opensearchChart = getService('opensearchChart'); const docTable = getService('docTable'); - const dataGridTable = getService('dataGrid'); - const comboBox = getService('comboBox'); - /* - * This page is left unchanged since some of the other selenium tests, ex. dashboard tests, visualization tests - * are still using some of the below helper functions. - */ class DiscoverPage { public async getChartTimespan() { const el = await find.byCssSelector('[data-test-subj="discoverIntervalDateRange"]'); @@ -106,7 +100,7 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider } public async getColumnHeaders() { - return await dataGridTable.getHeaderFields(); + return await docTable.getHeaderFields('embeddedSavedSearchDocTable'); } public async openLoadSavedSearchPanel() { @@ -206,7 +200,6 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider } public async getDocHeader() { - const table = this.dataGrid; const docHeader = await find.byCssSelector('thead > tr:nth-child(1)'); return await docHeader.getVisibleText(); } @@ -287,10 +280,6 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider return await testSubjects.click(`field-${field}`); } - public async clickFieldListItemDetails(field: string) { - return await testSubjects.click(`field-${field}-showDetails`); - } - public async clickFieldSort(field: string) { return await testSubjects.click(`docTableHeaderFieldSort_${field}`); } @@ -351,14 +340,18 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider await header.waitUntilLoadingHasFinished(); } - public async selectIndexPattern(indexPatternTitle: string) { - const dataExplorerComboBoxElement = await testSubjects.find('dataExplorerDSSelect'); - await comboBox.setElement(dataExplorerComboBoxElement, indexPatternTitle); + public async selectIndexPattern(indexPattern: string) { + await testSubjects.click('indexPattern-switch-link'); + await find.setValue('[data-test-subj="indexPattern-switcher"] input', indexPattern); + await find.clickByCssSelector( + `[data-test-subj="indexPattern-switcher"] [title="${indexPattern}"]` + ); await header.waitUntilLoadingHasFinished(); } - public async removeHeaderColumn(columnName: string) { - await dataGridTable.clickRemoveColumn(columnName); + public async removeHeaderColumn(name: string) { + await testSubjects.moveMouseTo(`docTableHeader-${name}`); + await testSubjects.click(`docTableRemoveHeader-${name}`); } public async openSidebarFieldFilter() { @@ -440,82 +433,6 @@ export function DiscoverPageProvider({ getService, getPageObjects }: FtrProvider public async clearSavedQuery() { await testSubjects.click('saved-query-management-clear-button'); } - - /** - * Retrieves data grid table values. - * - * This function fetches the values present in a data grid table. - * - * @returns {Promise} A promise resolving to the table values. - */ - public async getDataGridTableValues(): Promise { - return await dataGridTable.getDataGridTableValues(); - } - - /** - * Removes sorting from a specified column in a data grid. - * - * This function removes sorting of column if applied in the sort field. - * - * @param {string} columnName - The name of the column from which sorting should be removed. - * @returns {Promise} - */ - public async removeSort(columnName: string): Promise { - const parentDiv = await testSubjects.find( - `euiDataGridColumnSorting-sortColumn-${columnName}` - ); - - // Within this parent div, locate the button with the specified aria-label using CSS and click it - const cssSelector = `.euiDataGridColumnSorting__button[aria-label^="Remove from data grid sort: ${columnName}"]`; - const buttonToRemoveSort = await parentDiv.findByCssSelector(cssSelector); - await buttonToRemoveSort.click(); - } - - /** - * Clicks on a list item within the table column header based on column name and title. - * - * This function searches for a list item via title associated with a given column name. - * Once the item is found, its associated button is clicked. - * - * @param {string} columnName - The name of the column. - * @param {string} title - The title of the list item to be clicked. - * @returns {Promise} - * @throws Will throw an error if a clickable list item with the specified title is not found. - */ - public async clickTableHeaderListItem(columnName: string, title: string): Promise { - // locate the ul using the columnName - const ulElement = await testSubjects.find(`dataGridHeaderCellActionGroup-${columnName}`); - const $ = await ulElement.parseDomContent(); - - // loop through each
  • within the ul - const liElements = $('li').toArray(); - let index = 0; - - for (const liElement of liElements) { - const li = $(liElement); - - // Check if the li contains the isClickable class substring - if (li.is('li[class*="euiListGroupItem-isClickable"]')) { - const span = li.find(`span[title="${title}"]`); - - // If the span with the given title is found - if (span.length > 0) { - // find and click the button - const seleniumLiElement = await ulElement.findByCssSelector( - `li:nth-child(${index + 1}) button` - ); - // Click on the located WebElement - await seleniumLiElement.click(); - return; - } - } - index++; - } - - throw new Error( - `Could not find a clickable list item for column "${columnName}" with list item "${title}".` - ); - } } return new DiscoverPage(); diff --git a/test/functional/services/dashboard/expectations.ts b/test/functional/services/dashboard/expectations.ts index 641c56b586fd..ab6eaed8a9bc 100644 --- a/test/functional/services/dashboard/expectations.ts +++ b/test/functional/services/dashboard/expectations.ts @@ -36,7 +36,6 @@ export function DashboardExpectProvider({ getService, getPageObjects }: FtrProvi const log = getService('log'); const retry = getService('retry'); const testSubjects = getService('testSubjects'); - const dataGrid = getService('dataGrid'); const find = getService('find'); const filterBar = getService('filterBar'); const PageObjects = getPageObjects(['dashboard', 'visualize']); @@ -70,10 +69,10 @@ export function DashboardExpectProvider({ getService, getPageObjects }: FtrProvi }); } - async dataGridTableCellCount(expectedCount: number) { - log.debug(`DashboardExpect.dataGridTableCellCount(${expectedCount})`); + async docTableFieldCount(expectedCount: number) { + log.debug(`DashboardExpect.docTableFieldCount(${expectedCount})`); await retry.try(async () => { - const docTableCells = await testSubjects.findAll('dataGridRowCell', findTimeout); + const docTableCells = await testSubjects.findAll('docTableField', findTimeout); expect(docTableCells.length).to.be(expectedCount); }); } @@ -234,9 +233,11 @@ export function DashboardExpectProvider({ getService, getPageObjects }: FtrProvi async savedSearchRowCount(expectedCount: number) { log.debug(`DashboardExpect.savedSearchRowCount(${expectedCount})`); await retry.try(async () => { - // Need to change it here to find out how many rows there are - const timeStamps = await dataGrid.getDataGridTableColumn('date'); - expect(timeStamps.length).to.be(expectedCount); + const savedSearchRows = await testSubjects.findAll( + 'docTableExpandToggleColumn', + findTimeout + ); + expect(savedSearchRows.length).to.be(expectedCount); }); } diff --git a/test/functional/services/dashboard/visualizations.ts b/test/functional/services/dashboard/visualizations.ts index 4a8871677960..588368d4b4a1 100644 --- a/test/functional/services/dashboard/visualizations.ts +++ b/test/functional/services/dashboard/visualizations.ts @@ -37,6 +37,8 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F const queryBar = getService('queryBar'); const testSubjects = getService('testSubjects'); const dashboardAddPanel = getService('dashboardAddPanel'); + const browser = getService('browser'); + const opensearchDashboardsServer = getService('opensearchDashboardsServer'); const PageObjects = getPageObjects([ 'dashboard', 'visualize', @@ -72,6 +74,10 @@ export function DashboardVisualizationProvider({ getService, getPageObjects }: F }) { log.debug(`createSavedSearch(${name})`); + await opensearchDashboardsServer.uiSettings.replace({ + 'discover:v2': false, + }); + await browser.refresh(); await PageObjects.header.clickDiscover(); await PageObjects.timePicker.setHistoricalDataRange(); diff --git a/test/functional/services/data_grid.ts b/test/functional/services/data_grid.ts index b23eabb4ef01..e5f66fbc67c4 100644 --- a/test/functional/services/data_grid.ts +++ b/test/functional/services/data_grid.ts @@ -37,12 +37,8 @@ interface TabbedGridData { export function DataGridProvider({ getService }: FtrProviderContext) { const find = getService('find'); - const testSubjects = getService('testSubjects'); class DataGrid { - // This test no longer works in the new data explorer data grid table - // since each data grid table cell is now rendered differently - // https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5108 async getDataGridTableData(): Promise { const table = await find.byCssSelector('.euiDataGrid'); const $ = await table.parseDomContent(); @@ -70,104 +66,6 @@ export function DataGridProvider({ getService }: FtrProviderContext) { rows, }; } - - /** - * Retrieves the values from a data grid table. - * - * The function fetches values present in a data grid table and organizes them into rows and columns. - * Each row is an array of strings, and the entire table is an array of such rows. - * - * @returns {Promise} A promise resolving to a 2D array of table values. - */ - async getDataGridTableValues(): Promise { - const table = await testSubjects.find('docTable'); - const $ = await table.parseDomContent(); - const cellsArr = $.findTestSubjects('dataGridRowCell').toArray(); - const rows: string[][] = []; - let rowIdx = -1; - - for (const cell of cellsArr) { - const cCell = $(cell); - const isFirstColumn = cCell.attr('class').includes('euiDataGridRowCell--firstColumn'); - if (isFirstColumn) { - rowIdx++; - rows[rowIdx] = []; - } else { - rows[rowIdx].push(this.getTextFromCell(cCell)); - } - } - return Promise.resolve(rows); - } - - /** - * Retrieves the header fields of the data grid. - * - * @returns {Promise} An array containing names of the header fields. - */ - async getHeaderFields(): Promise { - const headerNames = []; - // Locate header cells, ignoring the inspect document button column - const headerCells = await find.allByCssSelector( - '.euiDataGridHeaderCell__button > .euiDataGridHeaderCell__content' - ); - - for (const cell of headerCells) { - const headerName = await cell.getAttribute('textContent'); - headerNames.push(headerName.trim()); - } - return Promise.resolve(headerNames); - } - - /** - * Clicks to remove a specified column from the data grid. - * - * @param {string} columnName - The name of the column to be removed. - */ - async clickRemoveColumn(columnName: string) { - await testSubjects.click(`dataGridHeaderCell-${columnName}`); - await find.clickByButtonText('Remove column'); - } - - /** - * Retrieves values from a specific column in a data grid table. - * - * This function targets a column based on a CSS class selector and retrieves its cell values. - * It makes use of the Cheerio library to parse and navigate the DOM. - * - * @param {string} selector - The CSS class suffix used to identify cells of the desired column. - * @returns {Promise} A promise resolving to an array of cell values from the specified column. - */ - async getDataGridTableColumn(selector: string): Promise { - const table = await find.byCssSelector('.euiDataGrid'); - const $ = await table.parseDomContent(); - - const columnValues: string[] = []; - $.findTestSubjects('dataGridRowCell') - .toArray() - .forEach((cell) => { - const cCell = $(cell); - if (cCell.hasClass(`euiDataGridRowCell--${selector}`)) { - // The column structure is very nested to get the actual text - columnValues.push(this.getTextFromCell(cCell)); - } - }); - - return columnValues; - } - - /** - * Extracts the text from a cell in the data grid. - * - * Given a cell represented by a Cheerio object, this function navigates its nested structure - * to extract the contained text. - * - * @param {any} cCell - The Cheerio representation of the cell from which text needs to be extracted. - * @returns {string} The extracted text from the cell. - */ - getTextFromCell(cCell: any): string { - // navigate the nested structure and get the text - return cCell.children().children().children().children().text(); - } } return new DataGrid(); diff --git a/yarn.lock b/yarn.lock index df151480c3b5..852105fa80ab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2841,6 +2841,18 @@ resolved "https://registry.yarnpkg.com/@tsd/typescript/-/typescript-4.7.4.tgz#f1e4e6c3099a174a0cb7aa51cf53f34f6494e528" integrity sha512-jbtC+RgKZ9Kk65zuRZbKLTACf+tvFW4Rfq0JEMXrlmV3P3yme+Hm+pnb5fJRyt61SjIitcrC810wj7+1tgsEmg== +"@types/angular-mocks@^1.7.1": + version "1.7.1" + resolved "https://registry.yarnpkg.com/@types/angular-mocks/-/angular-mocks-1.7.1.tgz#0f18066f8e6f327493aad0c280a43801ffa09464" + integrity sha512-44kaQL8sMGUIDV76zbJ3MX6aqPng7Wmfz91uds+WcNQFHTdSu6KDZNIypIZVAdrWnJfqrfL5VysaCejTW3JVIw== + dependencies: + "@types/angular" "*" + +"@types/angular@*", "@types/angular@^1.8.4": + version "1.8.4" + resolved "https://registry.yarnpkg.com/@types/angular/-/angular-1.8.4.tgz#a2cc163e508389c51d4c4119ebff6b9395cec472" + integrity sha512-wPS/ncJWhyxJsndsW1B6Ta8D4mi97x1yItSu+rkLDytU3oRIh2CFAjMuJceYwFAh9+DIohndWM0QBA9OU2Hv0g== + "@types/archiver@^5.3.1": version "5.3.1" resolved "https://registry.yarnpkg.com/@types/archiver/-/archiver-5.3.1.tgz#02991e940a03dd1a32678fead4b4ca03d0e387ca" @@ -4372,6 +4384,43 @@ ajv@^8.0.1, ajv@^8.11.0, ajv@^8.6.2: require-from-string "^2.0.2" uri-js "^4.2.2" +angular-aria@^1.8.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/angular-aria/-/angular-aria-1.8.2.tgz#3e5d546e549d8bddcf0b8031c677d3129d82a76d" + integrity sha512-xWT1Lm+Xug2GM6nGNMioBP4hXt3sj2eGaExa4cVCZUYSVWezgyuD1RiLgzJzwTkBGYplHQUzQaz3yDY8jVI6yQ== + +angular-elastic@^2.5.1: + version "2.5.1" + resolved "https://registry.yarnpkg.com/angular-elastic/-/angular-elastic-2.5.1.tgz#e938ab1bd8c76415b8ca6514b15fe3593a5df535" + integrity sha1-6TirG9jHZBW4ymUUsV/jWTpd9TU= + dependencies: + angular ">=1.0.6" + +angular-mocks@^1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/angular-mocks/-/angular-mocks-1.8.2.tgz#dc022420b86978cf317a8447c116c0be73a853bf" + integrity sha512-I5L3P0l21HPdVsP4A4qWmENt4ePjjbkDFdAzOaM7QiibFySbt14DptPbt2IjeG4vFBr4vSLbhIz8Fk03DISl8Q== + +angular-recursion@^1.0.5: + version "1.0.5" + resolved "https://registry.yarnpkg.com/angular-recursion/-/angular-recursion-1.0.5.tgz#cd405428a0bf55faf52eaa7988c1fe69cd930543" + integrity sha1-zUBUKKC/Vfr1Lqp5iMH+ac2TBUM= + +angular-route@^1.8.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/angular-route/-/angular-route-1.8.2.tgz#d482bf05a8c9e448300acd8f9989c635d31b5077" + integrity sha512-49LJYxuaQ/ZDiu9dD2xo1LkazqObkGjw0a7dUF7UKCT8EELgBsMd6QrI6aEVGtI62ppkiFkxqmiV8fcwYdXpug== + +angular-sanitize@^1.8.0: + version "1.8.2" + resolved "https://registry.yarnpkg.com/angular-sanitize/-/angular-sanitize-1.8.2.tgz#ae78040f00c5e2ce1c63780bcc47fa14a1698296" + integrity sha512-OB6Goa+QN3byf5asQ7XRl7DKZejm/F/ZOqa9z1skqYVOWA2hoBxoCmt9E7+i7T/TbxZP5zYzKxNZVVJNu860Hg== + +angular@>=1.0.6, angular@^1.8.2: + version "1.8.2" + resolved "https://registry.yarnpkg.com/angular/-/angular-1.8.2.tgz#5983bbb5a9fa63e213cb7749199e0d352de3a2f1" + integrity sha512-IauMOej2xEe7/7Ennahkbb5qd/HFADiNuLSESz9Q27inmi32zB0lnAsFeLEWcox3Gd1F6YhNd1CP7/9IukJ0Gw== + ansi-colors@4.1.1: version "4.1.1" resolved "https://registry.yarnpkg.com/ansi-colors/-/ansi-colors-4.1.1.tgz#cbb9ae256bf750af1eab344f229aa27fe94ba348" @@ -12972,6 +13021,11 @@ next-tick@1, next-tick@^1.1.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== +ngreact@^0.5.1: + version "0.5.2" + resolved "https://registry.yarnpkg.com/ngreact/-/ngreact-0.5.2.tgz#d48180b578b186ad70861a3de9ba508b3f22b2ae" + integrity sha512-FCQGtTkDrnI3ywhvK9wUf7C6SYfqKDdRW+cPvy358GFe3AnA4rfvWisDVUQyf5YwNr439ito9xUuuEv80QXhSQ== + nice-try@^1.0.4: version "1.0.5" resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366"