From 8975381e0e5eead66653581b979a89d8f971646e Mon Sep 17 00:00:00 2001 From: Lu Yu Date: Tue, 19 Mar 2024 12:55:28 -0700 Subject: [PATCH 01/45] [Multiple Datasource] Use data source filter function before rendering (#6175) * use filter function before rendering Signed-off-by: Lu Yu * add change log Signed-off-by: Lu Yu --------- Signed-off-by: Lu Yu Co-authored-by: ZilongX <99905560+ZilongX@users.noreply.github.com> --- CHANGELOG.md | 1 + .../data_source_selector.tsx | 38 +++++++------------ 2 files changed, 15 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e17bb7d1a207..6b7e1101d553 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -52,6 +52,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Add workspace id in basePath ([#6060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6060)) - Implement new home page ([#6065](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6065)) - Add sidecar service ([#5920](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5920)) +- [Multiple Datasource] Use data source filter function before rendering ([#6175](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6175)) - [Chrome] Introduce registerCollapsibleNavHeader to allow plugins to customize the rendering of nav menu header ([#5244](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5244)) - [Dynamic Configurations] Pass request headers when making application config calls ([#6164](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6164)) - [Discover] Options button to configure legacy mode and remove the top navigation option ([#6170](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6170)) diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx index e7503cba645a..3e9f4c377160 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx @@ -32,8 +32,8 @@ export interface DataSourceSelectorProps { } interface DataSourceSelectorState { - dataSourceOptions: DataSourceOption[]; selectedOption: DataSourceOption[]; + allDataSources: Array>; } export interface DataSourceOption { @@ -52,11 +52,7 @@ export class DataSourceSelector extends React.Component< super(props); this.state = { - dataSourceOptions: this.props.defaultOption - ? this.props.defaultOption - : this.props.hideLocalCluster - ? [] - : [LocalCluster], + allDataSources: [], selectedOption: this.props.defaultOption ? this.props.defaultOption : this.props.hideLocalCluster @@ -74,26 +70,10 @@ export class DataSourceSelector extends React.Component< getDataSourcesWithFields(this.props.savedObjectsClient, ['id', 'title', 'auth.type']) .then((fetchedDataSources) => { if (fetchedDataSources?.length) { - let filteredDataSources = fetchedDataSources; - if (this.props.dataSourceFilter) { - filteredDataSources = fetchedDataSources.filter((ds) => - this.props.dataSourceFilter!(ds) - ); - } - - const dataSourceOptions = filteredDataSources.map((dataSource) => ({ - id: dataSource.id, - label: dataSource.attributes?.title || '', - })); - - if (!this.props.hideLocalCluster) { - dataSourceOptions.unshift(LocalCluster); - } - if (!this._isMounted) return; this.setState({ ...this.state, - dataSourceOptions, + allDataSources: fetchedDataSources, }); } }) @@ -119,6 +99,16 @@ export class DataSourceSelector extends React.Component< this.props.placeholderText === undefined ? 'Select a data source' : this.props.placeholderText; + + const dataSources = this.props.dataSourceFilter + ? this.state.allDataSources.filter((ds) => this.props.dataSourceFilter!(ds)) + : this.state.allDataSources; + + const options = dataSources.map((ds) => ({ id: ds.id, label: ds.attributes?.title || '' })); + if (!this.props.hideLocalCluster) { + options.unshift(LocalCluster); + } + return ( this.onChange(e)} prepend={ From 73652f7c9e3f20d0bb16dc628c919626701d9832 Mon Sep 17 00:00:00 2001 From: Manasvini B Suryanarayana Date: Tue, 19 Mar 2024 13:24:05 -0700 Subject: [PATCH 02/45] Add release notes for 2.13.0 (#6194) Signed-off-by: Manasvini B Suryanarayana --- ...nsearch-dashboards.release-notes-2.13.0.md | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 release-notes/opensearch-dashboards.release-notes-2.13.0.md diff --git a/release-notes/opensearch-dashboards.release-notes-2.13.0.md b/release-notes/opensearch-dashboards.release-notes-2.13.0.md new file mode 100644 index 000000000000..5d4a30983c3b --- /dev/null +++ b/release-notes/opensearch-dashboards.release-notes-2.13.0.md @@ -0,0 +1,57 @@ +## Version 2.13.0 Release Notes + +### 🛡 Security + +- Support dynamic CSP rules to mitigate Clickjacking https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5641 +- [CVE-2020-36604] Employ a patched version of hoek `6.1.3` ([#6148](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6148)) +- [CVE-2024-27088] Bump es5-ext from `0.10.59` to `0.10.64` ([#6021](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6021)) + +### 📈 Features/Enhancements + +- [MD]Change cluster selector component name to data source selector ([#6042](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6042)) +- [Multiple Datasource] Add interfaces to register add-on authentication method from plug-in module ([#5851](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5851)) +- [Multiple Datasource] Able to Hide "Local Cluster" option from datasource DropDown ([#5827](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5827)) +- [Multiple Datasource] Add api registry and allow it to be added into client config in data source plugin ([#5895](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5895)) +- [Multiple Datasource] Concatenate data source name with index pattern name and change delimiter to double colon ([#5907](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5907)) +- [Multiple Datasource] Refactor client and legacy client to use authentication registry ([#5881](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5881)) +- [Multiple Datasource] Improved error handling for the search API when a null value is passed for the dataSourceId ([#5882](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5882)) +- [Multiple Datasource] Hide/Show authentication method in multi data source plugin based on configuration ([#5916](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5916)) +- [Dynamic Configurations] Add support for dynamic application configurations ([#5855](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5855)) +- [Multiple Datasource] Refactoring create and edit form to use authentication registry ([#6002](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6002)) +- [Multiple Datasource] Handles auth methods from auth registry in DataSource SavedObjects Client Wrapper ([#6062](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6062)) +- [Multiple Datasource] Expose a few properties for customize the appearance of the data source selector component ([#6057](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6057)) +- [Multiple Datasource] Handle form values(request payload) if the selected type is available in the authentication registry ([#6049](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6049)) +- [Multiple Datasource] Adds a session token to AWS credentials ([#6103](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6103)) +- [Multiple Datasource] Test connection schema validation for registered auth types ([#6109](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6109)) +- [Multiple DataSource] DataSource creation and edition page improvement to better support registered auth types ([#6122](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6122)) +- [Multiple Datasource] Export DataSourcePluginRequestContext at top level for plugins to use ([#6108](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6108)) +- [Multiple Datasource] Improves connection pooling support for AWSSigV4 clients in data sources ([#6135](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6135)) +- [Multiple Datasource] Add datasource version number to newly created data source object([#6178](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6178)) +- Implement new home page ([#6065](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6065)) +- Add sidecar service ([#5920](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5920)) +- [Dynamic Configurations] Pass request headers when making application config calls ([#6164](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6164)) +- [Discover] Options button to configure legacy mode and remove the top navigation option ([#6170](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6170)) + +### 🐛 Bug Fixes + +- [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) +- [BUG][Discover] Add key to index pattern options for support deplicate index pattern names([#5946](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5946)) +- [Discover] Fix table cell content overflowing in Safari ([#5948](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5948)) +- [BUG][MD]Fix schema for test connection to separate validation based on auth type ([#5997](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5997)) +- [Discover] Enable 'Back to Top' Feature in Discover for scrolling to top ([#6008](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6008)) +- [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) +- [osd/std] Add additional recovery from false-positives in handling of long numerals ([#5956](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5956)) +- [BUG][Multiple Datasource] Fix missing customApiRegistryPromise param for test connection ([#5944](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5944)) +- [BUG][Multiple Datasource] Add a migration function for datasource to add migrationVersion field ([#6025](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6025)) +- [BUG][MD]Expose picker using function in data source management plugin setup([#6030](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6030)) +- [BUG][Multiple Datasource] Fix data source filter bug and add tests ([#6152](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6152)) + +### 📝 Documentation + +- Fix link to documentation for geoHash precision ([#5967](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5967)) + +### 🛠 Maintenance + +- Bump `chromedriver` dependency to `121.0.1"` ([#5926](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5926)) +- Add @ruanyl as a maintainer ([#5982](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5982)) +- Add @BionIT as a maintainer ([#5988](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5988)) From 4a7761752cfe03254f1b28bb7f32dd2bb644205d Mon Sep 17 00:00:00 2001 From: Ashwin P Chandran Date: Tue, 19 Mar 2024 14:32:46 -0700 Subject: [PATCH 03/45] [CVE-2024-28849] Bump follow-redirects from 1.15.4 to 1.15.6 (#6201) * updated follow redirects Signed-off-by: Ashwin P Chandran * Adds changelog Signed-off-by: Ashwin P Chandran --------- Signed-off-by: Ashwin P Chandran --- CHANGELOG.md | 1 + yarn.lock | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b7e1101d553..f66d66183da8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Support dynamic CSP rules to mitigate Clickjacking https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5641 - [WS-2021-0638] Bump mocha from `7.2.0` to `10.1.0` ([#2711](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/2711)) - [CVE-2024-27088] Bump es5-ext from `0.10.59` to `0.10.64` ([#6021](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6021)) +- [CVE-2024-28849] Bump follow-redirect from `1.15.4` to `1.15.6` ([#6199](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6199)) ### 📈 Features/Enhancements diff --git a/yarn.lock b/yarn.lock index 2dbf1c7e21da..c9b266d1d1a7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -9263,9 +9263,9 @@ focus-lock@^0.10.2: tslib "^2.0.3" follow-redirects@^1.15.0, follow-redirects@^1.15.4: - version "1.15.4" - resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.4.tgz#cdc7d308bf6493126b17ea2191ea0ccf3e535adf" - integrity sha512-Cr4D/5wlrb0z9dgERpUL3LrmPKVDsETIJhaCMeDfuFYcqa5bldGV6wBsAN6X/vxlXQtFBMrXdXxdL8CbDTGniw== + version "1.15.6" + resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.6.tgz#7f815c0cda4249c74ff09e95ef97c23b5fd0399b" + integrity sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA== font-awesome@4.7.0: version "4.7.0" From 1f74ab35b8c7945e4d0033d01ac776cf1d70d711 Mon Sep 17 00:00:00 2001 From: Manasvini B Suryanarayana Date: Tue, 19 Mar 2024 16:09:57 -0700 Subject: [PATCH 04/45] Update release notes for 2.13 (#6209) Signed-off-by: Manasvini B Suryanarayana --- release-notes/opensearch-dashboards.release-notes-2.13.0.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/release-notes/opensearch-dashboards.release-notes-2.13.0.md b/release-notes/opensearch-dashboards.release-notes-2.13.0.md index 5d4a30983c3b..5a9b51383044 100644 --- a/release-notes/opensearch-dashboards.release-notes-2.13.0.md +++ b/release-notes/opensearch-dashboards.release-notes-2.13.0.md @@ -5,6 +5,7 @@ - Support dynamic CSP rules to mitigate Clickjacking https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5641 - [CVE-2020-36604] Employ a patched version of hoek `6.1.3` ([#6148](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6148)) - [CVE-2024-27088] Bump es5-ext from `0.10.59` to `0.10.64` ([#6021](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6021)) +- [CVE-2024-28849] Bump follow-redirect from `1.15.4` to `1.15.6` ([#6199](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6201)) ### 📈 Features/Enhancements @@ -27,6 +28,7 @@ - [Multiple Datasource] Export DataSourcePluginRequestContext at top level for plugins to use ([#6108](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6108)) - [Multiple Datasource] Improves connection pooling support for AWSSigV4 clients in data sources ([#6135](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6135)) - [Multiple Datasource] Add datasource version number to newly created data source object([#6178](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6178)) +- [Multiple Datasource] Add default functionality for customer to choose default datasource ([#6186](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6186)) - Implement new home page ([#6065](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6065)) - Add sidecar service ([#5920](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5920)) - [Dynamic Configurations] Pass request headers when making application config calls ([#6164](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6164)) From de978d4516a715eedf56b4abaa16a7aa11a2a654 Mon Sep 17 00:00:00 2001 From: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> Date: Tue, 19 Mar 2024 16:12:14 -0700 Subject: [PATCH 05/45] [MDS] Add Vega support for importing saved objects (#6123) * Add MDS support for Vega Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactor field to data_source_id Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add to CHANGELOG.md Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Added test cases and renamed field to use data_source_name Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add prefix datasource name test case and add example in default hjson Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Move CHANGELOG to appropriate section Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Increased test coverage of search() method Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add test cases for util function Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add util function to modify Vega Spec Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add method to verify Vega saved object type Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add import saved object support for Vega Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add unit tests for Vega objects in create and conflict modes Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Refactored utils test file Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add to CHANGELOG Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Use bulkget instead of single get Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add datasource references to the specs Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Fix bootstrap errors Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add edge case where title is potentially undefined Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Address PR comments Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Add more test coverage for checking conflict Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> * Fix unit test Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> --------- Signed-off-by: Huy Nguyen <73027756+huyaboo@users.noreply.github.com> --- CHANGELOG.md | 2 +- .../check_conflict_for_data_source.test.ts | 161 +++++++++++- .../import/check_conflict_for_data_source.ts | 47 +++- .../import/create_saved_objects.test.ts | 116 +++++++- .../import/create_saved_objects.ts | 162 +++++++----- .../import/import_saved_objects.test.ts | 1 + .../import/import_saved_objects.ts | 1 + .../vega_spec_with_multiple_urls.hjson | 147 +++++++++++ .../vega_spec_with_multiple_urls.json | 95 +++++++ .../vega_spec_with_multiple_urls_mds.hjson | 148 +++++++++++ .../vega_spec_with_multiple_urls_mds.json | 96 +++++++ .../vega_spec_with_opensearch_query.hjson | 61 +++++ .../vega_spec_with_opensearch_query.json | 36 +++ .../vega_spec_without_opensearch_query.hjson | 117 +++++++++ .../vega_spec_without_opensearch_query.json | 66 +++++ .../server/saved_objects/import/utils.test.ts | 247 ++++++++++++++++++ src/core/server/saved_objects/import/utils.ts | 103 ++++++++ src/plugins/vis_type_vega/server/plugin.ts | 16 +- src/plugins/vis_type_vega/server/services.ts | 10 + .../vega_outdated_references_mds.hjson | 223 ++++++++++++++++ .../vega_spec_up_to_date_urls_mds.hjson | 185 +++++++++++++ .../vega_spec_with_multiple_urls.hjson | 165 ++++++++++++ .../vega_spec_with_multiple_urls.json | 110 ++++++++ .../vega_spec_with_multiple_urls_mds.hjson | 185 +++++++++++++ .../vega_spec_with_multiple_urls_mds.json | 127 +++++++++ .../vega_spec_with_opensearch_query.hjson | 61 +++++ .../vega_spec_with_opensearch_query.json | 36 +++ .../vega_spec_with_opensearch_query_mds.hjson | 62 +++++ .../vega_spec_with_opensearch_query_mds.json | 37 +++ .../vega_spec_without_opensearch_query.hjson | 117 +++++++++ .../vega_spec_without_opensearch_query.json | 66 +++++ src/plugins/vis_type_vega/server/types.ts | 2 + .../vis_type_vega/server/utils.test.ts | 181 +++++++++++++ src/plugins/vis_type_vega/server/utils.ts | 98 +++++++ .../vega_visualization_client_wrapper.test.ts | 243 +++++++++++++++++ .../vega_visualization_client_wrapper.ts | 110 ++++++++ 36 files changed, 3568 insertions(+), 72 deletions(-) create mode 100644 src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.hjson create mode 100644 src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.json create mode 100644 src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.hjson create mode 100644 src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.json create mode 100644 src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.hjson create mode 100644 src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.json create mode 100644 src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.hjson create mode 100644 src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.json create mode 100644 src/core/server/saved_objects/import/utils.test.ts create mode 100644 src/core/server/saved_objects/import/utils.ts create mode 100644 src/plugins/vis_type_vega/server/services.ts create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_outdated_references_mds.hjson create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_up_to_date_urls_mds.hjson create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.hjson create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.json create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.hjson create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.json create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.hjson create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.json create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.hjson create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.json create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.hjson create mode 100644 src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.json create mode 100644 src/plugins/vis_type_vega/server/utils.test.ts create mode 100644 src/plugins/vis_type_vega/server/utils.ts create mode 100644 src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.test.ts create mode 100644 src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f66d66183da8..dfd5ae184e07 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -58,7 +58,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Dynamic Configurations] Pass request headers when making application config calls ([#6164](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6164)) - [Discover] Options button to configure legacy mode and remove the top navigation option ([#6170](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6170)) - [Multiple Datasource] Add default functionality for customer to choose default datasource ([#6058](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6058)) - +- [Multiple Datasource] Add import support for Vega when specifying a datasource ([#6123](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6123)) ### 🐛 Bug Fixes diff --git a/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts b/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts index 2b50ba8e9b35..b2a6ae6fda65 100644 --- a/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts +++ b/src/core/server/saved_objects/import/check_conflict_for_data_source.test.ts @@ -5,7 +5,7 @@ import { mockUuidv4 } from './__mocks__'; import { SavedObjectReference, SavedObjectsImportRetry } from 'opensearch-dashboards/public'; -import { SavedObject } from '../types'; +import { SavedObject, SavedObjectsClientContract } from '../types'; import { SavedObjectsErrorHelpers } from '..'; import { checkConflictsForDataSource, @@ -24,6 +24,45 @@ const createObject = (type: string, id: string): SavedObjectType => ({ references: (Symbol() as unknown) as SavedObjectReference[], }); +const createVegaVisualizationObject = (id: string): SavedObjectType => { + const visState = + id.split('_').length > 1 + ? '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: old-datasource-title\\n }\\n }\\n}"}}' + : '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n }\\n }\\n}"}}'; + return { + type: 'visualization', + id, + attributes: { title: 'some-title', visState }, + references: + id.split('_').length > 1 + ? [{ id: id.split('_')[0], type: 'data-source', name: 'dataSource' }] + : [], + } as SavedObjectType; +}; + +const getSavedObjectClient = (): SavedObjectsClientContract => { + const savedObject = {} as SavedObjectsClientContract; + savedObject.get = jest.fn().mockImplementation((type, id) => { + if (type === 'data-source' && id === 'old-datasource-id') { + return Promise.resolve({ + attributes: { + title: 'old-datasource-title', + }, + }); + } else if (type === 'data-source') { + return Promise.resolve({ + attributes: { + title: 'some-datasource-title', + }, + }); + } + + return Promise.resolve(undefined); + }); + + return savedObject; +}; + const getResultMock = { conflict: (type: string, id: string) => { const error = SavedObjectsErrorHelpers.createConflictError(type, id).output.payload; @@ -56,6 +95,7 @@ describe('#checkConflictsForDataSource', () => { retries?: SavedObjectsImportRetry[]; createNewCopies?: boolean; dataSourceId?: string; + savedObjectsClient?: SavedObjectsClientContract; }): ConflictsForDataSourceParams => { return { ...partial }; }; @@ -140,4 +180,123 @@ describe('#checkConflictsForDataSource', () => { importIdMap: new Map(), }); }); + + /* + Vega test cases + */ + it('will attach datasource name to Vega spec when importing from local to datasource', async () => { + const vegaSavedObject = createVegaVisualizationObject('some-object-id'); + const params = setupParams({ + objects: [vegaSavedObject], + ignoreRegularConflicts: true, + dataSourceId: 'some-datasource-id', + savedObjectsClient: getSavedObjectClient(), + }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + + expect(params.savedObjectsClient?.get).toHaveBeenCalledWith( + 'data-source', + 'some-datasource-id' + ); + expect(checkConflictsForDataSourceResult).toEqual( + expect.objectContaining({ + filteredObjects: [ + { + ...vegaSavedObject, + attributes: { + title: 'some-title', + visState: + '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: some-datasource-title\\n }\\n }\\n}"}}', + }, + id: 'some-datasource-id_some-object-id', + references: [ + { + id: 'some-datasource-id', + type: 'data-source', + name: 'dataSource', + }, + ], + }, + ], + errors: [], + importIdMap: new Map([ + [ + `visualization:some-object-id`, + { id: 'some-datasource-id_some-object-id', omitOriginId: true }, + ], + ]), + }) + ); + }); + + it('will not change Vega spec when importing from datasource to different datasource', async () => { + const vegaSavedObject = createVegaVisualizationObject('old-datasource-id_some-object-id'); + const params = setupParams({ + objects: [vegaSavedObject], + ignoreRegularConflicts: true, + dataSourceId: 'some-datasource-id', + savedObjectsClient: getSavedObjectClient(), + }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + + expect(params.savedObjectsClient?.get).toHaveBeenCalledWith( + 'data-source', + 'some-datasource-id' + ); + expect(checkConflictsForDataSourceResult).toEqual( + expect.objectContaining({ + filteredObjects: [ + { + ...vegaSavedObject, + attributes: { + title: 'some-title', + visState: + '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: old-datasource-title\\n }\\n }\\n}"}}', + }, + id: 'some-datasource-id_some-object-id', + }, + ], + errors: [], + importIdMap: new Map([ + [ + `visualization:some-object-id`, + { id: 'some-datasource-id_some-object-id', omitOriginId: true }, + ], + ]), + }) + ); + }); + + it('will not change Vega spec when dataSourceTitle is undefined', async () => { + const vegaSavedObject = createVegaVisualizationObject('old-datasource-id_some-object-id'); + const params = setupParams({ + objects: [vegaSavedObject], + ignoreRegularConflicts: true, + dataSourceId: 'nonexistent-datasource-title-id', + savedObjectsClient: getSavedObjectClient(), + }); + const checkConflictsForDataSourceResult = await checkConflictsForDataSource(params); + + expect(params.savedObjectsClient?.get).toHaveBeenCalledWith( + 'data-source', + 'nonexistent-datasource-title-id' + ); + expect(checkConflictsForDataSourceResult).toEqual( + expect.objectContaining({ + filteredObjects: [ + { + ...vegaSavedObject, + id: 'nonexistent-datasource-title-id_some-object-id', + }, + ], + errors: [], + importIdMap: new Map([ + [ + `visualization:some-object-id`, + { id: 'nonexistent-datasource-title-id_some-object-id', omitOriginId: true }, + ], + ]), + }) + ); + }); }); diff --git a/src/core/server/saved_objects/import/check_conflict_for_data_source.ts b/src/core/server/saved_objects/import/check_conflict_for_data_source.ts index 6611b01dfb2a..a0400c57d023 100644 --- a/src/core/server/saved_objects/import/check_conflict_for_data_source.ts +++ b/src/core/server/saved_objects/import/check_conflict_for_data_source.ts @@ -3,13 +3,24 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { SavedObject, SavedObjectsImportError, SavedObjectsImportRetry } from '../types'; +import { + SavedObject, + SavedObjectsClientContract, + SavedObjectsImportError, + SavedObjectsImportRetry, +} from '../types'; +import { + extractVegaSpecFromSavedObject, + getDataSourceTitleFromId, + updateDataSourceNameInVegaSpec, +} from './utils'; export interface ConflictsForDataSourceParams { objects: Array>; ignoreRegularConflicts?: boolean; retries?: SavedObjectsImportRetry[]; dataSourceId?: string; + savedObjectsClient?: SavedObjectsClientContract; } interface ImportIdMapEntry { @@ -31,6 +42,7 @@ export async function checkConflictsForDataSource({ ignoreRegularConflicts, retries = [], dataSourceId, + savedObjectsClient, }: ConflictsForDataSourceParams) { const filteredObjects: Array> = []; const errors: SavedObjectsImportError[] = []; @@ -43,6 +55,12 @@ export async function checkConflictsForDataSource({ (acc, cur) => acc.set(`${cur.type}:${cur.id}`, cur), new Map() ); + + const dataSourceTitle = + !!dataSourceId && !!savedObjectsClient + ? await getDataSourceTitleFromId(dataSourceId, savedObjectsClient) + : undefined; + objects.forEach((object) => { const { type, @@ -74,6 +92,33 @@ export async function checkConflictsForDataSource({ /** * Only update importIdMap and filtered objects */ + + // Some visualization types will need special modifications, like Vega visualizations + if (object.type === 'visualization') { + const vegaSpec = extractVegaSpecFromSavedObject(object); + + if (!!vegaSpec && !!dataSourceTitle) { + const updatedVegaSpec = updateDataSourceNameInVegaSpec({ + spec: vegaSpec, + newDataSourceName: dataSourceTitle, + }); + + // @ts-expect-error + const visStateObject = JSON.parse(object.attributes?.visState); + visStateObject.params.spec = updatedVegaSpec; + + // @ts-expect-error + object.attributes.visState = JSON.stringify(visStateObject); + if (!!dataSourceId) { + object.references.push({ + id: dataSourceId, + name: 'dataSource', + type: 'data-source', + }); + } + } + } + const omitOriginId = ignoreRegularConflicts; importIdMap.set(`${type}:${id}`, { id: `${dataSourceId}_${rawId}`, omitOriginId }); filteredObjects.push({ ...object, id: `${dataSourceId}_${rawId}` }); diff --git a/src/core/server/saved_objects/import/create_saved_objects.test.ts b/src/core/server/saved_objects/import/create_saved_objects.test.ts index f1118842c967..1a9e218f169d 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.test.ts @@ -115,6 +115,37 @@ const visualizationObj = { }, }, }; + +const getVegaVisualizationObj = (id: string) => ({ + type: 'visualization', + id, + attributes: { + title: 'some-title', + visState: + '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n }\\n }\\n}"}}', + }, + references: [], + namespaces: ['default'], + version: 'some-version', + updated_at: 'some-date', +}); + +const getVegaMDSVisualizationObj = (id: string, dataSourceId: string) => ({ + type: 'visualization', + id: dataSourceId ? `${dataSourceId}_${id}` : id, + attributes: { + title: 'some-other-title', + visState: + '{"title":"some-other-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: old-datasource-title\\n }\\n }\\n}"}}', + }, + references: [ + { + id: dataSourceId, + name: 'dataSource', + type: 'data-source', + }, + ], +}); // non-multi-namespace types shouldn't have origin IDs, but we include test cases to ensure it's handled gracefully // non-multi-namespace types by definition cannot result in an unresolvable conflict, so we don't include test cases for those const importId3 = 'id-foo'; @@ -142,8 +173,11 @@ describe('#createSavedObjects', () => { overwrite?: boolean; dataSourceId?: string; dataSourceTitle?: string; + savedObjectsCustomClient?: jest.Mocked; }): CreateSavedObjectsParams => { - savedObjectsClient = savedObjectsClientMock.create(); + savedObjectsClient = !!partial.savedObjectsCustomClient + ? partial.savedObjectsCustomClient + : savedObjectsClientMock.create(); bulkCreate = savedObjectsClient.bulkCreate; return { accumulatedErrors: [], ...partial, savedObjectsClient, importIdMap }; }; @@ -490,6 +524,29 @@ describe('#createSavedObjects', () => { expect(results).toEqual(expectedResultsWithDataSource); }; + const testVegaVisualizationsWithDataSources = async (params: { + objects: SavedObject[]; + expectedFilteredObjects: Array>; + dataSourceId?: string; + dataSourceTitle?: string; + }) => { + const savedObjectsCustomClient = savedObjectsClientMock.create(); + + const options = setupParams({ + ...params, + savedObjectsCustomClient, + }); + savedObjectsCustomClient.bulkCreate = jest.fn().mockResolvedValue({ + saved_objects: params.objects.map((obj) => { + return getResultMock.success(obj, options); + }), + }); + + const results = await createSavedObjects(options); + + expect(results.createdObjects).toMatchObject(params.expectedFilteredObjects); + }; + describe('with an undefined namespace', () => { test('calls bulkCreate once with input objects', async () => { await testBulkCreateObjects(); @@ -546,4 +603,61 @@ describe('#createSavedObjects', () => { ); }); }); + + describe('with a data source for Vega saved objects', () => { + test('can attach a data source name to the Vega spec if there is a local query', async () => { + const objects = [getVegaVisualizationObj('some-vega-id')]; + const expectedObject = getVegaVisualizationObj('some-vega-id'); + const expectedFilteredObjects = [ + { + ...expectedObject, + attributes: { + title: 'some-title_dataSourceName', + visState: + '{"title":"some-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: dataSourceName\\n }\\n }\\n}"}}', + }, + id: 'some-vega-id', + references: [ + { + id: 'some-datasource-id', + type: 'data-source', + name: 'dataSource', + }, + ], + }, + ]; + await testVegaVisualizationsWithDataSources({ + objects, + expectedFilteredObjects, + dataSourceId: 'some-datasource-id', + dataSourceTitle: 'dataSourceName', + }); + }); + + test('will not update the data source name in the Vega spec if no local cluster queries', async () => { + const objects = [getVegaMDSVisualizationObj('some-vega-id', 'old-datasource-id')]; + const expectedObject = getVegaMDSVisualizationObj('some-vega-id', 'old-datasource-id'); + expectedObject.references.push({ + id: 'some-datasource-id', + name: 'dataSource', + type: 'data-source', + }); + const expectedFilteredObjects = [ + { + ...expectedObject, + attributes: { + title: 'some-other-title_dataSourceName', + visState: + '{"title":"some-other-title","type":"vega","aggs":[],"params":{"spec":"{\\n data: {\\n url: {\\n index: example_index\\n data_source_name: old-datasource-title\\n }\\n }\\n}"}}', + }, + }, + ]; + await testVegaVisualizationsWithDataSources({ + objects, + expectedFilteredObjects, + dataSourceId: 'some-datasource-id', + dataSourceTitle: 'dataSourceName', + }); + }); + }); }); diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index 6b0015851baf..fa471d0d44d9 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -31,6 +31,7 @@ import { SavedObject, SavedObjectsClientContract, SavedObjectsImportError } from '../types'; import { extractErrors } from './extract_errors'; import { CreatedObject } from './types'; +import { extractVegaSpecFromSavedObject, updateDataSourceNameInVegaSpec } from './utils'; interface CreateSavedObjectsParams { objects: Array>; @@ -82,89 +83,116 @@ export const createSavedObjects = async ({ ); // filter out the 'version' field of each object, if it exists - - const objectsToCreate = filteredObjects.map(({ version, ...object }) => { - if (dataSourceId) { - // @ts-expect-error - if (dataSourceTitle && object.attributes.title) { - if ( - object.type === 'dashboard' || - object.type === 'visualization' || - object.type === 'search' - ) { - // @ts-expect-error - object.attributes.title = object.attributes.title + `_${dataSourceTitle}`; + const objectsToCreate = await Promise.all( + filteredObjects.map(({ version, ...object }) => { + if (dataSourceId) { + // @ts-expect-error + if (dataSourceTitle && object.attributes.title) { + if ( + object.type === 'dashboard' || + object.type === 'visualization' || + object.type === 'search' + ) { + // @ts-expect-error + object.attributes.title = object.attributes.title + `_${dataSourceTitle}`; + } } - } - if (object.type === 'index-pattern') { - object.references = [ - { - id: `${dataSourceId}`, - type: 'data-source', - name: 'dataSource', - }, - ]; - } + // Some visualization types will need special modifications, like Vega visualizations + if (object.type === 'visualization') { + const vegaSpec = extractVegaSpecFromSavedObject(object); - if (object.type === 'visualization' || object.type === 'search') { - // @ts-expect-error - const searchSourceString = object.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; - // @ts-expect-error - const visStateString = object.attributes?.visState; + if (!!vegaSpec && !!dataSourceTitle) { + const updatedVegaSpec = updateDataSourceNameInVegaSpec({ + spec: vegaSpec, + newDataSourceName: dataSourceTitle, + }); - if (searchSourceString) { - const searchSource = JSON.parse(searchSourceString); - if (searchSource.index) { - const searchSourceIndex = searchSource.index.includes('_') - ? searchSource.index.split('_')[searchSource.index.split('_').length - 1] - : searchSource.index; - searchSource.index = `${dataSourceId}_` + searchSourceIndex; + // @ts-expect-error + const visStateObject = JSON.parse(object.attributes?.visState); + visStateObject.params.spec = updatedVegaSpec; // @ts-expect-error - object.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify(searchSource); + object.attributes.visState = JSON.stringify(visStateObject); + object.references.push({ + id: `${dataSourceId}`, + type: 'data-source', + name: 'dataSource', + }); } } - if (visStateString) { - const visState = JSON.parse(visStateString); - const controlList = visState.params?.controls; - if (controlList) { + if (object.type === 'index-pattern') { + object.references = [ + { + id: `${dataSourceId}`, + type: 'data-source', + name: 'dataSource', + }, + ]; + } + + if (object.type === 'visualization' || object.type === 'search') { + // @ts-expect-error + const searchSourceString = object.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; + // @ts-expect-error + const visStateString = object.attributes?.visState; + + if (searchSourceString) { + const searchSource = JSON.parse(searchSourceString); + if (searchSource.index) { + const searchSourceIndex = searchSource.index.includes('_') + ? searchSource.index.split('_')[searchSource.index.split('_').length - 1] + : searchSource.index; + searchSource.index = `${dataSourceId}_` + searchSourceIndex; + + // @ts-expect-error + object.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( + searchSource + ); + } + } + + if (visStateString) { + const visState = JSON.parse(visStateString); + const controlList = visState.params?.controls; + if (controlList) { + // @ts-expect-error + controlList.map((control) => { + if (control.indexPattern) { + const controlIndexPattern = control.indexPattern.includes('_') + ? control.indexPattern.split('_')[control.indexPattern.split('_').length - 1] + : control.indexPattern; + control.indexPattern = `${dataSourceId}_` + controlIndexPattern; + } + }); + } // @ts-expect-error - controlList.map((control) => { - if (control.indexPattern) { - const controlIndexPattern = control.indexPattern.includes('_') - ? control.indexPattern.split('_')[control.indexPattern.split('_').length - 1] - : control.indexPattern; - control.indexPattern = `${dataSourceId}_` + controlIndexPattern; - } - }); + object.attributes.visState = JSON.stringify(visState); } - // @ts-expect-error - object.attributes.visState = JSON.stringify(visState); } } - } - // use the import ID map to ensure that each reference is being created with the correct ID - const references = object.references?.map((reference) => { - const { type, id } = reference; - const importIdEntry = importIdMap.get(`${type}:${id}`); + // use the import ID map to ensure that each reference is being created with the correct ID + const references = object.references?.map((reference) => { + const { type, id } = reference; + const importIdEntry = importIdMap.get(`${type}:${id}`); + if (importIdEntry?.id) { + return { ...reference, id: importIdEntry.id }; + } + return reference; + }); + // use the import ID map to ensure that each object is being created with the correct ID, also ensure that the `originId` is set on + // the created object if it did not have one (or is omitted if specified) + const importIdEntry = importIdMap.get(`${object.type}:${object.id}`); if (importIdEntry?.id) { - return { ...reference, id: importIdEntry.id }; + objectIdMap.set(`${object.type}:${importIdEntry.id}`, object); + const originId = importIdEntry.omitOriginId ? undefined : object.originId ?? object.id; + return { ...object, id: importIdEntry.id, originId, ...(references && { references }) }; } - return reference; - }); - // use the import ID map to ensure that each object is being created with the correct ID, also ensure that the `originId` is set on - // the created object if it did not have one (or is omitted if specified) - const importIdEntry = importIdMap.get(`${object.type}:${object.id}`); - if (importIdEntry?.id) { - objectIdMap.set(`${object.type}:${importIdEntry.id}`, object); - const originId = importIdEntry.omitOriginId ? undefined : object.originId ?? object.id; - return { ...object, id: importIdEntry.id, originId, ...(references && { references }) }; - } - return { ...object, ...(references && { references }) }; - }); + return { ...object, ...(references && { references }) }; + }) + ); const resolvableErrors = ['conflict', 'ambiguous_conflict', 'missing_references']; let expectedResults = objectsToCreate; if (!accumulatedErrors.some(({ error: { type } }) => resolvableErrors.includes(type))) { diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index 3dda6931bd1e..fff5b60c89cc 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -259,6 +259,7 @@ describe('#importSavedObjectsFromStream', () => { objects: collectedObjects, ignoreRegularConflicts: overwrite, dataSourceId: testDataSourceId, + savedObjectsClient, }; expect(checkConflictsForDataSource).toHaveBeenCalledWith(checkConflictsForDataSourceParams); }); diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index f2833c198e1b..e82b4e634e0f 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -119,6 +119,7 @@ export async function importSavedObjectsFromStream({ objects: checkConflictsResult.filteredObjects, ignoreRegularConflicts: overwrite, dataSourceId, + savedObjectsClient, }); checkOriginConflictsParams.objects = checkConflictsForDataSourceResult.filteredObjects; } diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.hjson b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.hjson new file mode 100644 index 000000000000..98e791db851d --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.hjson @@ -0,0 +1,147 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.json b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.json new file mode 100644 index 000000000000..8ec22019e828 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 800, + "height": 600, + "padding": 5, + "signals": [ + {"name": "mapType", "value": "topojson"} + ], + "data": [ + { + "name": "exampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_index_name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "urlData", + "url": "https://example.com/data.json", + "format": {"type": "json"} + }, + { + "name": "topojsonData", + "url": "https://example.com/map.topojson", + "format": {"type": "topojson", "feature": "your_feature_name"} + }, + { + "name": "geojsonData", + "url": "https://example.com/map.geojson", + "format": {"type": "json"} + } + ], + "projections": [ + { + "name": "projection", + "type": {"signal": "mapType"} + } + ], + "marks": [ + { + "type": "symbol", + "from": {"data": "exampleIndexSource"}, + "encode": { + "enter": { + "x": {"field": "_source.location.lon"}, + "y": {"field": "_source.location.lat"}, + "size": {"value": 50}, + "fill": {"value": "steelblue"}, + "stroke": {"value": "white"}, + "tooltip": {"signal": "datum._source.name"} + } + } + }, + { + "type": "symbol", + "from": {"data": "urlData"}, + "encode": { + "enter": { + "x": {"field": "longitude"}, + "y": {"field": "latitude"}, + "size": {"value": 50}, + "fill": {"value": "green"}, + "stroke": {"value": "white"}, + "tooltip": {"field": "name"} + } + } + }, + { + "type": "shape", + "from": {"data": "topojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightgray"} + } + } + }, + { + "type": "shape", + "from": {"data": "geojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightblue"} + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.hjson b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.hjson new file mode 100644 index 000000000000..6cf4dcb16db1 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.hjson @@ -0,0 +1,148 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + data_source_name: some datasource name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.json b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.json new file mode 100644 index 000000000000..41c14b079915 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_multiple_urls_mds.json @@ -0,0 +1,96 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 800, + "height": 600, + "padding": 5, + "signals": [ + {"name": "mapType", "value": "topojson"} + ], + "data": [ + { + "name": "exampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_index_name", + "data_source_name": "some datasource name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "urlData", + "url": "https://example.com/data.json", + "format": {"type": "json"} + }, + { + "name": "topojsonData", + "url": "https://example.com/map.topojson", + "format": {"type": "topojson", "feature": "your_feature_name"} + }, + { + "name": "geojsonData", + "url": "https://example.com/map.geojson", + "format": {"type": "json"} + } + ], + "projections": [ + { + "name": "projection", + "type": {"signal": "mapType"} + } + ], + "marks": [ + { + "type": "symbol", + "from": {"data": "exampleIndexSource"}, + "encode": { + "enter": { + "x": {"field": "_source.location.lon"}, + "y": {"field": "_source.location.lat"}, + "size": {"value": 50}, + "fill": {"value": "steelblue"}, + "stroke": {"value": "white"}, + "tooltip": {"signal": "datum._source.name"} + } + } + }, + { + "type": "symbol", + "from": {"data": "urlData"}, + "encode": { + "enter": { + "x": {"field": "longitude"}, + "y": {"field": "latitude"}, + "size": {"value": 50}, + "fill": {"value": "green"}, + "stroke": {"value": "white"}, + "tooltip": {"field": "name"} + } + } + }, + { + "type": "shape", + "from": {"data": "topojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightgray"} + } + } + }, + { + "type": "shape", + "from": {"data": "geojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightblue"} + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.hjson b/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.hjson new file mode 100644 index 000000000000..17f3f2e482ea --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.hjson @@ -0,0 +1,61 @@ +{ + $schema: https://vega.github.io/schema/vega-lite/v5.json + data: { + url: { + %context%: true + %timefield%: @timestamp + index: opensearch_dashboards_sample_data_logs + body: { + aggs: { + 1: { + terms: { + field: geo.dest + order: { + _count: desc + } + size: 5 + } + } + } + } + } + format: { + property: aggregations.1.buckets + } + } + transform: [ + { + calculate: datum.key + as: dest + } + { + calculate: datum.doc_count + as: count + } + ] + layer: [ + { + mark: { + type: bar + tooltip: true + } + } + ] + encoding: { + x: { + field: count + type: quantitative + axis: { + title: Count + } + } + y: { + field: dest + type: nominal + axis: { + title: Dest + } + sort: -x + } + } +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.json b/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.json new file mode 100644 index 000000000000..49392f5de16e --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_with_opensearch_query.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "data": { + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "opensearch_dashboards_sample_data_logs", + "body": { + "aggs": { + "1": { + "terms": { + "field": "geo.dest", + "order": {"_count": "desc"}, + "size": 5 + } + } + } + } + }, + "format": {"property": "aggregations.1.buckets"} + }, + "transform": [ + {"calculate": "datum.key", "as": "dest"}, + {"calculate": "datum.doc_count", "as": "count"} + ], + "layer": [{"mark": {"type": "bar", "tooltip": true}}], + "encoding": { + "x": {"field": "count", "type": "quantitative", "axis": {"title": "Count"}}, + "y": { + "field": "dest", + "type": "nominal", + "axis": {"title": "Dest"}, + "sort": "-x" + } + } +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.hjson b/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.hjson new file mode 100644 index 000000000000..8c4a0193ba97 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.hjson @@ -0,0 +1,117 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 400 + height: 200 + padding: 5 + // Data contained entirely within the spec + data: [ + { + name: table + values: [ + { + category: A + count: 28 + } + { + category: B + count: 55 + } + { + category: C + count: 43 + } + { + category: D + count: 91 + } + { + category: E + count: 81 + } + { + category: F + count: 53 + } + { + category: G + count: 19 + } + { + category: H + count: 87 + } + ] + } + ] + scales: [ + { + name: xscale + type: band + domain: { + data: table + field: category + } + range: width + padding: 0.05 + round: true + } + { + name: yscale + type: linear + domain: { + data: table + field: count + } + range: height + nice: true + } + ] + axes: [ + { + orient: bottom + scale: xscale + } + { + orient: left + scale: yscale + } + ] + marks: [ + { + type: rect + from: { + data: table + } + encode: { + enter: { + x: { + scale: xscale + field: category + } + width: { + scale: xscale + band: 1 + } + y: { + scale: yscale + field: count + } + y2: { + scale: yscale + value: 0 + } + } + update: { + fill: { + value: steelblue + } + } + hover: { + fill: { + value: red + } + } + } + } + ] +} diff --git a/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.json b/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.json new file mode 100644 index 000000000000..d24b9b207372 --- /dev/null +++ b/src/core/server/saved_objects/import/test_utils/vega_spec_without_opensearch_query.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 400, + "height": 200, + "padding": 5, + + "data": [ + { + "name": "table", + "values": [ + {"category": "A", "count": 28}, + {"category": "B", "count": 55}, + {"category": "C", "count": 43}, + {"category": "D", "count": 91}, + {"category": "E", "count": 81}, + {"category": "F", "count": 53}, + {"category": "G", "count": 19}, + {"category": "H", "count": 87} + ] + } + ], + + "scales": [ + { + "name": "xscale", + "type": "band", + "domain": {"data": "table", "field": "category"}, + "range": "width", + "padding": 0.05, + "round": true + }, + { + "name": "yscale", + "type": "linear", + "domain": {"data": "table", "field": "count"}, + "range": "height", + "nice": true + } + ], + + "axes": [ + { "orient": "bottom", "scale": "xscale" }, + { "orient": "left", "scale": "yscale" } + ], + + "marks": [ + { + "type": "rect", + "from": {"data": "table"}, + "encode": { + "enter": { + "x": {"scale": "xscale", "field": "category"}, + "width": {"scale": "xscale", "band": 1}, + "y": {"scale": "yscale", "field": "count"}, + "y2": {"scale": "yscale", "value": 0} + }, + "update": { + "fill": {"value": "steelblue"} + }, + "hover": { + "fill": {"value": "red"} + } + } + } + ] + } diff --git a/src/core/server/saved_objects/import/utils.test.ts b/src/core/server/saved_objects/import/utils.test.ts new file mode 100644 index 000000000000..604b6f6d473f --- /dev/null +++ b/src/core/server/saved_objects/import/utils.test.ts @@ -0,0 +1,247 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'fs'; +import { + extractVegaSpecFromSavedObject, + getDataSourceTitleFromId, + updateDataSourceNameInVegaSpec, +} from './utils'; +import { parse } from 'hjson'; +import { isEqual } from 'lodash'; +import { join } from 'path'; +import { SavedObject, SavedObjectsClientContract } from '../types'; + +describe('updateDataSourceNameInVegaSpec()', () => { + const loadHJSONStringFromFile = (filepath: string) => { + return readFileSync(join(__dirname, filepath)).toString(); + }; + + const loadJSONFromFile = (filepath: string) => { + return JSON.parse(readFileSync(join(__dirname, filepath)).toString()); + }; + + /* + JSON Test cases + */ + test('(JSON) When data has only one url body and it is an opensearch query, add data_source_name field to the spec', () => { + const openSearchQueryJSON = loadJSONFromFile( + './test_utils/vega_spec_with_opensearch_query.json' + ); + const jsonString = JSON.stringify(openSearchQueryJSON); + const modifiedString = JSON.parse( + updateDataSourceNameInVegaSpec({ spec: jsonString, newDataSourceName: 'newDataSource' }) + ); + + expect(modifiedString.data.url.hasOwnProperty('data_source_name')).toBe(true); + expect(modifiedString.data.url.data_source_name).toBe('newDataSource'); + + // These fields should be unchanged + Object.keys(openSearchQueryJSON).forEach((field) => { + if (field !== 'data') { + expect( + isEqual( + modifiedString[field as keyof typeof openSearchQueryJSON], + openSearchQueryJSON[field as keyof typeof openSearchQueryJSON] + ) + ).toBe(true); + } + }); + }); + + test('(JSON) When data has only one url body and it is not an opensearch query, change nothing', () => { + const nonOpenSearchQueryJSON = loadJSONFromFile( + './test_utils/vega_spec_without_opensearch_query.json' + ); + const jsonString = JSON.stringify(nonOpenSearchQueryJSON); + const modifiedJSON = JSON.parse( + updateDataSourceNameInVegaSpec({ spec: jsonString, newDataSourceName: 'noDataSource' }) + ); + expect(isEqual(modifiedJSON, nonOpenSearchQueryJSON)).toBe(true); + }); + + test('(JSON) When data has multiple url bodies, make sure only opensearch queries are updated with data_source_names', () => { + const multipleDataSourcesJSON = loadJSONFromFile( + './test_utils/vega_spec_with_multiple_urls.json' + ); + const jsonString = JSON.stringify(multipleDataSourcesJSON); + const modifiedString = JSON.parse( + updateDataSourceNameInVegaSpec({ spec: jsonString, newDataSourceName: 'newDataSource' }) + ); + + expect(modifiedString.data.length).toBe(multipleDataSourcesJSON.data.length); + for (let i = 0; i < modifiedString.data.length; i++) { + const originalUrlBody = multipleDataSourcesJSON.data[i]; + const urlBody = modifiedString.data[i]; + + if (urlBody.name !== 'exampleIndexSource') { + expect(isEqual(originalUrlBody, urlBody)).toBe(true); + } else { + expect(urlBody.url.hasOwnProperty('data_source_name')).toBe(true); + expect(urlBody.url.data_source_name).toBe('newDataSource'); + } + } + + // These fields should be unchanged + Object.keys(multipleDataSourcesJSON).forEach((field) => { + if (field !== 'data') { + expect( + isEqual( + modifiedString[field as keyof typeof multipleDataSourcesJSON], + multipleDataSourcesJSON[field as keyof typeof multipleDataSourcesJSON] + ) + ).toBe(true); + } + }); + }); + + test('(JSON) When an MDS object does not reference local queries, return the same spec', () => { + const multipleDataSourcesJSONMds = loadJSONFromFile( + './test_utils/vega_spec_with_multiple_urls_mds.json' + ); + const jsonString = JSON.stringify(multipleDataSourcesJSONMds); + const modifiedJSON = JSON.parse( + updateDataSourceNameInVegaSpec({ spec: jsonString, newDataSourceName: 'noDataSource' }) + ); + expect(isEqual(modifiedJSON, multipleDataSourcesJSONMds)).toBe(true); + }); + + /* + HJSON Test cases + */ + test('(HJSON) When data has only one url body and it is an opensearch query, add data_source_name field to the spec', () => { + const hjsonString = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_opensearch_query.hjson' + ); + + const originalHJSONParse = parse(hjsonString, { keepWsc: true }); + const hjsonParse = parse( + updateDataSourceNameInVegaSpec({ spec: hjsonString, newDataSourceName: 'newDataSource' }), + { + keepWsc: true, + } + ); + + expect(hjsonParse.data.url.hasOwnProperty('data_source_name')).toBe(true); + expect(hjsonParse.data.url.data_source_name).toBe('newDataSource'); + + // These fields should be unchanged + Object.keys(originalHJSONParse).forEach((field) => { + if (field !== 'data') { + expect( + isEqual( + originalHJSONParse[field as keyof typeof originalHJSONParse], + hjsonParse[field as keyof typeof originalHJSONParse] + ) + ).toBe(true); + } + }); + }); + + test('(HJSON) When data has only one url body and it is not an opensearch query, change nothing', () => { + const hjsonString = loadHJSONStringFromFile( + '/test_utils/vega_spec_without_opensearch_query.hjson' + ); + const originalHJSONParse = parse(hjsonString, { keepWsc: true }); + const hjsonParse = parse( + updateDataSourceNameInVegaSpec({ spec: hjsonString, newDataSourceName: 'noDataSource' }) + ); + + expect(isEqual(originalHJSONParse, hjsonParse)).toBe(true); + }); + + test('(HJSON) When data has multiple url bodies, make sure only opensearch queries are updated with data_source_names', () => { + const hjsonString = loadHJSONStringFromFile('/test_utils/vega_spec_with_multiple_urls.hjson'); + const originalHJSONParse = parse(hjsonString, { keepWsc: true }); + const hjsonParse = parse( + updateDataSourceNameInVegaSpec({ spec: hjsonString, newDataSourceName: 'newDataSource' }) + ); + + expect(hjsonParse.data.length).toBe(originalHJSONParse.data.length); + for (let i = 0; i < hjsonParse.data.length; i++) { + const originalUrlBody = originalHJSONParse.data[i]; + const urlBody = hjsonParse.data[i]; + + if (urlBody.name !== 'exampleIndexSource') { + expect(isEqual(originalUrlBody, urlBody)).toBe(true); + } else { + expect(urlBody.url.hasOwnProperty('data_source_name')).toBe(true); + expect(urlBody.url.data_source_name).toBe('newDataSource'); + } + } + + // These fields should be unchanged + Object.keys(originalHJSONParse).forEach((field) => { + if (field !== 'data') { + expect( + isEqual( + originalHJSONParse[field as keyof typeof originalHJSONParse], + hjsonParse[field as keyof typeof originalHJSONParse] + ) + ).toBe(true); + } + }); + }); + + test('(HJSON) When an MDS object does not reference local queries, return the same spec', () => { + const hjsonString = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_multiple_urls_mds.hjson' + ); + const originalHJSONParse = parse(hjsonString, { keepWsc: true }); + const hjsonParse = parse( + updateDataSourceNameInVegaSpec({ spec: hjsonString, newDataSourceName: 'newDataSource' }) + ); + + expect(isEqual(originalHJSONParse, hjsonParse)).toBe(true); + }); +}); + +describe('extractVegaSpecFromSavedObject()', () => { + test('For a Vega visualization saved object, return its spec', () => { + const spec = 'some-vega-spec'; + const vegaSavedObject = { + attributes: { + visState: `{"type": "vega", "params": {"spec": "${spec}"}}`, + }, + } as SavedObject; + + expect(extractVegaSpecFromSavedObject(vegaSavedObject)).toBe(spec); + }); + + test('For another saved object type, return undefined', () => { + const nonVegaSavedObject = { + attributes: { + visState: `{"type": "area", "params": {"spec": "some-spec"}}`, + }, + } as SavedObject; + + expect(extractVegaSpecFromSavedObject(nonVegaSavedObject)).toBe(undefined); + }); +}); + +describe('getDataSourceTitleFromId()', () => { + const savedObjectsClient = {} as SavedObjectsClientContract; + savedObjectsClient.get = jest.fn().mockImplementation((type, id) => { + if (type === 'data-source' && id === 'valid-id') { + return Promise.resolve({ + attributes: { + title: 'some-datasource-title', + }, + }); + } + + return Promise.resolve({}); + }); + + test('When a valid id is passed, return the correct title', async () => { + expect(await getDataSourceTitleFromId('valid-id', savedObjectsClient)).toBe( + 'some-datasource-title' + ); + }); + + test('When a nonexistent id is passed, return nothing', async () => { + expect(await getDataSourceTitleFromId('nonexistent-id', savedObjectsClient)).toBe(undefined); + }); +}); diff --git a/src/core/server/saved_objects/import/utils.ts b/src/core/server/saved_objects/import/utils.ts new file mode 100644 index 000000000000..9bb1d10cd0eb --- /dev/null +++ b/src/core/server/saved_objects/import/utils.ts @@ -0,0 +1,103 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parse, stringify } from 'hjson'; +import { SavedObject, SavedObjectsClientContract } from '../types'; + +export interface UpdateDataSourceNameInVegaSpecProps { + spec: string; + newDataSourceName: string; +} + +export const updateDataSourceNameInVegaSpec = ( + props: UpdateDataSourceNameInVegaSpecProps +): string => { + const { spec } = props; + + let parsedSpec = parseJSONSpec(spec); + const isJSONString = !!parsedSpec; + if (!parsedSpec) { + parsedSpec = parse(spec, { keepWsc: true }); + } + + const dataField = parsedSpec.data; + + if (dataField instanceof Array) { + parsedSpec.data = dataField.map((dataObject) => { + return updateDataSourceNameForDataObject(dataObject, props); + }); + } else if (dataField instanceof Object) { + parsedSpec.data = updateDataSourceNameForDataObject(dataField, props); + } else { + throw new Error(`"data" field should be an object or an array of objects`); + } + + return isJSONString + ? JSON.stringify(parsedSpec) + : stringify(parsedSpec, { + bracesSameLine: true, + keepWsc: true, + }); +}; + +export const getDataSourceTitleFromId = async ( + dataSourceId: string, + savedObjectsClient: SavedObjectsClientContract +) => { + return await savedObjectsClient.get('data-source', dataSourceId).then((response) => { + // @ts-expect-error + return response?.attributes?.title ?? undefined; + }); +}; + +export const extractVegaSpecFromSavedObject = (savedObject: SavedObject) => { + if (isVegaVisualization(savedObject)) { + // @ts-expect-error + const visStateObject = JSON.parse(savedObject.attributes?.visState); + return visStateObject.params.spec; + } + + return undefined; +}; + +const isVegaVisualization = (savedObject: SavedObject) => { + // @ts-expect-error + const visState = savedObject.attributes?.visState; + if (!!visState) { + const visStateObject = JSON.parse(visState); + return !!visStateObject.type && visStateObject.type === 'vega'; + } + return false; +}; + +const updateDataSourceNameForDataObject = ( + dataObject: any, + props: UpdateDataSourceNameInVegaSpecProps +) => { + const { newDataSourceName } = props; + if ( + dataObject.hasOwnProperty('url') && + dataObject.url.hasOwnProperty('index') && + !dataObject.url.hasOwnProperty('data_source_name') + ) { + dataObject.url.data_source_name = newDataSourceName; + } + + return dataObject; +}; + +const parseJSONSpec = (spec: string) => { + try { + const jsonSpec = JSON.parse(spec); + + if (jsonSpec && typeof jsonSpec === 'object') { + return jsonSpec; + } + } catch (e) { + return undefined; + } + + return undefined; +}; diff --git a/src/plugins/vis_type_vega/server/plugin.ts b/src/plugins/vis_type_vega/server/plugin.ts index cf3339211698..4451cb70a28f 100644 --- a/src/plugins/vis_type_vega/server/plugin.ts +++ b/src/plugins/vis_type_vega/server/plugin.ts @@ -36,6 +36,11 @@ import { VisTypeVegaPluginSetup, VisTypeVegaPluginStart, } from './types'; +import { + VEGA_VISUALIZATION_CLIENT_WRAPPER_ID, + vegaVisualizationClientWrapper, +} from './vega_visualization_client_wrapper'; +import { setDataSourceEnabled } from './services'; export class VisTypeVegaPlugin implements Plugin { private readonly config: ConfigObservable; @@ -44,10 +49,19 @@ export class VisTypeVegaPlugin implements Plugin('DataSource'); diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_outdated_references_mds.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_outdated_references_mds.hjson new file mode 100644 index 000000000000..5b23c66e67fb --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_outdated_references_mds.hjson @@ -0,0 +1,223 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: localExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: local_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: otherExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_other_index_name + data_source_name: a-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + data_source_name: b-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSourceC + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name_c + data_source_name: c-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSourceD + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name_d + data_source_name: d-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_up_to_date_urls_mds.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_up_to_date_urls_mds.hjson new file mode 100644 index 000000000000..8336fe9ac7de --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_up_to_date_urls_mds.hjson @@ -0,0 +1,185 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: localExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: local_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: otherExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_other_index_name + data_source_name: a-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + data_source_name: b-title + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.hjson new file mode 100644 index 000000000000..d8085c5923f3 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.hjson @@ -0,0 +1,165 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: otherExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_other_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.json new file mode 100644 index 000000000000..440fc26784e8 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls.json @@ -0,0 +1,110 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 800, + "height": 600, + "padding": 5, + "signals": [ + {"name": "mapType", "value": "topojson"} + ], + "data": [ + { + "name": "otherExampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_other_index_name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "exampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_index_name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "urlData", + "url": "https://example.com/data.json", + "format": {"type": "json"} + }, + { + "name": "topojsonData", + "url": "https://example.com/map.topojson", + "format": {"type": "topojson", "feature": "your_feature_name"} + }, + { + "name": "geojsonData", + "url": "https://example.com/map.geojson", + "format": {"type": "json"} + } + ], + "projections": [ + { + "name": "projection", + "type": {"signal": "mapType"} + } + ], + "marks": [ + { + "type": "symbol", + "from": {"data": "exampleIndexSource"}, + "encode": { + "enter": { + "x": {"field": "_source.location.lon"}, + "y": {"field": "_source.location.lat"}, + "size": {"value": 50}, + "fill": {"value": "steelblue"}, + "stroke": {"value": "white"}, + "tooltip": {"signal": "datum._source.name"} + } + } + }, + { + "type": "symbol", + "from": {"data": "urlData"}, + "encode": { + "enter": { + "x": {"field": "longitude"}, + "y": {"field": "latitude"}, + "size": {"value": 50}, + "fill": {"value": "green"}, + "stroke": {"value": "white"}, + "tooltip": {"field": "name"} + } + } + }, + { + "type": "shape", + "from": {"data": "topojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightgray"} + } + } + }, + { + "type": "shape", + "from": {"data": "geojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightblue"} + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.hjson new file mode 100644 index 000000000000..b92cdfca9886 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.hjson @@ -0,0 +1,185 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 800 + height: 600 + padding: 5 + signals: [ + { + name: mapType + value: topojson + } + ] + // Every data source type that Dashboards supports + data: [ + { + name: localExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: local_index_name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: otherExampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_other_index_name + data_source_name: some other datasource name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: exampleIndexSource + url: { + %context%: true + %timefield%: @timestamp + index: your_index_name + data_source_name: some datasource name + body: { + size: 1000 + query: { + match_all: { + } + } + } + } + format: { + property: hits.hits + } + } + { + name: urlData + url: https://example.com/data.json + format: { + type: json + } + } + { + name: topojsonData + url: https://example.com/map.topojson + format: { + type: topojson + feature: your_feature_name + } + } + { + name: geojsonData + url: https://example.com/map.geojson + format: { + type: json + } + } + ] + projections: [ + { + name: projection + type: { + signal: mapType + } + } + ] + marks: [ + { + type: symbol + from: { + data: exampleIndexSource + } + encode: { + enter: { + x: { + field: _source.location.lon + } + y: { + field: _source.location.lat + } + size: { + value: 50 + } + fill: { + value: steelblue + } + stroke: { + value: white + } + tooltip: { + signal: datum._source.name + } + } + } + } + { + type: symbol + from: { + data: urlData + } + encode: { + enter: { + x: { + field: longitude + } + y: { + field: latitude + } + size: { + value: 50 + } + fill: { + value: green + } + stroke: { + value: white + } + tooltip: { + field: name + } + } + } + } + { + type: shape + from: { + data: topojsonData + } + encode: { + enter: { + fill: { + value: lightgray + } + } + } + } + { + type: shape + from: { + data: geojsonData + } + encode: { + enter: { + fill: { + value: lightblue + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.json new file mode 100644 index 000000000000..3e883388bc5c --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_multiple_urls_mds.json @@ -0,0 +1,127 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 800, + "height": 600, + "padding": 5, + "signals": [ + {"name": "mapType", "value": "topojson"} + ], + "data": [ + { + "name": "localExampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "local_index_name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "otherExampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_other_index_name", + "data_source_name": "some other datasource name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "exampleIndexSource", + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "your_index_name", + "data_source_name": "some datasource name", + "body": { + "size": 1000, + "query": { + "match_all": {} + } + } + }, + "format": {"property": "hits.hits"} + }, + { + "name": "urlData", + "url": "https://example.com/data.json", + "format": {"type": "json"} + }, + { + "name": "topojsonData", + "url": "https://example.com/map.topojson", + "format": {"type": "topojson", "feature": "your_feature_name"} + }, + { + "name": "geojsonData", + "url": "https://example.com/map.geojson", + "format": {"type": "json"} + } + ], + "projections": [ + { + "name": "projection", + "type": {"signal": "mapType"} + } + ], + "marks": [ + { + "type": "symbol", + "from": {"data": "exampleIndexSource"}, + "encode": { + "enter": { + "x": {"field": "_source.location.lon"}, + "y": {"field": "_source.location.lat"}, + "size": {"value": 50}, + "fill": {"value": "steelblue"}, + "stroke": {"value": "white"}, + "tooltip": {"signal": "datum._source.name"} + } + } + }, + { + "type": "symbol", + "from": {"data": "urlData"}, + "encode": { + "enter": { + "x": {"field": "longitude"}, + "y": {"field": "latitude"}, + "size": {"value": 50}, + "fill": {"value": "green"}, + "stroke": {"value": "white"}, + "tooltip": {"field": "name"} + } + } + }, + { + "type": "shape", + "from": {"data": "topojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightgray"} + } + } + }, + { + "type": "shape", + "from": {"data": "geojsonData"}, + "encode": { + "enter": { + "fill": {"value": "lightblue"} + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.hjson new file mode 100644 index 000000000000..17f3f2e482ea --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.hjson @@ -0,0 +1,61 @@ +{ + $schema: https://vega.github.io/schema/vega-lite/v5.json + data: { + url: { + %context%: true + %timefield%: @timestamp + index: opensearch_dashboards_sample_data_logs + body: { + aggs: { + 1: { + terms: { + field: geo.dest + order: { + _count: desc + } + size: 5 + } + } + } + } + } + format: { + property: aggregations.1.buckets + } + } + transform: [ + { + calculate: datum.key + as: dest + } + { + calculate: datum.doc_count + as: count + } + ] + layer: [ + { + mark: { + type: bar + tooltip: true + } + } + ] + encoding: { + x: { + field: count + type: quantitative + axis: { + title: Count + } + } + y: { + field: dest + type: nominal + axis: { + title: Dest + } + sort: -x + } + } +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.json new file mode 100644 index 000000000000..49392f5de16e --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query.json @@ -0,0 +1,36 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "data": { + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "opensearch_dashboards_sample_data_logs", + "body": { + "aggs": { + "1": { + "terms": { + "field": "geo.dest", + "order": {"_count": "desc"}, + "size": 5 + } + } + } + } + }, + "format": {"property": "aggregations.1.buckets"} + }, + "transform": [ + {"calculate": "datum.key", "as": "dest"}, + {"calculate": "datum.doc_count", "as": "count"} + ], + "layer": [{"mark": {"type": "bar", "tooltip": true}}], + "encoding": { + "x": {"field": "count", "type": "quantitative", "axis": {"title": "Count"}}, + "y": { + "field": "dest", + "type": "nominal", + "axis": {"title": "Dest"}, + "sort": "-x" + } + } +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.hjson new file mode 100644 index 000000000000..7f307e84b0af --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.hjson @@ -0,0 +1,62 @@ +{ + $schema: https://vega.github.io/schema/vega-lite/v5.json + data: { + url: { + %context%: true + %timefield%: @timestamp + index: opensearch_dashboards_sample_data_logs + data_source_name: example data source + body: { + aggs: { + 1: { + terms: { + field: geo.dest + order: { + _count: desc + } + size: 5 + } + } + } + } + } + format: { + property: aggregations.1.buckets + } + } + transform: [ + { + calculate: datum.key + as: dest + } + { + calculate: datum.doc_count + as: count + } + ] + layer: [ + { + mark: { + type: bar + tooltip: true + } + } + ] + encoding: { + x: { + field: count + type: quantitative + axis: { + title: Count + } + } + y: { + field: dest + type: nominal + axis: { + title: Dest + } + sort: -x + } + } +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.json new file mode 100644 index 000000000000..7b90845be17d --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_with_opensearch_query_mds.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://vega.github.io/schema/vega-lite/v5.json", + "data": { + "url": { + "%context%": true, + "%timefield%": "@timestamp", + "index": "opensearch_dashboards_sample_data_logs", + "data_source_name": "example data source", + "body": { + "aggs": { + "1": { + "terms": { + "field": "geo.dest", + "order": {"_count": "desc"}, + "size": 5 + } + } + } + } + }, + "format": {"property": "aggregations.1.buckets"} + }, + "transform": [ + {"calculate": "datum.key", "as": "dest"}, + {"calculate": "datum.doc_count", "as": "count"} + ], + "layer": [{"mark": {"type": "bar", "tooltip": true}}], + "encoding": { + "x": {"field": "count", "type": "quantitative", "axis": {"title": "Count"}}, + "y": { + "field": "dest", + "type": "nominal", + "axis": {"title": "Dest"}, + "sort": "-x" + } + } +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.hjson b/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.hjson new file mode 100644 index 000000000000..8c4a0193ba97 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.hjson @@ -0,0 +1,117 @@ +{ + $schema: https://vega.github.io/schema/vega/v5.json + width: 400 + height: 200 + padding: 5 + // Data contained entirely within the spec + data: [ + { + name: table + values: [ + { + category: A + count: 28 + } + { + category: B + count: 55 + } + { + category: C + count: 43 + } + { + category: D + count: 91 + } + { + category: E + count: 81 + } + { + category: F + count: 53 + } + { + category: G + count: 19 + } + { + category: H + count: 87 + } + ] + } + ] + scales: [ + { + name: xscale + type: band + domain: { + data: table + field: category + } + range: width + padding: 0.05 + round: true + } + { + name: yscale + type: linear + domain: { + data: table + field: count + } + range: height + nice: true + } + ] + axes: [ + { + orient: bottom + scale: xscale + } + { + orient: left + scale: yscale + } + ] + marks: [ + { + type: rect + from: { + data: table + } + encode: { + enter: { + x: { + scale: xscale + field: category + } + width: { + scale: xscale + band: 1 + } + y: { + scale: yscale + field: count + } + y2: { + scale: yscale + value: 0 + } + } + update: { + fill: { + value: steelblue + } + } + hover: { + fill: { + value: red + } + } + } + } + ] +} diff --git a/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.json b/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.json new file mode 100644 index 000000000000..d24b9b207372 --- /dev/null +++ b/src/plugins/vis_type_vega/server/test_utils/vega_spec_without_opensearch_query.json @@ -0,0 +1,66 @@ +{ + "$schema": "https://vega.github.io/schema/vega/v5.json", + "width": 400, + "height": 200, + "padding": 5, + + "data": [ + { + "name": "table", + "values": [ + {"category": "A", "count": 28}, + {"category": "B", "count": 55}, + {"category": "C", "count": 43}, + {"category": "D", "count": 91}, + {"category": "E", "count": 81}, + {"category": "F", "count": 53}, + {"category": "G", "count": 19}, + {"category": "H", "count": 87} + ] + } + ], + + "scales": [ + { + "name": "xscale", + "type": "band", + "domain": {"data": "table", "field": "category"}, + "range": "width", + "padding": 0.05, + "round": true + }, + { + "name": "yscale", + "type": "linear", + "domain": {"data": "table", "field": "count"}, + "range": "height", + "nice": true + } + ], + + "axes": [ + { "orient": "bottom", "scale": "xscale" }, + { "orient": "left", "scale": "yscale" } + ], + + "marks": [ + { + "type": "rect", + "from": {"data": "table"}, + "encode": { + "enter": { + "x": {"scale": "xscale", "field": "category"}, + "width": {"scale": "xscale", "band": 1}, + "y": {"scale": "yscale", "field": "count"}, + "y2": {"scale": "yscale", "value": 0} + }, + "update": { + "fill": {"value": "steelblue"} + }, + "hover": { + "fill": {"value": "red"} + } + } + } + ] + } diff --git a/src/plugins/vis_type_vega/server/types.ts b/src/plugins/vis_type_vega/server/types.ts index 9695f6dc23d7..bcf4120577aa 100644 --- a/src/plugins/vis_type_vega/server/types.ts +++ b/src/plugins/vis_type_vega/server/types.ts @@ -29,6 +29,7 @@ */ import { Observable } from 'rxjs'; +import { DataSourcePluginSetup } from 'src/plugins/data_source/server'; import { HomeServerPluginSetup } from '../../home/server'; import { UsageCollectionSetup } from '../../usage_collection/server'; @@ -45,6 +46,7 @@ export interface VegaSavedObjectAttributes { export interface VisTypeVegaPluginSetupDependencies { usageCollection?: UsageCollectionSetup; home?: HomeServerPluginSetup; + dataSource?: DataSourcePluginSetup; } // eslint-disable-next-line @typescript-eslint/no-empty-interface diff --git a/src/plugins/vis_type_vega/server/utils.test.ts b/src/plugins/vis_type_vega/server/utils.test.ts new file mode 100644 index 000000000000..73d0f82954cb --- /dev/null +++ b/src/plugins/vis_type_vega/server/utils.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + extractDataSourceNamesInVegaSpec, + extractVegaSpecFromAttributes, + findDataSourceIdbyName, +} from './utils'; +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { SavedObjectsClientContract, SavedObjectsFindOptions } from 'src/core/server'; + +describe('findDataSourceIdbyName()', () => { + const savedObjectsClient = {} as SavedObjectsClientContract; + savedObjectsClient.find = jest.fn().mockImplementation((query: SavedObjectsFindOptions) => { + if (query.search === `"uniqueDataSource"`) { + return Promise.resolve({ + total: 1, + saved_objects: [{ id: 'some-datasource-id', attributes: { title: 'uniqueDataSource' } }], + }); + } else if (query.search === `"duplicateDataSource"`) { + return Promise.resolve({ + total: 2, + saved_objects: [ + { id: 'some-datasource-id', attributes: { title: 'duplicateDataSource' } }, + { id: 'some-other-datasource-id', attributes: { title: 'duplicateDataSource' } }, + ], + }); + } else if (query.search === `"DataSource"`) { + return Promise.resolve({ + total: 2, + saved_objects: [ + { id: 'some-datasource-id', attributes: { title: 'DataSource' } }, + { id: 'some-other-datasource-id', attributes: { title: 'DataSource Copy' } }, + ], + }); + } else { + return Promise.resolve({ + total: 0, + saved_objects: [], + }); + } + }); + + test('If no matching dataSourceName, then throw error', () => { + expect( + findDataSourceIdbyName({ dataSourceName: 'nonexistentDataSource', savedObjectsClient }) + ).rejects.toThrowError( + 'Expected exactly 1 result for data_source_name "nonexistentDataSource" but got 0 results' + ); + }); + + test('If duplicate dataSourceNames, then throw error', () => { + expect( + findDataSourceIdbyName({ dataSourceName: 'duplicateDataSource', savedObjectsClient }) + ).rejects.toThrowError( + 'Expected exactly 1 result for data_source_name "duplicateDataSource" but got 2 results' + ); + }); + + test('If dataSource is enabled but only one dataSourceName, then return id', async () => { + expect( + await findDataSourceIdbyName({ dataSourceName: 'uniqueDataSource', savedObjectsClient }) + ).toBe('some-datasource-id'); + }); + + test('If dataSource is enabled and the dataSourceName is a prefix of another, ensure the prefix is only returned', async () => { + expect(await findDataSourceIdbyName({ dataSourceName: 'DataSource', savedObjectsClient })).toBe( + 'some-datasource-id' + ); + }); +}); + +describe('extractDataSourceNamesInVegaSpec()', () => { + const loadHJSONStringFromFile = (filepath: string) => { + return readFileSync(join(__dirname, filepath)).toString(); + }; + + const loadJSONFromFile = (filepath: string) => { + return JSON.parse(readFileSync(join(__dirname, filepath)).toString()); + }; + + // JSON test cases + test('(JSON) Set should be empty when no queries are in the Vega spec', () => { + const noQueryJSON = loadJSONFromFile('/test_utils/vega_spec_without_opensearch_query.json'); + expect(extractDataSourceNamesInVegaSpec(JSON.stringify(noQueryJSON))).toMatchObject(new Set()); + }); + + test('(JSON) Set should be empty when one local cluster query is in the Vega spec', () => { + const oneLocalQueryJSON = loadJSONFromFile('/test_utils/vega_spec_with_opensearch_query.json'); + expect(extractDataSourceNamesInVegaSpec(JSON.stringify(oneLocalQueryJSON))).toMatchObject( + new Set() + ); + }); + + test('(JSON) Set should have exactly one data_source_name when one data source query is in the Vega spec', () => { + const oneDataSourceQueryJSON = loadJSONFromFile( + '/test_utils/vega_spec_with_opensearch_query_mds.json' + ); + expect(extractDataSourceNamesInVegaSpec(JSON.stringify(oneDataSourceQueryJSON))).toMatchObject( + new Set(['example data source']) + ); + }); + + test('(JSON) Set should be empty when many local cluster queries are in the Vega spec', () => { + const manyLocalQueriesJSON = loadJSONFromFile('/test_utils/vega_spec_with_multiple_urls.json'); + expect(extractDataSourceNamesInVegaSpec(JSON.stringify(manyLocalQueriesJSON))).toMatchObject( + new Set() + ); + }); + + test('(JSON) Set have multiple data_source_name fields when the Vega spec has a mix of local cluster and data source queries', () => { + const manyDataSourceQueriesJSON = loadJSONFromFile( + '/test_utils/vega_spec_with_multiple_urls_mds.json' + ); + expect( + extractDataSourceNamesInVegaSpec(JSON.stringify(manyDataSourceQueriesJSON)) + ).toMatchObject(new Set(['some other datasource name', 'some datasource name'])); + }); + + // HJSON test cases + test('(HJSON) Set should be empty when no queries are in the Vega spec', () => { + const noQueryHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_without_opensearch_query.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(noQueryHJSON)).toMatchObject(new Set()); + }); + + test('(HJSON) Set should be empty when one local cluster query is in the Vega spec', () => { + const oneLocalQueryHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_opensearch_query.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(oneLocalQueryHJSON)).toMatchObject(new Set()); + }); + + test('(HJSON) Set should have exactly one data_source_name when one data source query is in the Vega spec', () => { + const oneDataSourceQueryHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_opensearch_query_mds.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(oneDataSourceQueryHJSON)).toMatchObject( + new Set(['example data source']) + ); + }); + + test('(HJSON) Set should be empty when many local cluster queries are in the Vega spec', () => { + const manyLocalQueriesHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_multiple_urls.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(manyLocalQueriesHJSON)).toMatchObject(new Set()); + }); + + test('(HJSON) Set have multiple data_source_name fields when the Vega spec has a mix of local cluster and data source queries', () => { + const manyDataSourceQueriesHJSON = loadHJSONStringFromFile( + '/test_utils/vega_spec_with_multiple_urls_mds.hjson' + ); + expect(extractDataSourceNamesInVegaSpec(manyDataSourceQueriesHJSON)).toMatchObject( + new Set(['some other datasource name', 'some datasource name']) + ); + }); +}); + +describe('extractVegaSpecFromSavedObject()', () => { + test('For a Vega visualization saved object, return its spec', () => { + const spec = 'some-vega-spec'; + const vegaAttributes = { + visState: `{"type": "vega", "params": {"spec": "${spec}"}}`, + }; + + expect(extractVegaSpecFromAttributes(vegaAttributes)).toBe(spec); + }); + + test('For another saved object type, return undefined', () => { + const nonVegaAttributes = { + visState: `{"type": "area", "params": {"spec": "some-spec"}}`, + }; + + expect(extractVegaSpecFromAttributes(nonVegaAttributes)).toBe(undefined); + }); +}); diff --git a/src/plugins/vis_type_vega/server/utils.ts b/src/plugins/vis_type_vega/server/utils.ts new file mode 100644 index 000000000000..f8c83dce531e --- /dev/null +++ b/src/plugins/vis_type_vega/server/utils.ts @@ -0,0 +1,98 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { parse } from 'hjson'; +import { SavedObjectsClientContract } from 'src/core/server'; +import { DataSourceAttributes } from 'src/plugins/data_source/common/data_sources'; + +export interface FindDataSourceByTitleQueryProps { + dataSourceName: string; + savedObjectsClient: SavedObjectsClientContract; +} + +export const findDataSourceIdbyName = async (props: FindDataSourceByTitleQueryProps) => { + const { dataSourceName } = props; + const dataSources = await dataSourceFindQuery(props); + + // In the case that data_source_name is a prefix of another name, match exact data_source_name + const possibleDataSourceObjects = dataSources.saved_objects.filter( + (obj) => obj.attributes.title === dataSourceName + ); + + if (possibleDataSourceObjects.length !== 1) { + throw new Error( + `Expected exactly 1 result for data_source_name "${dataSourceName}" but got ${possibleDataSourceObjects.length} results` + ); + } + + return possibleDataSourceObjects.pop()?.id; +}; + +export const extractVegaSpecFromAttributes = (attributes: unknown) => { + if (isVegaVisualization(attributes)) { + // @ts-expect-error + const visStateObject = JSON.parse(attributes?.visState); + return visStateObject.params.spec; + } + + return undefined; +}; + +export const extractDataSourceNamesInVegaSpec = (spec: string) => { + const parsedSpec = parse(spec, { keepWsc: true }); + const dataField = parsedSpec.data; + const dataSourceNameSet = new Set(); + + if (dataField instanceof Array) { + dataField.forEach((dataObject) => { + const dataSourceName = getDataSourceNameFromObject(dataObject); + if (!!dataSourceName) { + dataSourceNameSet.add(dataSourceName); + } + }); + } else if (dataField instanceof Object) { + const dataSourceName = getDataSourceNameFromObject(dataField); + if (!!dataSourceName) { + dataSourceNameSet.add(dataSourceName); + } + } else { + throw new Error(`"data" field should be an object or an array of objects`); + } + + return dataSourceNameSet; +}; + +const getDataSourceNameFromObject = (dataObject: any) => { + if ( + dataObject.hasOwnProperty('url') && + dataObject.url.hasOwnProperty('index') && + dataObject.url.hasOwnProperty('data_source_name') + ) { + return dataObject.url.data_source_name; + } + + return undefined; +}; + +const isVegaVisualization = (attributes: unknown) => { + // @ts-expect-error + const visState = attributes?.visState; + if (!!visState) { + const visStateObject = JSON.parse(visState); + return !!visStateObject.type && visStateObject.type === 'vega'; + } + return false; +}; + +const dataSourceFindQuery = async (props: FindDataSourceByTitleQueryProps) => { + const { savedObjectsClient, dataSourceName } = props; + return await savedObjectsClient.find({ + type: 'data-source', + perPage: 10, + search: `"${dataSourceName}"`, + searchFields: ['title'], + fields: ['id', 'title'], + }); +}; diff --git a/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.test.ts b/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.test.ts new file mode 100644 index 000000000000..09af5459cf1d --- /dev/null +++ b/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.test.ts @@ -0,0 +1,243 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'fs'; +import { join } from 'path'; +import { SavedObjectsClientWrapperOptions, SavedObjectsFindOptions } from 'src/core/server'; +import { savedObjectsClientMock } from '../../../core/server/mocks'; +import { vegaVisualizationClientWrapper } from './vega_visualization_client_wrapper'; + +jest.mock('./services', () => ({ + getDataSourceEnabled: jest + .fn() + .mockReturnValueOnce({ enabled: false }) + .mockReturnValue({ enabled: true }), +})); + +describe('vegaVisualizationClientWrapper()', () => { + const loadHJSONStringFromFile = (filepath: string) => { + return readFileSync(join(__dirname, filepath)).toString(); + }; + + const getAttributesGivenSpec = (spec: string) => { + return { + title: 'Some Spec', + visState: JSON.stringify({ + title: 'Some Spec', + type: 'vega', + aggs: [], + params: { + spec, + }, + }), + }; + }; + + const client = savedObjectsClientMock.create(); + client.bulkGet = jest + .fn() + .mockImplementation((dataSourceIds: Array<{ id: string; type: string }>) => { + return Promise.resolve({ + saved_objects: dataSourceIds.map((request) => { + if (request.type === 'data-source' && request.id === 'id-a') { + return { + id: 'id-a', + attributes: { + title: 'a-title', + }, + }; + } else if (request.type === 'data-source' && request.id === 'id-b') { + return { + id: 'id-b', + attributes: { + title: 'b-title', + }, + }; + } else if (request.type === 'data-source' && request.id === 'id-z') { + return { + id: 'id-z', + attributes: { + title: 'z-title', + }, + }; + } + + return { + id: request.id, + attributes: undefined, + }; + }), + }); + }); + client.find = jest.fn().mockImplementation((query: SavedObjectsFindOptions) => { + if (query.search === `"c-title"`) { + return Promise.resolve({ + total: 1, + saved_objects: [{ id: 'id-c', attributes: { title: 'c-title' } }], + }); + } else if (query.search === `"d-title"`) { + return Promise.resolve({ + total: 1, + saved_objects: [{ id: 'id-d', attributes: { title: 'd-title' } }], + }); + } else { + return Promise.resolve({ + total: 0, + saved_objects: [], + }); + } + }); + const mockedWrapperOptions = {} as SavedObjectsClientWrapperOptions; + mockedWrapperOptions.client = client; + + beforeEach(() => { + client.create.mockClear(); + }); + + test('Should just call create as usual if MDS is disabled', async () => { + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', {}, { references: [] }); + expect(client.create).toBeCalledWith( + 'visualization', + {}, + expect.objectContaining({ references: [] }) + ); + }); + + test('Should just call create as usual if object type is not visualization type', async () => { + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('dashboard', {}, { references: [] }); + expect(client.create).toBeCalledWith( + 'dashboard', + {}, + expect.objectContaining({ references: [] }) + ); + }); + + test('Should just call create as usual if object type is not vega type', async () => { + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + // Avoids whitespacing issues by letting stringify format the string + const visState = JSON.stringify( + JSON.parse('{"type": "area", "params": {"spec": "no-spec-here"}}') + ); + await wrapper.create('visualization', { visState }, { references: [] }); + expect(client.create).toBeCalledWith( + 'visualization', + { visState }, + expect.objectContaining({ references: [] }) + ); + }); + + test('Should not update anything if the spec does not specify any data_source_name', async () => { + const spec = loadHJSONStringFromFile('/test_utils/vega_spec_with_multiple_urls.hjson'); + const attributes = getAttributesGivenSpec(spec); + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references: [] }); + expect(client.create).toBeCalledWith( + 'visualization', + attributes, + expect.objectContaining({ references: [] }) + ); + }); + + test('Should not update anything if the references is still up-to-date', async () => { + const spec = loadHJSONStringFromFile('/test_utils/vega_spec_up_to_date_urls_mds.hjson'); + const attributes = getAttributesGivenSpec(spec); + const references = [ + { + id: 'id-a', + type: 'data-source', + name: 'dataSource', + }, + { + id: 'id-b', + type: 'data-source', + name: 'dataSource', + }, + ]; + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references }); + expect(client.create).toBeCalledWith( + 'visualization', + attributes, + expect.objectContaining({ references }) + ); + }); + + test('Should throw an error if the Vega spec has invalid data_source_name field(s)', () => { + const spec = loadHJSONStringFromFile('/test_utils/vega_spec_with_multiple_urls_mds.hjson'); + const visState = { + title: 'Some Spec', + type: 'vega', + aggs: [], + params: { + spec, + }, + }; + const attributes = { + title: 'Some Spec', + visState: JSON.stringify(visState), + }; + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + expect(wrapper.create('visualization', attributes, { references: [] })).rejects.toThrowError( + `Expected exactly 1 result for data_source_name` + ); + }); + + test('Should update only the references section', async () => { + const spec = loadHJSONStringFromFile('/test_utils/vega_outdated_references_mds.hjson'); + const attributes = getAttributesGivenSpec(spec); + const commonReferences = [ + { + id: 'some-dashboard', + type: 'dashboard', + name: 'someDashboard', + }, + { + id: 'id-a', + type: 'data-source', + name: 'dataSource', + }, + { + id: 'id-b', + type: 'data-source', + name: 'dataSource', + }, + ]; + const oldReferences = [ + ...commonReferences, + { + id: 'id-z', + type: 'data-source', + name: 'dataSource', + }, + { + id: 'non-existent-id', + type: 'data-source', + name: 'dataSource', + }, + ]; + const newReferences = [ + ...commonReferences, + { + id: 'id-c', + type: 'data-source', + name: 'dataSource', + }, + { + id: 'id-d', + type: 'data-source', + name: 'dataSource', + }, + ]; + const wrapper = vegaVisualizationClientWrapper(mockedWrapperOptions); + await wrapper.create('visualization', attributes, { references: oldReferences }); + expect(client.create).toBeCalledWith( + 'visualization', + attributes, + expect.objectContaining({ references: newReferences }) + ); + }); +}); diff --git a/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.ts b/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.ts new file mode 100644 index 000000000000..4deada346c38 --- /dev/null +++ b/src/plugins/vis_type_vega/server/vega_visualization_client_wrapper.ts @@ -0,0 +1,110 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + SavedObjectsClientWrapperFactory, + SavedObjectsClientWrapperOptions, + SavedObjectsCreateOptions, + SavedObjectsErrorHelpers, +} from '../../../core/server'; +import { + extractDataSourceNamesInVegaSpec, + extractVegaSpecFromAttributes, + findDataSourceIdbyName, +} from './utils'; +import { getDataSourceEnabled } from './services'; + +export const VEGA_VISUALIZATION_CLIENT_WRAPPER_ID = 'vega-visualization-client-wrapper'; + +export const vegaVisualizationClientWrapper: SavedObjectsClientWrapperFactory = ( + wrapperOptions: SavedObjectsClientWrapperOptions +) => { + const createForVega = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + const vegaSpec = extractVegaSpecFromAttributes(attributes); + if (type !== 'visualization' || vegaSpec === undefined || !getDataSourceEnabled().enabled) { + return await wrapperOptions.client.create(type, attributes, options); + } + const dataSourceNamesSet = extractDataSourceNamesInVegaSpec(vegaSpec); + + const existingDataSourceReferences = options?.references + ?.filter((reference) => reference.type === 'data-source') + .map((dataSourceReference) => { + return { + id: dataSourceReference.id, + type: dataSourceReference.type, + }; + }); + + const existingDataSourceIdToNameMap = new Map(); + if (!!existingDataSourceReferences && existingDataSourceReferences.length > 0) { + (await wrapperOptions.client.bulkGet(existingDataSourceReferences)).saved_objects.forEach( + (object) => { + // @ts-expect-error + if (!!object.attributes && !!object.attributes.title) { + // @ts-expect-error + existingDataSourceIdToNameMap.set(object.id, object.attributes.title); + } + } + ); + } + + // Filters out outdated datasource references + const newReferences = options?.references?.filter((reference) => { + if (reference.type !== 'data-source') { + return true; + } + const dataSourceName = existingDataSourceIdToNameMap.get(reference.id); + if (dataSourceNamesSet.has(dataSourceName)) { + dataSourceNamesSet.delete(dataSourceName); + return true; + } + + return false; + }); + + for await (const dataSourceName of dataSourceNamesSet) { + const dataSourceId = await findDataSourceIdbyName({ + dataSourceName, + savedObjectsClient: wrapperOptions.client, + }); + if (dataSourceId) { + newReferences?.push({ + id: dataSourceId, + name: 'dataSource', + type: 'data-source', + }); + } else { + throw SavedObjectsErrorHelpers.createBadRequestError( + `data_source_name "${dataSourceName}" cannot be found in saved objects` + ); + } + } + + return await wrapperOptions.client.create(type, attributes, { + ...options, + references: newReferences, + }); + }; + + return { + ...wrapperOptions.client, + create: createForVega, + bulkCreate: wrapperOptions.client.bulkCreate, + checkConflicts: wrapperOptions.client.checkConflicts, + delete: wrapperOptions.client.delete, + find: wrapperOptions.client.find, + bulkGet: wrapperOptions.client.bulkGet, + get: wrapperOptions.client.get, + update: wrapperOptions.client.update, + bulkUpdate: wrapperOptions.client.bulkUpdate, + errors: wrapperOptions.client.errors, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + }; +}; From f103b7ed6c09285a5ff080022c14398c23fe6482 Mon Sep 17 00:00:00 2001 From: Tao Liu <33105471+Flyingliuhub@users.noreply.github.com> Date: Tue, 19 Mar 2024 19:55:21 -0700 Subject: [PATCH 06/45] [Admin] Add @xinruiba as a maintainer (#6217) * add @xinruiba as a maintainer Signed-off-by: Flyingliuhub <33105471+flyingliuhub@users.noreply.github.com> * update PR Id Signed-off-by: Flyingliuhub <33105471+flyingliuhub@users.noreply.github.com> --------- Signed-off-by: Flyingliuhub <33105471+flyingliuhub@users.noreply.github.com> --- .github/CODEOWNERS | 2 +- CHANGELOG.md | 1 + MAINTAINERS.md | 1 + 3 files changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 52648656bfa1..bc7e7b27c7d5 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @ananzh @kavilla @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @zhongnansu @manasvinibs @ZilongX @Flyingliuhub @BSFishy @curq @bandinib-amzn @SuZhou-Joe @ruanyl @BionIT +* @ananzh @kavilla @AMoo-Miki @ashwin-pc @joshuarrrr @abbyhu2000 @zengyan-amazon @zhongnansu @manasvinibs @ZilongX @Flyingliuhub @BSFishy @curq @bandinib-amzn @SuZhou-Joe @ruanyl @BionIT @xinruiba diff --git a/CHANGELOG.md b/CHANGELOG.md index dfd5ae184e07..8c806cccd64e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -103,6 +103,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Add @ruanyl as a maintainer ([#5982](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5982)) - Add @BionIT as a maintainer ([#5988](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5988)) - Move @kristenTian to emeritus maintainer ([#6136](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6136)) +- Add @xinruiba as a maintainer ([#6217](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6217)) ### 🪛 Refactoring diff --git a/MAINTAINERS.md b/MAINTAINERS.md index 9ba46abf4d32..4f9791b3949f 100644 --- a/MAINTAINERS.md +++ b/MAINTAINERS.md @@ -23,6 +23,7 @@ This document contains a list of maintainers in this repo. See [opensearch-proje | Su Zhou | [SuZhou-Joe](https://github.com/SuZhou-Joe) | Amazon | | Yulong Ruan | [ruanyl](https://github.com/ruanyl) | Amazon | | Lu Yu | [BionIT](https://github.com/BionIT) | Amazon | +| Xinrui Bai | [xinruiba](https://github.com/xinruiba) | Amazon | ## Emeritus From 0cc91ab3ed89ed72552a29e503cab2dab324a073 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 21 Mar 2024 07:00:59 +0800 Subject: [PATCH 07/45] [Workspace] Validate if workspace exists when setup inside a workspace (#6154) * feat: validate if workspace exists when setup inside a workspace Signed-off-by: SuZhou-Joe * feat: add CHANGELOG Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: optimize import order Signed-off-by: SuZhou-Joe * feat: add protection Signed-off-by: SuZhou-Joe * Apply suggestions from code review Co-authored-by: Yulong Ruan Signed-off-by: SuZhou-Joe * feat: jump to landing page Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Co-authored-by: Yulong Ruan --- CHANGELOG.md | 1 + src/plugins/workspace/common/constants.ts | 2 + .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/public/application.tsx | 26 +++ .../workspace_fatal_error.test.tsx.snap | 180 ++++++++++++++++++ .../components/workspace_fatal_error/index.ts | 6 + .../workspace_fatal_error.test.tsx | 71 +++++++ .../workspace_fatal_error.tsx | 65 +++++++ src/plugins/workspace/public/plugin.test.ts | 85 ++++++++- src/plugins/workspace/public/plugin.ts | 65 ++++++- src/plugins/workspace/public/types.ts | 9 + 11 files changed, 506 insertions(+), 6 deletions(-) create mode 100644 src/plugins/workspace/public/application.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx create mode 100644 src/plugins/workspace/public/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c806cccd64e..fe54b0d0deec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Discover] Options button to configure legacy mode and remove the top navigation option ([#6170](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6170)) - [Multiple Datasource] Add default functionality for customer to choose default datasource ([#6058](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6058)) - [Multiple Datasource] Add import support for Vega when specifying a datasource ([#6123](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6123)) +- [Workspace] Validate if workspace exists when setup inside a workspace ([#6154](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6154)) ### 🐛 Bug Fixes diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index e60bb6aea0eb..6ae89c0edad5 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; +export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index f34106ab4fed..4443b7e99834 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -7,5 +7,5 @@ "savedObjects" ], "optionalPlugins": [], - "requiredBundles": [] + "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx new file mode 100644 index 000000000000..f70c627e02b0 --- /dev/null +++ b/src/plugins/workspace/public/application.tsx @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, ScopedHistory } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +import { WorkspaceFatalError } from './components/workspace_fatal_error'; +import { Services } from './types'; + +export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { + const { element } = params; + const history = params.history as ScopedHistory<{ error?: string }>; + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap new file mode 100644 index 000000000000..01403b9bc33c --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap @@ -0,0 +1,180 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` render error with callout 1`] = ` +
+
+
+
+
+ +
+

+ + Something went wrong + +

+ +
+
+

+ + The workspace you are trying to access cannot be found. Please return to the homepage and try again. + +

+
+ +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+`; + +exports[` render normally 1`] = ` +
+
+
+
+
+ +
+

+ + Something went wrong + +

+ +
+
+

+ + The workspace you are trying to access cannot be found. Please return to the homepage and try again. + +

+
+ +
+
+
+ +
+
+
+
+
+
+
+`; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/index.ts b/src/plugins/workspace/public/components/workspace_fatal_error/index.ts new file mode 100644 index 000000000000..afb34b10d913 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceFatalError } from './workspace_fatal_error'; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx new file mode 100644 index 000000000000..11b229c9ccac --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { IntlProvider } from 'react-intl'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { WorkspaceFatalError } from './workspace_fatal_error'; +import { context } from '../../../../opensearch_dashboards_react/public'; +import { coreMock } from '../../../../../core/public/mocks'; + +describe('', () => { + it('render normally', async () => { + const { findByText, container } = render( + + + + ); + await findByText('Something went wrong'); + expect(container).toMatchSnapshot(); + }); + + it('render error with callout', async () => { + const { findByText, container } = render( + + + + ); + await findByText('errorInCallout'); + expect(container).toMatchSnapshot(); + }); + + it('click go back to homepage', async () => { + const { location } = window; + const setHrefSpy = jest.fn((href) => href); + if (window.location) { + // @ts-ignore + delete window.location; + } + window.location = {} as Location; + Object.defineProperty(window.location, 'href', { + get: () => 'http://localhost/', + set: setHrefSpy, + }); + const coreStartMock = coreMock.createStart(); + const { getByText } = render( + + + + + + ); + fireEvent.click(getByText('Go back to homepage')); + await waitFor( + () => { + expect(setHrefSpy).toBeCalledTimes(1); + }, + { + container: document.body, + } + ); + window.location = location; + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx new file mode 100644 index 000000000000..604dec277553 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx @@ -0,0 +1,65 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiButton, + EuiEmptyPrompt, + EuiPage, + EuiPageBody, + EuiPageContent, + EuiCallOut, +} from '@elastic/eui'; +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { HttpSetup } from 'opensearch-dashboards/public'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; + +export function WorkspaceFatalError(props: { error?: string }) { + const { + services: { http }, + } = useOpenSearchDashboards(); + const goBackToHome = () => { + window.location.href = (http as HttpSetup).basePath.prepend('/', { + withoutClientBasePath: true, + }); + }; + return ( + + + + + + + } + body={ +

+ +

+ } + actions={[ + + + , + ]} + /> + {props.error ? : null} +
+
+
+ ); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index e54a20552329..1bdbd7ef31ad 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,9 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Observable, Subscriber } from 'rxjs'; +import { waitFor } from '@testing-library/dom'; import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; -import { chromeServiceMock, coreMock } from '../../../core/public/mocks'; +import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; import { WorkspacePlugin } from './plugin'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; describe('Workspace plugin', () => { const getSetupMock = () => ({ @@ -23,12 +26,17 @@ describe('Workspace plugin', () => { expect(WorkspaceClientMock).toBeCalledTimes(1); }); - it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { + it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', async () => { const workspacePlugin = new WorkspacePlugin(); + const setupMock = getSetupMock(); const coreStart = coreMock.createStart(); + await workspacePlugin.setup(setupMock); workspacePlugin.start(coreStart); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); }); it('#setup when workspace id is in url and enterWorkspace return error', async () => { @@ -41,11 +49,82 @@ describe('Workspace plugin', () => { }, } as any) ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: false, + error: 'error', + }); const setupMock = getSetupMock(); + const applicationStartMock = applicationServiceMock.createStartContract(); + const chromeStartMock = chromeServiceMock.createStartContract(); + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: applicationStartMock, + chrome: chromeStartMock, + }, + {}, + {}, + ]) as any; + }); + + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); + expect(setupMock.getStartServices).toBeCalledTimes(1); + await waitFor( + () => { + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: 'error', + }, + }); + }, + { + container: document.body, + } + ); + windowSpy.mockRestore(); + }); + + it('#setup when workspace id is in url and enterWorkspace return success', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: true, + error: 'error', + }); + const setupMock = getSetupMock(); + const applicationStartMock = applicationServiceMock.createStartContract(); + let currentAppIdSubscriber: Subscriber | undefined; + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: { + ...applicationStartMock, + currentAppId$: new Observable((subscriber) => { + currentAppIdSubscriber = subscriber; + }), + }, + }, + {}, + {}, + ]) as any; + }); const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); - expect(setupMock.workspaces.currentWorkspaceId$.getValue()).toEqual('workspaceId'); + currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 8a69d597c84b..e3ecdc34bfb9 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,10 +4,20 @@ */ import type { Subscription } from 'rxjs'; -import { Plugin, CoreStart, CoreSetup } from '../../../core/public'; +import { + Plugin, + CoreStart, + CoreSetup, + AppMountParameters, + AppNavLinkStatus, +} from '../../../core/public'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; +import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; +type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; + export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; @@ -33,9 +43,60 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { const workspaceId = this.getWorkspaceIdFromURL(core.http.basePath.getBasePath()); if (workspaceId) { - core.workspaces.currentWorkspaceId$.next(workspaceId); + const result = await workspaceClient.enterWorkspace(workspaceId); + if (!result.success) { + /** + * Fatal error service does not support customized actions + * So we have to use a self-hosted page to show the errors and redirect. + */ + (async () => { + const [{ application, chrome }] = await core.getStartServices(); + chrome.setIsVisible(false); + application.navigateToApp(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: result?.error, + }, + }); + })(); + } else { + /** + * If the workspace id is valid and user is currently on workspace_fatal_error page, + * we should redirect user to overview page of workspace. + */ + (async () => { + const [{ application }] = await core.getStartServices(); + const currentAppIdSubscription = application.currentAppId$.subscribe((currentAppId) => { + if (currentAppId === WORKSPACE_FATAL_ERROR_APP_ID) { + application.navigateToApp(WORKSPACE_OVERVIEW_APP_ID); + } + currentAppIdSubscription.unsubscribe(); + }); + })(); + } } + const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { + const [coreStart] = await core.getStartServices(); + const services = { + ...coreStart, + workspaceClient, + }; + + return renderApp(params, services); + }; + + // workspace fatal error + core.application.register({ + id: WORKSPACE_FATAL_ERROR_APP_ID, + title: '', + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderFatalErrorApp } = await import('./application'); + return mountWorkspaceApp(params, renderFatalErrorApp); + }, + }); + return {}; } diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts new file mode 100644 index 000000000000..1b3f38e50857 --- /dev/null +++ b/src/plugins/workspace/public/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart } from '../../../core/public'; +import { WorkspaceClient } from './workspace_client'; + +export type Services = CoreStart & { workspaceClient: WorkspaceClient }; From a2ed39f76c75bec843243ff85113485984102dbc Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Thu, 21 Mar 2024 09:55:37 +0800 Subject: [PATCH 08/45] [Workspace] Register a workspace dropdown menu at the top of left nav bar (#6150) When workspace is enabled, the workspace plugin will register a workspace dropdown menu via `chrome.registerCollapsibleNavHeader` which displays a list of workspaces, links to create workspace page and workspace list page. --------- Signed-off-by: Yulong Ruan --- CHANGELOG.md | 1 + src/core/public/index.ts | 6 +- src/core/public/mocks.ts | 1 + src/core/public/utils/index.ts | 1 + src/core/public/workspace/index.ts | 7 +- .../workspace/workspaces_service.mock.ts | 44 ++-- .../public/workspace/workspaces_service.ts | 2 +- src/plugins/workspace/common/constants.ts | 4 +- .../workspace_menu/workspace_menu.test.tsx | 120 +++++++++++ .../workspace_menu/workspace_menu.tsx | 191 ++++++++++++++++++ src/plugins/workspace/public/plugin.test.ts | 7 + src/plugins/workspace/public/plugin.ts | 14 ++ 12 files changed, 372 insertions(+), 26 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index fe54b0d0deec..0cea2c205910 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Add default functionality for customer to choose default datasource ([#6058](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6058)) - [Multiple Datasource] Add import support for Vega when specifying a datasource ([#6123](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6123)) - [Workspace] Validate if workspace exists when setup inside a workspace ([#6154](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6154)) +- [Workspace] Register a workspace dropdown menu at the top of left nav bar ([#6150](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6150)) ### 🐛 Bug Fixes diff --git a/src/core/public/index.ts b/src/core/public/index.ts index c9d416cb6f43..c82457ef2184 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -94,7 +94,7 @@ export type { Logos } from '../common'; export { PackageInfo, EnvironmentMode } from '../server/types'; /** @interal */ export { CoreContext, CoreSystem } from './core_system'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils'; export { AppCategory, UiSettingsParams, @@ -357,6 +357,4 @@ export { export { __osdBootstrap__ } from './osd_bootstrap'; -export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; - -export { WORKSPACE_TYPE } from '../utils'; +export { WorkspacesStart, WorkspacesSetup, WorkspacesService, WorkspaceObject } from './workspace'; diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 3acc71424b91..05c3b7d18d1b 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -74,6 +74,7 @@ function createCoreSetupMock({ } = {}) { const mock = { application: applicationServiceMock.createSetupContract(), + chrome: chromeServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), docLinks: docLinksServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index c0c6f2582e9c..30055b0ff81c 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -36,4 +36,5 @@ export { WORKSPACE_TYPE, formatUrlWithWorkspaceId, getWorkspaceIdFromUrl, + cleanWorkspaceId, } from '../../utils'; diff --git a/src/core/public/workspace/index.ts b/src/core/public/workspace/index.ts index 4b9b2c86f649..712ad657fa65 100644 --- a/src/core/public/workspace/index.ts +++ b/src/core/public/workspace/index.ts @@ -3,4 +3,9 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { WorkspacesStart, WorkspacesService, WorkspacesSetup } from './workspaces_service'; +export { + WorkspacesStart, + WorkspacesService, + WorkspacesSetup, + WorkspaceObject, +} from './workspaces_service'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index ae56c035eb3a..9e8cdfce7393 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -6,27 +6,33 @@ import { BehaviorSubject } from 'rxjs'; import type { PublicMethodsOf } from '@osd/utility-types'; -import { WorkspacesService } from './workspaces_service'; -import { WorkspaceAttribute } from '..'; +import { WorkspacesService, WorkspaceObject } from './workspaces_service'; -const currentWorkspaceId$ = new BehaviorSubject(''); -const workspaceList$ = new BehaviorSubject([]); -const currentWorkspace$ = new BehaviorSubject(null); -const initialized$ = new BehaviorSubject(false); - -const createWorkspacesSetupContractMock = () => ({ - currentWorkspaceId$, - workspaceList$, - currentWorkspace$, - initialized$, -}); +const createWorkspacesSetupContractMock = () => { + const currentWorkspaceId$ = new BehaviorSubject(''); + const workspaceList$ = new BehaviorSubject([]); + const currentWorkspace$ = new BehaviorSubject(null); + const initialized$ = new BehaviorSubject(false); + return { + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + }; +}; -const createWorkspacesStartContractMock = () => ({ - currentWorkspaceId$, - workspaceList$, - currentWorkspace$, - initialized$, -}); +const createWorkspacesStartContractMock = () => { + const currentWorkspaceId$ = new BehaviorSubject(''); + const workspaceList$ = new BehaviorSubject([]); + const currentWorkspace$ = new BehaviorSubject(null); + const initialized$ = new BehaviorSubject(false); + return { + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + }; +}; export type WorkspacesServiceContract = PublicMethodsOf; const createMock = (): jest.Mocked => ({ diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index cc19b3c79229..e4cf3bc7a826 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -8,7 +8,7 @@ import { isEqual } from 'lodash'; import { CoreService, WorkspaceAttribute } from '../../types'; -type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; +export type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; interface WorkspaceObservables { /** diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 6ae89c0edad5..d2da08acb52d 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,8 +3,10 @@ * SPDX-License-Identifier: Apache-2.0 */ -export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; +export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; +export const WORKSPACE_LIST_APP_ID = 'workspace_list'; +export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx new file mode 100644 index 000000000000..c63b232bb232 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { WorkspaceMenu } from './workspace_menu'; +import { coreMock } from '../../../../../core/public/mocks'; +import { CoreStart } from '../../../../../core/public'; + +describe('', () => { + let coreStartMock: CoreStart; + + beforeEach(() => { + coreStartMock = coreMock.createStart(); + coreStartMock.workspaces.initialized$.next(true); + jest.spyOn(coreStartMock.application, 'getUrlForApp').mockImplementation((appId: string) => { + return `https://test.com/app/${appId}`; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('should display a list of workspaces in the dropdown', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + + expect(screen.getByText(/workspace 1/i)).toBeInTheDocument(); + expect(screen.getByText(/workspace 2/i)).toBeInTheDocument(); + }); + + it('should display current workspace name', () => { + coreStartMock.workspaces.currentWorkspace$.next({ id: 'workspace-1', name: 'workspace 1' }); + render(); + expect(screen.getByText(/workspace 1/i)).toBeInTheDocument(); + }); + + it('should close the workspace dropdown list', async () => { + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + + expect(screen.getByLabelText(/close workspace dropdown/i)).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText(/close workspace dropdown/i)); + await waitFor(() => { + expect(screen.queryByLabelText(/close workspace dropdown/i)).not.toBeInTheDocument(); + }); + }); + + it('should navigate to the workspace', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + fireEvent.click(screen.getByText(/workspace 1/i)); + + expect(window.location.assign).toHaveBeenCalledWith( + 'https://test.com/w/workspace-1/app/workspace_overview' + ); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); + + it('should navigate to create workspace page', () => { + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + fireEvent.click(screen.getByText(/create workspace/i)); + expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_create'); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); + + it('should navigate to workspace list page', () => { + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + fireEvent.click(screen.getByText(/all workspace/i)); + expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_list'); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx new file mode 100644 index 000000000000..5b16b9766b22 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -0,0 +1,191 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { useObservable } from 'react-use'; +import { + EuiButtonIcon, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiListGroup, + EuiListGroupItem, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import type { EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; + +import { + WORKSPACE_CREATE_APP_ID, + WORKSPACE_LIST_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, +} from '../../../common/constants'; +import { cleanWorkspaceId, formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; + +interface Props { + coreStart: CoreStart; +} + +/** + * Return maximum five workspaces, the current selected workspace + * will be on the top of the list. + */ +function getFilteredWorkspaceList( + workspaceList: WorkspaceObject[], + currentWorkspace: WorkspaceObject | null +): WorkspaceObject[] { + return [ + ...(currentWorkspace ? [currentWorkspace] : []), + ...workspaceList.filter((workspace) => workspace.id !== currentWorkspace?.id), + ].slice(0, 5); +} + +export const WorkspaceMenu = ({ coreStart }: Props) => { + const [isPopoverOpen, setPopover] = useState(false); + const currentWorkspace = useObservable(coreStart.workspaces.currentWorkspace$, null); + const workspaceList = useObservable(coreStart.workspaces.workspaceList$, []); + + const defaultHeaderName = i18n.translate( + 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', + { + defaultMessage: 'Select a workspace', + } + ); + const filteredWorkspaceList = getFilteredWorkspaceList(workspaceList, currentWorkspace); + const currentWorkspaceName = currentWorkspace?.name ?? defaultHeaderName; + + const openPopover = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + + const workspaceToItem = (workspace: WorkspaceObject) => { + const workspaceURL = formatUrlWithWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: false, + }), + workspace.id, + coreStart.http.basePath + ); + const name = + currentWorkspace?.name === workspace.name ? ( + + {workspace.name} + + ) : ( + workspace.name + ); + return { + name, + key: workspace.id, + icon: , + onClick: () => { + window.location.assign(workspaceURL); + }, + }; + }; + + const getWorkspaceListItems = () => { + const workspaceListItems: EuiContextMenuPanelItemDescriptor[] = filteredWorkspaceList.map( + workspaceToItem + ); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.createWorkspace', { + defaultMessage: 'Create workspace', + }), + key: WORKSPACE_CREATE_APP_ID, + onClick: () => { + window.location.assign( + cleanWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }) + ) + ); + }, + }); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.allWorkspace', { + defaultMessage: 'All workspaces', + }), + key: WORKSPACE_LIST_APP_ID, + onClick: () => { + window.location.assign( + cleanWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_LIST_APP_ID, { + absolute: false, + }) + ) + ); + }, + }); + return workspaceListItems; + }; + + const currentWorkspaceButton = ( + <> + + + + + ); + + const currentWorkspaceTitle = ( + + + {currentWorkspaceName} + + + + + + ); + + const panels = [ + { + id: 0, + title: currentWorkspaceTitle, + items: getWorkspaceListItems(), + }, + ]; + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 1bdbd7ef31ad..2306c460b88d 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -127,4 +127,11 @@ describe('Workspace plugin', () => { expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); }); + + it('#setup register workspace dropdown menu when setup', async () => { + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + expect(setupMock.chrome.registerCollapsibleNavHeader).toBeCalledTimes(1); + }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index e3ecdc34bfb9..8a5bf9b125f5 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,6 +4,7 @@ */ import type { Subscription } from 'rxjs'; +import React from 'react'; import { Plugin, CoreStart, @@ -15,6 +16,7 @@ import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../comm import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; +import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; @@ -30,9 +32,11 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }); } } + private getWorkspaceIdFromURL(basePath?: string): string | null { return getWorkspaceIdFromUrl(window.location.href, basePath); } + public async setup(core: CoreSetup) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); @@ -97,6 +101,16 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }, }); + /** + * Register workspace dropdown selector on the top of left navigation menu + */ + core.chrome.registerCollapsibleNavHeader(() => { + if (!this.coreStart) { + return null; + } + return React.createElement(WorkspaceMenu, { coreStart: this.coreStart }); + }); + return {}; } From 4a8e3e81c2f25395937dc35e2c9474b0c970bf3d Mon Sep 17 00:00:00 2001 From: "Yuanqi(Ella) Zhu" <53279298+zhyuanqi@users.noreply.github.com> Date: Wed, 20 Mar 2024 21:30:28 -0700 Subject: [PATCH 09/45] Add icon in datasource table page to show the default datasource (#6231) Signed-off-by: Yuanqi(Ella) Zhu --- CHANGELOG.md | 1 + .../data_source_table.test.tsx.snap | 40 +++++++++++++++++++ .../data_source_table.test.tsx | 5 +++ .../data_source_table/data_source_table.tsx | 7 ++++ 4 files changed, 53 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0cea2c205910..3b2abfe2c19e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -61,6 +61,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Add import support for Vega when specifying a datasource ([#6123](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6123)) - [Workspace] Validate if workspace exists when setup inside a workspace ([#6154](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6154)) - [Workspace] Register a workspace dropdown menu at the top of left nav bar ([#6150](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6150)) +- [Multiple Datasource] Add icon in datasource table page to show the default datasource ([#6231](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6231)) ### 🐛 Bug Fixes diff --git a/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap index 53e2fac37130..acab011f27a8 100644 --- a/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_table/__snapshots__/data_source_table.test.tsx.snap @@ -1927,6 +1927,46 @@ exports[`DataSourceTable should get datasources successful should render normall + + + + + + Default + + + + + + + + diff --git a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx index cdb25c057844..dd33c28b6890 100644 --- a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.test.tsx @@ -19,11 +19,13 @@ const deleteButtonIdentifier = '[data-test-subj="deleteDataSourceConnections"]'; const tableIdentifier = 'EuiInMemoryTable'; const confirmModalIdentifier = 'EuiConfirmModal'; const tableColumnHeaderIdentifier = 'EuiTableHeaderCell'; +const badgeIcon = 'EuiBadge'; const tableColumnHeaderButtonIdentifier = 'EuiTableHeaderCell .euiTableHeaderButton'; const emptyStateIdentifier = '[data-test-subj="datasourceTableEmptyState"]'; describe('DataSourceTable', () => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); + const uiSettings = mockedContext.uiSettings; let component: ReactWrapper, React.Component<{}, {}, any>>; const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; describe('should get datasources failed', () => { @@ -57,6 +59,7 @@ describe('DataSourceTable', () => { describe('should get datasources successful', () => { beforeEach(async () => { spyOn(utils, 'getDataSources').and.returnValue(Promise.resolve(getMappedDataSources)); + spyOn(uiSettings, 'get').and.returnValue('test'); await act(async () => { component = await mount( wrapWithIntl( @@ -83,6 +86,7 @@ describe('DataSourceTable', () => { }); it('should sort datasources based on description', () => { + expect(component.find(badgeIcon).exists()).toBe(true); expect(component.find(tableIdentifier).exists()).toBe(true); act(() => { component.find(tableColumnHeaderButtonIdentifier).last().simulate('click'); @@ -90,6 +94,7 @@ describe('DataSourceTable', () => { component.update(); // @ts-ignore expect(component.find(tableColumnHeaderIdentifier).last().props().isSorted).toBe(true); + expect(uiSettings.get).toHaveBeenCalled(); }); it('should enable delete button when select datasources', () => { diff --git a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx index bbb2ce5f4100..b27f957fe142 100644 --- a/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx +++ b/src/plugins/data_source_management/public/components/data_source_table/data_source_table.tsx @@ -4,6 +4,7 @@ */ import { + EuiBadge, EuiButton, EuiButtonEmpty, EuiConfirmModal, @@ -50,6 +51,7 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => { setBreadcrumbs, savedObjects, notifications: { toasts }, + uiSettings, } = useOpenSearchDashboards().services; /* Component state variables */ @@ -147,6 +149,11 @@ export const DataSourceTable = ({ history }: RouteComponentProps) => { {name} + {index.id === uiSettings.get('defaultDataSource', null) ? ( + + Default + + ) : null} ), dataType: 'string' as const, From 0dce00a806bf8815ca84802541010767b6b3a8bd Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 22 Mar 2024 14:18:59 +0800 Subject: [PATCH 10/45] [Workspace] Add base path when parsing url in http service (#6233) * fix: add base path when parse url in http service Signed-off-by: SuZhou-Joe * feat: add CHANGELOG Signed-off-by: SuZhou-Joe * feat: optimize unit test cases for parse clientBasePath from url when basePath enabled Signed-off-by: SuZhou-Joe * feat: add empty line before getWorkspaceIdFromURL method Signed-off-by: SuZhou-Joe * feat: optimize comment Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Co-authored-by: Xinrui Bai-amazon <139305463+xinruiba@users.noreply.github.com> --- CHANGELOG.md | 1 + src/core/public/http/http_service.test.ts | 7 ++++--- src/core/public/http/http_service.ts | 2 +- src/core/utils/workspace.test.ts | 4 ++-- src/core/utils/workspace.ts | 2 +- src/plugins/workspace/public/plugin.ts | 9 ++++----- src/plugins/workspace/server/plugin.ts | 5 ++++- 7 files changed, 17 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b2abfe2c19e..facd1228a115 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -80,6 +80,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [BUG][MD]Expose picker using function in data source management plugin setup([#6030](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6030)) - [BUG][Multiple Datasource] Fix data source filter bug and add tests ([#6152](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6152)) - [BUG][Multiple Datasource] Fix obsolete snapshots for test within data source management plugin ([#6185](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6185)) +- [Workspace] Add base path when parse url in http service ([#6233](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6233)) ### 🚞 Infrastructure diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index 5671064e4c52..73010e1c5a93 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -83,21 +83,22 @@ describe('#setup()', () => { expect(setupResult.basePath.get()).toEqual(''); }); - it('setup basePath with workspaceId provided in window.location.href', () => { + it('setup basePath with workspaceId provided in window.location.href and basePath present in injectedMetadata', () => { const windowSpy = jest.spyOn(window, 'window', 'get'); windowSpy.mockImplementation( () => ({ location: { - href: 'http://localhost/w/workspaceId/app', + href: 'http://localhost/base_path/w/workspaceId/app', }, } as any) ); const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + injectedMetadata.getBasePath.mockReturnValue('/base_path'); const fatalErrors = fatalErrorsServiceMock.createSetupContract(); const httpService = new HttpService(); const setupResult = httpService.setup({ fatalErrors, injectedMetadata }); - expect(setupResult.basePath.get()).toEqual('/w/workspaceId'); + expect(setupResult.basePath.get()).toEqual('/base_path/w/workspaceId'); windowSpy.mockRestore(); }); }); diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index 6832703c7925..c1a538dfca1c 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -53,7 +53,7 @@ export class HttpService implements CoreService { public setup({ injectedMetadata, fatalErrors }: HttpDeps): HttpSetup { const opensearchDashboardsVersion = injectedMetadata.getOpenSearchDashboardsVersion(); let clientBasePath = ''; - const workspaceId = getWorkspaceIdFromUrl(window.location.href); + const workspaceId = getWorkspaceIdFromUrl(window.location.href, injectedMetadata.getBasePath()); if (workspaceId) { clientBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; } diff --git a/src/core/utils/workspace.test.ts b/src/core/utils/workspace.test.ts index a852ddcc5190..722bbca566e5 100644 --- a/src/core/utils/workspace.test.ts +++ b/src/core/utils/workspace.test.ts @@ -8,11 +8,11 @@ import { httpServiceMock } from '../public/mocks'; describe('#getWorkspaceIdFromUrl', () => { it('return workspace when there is a match', () => { - expect(getWorkspaceIdFromUrl('http://localhost/w/foo')).toEqual('foo'); + expect(getWorkspaceIdFromUrl('http://localhost/w/foo', '')).toEqual('foo'); }); it('return empty when there is not a match', () => { - expect(getWorkspaceIdFromUrl('http://localhost/w2/foo')).toEqual(''); + expect(getWorkspaceIdFromUrl('http://localhost/w2/foo', '')).toEqual(''); }); it('return workspace when there is a match with basePath provided', () => { diff --git a/src/core/utils/workspace.ts b/src/core/utils/workspace.ts index c383967483a8..f16b28423b66 100644 --- a/src/core/utils/workspace.ts +++ b/src/core/utils/workspace.ts @@ -6,7 +6,7 @@ import { WORKSPACE_PATH_PREFIX } from './constants'; import { IBasePath } from '../public'; -export const getWorkspaceIdFromUrl = (url: string, basePath?: string): string => { +export const getWorkspaceIdFromUrl = (url: string, basePath: string): string => { const regexp = new RegExp(`^${basePath || ''}\/w\/([^\/]*)`); const urlObject = new URL(url); const matchedResult = urlObject.pathname.match(regexp); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 8a5bf9b125f5..f0c82bda90b7 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -33,10 +33,6 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { } } - private getWorkspaceIdFromURL(basePath?: string): string | null { - return getWorkspaceIdFromUrl(window.location.href, basePath); - } - public async setup(core: CoreSetup) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); @@ -44,7 +40,10 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { /** * Retrieve workspace id from url */ - const workspaceId = this.getWorkspaceIdFromURL(core.http.basePath.getBasePath()); + const workspaceId = getWorkspaceIdFromUrl( + window.location.href, + core.http.basePath.getBasePath() + ); if (workspaceId) { const result = await workspaceClient.enterWorkspace(workspaceId); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index f5b7da6430e0..e846470210c3 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -27,7 +27,10 @@ export class WorkspacePlugin implements Plugin { - const workspaceId = getWorkspaceIdFromUrl(request.url.toString()); + const workspaceId = getWorkspaceIdFromUrl( + request.url.toString(), + '' // No need to pass basePath here because the request.url will be rewrite by registerOnPreRouting method in `src/core/server/http/http_server.ts` + ); if (workspaceId) { const requestUrl = new URL(request.url.toString()); From a9b400e42cf9949e7e647c0da5c37bbcd6d0f6d7 Mon Sep 17 00:00:00 2001 From: Craig Perkins Date: Fri, 22 Mar 2024 13:47:40 -0400 Subject: [PATCH 11/45] [Multiple Datasources] Add TLS configuration for multiple data sources (#6171) * Add TLS configuration for multiple data sources Signed-off-by: Craig Perkins * Add to CHANGELOG and add examples commented out in opensearch_dashboards.yml Signed-off-by: Craig Perkins * Add tests and replace instance of any Signed-off-by: Craig Perkins * Add tls config to legacy client Signed-off-by: Craig Perkins * Add test for certificate mode Signed-off-by: Craig Perkins * Respond to PR feedback Signed-off-by: Craig Perkins * Extract readCertificateAuthorities to util file and add more tests Signed-off-by: Craig Perkins --------- Signed-off-by: Craig Perkins Signed-off-by: Craig Perkins --- CHANGELOG.md | 1 + config/opensearch_dashboards.yml | 8 ++ src/plugins/data_source/config.ts | 9 ++ .../server/client/client_config.test.ts | 97 +++++++++++++++++-- .../server/client/client_config.ts | 46 ++++++++- .../server/legacy/client_config.test.ts | 94 ++++++++++++++++-- .../server/legacy/client_config.ts | 44 ++++++++- .../server/util/tls_settings_provider.test.ts | 58 +++++++++++ .../server/util/tls_settings_provider.ts | 36 +++++++ 9 files changed, 372 insertions(+), 21 deletions(-) create mode 100644 src/plugins/data_source/server/util/tls_settings_provider.test.ts create mode 100644 src/plugins/data_source/server/util/tls_settings_provider.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index facd1228a115..4849bfcf13b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -62,6 +62,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Validate if workspace exists when setup inside a workspace ([#6154](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6154)) - [Workspace] Register a workspace dropdown menu at the top of left nav bar ([#6150](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6150)) - [Multiple Datasource] Add icon in datasource table page to show the default datasource ([#6231](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6231)) +- [Multiple Datasource] Add TLS configuration for multiple data sources ([#6171](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6171)) ### 🐛 Bug Fixes diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 99df1d808bab..40d643b014fd 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -281,6 +281,14 @@ # 'ff00::/8', # ] +# Optional setting that enables you to specify a path to PEM files for the certificate +# authority for your connected datasources. +#data_source.ssl.certificateAuthorities: [ "/path/to/your/CA.pem" ] + +# To disregard the validity of SSL certificates for connected data sources, change this setting's value to 'none'. +# Possible values include full, certificate and none +#data_source.ssl.verificationMode: full + # Set enabled false to hide authentication method in OpenSearch Dashboards. # If this setting is commented then all 3 options will be available in OpenSearch Dashboards. # Default value will be considered to True. diff --git a/src/plugins/data_source/config.ts b/src/plugins/data_source/config.ts index 50013537b127..30824b486257 100644 --- a/src/plugins/data_source/config.ts +++ b/src/plugins/data_source/config.ts @@ -31,6 +31,15 @@ export const configSchema = schema.object({ defaultValue: new Array(32).fill(0), }), }), + ssl: schema.object({ + verificationMode: schema.oneOf( + [schema.literal('none'), schema.literal('certificate'), schema.literal('full')], + { defaultValue: 'full' } + ), + certificateAuthorities: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string(), { minSize: 1 })]) + ), + }), clientPool: schema.object({ size: schema.number({ defaultValue: 5 }), }), diff --git a/src/plugins/data_source/server/client/client_config.test.ts b/src/plugins/data_source/server/client/client_config.test.ts index c6dfff3fe4c6..e6aef818f7de 100644 --- a/src/plugins/data_source/server/client/client_config.test.ts +++ b/src/plugins/data_source/server/client/client_config.test.ts @@ -5,17 +5,20 @@ import { DataSourcePluginConfigType } from '../../config'; import { parseClientOptions } from './client_config'; -const TEST_DATA_SOURCE_ENDPOINT = 'http://test.com/'; +jest.mock('fs'); +const mockReadFileSync: jest.Mock = jest.requireMock('fs').readFileSync; -const config = { - enabled: true, - clientPool: { - size: 5, - }, -} as DataSourcePluginConfigType; +const TEST_DATA_SOURCE_ENDPOINT = 'http://test.com/'; describe('parseClientOptions', () => { test('include the ssl client configs as defaults', () => { + const config = { + enabled: true, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + expect(parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT)).toEqual( expect.objectContaining({ node: TEST_DATA_SOURCE_ENDPOINT, @@ -26,4 +29,84 @@ describe('parseClientOptions', () => { }) ); }); + + test('test ssl config with verification mode set to none', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'none', + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + expect(parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT)).toEqual( + expect.objectContaining({ + node: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + requestCert: true, + rejectUnauthorized: false, + ca: [], + }, + }) + ); + }); + + test('test ssl config with verification mode set to certificate', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'certificate', + certificateAuthorities: ['some-path'], + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const parsedConfig = parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(parsedConfig).toEqual( + expect.objectContaining({ + node: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + requestCert: true, + rejectUnauthorized: true, + checkServerIdentity: expect.any(Function), + ca: ['content-of-some-path'], + }, + }) + ); + expect(parsedConfig.ssl?.checkServerIdentity()).toBeUndefined(); + }); + + test('test ssl config with verification mode set to full', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'full', + certificateAuthorities: ['some-path'], + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const parsedConfig = parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(parsedConfig).toEqual( + expect.objectContaining({ + node: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + requestCert: true, + rejectUnauthorized: true, + ca: ['content-of-some-path'], + }, + }) + ); + }); }); diff --git a/src/plugins/data_source/server/client/client_config.ts b/src/plugins/data_source/server/client/client_config.ts index 61ffde2be748..f77986810f1b 100644 --- a/src/plugins/data_source/server/client/client_config.ts +++ b/src/plugins/data_source/server/client/client_config.ts @@ -4,7 +4,17 @@ */ import { ClientOptions } from '@opensearch-project/opensearch'; +import { checkServerIdentity } from 'tls'; import { DataSourcePluginConfigType } from '../../config'; +import { readCertificateAuthorities } from '../util/tls_settings_provider'; + +/** @internal */ +type DataSourceSSLConfigOptions = Partial<{ + requestCert: boolean; + rejectUnauthorized: boolean; + checkServerIdentity: typeof checkServerIdentity; + ca: string[]; +}>; /** * Parse the client options from given data source config and endpoint @@ -18,12 +28,40 @@ export function parseClientOptions( endpoint: string, registeredSchema: any[] ): ClientOptions { + const sslConfig: DataSourceSSLConfigOptions = { + requestCert: true, + rejectUnauthorized: true, + }; + + if (config.ssl) { + const verificationMode = config.ssl.verificationMode; + switch (verificationMode) { + case 'none': + sslConfig.rejectUnauthorized = false; + break; + case 'certificate': + sslConfig.rejectUnauthorized = true; + + // by default, NodeJS is checking the server identify + sslConfig.checkServerIdentity = () => undefined; + break; + case 'full': + sslConfig.rejectUnauthorized = true; + break; + default: + throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); + } + + const { certificateAuthorities } = readCertificateAuthorities( + config.ssl?.certificateAuthorities + ); + + sslConfig.ca = certificateAuthorities || []; + } + const clientOptions: ClientOptions = { node: endpoint, - ssl: { - requestCert: true, - rejectUnauthorized: true, - }, + ssl: sslConfig, plugins: registeredSchema, }; diff --git a/src/plugins/data_source/server/legacy/client_config.test.ts b/src/plugins/data_source/server/legacy/client_config.test.ts index a15143ecf69f..67445a686f90 100644 --- a/src/plugins/data_source/server/legacy/client_config.test.ts +++ b/src/plugins/data_source/server/legacy/client_config.test.ts @@ -5,17 +5,20 @@ import { DataSourcePluginConfigType } from '../../config'; import { parseClientOptions } from './client_config'; -const TEST_DATA_SOURCE_ENDPOINT = 'http://test.com/'; +jest.mock('fs'); +const mockReadFileSync: jest.Mock = jest.requireMock('fs').readFileSync; -const config = { - enabled: true, - clientPool: { - size: 5, - }, -} as DataSourcePluginConfigType; +const TEST_DATA_SOURCE_ENDPOINT = 'http://test.com/'; describe('parseClientOptions', () => { test('include the ssl client configs as defaults', () => { + const config = { + enabled: true, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + expect(parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT)).toEqual( expect.objectContaining({ host: TEST_DATA_SOURCE_ENDPOINT, @@ -25,4 +28,81 @@ describe('parseClientOptions', () => { }) ); }); + + test('test ssl config with verification mode set to none', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'none', + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + expect(parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT)).toEqual( + expect.objectContaining({ + host: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + rejectUnauthorized: false, + ca: [], + }, + }) + ); + }); + + test('test ssl config with verification mode set to certificate', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'certificate', + certificateAuthorities: ['some-path'], + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const parsedConfig = parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(parsedConfig).toEqual( + expect.objectContaining({ + host: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + rejectUnauthorized: true, + checkServerIdentity: expect.any(Function), + ca: ['content-of-some-path'], + }, + }) + ); + expect(parsedConfig.ssl?.checkServerIdentity()).toBeUndefined(); + }); + + test('test ssl config with verification mode set to full', () => { + const config = { + enabled: true, + ssl: { + verificationMode: 'full', + certificateAuthorities: ['some-path'], + }, + clientPool: { + size: 5, + }, + } as DataSourcePluginConfigType; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const parsedConfig = parseClientOptions(config, TEST_DATA_SOURCE_ENDPOINT); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(parsedConfig).toEqual( + expect.objectContaining({ + host: TEST_DATA_SOURCE_ENDPOINT, + ssl: { + rejectUnauthorized: true, + ca: ['content-of-some-path'], + }, + }) + ); + }); }); diff --git a/src/plugins/data_source/server/legacy/client_config.ts b/src/plugins/data_source/server/legacy/client_config.ts index eed052cf245d..a3704d3ec099 100644 --- a/src/plugins/data_source/server/legacy/client_config.ts +++ b/src/plugins/data_source/server/legacy/client_config.ts @@ -4,7 +4,17 @@ */ import { ConfigOptions } from 'elasticsearch'; +import { checkServerIdentity } from 'tls'; import { DataSourcePluginConfigType } from '../../config'; +import { readCertificateAuthorities } from '../util/tls_settings_provider'; + +/** @internal */ +type LegacyDataSourceSSLConfigOptions = Partial<{ + requestCert: boolean; + rejectUnauthorized: boolean; + checkServerIdentity: typeof checkServerIdentity; + ca: string[]; +}>; /** * Parse the client options from given data source config and endpoint @@ -18,11 +28,39 @@ export function parseClientOptions( endpoint: string, registeredSchema: any[] ): ConfigOptions { + const sslConfig: LegacyDataSourceSSLConfigOptions = { + rejectUnauthorized: true, + }; + + if (config.ssl) { + const verificationMode = config.ssl.verificationMode; + switch (verificationMode) { + case 'none': + sslConfig.rejectUnauthorized = false; + break; + case 'certificate': + sslConfig.rejectUnauthorized = true; + + // by default, NodeJS is checking the server identify + sslConfig.checkServerIdentity = () => undefined; + break; + case 'full': + sslConfig.rejectUnauthorized = true; + break; + default: + throw new Error(`Unknown ssl verificationMode: ${verificationMode}`); + } + + const { certificateAuthorities } = readCertificateAuthorities( + config.ssl?.certificateAuthorities + ); + + sslConfig.ca = certificateAuthorities || []; + } + const configOptions: ConfigOptions = { host: endpoint, - ssl: { - rejectUnauthorized: true, - }, + ssl: sslConfig, plugins: registeredSchema, }; diff --git a/src/plugins/data_source/server/util/tls_settings_provider.test.ts b/src/plugins/data_source/server/util/tls_settings_provider.test.ts new file mode 100644 index 000000000000..3458ea8e6ccf --- /dev/null +++ b/src/plugins/data_source/server/util/tls_settings_provider.test.ts @@ -0,0 +1,58 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { readCertificateAuthorities } from './tls_settings_provider'; + +jest.mock('fs'); +const mockReadFileSync: jest.Mock = jest.requireMock('fs').readFileSync; + +describe('readCertificateAuthorities', () => { + test('test readCertificateAuthorities with list of paths', () => { + const ca: string[] = ['some-path']; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const certificateAuthorities = readCertificateAuthorities(ca); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(certificateAuthorities).toEqual({ + certificateAuthorities: ['content-of-some-path'], + }); + }); + + test('test readCertificateAuthorities with single path', () => { + const ca: string = 'some-path'; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const certificateAuthorities = readCertificateAuthorities(ca); + expect(mockReadFileSync).toHaveBeenCalledTimes(1); + mockReadFileSync.mockClear(); + expect(certificateAuthorities).toEqual({ + certificateAuthorities: ['content-of-some-path'], + }); + }); + + test('test readCertificateAuthorities empty list', () => { + const ca: string[] = []; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const certificateAuthorities = readCertificateAuthorities(ca); + expect(mockReadFileSync).toHaveBeenCalledTimes(0); + mockReadFileSync.mockClear(); + expect(certificateAuthorities).toEqual({ + certificateAuthorities: [], + }); + }); + + test('test readCertificateAuthorities undefined', () => { + const ca = undefined; + mockReadFileSync.mockReset(); + mockReadFileSync.mockImplementation((path: string) => `content-of-${path}`); + const certificateAuthorities = readCertificateAuthorities(ca); + expect(mockReadFileSync).toHaveBeenCalledTimes(0); + mockReadFileSync.mockClear(); + expect(certificateAuthorities).toEqual({ + certificateAuthorities: [], + }); + }); +}); diff --git a/src/plugins/data_source/server/util/tls_settings_provider.ts b/src/plugins/data_source/server/util/tls_settings_provider.ts new file mode 100644 index 000000000000..0924041a756d --- /dev/null +++ b/src/plugins/data_source/server/util/tls_settings_provider.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { readFileSync } from 'fs'; + +export const readCertificateAuthorities = ( + listOfCertificateAuthorities: string | string[] | undefined +) => { + let certificateAuthorities: string[] | undefined = []; + + const addCertificateAuthorities = (ca: string[]) => { + if (ca && ca.length) { + certificateAuthorities = [...(certificateAuthorities || []), ...ca]; + } + }; + + const ca = listOfCertificateAuthorities; + if (ca) { + const parsed: string[] = []; + const paths = Array.isArray(ca) ? ca : [ca]; + for (const path of paths) { + parsed.push(readFile(path)); + } + addCertificateAuthorities(parsed); + } + + return { + certificateAuthorities, + }; +}; + +const readFile = (file: string) => { + return readFileSync(file, 'utf8'); +}; From 902e216765f0ed232861a8a883e0799d30af21d6 Mon Sep 17 00:00:00 2001 From: Miki Date: Fri, 22 Mar 2024 12:11:21 -0700 Subject: [PATCH 12/45] [osd/std] Add additional recovery from false-positives in handling of long numerals (#6245) Signed-off-by: Miki --- CHANGELOG.md | 2 +- packages/osd-std/src/json.ts | 12 +++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4849bfcf13b2..a5193ee87c54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -75,7 +75,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Discover] Enable 'Back to Top' Feature in Discover for scrolling to top ([#6008](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6008)) - [Discover] Fix lazy loading of the legacy table from getting stuck ([#6041](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6041)) - [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) -- [osd/std] Add additional recovery from false-positives in handling of long numerals ([#5956](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5956)) +- [osd/std] Add additional recovery from false-positives in handling of long numerals ([#5956](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5956), [#6245](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6245)) - [BUG][Multiple Datasource] Fix missing customApiRegistryPromise param for test connection ([#5944](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5944)) - [BUG][Multiple Datasource] Add a migration function for datasource to add migrationVersion field ([#6025](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6025)) - [BUG][MD]Expose picker using function in data source management plugin setup([#6030](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6030)) diff --git a/packages/osd-std/src/json.ts b/packages/osd-std/src/json.ts index 3af3d3c4b96c..7387790169a3 100644 --- a/packages/osd-std/src/json.ts +++ b/packages/osd-std/src/json.ts @@ -152,7 +152,8 @@ const parseStringWithLongNumerals = ( /* There are two types of exception objects that can be raised: * 1) a textual message with the position that we need to parse * i. Unexpected [token|string ...] at position ... - * ii. expected ',' or '}' after property value in object at line ... column ... + * ii. Expected ',' or ... after ... in JSON at position ... + * iii. expected ',' or ... after ... in object at line ... column ... * 2) a proper object with lineNumber and columnNumber which we can use * Note: this might refer to the part of the code that threw the exception but * we will try it anyway; the regex is specific enough to not produce @@ -161,23 +162,24 @@ const parseStringWithLongNumerals = ( let { lineNumber, columnNumber } = e; if (typeof e?.message === 'string') { - /* Check for 1-i (seen in Node) + /* Check for 1-i and 1-ii * Finding "..."෴1111"..." inside a string value, the extra quotes throw a syntax error * and the position points to " that is assumed to be the begining of a value. */ - let match = e.message.match(/^Unexpected .*at position (\d+)(\s|$)/); + let match = e.message.match(/^(?:Une|E)xpected .*at position (\d+)(\D|$)/i); if (match) { lineNumber = 1; // Add 1 to reach the marker columnNumber = parseInt(match[1], 10) + 1; } else { - /* Check for 1-ii (seen in browsers) + /* Check for 1-iii * Finding "...,"෴1111"..." inside a string value, the extra quotes throw a syntax error * and the column number points to the marker after the " that is assumed to be terminating the * value. + * PS: There are different versions of this error across browsers and platforms. */ // ToDo: Add functional tests for this path - match = e.message.match(/expected .*at line (\d+) column (\d+)(\s|$)/); + match = e.message.match(/expected .*line (\d+) column (\d+)(\D|$)/i); if (match) { lineNumber = parseInt(match[1], 10); columnNumber = parseInt(match[2], 10); From 27d73ab263a1663f90981d816ac77fb7660553d3 Mon Sep 17 00:00:00 2001 From: Kawika Avilla Date: Fri, 22 Mar 2024 17:49:33 -0700 Subject: [PATCH 13/45] [CI][Tests] set legacy browser warning (#6250) * [CI][Tests] set legacy browser warning Setting legacy browser when running Cypress tests. Signed-off-by: Kawika Avilla * Update .github/workflows/cypress_workflow.yml Co-authored-by: Huan Jiang Signed-off-by: Kawika Avilla * switch to false Signed-off-by: Kawika Avilla --------- Signed-off-by: Kawika Avilla Co-authored-by: Huan Jiang --- .github/workflows/cypress_workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index e354be1415ce..ac5d54bb7a2d 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -30,7 +30,7 @@ env: TEST_REPO: ${{ inputs.test_repo != '' && inputs.test_repo || 'opensearch-project/opensearch-dashboards-functional-test' }} TEST_BRANCH: "${{ inputs.test_branch != '' && inputs.test_branch || github.base_ref }}" FTR_PATH: 'ftr' - START_CMD: 'node ../scripts/opensearch_dashboards --dev --no-base-path --no-watch --savedObjects.maxImportPayloadBytes=10485760 --server.maxPayloadBytes=1759977 --logging.json=false --data.search.aggs.shardDelay.enabled=true' + START_CMD: 'node ../scripts/opensearch_dashboards --dev --no-base-path --no-watch --savedObjects.maxImportPayloadBytes=10485760 --server.maxPayloadBytes=1759977 --logging.json=false --data.search.aggs.shardDelay.enabled=true --csp.warnLegacyBrowsers=false' OPENSEARCH_SNAPSHOT_CMD: 'node ../scripts/opensearch snapshot -E cluster.routing.allocation.disk.threshold_enabled=false' CYPRESS_BROWSER: 'chromium' CYPRESS_VISBUILDER_ENABLED: true From 312075c9b8b1c881fb434a7c82c58a4c34780599 Mon Sep 17 00:00:00 2001 From: Miki Date: Mon, 25 Mar 2024 10:03:25 -0700 Subject: [PATCH 14/45] [osd/std] Add fallback mechanism when recovery from false-positives in handling of long numerals fails (#6253) Signed-off-by: Miki --- CHANGELOG.md | 1 + packages/osd-std/src/json.ts | 129 ++++++++++++++++++----------------- 2 files changed, 68 insertions(+), 62 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a5193ee87c54..2716ecc4a8ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -76,6 +76,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Discover] Fix lazy loading of the legacy table from getting stuck ([#6041](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6041)) - [BUG][Discover] Allow saved sort from search embeddable to load in Dashboard ([#5934](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5934)) - [osd/std] Add additional recovery from false-positives in handling of long numerals ([#5956](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5956), [#6245](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6245)) +- [osd/std] Add fallback mechanism when recovery from false-positives in handling of long numerals fails ([#6253](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6253)) - [BUG][Multiple Datasource] Fix missing customApiRegistryPromise param for test connection ([#5944](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5944)) - [BUG][Multiple Datasource] Add a migration function for datasource to add migrationVersion field ([#6025](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6025)) - [BUG][MD]Expose picker using function in data source management plugin setup([#6030](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6030)) diff --git a/packages/osd-std/src/json.ts b/packages/osd-std/src/json.ts index 7387790169a3..d8bb27e1eb6a 100644 --- a/packages/osd-std/src/json.ts +++ b/packages/osd-std/src/json.ts @@ -143,77 +143,82 @@ const parseStringWithLongNumerals = ( * To find those instances, we try to parse and watch for the location of any errors. If an error * is caused by the marking, we remove that single marking and try again. */ - do { - try { - hadException = false; - obj = parseMarkedText(markedJSON); - } catch (e) { - hadException = true; - /* There are two types of exception objects that can be raised: - * 1) a textual message with the position that we need to parse - * i. Unexpected [token|string ...] at position ... - * ii. Expected ',' or ... after ... in JSON at position ... - * iii. expected ',' or ... after ... in object at line ... column ... - * 2) a proper object with lineNumber and columnNumber which we can use - * Note: this might refer to the part of the code that threw the exception but - * we will try it anyway; the regex is specific enough to not produce - * false-positives. - */ - let { lineNumber, columnNumber } = e; - - if (typeof e?.message === 'string') { - /* Check for 1-i and 1-ii - * Finding "..."෴1111"..." inside a string value, the extra quotes throw a syntax error - * and the position points to " that is assumed to be the begining of a value. + try { + do { + try { + hadException = false; + obj = parseMarkedText(markedJSON); + } catch (e) { + hadException = true; + /* There are two types of exception objects that can be raised: + * 1) a textual message with the position that we need to parse + * i. Unexpected [token|string ...] at position ... + * ii. Expected ',' or ... after ... in JSON at position ... + * iii. expected ',' or ... after ... in object at line ... column ... + * 2) a proper object with lineNumber and columnNumber which we can use + * Note: this might refer to the part of the code that threw the exception but + * we will try it anyway; the regex is specific enough to not produce + * false-positives. */ - let match = e.message.match(/^(?:Une|E)xpected .*at position (\d+)(\D|$)/i); - if (match) { - lineNumber = 1; - // Add 1 to reach the marker - columnNumber = parseInt(match[1], 10) + 1; - } else { - /* Check for 1-iii - * Finding "...,"෴1111"..." inside a string value, the extra quotes throw a syntax error - * and the column number points to the marker after the " that is assumed to be terminating the - * value. - * PS: There are different versions of this error across browsers and platforms. + let { lineNumber, columnNumber } = e; + + if (typeof e?.message === 'string') { + /* Check for 1-i and 1-ii + * Finding "..."෴1111"..." inside a string value, the extra quotes throw a syntax error + * and the position points to " that is assumed to be the begining of a value. */ - // ToDo: Add functional tests for this path - match = e.message.match(/expected .*line (\d+) column (\d+)(\D|$)/i); + let match = e.message.match(/^(?:Un)?expected .*at position (\d+)(\D|$)/i); if (match) { - lineNumber = parseInt(match[1], 10); - columnNumber = parseInt(match[2], 10); + lineNumber = 1; + // Add 1 to reach the marker + columnNumber = parseInt(match[1], 10) + 1; + } else { + /* Check for 1-iii + * Finding "...,"෴1111"..." inside a string value, the extra quotes throw a syntax error + * and the column number points to the marker after the " that is assumed to be terminating the + * value. + * PS: There are different versions of this error across browsers and platforms. + */ + // ToDo: Add functional tests for this path + match = e.message.match(/expected .*line (\d+) column (\d+)(\D|$)/i); + if (match) { + lineNumber = parseInt(match[1], 10); + columnNumber = parseInt(match[2], 10); + } } } - } - if (lineNumber < 1 || columnNumber < 2) { - /* The problem is not with this replacement. - * Note: This will never happen because the outer parse would have already thrown. - */ - // coverage:ignore-line - throw e; - } + if (lineNumber < 1 || columnNumber < 2) { + /* The problem is not with this replacement. + * Note: This will never happen because the outer parse would have already thrown. + */ + // coverage:ignore-line + throw e; + } - /* We need to skip e.lineNumber - 1 number of `\n` occurrences. - * Then, we need to go to e.columnNumber - 2 to look for `"\d+"`; we need to `-1` to - * account for the quote but an additional `-1` is needed because columnNumber starts from 1. - */ - const re = new RegExp( - `^((?:.*\\n){${lineNumber - 1}}[^\\n]{${columnNumber - 2}})"${marker}(-?\\d+)"` - ); - if (!re.test(markedJSON)) { - /* The exception is not caused by adding the marker. - * Note: This will never happen because the outer parse would have already thrown. + /* We need to skip e.lineNumber - 1 number of `\n` occurrences. + * Then, we need to go to e.columnNumber - 2 to look for `"\d+"`; we need to `-1` to + * account for the quote but an additional `-1` is needed because columnNumber starts from 1. */ - // coverage:ignore-line - throw e; - } + const re = new RegExp( + `^((?:.*\\n){${lineNumber - 1}}[^\\n]{${columnNumber - 2}})"${marker}(-?\\d+)"` + ); + if (!re.test(markedJSON)) { + /* The exception is not caused by adding the marker. + * Note: This will never happen because the outer parse would have already thrown. + */ + // coverage:ignore-line + throw e; + } - // We have found a bad replacement; let's remove it. - markedJSON = markedJSON.replace(re, '$1$2'); - } - } while (hadException); + // We have found a bad replacement; let's remove it. + markedJSON = markedJSON.replace(re, '$1$2'); + } + } while (hadException); + } catch (ex) { + // If parsing of marked `text` fails, fallback to parsing the original `text` + obj = JSON.parse(text, reviver || undefined); + } return obj; }; From f27e80aa4331f55bbff66abb5c1c3cdef2b84b7f Mon Sep 17 00:00:00 2001 From: Huan Jiang Date: Mon, 25 Mar 2024 11:26:29 -0700 Subject: [PATCH 15/45] fix a doc reference typo in contribution doc (#6255) Signed-off-by: Huan Jiang --- CONTRIBUTING.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4a605d04c052..5fa2b116a242 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,7 +23,7 @@ OpenSearch is a community project that is built and maintained by people just li ### Join the Discussion -See the [communication guide](COMMUNICATION.md)for information on how to join our slack workspace, forum, or developer office hours. +See the [communication guide](COMMUNICATIONS.md)for information on how to join our slack workspace, forum, or developer office hours. ### Bug Reports From 6c25f2d078ebbddc6ec2684e9cb01ad5a55fdae6 Mon Sep 17 00:00:00 2001 From: Xinrui Bai-amazon <139305463+xinruiba@users.noreply.github.com> Date: Mon, 25 Mar 2024 12:21:24 -0700 Subject: [PATCH 16/45] [Multiple DataSource] Codebase maintenance involves updating typos and removing unused imported packages (#6238) * [MD] Fix typo and remove unused imported package Signed-off-by: Xinrui Bai * Update chagefile.md Signed-off-by: Xinrui Bai --------- Signed-off-by: Xinrui Bai --- CHANGELOG.md | 1 + .../authentication_methods_registry.mock.ts | 6 +-- .../authentication_methods_registry.test.ts | 8 +-- .../authentication_methods_registry.ts | 6 +-- .../data_source/server/auth_registry/index.ts | 4 +- .../server/client/configure_client.test.ts | 28 +++++------ .../server/client/configure_client_utils.ts | 9 ++-- .../legacy/configure_legacy_client.test.ts | 32 ++++++------ src/plugins/data_source/server/plugin.ts | 10 ++-- .../routes/fetch_data_source_version.test.ts | 8 +-- .../routes/fetch_data_source_version.ts | 4 +- .../server/routes/test_connection.test.ts | 8 +-- .../server/routes/test_connection.ts | 4 +- ...ource_saved_objects_client_wrapper.test.ts | 4 +- ...ata_source_saved_objects_client_wrapper.ts | 4 +- src/plugins/data_source/server/types.ts | 6 +-- .../authentication_methods_registry.test.ts | 8 +-- .../authentication_methods_registry.ts | 6 +-- .../public/auth_registry/index.ts | 4 +- .../create_data_source_form.test.tsx | 36 ++++++------- .../create_form/create_data_source_form.tsx | 20 ++++---- .../edit_form/edit_data_source_form.test.tsx | 16 +++--- .../edit_form/edit_data_source_form.tsx | 26 +++++----- .../edit_data_source.test.tsx | 6 +-- .../public/components/utils.test.ts | 50 +++++++++---------- .../public/components/utils.ts | 20 ++++---- .../datasource_form_validation.test.ts | 24 ++++----- .../validation/datasource_form_validation.ts | 6 +-- .../mount_management_section.tsx | 6 +-- .../data_source_management/public/mocks.ts | 6 +-- .../public/plugin.test.ts | 2 +- .../data_source_management/public/plugin.ts | 10 ++-- .../data_source_management/public/types.ts | 4 +- 33 files changed, 195 insertions(+), 197 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2716ecc4a8ce..54f7e22671bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Add Vega support to MDS by specifying a data source name in the Vega spec ([#5975](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5975)) - [Multiple Datasource] Test connection schema validation for registered auth types ([#6109](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6109)) - [Multiple DataSource] DataSource creation and edition page improvement to better support registered auth types ([#6122](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6122)) +- [Multiple DataSource] Codebase maintenance involves updating typos and removing unused imported packages ([#6238](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6238)) - [Workspace] Consume workspace id in saved object client ([#6014](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6014)) - [Multiple Datasource] Export DataSourcePluginRequestContext at top level for plugins to use ([#6108](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6108)) - [Multiple Datasource] Expose filterfn in datasource menu component to allow filter data sources before rendering in navigation bar ([#6113](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6113)) diff --git a/src/plugins/data_source/server/auth_registry/authentication_methods_registry.mock.ts b/src/plugins/data_source/server/auth_registry/authentication_methods_registry.mock.ts index 41e63798556e..06d7ce4993d0 100644 --- a/src/plugins/data_source/server/auth_registry/authentication_methods_registry.mock.ts +++ b/src/plugins/data_source/server/auth_registry/authentication_methods_registry.mock.ts @@ -3,12 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { IAuthenticationMethodRegistery } from './authentication_methods_registry'; +import { IAuthenticationMethodRegistry } from './authentication_methods_registry'; const create = () => (({ getAllAuthenticationMethods: jest.fn(), getAuthenticationMethod: jest.fn(), - } as unknown) as jest.Mocked); + } as unknown) as jest.Mocked); -export const authenticationMethodRegisteryMock = { create }; +export const authenticationMethodRegistryMock = { create }; diff --git a/src/plugins/data_source/server/auth_registry/authentication_methods_registry.test.ts b/src/plugins/data_source/server/auth_registry/authentication_methods_registry.test.ts index 948641870a8a..b171a5dab2d2 100644 --- a/src/plugins/data_source/server/auth_registry/authentication_methods_registry.test.ts +++ b/src/plugins/data_source/server/auth_registry/authentication_methods_registry.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthenticationMethodRegistery } from './authentication_methods_registry'; +import { AuthenticationMethodRegistry } from './authentication_methods_registry'; import { AuthenticationMethod } from '../../server/types'; const createAuthenticationMethod = ( @@ -14,11 +14,11 @@ const createAuthenticationMethod = ( ...authMethod, }); -describe('AuthenticationMethodRegistery', () => { - let registry: AuthenticationMethodRegistery; +describe('AuthenticationMethodRegistry', () => { + let registry: AuthenticationMethodRegistry; beforeEach(() => { - registry = new AuthenticationMethodRegistery(); + registry = new AuthenticationMethodRegistry(); }); it('allows to register authentication method', () => { diff --git a/src/plugins/data_source/server/auth_registry/authentication_methods_registry.ts b/src/plugins/data_source/server/auth_registry/authentication_methods_registry.ts index 9fe2eb1e37e3..988c5b9f37d0 100644 --- a/src/plugins/data_source/server/auth_registry/authentication_methods_registry.ts +++ b/src/plugins/data_source/server/auth_registry/authentication_methods_registry.ts @@ -7,12 +7,12 @@ import { deepFreeze } from '@osd/std'; import { AuthenticationMethod } from '../../server/types'; import { AuthType } from '../../common/data_sources'; -export type IAuthenticationMethodRegistery = Omit< - AuthenticationMethodRegistery, +export type IAuthenticationMethodRegistry = Omit< + AuthenticationMethodRegistry, 'registerAuthenticationMethod' >; -export class AuthenticationMethodRegistery { +export class AuthenticationMethodRegistry { private readonly authMethods = new Map(); /** * Register a authMethods with function to return credentials inside the registry. diff --git a/src/plugins/data_source/server/auth_registry/index.ts b/src/plugins/data_source/server/auth_registry/index.ts index 9352afd8b661..704f5a26975e 100644 --- a/src/plugins/data_source/server/auth_registry/index.ts +++ b/src/plugins/data_source/server/auth_registry/index.ts @@ -4,6 +4,6 @@ */ export { - IAuthenticationMethodRegistery, - AuthenticationMethodRegistery, + IAuthenticationMethodRegistry, + AuthenticationMethodRegistry, } from './authentication_methods_registry'; diff --git a/src/plugins/data_source/server/client/configure_client.test.ts b/src/plugins/data_source/server/client/configure_client.test.ts index f01db28080e6..3cc89a5ef6e3 100644 --- a/src/plugins/data_source/server/client/configure_client.test.ts +++ b/src/plugins/data_source/server/client/configure_client.test.ts @@ -28,8 +28,8 @@ import { cryptographyServiceSetupMock } from '../cryptography_service.mocks'; import { CryptographyServiceSetup } from '../cryptography_service'; import { DataSourceClientParams, AuthenticationMethod, ClientParameters } from '../types'; import { CustomApiSchemaRegistry } from '../schema_registry'; -import { IAuthenticationMethodRegistery } from '../auth_registry'; -import { authenticationMethodRegisteryMock } from '../auth_registry/authentication_methods_registry.mock'; +import { IAuthenticationMethodRegistry } from '../auth_registry'; +import { authenticationMethodRegistryMock } from '../auth_registry/authentication_methods_registry.mock'; const DATA_SOURCE_ID = 'a54b76ec86771ee865a0f74a305dfff8'; @@ -46,7 +46,7 @@ describe('configureClient', () => { let usernamePasswordAuthContent: UsernamePasswordTypedContent; let sigV4AuthContent: SigV4Content; let customApiSchemaRegistry: CustomApiSchemaRegistry; - let authenticationMethodRegistery: jest.Mocked; + let authenticationMethodRegistry: jest.Mocked; let clientParameters: ClientParameters; const customAuthContent = { @@ -70,7 +70,7 @@ describe('configureClient', () => { savedObjectsMock = savedObjectsClientMock.create(); cryptographyMock = cryptographyServiceSetupMock.create(); customApiSchemaRegistry = new CustomApiSchemaRegistry(); - authenticationMethodRegistery = authenticationMethodRegisteryMock.create(); + authenticationMethodRegistry = authenticationMethodRegistryMock.create(); config = { enabled: true, @@ -128,7 +128,7 @@ describe('configureClient', () => { }; ClientMock.mockImplementation(() => dsClient); - authenticationMethodRegistery.getAuthenticationMethod.mockImplementation(() => authMethod); + authenticationMethodRegistry.getAuthenticationMethod.mockImplementation(() => authMethod); authRegistryCredentialProviderMock.mockReturnValue(clientParameters); }); @@ -299,13 +299,13 @@ describe('configureClient', () => { }); const client = await configureClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, clientPoolSetup, config, logger ); expect(authRegistryCredentialProviderMock).toHaveBeenCalled(); - expect(authenticationMethodRegistery.getAuthenticationMethod).toHaveBeenCalledTimes(1); + expect(authenticationMethodRegistry.getAuthenticationMethod).toHaveBeenCalledTimes(1); expect(ClientMock).toHaveBeenCalledTimes(1); expect(savedObjectsMock.get).toHaveBeenCalledTimes(1); expect(client).toBe(dsClient.child.mock.results[0].value); @@ -343,7 +343,7 @@ describe('configureClient', () => { }); const client = await configureClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, clientPoolSetup, config, logger @@ -379,7 +379,7 @@ describe('configureClient', () => { }); const client = await configureClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, clientPoolSetup, config, logger @@ -555,7 +555,7 @@ describe('configureClient', () => { name: 'clientPoolTest', credentialProvider: jest.fn(), }; - authenticationMethodRegistery.getAuthenticationMethod + authenticationMethodRegistry.getAuthenticationMethod .mockReset() .mockImplementation(() => authMethodWithClientPool); const mockDataSourceAttr = { ...dataSourceAttr, name: 'custom_auth' }; @@ -574,14 +574,14 @@ describe('configureClient', () => { }); test('If endpoint is same for multiple requests client pool size should be 1', async () => { await configureClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, opensearchClientPoolSetup, config, logger ); await configureClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, opensearchClientPoolSetup, config, logger @@ -592,7 +592,7 @@ describe('configureClient', () => { test('If endpoint is different for two requests client pool size should be 2', async () => { await configureClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, opensearchClientPoolSetup, config, logger @@ -622,7 +622,7 @@ describe('configureClient', () => { }); await configureClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, opensearchClientPoolSetup, config, logger diff --git a/src/plugins/data_source/server/client/configure_client_utils.ts b/src/plugins/data_source/server/client/configure_client_utils.ts index a7229a0d32fa..2748ddfd5ca4 100644 --- a/src/plugins/data_source/server/client/configure_client_utils.ts +++ b/src/plugins/data_source/server/client/configure_client_utils.ts @@ -5,10 +5,7 @@ import { Client } from '@opensearch-project/opensearch'; import { Client as LegacyClient } from 'elasticsearch'; -import { - OpenSearchDashboardsRequest, - SavedObjectsClientContract, -} from '../../../../../src/core/server'; +import { SavedObjectsClientContract } from '../../../../../src/core/server'; import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../common'; import { DataSourceAttributes, @@ -18,7 +15,7 @@ import { } from '../../common/data_sources'; import { CryptographyServiceSetup } from '../cryptography_service'; import { createDataSourceError } from '../lib/error'; -import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { IAuthenticationMethodRegistry } from '../auth_registry'; import { AuthenticationMethod, ClientParameters } from '../types'; /** @@ -142,7 +139,7 @@ export const generateCacheKey = (endpoint: string, cacheKeySuffix?: string) => { export const getAuthenticationMethod = ( dataSourceAttr: DataSourceAttributes, - authRegistry?: IAuthenticationMethodRegistery + authRegistry?: IAuthenticationMethodRegistry ): AuthenticationMethod => { const name = dataSourceAttr.name ?? dataSourceAttr.auth.type; return authRegistry?.getAuthenticationMethod(name) as AuthenticationMethod; diff --git a/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts b/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts index 581e545315e2..b657df32565d 100644 --- a/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts +++ b/src/plugins/data_source/server/legacy/configure_legacy_client.test.ts @@ -27,8 +27,8 @@ import { ClientMock, parseClientOptionsMock } from './configure_legacy_client.te import { authRegistryCredentialProviderMock } from '../client/configure_client.test.mocks'; import { configureLegacyClient } from './configure_legacy_client'; import { CustomApiSchemaRegistry } from '../schema_registry'; -import { IAuthenticationMethodRegistery } from '../auth_registry'; -import { authenticationMethodRegisteryMock } from '../auth_registry/authentication_methods_registry.mock'; +import { IAuthenticationMethodRegistry } from '../auth_registry'; +import { authenticationMethodRegistryMock } from '../auth_registry/authentication_methods_registry.mock'; const DATA_SOURCE_ID = 'a54b76ec86771ee865a0f74a305dfff8'; @@ -41,7 +41,7 @@ describe('configureLegacyClient', () => { let configOptions: ConfigOptions; let dataSourceAttr: DataSourceAttributes; let sigV4AuthContent: SigV4Content; - let authenticationMethodRegistery: jest.Mocked; + let authenticationMethodRegistry: jest.Mocked; let clientParameters: ClientParameters; let mockOpenSearchClientInstance: { @@ -77,7 +77,7 @@ describe('configureLegacyClient', () => { logger = loggingSystemMock.createLogger(); savedObjectsMock = savedObjectsClientMock.create(); cryptographyMock = cryptographyServiceSetupMock.create(); - authenticationMethodRegistery = authenticationMethodRegisteryMock.create(); + authenticationMethodRegistry = authenticationMethodRegistryMock.create(); config = { enabled: true, clientPool: { @@ -144,7 +144,7 @@ describe('configureLegacyClient', () => { }); }); - authenticationMethodRegistery.getAuthenticationMethod.mockImplementation(() => authMethod); + authenticationMethodRegistry.getAuthenticationMethod.mockImplementation(() => authMethod); authRegistryCredentialProviderMock.mockReturnValue(clientParameters); }); @@ -318,14 +318,14 @@ describe('configureLegacyClient', () => { }); await configureLegacyClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, callApiParams, clientPoolSetup, config, logger ); expect(authRegistryCredentialProviderMock).toHaveBeenCalled(); - expect(authenticationMethodRegistery.getAuthenticationMethod).toHaveBeenCalledTimes(1); + expect(authenticationMethodRegistry.getAuthenticationMethod).toHaveBeenCalledTimes(1); expect(ClientMock).toHaveBeenCalledTimes(1); expect(savedObjectsMock.get).toHaveBeenCalledTimes(1); expect(mockOpenSearchClientInstance.ping).toHaveBeenCalledTimes(1); @@ -365,14 +365,14 @@ describe('configureLegacyClient', () => { }); await configureLegacyClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, callApiParams, clientPoolSetup, config, logger ); expect(authRegistryCredentialProviderMock).toHaveBeenCalled(); - expect(authenticationMethodRegistery.getAuthenticationMethod).toHaveBeenCalledTimes(1); + expect(authenticationMethodRegistry.getAuthenticationMethod).toHaveBeenCalledTimes(1); expect(ClientMock).toHaveBeenCalledTimes(1); expect(savedObjectsMock.get).toHaveBeenCalledTimes(1); expect(mockOpenSearchClientInstance.ping).toHaveBeenCalledTimes(1); @@ -405,14 +405,14 @@ describe('configureLegacyClient', () => { }); await configureLegacyClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, callApiParams, clientPoolSetup, config, logger ); expect(authRegistryCredentialProviderMock).toHaveBeenCalled(); - expect(authenticationMethodRegistery.getAuthenticationMethod).toHaveBeenCalledTimes(1); + expect(authenticationMethodRegistry.getAuthenticationMethod).toHaveBeenCalledTimes(1); expect(ClientMock).toHaveBeenCalledTimes(1); expect(savedObjectsMock.get).toHaveBeenCalledTimes(1); expect(mockOpenSearchClientInstance.ping).toHaveBeenCalledTimes(1); @@ -657,7 +657,7 @@ describe('configureLegacyClient', () => { name: 'clientPoolTest', credentialProvider: jest.fn(), }; - authenticationMethodRegistery.getAuthenticationMethod + authenticationMethodRegistry.getAuthenticationMethod .mockReset() .mockImplementation(() => authMethodWithClientPool); const mockDataSourceAttr = { ...dataSourceAttr, name: 'custom_auth' }; @@ -676,7 +676,7 @@ describe('configureLegacyClient', () => { }); test(' If endpoint is same for multiple requests client pool size should be 1', async () => { await configureLegacyClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, callApiParams, opensearchClientPoolSetup, config, @@ -684,7 +684,7 @@ describe('configureLegacyClient', () => { ); await configureLegacyClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, callApiParams, opensearchClientPoolSetup, config, @@ -696,7 +696,7 @@ describe('configureLegacyClient', () => { test('If endpoint is different for two requests client pool size should be 2', async () => { await configureLegacyClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, callApiParams, opensearchClientPoolSetup, config, @@ -727,7 +727,7 @@ describe('configureLegacyClient', () => { }); await configureLegacyClient( - { ...dataSourceClientParams, authRegistry: authenticationMethodRegistery }, + { ...dataSourceClientParams, authRegistry: authenticationMethodRegistry }, callApiParams, opensearchClientPoolSetup, config, diff --git a/src/plugins/data_source/server/plugin.ts b/src/plugins/data_source/server/plugin.ts index c239c816594e..6eabe5431689 100644 --- a/src/plugins/data_source/server/plugin.ts +++ b/src/plugins/data_source/server/plugin.ts @@ -31,7 +31,7 @@ import { ensureRawRequest } from '../../../../src/core/server/http/router'; import { createDataSourceError } from './lib/error'; import { registerTestConnectionRoute } from './routes/test_connection'; import { registerFetchDataSourceVersionRoute } from './routes/fetch_data_source_version'; -import { AuthenticationMethodRegistery, IAuthenticationMethodRegistery } from './auth_registry'; +import { AuthenticationMethodRegistry, IAuthenticationMethodRegistry } from './auth_registry'; import { CustomApiSchemaRegistry } from './schema_registry'; export class DataSourcePlugin implements Plugin { @@ -40,7 +40,7 @@ export class DataSourcePlugin implements Plugin; private started = false; - private authMethodsRegistry = new AuthenticationMethodRegistery(); + private authMethodsRegistry = new AuthenticationMethodRegistry(); private customApiSchemaRegistry = new CustomApiSchemaRegistry(); constructor(private initializerContext: PluginInitializerContext) { @@ -64,7 +64,7 @@ export class DataSourcePlugin implements Plugin { const dataSourcePluginStart = selfStart as DataSourcePluginStart; - return dataSourcePluginStart.getAuthenticationMethodRegistery(); + return dataSourcePluginStart.getAuthenticationMethodRegistry(); }); const dataSourceSavedObjectsClientWrapper = new DataSourceSavedObjectsClientWrapper( @@ -162,7 +162,7 @@ export class DataSourcePlugin implements Plugin this.authMethodsRegistry, + getAuthenticationMethodRegistry: () => this.authMethodsRegistry, getCustomApiSchemaRegistry: () => this.customApiSchemaRegistry, }; } @@ -176,7 +176,7 @@ export class DataSourcePlugin implements Plugin, - authRegistryPromise: Promise, + authRegistryPromise: Promise, customApiSchemaRegistryPromise: Promise ): IContextProvider, 'dataSource'> => { return async (context, req) => { diff --git a/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts b/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts index d81073f9beba..5514cbdc40f6 100644 --- a/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts +++ b/src/plugins/data_source/server/routes/fetch_data_source_version.test.ts @@ -7,8 +7,8 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@osd/utility-types'; import { setupServer } from '../../../../../src/core/server/test_utils'; -import { IAuthenticationMethodRegistery } from '../auth_registry'; -import { authenticationMethodRegisteryMock } from '../auth_registry/authentication_methods_registry.mock'; +import { IAuthenticationMethodRegistry } from '../auth_registry'; +import { authenticationMethodRegistryMock } from '../auth_registry/authentication_methods_registry.mock'; import { CustomApiSchemaRegistry } from '../schema_registry'; import { DataSourceServiceSetup } from '../../server/data_source_service'; import { CryptographyServiceSetup } from '../cryptography_service'; @@ -30,7 +30,7 @@ describe(`Fetch DataSource Version ${URL}`, () => { let customApiSchemaRegistryPromise: Promise; let dataSourceClient: ReturnType; let dataSourceServiceSetupMock: DataSourceServiceSetup; - let authRegistryPromiseMock: Promise; + let authRegistryPromiseMock: Promise; const dataSourceAttr = { endpoint: 'https://test.com', auth: { @@ -155,7 +155,7 @@ describe(`Fetch DataSource Version ${URL}`, () => { beforeEach(async () => { ({ server, httpSetup, handlerContext } = await setupServer()); customApiSchemaRegistryPromise = Promise.resolve(customApiSchemaRegistry); - authRegistryPromiseMock = Promise.resolve(authenticationMethodRegisteryMock.create()); + authRegistryPromiseMock = Promise.resolve(authenticationMethodRegistryMock.create()); dataSourceClient = opensearchClientMock.createInternalClient(); dataSourceServiceSetupMock = { diff --git a/src/plugins/data_source/server/routes/fetch_data_source_version.ts b/src/plugins/data_source/server/routes/fetch_data_source_version.ts index 5bd53f728e44..b2f03f7fddc0 100644 --- a/src/plugins/data_source/server/routes/fetch_data_source_version.ts +++ b/src/plugins/data_source/server/routes/fetch_data_source_version.ts @@ -9,14 +9,14 @@ import { AuthType, DataSourceAttributes, SigV4ServiceName } from '../../common/d import { DataSourceConnectionValidator } from './data_source_connection_validator'; import { DataSourceServiceSetup } from '../data_source_service'; import { CryptographyServiceSetup } from '../cryptography_service'; -import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { IAuthenticationMethodRegistry } from '../auth_registry'; import { CustomApiSchemaRegistry } from '../schema_registry/custom_api_schema_registry'; export const registerFetchDataSourceVersionRoute = async ( router: IRouter, dataSourceServiceSetup: DataSourceServiceSetup, cryptography: CryptographyServiceSetup, - authRegistryPromise: Promise, + authRegistryPromise: Promise, customApiSchemaRegistryPromise: Promise ) => { const authRegistry = await authRegistryPromise; diff --git a/src/plugins/data_source/server/routes/test_connection.test.ts b/src/plugins/data_source/server/routes/test_connection.test.ts index 8027354601b3..cbe35ef6562a 100644 --- a/src/plugins/data_source/server/routes/test_connection.test.ts +++ b/src/plugins/data_source/server/routes/test_connection.test.ts @@ -7,8 +7,8 @@ import supertest from 'supertest'; import { UnwrapPromise } from '@osd/utility-types'; import { setupServer } from '../../../../../src/core/server/test_utils'; -import { IAuthenticationMethodRegistery } from '../auth_registry'; -import { authenticationMethodRegisteryMock } from '../auth_registry/authentication_methods_registry.mock'; +import { IAuthenticationMethodRegistry } from '../auth_registry'; +import { authenticationMethodRegistryMock } from '../auth_registry/authentication_methods_registry.mock'; import { CustomApiSchemaRegistry } from '../schema_registry'; import { DataSourceServiceSetup } from '../../server/data_source_service'; import { CryptographyServiceSetup } from '../cryptography_service'; @@ -30,7 +30,7 @@ describe(`Test connection ${URL}`, () => { let customApiSchemaRegistryPromise: Promise; let dataSourceClient: ReturnType; let dataSourceServiceSetupMock: DataSourceServiceSetup; - let authRegistryPromiseMock: Promise; + let authRegistryPromiseMock: Promise; const dataSourceAttr = { endpoint: 'https://test.com', auth: { @@ -155,7 +155,7 @@ describe(`Test connection ${URL}`, () => { beforeEach(async () => { ({ server, httpSetup, handlerContext } = await setupServer()); customApiSchemaRegistryPromise = Promise.resolve(customApiSchemaRegistry); - authRegistryPromiseMock = Promise.resolve(authenticationMethodRegisteryMock.create()); + authRegistryPromiseMock = Promise.resolve(authenticationMethodRegistryMock.create()); dataSourceClient = opensearchClientMock.createInternalClient(); dataSourceServiceSetupMock = { diff --git a/src/plugins/data_source/server/routes/test_connection.ts b/src/plugins/data_source/server/routes/test_connection.ts index 4edebc12fa8d..1aded3a21324 100644 --- a/src/plugins/data_source/server/routes/test_connection.ts +++ b/src/plugins/data_source/server/routes/test_connection.ts @@ -9,14 +9,14 @@ import { AuthType, DataSourceAttributes, SigV4ServiceName } from '../../common/d import { DataSourceConnectionValidator } from './data_source_connection_validator'; import { DataSourceServiceSetup } from '../data_source_service'; import { CryptographyServiceSetup } from '../cryptography_service'; -import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { IAuthenticationMethodRegistry } from '../auth_registry'; import { CustomApiSchemaRegistry } from '../schema_registry/custom_api_schema_registry'; export const registerTestConnectionRoute = async ( router: IRouter, dataSourceServiceSetup: DataSourceServiceSetup, cryptography: CryptographyServiceSetup, - authRegistryPromise: Promise, + authRegistryPromise: Promise, customApiSchemaRegistryPromise: Promise ) => { const authRegistry = await authRegistryPromise; diff --git a/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.test.ts b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.test.ts index d6d06374a67c..4921218a2d48 100644 --- a/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.test.ts +++ b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.test.ts @@ -23,10 +23,10 @@ describe('DataSourceSavedObjectsClientWrapper', () => { credentialProvider: jest.fn(), }; jest.mock('../auth_registry'); - const { AuthenticationMethodRegistery: authenticationMethodRegistery } = jest.requireActual( + const { AuthenticationMethodRegistry: authenticationMethodRegistry } = jest.requireActual( '../auth_registry' ); - const authRegistry = new authenticationMethodRegistery(); + const authRegistry = new authenticationMethodRegistry(); authRegistry.registerAuthenticationMethod(customAuthMethod); const requestHandlerContext = coreMock.createRequestHandlerContext(); diff --git a/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts index a35313553993..25fdf6d59d75 100644 --- a/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts +++ b/src/plugins/data_source/server/saved_objects/data_source_saved_objects_client_wrapper.ts @@ -25,7 +25,7 @@ import { } from '../../common/data_sources'; import { EncryptionContext, CryptographyServiceSetup } from '../cryptography_service'; import { isValidURL } from '../util/endpoint_validator'; -import { IAuthenticationMethodRegistery } from '../auth_registry'; +import { IAuthenticationMethodRegistry } from '../auth_registry'; /** * Describes the Credential Saved Objects Client Wrapper class, @@ -141,7 +141,7 @@ export class DataSourceSavedObjectsClientWrapper { constructor( private cryptography: CryptographyServiceSetup, private logger: Logger, - private authRegistryPromise: Promise, + private authRegistryPromise: Promise, private endpointBlockedIps?: string[] ) {} diff --git a/src/plugins/data_source/server/types.ts b/src/plugins/data_source/server/types.ts index 847e2f72ff68..1f7d297c8b87 100644 --- a/src/plugins/data_source/server/types.ts +++ b/src/plugins/data_source/server/types.ts @@ -18,7 +18,7 @@ import { import { CryptographyServiceSetup } from './cryptography_service'; import { DataSourceError } from './lib/error'; -import { IAuthenticationMethodRegistery } from './auth_registry'; +import { IAuthenticationMethodRegistry } from './auth_registry'; import { CustomApiSchemaRegistry } from './schema_registry'; export interface LegacyClientCallAPIParams { @@ -40,7 +40,7 @@ export interface DataSourceClientParams { // When client parameters are required to be retrieved from the request header, the caller should provide the request. request?: OpenSearchDashboardsRequest; // To retrieve the credentials provider for the authentication method from the registry in order to return the client. - authRegistry?: IAuthenticationMethodRegistery; + authRegistry?: IAuthenticationMethodRegistry; } export interface DataSourceCredentialsProviderOptions { @@ -95,6 +95,6 @@ export interface DataSourcePluginSetup { } export interface DataSourcePluginStart { - getAuthenticationMethodRegistery: () => IAuthenticationMethodRegistery; + getAuthenticationMethodRegistry: () => IAuthenticationMethodRegistry; getCustomApiSchemaRegistry: () => CustomApiSchemaRegistry; } diff --git a/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.test.ts b/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.test.ts index f2bd07af4dc5..599f66018d4d 100644 --- a/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.test.ts +++ b/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.test.ts @@ -3,15 +3,15 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { AuthenticationMethodRegistery } from './authentication_methods_registry'; +import { AuthenticationMethodRegistry } from './authentication_methods_registry'; import React from 'react'; import { createAuthenticationMethod } from '../mocks'; -describe('AuthenticationMethodRegistery', () => { - let registry: AuthenticationMethodRegistery; +describe('AuthenticationMethodRegistry', () => { + let registry: AuthenticationMethodRegistry; beforeEach(() => { - registry = new AuthenticationMethodRegistery(); + registry = new AuthenticationMethodRegistry(); }); it('allows to register authentication method', () => { diff --git a/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.ts b/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.ts index 7d6bf38026ad..a9443601af0a 100644 --- a/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.ts +++ b/src/plugins/data_source_management/public/auth_registry/authentication_methods_registry.ts @@ -16,12 +16,12 @@ export interface AuthenticationMethod { credentialFormField?: { [key: string]: string }; } -export type IAuthenticationMethodRegistery = Omit< - AuthenticationMethodRegistery, +export type IAuthenticationMethodRegistry = Omit< + AuthenticationMethodRegistry, 'registerAuthenticationMethod' >; -export class AuthenticationMethodRegistery { +export class AuthenticationMethodRegistry { private readonly authMethods = new Map(); /** * Register a authMethods with function to return credentials inside the registry. diff --git a/src/plugins/data_source_management/public/auth_registry/index.ts b/src/plugins/data_source_management/public/auth_registry/index.ts index 5cbadd12a51a..bc832d1c4f11 100644 --- a/src/plugins/data_source_management/public/auth_registry/index.ts +++ b/src/plugins/data_source_management/public/auth_registry/index.ts @@ -4,7 +4,7 @@ */ export { - IAuthenticationMethodRegistery, + IAuthenticationMethodRegistry, AuthenticationMethod, - AuthenticationMethodRegistery, + AuthenticationMethodRegistry, } from './authentication_methods_registry'; diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.test.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.test.tsx index 2c7778868bca..e03d3ef2b736 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.test.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.test.tsx @@ -17,7 +17,7 @@ import { sigV4AuthMethod, usernamePasswordAuthMethod, } from '../../../../types'; -import { AuthenticationMethod, AuthenticationMethodRegistery } from '../../../../auth_registry'; +import { AuthenticationMethod, AuthenticationMethodRegistry } from '../../../../auth_registry'; const titleIdentifier = '[data-test-subj="createDataSourceFormTitleField"]'; const descriptionIdentifier = `[data-test-subj="createDataSourceFormDescriptionField"]`; @@ -30,13 +30,13 @@ const testConnectionButtonIdentifier = '[data-test-subj="createDataSourceTestCon describe('Datasource Management: Create Datasource form', () => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod( noAuthCredentialAuthMethod ); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod( usernamePasswordAuthMethod ); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(sigV4AuthMethod); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(sigV4AuthMethod); let component: ReactWrapper, React.Component<{}, {}, any>>; const mockSubmitHandler = jest.fn(); @@ -257,10 +257,10 @@ describe('Datasource Management: Create Datasource form with different authType authMethodCombinationsToBeTested.forEach((authMethodCombination) => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + mockedContext.authenticationMethodRegistry = new AuthenticationMethodRegistry(); authMethodCombination.forEach((authMethod) => { - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(authMethod); }); component = mount( @@ -296,10 +296,10 @@ describe('Datasource Management: Create Datasource form with different authType authMethodCombinationsToBeTested.forEach((authMethodCombination) => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + mockedContext.authenticationMethodRegistry = new AuthenticationMethodRegistry(); authMethodCombination.forEach((authMethod) => { - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(authMethod); }); component = mount( @@ -335,10 +335,10 @@ describe('Datasource Management: Create Datasource form with different authType authMethodCombinationsToBeTested.forEach((authMethodCombination) => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + mockedContext.authenticationMethodRegistry = new AuthenticationMethodRegistry(); authMethodCombination.forEach((authMethod) => { - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(authMethod); }); component = mount( @@ -398,10 +398,10 @@ describe('Datasource Management: Create Datasource form with registered Auth Typ authMethodCombinationsToBeTested.forEach((authMethodCombination) => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + mockedContext.authenticationMethodRegistry = new AuthenticationMethodRegistry(); authMethodCombination.forEach((authMethod) => { - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(authMethod); }); component = mount( @@ -451,10 +451,10 @@ describe('Datasource Management: Create Datasource form with registered Auth Typ authMethodCombinationsToBeTested.forEach((authMethodCombination) => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + mockedContext.authenticationMethodRegistry = new AuthenticationMethodRegistry(); authMethodCombination.forEach((authMethod) => { - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(authMethod); }); component = mount( @@ -498,10 +498,10 @@ describe('Datasource Management: Create Datasource form with registered Auth Typ authMethodCombinationsToBeTested.forEach((authMethodCombination) => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); + mockedContext.authenticationMethodRegistry = new AuthenticationMethodRegistry(); authMethodCombination.forEach((authMethod) => { - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(authMethod); }); component = mount( @@ -541,8 +541,8 @@ describe('Datasource Management: Create Datasource form with registered Auth Typ } as AuthenticationMethod; const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + mockedContext.authenticationMethodRegistry = new AuthenticationMethodRegistry(); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(authMethodToBeTested); component = mount( wrapWithIntl( diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx index 180476e8b08c..e2f256d23e80 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx @@ -22,7 +22,7 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; -import { AuthenticationMethodRegistery } from '../../../../auth_registry'; +import { AuthenticationMethodRegistry } from '../../../../auth_registry'; import { SigV4Content, SigV4ServiceName } from '../../../../../../data_source/common/data_sources'; import { AuthType, @@ -77,17 +77,17 @@ export class CreateDataSourceForm extends React.Component< authOptions: Array> = []; isNoAuthOptionEnabled: boolean; - authenticationMethodRegistery: AuthenticationMethodRegistery; + authenticationMethodRegistry: AuthenticationMethodRegistry; constructor(props: CreateDataSourceProps, context: DataSourceManagementContextValue) { super(props, context); - this.authenticationMethodRegistery = context.services.authenticationMethodRegistery; - const registeredAuthMethods = this.authenticationMethodRegistery.getAllAuthenticationMethods(); - const initialSelectedAuthMethod = getDefaultAuthMethod(this.authenticationMethodRegistery); + this.authenticationMethodRegistry = context.services.authenticationMethodRegistry; + const registeredAuthMethods = this.authenticationMethodRegistry.getAllAuthenticationMethods(); + const initialSelectedAuthMethod = getDefaultAuthMethod(this.authenticationMethodRegistry); this.isNoAuthOptionEnabled = - this.authenticationMethodRegistery.getAuthenticationMethod(AuthType.NoAuth) !== undefined; + this.authenticationMethodRegistry.getAuthenticationMethod(AuthType.NoAuth) !== undefined; this.authOptions = registeredAuthMethods.map((authMethod) => { return authMethod.credentialSourceOption; @@ -114,7 +114,7 @@ export class CreateDataSourceForm extends React.Component< this.state, this.props.existingDatasourceNamesList, '', - this.authenticationMethodRegistery + this.authenticationMethodRegistry ); }; @@ -158,7 +158,7 @@ export class CreateDataSourceForm extends React.Component< const registeredAuthCredentials = extractRegisteredAuthTypeCredentials( (credentials ?? {}) as { [key: string]: string }, authType, - this.authenticationMethodRegistery + this.authenticationMethodRegistry ); this.setState({ @@ -337,7 +337,7 @@ export class CreateDataSourceForm extends React.Component< credentials = extractRegisteredAuthTypeCredentials( currentCredentials, authType, - this.authenticationMethodRegistery + this.authenticationMethodRegistry ); } @@ -354,7 +354,7 @@ export class CreateDataSourceForm extends React.Component< }; getCredentialFormFromRegistry = (authType: string) => { - const registeredAuthMethod = this.authenticationMethodRegistery.getAuthenticationMethod( + const registeredAuthMethod = this.authenticationMethodRegistry.getAuthenticationMethod( authType ); const authCredentialForm = registeredAuthMethod?.credentialForm; diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx index e5f73c503f6e..be5a3a31be73 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.test.tsx @@ -22,7 +22,7 @@ import { sigV4AuthMethod, usernamePasswordAuthMethod, } from '../../../../types'; -import { AuthenticationMethod, AuthenticationMethodRegistery } from '../../../../auth_registry'; +import { AuthenticationMethod, AuthenticationMethodRegistry } from '../../../../auth_registry'; const titleFieldIdentifier = 'dataSourceTitle'; const titleFormRowIdentifier = '[data-test-subj="editDataSourceTitleFormRow"]'; @@ -36,13 +36,13 @@ const passwordFieldIdentifier = '[data-test-subj="updateDataSourceFormPasswordFi const updatePasswordBtnIdentifier = '[data-test-subj="editDatasourceUpdatePasswordBtn"]'; describe('Datasource Management: Edit Datasource Form', () => { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod( noAuthCredentialAuthMethod ); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod( usernamePasswordAuthMethod ); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(sigV4AuthMethod); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(sigV4AuthMethod); let component: ReactWrapper, React.Component<{}, {}, any>>; const mockFn = jest.fn(); @@ -385,8 +385,8 @@ describe('With Registered Authentication', () => { } as AuthenticationMethod; const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTest); + mockedContext.authenticationMethodRegistry = new AuthenticationMethodRegistry(); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(authMethodToBeTest); component = mount( wrapWithIntl( @@ -426,8 +426,8 @@ describe('With Registered Authentication', () => { } as AuthenticationMethod; const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); - mockedContext.authenticationMethodRegistery = new AuthenticationMethodRegistery(); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTest); + mockedContext.authenticationMethodRegistry = new AuthenticationMethodRegistry(); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(authMethodToBeTest); component = mount( wrapWithIntl( diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx index 6714881c4a11..63336cca5d32 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/edit_form/edit_data_source_form.tsx @@ -25,7 +25,7 @@ import { import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; import deepEqual from 'fast-deep-equal'; -import { AuthenticationMethodRegistery } from '../../../../auth_registry'; +import { AuthenticationMethodRegistry } from '../../../../auth_registry'; import { SigV4Content, SigV4ServiceName } from '../../../../../../data_source/common/data_sources'; import { Header } from '../header'; import { @@ -81,19 +81,19 @@ export class EditDataSourceForm extends React.Component> = []; - authenticationMethodRegistery: AuthenticationMethodRegistery; + authenticationMethodRegistry: AuthenticationMethodRegistry; constructor(props: EditDataSourceProps, context: DataSourceManagementContextValue) { super(props, context); - this.authenticationMethodRegistery = context.services.authenticationMethodRegistery; - this.authOptions = this.authenticationMethodRegistery + this.authenticationMethodRegistry = context.services.authenticationMethodRegistry; + this.authOptions = this.authenticationMethodRegistry .getAllAuthenticationMethods() .map((authMethod) => { return authMethod.credentialSourceOption; }); - const initialSelectedAuthMethod = getDefaultAuthMethod(this.authenticationMethodRegistery); + const initialSelectedAuthMethod = getDefaultAuthMethod(this.authenticationMethodRegistry); this.state = { formErrorsByField: { ...defaultValidation }, @@ -129,7 +129,7 @@ export class EditDataSourceForm extends React.Component { - const registeredAuthMethod = this.authenticationMethodRegistery.getAuthenticationMethod( + const registeredAuthMethod = this.authenticationMethodRegistry.getAuthenticationMethod( authType ); const authCredentialForm = registeredAuthMethod?.credentialForm; @@ -1078,13 +1078,13 @@ export class EditDataSourceForm extends React.Component { const mockedContext = mockManagementPlugin.createDataSourceManagementContext(); const uiSettings = mockedContext.uiSettings; - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod( noAuthCredentialAuthMethod ); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod( + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod( usernamePasswordAuthMethod ); - mockedContext.authenticationMethodRegistery.registerAuthenticationMethod(sigV4AuthMethod); + mockedContext.authenticationMethodRegistry.registerAuthenticationMethod(sigV4AuthMethod); let component: ReactWrapper, React.Component<{}, {}, any>>; const history = (scopedHistoryMock.create() as unknown) as ScopedHistory; diff --git a/src/plugins/data_source_management/public/components/utils.test.ts b/src/plugins/data_source_management/public/components/utils.test.ts index a327c22bf0c9..a4069f01907b 100644 --- a/src/plugins/data_source_management/public/components/utils.test.ts +++ b/src/plugins/data_source_management/public/components/utils.test.ts @@ -32,7 +32,7 @@ import { usernamePasswordAuthMethod, } from '../types'; import { HttpStart } from 'opensearch-dashboards/public'; -import { AuthenticationMethod, AuthenticationMethodRegistery } from '../auth_registry'; +import { AuthenticationMethod, AuthenticationMethodRegistry } from '../auth_registry'; import { deepEqual } from 'assert'; const { savedObjects } = coreMock.createStart(); @@ -239,13 +239,13 @@ describe('DataSourceManagement: Utils.ts', () => { ]; authMethodCombinationsToBeTested.forEach((authOptions) => { - const authenticationMethodRegistery = new AuthenticationMethodRegistery(); + const authenticationMethodRegistry = new AuthenticationMethodRegistry(); authOptions.forEach((authMethod) => { - authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + authenticationMethodRegistry.registerAuthenticationMethod(authMethod); }); - expect(getDefaultAuthMethod(authenticationMethodRegistery)?.name).toBe( + expect(getDefaultAuthMethod(authenticationMethodRegistry)?.name).toBe( AuthType.UsernamePasswordType ); }); @@ -259,19 +259,19 @@ describe('DataSourceManagement: Utils.ts', () => { ]; authMethodCombinationsToBeTested.forEach((authOptions) => { - const authenticationMethodRegistery = new AuthenticationMethodRegistery(); + const authenticationMethodRegistry = new AuthenticationMethodRegistry(); authOptions.forEach((authMethod) => { - authenticationMethodRegistery.registerAuthenticationMethod(authMethod); + authenticationMethodRegistry.registerAuthenticationMethod(authMethod); }); - expect(getDefaultAuthMethod(authenticationMethodRegistery)?.name).toBe(authOptions[0].name); + expect(getDefaultAuthMethod(authenticationMethodRegistry)?.name).toBe(authOptions[0].name); }); }); - test('default auth type is NoAuth when no auth options registered in authenticationMethodRegistery, this should not happen in real customer scenario for MD', () => { - const authenticationMethodRegistery = new AuthenticationMethodRegistery(); - expect(getDefaultAuthMethod(authenticationMethodRegistery)?.name).toBe(AuthType.NoAuth); + test('default auth type is NoAuth when no auth options registered in authenticationMethodRegistry, this should not happen in real customer scenario for MD', () => { + const authenticationMethodRegistry = new AuthenticationMethodRegistry(); + expect(getDefaultAuthMethod(authenticationMethodRegistry)?.name).toBe(AuthType.NoAuth); }); }); @@ -303,13 +303,13 @@ describe('DataSourceManagement: Utils.ts', () => { passWordRegistered: 'some filled in password from registed auth credential form', }; - const authenticationMethodRegistery = new AuthenticationMethodRegistery(); - authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + const authenticationMethodRegistry = new AuthenticationMethodRegistry(); + authenticationMethodRegistry.registerAuthenticationMethod(authMethodToBeTested); const registedAuthTypeCredentials = extractRegisteredAuthTypeCredentials( mockedCredentialState, authTypeToBeTested, - authenticationMethodRegistery + authenticationMethodRegistry ); expect(deepEqual(registedAuthTypeCredentials, expectExtractedAuthCredentials)); @@ -331,13 +331,13 @@ describe('DataSourceManagement: Utils.ts', () => { passWord: 'some password', } as { [key: string]: string }; - const authenticationMethodRegistery = new AuthenticationMethodRegistery(); - authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + const authenticationMethodRegistry = new AuthenticationMethodRegistry(); + authenticationMethodRegistry.registerAuthenticationMethod(authMethodToBeTested); const registedAuthTypeCredentials = extractRegisteredAuthTypeCredentials( mockedCredentialState, authTypeToBeTested, - authenticationMethodRegistery + authenticationMethodRegistry ); expect(deepEqual(registedAuthTypeCredentials, {})); @@ -369,13 +369,13 @@ describe('DataSourceManagement: Utils.ts', () => { passWordRegistered: '', }; - const authenticationMethodRegistery = new AuthenticationMethodRegistery(); - authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + const authenticationMethodRegistry = new AuthenticationMethodRegistry(); + authenticationMethodRegistry.registerAuthenticationMethod(authMethodToBeTested); const registedAuthTypeCredentials = extractRegisteredAuthTypeCredentials( mockedCredentialState, authTypeToBeTested, - authenticationMethodRegistery + authenticationMethodRegistry ); expect(deepEqual(registedAuthTypeCredentials, expectExtractedAuthCredentials)); @@ -401,13 +401,13 @@ describe('DataSourceManagement: Utils.ts', () => { registeredField: 'some value', }; - const authenticationMethodRegistery = new AuthenticationMethodRegistery(); - authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + const authenticationMethodRegistry = new AuthenticationMethodRegistry(); + authenticationMethodRegistry.registerAuthenticationMethod(authMethodToBeTested); const registedAuthTypeCredentials = extractRegisteredAuthTypeCredentials( mockedCredentialState, authTypeToBeTested, - authenticationMethodRegistery + authenticationMethodRegistry ); expect(deepEqual(registedAuthTypeCredentials, expectExtractedAuthCredentials)); @@ -435,13 +435,13 @@ describe('DataSourceManagement: Utils.ts', () => { registeredField: 'some other values', }; - const authenticationMethodRegistery = new AuthenticationMethodRegistery(); - authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + const authenticationMethodRegistry = new AuthenticationMethodRegistry(); + authenticationMethodRegistry.registerAuthenticationMethod(authMethodToBeTested); const registedAuthTypeCredentials = extractRegisteredAuthTypeCredentials( mockedCredentialState, authTypeToBeTested, - authenticationMethodRegistery + authenticationMethodRegistry ); expect(deepEqual(registedAuthTypeCredentials, expectExtractedAuthCredentials)); diff --git a/src/plugins/data_source_management/public/components/utils.ts b/src/plugins/data_source_management/public/components/utils.ts index 10ce2bb6bf43..d7d6e94265e2 100644 --- a/src/plugins/data_source_management/public/components/utils.ts +++ b/src/plugins/data_source_management/public/components/utils.ts @@ -10,7 +10,7 @@ import { defaultAuthType, noAuthCredentialAuthMethod, } from '../types'; -import { AuthenticationMethodRegistery } from '../auth_registry'; +import { AuthenticationMethodRegistry } from '../auth_registry'; export async function getDataSources(savedObjectsClient: SavedObjectsClientContract) { return savedObjectsClient @@ -150,17 +150,17 @@ export const isValidUrl = (endpoint: string) => { }; export const getDefaultAuthMethod = ( - authenticationMethodRegistery: AuthenticationMethodRegistery + authenticationMethodRegistry: AuthenticationMethodRegistry ) => { - const registeredAuthMethods = authenticationMethodRegistery.getAllAuthenticationMethods(); + const registeredAuthMethods = authenticationMethodRegistry.getAllAuthenticationMethods(); const defaultAuthMethod = registeredAuthMethods.length > 0 - ? authenticationMethodRegistery.getAuthenticationMethod(registeredAuthMethods[0].name) + ? authenticationMethodRegistry.getAuthenticationMethod(registeredAuthMethods[0].name) : noAuthCredentialAuthMethod; const initialSelectedAuthMethod = - authenticationMethodRegistery.getAuthenticationMethod(defaultAuthType) ?? defaultAuthMethod; + authenticationMethodRegistry.getAuthenticationMethod(defaultAuthType) ?? defaultAuthMethod; return initialSelectedAuthMethod; }; @@ -168,15 +168,15 @@ export const getDefaultAuthMethod = ( export const extractRegisteredAuthTypeCredentials = ( currentCredentialState: { [key: string]: string }, authType: string, - authenticationMethodRegistery: AuthenticationMethodRegistery + authenticationMethodRegistry: AuthenticationMethodRegistry ) => { const registeredCredentials = {} as { [key: string]: string }; const registeredCredentialField = - authenticationMethodRegistery.getAuthenticationMethod(authType)?.credentialFormField ?? {}; + authenticationMethodRegistry.getAuthenticationMethod(authType)?.credentialFormField ?? {}; - Object.keys(registeredCredentialField).forEach((credentialFiled) => { - registeredCredentials[credentialFiled] = - currentCredentialState[credentialFiled] ?? registeredCredentialField[credentialFiled]; + Object.keys(registeredCredentialField).forEach((credentialField) => { + registeredCredentials[credentialField] = + currentCredentialState[credentialField] ?? registeredCredentialField[credentialField]; }); return registeredCredentials; diff --git a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts index 1eaaea0f5672..d0ef842ad4f5 100644 --- a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts +++ b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.test.ts @@ -8,11 +8,11 @@ import { CreateDataSourceState } from '../create_data_source_wizard/components/c import { EditDataSourceState } from '../edit_data_source/components/edit_form/edit_data_source_form'; import { defaultValidation, performDataSourceFormValidation } from './datasource_form_validation'; import { mockDataSourceAttributesWithAuth } from '../../mocks'; -import { AuthenticationMethod, AuthenticationMethodRegistery } from '../../auth_registry'; +import { AuthenticationMethod, AuthenticationMethodRegistry } from '../../auth_registry'; describe('DataSourceManagement: Form Validation', () => { describe('validate create/edit datasource', () => { - let authenticationMethodRegistery = new AuthenticationMethodRegistery(); + let authenticationMethodRegistry = new AuthenticationMethodRegistry(); let form: CreateDataSourceState | EditDataSourceState = { formErrorsByField: { ...defaultValidation }, title: '', @@ -27,7 +27,7 @@ describe('DataSourceManagement: Form Validation', () => { }, }; test('should fail validation when title is empty', () => { - const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistry); expect(result).toBe(false); }); test('should fail validation on duplicate title', () => { @@ -36,31 +36,31 @@ describe('DataSourceManagement: Form Validation', () => { form, ['oldTitle', 'test'], 'oldTitle', - authenticationMethodRegistery + authenticationMethodRegistry ); expect(result).toBe(false); }); test('should fail validation when endpoint is not valid', () => { form.endpoint = mockDataSourceAttributesWithAuth.endpoint; - const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistry); expect(result).toBe(false); }); test('should fail validation when username is empty', () => { form.endpoint = 'test'; - const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistry); expect(result).toBe(false); }); test('should fail validation when password is empty', () => { form.auth.credentials.username = 'test'; form.auth.credentials.password = ''; - const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistry); expect(result).toBe(false); }); test('should NOT fail validation on empty username/password when No Auth is selected', () => { form.auth.type = AuthType.NoAuth; form.title = 'test'; form.endpoint = mockDataSourceAttributesWithAuth.endpoint; - const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistery); + const result = performDataSourceFormValidation(form, [], '', authenticationMethodRegistry); expect(result).toBe(true); }); test('should NOT fail validation on all fields', () => { @@ -69,12 +69,12 @@ describe('DataSourceManagement: Form Validation', () => { form, [mockDataSourceAttributesWithAuth.title], mockDataSourceAttributesWithAuth.title, - authenticationMethodRegistery + authenticationMethodRegistry ); expect(result).toBe(true); }); test('should NOT fail validation when registered auth type is selected and related credential field not empty', () => { - authenticationMethodRegistery = new AuthenticationMethodRegistery(); + authenticationMethodRegistry = new AuthenticationMethodRegistry(); const authMethodToBeTested = { name: 'Some Auth Type', credentialSourceOption: { @@ -88,7 +88,7 @@ describe('DataSourceManagement: Form Validation', () => { }, } as AuthenticationMethod; - authenticationMethodRegistery.registerAuthenticationMethod(authMethodToBeTested); + authenticationMethodRegistry.registerAuthenticationMethod(authMethodToBeTested); const formWithRegisteredAuth: CreateDataSourceState | EditDataSourceState = { formErrorsByField: { ...defaultValidation }, @@ -107,7 +107,7 @@ describe('DataSourceManagement: Form Validation', () => { formWithRegisteredAuth, [], '', - authenticationMethodRegistery + authenticationMethodRegistry ); expect(result).toBe(true); }); diff --git a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts index 2ae5585e1815..32c87fbee7d1 100644 --- a/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts +++ b/src/plugins/data_source_management/public/components/validation/datasource_form_validation.ts @@ -8,7 +8,7 @@ import { extractRegisteredAuthTypeCredentials, isValidUrl } from '../utils'; import { CreateDataSourceState } from '../create_data_source_wizard/components/create_form/create_data_source_form'; import { EditDataSourceState } from '../edit_data_source/components/edit_form/edit_data_source_form'; import { AuthType } from '../../types'; -import { AuthenticationMethodRegistery } from '../../auth_registry'; +import { AuthenticationMethodRegistry } from '../../auth_registry'; export interface CreateEditDataSourceValidation { title: string[]; @@ -70,7 +70,7 @@ export const performDataSourceFormValidation = ( formValues: CreateDataSourceState | EditDataSourceState, existingDatasourceNamesList: string[], existingTitle: string, - authenticationMethodRegistery: AuthenticationMethodRegistery + authenticationMethodRegistry: AuthenticationMethodRegistry ) => { /* Title validation */ const titleValid = isTitleValid(formValues?.title, existingDatasourceNamesList, existingTitle); @@ -122,7 +122,7 @@ export const performDataSourceFormValidation = ( const registeredCredentials = extractRegisteredAuthTypeCredentials( (formValues?.auth?.credentials ?? {}) as { [key: string]: string }, formValues?.auth?.type ?? '', - authenticationMethodRegistery + authenticationMethodRegistry ); for (const credentialValue of Object.values(registeredCredentials)) { diff --git a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx index 6b421a32d2b2..6487d60c934b 100644 --- a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx @@ -17,7 +17,7 @@ import { CreateDataSourceWizardWithRouter } from '../components/create_data_sour import { DataSourceTableWithRouter } from '../components/data_source_table'; import { DataSourceManagementContext } from '../types'; import { EditDataSourceWithRouter } from '../components/edit_data_source'; -import { AuthenticationMethodRegistery } from '../auth_registry'; +import { AuthenticationMethodRegistry } from '../auth_registry'; export interface DataSourceManagementStartDependencies { data: DataPublicPluginStart; @@ -26,7 +26,7 @@ export interface DataSourceManagementStartDependencies { export async function mountManagementSection( getStartServices: StartServicesAccessor, params: ManagementAppMountParams, - authMethodsRegistry: AuthenticationMethodRegistery + authMethodsRegistry: AuthenticationMethodRegistry ) { const [ { chrome, application, savedObjects, uiSettings, notifications, overlays, http, docLinks }, @@ -42,7 +42,7 @@ export async function mountManagementSection( http, docLinks, setBreadcrumbs: params.setBreadcrumbs, - authenticationMethodRegistery: authMethodsRegistry, + authenticationMethodRegistry: authMethodsRegistry, }; ReactDOM.render( diff --git a/src/plugins/data_source_management/public/mocks.ts b/src/plugins/data_source_management/public/mocks.ts index e472860893ef..90368762698f 100644 --- a/src/plugins/data_source_management/public/mocks.ts +++ b/src/plugins/data_source_management/public/mocks.ts @@ -15,7 +15,7 @@ import { } from './plugin'; import { managementPluginMock } from '../../management/public/mocks'; import { mockManagementPlugin as indexPatternManagementPluginMock } from '../../index_pattern_management/public/mocks'; -import { AuthenticationMethod, AuthenticationMethodRegistery } from './auth_registry'; +import { AuthenticationMethod, AuthenticationMethodRegistry } from './auth_registry'; /* Mock Types */ @@ -30,7 +30,7 @@ export const docLinks = { }, }; -export const authenticationMethodRegistery = new AuthenticationMethodRegistery(); +export const authenticationMethodRegistry = new AuthenticationMethodRegistry(); const createDataSourceManagementContext = () => { const { @@ -53,7 +53,7 @@ const createDataSourceManagementContext = () => { http, docLinks, setBreadcrumbs: () => {}, - authenticationMethodRegistery, + authenticationMethodRegistry, }; }; diff --git a/src/plugins/data_source_management/public/plugin.test.ts b/src/plugins/data_source_management/public/plugin.test.ts index 98615119a0c1..ecad5f38922c 100644 --- a/src/plugins/data_source_management/public/plugin.test.ts +++ b/src/plugins/data_source_management/public/plugin.test.ts @@ -19,7 +19,7 @@ describe('#dataSourceManagement', () => { const typeA = createAuthenticationMethod({ name: 'typeA' }); setup.registerAuthenticationMethod(createAuthenticationMethod(typeA)); const start = doStart(); - const registry = start.getAuthenticationMethodRegistery(); + const registry = start.getAuthenticationMethodRegistry(); expect(registry.getAuthenticationMethod('typeA')).toEqual(typeA); }); }); diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 12cab715b205..cf53bfc2e76e 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -14,8 +14,8 @@ import { IndexPatternManagementSetup } from '../../index_pattern_management/publ import { DataSourceColumn } from './components/data_source_column/data_source_column'; import { AuthenticationMethod, - IAuthenticationMethodRegistery, - AuthenticationMethodRegistery, + IAuthenticationMethodRegistry, + AuthenticationMethodRegistry, } from './auth_registry'; import { noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod } from './types'; import { DataSourceSelectorProps } from './components/data_source_selector/data_source_selector'; @@ -37,7 +37,7 @@ export interface DataSourceManagementPluginSetup { } export interface DataSourceManagementPluginStart { - getAuthenticationMethodRegistery: () => IAuthenticationMethodRegistery; + getAuthenticationMethodRegistry: () => IAuthenticationMethodRegistry; } const DSM_APP_ID = 'dataSources'; @@ -50,7 +50,7 @@ export class DataSourceManagementPlugin DataSourceManagementSetupDependencies > { private started = false; - private authMethodsRegistry = new AuthenticationMethodRegistery(); + private authMethodsRegistry = new AuthenticationMethodRegistry(); public setup( core: CoreSetup, @@ -111,7 +111,7 @@ export class DataSourceManagementPlugin public start(core: CoreStart) { this.started = true; return { - getAuthenticationMethodRegistery: () => this.authMethodsRegistry, + getAuthenticationMethodRegistry: () => this.authMethodsRegistry, }; } diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index 32c9a20b7f03..e76b7b4d08a8 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -18,7 +18,7 @@ import { SavedObjectAttributes } from 'src/core/types'; import { i18n } from '@osd/i18n'; import { SigV4ServiceName } from '../../data_source/common/data_sources'; import { OpenSearchDashboardsReactContextValue } from '../../opensearch_dashboards_react/public'; -import { AuthenticationMethodRegistery } from './auth_registry'; +import { AuthenticationMethodRegistry } from './auth_registry'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface DataSourceManagementPluginStart {} @@ -33,7 +33,7 @@ export interface DataSourceManagementContext { http: HttpSetup; docLinks: DocLinksStart; setBreadcrumbs: ManagementAppMountParams['setBreadcrumbs']; - authenticationMethodRegistery: AuthenticationMethodRegistery; + authenticationMethodRegistry: AuthenticationMethodRegistry; } export interface DataSourceTableItem { From 4f1884b2e458ca5f7fa72dd0a0ee94aba6f547c4 Mon Sep 17 00:00:00 2001 From: Lu Yu Date: Mon, 25 Mar 2024 17:19:54 -0700 Subject: [PATCH 17/45] [Multiple Datasource] Refactor data source menu and interface to allow cleaner selection of component and related configurations (#6256) * refactor Signed-off-by: Lu Yu * refactor data source menu Signed-off-by: Lu Yu * add change log Signed-off-by: Lu Yu * move configs based on if it is required Signed-off-by: Lu Yu * move optional params below required Signed-off-by: Lu Yu --------- Signed-off-by: Lu Yu Co-authored-by: ZilongX <99905560+ZilongX@users.noreply.github.com> --- CHANGELOG.md | 1 + .../create_data_source_menu.test.tsx.snap | 174 ++++++++--- .../data_source_menu.test.tsx.snap | 287 +++++++++++++----- .../create_data_source_menu.test.tsx | 91 +++++- .../create_data_source_menu.tsx | 18 +- .../data_source_menu.test.tsx | 69 +++-- .../data_source_menu/data_source_menu.tsx | 161 ++++------ .../data_source_selectable.test.tsx | 6 +- .../data_source_selectable.tsx | 7 +- .../components/data_source_menu/index.ts | 7 + .../components/data_source_menu/types.ts | 59 ++++ .../data_source_view/data_source_view.tsx | 2 +- .../data_source_management/public/index.ts | 8 + .../data_source_management/public/plugin.ts | 6 +- 14 files changed, 620 insertions(+), 276 deletions(-) create mode 100644 src/plugins/data_source_management/public/components/data_source_menu/types.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f7e22671bf..665b35e8bc9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,6 +64,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Workspace] Register a workspace dropdown menu at the top of left nav bar ([#6150](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6150)) - [Multiple Datasource] Add icon in datasource table page to show the default datasource ([#6231](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6231)) - [Multiple Datasource] Add TLS configuration for multiple data sources ([#6171](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6171)) +- [Multiple Datasource] Refactor data source menu and interface to allow cleaner selection of component and related configurations ([#6256](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6256)) ### 🐛 Bug Fixes diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap index 207e38d657da..55b037cb8307 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap @@ -1,13 +1,138 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`create data source menu should render normally 1`] = ` +exports[`create data source menu should render data source selectable normally 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+
+
+ + +
+
+
+ , + "container":
+
+
+ + +
+
+
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} +`; + +exports[`when setMenuMountPoint is provided should mount data source component 1`] = ` Object { "asFragment": [Function], "baseElement":
+
, - "container":
- -
, + "container":
, "debug": [Function], "findAllByAltText": [Function], "findAllByDisplayValue": [Function], diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap index ea00d96c5798..6fa9b51ce3e7 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap @@ -4,9 +4,109 @@ exports[`DataSourceMenu should render data source aggregated view 1`] = ` Object { "asFragment": [Function], "baseElement": -
+
+ + + All + +
+
+ +
+
+
, - "container":
, + "container":
+ + + All + +
+
+ +
+
+
, "debug": [Function], "findAllByAltText": [Function], "findAllByDisplayValue": [Function], @@ -62,83 +162,120 @@ Object { `; exports[`DataSourceMenu should render data source selectable only with local cluster is hidden 1`] = ` - - - - - + `; exports[`DataSourceMenu should render data source selectable only with local cluster not hidden 1`] = ` - - - - - + `; exports[`DataSourceMenu should render data source view only 1`] = ` - - - - - + +`; + +exports[`DataSourceMenu should render nothing 1`] = ` +Object { + "asFragment": [Function], + "baseElement": +
+ , + "container":
, + "debug": [Function], + "findAllByAltText": [Function], + "findAllByDisplayValue": [Function], + "findAllByLabelText": [Function], + "findAllByPlaceholderText": [Function], + "findAllByRole": [Function], + "findAllByTestId": [Function], + "findAllByText": [Function], + "findAllByTitle": [Function], + "findByAltText": [Function], + "findByDisplayValue": [Function], + "findByLabelText": [Function], + "findByPlaceholderText": [Function], + "findByRole": [Function], + "findByTestId": [Function], + "findByText": [Function], + "findByTitle": [Function], + "getAllByAltText": [Function], + "getAllByDisplayValue": [Function], + "getAllByLabelText": [Function], + "getAllByPlaceholderText": [Function], + "getAllByRole": [Function], + "getAllByTestId": [Function], + "getAllByText": [Function], + "getAllByTitle": [Function], + "getByAltText": [Function], + "getByDisplayValue": [Function], + "getByLabelText": [Function], + "getByPlaceholderText": [Function], + "getByRole": [Function], + "getByTestId": [Function], + "getByText": [Function], + "getByTitle": [Function], + "queryAllByAltText": [Function], + "queryAllByDisplayValue": [Function], + "queryAllByLabelText": [Function], + "queryAllByPlaceholderText": [Function], + "queryAllByRole": [Function], + "queryAllByTestId": [Function], + "queryAllByText": [Function], + "queryAllByTitle": [Function], + "queryByAltText": [Function], + "queryByDisplayValue": [Function], + "queryByLabelText": [Function], + "queryByPlaceholderText": [Function], + "queryByRole": [Function], + "queryByTestId": [Function], + "queryByText": [Function], + "queryByTitle": [Function], + "rerender": [Function], + "unmount": [Function], +} `; diff --git a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx index 1e4c43389847..52aaefe5ea75 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.test.tsx @@ -4,10 +4,12 @@ */ import { createDataSourceMenu } from './create_data_source_menu'; -import { SavedObjectsClientContract } from '../../../../../core/public'; +import { MountPoint, SavedObjectsClientContract } from '../../../../../core/public'; import { notificationServiceMock } from '../../../../../core/public/mocks'; import React from 'react'; -import { render } from '@testing-library/react'; +import { act, render } from '@testing-library/react'; +import { DataSourceComponentType, DataSourceSelectableConfig } from './types'; +import { ReactWrapper } from 'enzyme'; describe('create data source menu', () => { let client: SavedObjectsClientContract; @@ -19,18 +21,18 @@ describe('create data source menu', () => { } as any; }); - it('should render normally', () => { + it('should render data source selectable normally', () => { const props = { - showDataSourceSelectable: true, - appName: 'myapp', - savedObjects: client, - notifications, - fullWidth: true, - hideLocalCluster: true, - disableDataSourceSelectable: false, - className: 'myclass', + componentType: DataSourceComponentType.DataSourceSelectable, + componentConfig: { + fullWidth: true, + hideLocalCluster: true, + onSelectedDataSources: jest.fn(), + savedObjects: client, + notifications, + }, }; - const TestComponent = createDataSourceMenu(); + const TestComponent = createDataSourceMenu(); const component = render(); expect(component).toMatchSnapshot(); expect(client.find).toBeCalledWith({ @@ -41,3 +43,68 @@ describe('create data source menu', () => { expect(notifications.toasts.addWarning).toBeCalledTimes(0); }); }); + +describe('when setMenuMountPoint is provided', () => { + let portalTarget: HTMLElement; + let mountPoint: MountPoint; + let setMountPoint: jest.Mock<(mountPoint: MountPoint) => void>; + let dom: ReactWrapper; + + let client: SavedObjectsClientContract; + const notifications = notificationServiceMock.createStartContract(); + + const refresh = () => { + new Promise(async (resolve) => { + if (dom) { + act(() => { + dom.update(); + }); + } + setImmediate(() => resolve(dom)); // flushes any pending promises + }); + }; + + beforeEach(() => { + portalTarget = document.createElement('div'); + document.body.append(portalTarget); + setMountPoint = jest.fn().mockImplementation((mp) => (mountPoint = mp)); + client = { + find: jest.fn().mockResolvedValue([]), + } as any; + }); + + afterEach(() => { + if (portalTarget) { + portalTarget.remove(); + } + }); + + it('should mount data source component', async () => { + const props = { + setMenuMountPoint: setMountPoint, + componentType: DataSourceComponentType.DataSourceSelectable, + componentConfig: { + fullWidth: true, + hideLocalCluster: true, + onSelectedDataSources: jest.fn(), + savedObjects: client, + notifications, + }, + }; + const TestComponent = createDataSourceMenu(); + const component = render(); + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + expect(component).toMatchSnapshot(); + expect(client.find).toBeCalledWith({ + fields: ['id', 'title', 'auth.type'], + perPage: 10000, + type: 'data-source', + }); + expect(notifications.toasts.addWarning).toBeCalledTimes(0); + expect(setMountPoint).toBeCalledTimes(1); + }); +}); diff --git a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx index 7d5972f8e068..2e76c2cf23c6 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/create_data_source_menu.tsx @@ -4,10 +4,22 @@ */ import React from 'react'; -import { DataSourceMenu, DataSourceMenuProps } from './data_source_menu'; +import { EuiHeaderLinks } from '@elastic/eui'; +import { DataSourceMenu } from './data_source_menu'; +import { DataSourceMenuProps } from './types'; +import { MountPointPortal } from '../../../../opensearch_dashboards_react/public'; -export function createDataSourceMenu() { - return (props: DataSourceMenuProps) => { +export function createDataSourceMenu() { + return (props: DataSourceMenuProps) => { + if (props.setMenuMountPoint) { + return ( + + + + + + ); + } return ; }; } diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx index a5ec61107b3e..af5ef0cf3f32 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.test.tsx @@ -9,6 +9,7 @@ import { notificationServiceMock } from '../../../../../core/public/mocks'; import React from 'react'; import { DataSourceMenu } from './data_source_menu'; import { render } from '@testing-library/react'; +import { DataSourceComponentType } from './types'; describe('DataSourceMenu', () => { let component: ShallowWrapper, React.Component<{}, {}, any>>; @@ -25,15 +26,14 @@ describe('DataSourceMenu', () => { it('should render data source selectable only with local cluster not hidden', () => { component = shallow( ); expect(component).toMatchSnapshot(); @@ -42,15 +42,14 @@ describe('DataSourceMenu', () => { it('should render data source selectable only with local cluster is hidden', () => { component = shallow( ); expect(component).toMatchSnapshot(); @@ -59,10 +58,8 @@ describe('DataSourceMenu', () => { it('should render data source view only', () => { component = shallow( ); expect(component).toMatchSnapshot(); @@ -71,12 +68,28 @@ describe('DataSourceMenu', () => { it('should render data source aggregated view', () => { const container = render( + ); + expect(container).toMatchSnapshot(); + }); + + it('should render nothing', () => { + const container = render( + ); expect(container).toMatchSnapshot(); diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx index 9be65882a9b8..c5d4cf421696 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_menu.tsx @@ -4,94 +4,68 @@ */ import React, { ReactElement } from 'react'; -import { EuiHeaderLinks } from '@elastic/eui'; -import classNames from 'classnames'; -import { - MountPoint, - NotificationsStart, - SavedObjectsClientContract, - SavedObject, -} from '../../../../../core/public'; -import { MountPointPortal } from '../../../../opensearch_dashboards_react/public'; import { DataSourceSelectable } from './data_source_selectable'; -import { DataSourceOption } from '../data_source_selector/data_source_selector'; import { DataSourceAggregatedView } from '../data_source_aggregated_view'; import { DataSourceView } from '../data_source_view'; -import { DataSourceAttributes } from '../../types'; - -export interface DataSourceMenuProps { - showDataSourceSelectable?: boolean; - showDataSourceView?: boolean; - showDataSourceAggregatedView?: boolean; - activeDataSourceIds?: string[]; - appName: string; - savedObjects?: SavedObjectsClientContract; - notifications?: NotificationsStart; - fullWidth: boolean; - hideLocalCluster?: boolean; - dataSourceCallBackFunc?: (dataSource: DataSourceOption) => void; - disableDataSourceSelectable?: boolean; - className?: string; - selectedOption?: DataSourceOption[]; - setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; - dataSourceFilter?: (dataSource: SavedObject) => boolean; - displayAllCompatibleDataSources?: boolean; -} - -export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null { - const { - savedObjects, - notifications, - dataSourceCallBackFunc, - showDataSourceSelectable, - disableDataSourceSelectable, - showDataSourceAggregatedView, - fullWidth, - hideLocalCluster, - selectedOption, - showDataSourceView, - dataSourceFilter, - activeDataSourceIds, - displayAllCompatibleDataSources, - } = props; +import { + DataSourceAggregatedViewConfig, + DataSourceComponentType, + DataSourceMenuProps, + DataSourceSelectableConfig, + DataSourceViewConfig, +} from './types'; - if (!showDataSourceSelectable && !showDataSourceView && !showDataSourceAggregatedView) { - return null; - } +export function DataSourceMenu(props: DataSourceMenuProps): ReactElement | null { + const { componentType, componentConfig } = props; - function renderDataSourceView(className: string): ReactElement | null { - if (!showDataSourceView) return null; + function renderDataSourceView(config: DataSourceViewConfig): ReactElement | null { + const { activeOption, fullWidth } = config; return ( - - 0 ? selectedOption : undefined} - /> - + 0 ? activeOption : undefined} + fullWidth={fullWidth} + /> ); } - function renderDataSourceSelectable(className: string): ReactElement | null { - if (!showDataSourceSelectable) return null; + function renderDataSourceSelectable(config: DataSourceSelectableConfig): ReactElement | null { + const { + onSelectedDataSources, + disabled, + activeOption, + hideLocalCluster, + fullWidth, + savedObjects, + notifications, + dataSourceFilter, + } = config; return ( - - 0 ? selectedOption : undefined} - dataSourceFilter={dataSourceFilter} - /> - + 0 ? activeOption : undefined} + dataSourceFilter={dataSourceFilter} + hideLocalCluster={hideLocalCluster || false} + fullWidth={fullWidth} + /> ); } - function renderDataSourceAggregatedView(): ReactElement | null { - if (!showDataSourceAggregatedView) return null; + function renderDataSourceAggregatedView( + config: DataSourceAggregatedViewConfig + ): ReactElement | null { + const { + fullWidth, + hideLocalCluster, + activeDataSourceIds, + displayAllCompatibleDataSources, + savedObjects, + notifications, + dataSourceFilter, + } = config; return ( - - {renderDataSourceAggregatedView()} - {renderDataSourceSelectable(menuClassName)} - {renderDataSourceView(menuClassName)} - - - ); - } else { - return ( - <> - {renderDataSourceSelectable(menuClassName)} - {renderDataSourceView(menuClassName)} - - ); + function renderLayout(): ReactElement | null { + switch (componentType) { + case DataSourceComponentType.DataSourceAggregatedView: + return renderDataSourceAggregatedView(componentConfig as DataSourceAggregatedViewConfig); + case DataSourceComponentType.DataSourceSelectable: + return renderDataSourceSelectable(componentConfig as DataSourceSelectableConfig); + case DataSourceComponentType.DataSourceView: + return renderDataSourceView(componentConfig as DataSourceViewConfig); + default: + return null; } } return renderLayout(); } - -DataSourceMenu.defaultProps = { - disableDataSourceSelectable: false, - showDataSourceAggregatedView: false, - showDataSourceSelectable: false, - displayAllCompatibleDataSources: false, - showDataSourceView: false, - hideLocalCluster: false, -}; diff --git a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx index 9b0215d157e8..b9f06ce3f8bb 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_menu/data_source_selectable.test.tsx @@ -30,7 +30,7 @@ describe('DataSourceSelectable', () => { { { void; + onSelectedDataSources: (dataSources: DataSourceOption[]) => void; disabled: boolean; hideLocalCluster: boolean; fullWidth: boolean; @@ -119,7 +120,7 @@ export class DataSourceSelectable extends React.Component< this.setState({ selectedOption: [selectedDataSource], }); - this.props.onSelectedDataSource({ ...selectedDataSource }); + this.props.onSelectedDataSources([selectedDataSource]); } render() { diff --git a/src/plugins/data_source_management/public/components/data_source_menu/index.ts b/src/plugins/data_source_management/public/components/data_source_menu/index.ts index 21951dc8d29e..2a764d602b56 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/index.ts +++ b/src/plugins/data_source_management/public/components/data_source_menu/index.ts @@ -4,3 +4,10 @@ */ export { DataSourceMenu } from './data_source_menu'; +export { + DataSourceSelectableConfig, + DataSourceAggregatedViewConfig, + DataSourceComponentType, + DataSourceViewConfig, + DataSourceMenuProps, +} from './types'; diff --git a/src/plugins/data_source_management/public/components/data_source_menu/types.ts b/src/plugins/data_source_management/public/components/data_source_menu/types.ts new file mode 100644 index 000000000000..e570eb83ec82 --- /dev/null +++ b/src/plugins/data_source_management/public/components/data_source_menu/types.ts @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { + MountPoint, + NotificationsStart, + SavedObjectsClientContract, + SavedObject, +} from '../../../../../core/public'; +import { DataSourceAttributes } from '../../types'; + +export interface DataSourceOption { + id: string; + label?: string; +} + +export interface DataSourceBaseConfig { + fullWidth: boolean; + disabled?: boolean; +} + +export interface DataSourceMenuProps { + componentType: DataSourceComponentType; + componentConfig: T; + setMenuMountPoint?: (menuMount: MountPoint | undefined) => void; +} + +export const DataSourceComponentType = { + DataSourceSelectable: 'DataSourceSelectable', + DataSourceView: 'DataSourceView', + DataSourceAggregatedView: 'DataSourceAggregatedView', +} as const; + +export type DataSourceComponentType = typeof DataSourceComponentType[keyof typeof DataSourceComponentType]; + +export interface DataSourceViewConfig extends DataSourceBaseConfig { + activeOption: DataSourceOption[]; + savedObjects?: SavedObjectsClientContract; + notifications?: NotificationsStart; +} + +export interface DataSourceAggregatedViewConfig extends DataSourceBaseConfig { + savedObjects: SavedObjectsClientContract; + notifications: NotificationsStart; + activeDataSourceIds?: string[]; + hideLocalCluster?: boolean; + displayAllCompatibleDataSources?: boolean; + dataSourceFilter?: (dataSource: SavedObject) => boolean; +} + +export interface DataSourceSelectableConfig extends DataSourceBaseConfig { + onSelectedDataSources: (dataSources: DataSourceOption[]) => void; + savedObjects: SavedObjectsClientContract; + notifications: NotificationsStart; + activeOption?: DataSourceOption[]; + hideLocalCluster?: boolean; + dataSourceFilter?: (dataSource: SavedObject) => boolean; +} diff --git a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx index 068842ef26a6..23bb2e34feab 100644 --- a/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx +++ b/src/plugins/data_source_management/public/components/data_source_view/data_source_view.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { i18n } from '@osd/i18n'; import { EuiPopover, EuiButtonEmpty, EuiButtonIcon, EuiContextMenu } from '@elastic/eui'; -import { DataSourceOption } from '../data_source_selector/data_source_selector'; +import { DataSourceOption } from '../data_source_menu/types'; interface DataSourceViewProps { fullWidth: boolean; diff --git a/src/plugins/data_source_management/public/index.ts b/src/plugins/data_source_management/public/index.ts index 5e2e9b647396..8ffda8701f68 100644 --- a/src/plugins/data_source_management/public/index.ts +++ b/src/plugins/data_source_management/public/index.ts @@ -10,7 +10,15 @@ import { DataSourceManagementPlugin } from './plugin'; export function plugin() { return new DataSourceManagementPlugin(); } + export { DataSourceManagementPluginStart } from './types'; export { DataSourceSelector } from './components/data_source_selector'; export { DataSourceMenu } from './components/data_source_menu'; export { DataSourceManagementPlugin, DataSourceManagementPluginSetup } from './plugin'; +export { + DataSourceSelectableConfig, + DataSourceComponentType, + DataSourceAggregatedViewConfig, + DataSourceViewConfig, + DataSourceMenuProps, +} from './components/data_source_menu'; diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index cf53bfc2e76e..9e6da39dc08b 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -20,7 +20,7 @@ import { import { noAuthCredentialAuthMethod, sigV4AuthMethod, usernamePasswordAuthMethod } from './types'; import { DataSourceSelectorProps } from './components/data_source_selector/data_source_selector'; import { createDataSourceMenu } from './components/data_source_menu/create_data_source_menu'; -import { DataSourceMenuProps } from './components/data_source_menu/data_source_menu'; +import { DataSourceMenuProps } from './components/data_source_menu'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; @@ -32,7 +32,7 @@ export interface DataSourceManagementPluginSetup { registerAuthenticationMethod: (authMethodValues: AuthenticationMethod) => void; ui: { DataSourceSelector: React.ComponentType; - DataSourceMenu: React.ComponentType; + getDataSourceMenu: () => React.ComponentType>; }; } @@ -103,7 +103,7 @@ export class DataSourceManagementPlugin registerAuthenticationMethod, ui: { DataSourceSelector: createDataSourceSelector(), - DataSourceMenu: createDataSourceMenu(), + getDataSourceMenu: () => createDataSourceMenu(), }, }; } From f5896b894a0cecf59c7cfbb8ebf944dcd89c67f9 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Tue, 26 Mar 2024 10:14:10 +0800 Subject: [PATCH 18/45] [Workspace]Add create workspace page (#6179) * Add workspace create page Signed-off-by: Lin Wang * Add change log for create workspace Signed-off-by: Lin Wang * Address PR comments Signed-off-by: Lin Wang * Update annotation for default selected features ids Signed-off-by: Lin Wang * Address PR comments Signed-off-by: Lin Wang * Add unit tests for unselected single feature Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang Signed-off-by: SuZhou-Joe Co-authored-by: SuZhou-Joe --- CHANGELOG.md | 1 + src/plugins/workspace/common/constants.ts | 6 + src/plugins/workspace/public/application.tsx | 14 ++ .../components/workspace_creator/index.tsx | 6 + .../workspace_creator.test.tsx | 213 ++++++++++++++++++ .../workspace_creator/workspace_creator.tsx | 93 ++++++++ .../components/workspace_creator_app.tsx | 35 +++ .../components/workspace_form/constants.ts | 14 ++ .../public/components/workspace_form/index.ts | 8 + .../public/components/workspace_form/types.ts | 38 ++++ .../workspace_form/use_workspace_form.test.ts | 76 +++++++ .../workspace_form/use_workspace_form.ts | 148 ++++++++++++ .../components/workspace_form/utils.test.ts | 126 +++++++++++ .../public/components/workspace_form/utils.ts | 100 ++++++++ .../workspace_form/workspace_bottom_bar.tsx | 110 +++++++++ .../workspace_form/workspace_cancel_modal.tsx | 43 ++++ .../workspace_feature_selector.test.tsx | 92 ++++++++ .../workspace_feature_selector.tsx | 143 ++++++++++++ .../workspace_form/workspace_form.tsx | 151 +++++++++++++ src/plugins/workspace/public/hooks.ts | 19 ++ src/plugins/workspace/public/plugin.test.ts | 5 +- src/plugins/workspace/public/plugin.ts | 20 +- src/plugins/workspace/public/utils.test.ts | 93 ++++++++ src/plugins/workspace/public/utils.ts | 57 +++++ 24 files changed, 1608 insertions(+), 3 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_creator/index.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator_app.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/constants.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/types.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/utils.test.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/utils.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_form.tsx create mode 100644 src/plugins/workspace/public/hooks.ts create mode 100644 src/plugins/workspace/public/utils.test.ts create mode 100644 src/plugins/workspace/public/utils.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 665b35e8bc9d..b00f9c3ae93a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -65,6 +65,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Add icon in datasource table page to show the default datasource ([#6231](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6231)) - [Multiple Datasource] Add TLS configuration for multiple data sources ([#6171](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6171)) - [Multiple Datasource] Refactor data source menu and interface to allow cleaner selection of component and related configurations ([#6256](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6256)) +- [Workspace] Add create workspace page ([#6179](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6179)) ### 🐛 Bug Fixes diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index d2da08acb52d..91db9f37fc40 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -6,7 +6,13 @@ export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; export const WORKSPACE_LIST_APP_ID = 'workspace_list'; +export const WORKSPACE_UPDATE_APP_ID = 'workspace_update'; export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; +/** + * Since every workspace always have overview and update page, these features will be selected by default + * and can't be changed in the workspace form feature selector + */ +export const DEFAULT_SELECTED_FEATURES_IDS = [WORKSPACE_UPDATE_APP_ID, WORKSPACE_OVERVIEW_APP_ID]; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index f70c627e02b0..66672561616a 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -8,8 +8,22 @@ import ReactDOM from 'react-dom'; import { AppMountParameters, ScopedHistory } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; import { WorkspaceFatalError } from './components/workspace_fatal_error'; +import { WorkspaceCreatorApp } from './components/workspace_creator_app'; import { Services } from './types'; +export const renderCreatorApp = ({ element }: AppMountParameters, services: Services) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { const { element } = params; const history = params.history as ScopedHistory<{ error?: string }>; diff --git a/src/plugins/workspace/public/components/workspace_creator/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx new file mode 100644 index 000000000000..c8cdbfab65be --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceCreator } from './workspace_creator'; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx new file mode 100644 index 000000000000..9cc4f9b53f69 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -0,0 +1,213 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { PublicAppInfo } from 'opensearch-dashboards/public'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import { WorkspaceCreator as WorkspaceCreatorComponent } from './workspace_creator'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; + +const workspaceClientCreate = jest + .fn() + .mockReturnValue({ result: { id: 'successResult' }, success: true }); + +const navigateToApp = jest.fn(); +const notificationToastsAddSuccess = jest.fn(); +const notificationToastsAddDanger = jest.fn(); +const PublicAPPInfoMap = new Map([ + ['app1', { id: 'app1', title: 'app1' }], + ['app2', { id: 'app2', title: 'app2', category: { id: 'category1', label: 'category1' } }], + ['app3', { id: 'app3', category: { id: 'category1', label: 'category1' } }], + ['app4', { id: 'app4', category: { id: 'category2', label: 'category2' } }], + ['app5', { id: 'app5', category: { id: 'category2', label: 'category2' } }], +]); + +const mockCoreStart = coreMock.createStart(); + +const WorkspaceCreator = (props: any) => { + const { Provider } = createOpenSearchDashboardsReactContext({ + ...mockCoreStart, + ...{ + application: { + ...mockCoreStart.application, + navigateToApp, + getUrlForApp: jest.fn(() => '/app/workspace_overview'), + applications$: new BehaviorSubject>(PublicAPPInfoMap as any), + }, + notifications: { + ...mockCoreStart.notifications, + toasts: { + ...mockCoreStart.notifications.toasts, + addDanger: notificationToastsAddDanger, + addSuccess: notificationToastsAddSuccess, + }, + }, + workspaceClient: { + ...mockCoreStart.workspaces, + create: workspaceClientCreate, + }, + }, + }); + + return ( + + + + ); +}; + +function clearMockedFunctions() { + workspaceClientCreate.mockClear(); + notificationToastsAddDanger.mockClear(); + notificationToastsAddSuccess.mockClear(); +} + +describe('WorkspaceCreator', () => { + beforeEach(() => clearMockedFunctions()); + const { location } = window; + const setHrefSpy = jest.fn((href) => href); + + beforeAll(() => { + if (window.location) { + // @ts-ignore + delete window.location; + } + window.location = {} as Location; + Object.defineProperty(window.location, 'href', { + get: () => 'http://localhost/w/workspace/app/workspace_create', + set: setHrefSpy, + }); + }); + + afterAll(() => { + window.location = location; + }); + + it('should not create workspace when name is empty', async () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('should not create workspace with invalid name', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: '~' }, + }); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('should not create workspace with invalid description', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); + fireEvent.input(descriptionInput, { + target: { value: '~' }, + }); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('cancel create workspace', async () => { + const { findByText, getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); + await findByText('Discard changes?'); + fireEvent.click(getByTestId('confirmModalConfirmButton')); + expect(navigateToApp).toHaveBeenCalled(); + }); + + it('create workspace with detailed information', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); + fireEvent.input(descriptionInput, { + target: { value: 'test workspace description' }, + }); + const colorSelector = getByTestId( + 'euiColorPickerAnchor workspaceForm-workspaceDetails-colorPicker' + ); + fireEvent.input(colorSelector, { + target: { value: '#000000' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + color: '#000000', + description: 'test workspace description', + }) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('create workspace with customized features', async () => { + setHrefSpy.mockReset(); + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-app1')); + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-category1')); + expect(setHrefSpy).not.toHaveBeenCalled(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + features: expect.arrayContaining(['app1', 'app2', 'app3']), + }) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + await waitFor(() => { + expect(setHrefSpy).toHaveBeenCalledWith(expect.stringMatching(/workspace_overview$/)); + }); + }); + + it('should show danger toasts after create workspace failed', async () => { + workspaceClientCreate.mockReturnValue({ result: { id: 'failResult' }, success: false }); + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalled(); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); + }); + + it('should show danger toasts after call create workspace API failed', async () => { + workspaceClientCreate.mockImplementation(async () => { + throw new Error(); + }); + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalled(); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx new file mode 100644 index 000000000000..83d0f6675fe6 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceForm, WorkspaceFormSubmitData, WorkspaceOperationType } from '../workspace_form'; +import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { WorkspaceClient } from '../../workspace_client'; + +export const WorkspaceCreator = () => { + const { + services: { application, notifications, http, workspaceClient }, + } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); + + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormSubmitData) => { + let result; + try { + result = await workspaceClient.create(data); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.create.success', { + defaultMessage: 'Create workspace successfully', + }), + }); + if (application && http) { + const newWorkspaceId = result.result.id; + // Redirect page after one second, leave one second time to show create successful toast. + window.setTimeout(() => { + window.location.href = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + newWorkspaceId, + http.basePath + ); + }, 1000); + } + return; + } + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: result?.error, + }); + }, + [notifications?.toasts, http, application, workspaceClient] + ); + + return ( + + + + + + {application && ( + + )} + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator_app.tsx b/src/plugins/workspace/public/components/workspace_creator_app.tsx new file mode 100644 index 000000000000..b74359929352 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceCreator } from './workspace_creator'; + +export const WorkspaceCreatorApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceCreateTitle', { + defaultMessage: 'Create workspace', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts new file mode 100644 index 000000000000..83ae111e9c20 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -0,0 +1,14 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum WorkspaceOperationType { + Create = 'create', + Update = 'update', +} + +export enum WorkspaceFormTabs { + NotSelected, + FeatureVisibility, +} diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts new file mode 100644 index 000000000000..6531d4a1c6f7 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceForm } from './workspace_form'; +export { WorkspaceFormSubmitData } from './types'; +export { WorkspaceOperationType } from './constants'; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts new file mode 100644 index 000000000000..8014c2321ad5 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WorkspaceOperationType } from './constants'; +import type { ApplicationStart } from '../../../../../core/public'; + +export interface WorkspaceFormSubmitData { + name: string; + description?: string; + features?: string[]; + color?: string; +} + +export interface WorkspaceFormData extends WorkspaceFormSubmitData { + id: string; + reserved?: boolean; +} + +export interface WorkspaceFeature { + id: string; + name: string; +} + +export interface WorkspaceFeatureGroup { + name: string; + features: WorkspaceFeature[]; +} + +export type WorkspaceFormErrors = { [key in keyof WorkspaceFormData]?: string }; + +export interface WorkspaceFormProps { + application: ApplicationStart; + onSubmit?: (formData: WorkspaceFormSubmitData) => void; + defaultValues?: WorkspaceFormData; + operationType?: WorkspaceOperationType; +} diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts new file mode 100644 index 000000000000..2bbef2d62fbd --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.test.ts @@ -0,0 +1,76 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { renderHook, act } from '@testing-library/react-hooks'; + +import { applicationServiceMock } from '../../../../../core/public/mocks'; +import { WorkspaceFormData } from './types'; +import { useWorkspaceForm } from './use_workspace_form'; + +const setup = (defaultValues?: WorkspaceFormData) => { + const onSubmitMock = jest.fn(); + const renderResult = renderHook(useWorkspaceForm, { + initialProps: { + application: applicationServiceMock.createStartContract(), + defaultValues, + onSubmit: onSubmitMock, + }, + }); + return { + renderResult, + onSubmitMock, + }; +}; + +describe('useWorkspaceForm', () => { + it('should return "Invalid workspace name" and not call onSubmit when invalid name', async () => { + const { renderResult, onSubmitMock } = setup({ + id: 'foo', + name: '~', + }); + expect(renderResult.result.current.formErrors).toEqual({}); + + act(() => { + renderResult.result.current.handleFormSubmit({ preventDefault: jest.fn() }); + }); + expect(renderResult.result.current.formErrors).toEqual({ + name: 'Invalid workspace name', + }); + expect(onSubmitMock).not.toHaveBeenCalled(); + }); + it('should return "Invalid workspace description" and not call onSubmit when invalid description', async () => { + const { renderResult, onSubmitMock } = setup({ + id: 'foo', + name: 'test-workspace-name', + description: '~', + }); + expect(renderResult.result.current.formErrors).toEqual({}); + + act(() => { + renderResult.result.current.handleFormSubmit({ preventDefault: jest.fn() }); + }); + expect(renderResult.result.current.formErrors).toEqual({ + description: 'Invalid workspace description', + }); + expect(onSubmitMock).not.toHaveBeenCalled(); + }); + it('should call onSubmit with workspace name and features', async () => { + const { renderResult, onSubmitMock } = setup({ + id: 'foo', + name: 'test-workspace-name', + }); + expect(renderResult.result.current.formErrors).toEqual({}); + + act(() => { + renderResult.result.current.handleFormSubmit({ preventDefault: jest.fn() }); + }); + expect(onSubmitMock).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test-workspace-name', + features: ['workspace_update', 'workspace_overview'], + }) + ); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts new file mode 100644 index 000000000000..315f3486f83e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -0,0 +1,148 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useState, FormEventHandler, useRef, useMemo, useEffect } from 'react'; +import { htmlIdGenerator, EuiFieldTextProps, EuiColorPickerProps } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { useApplications } from '../../hooks'; +import { featureMatchesConfig } from '../../utils'; + +import { WorkspaceFormTabs } from './constants'; +import { WorkspaceFormProps, WorkspaceFormErrors } from './types'; +import { appendDefaultFeatureIds, getNumberOfErrors, isValidFormTextInput } from './utils'; + +const workspaceHtmlIdGenerator = htmlIdGenerator(); + +export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: WorkspaceFormProps) => { + const applications = useApplications(application); + const [name, setName] = useState(defaultValues?.name); + const [description, setDescription] = useState(defaultValues?.description); + const [color, setColor] = useState(defaultValues?.color); + + const [selectedTab, setSelectedTab] = useState(WorkspaceFormTabs.FeatureVisibility); + // The matched feature id list based on original feature config, + // the feature category will be expanded to list of feature ids + const defaultFeatures = useMemo(() => { + // The original feature list, may contain feature id and category wildcard like @management, etc. + const defaultOriginalFeatures = defaultValues?.features ?? []; + return applications.filter(featureMatchesConfig(defaultOriginalFeatures)).map((app) => app.id); + }, [defaultValues?.features, applications]); + + const defaultFeaturesRef = useRef(defaultFeatures); + defaultFeaturesRef.current = defaultFeatures; + + const [selectedFeatureIds, setSelectedFeatureIds] = useState( + appendDefaultFeatureIds(defaultFeatures) + ); + + const [formErrors, setFormErrors] = useState({}); + const numberOfErrors = useMemo(() => getNumberOfErrors(formErrors), [formErrors]); + const formIdRef = useRef(); + const getFormData = () => ({ + name, + description, + features: selectedFeatureIds, + color, + }); + const getFormDataRef = useRef(getFormData); + getFormDataRef.current = getFormData; + + if (!formIdRef.current) { + formIdRef.current = workspaceHtmlIdGenerator(); + } + + const handleFormSubmit = useCallback( + (e) => { + e.preventDefault(); + let currentFormErrors: WorkspaceFormErrors = {}; + const formData = getFormDataRef.current(); + if (!formData.name) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.empty', { + defaultMessage: "Name can't be empty.", + }), + }; + } + if (formData.name && !isValidFormTextInput(formData.name)) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.invalid', { + defaultMessage: 'Invalid workspace name', + }), + }; + } + if (formData.description && !isValidFormTextInput(formData.description)) { + currentFormErrors = { + ...currentFormErrors, + description: i18n.translate('workspace.form.detail.description.invalid', { + defaultMessage: 'Invalid workspace description', + }), + }; + } + setFormErrors(currentFormErrors); + if (getNumberOfErrors(currentFormErrors) > 0) { + return; + } + + const featureConfigChanged = + formData.features.length !== defaultFeatures.length || + formData.features.some((feat) => !defaultFeatures.includes(feat)); + + if (!featureConfigChanged) { + // If feature config not changed, set workspace feature config to the original value. + // The reason why we do this is when a workspace feature is configured by wildcard, + // such as `['@management']` or `['*']`. The form value `formData.features` will be + // expanded to array of individual feature id, if the feature hasn't changed, we will + // set the feature config back to the original value so that category wildcard won't + // expanded to feature ids + formData.features = defaultValues?.features ?? []; + } + + onSubmit?.({ ...formData, name: formData.name! }); + }, + [defaultFeatures, onSubmit, defaultValues?.features] + ); + + const handleNameInputChange = useCallback['onChange']>((e) => { + setName(e.target.value); + }, []); + + const handleDescriptionInputChange = useCallback['onChange']>((e) => { + setDescription(e.target.value); + }, []); + + const handleColorChange = useCallback['onChange']>((text) => { + setColor(text); + }, []); + + const handleTabFeatureClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.FeatureVisibility); + }, []); + + const handleFeaturesChange = useCallback((featureIds: string[]) => { + setSelectedFeatureIds(featureIds); + }, []); + + useEffect(() => { + // When applications changed, reset form feature selection to original value + setSelectedFeatureIds(appendDefaultFeatureIds(defaultFeaturesRef.current)); + }, [applications]); + + return { + formId: formIdRef.current, + formData: getFormData(), + formErrors, + selectedTab, + applications, + numberOfErrors, + handleFormSubmit, + handleColorChange, + handleFeaturesChange, + handleNameInputChange, + handleTabFeatureClick, + handleDescriptionInputChange, + }; +}; diff --git a/src/plugins/workspace/public/components/workspace_form/utils.test.ts b/src/plugins/workspace/public/components/workspace_form/utils.test.ts new file mode 100644 index 000000000000..6101bd078831 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/utils.test.ts @@ -0,0 +1,126 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppNavLinkStatus, DEFAULT_APP_CATEGORIES } from '../../../../../core/public'; +import { convertApplicationsToFeaturesOrGroups } from './utils'; + +describe('convertApplicationsToFeaturesOrGroups', () => { + it('should filter out invisible features', () => { + expect( + convertApplicationsToFeaturesOrGroups([ + { id: 'foo1', title: 'Foo 1', navLinkStatus: AppNavLinkStatus.hidden }, + { id: 'foo2', title: 'Foo 2', navLinkStatus: AppNavLinkStatus.visible, chromeless: true }, + { + id: 'foo3', + title: 'Foo 3', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.management, + }, + { + id: 'workspace_overview', + title: 'Workspace Overview', + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'bar', + title: 'Bar', + navLinkStatus: AppNavLinkStatus.visible, + }, + ]) + ).toEqual([ + { + id: 'bar', + name: 'Bar', + }, + ]); + }); + it('should group same category applications in same feature group', () => { + expect( + convertApplicationsToFeaturesOrGroups([ + { + id: 'foo', + title: 'Foo', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }, + { + id: 'bar', + title: 'Bar', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }, + { + id: 'baz', + title: 'Baz', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.observability, + }, + ]) + ).toEqual([ + { + name: 'OpenSearch Dashboards', + features: [ + { + id: 'foo', + name: 'Foo', + }, + { + id: 'bar', + name: 'Bar', + }, + ], + }, + { + name: 'Observability', + features: [ + { + id: 'baz', + name: 'Baz', + }, + ], + }, + ]); + }); + it('should return features if application without category', () => { + expect( + convertApplicationsToFeaturesOrGroups([ + { + id: 'foo', + title: 'Foo', + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'baz', + title: 'Baz', + navLinkStatus: AppNavLinkStatus.visible, + category: DEFAULT_APP_CATEGORIES.observability, + }, + { + id: 'bar', + title: 'Bar', + navLinkStatus: AppNavLinkStatus.visible, + }, + ]) + ).toEqual([ + { + id: 'foo', + name: 'Foo', + }, + { + id: 'bar', + name: 'Bar', + }, + { + name: 'Observability', + features: [ + { + id: 'baz', + name: 'Baz', + }, + ], + }, + ]); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts new file mode 100644 index 000000000000..5514e6a8fb9c --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -0,0 +1,100 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + AppNavLinkStatus, + DEFAULT_APP_CATEGORIES, + PublicAppInfo, +} from '../../../../../core/public'; +import { DEFAULT_SELECTED_FEATURES_IDS } from '../../../common/constants'; + +import { WorkspaceFeature, WorkspaceFeatureGroup, WorkspaceFormErrors } from './types'; + +export const isWorkspaceFeatureGroup = ( + featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup +): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; + +export const appendDefaultFeatureIds = (ids: string[]) => { + // concat default checked ids and unique the result + return Array.from(new Set(ids.concat(DEFAULT_SELECTED_FEATURES_IDS))); +}; + +export const isValidFormTextInput = (input?: string) => { + /** + * This regular expression is from the workspace form name and description field UI. + * It only accepts below characters. + **/ + const regex = /^[0-9a-zA-Z()_\[\]\-\s]+$/; + return typeof input === 'string' && regex.test(input); +}; + +export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { + let numberOfErrors = 0; + if (formErrors.name) { + numberOfErrors += 1; + } + if (formErrors.description) { + numberOfErrors += 1; + } + return numberOfErrors; +}; + +export const convertApplicationsToFeaturesOrGroups = ( + applications: Array< + Pick + > +) => { + const UNDEFINED = 'undefined'; + + // Filter out all hidden applications and management applications and default selected features + const visibleApplications = applications.filter( + ({ navLinkStatus, chromeless, category, id }) => + navLinkStatus !== AppNavLinkStatus.hidden && + !chromeless && + !DEFAULT_SELECTED_FEATURES_IDS.includes(id) && + category?.id !== DEFAULT_APP_CATEGORIES.management.id + ); + + /** + * + * Convert applications to features map, the map use category label as + * map key and group all same category applications in one array after + * transfer application to feature. + * + **/ + const categoryLabel2Features = visibleApplications.reduce<{ + [key: string]: WorkspaceFeature[]; + }>((previousValue, application) => { + const label = application.category?.label || UNDEFINED; + + return { + ...previousValue, + [label]: [...(previousValue[label] || []), { id: application.id, name: application.title }], + }; + }, {}); + + /** + * + * Iterate all keys of categoryLabel2Features map, convert map to features or groups array. + * Features with category label will be converted to feature groups. Features without "undefined" + * category label will be converted to single features. Then append them to the result array. + * + **/ + return Object.keys(categoryLabel2Features).reduce< + Array + >((previousValue, categoryLabel) => { + const features = categoryLabel2Features[categoryLabel]; + if (categoryLabel === UNDEFINED) { + return [...previousValue, ...features]; + } + return [ + ...previousValue, + { + name: categoryLabel, + features, + }, + ]; + }, []); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx new file mode 100644 index 000000000000..7e528d2214ee --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx @@ -0,0 +1,110 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBottomBar, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { WorkspaceOperationType } from '../workspace_form'; +import { WorkspaceCancelModal } from './workspace_cancel_modal'; + +interface WorkspaceBottomBarProps { + formId: string; + operationType?: WorkspaceOperationType; + numberOfErrors: number; + application: ApplicationStart; + numberOfUnSavedChanges?: number; +} + +export const WorkspaceBottomBar = ({ + formId, + operationType, + numberOfErrors, + numberOfUnSavedChanges, + application, +}: WorkspaceBottomBarProps) => { + const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); + const closeCancelModal = () => setIsCancelModalVisible(false); + const showCancelModal = () => setIsCancelModalVisible(true); + + return ( +
+ + + + + + + {operationType === WorkspaceOperationType.Update ? ( + + {i18n.translate('workspace.form.bottomBar.unsavedChanges', { + defaultMessage: '{numberOfUnSavedChanges} Unsaved change(s)', + values: { + numberOfUnSavedChanges, + }, + })} + + ) : ( + + {i18n.translate('workspace.form.bottomBar.errors', { + defaultMessage: '{numberOfErrors} Error(s)', + values: { + numberOfErrors, + }, + })} + + )} + + + + + + {i18n.translate('workspace.form.bottomBar.cancel', { + defaultMessage: 'Cancel', + })} + + + {operationType === WorkspaceOperationType.Create && ( + + {i18n.translate('workspace.form.bottomBar.createWorkspace', { + defaultMessage: 'Create workspace', + })} + + )} + {operationType === WorkspaceOperationType.Update && ( + + {i18n.translate('workspace.form.bottomBar.saveChanges', { + defaultMessage: 'Save changes', + })} + + )} + + + + + {isCancelModalVisible && ( + + )} +
+ ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx new file mode 100644 index 000000000000..11e835087cd6 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx @@ -0,0 +1,43 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiConfirmModal } from '@elastic/eui'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { WORKSPACE_LIST_APP_ID } from '../../../common/constants'; + +interface WorkspaceCancelModalProps { + application: ApplicationStart; + closeCancelModal: () => void; +} + +export const WorkspaceCancelModal = ({ + application, + closeCancelModal, +}: WorkspaceCancelModalProps) => { + return ( + application?.navigateToApp(WORKSPACE_LIST_APP_ID)} + cancelButtonText={i18n.translate('workspace.form.cancelButtonText', { + defaultMessage: 'Continue editing', + })} + confirmButtonText={i18n.translate('workspace.form.confirmButtonText', { + defaultMessage: 'Discard changes', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + > + {i18n.translate('workspace.form.cancelModal.body', { + defaultMessage: 'This will discard all changes. Are you sure?', + })} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx new file mode 100644 index 000000000000..0875b0d1ff10 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.test.tsx @@ -0,0 +1,92 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render } from '@testing-library/react'; +import { + WorkspaceFeatureSelector, + WorkspaceFeatureSelectorProps, +} from './workspace_feature_selector'; +import { AppNavLinkStatus } from '../../../../../core/public'; + +const setup = (options?: Partial) => { + const onChangeMock = jest.fn(); + const applications = [ + { + id: 'app-1', + title: 'App 1', + category: { id: 'category-1', label: 'Category 1' }, + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'app-2', + title: 'App 2', + category: { id: 'category-1', label: 'Category 1' }, + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'app-3', + title: 'App 3', + category: { id: 'category-2', label: 'Category 2' }, + navLinkStatus: AppNavLinkStatus.visible, + }, + { + id: 'app-4', + title: 'App 4', + navLinkStatus: AppNavLinkStatus.visible, + }, + ]; + const renderResult = render( + + ); + return { + renderResult, + onChangeMock, + }; +}; + +describe('WorkspaceFeatureSelector', () => { + it('should call onChange with clicked feature', () => { + const { renderResult, onChangeMock } = setup(); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByText('App 1')); + expect(onChangeMock).toHaveBeenCalledWith(['app-1']); + }); + it('should call onChange with empty array after selected feature clicked', () => { + const { renderResult, onChangeMock } = setup({ + selectedFeatures: ['app-2'], + }); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click(renderResult.getByText('App 2')); + expect(onChangeMock).toHaveBeenCalledWith([]); + }); + it('should call onChange with features under clicked group', () => { + const { renderResult, onChangeMock } = setup(); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click( + renderResult.getByTestId('workspaceForm-workspaceFeatureVisibility-Category 1') + ); + expect(onChangeMock).toHaveBeenCalledWith(['app-1', 'app-2']); + }); + it('should call onChange without features under clicked group when group already selected', () => { + const { renderResult, onChangeMock } = setup({ + selectedFeatures: ['app-1', 'app-2', 'app-3'], + }); + + expect(onChangeMock).not.toHaveBeenCalled(); + fireEvent.click( + renderResult.getByTestId('workspaceForm-workspaceFeatureVisibility-Category 1') + ); + expect(onChangeMock).toHaveBeenCalledWith(['app-3']); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx new file mode 100644 index 000000000000..da9deb174f52 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx @@ -0,0 +1,143 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiText, + EuiFlexItem, + EuiCheckbox, + EuiCheckboxGroup, + EuiFlexGroup, + EuiCheckboxGroupProps, + EuiCheckboxProps, +} from '@elastic/eui'; + +import { PublicAppInfo } from '../../../../../core/public'; + +import { isWorkspaceFeatureGroup, convertApplicationsToFeaturesOrGroups } from './utils'; + +export interface WorkspaceFeatureSelectorProps { + applications: Array< + Pick + >; + selectedFeatures: string[]; + onChange: (newFeatures: string[]) => void; +} + +export const WorkspaceFeatureSelector = ({ + applications, + selectedFeatures, + onChange, +}: WorkspaceFeatureSelectorProps) => { + const featuresOrGroups = useMemo(() => convertApplicationsToFeaturesOrGroups(applications), [ + applications, + ]); + + const handleFeatureChange = useCallback( + (featureId) => { + if (!selectedFeatures.includes(featureId)) { + onChange([...selectedFeatures, featureId]); + return; + } + onChange(selectedFeatures.filter((selectedId) => selectedId !== featureId)); + }, + [selectedFeatures, onChange] + ); + + const handleFeatureCheckboxChange = useCallback( + (e) => { + handleFeatureChange(e.target.id); + }, + [handleFeatureChange] + ); + + const handleFeatureGroupChange = useCallback( + (e) => { + const featureOrGroup = featuresOrGroups.find( + (item) => isWorkspaceFeatureGroup(item) && item.name === e.target.id + ); + if (!featureOrGroup || !isWorkspaceFeatureGroup(featureOrGroup)) { + return; + } + const groupFeatureIds = featureOrGroup.features.map((feature) => feature.id); + const notExistsIds = groupFeatureIds.filter((id) => !selectedFeatures.includes(id)); + // Check all not selected features if not been selected in current group. + if (notExistsIds.length > 0) { + onChange([...selectedFeatures, ...notExistsIds]); + return; + } + // Need to un-check these features, if all features in group has been selected + onChange(selectedFeatures.filter((featureId) => !groupFeatureIds.includes(featureId))); + }, + [featuresOrGroups, selectedFeatures, onChange] + ); + + return ( + <> + {featuresOrGroups.map((featureOrGroup) => { + const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; + const selectedIds = selectedFeatures.filter((id) => + (isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.features + : [featureOrGroup] + ).find((item) => item.id === id) + ); + const featureOrGroupId = isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.name + : featureOrGroup.id; + + return ( + + +
+ + {featureOrGroup.name} + +
+
+ + 0 ? ` (${selectedIds.length}/${features.length})` : '' + }`} + checked={selectedIds.length > 0} + indeterminate={ + isWorkspaceFeatureGroup(featureOrGroup) && + selectedIds.length > 0 && + selectedIds.length < features.length + } + data-test-subj={`workspaceForm-workspaceFeatureVisibility-${featureOrGroupId}`} + /> + {isWorkspaceFeatureGroup(featureOrGroup) && ( + ({ + id: item.id, + label: item.name, + }))} + idToSelectedMap={selectedIds.reduce( + (previousValue, currentValue) => ({ + ...previousValue, + [currentValue]: true, + }), + {} + )} + onChange={handleFeatureChange} + style={{ marginLeft: 40 }} + data-test-subj={`workspaceForm-workspaceFeatureVisibility-featureWithCategory-${featureOrGroupId}`} + /> + )} + +
+ ); + })} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx new file mode 100644 index 000000000000..69793c75395d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -0,0 +1,151 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiPanel, + EuiSpacer, + EuiTitle, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiText, + EuiColorPicker, + EuiHorizontalRule, + EuiTab, + EuiTabs, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +import { WorkspaceBottomBar } from './workspace_bottom_bar'; +import { WorkspaceFormProps } from './types'; +import { WorkspaceFormTabs } from './constants'; +import { useWorkspaceForm } from './use_workspace_form'; +import { WorkspaceFeatureSelector } from './workspace_feature_selector'; + +export const WorkspaceForm = (props: WorkspaceFormProps) => { + const { application, defaultValues, operationType } = props; + const { + formId, + formData, + formErrors, + selectedTab, + applications, + numberOfErrors, + handleFormSubmit, + handleColorChange, + handleFeaturesChange, + handleNameInputChange, + handleTabFeatureClick, + handleDescriptionInputChange, + } = useWorkspaceForm(props); + const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { + defaultMessage: 'Workspace Details', + }); + const featureVisibilityTitle = i18n.translate('workspace.form.featureVisibility.title', { + defaultMessage: 'Feature Visibility', + }); + + return ( + + + +

{workspaceDetailsTitle}

+
+ + + + + + + Description - optional + + } + helpText={i18n.translate('workspace.form.workspaceDetails.description.helpText', { + defaultMessage: + 'Valid characters are a-z, A-Z, 0-9, (), [], _ (underscore), - (hyphen) and (space).', + })} + isInvalid={!!formErrors.description} + error={formErrors.description} + > + + + +
+ + {i18n.translate('workspace.form.workspaceDetails.color.helpText', { + defaultMessage: 'Accent color for your workspace', + })} + + + +
+
+
+ + + + + {featureVisibilityTitle} + + + {selectedTab === WorkspaceFormTabs.FeatureVisibility && ( + + +

{featureVisibilityTitle}

+
+ + + +
+ )} + + +
+ ); +}; diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts new file mode 100644 index 000000000000..875e9b494f23 --- /dev/null +++ b/src/plugins/workspace/public/hooks.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public'; +import { useObservable } from 'react-use'; +import { useMemo } from 'react'; + +export function useApplications(applicationInstance: ApplicationStart) { + const applications = useObservable(applicationInstance.applications$); + return useMemo(() => { + const apps: PublicAppInfo[] = []; + applications?.forEach((app) => { + apps.push(app); + }); + return apps; + }, [applications]); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 2306c460b88d..0ec007079c24 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -23,6 +23,7 @@ describe('Workspace plugin', () => { const setupMock = getSetupMock(); const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(2); expect(WorkspaceClientMock).toBeCalledTimes(1); }); @@ -34,7 +35,7 @@ describe('Workspace plugin', () => { workspacePlugin.start(coreStart); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); - expect(setupMock.application.register).toBeCalledTimes(1); + expect(setupMock.application.register).toBeCalledTimes(2); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); }); @@ -69,7 +70,7 @@ describe('Workspace plugin', () => { const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); - expect(setupMock.application.register).toBeCalledTimes(1); + expect(setupMock.application.register).toBeCalledTimes(2); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); expect(setupMock.getStartServices).toBeCalledTimes(1); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index f0c82bda90b7..eeaab74e8e44 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -5,6 +5,7 @@ import type { Subscription } from 'rxjs'; import React from 'react'; +import { i18n } from '@osd/i18n'; import { Plugin, CoreStart, @@ -12,7 +13,11 @@ import { AppMountParameters, AppNavLinkStatus, } from '../../../core/public'; -import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { + WORKSPACE_FATAL_ERROR_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_CREATE_APP_ID, +} from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; @@ -89,6 +94,19 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { return renderApp(params, services); }; + // create + core.application.register({ + id: WORKSPACE_CREATE_APP_ID, + title: i18n.translate('workspace.settings.workspaceCreate', { + defaultMessage: 'Create Workspace', + }), + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderCreatorApp } = await import('./application'); + return mountWorkspaceApp(params, renderCreatorApp); + }, + }); + // workspace fatal error core.application.register({ id: WORKSPACE_FATAL_ERROR_APP_ID, diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts new file mode 100644 index 000000000000..510a775cd745 --- /dev/null +++ b/src/plugins/workspace/public/utils.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { featureMatchesConfig } from './utils'; + +describe('workspace utils: featureMatchesConfig', () => { + it('feature configured with `*` should match any features', () => { + const match = featureMatchesConfig(['*']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should NOT match the config if feature id not matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + }); + + it('should match the config if feature id matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should match the config if feature category matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', '@management', 'visualize']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); + + it('should match any features but not the excluded feature id', () => { + const match = featureMatchesConfig(['*', '!discover']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(false); + }); + + it('should match any features but not the excluded feature category', () => { + const match = featureMatchesConfig(['*', '!@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should NOT match the excluded feature category', () => { + const match = featureMatchesConfig(['!@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + }); + + it('should match features of a category but NOT the excluded feature', () => { + const match = featureMatchesConfig(['@management', '!dev_tools']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); + + it('a config presents later in the config array should override the previous config', () => { + // though `dev_tools` is excluded, but this config will override by '@management' as dev_tools has category 'management' + const match = featureMatchesConfig(['!dev_tools', '@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts new file mode 100644 index 000000000000..444b3aadadf3 --- /dev/null +++ b/src/plugins/workspace/public/utils.ts @@ -0,0 +1,57 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppCategory } from '../../../core/public'; + +/** + * Checks if a given feature matches the provided feature configuration. + * + * Rules: + * 1. `*` matches any feature. + * 2. Config starts with `@` matches category, for example, @management matches any feature of `management` category, + * 3. To match a specific feature, use the feature id, such as `discover`, + * 4. To exclude a feature or category, prepend with `!`, e.g., `!discover` or `!@management`. + * 5. The order of featureConfig array matters. From left to right, later configs override the previous ones. + * For example, ['!@management', '*'] matches any feature because '*' overrides the previous setting: '!@management'. + */ +export const featureMatchesConfig = (featureConfigs: string[]) => ({ + id, + category, +}: { + id: string; + category?: AppCategory; +}) => { + let matched = false; + + for (const featureConfig of featureConfigs) { + // '*' matches any feature + if (featureConfig === '*') { + matched = true; + } + + // The config starts with `@` matches a category + if (category && featureConfig === `@${category.id}`) { + matched = true; + } + + // The config matches a feature id + if (featureConfig === id) { + matched = true; + } + + // If a config starts with `!`, such feature or category will be excluded + if (featureConfig.startsWith('!')) { + if (category && featureConfig === `!@${category.id}`) { + matched = false; + } + + if (featureConfig === `!${id}`) { + matched = false; + } + } + } + + return matched; +}; From 74e0df3b86e5d70b11a7c2eb03082b09e0f985c5 Mon Sep 17 00:00:00 2001 From: Lu Yu Date: Mon, 25 Mar 2024 20:30:34 -0700 Subject: [PATCH 19/45] [Multiple Datasource] Remove arrow down icon from data source selectable component (#6257) * remove arrowdown icon from data source selector Signed-off-by: Lu Yu * add change log Signed-off-by: Lu Yu * update snapshot Signed-off-by: Lu Yu --------- Signed-off-by: Lu Yu --- CHANGELOG.md | 1 + .../create_data_source_menu.test.tsx.snap | 21 ++++++------------- .../data_source_selectable.test.tsx.snap | 21 ++++++------------- .../data_source_selectable.tsx | 5 ++--- 4 files changed, 15 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b00f9c3ae93a..b1f58d242853 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -59,6 +59,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Dynamic Configurations] Pass request headers when making application config calls ([#6164](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6164)) - [Discover] Options button to configure legacy mode and remove the top navigation option ([#6170](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6170)) - [Multiple Datasource] Add default functionality for customer to choose default datasource ([#6058](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/6058)) +- [Multiple Datasource] Remove arrow down icon from data source selectable component ([#6257](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6257)) - [Multiple Datasource] Add import support for Vega when specifying a datasource ([#6123](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6123)) - [Workspace] Validate if workspace exists when setup inside a workspace ([#6154](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6154)) - [Workspace] Register a workspace dropdown menu at the top of left nav bar ([#6150](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/6150)) diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap index 55b037cb8307..cb7e4410472c 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/create_data_source_menu.test.tsx.snap @@ -12,9 +12,6 @@ Object {
- +
+
+
+
+
+