diff --git a/changelogs/fragments/8611.yml b/changelogs/fragments/8611.yml new file mode 100644 index 000000000000..2f7ec1677a58 --- /dev/null +++ b/changelogs/fragments/8611.yml @@ -0,0 +1,2 @@ +fix: +- Bump url to 0.11.4 ([#8611](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8611)) \ No newline at end of file diff --git a/changelogs/fragments/8739.yml b/changelogs/fragments/8739.yml new file mode 100644 index 000000000000..563d6c0cacac --- /dev/null +++ b/changelogs/fragments/8739.yml @@ -0,0 +1,2 @@ +fix: +- [Workspace] [Bug] Check if workspaces exists when creating saved objects. ([#8739](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8739)) \ No newline at end of file diff --git a/changelogs/fragments/8842.yml b/changelogs/fragments/8842.yml new file mode 100644 index 000000000000..b9973f347f9e --- /dev/null +++ b/changelogs/fragments/8842.yml @@ -0,0 +1,2 @@ +fix: +- [Workspace]Fix error toasts in sample data page ([#8842](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8842)) \ No newline at end of file diff --git a/changelogs/fragments/8886.yml b/changelogs/fragments/8886.yml new file mode 100644 index 000000000000..74b3b404d8f5 --- /dev/null +++ b/changelogs/fragments/8886.yml @@ -0,0 +1,2 @@ +chore: +- Bump `@opensearch-project/opensearch` from 2.9.0 to 2.13.0 ([#8886](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8886)) \ No newline at end of file diff --git a/changelogs/fragments/8888.yml b/changelogs/fragments/8888.yml new file mode 100644 index 000000000000..cf22e39bf062 --- /dev/null +++ b/changelogs/fragments/8888.yml @@ -0,0 +1,2 @@ +refactor: +- [Workspace] Isolate objects based on workspace when calling get/bulkGet ([#8888](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8888)) \ No newline at end of file diff --git a/changelogs/fragments/8900.yml b/changelogs/fragments/8900.yml new file mode 100644 index 000000000000..78ae369755a7 --- /dev/null +++ b/changelogs/fragments/8900.yml @@ -0,0 +1,2 @@ +feat: +- Optimize recent items and filter out items whose workspace is deleted ([#8900](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8900)) \ No newline at end of file diff --git a/changelogs/fragments/8915.yml b/changelogs/fragments/8915.yml deleted file mode 100644 index 46c124d3f25f..000000000000 --- a/changelogs/fragments/8915.yml +++ /dev/null @@ -1,2 +0,0 @@ -fix: -- Do not support data sources with no version for vis augmenter ([#8915](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8915)) \ No newline at end of file diff --git a/changelogs/fragments/8919.yml b/changelogs/fragments/8919.yml new file mode 100644 index 000000000000..f18d457de271 --- /dev/null +++ b/changelogs/fragments/8919.yml @@ -0,0 +1,2 @@ +fix: +- Change some of the http link in settings page to https link ([#8919](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8919)) \ No newline at end of file diff --git a/changelogs/fragments/8920.yml b/changelogs/fragments/8920.yml new file mode 100644 index 000000000000..f25a3042d437 --- /dev/null +++ b/changelogs/fragments/8920.yml @@ -0,0 +1,2 @@ +feat: +- [workspace]support search dev tools by its category name ([#8920](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8920)) \ No newline at end of file diff --git a/changelogs/fragments/8926.yml b/changelogs/fragments/8926.yml new file mode 100644 index 000000000000..b99f449c54ca --- /dev/null +++ b/changelogs/fragments/8926.yml @@ -0,0 +1,2 @@ +chore: +- Update cypress to v12 ([#8926](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8926)) \ No newline at end of file diff --git a/changelogs/fragments/8930.yml b/changelogs/fragments/8930.yml new file mode 100644 index 000000000000..50551ecb2956 --- /dev/null +++ b/changelogs/fragments/8930.yml @@ -0,0 +1,2 @@ +fix: +- Update saved search initialization logic to use current query instead of default query ([#8930](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8930)) \ No newline at end of file diff --git a/changelogs/fragments/8932.yml b/changelogs/fragments/8932.yml new file mode 100644 index 000000000000..a048de0a102a --- /dev/null +++ b/changelogs/fragments/8932.yml @@ -0,0 +1,2 @@ +feat: +- Support custom logic to insert time filter based on dataset type ([#8932](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8932)) \ No newline at end of file diff --git a/changelogs/fragments/8935.yml b/changelogs/fragments/8935.yml new file mode 100644 index 000000000000..84922a039ffc --- /dev/null +++ b/changelogs/fragments/8935.yml @@ -0,0 +1,2 @@ +fix: +- Use roundUp when converting timestamp for PPL ([#8935](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8935)) \ No newline at end of file diff --git a/changelogs/fragments/8951.yml b/changelogs/fragments/8951.yml new file mode 100644 index 000000000000..da724b7d3c66 --- /dev/null +++ b/changelogs/fragments/8951.yml @@ -0,0 +1,2 @@ +fix: +- SQL syntax highlighting double quotes ([#8951](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8951)) \ No newline at end of file diff --git a/changelogs/fragments/8993.yml b/changelogs/fragments/8993.yml new file mode 100644 index 000000000000..dac519c8b746 --- /dev/null +++ b/changelogs/fragments/8993.yml @@ -0,0 +1,2 @@ +fix: +- Support imports without extensions in cypress webpack build ([#8993](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8993)) \ No newline at end of file diff --git a/cypress.config.ts b/cypress.config.ts new file mode 100644 index 000000000000..1a9d8cc98028 --- /dev/null +++ b/cypress.config.ts @@ -0,0 +1,66 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { defineConfig } from 'cypress'; +import webpackPreprocessor from '@cypress/webpack-preprocessor'; + +module.exports = defineConfig({ + retries: 2, + defaultCommandTimeout: 60000, + requestTimeout: 60000, + responseTimeout: 60000, + viewportWidth: 2000, + viewportHeight: 1320, + env: { + openSearchUrl: 'http://localhost:9200', + SECURITY_ENABLED: false, + AGGREGATION_VIEW: false, + username: 'admin', + password: 'myStrongPassword123!', + ENDPOINT_WITH_PROXY: false, + MANAGED_SERVICE_ENDPOINT: false, + VISBUILDER_ENABLED: true, + DATASOURCE_MANAGEMENT_ENABLED: false, + ML_COMMONS_DASHBOARDS_ENABLED: true, + WAIT_FOR_LOADER_BUFFER_MS: 0, + }, + e2e: { + baseUrl: 'http://localhost:5601', + specPattern: 'cypress/integration/**/*_spec.{js,jsx,ts,tsx}', + testIsolation: false, + setupNodeEvents, + }, +}); + +function setupNodeEvents( + on: Cypress.PluginEvents, + config: Cypress.PluginConfigOptions +): Cypress.PluginConfigOptions { + const { webpackOptions } = webpackPreprocessor.defaultOptions; + + /** + * By default, cypress' internal webpack preprocessor doesn't allow imports without file extensions. + * This makes our life a bit hard since if any file in our testing dependency graph has an import without + * the .js extension our cypress build will fail. + * + * This extra rule relaxes this a bit by allowing imports without file extension + * ex. import module from './module' + */ + webpackOptions!.module!.rules.unshift({ + test: /\.m?js/, + resolve: { + enforceExtension: false, + }, + }); + + on( + 'file:preprocessor', + webpackPreprocessor({ + webpackOptions, + }) + ); + + return config; +} diff --git a/cypress.json b/cypress.json deleted file mode 100644 index 46e8c7e8ea16..000000000000 --- a/cypress.json +++ /dev/null @@ -1,21 +0,0 @@ -{ - "defaultCommandTimeout": 60000, - "requestTimeout": 60000, - "responseTimeout": 60000, - "baseUrl": "http://localhost:5601", - "viewportWidth": 2000, - "viewportHeight": 1320, - "env": { - "openSearchUrl": "http://localhost:9200", - "SECURITY_ENABLED": false, - "AGGREGATION_VIEW": false, - "username": "admin", - "password": "myStrongPassword123!", - "ENDPOINT_WITH_PROXY": false, - "MANAGED_SERVICE_ENDPOINT": false, - "VISBUILDER_ENABLED": true, - "DATASOURCE_MANAGEMENT_ENABLED": false, - "ML_COMMONS_DASHBOARDS_ENABLED": true, - "WAIT_FOR_LOADER_BUFFER_MS": 0 - } -} diff --git a/cypress/integration/core_opensearch_dashboards/filter_for_value_spec.js b/cypress/integration/core_opensearch_dashboards/filter_for_value_spec.js new file mode 100644 index 000000000000..43305f08bf60 --- /dev/null +++ b/cypress/integration/core_opensearch_dashboards/filter_for_value_spec.js @@ -0,0 +1,63 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MiscUtils } from '@opensearch-dashboards-test/opensearch-dashboards-test-library'; +import { DataExplorerPage } from '../../utils/dashboards/data_explorer/data_explorer_page.po'; + +const miscUtils = new MiscUtils(cy); + +describe('filter for value spec', () => { + beforeEach(() => { + cy.localLogin(Cypress.env('username'), Cypress.env('password')); + miscUtils.visitPage('app/data-explorer/discover'); + cy.getNewSearchButton().click(); + }); + describe('filter actions in table field', () => { + describe('index pattern dataset', () => { + // filter actions should exist for DQL + it('DQL', () => { + DataExplorerPage.selectIndexPatternDataset('DQL'); + DataExplorerPage.setSearchRelativeDateRange('15', 'Years ago'); + DataExplorerPage.checkDocTableFirstFieldFilterForAndOutButton(true); + DataExplorerPage.checkDocTableFirstFieldFilterForButtonFiltersCorrectField(); + DataExplorerPage.checkDocTableFirstFieldFilterOutButtonFiltersCorrectField(); + }); + // filter actions should exist for Lucene + it('Lucene', () => { + DataExplorerPage.selectIndexPatternDataset('Lucene'); + DataExplorerPage.setSearchRelativeDateRange('15', 'Years ago'); + DataExplorerPage.checkDocTableFirstFieldFilterForAndOutButton(true); + DataExplorerPage.checkDocTableFirstFieldFilterForButtonFiltersCorrectField(); + DataExplorerPage.checkDocTableFirstFieldFilterOutButtonFiltersCorrectField(); + }); + // filter actions should not exist for SQL + it('SQL', () => { + DataExplorerPage.selectIndexPatternDataset('OpenSearch SQL'); + DataExplorerPage.checkDocTableFirstFieldFilterForAndOutButton(false); + }); + // filter actions should not exist for PPL + it('PPL', () => { + DataExplorerPage.selectIndexPatternDataset('PPL'); + DataExplorerPage.setSearchRelativeDateRange('15', 'Years ago'); + DataExplorerPage.checkDocTableFirstFieldFilterForAndOutButton(false); + }); + }); + describe('index dataset', () => { + // filter actions should not exist for SQL + it('SQL', () => { + DataExplorerPage.selectIndexDataset( + 'OpenSearch SQL', + "I don't want to use the time filter" + ); + DataExplorerPage.checkDocTableFirstFieldFilterForAndOutButton(false); + }); + // filter actions should not exist for PPL + it('PPL', () => { + DataExplorerPage.selectIndexDataset('PPL', "I don't want to use the time filter"); + DataExplorerPage.checkDocTableFirstFieldFilterForAndOutButton(false); + }); + }); + }); +}); diff --git a/cypress/integration/core_opensearch_dashboards/sidebar_test_spec.js b/cypress/integration/core_opensearch_dashboards/sidebar_test_spec.js new file mode 100644 index 000000000000..b90d212e0ef7 --- /dev/null +++ b/cypress/integration/core_opensearch_dashboards/sidebar_test_spec.js @@ -0,0 +1,266 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { MiscUtils } from '@opensearch-dashboards-test/opensearch-dashboards-test-library'; +import { DataExplorerPage } from '../../utils/dashboards/data_explorer/data_explorer_page.po'; + +const miscUtils = new MiscUtils(cy); + +describe('sidebar spec', function () { + beforeEach(function () { + cy.localLogin(Cypress.env('username'), Cypress.env('password')); + miscUtils.visitPage('app/data-explorer/discover'); + }); + + describe('sidebar fields', function () { + describe('add fields', function () { + function addFields( + testFields, + expectedValues, + pplQuery, + sqlQuery, + indexPattern = true, + nested = false + ) { + const offset = indexPattern ? 1 : 0; // defines starting column + const dataColumnOffset = nested ? -1 : 0; + if (indexPattern) { + DataExplorerPage.selectIndexPatternDataset('DQL'); + DataExplorerPage.setQueryEditorLanguage('DQL'); + DataExplorerPage.setSearchRelativeDateRange('15', 'Years ago'); + } else { + if (nested) { + DataExplorerPage.selectIndexDataset( + 'OpenSearch SQL', + "I don't want to use the time filter", + 'cypress-test-os', + 'opensearch_dashboards_sample_data_ecommerce' + ); + } else { + DataExplorerPage.selectIndexDataset( + 'OpenSearch SQL', + "I don't want to use the time filter", + 'cypress-test-os', + 'vis-builder' + ); + } + } + // Check default column + DataExplorerPage.getDocTableHeader(0 + offset).should('have.text', '_source'); + // Select some fields + testFields.forEach((field) => { + DataExplorerPage.getFieldBtnByName(field).click(); + }); + // Check that the default column no longer exists + DataExplorerPage.getDocTableHeader(0 + offset).should('not.have.text', '_source'); + // Check table headers persistence between DQL and PPL + DataExplorerPage.checkTableHeadersByArray(testFields, offset); + DataExplorerPage.setQueryEditorLanguage('PPL'); + DataExplorerPage.checkTableHeadersByArray(testFields, offset); + // Remove some fields + const firstTestField = testFields[0]; + const secondTestField = testFields[1]; + DataExplorerPage.getFieldBtnByName(firstTestField).click(); + DataExplorerPage.getFieldBtnByName(secondTestField).click(); + DataExplorerPage.getDocTableHeader(0 + offset).should('not.have.text', firstTestField); + DataExplorerPage.getDocTableHeader(1 + offset).should('not.have.text', secondTestField); + // Remove all fields + const thirdTestField = testFields[2]; + const fourthTestField = testFields[3]; + DataExplorerPage.getFieldBtnByName(thirdTestField).click(); + DataExplorerPage.getFieldBtnByName(fourthTestField).click(); + DataExplorerPage.getDocTableHeader(0 + offset).should('have.text', '_source'); + DataExplorerPage.getDocTableHeader(1 + offset).should('not.exist'); + // Select some fields + testFields.forEach((field) => { + DataExplorerPage.getFieldBtnByName(field).click(); + }); + // Check default column again + DataExplorerPage.getDocTableHeader(0 + offset).should('not.have.text', '_source'); + // Check the columns match the selected fields + DataExplorerPage.checkTableHeadersByArray(testFields, offset); + if (indexPattern) { + // Validate default hits + DataExplorerPage.checkQueryHitsText('10,000'); + } + // Send PPL query + cy.intercept('/api/enhancements/search/ppl').as('pplQuery'); + DataExplorerPage.sendQueryOnMultilineEditor(pplQuery); + cy.wait('@pplQuery').then(function () { + // Check table headers persistence after PPL query + DataExplorerPage.checkTableHeadersByArray(testFields, offset); + if (indexPattern) { + // Check filter was correctly applied + DataExplorerPage.checkQueryHitsText('6,588'); + } + // Validate the first 5 rows on the _id column + DataExplorerPage.checkDocTableColumnByArr(expectedValues, 1 + offset + dataColumnOffset); + }); + // Send SQL query + DataExplorerPage.setQueryEditorLanguage('OpenSearch SQL'); + cy.intercept('/api/enhancements/search/sql').as('sqlQuery'); + DataExplorerPage.sendQueryOnMultilineEditor(sqlQuery); + cy.wait('@sqlQuery').then(function () { + // Check table headers persistence after SQL query + DataExplorerPage.checkTableHeadersByArray(testFields, offset); + // Validate the first 5 rows on the _id column + DataExplorerPage.checkDocTableColumnByArr(expectedValues, 1 + offset + dataColumnOffset); + }); + } + + const pplQuery = 'source = vis-builder* | where age > 40'; + const sqlQuery = 'SELECT * FROM vis-builder* WHERE age > 40'; + const testFields = ['_id', 'age', 'birthdate', 'salary']; + const expectedIdValues = ['50', '57', '52', '66', '46']; + it('add field in index pattern: DQL to PPL and SQL', function () { + addFields(testFields, expectedIdValues, pplQuery, sqlQuery); + }); + + it('add field in index: SQL and PPL', function () { + addFields(testFields, expectedIdValues, pplQuery, sqlQuery, false); + }); + + const nestedTestFields = [ + 'geoip.region_name', + 'products.quantity', + 'event.dataset', + 'products.taxful_price', + ]; + const expectedRegionValues = [ + 'Cairo Governorate', + 'Dubai', + 'California', + ' - ', + 'Cairo Governorate', + ]; + it.skip('add nested field in index pattern: DQL to PPL and SQL', function () { + addFields(nestedTestFields, expectedRegionValues, pplQuery, sqlQuery, true, true); + }); + + it('add nested field in index: SQL and PPL', function () { + addFields(nestedTestFields, expectedRegionValues, pplQuery, sqlQuery, false, true); + }); + }); + + describe('filter fields', function () { + function filterFields() { + DataExplorerPage.checkSidebarFilterBarResults('equal', 'categories'); + DataExplorerPage.checkSidebarFilterBarResults('include', 'a'); + DataExplorerPage.checkSidebarFilterBarResults('include', 'ag'); + DataExplorerPage.checkSidebarFilterBarNegativeResults('non-existent field'); + } + + it('index pattern: DQL, PPL and SQL', function () { + DataExplorerPage.selectIndexPatternDataset('DQL'); + DataExplorerPage.setQueryEditorLanguage('DQL'); + DataExplorerPage.setSearchRelativeDateRange('15', 'Years ago'); + filterFields(); + DataExplorerPage.setQueryEditorLanguage('PPL'); + filterFields(); + DataExplorerPage.setQueryEditorLanguage('OpenSearch SQL'); + filterFields(); + }); + + it('index: PPL and SQL', function () { + DataExplorerPage.selectIndexDataset( + 'PPL', + "I don't want to use the time filter", + 'cypress-test-os', + 'vis-builder' + ); + DataExplorerPage.setQueryEditorLanguage('PPL'); + filterFields(); + DataExplorerPage.setQueryEditorLanguage('OpenSearch SQL'); + filterFields(); + DataExplorerPage.setQueryEditorLanguage('Lucene'); + filterFields(); + }); + }); + + describe('side panel collapse/expand', function () { + function collapseAndExpand() { + DataExplorerPage.getSidebar().should('be.visible'); + DataExplorerPage.collapseSidebar(); + DataExplorerPage.getSidebar().should('not.be.visible'); + DataExplorerPage.expandSidebar(); + DataExplorerPage.getSidebar().should('be.visible'); + } + + function checkCollapseAndExpand(indexPattern = true) { + if (indexPattern) { + DataExplorerPage.setQueryEditorLanguage('DQL'); + collapseAndExpand(); + } + DataExplorerPage.setQueryEditorLanguage('PPL'); + collapseAndExpand(); + DataExplorerPage.setQueryEditorLanguage('OpenSearch SQL'); + collapseAndExpand(); + if (indexPattern) { + DataExplorerPage.setQueryEditorLanguage('DQL'); + DataExplorerPage.getSidebar().should('be.visible'); + } + } + + function checkCollapse(indexPattern = true) { + if (indexPattern) { + DataExplorerPage.setQueryEditorLanguage('DQL'); + DataExplorerPage.collapseSidebar(); + DataExplorerPage.getSidebar().should('not.be.visible'); + } + DataExplorerPage.setQueryEditorLanguage('PPL'); + if (!indexPattern) { + DataExplorerPage.collapseSidebar(); + } + DataExplorerPage.getSidebar().should('not.be.visible'); + DataExplorerPage.setQueryEditorLanguage('OpenSearch SQL'); + DataExplorerPage.getSidebar().should('not.be.visible'); + if (indexPattern) { + DataExplorerPage.setQueryEditorLanguage('DQL'); + DataExplorerPage.getSidebar().should('not.be.visible'); + } + } + + it('index pattern: collapse and expand for DQL, PPL and SQL', function () { + // this test case does three things: + // 1. checks the persistence of the sidebar state accross query languages + // 2. checks that the default state is expanded (first iteration of collapseAndExpand()) + // 3. collapses and expands the sidebar for every language + DataExplorerPage.selectIndexPatternDataset('DQL'); + checkCollapseAndExpand(); + }); + + it('index pattern: check collapsed state for DQL, PPL and SQL', function () { + // this test case checks that the sidebar remains collapsed accross query languages + DataExplorerPage.selectIndexPatternDataset('DQL'); + checkCollapse(); + }); + + it('index: collapse and expand for PPL and SQL', function () { + // this test case does three things: + // 1. checks the persistence of the sidebar state accross query languages + // 2. checks that the default state is expanded (first iteration of collapseAndExpand()) + // 3. collapses and expands the sidebar for every language + DataExplorerPage.selectIndexDataset( + 'PPL', + "I don't want to use the time filter", + 'cypress-test-os', + 'vis-builder' + ); + checkCollapseAndExpand(false); + }); + + it('index: check collapsed state for PPL and SQL', function () { + // this test case checks that the sidebar remains collapsed accross query languages + DataExplorerPage.selectIndexDataset( + 'PPL', + "I don't want to use the time filter", + 'cypress-test-os', + 'vis-builder' + ); + checkCollapse(false); + }); + }); + }); +}); diff --git a/cypress/plugins/index.js b/cypress/plugins/index.js new file mode 100644 index 000000000000..59b2bab6e4e6 --- /dev/null +++ b/cypress/plugins/index.js @@ -0,0 +1,22 @@ +/// +// *********************************************************** +// This example plugins/index.js can be used to load plugins +// +// You can change the location of this file or turn off loading +// the plugins file with the 'pluginsFile' configuration option. +// +// You can read more here: +// https://on.cypress.io/plugins-guide +// *********************************************************** + +// This function is called when a project is opened or re-opened (e.g. due to +// the project's config changing) + +/** + * @type {Cypress.PluginConfig} + */ +// eslint-disable-next-line no-unused-vars +module.exports = (on, config) => { + // `on` is used to hook into various events Cypress emits + // `config` is the resolved Cypress config +} diff --git a/cypress/support/e2e.js b/cypress/support/e2e.js new file mode 100644 index 000000000000..bc7c1b25b8eb --- /dev/null +++ b/cypress/support/e2e.js @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import '../utils/commands'; +import '../utils/dashboards/data_explorer/commands'; diff --git a/cypress/utils/commands.js b/cypress/utils/commands.js index 56a1fd0cff0e..c67cd85e1020 100644 --- a/cypress/utils/commands.js +++ b/cypress/utils/commands.js @@ -3,13 +3,71 @@ * SPDX-License-Identifier: Apache-2.0 */ -// --- Typed commands -- +import { + MiscUtils, + LoginPage, +} from '@opensearch-dashboards-test/opensearch-dashboards-test-library'; +const miscUtils = new MiscUtils(cy); +const loginPage = new LoginPage(cy); + +/** + * Get DOM element by data-test-subj id. + * @param testId data-test-subj value. + * @param options cy.get() options. Default: {} + */ Cypress.Commands.add('getElementByTestId', (testId, options = {}) => { return cy.get(`[data-test-subj="${testId}"]`, options); }); +/** + * Get DOM element by partial data-test-subj id. + * @param testId data-test-subj value. + * @param options cy.get() options. Default: {} + * @comparisonType choose a partial data-test-subj comparison type. Accepted values: 'beginning', 'ending', 'substring'. + */ +Cypress.Commands.add('getElementByTestIdLike', (testId, comparisonType, options = {}) => { + const comparison = { + beginning: '^', + ending: '$', + substring: '*', + }; + const chosenType = comparison[comparisonType] || ''; + return cy.get(`[data-test-subj${chosenType}="${testId}"]`, options); +}); + +/** + * Get multiple DOM elements by data-test-subj ids. + */ Cypress.Commands.add('getElementsByTestIds', (testIds, options = {}) => { const selectors = [testIds].flat(Infinity).map((testId) => `[data-test-subj="${testId}"]`); return cy.get(selectors.join(','), options); }); + +/** + * Find element from previous chained element by data-test-subj id. + */ +Cypress.Commands.add( + 'findElementByTestId', + { prevSubject: true }, + (subject, testId, options = {}) => { + return cy.wrap(subject).find(`[data-test-subj="${testId}"]`, options); + } +); + +/** + * Go to the local instance of OSD's home page and login if needed. + */ +Cypress.Commands.add('localLogin', (username, password) => { + cy.session('test_automation', function () { + miscUtils.visitPage('/app/home'); + cy.url().then(($url) => { + if ($url.includes('login')) { + loginPage.enterUserName(username); + loginPage.enterPassword(password); + loginPage.submit(); + } + cy.url().should('contain', '/app/home'); + }); + }); +}); diff --git a/cypress/utils/dashboards/data_explorer/commands.js b/cypress/utils/dashboards/data_explorer/commands.js new file mode 100644 index 000000000000..b76e5d6f8d1c --- /dev/null +++ b/cypress/utils/dashboards/data_explorer/commands.js @@ -0,0 +1,50 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DATA_EXPLORER_PAGE_ELEMENTS } from './elements.js'; + +/** + * Get the New Search button. + */ +Cypress.Commands.add('getNewSearchButton', () => { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.NEW_SEARCH_BUTTON, { timeout: 10000 }); +}); + +/** + * Get the Query Submit button. + */ +Cypress.Commands.add('getQuerySubmitButton', () => { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.QUERY_SUBMIT_BUTTON); +}); + +/** + * Get the Search Bar Date Picker button. + */ +Cypress.Commands.add('getSearchDatePickerButton', () => { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.SEARCH_DATE_PICKER_BUTTON); +}); + +/** + * Get the Relative Date tab in the Search Bar Date Picker. + */ +Cypress.Commands.add('getDatePickerRelativeTab', () => { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.SEARCH_DATE_PICKER_RELATIVE_TAB); +}); + +/** + * Get the Relative Date Input in the Search Bar Date Picker. + */ +Cypress.Commands.add('getDatePickerRelativeInput', () => { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.SEARCH_DATE_RELATIVE_PICKER_INPUT); +}); + +/** + * Get the Relative Date Unit selector in the Search Bar Date Picker. + */ +Cypress.Commands.add('getDatePickerRelativeUnitSelector', () => { + return cy.getElementByTestId( + DATA_EXPLORER_PAGE_ELEMENTS.SEARCH_DATE_RELATIVE_PICKER_UNIT_SELECTOR + ); +}); diff --git a/cypress/utils/dashboards/data_explorer/constants.js b/cypress/utils/dashboards/data_explorer/constants.js new file mode 100644 index 000000000000..657e3201f680 --- /dev/null +++ b/cypress/utils/dashboards/data_explorer/constants.js @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const INDEX_CLUSTER_NAME = 'cypress-test-os'; +export const INDEX_NAME = 'vis-builder'; +export const INDEX_PATTERN_NAME = 'cypress-test-os::vis-builder*'; diff --git a/cypress/utils/dashboards/data_explorer/data_explorer_page.po.js b/cypress/utils/dashboards/data_explorer/data_explorer_page.po.js new file mode 100644 index 000000000000..1ed080833f3f --- /dev/null +++ b/cypress/utils/dashboards/data_explorer/data_explorer_page.po.js @@ -0,0 +1,440 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { DATA_EXPLORER_PAGE_ELEMENTS } from './elements.js'; +import { INDEX_PATTERN_NAME } from './constants.js'; + +export class DataExplorerPage { + /** + * Get the Dataset selector button + */ + static getDatasetSelectorButton() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DATASET_SELECTOR_BUTTON); + } + + /** + * Get the all Datasets button in the Datasets popup. + */ + static getAllDatasetsButton() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.ALL_DATASETS_BUTTON); + } + + /** + * Get the Time Selector in the Dataset Selector. + */ + static getDatasetTimeSelector() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DATASET_SELECTOR_TIME_SELECTOR); + } + + /** + * Get the Language Selector in the Dataset Selector. + */ + static getDatasetLanguageSelector() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DATASET_SELECTOR_LANGUAGE_SELECTOR); + } + + /** + * Get the Select Dataset button in the Dataset Selector. + */ + static getDatasetSelectDataButton() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DATASET_SELECTOR_SELECT_DATA_BUTTON); + } + + /** + * Get the Dataset Explorer Window. + */ + static getDatasetExplorerWindow() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DATASET_EXPLORER_WINDOW); + } + + /** + * Get the Next button in the Dataset Selector. + */ + static getDatasetExplorerNextButton() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DATASET_SELECTOR_NEXT_BUTTON); + } + + /** + * Get specific DocTable column header. + * @param index Integer starts from 0 for the first column header. + */ + static getDocTableHeader(index) { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DOC_TABLE_HEADER_FIELD).eq(index); + } + + /** + * Get specific row of DocTable. + * @param rowNumber Integer starts from 0 for the first row + */ + static getDocTableRow(rowNumber) { + return cy + .getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DOC_TABLE) + .get('tbody tr') + .eq(rowNumber); + } + + /** + * Get specific field of DocTable. + * @param columnNumber Integer starts from 0 for the first column + * @param rowNumber Integer starts from 0 for the first row + */ + static getDocTableField(columnNumber, rowNumber) { + return DataExplorerPage.getDocTableRow(rowNumber) + .findElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DOC_TABLE_ROW_FIELD) + .eq(columnNumber); + } + + /** + * Get page header. + */ + static getPageHeader() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.HEADER_GLOBAL_NAV); + } + + /** + * Get query multiline editor element. + */ + static getQueryMultilineEditor() { + DataExplorerPage.getPageHeader().click(); + return cy.get('.view-line'); + } + + /** + * Selects the query submit button over the query multiline editor. + */ + static getQuerySubmitBtn() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.QUERY_SUBMIT_BUTTON); + } + + /** + * + * @param expectedValues array of expected values. E.g. ['50', '57', '52'] + * @param columnNumber column index beginning at 0 + */ + static checkDocTableColumnByArr(expectedValues, columnNumber) { + let currentRow = 0; + expectedValues.forEach((value) => { + DataExplorerPage.getDocTableField(columnNumber, currentRow).should('have.text', value); + currentRow++; + }); + } + + /** + * Clears the query multiline editor content. + * Default cy.clear() will not work. + * @param del true/false. true: Deletes character to the right of the cursor; false: Deletes character to the left of the cursor + * @see https://docs.cypress.io/api/commands/type#Arguments + */ + static clearQueryMultilineEditor() { + DataExplorerPage.getQueryMultilineEditor() + .invoke('text') + .then(function ($content) { + const contentLen = $content.length + 1; + DataExplorerPage.getQueryMultilineEditor().type('a'); + DataExplorerPage.getQueryMultilineEditor().type('{backspace}'.repeat(contentLen)); + }); + } + + /** + * Sends a new query via the query multiline editor. + * @param del true/false. true: Deletes character to the right of the cursor; false: Deletes character to the left of the cursor + * @see https://docs.cypress.io/api/commands/type#Arguments + */ + static sendQueryOnMultilineEditor(query) { + DataExplorerPage.clearQueryMultilineEditor(); + DataExplorerPage.getQueryMultilineEditor().type(query); + DataExplorerPage.getQuerySubmitBtn().click(); + } + + /** + * Set the query editor language + * @param language Accepted values: 'DQL', 'Lucene', 'OpenSearch SQL', 'PPL' + */ + static setQueryEditorLanguage(language) { + DataExplorerPage.getPageHeader().click(); // remove helper message + + cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.QUERY_EDITOR_LANGUAGE_SELECTOR).click(); + + cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.QUERY_EDITOR_LANGUAGE_OPTIONS) + .find('button') + .contains(language) + .click(); + } + + /** + * Get filter pill value. + */ + static getGlobalQueryEditorFilterValue() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.GLOBAL_QUERY_EDITOR_FILTER_VALUE, { + timeout: 10000, + }); + } + + /** + * Get query hits. + */ + static getDiscoverQueryHits() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.DISCOVER_QUERY_HITS); + } + + /** + * Get Table Field Filter Out Button. + */ + static getTableFieldFilterOutButton() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.TABLE_FIELD_FILTER_OUT_BUTTON); + } + + /** + * Get Table Field Filter For Button. + */ + static getTableFieldFilterForButton() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.TABLE_FIELD_FILTER_FOR_BUTTON); + } + + /** + * Get Filter Bar. + */ + static getFilterBar() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.GLOBAL_FILTER_BAR); + } + + /** + * Get sidebar filter bar. + */ + static getSidebarFilterBar() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.SIDEBAR_FILTER_BAR); + } + + /** + * Click on the "Clear input" button on the sidebar filter bar. + */ + static clearSidebarFilterBar() { + return cy.get('button[aria-label="Clear input"]').click(); + } + + /** + * Get sidebar add field button by index. + * @param index Integer that starts at 0 for the first add button. + */ + static getFieldBtnByIndex(index) { + return cy.getElementByTestIdLike('fieldToggle-', 'beginning').eq(index); + } + + /** + * Get sidebar add field button by name. + */ + static getFieldBtnByName(name) { + return cy.getElementByTestId('fieldToggle-' + name); + } + + /** + * Get all sidebar add field button. + */ + static getAllSidebarAddFields() { + return cy.get('[data-test-subj^="field-"]:not([data-test-subj$="showDetails"])'); + } + + static getSidebar() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.SIDEBAR_PANEL_OWNREFERENCE); + } + + static getResizeableBar() { + return cy.getElementByTestId(DATA_EXPLORER_PAGE_ELEMENTS.SIDEBAR_PANEL_RESIZEABLE_BAR); + } + + static getResizeableToggleButton() { + return cy.get('.euiResizableToggleButton'); + } + + static collapseSidebar() { + DataExplorerPage.getResizeableBar().trigger('mouseover').click(); + DataExplorerPage.getResizeableToggleButton().click({ force: true }); + } + + static expandSidebar() { + DataExplorerPage.getResizeableToggleButton().click(); + } + + /** + * Check the results of the sidebar filter bar search. + * @param search string to look up + * @param assertion the type of assertion that is going to be performed. Example: 'eq', 'include' + */ + static checkSidebarFilterBarResults(assertion, search) { + DataExplorerPage.getSidebarFilterBar().type(search, { force: true }); + DataExplorerPage.getAllSidebarAddFields().each(function ($field) { + cy.wrap($field) + .should('be.visible') + .invoke('text') + .then(function ($fieldTxt) { + cy.wrap($fieldTxt).should(assertion, search); + }); + }); + DataExplorerPage.clearSidebarFilterBar(); + } + + /** + * Checks that the searched non-existent field does not appear on the DOM. + * @param search non-existent field + */ + static checkSidebarFilterBarNegativeResults(search) { + DataExplorerPage.getSidebarFilterBar().type(search); + DataExplorerPage.getAllSidebarAddFields().should('not.exist'); + DataExplorerPage.clearSidebarFilterBar(); + } + + /** + * Open window to select Dataset + */ + static openDatasetExplorerWindow() { + DataExplorerPage.getDatasetSelectorButton().click(); + DataExplorerPage.getAllDatasetsButton().click(); + } + + /** + * Select a Time Field in the Dataset Selector + * @param timeField Timefield for Language specific Time field. PPL allows "birthdate", "timestamp" and "I don't want to use the time filter" + */ + static selectDatasetTimeField(timeField) { + DataExplorerPage.getDatasetTimeSelector().select(timeField); + } + + /** + * Select a language in the Dataset Selector for Index + * @param datasetLanguage Index supports "OpenSearch SQL" and "PPL" + */ + static selectIndexDatasetLanguage(datasetLanguage, timeField) { + DataExplorerPage.getDatasetLanguageSelector().select(datasetLanguage); + DataExplorerPage.selectDatasetTimeField(timeField); + DataExplorerPage.getDatasetSelectDataButton().click(); + } + + /** + * Select an index dataset. + * @param datasetLanguage Index supports "OpenSearch SQL" and "PPL" + */ + static selectIndexDataset(datasetLanguage, timeField, indexCluster, indexName) { + DataExplorerPage.openDatasetExplorerWindow(); + DataExplorerPage.getDatasetExplorerWindow().contains('Indexes').click(); + DataExplorerPage.getDatasetExplorerWindow().contains(indexCluster, { timeout: 10000 }).click(); + DataExplorerPage.getDatasetExplorerWindow().contains(indexName, { timeout: 10000 }).click(); + DataExplorerPage.getDatasetExplorerNextButton().click(); + DataExplorerPage.selectIndexDatasetLanguage(datasetLanguage, timeField); + } + + /** + * Select a language in the Dataset Selector for Index Pattern + * @param datasetLanguage Index Pattern supports "DQL", "Lucene", "OpenSearch SQL" and "PPL" + */ + static selectIndexPatternDatasetLanguage(datasetLanguage) { + DataExplorerPage.getDatasetLanguageSelector().select(datasetLanguage); + DataExplorerPage.getDatasetSelectDataButton().click(); + } + + /** + * Select an index pattern dataset. + * @param datasetLanguage Index Pattern supports "DQL", "Lucene", "OpenSearch SQL" and "PPL" + */ + static selectIndexPatternDataset(datasetLanguage) { + DataExplorerPage.openDatasetExplorerWindow(); + DataExplorerPage.getDatasetExplorerWindow().contains('Index Patterns').click(); + DataExplorerPage.getDatasetExplorerWindow() + .contains(INDEX_PATTERN_NAME, { timeout: 10000 }) + .click(); + DataExplorerPage.getDatasetExplorerNextButton().click(); + DataExplorerPage.selectIndexPatternDatasetLanguage(datasetLanguage); + } + + /** + * Set search Date range + * @param relativeNumber Relative integer string to set date range + * @param relativeUnit Unit for number. Accepted Units: seconds/Minutes/Hours/Days/Weeks/Months/Years ago/from now + * @example setSearchRelativeDateRange('15', 'years ago') + */ + static setSearchRelativeDateRange(relativeNumber, relativeUnit) { + cy.getSearchDatePickerButton().click(); + cy.getDatePickerRelativeTab().click(); + cy.getDatePickerRelativeInput().clear().type(relativeNumber); + cy.getDatePickerRelativeUnitSelector().select(relativeUnit); + cy.getQuerySubmitButton().click(); + } + + /** + * Check the filter pill text matches expectedFilterText. + * @param expectedFilterText expected text in filter pill. + */ + static checkFilterPillText(expectedFilterText) { + DataExplorerPage.getGlobalQueryEditorFilterValue().should('have.text', expectedFilterText); + } + + /** + * Check the query hit text matches expectedQueryHitText. + * @param expectedQueryHitsText expected text for query hits + */ + static checkQueryHitsText(expectedQueryHitsText) { + DataExplorerPage.getDiscoverQueryHits().should('have.text', expectedQueryHitsText); + } + + /** + * Check for the first Table Field's Filter For and Filter Out button. + * @param isExists Boolean determining if these button should exist + */ + static checkDocTableFirstFieldFilterForAndOutButton(isExists) { + const shouldText = isExists ? 'exist' : 'not.exist'; + DataExplorerPage.getDocTableField(0, 0).within(() => { + DataExplorerPage.getTableFieldFilterForButton().should(shouldText); + DataExplorerPage.getTableFieldFilterOutButton().should(shouldText); + }); + } + + /** + * Check the Doc Table first Field's Filter For button filters the correct value. + */ + static checkDocTableFirstFieldFilterForButtonFiltersCorrectField() { + DataExplorerPage.getDocTableField(0, 0).then(($field) => { + const filterFieldText = $field.find('span span').text(); + $field + .find(`[data-test-subj="${DATA_EXPLORER_PAGE_ELEMENTS.TABLE_FIELD_FILTER_FOR_BUTTON}"]`) + .click(); + DataExplorerPage.checkFilterPillText(filterFieldText); + DataExplorerPage.checkQueryHitsText('1'); // checkQueryHitText must be in front of checking first line text to give time for DocTable to update. + DataExplorerPage.getDocTableField(0, 0) + .find('span span') + .should('have.text', filterFieldText); + }); + DataExplorerPage.getFilterBar().find('[aria-label="Delete"]').click(); + DataExplorerPage.checkQueryHitsText('10,000'); + } + + /** + * Check the Doc Table first Field's Filter Out button filters the correct value. + */ + static checkDocTableFirstFieldFilterOutButtonFiltersCorrectField() { + DataExplorerPage.getDocTableField(0, 0).then(($field) => { + const filterFieldText = $field.find('span span').text(); + $field + .find(`[data-test-subj="${DATA_EXPLORER_PAGE_ELEMENTS.TABLE_FIELD_FILTER_OUT_BUTTON}"]`) + .click(); + DataExplorerPage.checkFilterPillText(filterFieldText); + DataExplorerPage.checkQueryHitsText('9,999'); // checkQueryHitText must be in front of checking first line text to give time for DocTable to update. + DataExplorerPage.getDocTableField(0, 0) + .find('span span') + .should('not.have.text', filterFieldText); + }); + DataExplorerPage.getFilterBar().find('[aria-label="Delete"]').click(); + DataExplorerPage.checkQueryHitsText('10,000'); + } + + /** + * + * @param expectedHeaders array containing the expected header names + * @param offset used to adjust the index of the table headers being checked. Set to 1 by default, which means the method starts checking headers from an index that is 1 higher than the current loop index (i + offset). + */ + static checkTableHeadersByArray(expectedHeaders, offset = 1) { + for (let i = 0; i < expectedHeaders.length; i++) { + DataExplorerPage.getDocTableHeader(i + offset).should('have.text', expectedHeaders[i]); + } + } +} diff --git a/cypress/utils/dashboards/data_explorer/elements.js b/cypress/utils/dashboards/data_explorer/elements.js new file mode 100644 index 000000000000..3346fe2a585f --- /dev/null +++ b/cypress/utils/dashboards/data_explorer/elements.js @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const DATA_EXPLORER_PAGE_ELEMENTS = { + HEADER_GLOBAL_NAV: 'headerGlobalNav', + NEW_SEARCH_BUTTON: 'discoverNewButton', + DISCOVER_QUERY_HITS: 'discoverQueryHits', + DATASET_SELECTOR_BUTTON: 'datasetSelectorButton', + ALL_DATASETS_BUTTON: 'datasetSelectorAdvancedButton', + DATASET_EXPLORER_WINDOW: 'datasetExplorerWindow', + DATASET_SELECTOR_NEXT_BUTTON: 'datasetSelectorNext', + DATASET_SELECTOR_LANGUAGE_SELECTOR: 'advancedSelectorLanguageSelect', + DATASET_SELECTOR_TIME_SELECTOR: 'advancedSelectorTimeFieldSelect', + DATASET_SELECTOR_SELECT_DATA_BUTTON: 'advancedSelectorConfirmButton', + DOC_TABLE: 'docTable', + DOC_TABLE_HEADER_FIELD: 'docTableHeaderField', + DOC_TABLE_ROW_FIELD: 'docTableField', + TABLE_FIELD_FILTER_FOR_BUTTON: 'filterForValue', + TABLE_FIELD_FILTER_OUT_BUTTON: 'filterOutValue', + SEARCH_DATE_PICKER_BUTTON: 'superDatePickerShowDatesButton', + SEARCH_DATE_PICKER_RELATIVE_TAB: 'superDatePickerRelativeTab', + SEARCH_DATE_RELATIVE_PICKER_INPUT: 'superDatePickerRelativeDateInputNumber', + SEARCH_DATE_RELATIVE_PICKER_UNIT_SELECTOR: 'superDatePickerRelativeDateInputUnitSelector', + QUERY_EDITOR_LANGUAGE_SELECTOR: 'queryEditorLanguageSelector', + QUERY_EDITOR_LANGUAGE_OPTIONS: 'queryEditorLanguageOptions', + QUERY_SUBMIT_BUTTON: 'querySubmitButton', + QUERY_EDITOR_MULTILINE: 'osdQueryEditor__multiLine', + GLOBAL_QUERY_EDITOR_FILTER_VALUE: 'globalFilterLabelValue', + GLOBAL_FILTER_BAR: 'globalFilterBar', + SIDEBAR_PANEL_OWNREFERENCE: 'sidebarPanel', + SIDEBAR_PANEL_RESIZEABLE_BAR: 'euiResizableButton', + SIDEBAR_FILTER_BAR: 'fieldFilterSearchInput', +}; diff --git a/docs/_sidebar.md b/docs/_sidebar.md index 2584d106beb0..8fc7e380e6b6 100644 --- a/docs/_sidebar.md +++ b/docs/_sidebar.md @@ -149,6 +149,7 @@ - [Opensearch dashboards.release notes 1.3.17](../release-notes/opensearch-dashboards.release-notes-1.3.17.md) - [Opensearch dashboards.release notes 1.3.19](../release-notes/opensearch-dashboards.release-notes-1.3.19.md) - [Opensearch dashboards.release notes 1.3.2](../release-notes/opensearch-dashboards.release-notes-1.3.2.md) + - [Opensearch dashboards.release notes 1.3.20](../release-notes/opensearch-dashboards.release-notes-1.3.20.md) - [Opensearch dashboards.release notes 1.3.3](../release-notes/opensearch-dashboards.release-notes-1.3.3.md) - [Opensearch dashboards.release notes 1.3.5](../release-notes/opensearch-dashboards.release-notes-1.3.5.md) - [Opensearch dashboards.release notes 1.3.6](../release-notes/opensearch-dashboards.release-notes-1.3.6.md) diff --git a/package.json b/package.json index 5bd2a4a5d09f..7c3bb252ecef 100644 --- a/package.json +++ b/package.json @@ -111,7 +111,6 @@ "**/jest-config": "npm:@amoo-miki/jest-config@27.5.1", "**/jest-jasmine2": "npm:@amoo-miki/jest-jasmine2@27.5.1", "**/joi/hoek": "npm:@amoo-miki/hoek@6.1.3", - "**/json11": "^2.0.0", "**/json-schema": "^0.4.0", "**/kind-of": ">=6.0.3", "**/load-bmfont/phin": "^3.7.1", @@ -125,6 +124,7 @@ "**/trim": "^0.0.3", "**/typescript": "4.6.4", "**/unset-value": "^2.0.1", + "**/url": "^0.11.4", "**/watchpack-chokidar2/chokidar": "^3.5.3", "**/xml2js": "^0.5.0", "**/yaml": "^2.2.2" @@ -166,7 +166,7 @@ "@hapi/vision": "^6.1.0", "@hapi/wreck": "^17.1.0", "@opensearch-dashboards-test/opensearch-dashboards-test-library": "https://github.com/opensearch-project/opensearch-dashboards-test-library/archive/refs/tags/1.0.6.tar.gz", - "@opensearch-project/opensearch": "^2.9.0", + "@opensearch-project/opensearch": "^2.13.0", "@opensearch/datemath": "5.0.3", "@osd/ace": "1.0.0", "@osd/analytics": "1.0.0", @@ -260,6 +260,7 @@ "@babel/plugin-transform-class-static-block": "^7.24.4", "@babel/register": "^7.22.9", "@babel/types": "^7.22.9", + "@cypress/webpack-preprocessor": "^5.17.1", "@elastic/apm-rum": "^5.6.1", "@elastic/charts": "31.1.0", "@elastic/ems-client": "7.10.0", @@ -384,7 +385,7 @@ "chromedriver": "^121.0.1", "classnames": "^2.3.1", "compare-versions": "3.5.1", - "cypress": "9.5.4", + "cypress": "12.17.4", "d3": "3.5.17", "d3-cloud": "1.2.5", "dedent": "^0.7.0", diff --git a/packages/opensearch-datemath/index.d.ts b/packages/opensearch-datemath/index.d.ts index 0706d7d0dccf..fde4b10013a7 100644 --- a/packages/opensearch-datemath/index.d.ts +++ b/packages/opensearch-datemath/index.d.ts @@ -47,6 +47,8 @@ declare const datemath: { /** * Parses a string into a moment object. The string can be something like "now - 15m". + * @param options.roundUp - If true, rounds the parsed date to the end of the + * unit. Only works for string with "/" like "now/d". * @param options.forceNow If this optional parameter is supplied, "now" will be treated as this * date, rather than the real "now". */ diff --git a/packages/osd-monaco/src/xjson/lexer_rules/opensearchsql.ts b/packages/osd-monaco/src/xjson/lexer_rules/opensearchsql.ts index 0ff29b71c09d..6697b3592c15 100644 --- a/packages/osd-monaco/src/xjson/lexer_rules/opensearchsql.ts +++ b/packages/osd-monaco/src/xjson/lexer_rules/opensearchsql.ts @@ -134,18 +134,22 @@ export const lexerRules = { [new RegExp(operators.join('|')), 'operator'], [/[0-9]+(\.[0-9]+)?/, 'number'], [/'([^'\\]|\\.)*$/, 'string.invalid'], // non-terminated string - [/'/, 'string', '@string'], - [/"/, 'string', '@string'], + [/'/, 'string', '@stringSingle'], + [/"/, 'string', '@stringDouble'], ], whitespace: [ [/[ \t\r\n]+/, 'white'], [/\/\*/, 'comment', '@comment'], [/--.*$/, 'comment'], ], - string: [ + stringSingle: [ [/[^'\\]+/, 'string'], [/\\./, 'string.escape'], [/'/, 'string', '@pop'], + ], + stringDouble: [ + [/[^"\\]+/, 'string'], + [/\\./, 'string.escape'], [/"/, 'string', '@pop'], ], comment: [ diff --git a/packages/osd-opensearch-archiver/package.json b/packages/osd-opensearch-archiver/package.json index d1e9174299fa..bc4e8b227b30 100644 --- a/packages/osd-opensearch-archiver/package.json +++ b/packages/osd-opensearch-archiver/package.json @@ -13,7 +13,7 @@ "dependencies": { "@osd/dev-utils": "1.0.0", "@osd/std": "1.0.0", - "@opensearch-project/opensearch": "^2.9.0" + "@opensearch-project/opensearch": "^2.13.0" }, "devDependencies": {} } diff --git a/packages/osd-opensearch/package.json b/packages/osd-opensearch/package.json index 4459c846c6c2..a70263e8af6d 100644 --- a/packages/osd-opensearch/package.json +++ b/packages/osd-opensearch/package.json @@ -12,7 +12,7 @@ "osd:watch": "../../scripts/use_node scripts/build --watch" }, "dependencies": { - "@opensearch-project/opensearch": "^2.9.0", + "@opensearch-project/opensearch": "^2.13.0", "@osd/dev-utils": "1.0.0", "abort-controller": "^3.0.0", "chalk": "^4.1.0", diff --git a/packages/osd-plugin-generator/template/public/components/app.tsx.ejs b/packages/osd-plugin-generator/template/public/components/app.tsx.ejs index 2029a69dd8db..876b3f8c5e75 100644 --- a/packages/osd-plugin-generator/template/public/components/app.tsx.ejs +++ b/packages/osd-plugin-generator/template/public/components/app.tsx.ejs @@ -4,7 +4,7 @@ import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; import { BrowserRouter as Router } from 'react-router-dom'; import { -EuiSmallButton, +EuiButton, EuiHorizontalRule, EuiPage, EuiPageBody, diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 59be50284dca..7865473ee494 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -84,15 +84,6 @@ const run = async () => { }, ]) ); - //ToDo: Remove when opensearch-js is released to include https://github.com/opensearch-project/opensearch-js/pull/889 - promises.push( - patchFile('node_modules/@opensearch-project/opensearch/lib/Serializer.js', [ - { - from: 'val < Number.MAX_SAFE_INTEGER', - to: 'val < Number.MIN_SAFE_INTEGER', - }, - ]) - ); await Promise.all(promises); }; diff --git a/src/core/public/chrome/ui/header/recent_items.test.tsx b/src/core/public/chrome/ui/header/recent_items.test.tsx index d01912e9c27f..28bae880fcfa 100644 --- a/src/core/public/chrome/ui/header/recent_items.test.tsx +++ b/src/core/public/chrome/ui/header/recent_items.test.tsx @@ -18,7 +18,7 @@ jest.mock('./nav_link', () => ({ }), })); -const mockRecentlyAccessed = new BehaviorSubject([ +const mockRecentlyAccessed$ = new BehaviorSubject([ { id: '6ef856c0-5f86-11ef-b7df-1bb1cf26ce5b', label: 'visualizeMock', @@ -28,7 +28,7 @@ const mockRecentlyAccessed = new BehaviorSubject([ }, ]); -const mockWorkspaceList = new BehaviorSubject([ +const mockWorkspaceList$ = new BehaviorSubject([ { id: 'workspace_1', name: 'WorkspaceMock_1', @@ -49,7 +49,14 @@ const defaultMockProps = { navigateToUrl: applicationServiceMock.createStartContract().navigateToUrl, workspaceList$: new BehaviorSubject([]), recentlyAccessed$: new BehaviorSubject([]), - navLinks$: new BehaviorSubject([]), + navLinks$: new BehaviorSubject([ + { + id: '', + title: '', + baseUrl: '', + href: '', + }, + ]), basePath: httpServiceMock.createStartContract().basePath, http: httpServiceMock.createSetupContract(), renderBreadcrumbs: <>, @@ -85,7 +92,8 @@ describe('Recent items', () => { it('should be able to render recent works', async () => { const mockProps = { ...defaultMockProps, - recentlyAccessed$: mockRecentlyAccessed, + recentlyAccessed$: mockRecentlyAccessed$, + workspaceList$: mockWorkspaceList$, }; await act(async () => { @@ -97,11 +105,11 @@ describe('Recent items', () => { expect(screen.getByText('visualizeMock')).toBeInTheDocument(); }); - it('shoulde be able to display workspace name if the asset is attched to a workspace and render it with brackets wrapper ', async () => { + it('should be able to display workspace name if the asset is attched to a workspace and render it with brackets wrapper ', async () => { const mockProps = { ...defaultMockProps, - recentlyAccessed$: mockRecentlyAccessed, - workspaceList$: mockWorkspaceList, + recentlyAccessed$: mockRecentlyAccessed$, + workspaceList$: mockWorkspaceList$, }; await act(async () => { @@ -116,8 +124,8 @@ describe('Recent items', () => { it('should call navigateToUrl with link generated from createRecentNavLink when clicking a recent item', async () => { const mockProps = { ...defaultMockProps, - recentlyAccessed$: mockRecentlyAccessed, - workspaceList$: mockWorkspaceList, + recentlyAccessed$: mockRecentlyAccessed$, + workspaceList$: mockWorkspaceList$, }; const navigateToUrl = jest.fn(); @@ -137,7 +145,7 @@ describe('Recent items', () => { it('should be able to display the preferences popover setting when clicking Preferences button', async () => { const mockProps = { ...defaultMockProps, - recentlyAccessed$: mockRecentlyAccessed, + recentlyAccessed$: mockRecentlyAccessed$, }; await act(async () => { @@ -158,4 +166,9 @@ describe('Recent items', () => { ); expect(baseElement).toMatchSnapshot(); }); + + it('should show not display item if it is in a workspace which is not available', () => { + render(); + expect(screen.queryByText('visualizeMock')).not.toBeInTheDocument(); + }); }); diff --git a/src/core/public/chrome/ui/header/recent_items.tsx b/src/core/public/chrome/ui/header/recent_items.tsx index 7efd276b8fa9..298bf51d2bc6 100644 --- a/src/core/public/chrome/ui/header/recent_items.tsx +++ b/src/core/public/chrome/ui/header/recent_items.tsx @@ -143,7 +143,9 @@ export const RecentItems = ({ setIsPreferencesPopoverOpen((IsPreferencesPopoverOpe) => !IsPreferencesPopoverOpe); }} > - Preferences + {i18n.translate('core.header.recent.preferences', { + defaultMessage: 'Preferences', + })} } isOpen={isPreferencesPopoverOpen} @@ -152,7 +154,11 @@ export const RecentItems = ({ setIsPreferencesPopoverOpen(false); }} > - Preferences + + {i18n.translate('core.header.recent.preferences.title', { + defaultMessage: 'Preferences', + })} + Recents, + children: ( + + {i18n.translate('core.header.recent.preferences.legend', { + defaultMessage: 'Recents', + })} + + ), }} /> @@ -208,15 +220,20 @@ export const RecentItems = ({ useEffect(() => { const savedObjects = recentlyAccessedItems - .filter((item) => item.meta?.type) + .filter( + (item) => + item.meta?.type && + (!item.workspaceId || + // If the workspace id is existing but the workspace is deleted, filter the item + (item.workspaceId && + !!workspaceList.find((workspace) => workspace.id === item.workspaceId))) + ) .map((item) => ({ type: item.meta?.type || '', id: item.id, })); - if (savedObjects.length) { bulkGetDetail(savedObjects, http).then((res) => { - const filteredNavLinks = navLinks.filter((link) => !link.hidden); const formatDetailedSavedObjects = res.map((obj) => { const recentAccessItem = recentlyAccessedItems.find( (item) => item.id === obj.id @@ -225,33 +242,21 @@ export const RecentItems = ({ const findWorkspace = workspaceList.find( (workspace) => workspace.id === recentAccessItem.workspaceId ); + return { ...recentAccessItem, ...obj, ...recentAccessItem.meta, updatedAt: moment(obj?.updated_at).valueOf(), workspaceName: findWorkspace?.name, - link: createRecentNavLink(recentAccessItem, filteredNavLinks, basePath, navigateToUrl) - .href, }; }); - // here I write this argument to avoid Unnecessary re-rendering - if (JSON.stringify(formatDetailedSavedObjects) !== JSON.stringify(detailedSavedObjects)) { - setDetailedSavedObjects(formatDetailedSavedObjects); - } + setDetailedSavedObjects(formatDetailedSavedObjects); }); } - }, [ - navLinks, - basePath, - navigateToUrl, - recentlyAccessedItems, - http, - workspaceList, - detailedSavedObjects, - ]); + }, [recentlyAccessedItems, http, workspaceList]); - const selectedRecentsItems = useMemo(() => { + const selectedRecentItems = useMemo(() => { return detailedSavedObjects.slice(0, Number(recentsRadioIdSelected)); }, [detailedSavedObjects, recentsRadioIdSelected]); @@ -283,11 +288,20 @@ export const RecentItems = ({ - {selectedRecentsItems.length > 0 ? ( + {selectedRecentItems.length > 0 ? ( - {selectedRecentsItems.map((item) => ( + {selectedRecentItems.map((item) => ( handleItemClick(item.link)} + onClick={() => + handleItemClick( + createRecentNavLink( + item, + navLinks.filter((link) => !link.hidden), + basePath, + navigateToUrl + ).href + ) + } key={item.link} style={{ padding: '1px' }} label={ @@ -309,7 +323,9 @@ export const RecentItems = ({ ) : ( - No recently viewed items + {i18n.translate('core.header.recent.no.recents', { + defaultMessage: 'No recently viewed items', + })} )} diff --git a/src/core/server/ui_settings/settings/date_formats.ts b/src/core/server/ui_settings/settings/date_formats.ts index 804d3bb3b58a..b426b76a6dbb 100644 --- a/src/core/server/ui_settings/settings/date_formats.ts +++ b/src/core/server/ui_settings/settings/date_formats.ts @@ -122,7 +122,7 @@ export const getDateFormatSettings = (): Record => { 'core.ui_settings.params.dateFormat.scaled.intervalsLinkText', values: { intervalsLink: - '' + + '' + i18n.translate('core.ui_settings.params.dateFormat.scaled.intervalsLinkText', { defaultMessage: 'ISO8601 intervals', }) + diff --git a/src/plugins/data/common/data_frames/utils.test.ts b/src/plugins/data/common/data_frames/utils.test.ts new file mode 100644 index 000000000000..5ba877c963c2 --- /dev/null +++ b/src/plugins/data/common/data_frames/utils.test.ts @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import datemath from '@opensearch/datemath'; +import { formatTimePickerDate } from '.'; + +describe('formatTimePickerDate', () => { + const mockDateFormat = 'YYYY-MM-DD HH:mm:ss'; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('should handle date range with rounding', () => { + jest.spyOn(datemath, 'parse'); + + const result = formatTimePickerDate({ from: 'now/d', to: 'now/d' }, mockDateFormat); + + expect(result.fromDate).not.toEqual(result.toDate); + + expect(datemath.parse).toHaveBeenCalledTimes(2); + expect(datemath.parse).toHaveBeenCalledWith('now/d', { roundUp: undefined }); + expect(datemath.parse).toHaveBeenCalledWith('now/d', { roundUp: true }); + }); +}); diff --git a/src/plugins/data/common/data_frames/utils.ts b/src/plugins/data/common/data_frames/utils.ts index fdee757bfabb..7e280478630a 100644 --- a/src/plugins/data/common/data_frames/utils.ts +++ b/src/plugins/data/common/data_frames/utils.ts @@ -156,13 +156,13 @@ export const getTimeField = ( * the `dateFormat` parameter */ export const formatTimePickerDate = (dateRange: TimeRange, dateFormat: string) => { - const dateMathParse = (date: string) => { - const parsedDate = datemath.parse(date); + const dateMathParse = (date: string, roundUp?: boolean) => { + const parsedDate = datemath.parse(date, { roundUp }); return parsedDate ? parsedDate.utc().format(dateFormat) : ''; }; const fromDate = dateMathParse(dateRange.from); - const toDate = dateMathParse(dateRange.to); + const toDate = dateMathParse(dateRange.to, true); return { fromDate, toDate }; }; diff --git a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.mock.ts b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.mock.ts index df5521078feb..ba491cb51191 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/dataset_service.mock.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/dataset_service.mock.ts @@ -43,6 +43,9 @@ const createSetupDatasetServiceMock = (): jest.Mocked => fetchOptions: jest.fn(), getRecentDatasets: jest.fn(), addRecentDataset: jest.fn(), + clearCache: jest.fn(), + getLastCacheTime: jest.fn(), + removeFromRecentDatasets: jest.fn(), }; }; diff --git a/src/plugins/data/public/query/query_string/dataset_service/types.ts b/src/plugins/data/public/query/query_string/dataset_service/types.ts index 65c322acec6f..d97afec8abb6 100644 --- a/src/plugins/data/public/query/query_string/dataset_service/types.ts +++ b/src/plugins/data/public/query/query_string/dataset_service/types.ts @@ -43,6 +43,13 @@ export interface DatasetTypeConfig { id: string; /** Human-readable title for the dataset type */ title: string; + languageOverrides?: { + [language: string]: { + /** The override transfers the responsibility of handling the input from + * the language interceptor to the dataset type search strategy. */ + hideDatePicker?: boolean; + }; + }; /** Metadata for UI representation */ meta: { /** Icon to represent the dataset type */ @@ -51,7 +58,7 @@ export interface DatasetTypeConfig { tooltip?: string; /** Optional preference for search on page load else defaulted to true */ searchOnLoad?: boolean; - /** Optional supportsTimeFilter determines if a time filter is needed */ + /** Optional supportsTimeFilter determines if a time field is supported */ supportsTimeFilter?: boolean; /** Optional isFieldLoadAsync determines if field loads are async */ isFieldLoadAsync?: boolean; diff --git a/src/plugins/data/public/ui/dataset_selector/configurator.test.tsx b/src/plugins/data/public/ui/dataset_selector/configurator.test.tsx index 462c6298a0a3..38d4e4e12183 100644 --- a/src/plugins/data/public/ui/dataset_selector/configurator.test.tsx +++ b/src/plugins/data/public/ui/dataset_selector/configurator.test.tsx @@ -3,14 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { render, screen, fireEvent, waitFor } from '@testing-library/react'; -import { Configurator } from './configurator'; import '@testing-library/jest-dom'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; import React from 'react'; -import { setQueryService, setIndexPatterns } from '../../services'; import { IntlProvider } from 'react-intl'; -import { Query } from '../../../../data/public'; import { Dataset } from 'src/plugins/data/common'; +import { Query } from '../../../../data/public'; +import { setIndexPatterns, setQueryService } from '../../services'; +import { Configurator } from './configurator'; const getQueryMock = jest.fn().mockReturnValue({ query: '', @@ -358,4 +358,68 @@ describe('Configurator Component', () => { expect(submitButton).toBeEnabled(); }); }); + + it('should show the date picker if supportsTimeFilter is undefined', async () => { + const mockDataset = { + ...mockBaseDataset, + timeFieldName: undefined, + type: 'index', + }; + const { container } = render( + + + + ); + + expect( + container.querySelector(`[data-test-subj="advancedSelectorTimeFieldSelect"]`) + ).toBeTruthy(); + }); + + it('should hide the date picker if supportsTimeFilter is false', async () => { + const mockDataset = { + ...mockBaseDataset, + timeFieldName: undefined, + type: 'index', + }; + const datasetTypeConfig = mockServices + .getQueryService() + .queryString.getDatasetService() + .getType(); + mockServices + .getQueryService() + .queryString.getDatasetService() + .getType.mockReturnValue({ + ...datasetTypeConfig, + meta: { + supportsTimeFilter: false, + }, + }); + const { container } = render( + + + + ); + + expect( + container.querySelector(`[data-test-subj="advancedSelectorTimeFieldSelect"]`) + ).toBeFalsy(); + + mockServices + .getQueryService() + .queryString.getDatasetService() + .getType.mockReturnValue(datasetTypeConfig); + }); }); diff --git a/src/plugins/data/public/ui/dataset_selector/configurator.tsx b/src/plugins/data/public/ui/dataset_selector/configurator.tsx index 0dba9107934c..4906bec2ef84 100644 --- a/src/plugins/data/public/ui/dataset_selector/configurator.tsx +++ b/src/plugins/data/public/ui/dataset_selector/configurator.tsx @@ -69,6 +69,7 @@ export const Configurator = ({ const [selectedIndexedView, setSelectedIndexedView] = useState(); const [indexedViews, setIndexedViews] = useState([]); const [isLoadingIndexedViews, setIsLoadingIndexedViews] = useState(false); + const [timeFieldsLoading, setTimeFieldsLoading] = useState(false); useEffect(() => { let isMounted = true; @@ -91,23 +92,26 @@ export const Configurator = ({ const submitDisabled = useMemo(() => { return ( - timeFieldName === undefined && - !( - languageService.getLanguage(language)?.hideDatePicker || - dataset.type === DEFAULT_DATA.SET_TYPES.INDEX_PATTERN - ) && - timeFields && - timeFields.length > 0 + timeFieldsLoading || + (timeFieldName === undefined && + !(dataset.type === DEFAULT_DATA.SET_TYPES.INDEX_PATTERN) && + timeFields && + timeFields.length > 0) ); - }, [dataset, language, timeFieldName, timeFields, languageService]); + }, [dataset, timeFieldName, timeFields, timeFieldsLoading]); useEffect(() => { const fetchFields = async () => { - const datasetFields = await queryString - .getDatasetService() - .getType(baseDataset.type) - ?.fetchFields(baseDataset); + const datasetType = queryString.getDatasetService().getType(baseDataset.type); + if (!datasetType) { + setTimeFields([]); + return; + } + setTimeFieldsLoading(true); + const datasetFields = await datasetType + .fetchFields(baseDataset) + .finally(() => setTimeFieldsLoading(false)); const dateFields = datasetFields?.filter((field) => field.type === 'date'); setTimeFields(dateFields || []); }; @@ -152,6 +156,16 @@ export const Configurator = ({ }; }, [indexedViewsService, selectedIndexedView, dataset]); + const shouldRenderDatePickerField = useCallback(() => { + const datasetType = queryString.getDatasetService().getType(dataset.type); + + const supportsTimeField = datasetType?.meta?.supportsTimeFilter; + if (supportsTimeField !== undefined) { + return Boolean(supportsTimeField); + } + return true; + }, [dataset.type, queryString]); + return ( <> @@ -256,7 +270,7 @@ export const Configurator = ({ data-test-subj="advancedSelectorLanguageSelect" /> - {!languageService.getLanguage(language)?.hideDatePicker && + {shouldRenderDatePickerField() && (dataset.type === DEFAULT_DATA.SET_TYPES.INDEX_PATTERN ? (
( - + { : Warning @@ -125,6 +126,7 @@ test('alias with error status', () => { : Error @@ -141,6 +143,7 @@ test('warning', () => { : Warning @@ -157,6 +160,7 @@ test('error', () => { : Error diff --git a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx index 529053ffd042..32f14b3eba34 100644 --- a/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx +++ b/src/plugins/data/public/ui/filter_bar/filter_editor/lib/filter_label.tsx @@ -59,7 +59,11 @@ export default function FilterLabel({ filter, valueLabel, filterLabelStatus }: F ); const getValue = (text?: string) => { - return {text}; + return ( + + {text} + + ); }; if (filter.meta.alias !== null) { diff --git a/src/plugins/data/public/ui/query_editor/editors/default_editor/index.tsx b/src/plugins/data/public/ui/query_editor/editors/default_editor/index.tsx index 1eaf373f2c8e..f642548bafc3 100644 --- a/src/plugins/data/public/ui/query_editor/editors/default_editor/index.tsx +++ b/src/plugins/data/public/ui/query_editor/editors/default_editor/index.tsx @@ -32,7 +32,7 @@ export const DefaultInput: React.FC = ({ provideCompletionItems, }) => { return ( -
+
{ )} size="s" items={languageOptionsMenu} + data-test-subj="queryEditorLanguageOptions" /> ); diff --git a/src/plugins/data/public/ui/query_editor/query_editor_top_row.test.tsx b/src/plugins/data/public/ui/query_editor/query_editor_top_row.test.tsx index 62fe653bfd45..284cbe8d4ff0 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_top_row.test.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_top_row.test.tsx @@ -3,15 +3,16 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Query, UI_SETTINGS } from '../../../common'; -import { coreMock } from '../../../../../core/public/mocks'; -import { dataPluginMock } from '../../mocks'; -import React from 'react'; import { I18nProvider } from '@osd/i18n/react'; -import { createEditor, DQLBody, QueryEditorTopRow, SingleLineInput } from '../'; -import { OpenSearchDashboardsContextProvider } from 'src/plugins/opensearch_dashboards_react/public'; import { cleanup, render, waitFor } from '@testing-library/react'; -import { LanguageConfig } from '../../query'; +import React from 'react'; +import { OpenSearchDashboardsContextProvider } from 'src/plugins/opensearch_dashboards_react/public'; +import { createEditor, DQLBody, QueryEditorTopRow, SingleLineInput } from '../'; +import { coreMock } from '../../../../../core/public/mocks'; +import { Query, UI_SETTINGS } from '../../../common'; +import { dataPluginMock } from '../../mocks'; +import { DatasetTypeConfig, LanguageConfig } from '../../query'; +import { datasetServiceMock } from '../../query/query_string/dataset_service/dataset_service.mock'; import { getQueryService } from '../../services'; const startMock = coreMock.createStart(); @@ -66,6 +67,7 @@ const createMockStorage = () => ({ }); const dataPlugin = dataPluginMock.createStartContract(true); +const datasetService = datasetServiceMock.createStartContract(); function wrapQueryEditorTopRowInContext(testProps: any) { const defaultOptions = { @@ -111,6 +113,7 @@ describe('QueryEditorTopRow', () => { beforeEach(() => { jest.clearAllMocks(); (getQueryService as jest.Mock).mockReturnValue(dataPlugin.query); + dataPlugin.query.queryString.getDatasetService = jest.fn().mockReturnValue(datasetService); }); afterEach(() => { @@ -155,4 +158,49 @@ describe('QueryEditorTopRow', () => { await waitFor(() => expect(container.querySelector(QUERY_EDITOR)).toBeTruthy()); expect(container.querySelector(DATE_PICKER)).toBeFalsy(); }); + + it('Should not render date picker if dataset type does not support time field', async () => { + const query: Query = { + query: 'test query', + dataset: datasetService.getDefault(), + language: 'test-language', + }; + dataPlugin.query.queryString.getQuery = jest.fn().mockReturnValue(query); + datasetService.getType.mockReturnValue({ + meta: { supportsTimeFilter: false }, + } as DatasetTypeConfig); + + const { container } = render( + wrapQueryEditorTopRowInContext({ + query, + showQueryEditor: false, + showDatePicker: true, + }) + ); + await waitFor(() => expect(container.querySelector(QUERY_EDITOR)).toBeTruthy()); + expect(container.querySelector(DATE_PICKER)).toBeFalsy(); + }); + + it('Should render date picker if dataset overrides hideDatePicker to false', async () => { + const query: Query = { + query: 'test query', + dataset: datasetService.getDefault(), + language: 'test-language', + }; + dataPlugin.query.queryString.getQuery = jest.fn().mockReturnValue(query); + datasetService.getType.mockReturnValue(({ + meta: { supportsTimeFilter: true }, + languageOverrides: { 'test-language': { hideDatePicker: false } }, + } as unknown) as DatasetTypeConfig); + + const { container } = render( + wrapQueryEditorTopRowInContext({ + query, + showQueryEditor: false, + showDatePicker: true, + }) + ); + await waitFor(() => expect(container.querySelector(QUERY_EDITOR)).toBeTruthy()); + expect(container.querySelector(DATE_PICKER)).toBeTruthy(); + }); }); diff --git a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx index ea15fbfeeaa1..ad22750207ed 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_top_row.tsx @@ -224,18 +224,53 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { ); } + /** + * Determines if the date picker should be rendered based on UI settings, dataset configuration, and language settings. + * + * @returns {boolean} Whether the date picker should be rendered + * + * UI Settings permutations (isDatePickerEnabled): + * - showDatePicker=true || showAutoRefreshOnly=true => true + * - showDatePicker=false && showAutoRefreshOnly=false => false + * - both undefined => true (default) + * If isDatePickerEnabled is false, returns false immediately + * + * Dataset Type permutations (datasetType?.meta?.supportsTimeFilter): + * - supportsTimeFilter=false => false + * + * Language permutations (when dataset.meta.supportsTimeFilter is undefined or true): + * - queryLanguage=undefined => true (shows date picker) + * - queryLanguage exists: + * - languageOverrides[queryLanguage].hideDatePicker=true => false + * - languageOverrides[queryLanguage].hideDatePicker=false => true + * - hideDatePicker=true => false + * - hideDatePicker=false => true + * - hideDatePicker=undefined => true + */ function shouldRenderDatePicker(): boolean { - return ( - Boolean((props.showDatePicker || props.showAutoRefreshOnly) ?? true) && - !( - queryLanguage && - data.query.queryString.getLanguageService().getLanguage(queryLanguage)?.hideDatePicker - ) && - (props.query?.dataset - ? data.query.queryString.getDatasetService().getType(props.query.dataset.type)?.meta - ?.supportsTimeFilter !== false - : true) + const { queryString } = data.query; + const datasetService = queryString.getDatasetService(); + const languageService = queryString.getLanguageService(); + const isDatePickerEnabled = Boolean( + (props.showDatePicker || props.showAutoRefreshOnly) ?? true ); + if (!isDatePickerEnabled) return false; + + // Get dataset type configuration + const datasetType = props.query?.dataset + ? datasetService.getType(props.query?.dataset.type) + : undefined; + // Check if dataset type explicitly configures the `supportsTimeFilter` option + if (datasetType?.meta?.supportsTimeFilter === false) return false; + + if ( + queryLanguage && + datasetType?.languageOverrides?.[queryLanguage]?.hideDatePicker !== undefined + ) { + return Boolean(!datasetType.languageOverrides[queryLanguage].hideDatePicker); + } + + return Boolean(!(queryLanguage && languageService.getLanguage(queryLanguage)?.hideDatePicker)); } function shouldRenderQueryEditor(): boolean { diff --git a/src/plugins/data_explorer/public/components/sidebar/index.tsx b/src/plugins/data_explorer/public/components/sidebar/index.tsx index 6a4ef0585e33..2aa747abca86 100644 --- a/src/plugins/data_explorer/public/components/sidebar/index.tsx +++ b/src/plugins/data_explorer/public/components/sidebar/index.tsx @@ -125,6 +125,7 @@ export const Sidebar: FC = ({ children, datasetSelectorRef }) => { className="eui-yScroll deSidebar_panel" hasBorder={true} borderRadius="l" + data-test-subj="sidebarPanel" > {isEnhancementEnabled &&
} diff --git a/src/plugins/dev_tools/public/global_search/search_devtool_command.test.tsx b/src/plugins/dev_tools/public/global_search/search_devtool_command.test.tsx index 883584e49e08..9a5ce520e8f1 100644 --- a/src/plugins/dev_tools/public/global_search/search_devtool_command.test.tsx +++ b/src/plugins/dev_tools/public/global_search/search_devtool_command.test.tsx @@ -32,6 +32,17 @@ describe('DevtoolSearchCommand', () => { expect(searchResult).toHaveLength(0); }); + it('searchForDevTools matches category', async () => { + const searchResult = await searchForDevTools('dev', { + devTools: devToolsFn, + title: 'Dev tools', + uiActionsApi: uiActionsApiFn, + }); + + // match all sub apps + expect(searchResult).toHaveLength(2); + }); + it('searchForDevTools with match tool', async () => { const searchResult = await searchForDevTools('console', { devTools: devToolsFn, @@ -56,7 +67,11 @@ describe('DevtoolSearchCommand', () => { /> - Dev tools + + Dev tools + , }, diff --git a/src/plugins/dev_tools/public/global_search/search_devtool_command.tsx b/src/plugins/dev_tools/public/global_search/search_devtool_command.tsx index 7bb8a9cb7238..03efbb751807 100644 --- a/src/plugins/dev_tools/public/global_search/search_devtool_command.tsx +++ b/src/plugins/dev_tools/public/global_search/search_devtool_command.tsx @@ -33,12 +33,18 @@ export const searchForDevTools = async ( - {props.title} + + {props.title} + ); - return tools - .filter((tool) => tool.title.toLowerCase().includes(query.toLowerCase())) + const titleMatched = props.title.toLowerCase().includes(query.toLowerCase()); + const matchedTools = titleMatched + ? tools + : tools.filter((tool) => tool.title.toLowerCase().includes(query.toLowerCase())); + + return matchedTools .map((tool) => ({ breadcrumbs: [ { diff --git a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx index 1e92858157bc..5dcd040d8e76 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx +++ b/src/plugins/discover/public/application/components/default_discover_table/default_discover_table.tsx @@ -186,6 +186,17 @@ const DefaultDiscoverTableUI = ({ // Allow auto column-sizing using the initially rendered rows and then convert to fixed const tableLayoutRequestFrameRef = useRef(0); + /* In asynchronous data loading, column metadata may arrive before the corresponding data, resulting in + layout being calculated for the new column definitions using the old data. To mitigate this issue, we + additionally trigger a recalculation when a change is observed in the index that the data attributes + itself to. This ensures a re-layout is performed when new data is loaded or the column definitions + change, effectively addressing the symptoms of the race condition. + */ + const indexOfRenderedData = rows?.[0]?._index; + const timeFromFirstRow = + typeof indexPattern?.timeFieldName === 'string' && + rows?.[0]?._source?.[indexPattern.timeFieldName]; + useEffect(() => { if (tableElement) { // Load the first batch of rows and adjust the columns to the contents @@ -214,7 +225,7 @@ const DefaultDiscoverTableUI = ({ } return () => cancelAnimationFrame(tableLayoutRequestFrameRef.current); - }, [columns, tableElement]); + }, [columns, tableElement, indexOfRenderedData, timeFromFirstRow]); return ( indexPattern && ( diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.test.tsx b/src/plugins/discover/public/application/view_components/utils/use_search.test.tsx index b76651899b61..f5021b90c1e7 100644 --- a/src/plugins/discover/public/application/view_components/utils/use_search.test.tsx +++ b/src/plugins/discover/public/application/view_components/utils/use_search.test.tsx @@ -18,12 +18,37 @@ jest.mock('./use_index_pattern', () => ({ useIndexPattern: jest.fn(), })); +const mockQuery = { + query: 'test query', + language: 'test language', +}; + +const mockDefaultQuery = { + query: 'default query', + language: 'default language', +}; + const mockSavedSearch = { id: 'test-saved-search', title: 'Test Saved Search', searchSource: { setField: jest.fn(), - getField: jest.fn(), + getField: jest.fn().mockReturnValue(mockQuery), + fetch: jest.fn(), + getSearchRequestBody: jest.fn().mockResolvedValue({}), + getOwnField: jest.fn(), + getDataFrame: jest.fn(() => ({ name: 'test-pattern' })), + }, + getFullPath: jest.fn(), + getOpenSearchType: jest.fn(), +}; + +const mockSavedSearchEmptyQuery = { + id: 'test-saved-search', + title: 'Test Saved Search', + searchSource: { + setField: jest.fn(), + getField: jest.fn().mockReturnValue(undefined), fetch: jest.fn(), getSearchRequestBody: jest.fn().mockResolvedValue({}), getOwnField: jest.fn(), @@ -215,4 +240,36 @@ describe('useSearch', () => { expect.objectContaining({ status: ResultStatus.LOADING, rows: [] }) ); }); + + it('should load saved search', async () => { + const services = createMockServices(); + services.data.query.queryString.setQuery = jest.fn(); + + const { waitForNextUpdate } = renderHook(() => useSearch(services), { + wrapper, + }); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(services.data.query.queryString.setQuery).toBeCalledWith(mockQuery); + }); + + it('if no saved search, use get query', async () => { + const services = createMockServices(); + services.getSavedSearchById = jest.fn().mockResolvedValue(mockSavedSearchEmptyQuery); + services.data.query.queryString.getQuery = jest.fn().mockReturnValue(mockDefaultQuery); + services.data.query.queryString.setQuery = jest.fn(); + + const { waitForNextUpdate } = renderHook(() => useSearch(services), { + wrapper, + }); + + await act(async () => { + await waitForNextUpdate(); + }); + + expect(services.data.query.queryString.setQuery).toBeCalledWith(mockDefaultQuery); + }); }); diff --git a/src/plugins/discover/public/application/view_components/utils/use_search.ts b/src/plugins/discover/public/application/view_components/utils/use_search.ts index 158a9cd46074..7923f0e717c2 100644 --- a/src/plugins/discover/public/application/view_components/utils/use_search.ts +++ b/src/plugins/discover/public/application/view_components/utils/use_search.ts @@ -392,8 +392,7 @@ export const useSearch = (services: DiscoverViewServices) => { const savedSearchInstance = await getSavedSearchById(savedSearchId); const query = - savedSearchInstance.searchSource.getField('query') || - data.query.queryString.getDefaultQuery(); + savedSearchInstance.searchSource.getField('query') || data.query.queryString.getQuery(); const isEnhancementsEnabled = await uiSettings.get('query:enhancements:enabled'); if (isEnhancementsEnabled && query.dataset) { @@ -432,7 +431,7 @@ export const useSearch = (services: DiscoverViewServices) => { } filterManager.setAppFilters(actualFilters); - data.query.queryString.setQuery(savedQuery ? data.query.queryString.getQuery() : query); + data.query.queryString.setQuery(query); setSavedSearch(savedSearchInstance); if (savedSearchInstance?.id) { diff --git a/src/plugins/home/public/application/opensearch_dashboards_services.ts b/src/plugins/home/public/application/opensearch_dashboards_services.ts index 1107e46ecf2e..eb4b085d86ae 100644 --- a/src/plugins/home/public/application/opensearch_dashboards_services.ts +++ b/src/plugins/home/public/application/opensearch_dashboards_services.ts @@ -37,6 +37,7 @@ import { SavedObjectsClientContract, IUiSettingsClient, ApplicationStart, + WorkspacesSetup, } from 'opensearch-dashboards/public'; import { UiStatsMetricType } from '@osd/analytics'; import { TelemetryPluginStart } from '../../../telemetry/public'; @@ -77,6 +78,7 @@ export interface HomeOpenSearchDashboardsServices { }; dataSource?: DataSourcePluginStart; sectionTypes: SectionTypeService; + workspaces?: WorkspacesSetup; } let services: HomeOpenSearchDashboardsServices | null = null; diff --git a/src/plugins/home/public/application/sample_data_client.js b/src/plugins/home/public/application/sample_data_client.js index 045736c428f6..b2adaf44cf81 100644 --- a/src/plugins/home/public/application/sample_data_client.js +++ b/src/plugins/home/public/application/sample_data_client.js @@ -41,11 +41,26 @@ export async function listSampleDataSets(dataSourceId) { return await getServices().http.get(sampleDataUrl, { query }); } +const canUpdateUISetting = () => { + const { + application: { capabilities }, + workspaces, + } = getServices(); + if ( + capabilities.workspaces && + capabilities.workspaces.enabled && + capabilities.workspaces.permissionEnabled + ) { + return !!workspaces?.currentWorkspace$.getValue()?.owner; + } + return true; +}; + export async function installSampleDataSet(id, sampleDataDefaultIndex, dataSourceId) { const query = buildQuery(dataSourceId); await getServices().http.post(`${sampleDataUrl}/${id}`, { query }); - if (getServices().uiSettings.isDefault('defaultIndex')) { + if (canUpdateUISetting() && getServices().uiSettings.isDefault('defaultIndex')) { getServices().uiSettings.set('defaultIndex', sampleDataDefaultIndex); } @@ -59,6 +74,7 @@ export async function uninstallSampleDataSet(id, sampleDataDefaultIndex, dataSou const uiSettings = getServices().uiSettings; if ( + canUpdateUISetting() && !uiSettings.isDefault('defaultIndex') && uiSettings.get('defaultIndex') === sampleDataDefaultIndex ) { diff --git a/src/plugins/home/public/application/sample_data_client.test.js b/src/plugins/home/public/application/sample_data_client.test.js new file mode 100644 index 000000000000..35f86efef729 --- /dev/null +++ b/src/plugins/home/public/application/sample_data_client.test.js @@ -0,0 +1,167 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import { setServices } from '../application/opensearch_dashboards_services'; +import { installSampleDataSet, uninstallSampleDataSet } from './sample_data_client'; + +const mockHttp = { + post: jest.fn(), + delete: jest.fn(), +}; + +const mockUiSettings = { + isDefault: jest.fn(), + set: jest.fn(), + get: jest.fn(), +}; + +const mockApplication = { + capabilities: { + workspaces: { + enabled: false, + permissionEnabled: false, + }, + }, +}; + +const mockIndexPatternService = { + clearCache: jest.fn(), +}; + +const mockWorkspace = { + currentWorkspace$: new BehaviorSubject(), +}; + +const mockServices = { + workspaces: mockWorkspace, + http: mockHttp, + uiSettings: mockUiSettings, + application: mockApplication, + indexPatternService: mockIndexPatternService, +}; + +setServices(mockServices); + +describe('installSampleDataSet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUiSettings.isDefault.mockReturnValue(true); + setServices(mockServices); + }); + + it('should install the sample data set and set the default index', async () => { + const id = 'sample-data-id'; + const sampleDataDefaultIndex = 'sample-data-index'; + const dataSourceId = 'data-source-id'; + + await installSampleDataSet(id, sampleDataDefaultIndex, dataSourceId); + + expect(mockHttp.post).toHaveBeenCalledWith(`/api/sample_data/${id}`, { + query: expect.anything(), + }); + expect(mockUiSettings.set).toHaveBeenCalledWith('defaultIndex', sampleDataDefaultIndex); + expect(mockIndexPatternService.clearCache).toHaveBeenCalled(); + }); + + it('should install the sample data set and not set the default index when workspace is enabled', async () => { + const id = 'sample-data-id'; + const sampleDataDefaultIndex = 'sample-data-index'; + const dataSourceId = 'data-source-id'; + + setServices({ + ...mockServices, + workspaces: { + currentWorkspace$: new BehaviorSubject(), + }, + application: { + capabilities: { + workspaces: { + enabled: true, + permissionEnabled: true, + }, + }, + }, + }); + + await installSampleDataSet(id, sampleDataDefaultIndex, dataSourceId); + + expect(mockHttp.post).toHaveBeenCalledWith(`/api/sample_data/${id}`, { + query: expect.anything(), + }); + expect(mockUiSettings.set).not.toHaveBeenCalled(); + expect(mockIndexPatternService.clearCache).toHaveBeenCalled(); + }); +}); + +describe('uninstallSampleDataSet', () => { + beforeEach(() => { + jest.clearAllMocks(); + mockUiSettings.isDefault.mockReturnValue(false); + setServices(mockServices); + }); + + it('should uninstall the sample data set and clear the default index', async () => { + const id = 'sample-data-id'; + const sampleDataDefaultIndex = 'sample-data-index'; + const dataSourceId = 'data-source-id'; + + mockUiSettings.get.mockReturnValue(sampleDataDefaultIndex); + + await uninstallSampleDataSet(id, sampleDataDefaultIndex, dataSourceId); + + expect(mockHttp.delete).toHaveBeenCalledWith(`/api/sample_data/${id}`, { + query: expect.anything(), + }); + expect(mockUiSettings.set).toHaveBeenCalledWith('defaultIndex', null); + expect(mockIndexPatternService.clearCache).toHaveBeenCalled(); + }); + + it('should uninstall the sample data set and not clear the default index when workspace is enabled', async () => { + const id = 'sample-data-id'; + const sampleDataDefaultIndex = 'sample-data-index'; + const dataSourceId = 'data-source-id'; + + setServices({ + ...mockServices, + workspaces: { + currentWorkspace$: new BehaviorSubject(), + }, + application: { + capabilities: { + workspaces: { + enabled: true, + permissionEnabled: true, + }, + }, + }, + }); + + await uninstallSampleDataSet(id, sampleDataDefaultIndex, dataSourceId); + + expect(mockHttp.delete).toHaveBeenCalledWith(`/api/sample_data/${id}`, { + query: expect.anything(), + }); + expect(mockUiSettings.set).not.toHaveBeenCalled(); + expect(mockIndexPatternService.clearCache).toHaveBeenCalled(); + }); + + it('should uninstall the sample data set and not clear the default index when it is not the sample data index', async () => { + const id = 'sample-data-id'; + const sampleDataDefaultIndex = 'sample-data-index'; + const dataSourceId = 'data-source-id'; + + mockUiSettings.isDefault.mockReturnValue(false); + mockUiSettings.get.mockReturnValue('other-index'); + + await uninstallSampleDataSet(id, sampleDataDefaultIndex, dataSourceId); + + expect(mockHttp.delete).toHaveBeenCalledWith(`/api/sample_data/${id}`, { + query: expect.anything(), + }); + expect(mockUiSettings.set).not.toHaveBeenCalled(); + expect(mockIndexPatternService.clearCache).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index 435c7d4d3b9f..6d9771c724ef 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -156,6 +156,7 @@ export class HomePublicPlugin injectedMetadata: coreStart.injectedMetadata, dataSource, sectionTypes: this.sectionTypeService, + workspaces: core.workspaces, ...homeOpenSearchDashboardsServices, }); }; diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts index 3e4636c32486..da8dea3c2fe3 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts @@ -62,27 +62,10 @@ export function createUninstallRoute( return response.notFound(); } - const caller = dataSourceId - ? context.dataSource.opensearch.legacy.getClient(dataSourceId).callAPI - : context.core.opensearch.legacy.client.callAsCurrentUser; - - for (let i = 0; i < sampleDataset.dataIndices.length; i++) { - const dataIndexConfig = sampleDataset.dataIndices[i]; - const index = - dataIndexConfig.indexName ?? createIndexName(sampleDataset.id, dataIndexConfig.id); - - try { - await caller('indices.delete', { index }); - } catch (err) { - return response.customError({ - statusCode: err.status, - body: { - message: `Unable to delete sample data index "${index}", error: ${err.message}`, - }, - }); - } - } - + /** + * Delete saved objects before removing the data index to avoid partial deletion + * of sample data when a read-only workspace user attempts to remove sample data. + */ const savedObjectsList = getFinalSavedObjects({ dataset: sampleDataset, workspaceId, @@ -99,7 +82,7 @@ export function createUninstallRoute( // ignore 404s since users could have deleted some of the saved objects via the UI if (_.get(err, 'output.statusCode') !== 404) { return response.customError({ - statusCode: err.status, + statusCode: err.status || _.get(err, 'output.statusCode'), body: { message: `Unable to delete sample dataset saved objects, error: ${err.message}`, }, @@ -107,6 +90,27 @@ export function createUninstallRoute( } } + const caller = dataSourceId + ? context.dataSource.opensearch.legacy.getClient(dataSourceId).callAPI + : context.core.opensearch.legacy.client.callAsCurrentUser; + + for (let i = 0; i < sampleDataset.dataIndices.length; i++) { + const dataIndexConfig = sampleDataset.dataIndices[i]; + const index = + dataIndexConfig.indexName ?? createIndexName(sampleDataset.id, dataIndexConfig.id); + + try { + await caller('indices.delete', { index }); + } catch (err) { + return response.customError({ + statusCode: err.status, + body: { + message: `Unable to delete sample data index "${index}", error: ${err.message}`, + }, + }); + } + } + // track the usage operation in a non-blocking way usageTracker.addUninstall(request.params.id); diff --git a/src/plugins/maps_legacy/server/ui_settings.ts b/src/plugins/maps_legacy/server/ui_settings.ts index 3209723da939..9b708749dc03 100644 --- a/src/plugins/maps_legacy/server/ui_settings.ts +++ b/src/plugins/maps_legacy/server/ui_settings.ts @@ -95,7 +95,7 @@ export function getUiSettings(): Record> { 'maps_legacy.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText', values: { propertiesLink: - '' + + '' + i18n.translate( 'maps_legacy.advancedSettings.visualization.tileMap.wmsDefaults.propertiesLinkText', { diff --git a/src/plugins/query_enhancements/common/types.ts b/src/plugins/query_enhancements/common/types.ts index 1bb977527d4a..2f73ca52d496 100644 --- a/src/plugins/query_enhancements/common/types.ts +++ b/src/plugins/query_enhancements/common/types.ts @@ -4,7 +4,7 @@ */ import { CoreSetup } from 'opensearch-dashboards/public'; -import { PollQueryResultsParams } from '../../data/common'; +import { PollQueryResultsParams, TimeRange } from '../../data/common'; export interface QueryAggConfig { [key: string]: { @@ -26,7 +26,10 @@ export interface EnhancedFetchContext { http: CoreSetup['http']; path: string; signal?: AbortSignal; - body?: { pollQueryResultsParams: PollQueryResultsParams }; + body?: { + pollQueryResultsParams?: PollQueryResultsParams; + timeRange?: TimeRange; + }; } export interface QueryStatusOptions { diff --git a/src/plugins/query_enhancements/common/utils.test.ts b/src/plugins/query_enhancements/common/utils.test.ts index 39bbdc258bea..787cebb0c082 100644 --- a/src/plugins/query_enhancements/common/utils.test.ts +++ b/src/plugins/query_enhancements/common/utils.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { handleFacetError } from './utils'; +import { throwFacetError } from './utils'; describe('handleFacetError', () => { const error = new Error('mock-error'); @@ -16,9 +16,9 @@ describe('handleFacetError', () => { data: error, }; - expect(() => handleFacetError(response)).toThrowError(); + expect(() => throwFacetError(response)).toThrowError(); try { - handleFacetError(response); + throwFacetError(response); } catch (err: any) { expect(err.message).toBe('test error message'); expect(err.name).toBe('400'); diff --git a/src/plugins/query_enhancements/common/utils.ts b/src/plugins/query_enhancements/common/utils.ts index 9b2bb9e3aacf..634a56b84603 100644 --- a/src/plugins/query_enhancements/common/utils.ts +++ b/src/plugins/query_enhancements/common/utils.ts @@ -42,7 +42,7 @@ export const removeKeyword = (queryString: string | undefined) => { return queryString?.replace(new RegExp('.keyword'), '') ?? ''; }; -export const handleFacetError = (response: any) => { +export const throwFacetError = (response: any) => { const error = new Error(response.data.body?.message ?? response.data.body ?? response.data); error.name = response.data.status ?? response.status ?? response.data.statusCode; (error as any).status = error.name; @@ -55,6 +55,7 @@ export const fetch = (context: EnhancedFetchContext, query: Query, aggConfig?: Q query: { ...query, format: 'jdbc' }, aggConfig, pollQueryResultsParams: context.body?.pollQueryResultsParams, + timeRange: context.body?.timeRange, }); return from( http.fetch({ diff --git a/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts b/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts index 57152dbe98ea..ecfe32ff8a75 100644 --- a/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts +++ b/src/plugins/query_enhancements/public/search/ppl_search_interceptor.ts @@ -50,6 +50,7 @@ export class PPLSearchInterceptor extends SearchInterceptor { signal, body: { pollQueryResultsParams: request.params?.pollQueryResultsParams, + timeRange: request.params?.body?.timeRange, }, }; @@ -68,15 +69,33 @@ export class PPLSearchInterceptor extends SearchInterceptor { .getDatasetService() .getType(datasetType); strategy = datasetTypeConfig?.getSearchOptions?.().strategy ?? strategy; + + if ( + dataset?.timeFieldName && + datasetTypeConfig?.languageOverrides?.PPL?.hideDatePicker === false + ) { + request.params = { + ...request.params, + body: { + ...request.params.body, + timeRange: this.queryService.timefilter.timefilter.getTime(), + }, + }; + } } return this.runSearch(request, options.abortSignal, strategy); } private buildQuery() { - const query: Query = this.queryService.queryString.getQuery(); + const { queryString } = this.queryService; + const query: Query = queryString.getQuery(); const dataset = query.dataset; if (!dataset || !dataset.timeFieldName) return query; + const datasetService = queryString.getDatasetService(); + if (datasetService.getType(dataset.type)?.languageOverrides?.PPL?.hideDatePicker === false) + return query; + const [baseQuery, ...afterPipeParts] = query.query.split('|'); const afterPipe = afterPipeParts.length > 0 ? ` | ${afterPipeParts.join('|').trim()}` : ''; const timeFilter = this.getTimeFilter(dataset.timeFieldName); diff --git a/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts b/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts index 9fe17fc79322..9f93dd067cb3 100644 --- a/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts +++ b/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts @@ -42,6 +42,7 @@ export class SQLSearchInterceptor extends SearchInterceptor { signal, body: { pollQueryResultsParams: request.params?.pollQueryResultsParams, + timeRange: request.params?.body?.timeRange, }, }; @@ -62,6 +63,16 @@ export class SQLSearchInterceptor extends SearchInterceptor { .getDatasetService() .getType(datasetType); strategy = datasetTypeConfig?.getSearchOptions?.().strategy ?? strategy; + + if (datasetTypeConfig?.languageOverrides?.SQL?.hideDatePicker === false) { + request.params = { + ...request.params, + body: { + ...request.params.body, + timeRange: this.queryService.timefilter.timefilter.getTime(), + }, + }; + } } return this.runSearch(request, options.abortSignal, strategy); diff --git a/src/plugins/query_enhancements/server/routes/index.test.ts b/src/plugins/query_enhancements/server/routes/index.test.ts new file mode 100644 index 000000000000..9c7c7a56de2e --- /dev/null +++ b/src/plugins/query_enhancements/server/routes/index.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coerceStatusCode } from '.'; + +describe('coerceStatusCode', () => { + it('should return 503 when input is 500', () => { + expect(coerceStatusCode(500)).toBe(503); + }); + + it('should return the input status code when it is not 500', () => { + expect(coerceStatusCode(404)).toBe(404); + }); + + it('should return 503 when input is undefined or null', () => { + expect(coerceStatusCode((undefined as unknown) as number)).toBe(503); + expect(coerceStatusCode((null as unknown) as number)).toBe(503); + }); +}); diff --git a/src/plugins/query_enhancements/server/routes/index.ts b/src/plugins/query_enhancements/server/routes/index.ts index 79b93a279272..2cda4a9f0cbf 100644 --- a/src/plugins/query_enhancements/server/routes/index.ts +++ b/src/plugins/query_enhancements/server/routes/index.ts @@ -16,6 +16,15 @@ import { API } from '../../common'; import { registerQueryAssistRoutes } from './query_assist'; import { registerDataSourceConnectionsRoutes } from './data_source_connection'; +/** + * Coerce status code to 503 for 500 errors from dependency services. Only use + * this function to handle errors throw by other services, and not from OSD. + */ +export const coerceStatusCode = (statusCode: number) => { + if (statusCode === 500) return 503; + return statusCode || 503; +}; + /** * @experimental * @@ -77,6 +86,7 @@ export function defineSearchStrategyRouteProvider(logger: Logger, router: IRoute sessionId: schema.maybe(schema.string()), }) ), + timeRange: schema.maybe(schema.object({}, { unknowns: 'allow' })), }), }, }, @@ -92,7 +102,7 @@ export function defineSearchStrategyRouteProvider(logger: Logger, router: IRoute error = err; } return res.custom({ - statusCode: error.status || err.status, + statusCode: coerceStatusCode(error.status || err.status), body: err.message, }); } diff --git a/src/plugins/query_enhancements/server/search/ppl_async_search_strategy.ts b/src/plugins/query_enhancements/server/search/ppl_async_search_strategy.ts index 309c5fd522b6..2af66fb427c2 100644 --- a/src/plugins/query_enhancements/server/search/ppl_async_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/ppl_async_search_strategy.ts @@ -13,7 +13,7 @@ import { Query, } from '../../../data/common'; import { ISearchStrategy, SearchUsage } from '../../../data/server'; -import { buildQueryStatusConfig, getFields, handleFacetError, SEARCH_STRATEGY } from '../../common'; +import { buildQueryStatusConfig, getFields, throwFacetError, SEARCH_STRATEGY } from '../../common'; import { Facet } from '../utils'; export const pplAsyncSearchStrategyProvider = ( @@ -45,7 +45,7 @@ export const pplAsyncSearchStrategyProvider = ( request.body = { ...request.body, lang: SEARCH_STRATEGY.PPL }; const rawResponse: any = await pplAsyncFacet.describeQuery(context, request); - if (!rawResponse.success) handleFacetError(rawResponse); + if (!rawResponse.success) throwFacetError(rawResponse); const statusConfig = buildQueryStatusConfig(rawResponse); @@ -60,7 +60,7 @@ export const pplAsyncSearchStrategyProvider = ( request.params = { queryId: inProgressQueryId }; const queryStatusResponse = await pplAsyncJobsFacet.describeQuery(context, request); - if (!queryStatusResponse.success) handleFacetError(queryStatusResponse); + if (!queryStatusResponse.success) throwFacetError(queryStatusResponse); const queryStatus = queryStatusResponse.data?.status; logger.info(`pplAsyncSearchStrategy: JOB: ${inProgressQueryId} - STATUS: ${queryStatus}`); diff --git a/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts b/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts new file mode 100644 index 000000000000..ae8105180db8 --- /dev/null +++ b/src/plugins/query_enhancements/server/search/ppl_search_strategy.test.ts @@ -0,0 +1,372 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ILegacyClusterClient, + Logger, + RequestHandlerContext, + SharedGlobalConfig, +} from 'opensearch-dashboards/server'; +import { Observable, of } from 'rxjs'; +import { DATA_FRAME_TYPES, IOpenSearchDashboardsSearchRequest } from '../../../data/common'; +import { SearchUsage } from '../../../data/server'; +import * as utils from '../../common/utils'; +import * as facet from '../utils/facet'; +import { pplSearchStrategyProvider } from './ppl_search_strategy'; + +jest.mock('../../common/utils', () => ({ + ...jest.requireActual('../../common/utils'), + getFields: jest.fn(), +})); + +describe('pplSearchStrategyProvider', () => { + let config$: Observable; + let logger: Logger; + let client: ILegacyClusterClient; + let usage: SearchUsage; + const emptyRequestHandlerContext = ({} as unknown) as RequestHandlerContext; + + beforeEach(() => { + config$ = of({} as SharedGlobalConfig); + logger = ({ + error: jest.fn(), + } as unknown) as Logger; + client = {} as ILegacyClusterClient; + usage = { + trackSuccess: jest.fn(), + trackError: jest.fn(), + } as SearchUsage; + }); + + it('should return an object with a search method', () => { + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + expect(strategy).toHaveProperty('search'); + expect(typeof strategy.search).toBe('function'); + }); + + it('should handle successful search response', async () => { + const mockResponse = { + success: true, + data: { + schema: [ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ], + datarows: [ + [1, 'value1'], + [2, 'value2'], + ], + }, + took: 100, + }; + const mockFacet = ({ + describeQuery: jest.fn().mockResolvedValue(mockResponse), + } as unknown) as facet.Facet; + jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); + (utils.getFields as jest.Mock).mockReturnValue([ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ]); + + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + const result = await strategy.search( + emptyRequestHandlerContext, + ({ + body: { query: { query: 'source = table', dataset: { id: 'test-dataset' } } }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ); + + expect(result).toEqual({ + type: DATA_FRAME_TYPES.DEFAULT, + body: { + name: 'test-dataset', + fields: [ + { name: 'field1', type: 'long', values: [] }, + { name: 'field2', type: 'text', values: [] }, + ], + schema: [ + { name: 'field1', type: 'long', values: [] }, + { name: 'field2', type: 'text', values: [] }, + ], + size: 2, + }, + took: 100, + }); + expect(usage.trackSuccess).toHaveBeenCalledWith(100); + }); + + it('should handle failed search response', async () => { + const mockResponse = { + success: false, + data: { cause: 'Query failed' }, + took: 50, + }; + const mockFacet = ({ + describeQuery: jest.fn().mockResolvedValue(mockResponse), + } as unknown) as facet.Facet; + jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); + + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + await expect( + strategy.search( + emptyRequestHandlerContext, + ({ + body: { query: { query: 'source = table' } }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ) + ).rejects.toThrow(); + }); + + it('should handle exceptions', async () => { + const mockError = new Error('Something went wrong'); + const mockFacet = ({ + describeQuery: jest.fn().mockRejectedValue(mockError), + } as unknown) as facet.Facet; + jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); + + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + await expect( + strategy.search( + emptyRequestHandlerContext, + ({ + body: { query: { query: 'source = table' } }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ) + ).rejects.toThrow(mockError); + expect(logger.error).toHaveBeenCalledWith(`pplSearchStrategy: ${mockError.message}`); + expect(usage.trackError).toHaveBeenCalled(); + }); + + it('should throw error when describeQuery success is false', async () => { + const mockError = new Error('Something went wrong'); + const mockFacet = ({ + describeQuery: jest.fn().mockResolvedValue({ success: false, data: mockError }), + } as unknown) as facet.Facet; + jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); + + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + await expect( + strategy.search( + emptyRequestHandlerContext, + ({ + body: { query: { query: 'source = table' } }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ) + ).rejects.toThrowError(); + expect(logger.error).toHaveBeenCalledWith(expect.stringContaining(mockError.message)); + expect(usage.trackError).toHaveBeenCalled(); + }); + + it('should handle empty search response', async () => { + const mockResponse = { + success: true, + data: { + schema: [ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ], + datarows: [], + }, + took: 10, + }; + const mockFacet = ({ + describeQuery: jest.fn().mockResolvedValue(mockResponse), + } as unknown) as facet.Facet; + jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); + (utils.getFields as jest.Mock).mockReturnValue([ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ]); + + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + const result = await strategy.search( + emptyRequestHandlerContext, + ({ + body: { query: { query: 'source = empty_table', dataset: { id: 'empty-dataset' } } }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ); + + expect(result).toEqual({ + type: DATA_FRAME_TYPES.DEFAULT, + body: { + name: 'empty-dataset', + fields: [ + { name: 'field1', type: 'long', values: [] }, + { name: 'field2', type: 'text', values: [] }, + ], + schema: [ + { name: 'field1', type: 'long', values: [] }, + { name: 'field2', type: 'text', values: [] }, + ], + size: 0, + }, + took: 10, + }); + expect(usage.trackSuccess).toHaveBeenCalledWith(10); + }); + + it('should handle aggConfig when response succeeds', async () => { + const mockResponse = { + success: true, + data: { + schema: [ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ], + datarows: [ + [1, 'value1'], + [2, 'value2'], + ], + }, + took: 10, + }; + const mockFacet = ({ + describeQuery: jest.fn().mockResolvedValue(mockResponse), + } as unknown) as facet.Facet; + jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); + (utils.getFields as jest.Mock).mockReturnValue([ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ]); + + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + const result = await strategy.search( + emptyRequestHandlerContext, + ({ + body: { + query: { query: 'source = empty_table', dataset: { id: 'empty-dataset' } }, + aggConfig: { + date_histogram: { + field: 'timestamp', + fixed_interval: '12h', + time_zone: 'America/Los_Angeles', + min_doc_count: 1, + }, + qs: { + '2': 'source = empty_table | stats count() by span(timestamp, 12h)', + }, + }, + }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ); + + expect(result).toEqual({ + type: DATA_FRAME_TYPES.DEFAULT, + body: { + name: 'empty-dataset', + fields: [ + { name: 'field1', type: 'long', values: [] }, + { name: 'field2', type: 'text', values: [] }, + ], + schema: [ + { name: 'field1', type: 'long', values: [] }, + { name: 'field2', type: 'text', values: [] }, + ], + aggs: { + '2': [ + { key: 'value1', value: 1 }, + { key: 'value2', value: 2 }, + ], + }, + meta: { + date_histogram: { + field: 'timestamp', + fixed_interval: '12h', + time_zone: 'America/Los_Angeles', + min_doc_count: 1, + }, + qs: { '2': 'source = empty_table | stats count() by span(timestamp, 12h)' }, + }, + size: 2, + }, + took: 10, + }); + expect(usage.trackSuccess).toHaveBeenCalledWith(10); + }); + + it('should handle aggConfig when aggregation fails', async () => { + const mockResponse = { + success: true, + data: { + schema: [ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ], + datarows: [ + [1, 'value1'], + [2, 'value2'], + ], + }, + took: 10, + }; + const mockError = new Error('Something went wrong'); + const mockFacet = ({ + describeQuery: jest + .fn() + .mockResolvedValueOnce(mockResponse) + .mockResolvedValue({ success: false, data: mockError }), + } as unknown) as facet.Facet; + jest.spyOn(facet, 'Facet').mockImplementation(() => mockFacet); + (utils.getFields as jest.Mock).mockReturnValue([ + { name: 'field1', type: 'long' }, + { name: 'field2', type: 'text' }, + ]); + + const strategy = pplSearchStrategyProvider(config$, logger, client, usage); + const result = await strategy.search( + emptyRequestHandlerContext, + ({ + body: { + query: { query: 'source = empty_table', dataset: { id: 'empty-dataset' } }, + aggConfig: { + date_histogram: { + field: 'timestamp', + fixed_interval: '12h', + time_zone: 'America/Los_Angeles', + min_doc_count: 1, + }, + qs: { + '2': 'source = empty_table | stats count() by span(timestamp, 12h)', + }, + }, + }, + } as unknown) as IOpenSearchDashboardsSearchRequest, + {} + ); + + expect(result).toEqual({ + type: DATA_FRAME_TYPES.DEFAULT, + body: { + name: 'empty-dataset', + fields: [ + { name: 'field1', type: 'long', values: [] }, + { name: 'field2', type: 'text', values: [] }, + ], + schema: [ + { name: 'field1', type: 'long', values: [] }, + { name: 'field2', type: 'text', values: [] }, + ], + meta: { + date_histogram: { + field: 'timestamp', + fixed_interval: '12h', + time_zone: 'America/Los_Angeles', + min_doc_count: 1, + }, + qs: { '2': 'source = empty_table | stats count() by span(timestamp, 12h)' }, + }, + size: 2, + }, + took: 10, + }); + expect(usage.trackSuccess).toHaveBeenCalledWith(10); + }); +}); diff --git a/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts b/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts index d71ae6810fad..d47d2ca41c4a 100644 --- a/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts @@ -14,7 +14,7 @@ import { Query, createDataFrame, } from '../../../data/common'; -import { getFields, handleFacetError } from '../../common/utils'; +import { getFields, throwFacetError } from '../../common/utils'; import { Facet } from '../utils'; import { QueryAggConfig } from '../../common'; @@ -39,7 +39,7 @@ export const pplSearchStrategyProvider = ( const aggConfig: QueryAggConfig | undefined = request.body.aggConfig; const rawResponse: any = await pplFacet.describeQuery(context, request); - if (!rawResponse.success) handleFacetError(rawResponse); + if (!rawResponse.success) throwFacetError(rawResponse); const dataFrame = createDataFrame({ name: query.dataset?.id, @@ -56,7 +56,7 @@ export const pplSearchStrategyProvider = ( for (const [key, aggQueryString] of Object.entries(aggConfig.qs)) { request.body.query.query = aggQueryString; const rawAggs: any = await pplFacet.describeQuery(context, request); - if (!rawAggs.success) handleFacetError(rawResponse); + if (!rawAggs.success) continue; (dataFrame as IDataFrameWithAggs).aggs = {}; (dataFrame as IDataFrameWithAggs).aggs[key] = rawAggs.data.datarows?.map((hit: any) => { return { diff --git a/src/plugins/query_enhancements/server/search/sql_async_search_strategy.ts b/src/plugins/query_enhancements/server/search/sql_async_search_strategy.ts index bc25f69a70f6..76642b9dbac5 100644 --- a/src/plugins/query_enhancements/server/search/sql_async_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/sql_async_search_strategy.ts @@ -13,7 +13,7 @@ import { Query, } from '../../../data/common'; import { ISearchStrategy, SearchUsage } from '../../../data/server'; -import { buildQueryStatusConfig, getFields, handleFacetError, SEARCH_STRATEGY } from '../../common'; +import { buildQueryStatusConfig, getFields, throwFacetError, SEARCH_STRATEGY } from '../../common'; import { Facet } from '../utils'; export const sqlAsyncSearchStrategyProvider = ( @@ -45,7 +45,7 @@ export const sqlAsyncSearchStrategyProvider = ( request.body = { ...request.body, lang: SEARCH_STRATEGY.SQL }; const rawResponse: any = await sqlAsyncFacet.describeQuery(context, request); - if (!rawResponse.success) handleFacetError(rawResponse); + if (!rawResponse.success) throwFacetError(rawResponse); const statusConfig = buildQueryStatusConfig(rawResponse); @@ -60,7 +60,7 @@ export const sqlAsyncSearchStrategyProvider = ( request.params = { queryId: inProgressQueryId }; const queryStatusResponse = await sqlAsyncJobsFacet.describeQuery(context, request); - if (!queryStatusResponse.success) handleFacetError(queryStatusResponse); + if (!queryStatusResponse.success) throwFacetError(queryStatusResponse); const queryStatus = queryStatusResponse.data?.status; logger.info(`sqlAsyncSearchStrategy: JOB: ${inProgressQueryId} - STATUS: ${queryStatus}`); diff --git a/src/plugins/query_enhancements/server/search/sql_search_strategy.ts b/src/plugins/query_enhancements/server/search/sql_search_strategy.ts index 8fa945c8809e..09f2775d0fe2 100644 --- a/src/plugins/query_enhancements/server/search/sql_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/sql_search_strategy.ts @@ -13,7 +13,7 @@ import { Query, createDataFrame, } from '../../../data/common'; -import { getFields, handleFacetError } from '../../common/utils'; +import { getFields, throwFacetError } from '../../common/utils'; import { Facet } from '../utils'; export const sqlSearchStrategyProvider = ( @@ -36,7 +36,7 @@ export const sqlSearchStrategyProvider = ( const query: Query = request.body.query; const rawResponse: any = await sqlFacet.describeQuery(context, request); - if (!rawResponse.success) handleFacetError(rawResponse); + if (!rawResponse.success) throwFacetError(rawResponse); const dataFrame = createDataFrame({ name: query.dataset?.id, diff --git a/src/plugins/vis_augmenter/public/plugin.ts b/src/plugins/vis_augmenter/public/plugin.ts index bd6e45a3967b..9760bfd75b2d 100644 --- a/src/plugins/vis_augmenter/public/plugin.ts +++ b/src/plugins/vis_augmenter/public/plugin.ts @@ -13,7 +13,6 @@ import { setUiActions, setEmbeddable, setQueryService, - setIndexPatterns, setVisualizations, setCore, } from './services'; @@ -63,7 +62,6 @@ export class VisAugmenterPlugin setUiActions(uiActions); setEmbeddable(embeddable); setQueryService(data.query); - setIndexPatterns(data.indexPatterns); setVisualizations(visualizations); setCore(core); setFlyoutState(VIEW_EVENTS_FLYOUT_STATE.CLOSED); diff --git a/src/plugins/vis_augmenter/public/services.ts b/src/plugins/vis_augmenter/public/services.ts index 44a7ea8b424b..1d7f3e2111db 100644 --- a/src/plugins/vis_augmenter/public/services.ts +++ b/src/plugins/vis_augmenter/public/services.ts @@ -8,7 +8,7 @@ import { IUiSettingsClient } from '../../../core/public'; import { SavedObjectLoaderAugmentVis } from './saved_augment_vis'; import { EmbeddableStart } from '../../embeddable/public'; import { UiActionsStart } from '../../ui_actions/public'; -import { DataPublicPluginStart, IndexPatternsContract } from '../../../plugins/data/public'; +import { DataPublicPluginStart } from '../../../plugins/data/public'; import { VisualizationsStart } from '../../visualizations/public'; import { CoreStart } from '../../../core/public'; @@ -26,10 +26,6 @@ export const [getQueryService, setQueryService] = createGetterSetter< DataPublicPluginStart['query'] >('Query'); -export const [getIndexPatterns, setIndexPatterns] = createGetterSetter( - 'IndexPatterns' -); - export const [getVisualizations, setVisualizations] = createGetterSetter( 'visualizations' ); diff --git a/src/plugins/vis_augmenter/public/utils/utils.test.ts b/src/plugins/vis_augmenter/public/utils/utils.test.ts index 05f90522fe4a..f831deef3955 100644 --- a/src/plugins/vis_augmenter/public/utils/utils.test.ts +++ b/src/plugins/vis_augmenter/public/utils/utils.test.ts @@ -21,12 +21,11 @@ import { PluginResource, VisLayerErrorTypes, SavedObjectLoaderAugmentVis, - isEligibleForDataSource, } from '../'; import { PLUGIN_AUGMENTATION_ENABLE_SETTING } from '../../common/constants'; import { AggConfigs } from '../../../data/common'; import { uiSettingsServiceMock } from '../../../../core/public/mocks'; -import { setIndexPatterns, setUISettings } from '../services'; +import { setUISettings } from '../services'; import { STUB_INDEX_PATTERN_WITH_FIELDS, TYPES_REGISTRY, @@ -36,7 +35,6 @@ import { createPointInTimeEventsVisLayer, createVisLayer, } from '../mocks'; -import { dataPluginMock } from 'src/plugins/data/public/mocks'; describe('utils', () => { const uiSettingsMock = uiSettingsServiceMock.createStartContract(); @@ -62,7 +60,7 @@ describe('utils', () => { aggs: VALID_AGGS, }, } as unknown) as Vis; - expect(await isEligibleForVisLayers(vis)).toEqual(false); + expect(isEligibleForVisLayers(vis)).toEqual(false); }); it('vis is ineligible with no date_histogram', async () => { const invalidConfigStates = [ @@ -89,7 +87,7 @@ describe('utils', () => { invalidAggs, }, } as unknown) as Vis; - expect(await isEligibleForVisLayers(vis)).toEqual(false); + expect(isEligibleForVisLayers(vis)).toEqual(false); }); it('vis is ineligible with invalid aggs counts', async () => { const invalidConfigStates = [ @@ -113,7 +111,7 @@ describe('utils', () => { invalidAggs, }, } as unknown) as Vis; - expect(await isEligibleForVisLayers(vis)).toEqual(false); + expect(isEligibleForVisLayers(vis)).toEqual(false); }); it('vis is ineligible with no metric aggs', async () => { const invalidConfigStates = [ @@ -135,7 +133,7 @@ describe('utils', () => { invalidAggs, }, } as unknown) as Vis; - expect(await isEligibleForVisLayers(vis)).toEqual(false); + expect(isEligibleForVisLayers(vis)).toEqual(false); }); it('vis is ineligible with series param is not line type', async () => { const vis = ({ @@ -156,7 +154,7 @@ describe('utils', () => { aggs: VALID_AGGS, }, } as unknown) as Vis; - expect(await isEligibleForVisLayers(vis)).toEqual(false); + expect(isEligibleForVisLayers(vis)).toEqual(false); }); it('vis is ineligible with series param not all being line type', async () => { const vis = ({ @@ -180,7 +178,7 @@ describe('utils', () => { aggs: VALID_AGGS, }, } as unknown) as Vis; - expect(await isEligibleForVisLayers(vis)).toEqual(false); + expect(isEligibleForVisLayers(vis)).toEqual(false); }); it('vis is ineligible with invalid x-axis due to no segment aggregation', async () => { const badConfigStates = [ @@ -218,7 +216,7 @@ describe('utils', () => { badAggs, }, } as unknown) as Vis; - expect(await isEligibleForVisLayers(invalidVis)).toEqual(false); + expect(isEligibleForVisLayers(invalidVis)).toEqual(false); }); it('vis is ineligible with xaxis not on bottom', async () => { const invalidVis = ({ @@ -239,7 +237,7 @@ describe('utils', () => { aggs: VALID_AGGS, }, } as unknown) as Vis; - expect(await isEligibleForVisLayers(invalidVis)).toEqual(false); + expect(isEligibleForVisLayers(invalidVis)).toEqual(false); }); it('vis is ineligible with no seriesParams', async () => { const invalidVis = ({ @@ -255,16 +253,16 @@ describe('utils', () => { aggs: VALID_AGGS, }, } as unknown) as Vis; - expect(await isEligibleForVisLayers(invalidVis)).toEqual(false); + expect(isEligibleForVisLayers(invalidVis)).toEqual(false); }); it('vis is ineligible with valid type and disabled setting', async () => { uiSettingsMock.get.mockImplementation((key: string) => { return key !== PLUGIN_AUGMENTATION_ENABLE_SETTING; }); - expect(await isEligibleForVisLayers(VALID_VIS)).toEqual(false); + expect(isEligibleForVisLayers(VALID_VIS)).toEqual(false); }); it('vis is eligible with valid type', async () => { - expect(await isEligibleForVisLayers(VALID_VIS)).toEqual(true); + expect(isEligibleForVisLayers(VALID_VIS)).toEqual(true); }); }); @@ -662,107 +660,4 @@ describe('utils', () => { expect(mockDeleteFn).toHaveBeenCalledTimes(1); }); }); - - describe('isEligibleForDataSource', () => { - beforeEach(() => { - jest.clearAllMocks(); - }); - it('returns true if the Vis indexPattern does not have a dataSourceRef', async () => { - const indexPatternsMock = dataPluginMock.createStartContract().indexPatterns; - indexPatternsMock.getDataSource = jest.fn().mockReturnValue(undefined); - setIndexPatterns(indexPatternsMock); - const vis = { - data: { - indexPattern: { - id: '123', - }, - }, - } as Vis; - expect(await isEligibleForDataSource(vis)).toEqual(true); - }); - it('returns true if the Vis indexPattern has a dataSourceRef with a compatible version', async () => { - const indexPatternsMock = dataPluginMock.createStartContract().indexPatterns; - indexPatternsMock.getDataSource = jest.fn().mockReturnValue({ - id: '456', - attributes: { - dataSourceVersion: '1.2.3', - }, - }); - setIndexPatterns(indexPatternsMock); - const vis = { - data: { - indexPattern: { - id: '123', - dataSourceRef: { - id: '456', - }, - }, - }, - } as Vis; - expect(await isEligibleForDataSource(vis)).toEqual(true); - }); - it('returns false if the Vis indexPattern has a dataSourceRef with an incompatible version', async () => { - const indexPatternsMock = dataPluginMock.createStartContract().indexPatterns; - indexPatternsMock.getDataSource = jest.fn().mockReturnValue({ - id: '456', - attributes: { - dataSourceVersion: '.0', - }, - }); - setIndexPatterns(indexPatternsMock); - const vis = { - data: { - indexPattern: { - id: '123', - dataSourceRef: { - id: '456', - }, - }, - }, - } as Vis; - expect(await isEligibleForDataSource(vis)).toEqual(false); - }); - it('returns false if the Vis indexPattern has a dataSourceRef with an undefined version', async () => { - const indexPatternsMock = dataPluginMock.createStartContract().indexPatterns; - indexPatternsMock.getDataSource = jest.fn().mockReturnValue({ - id: '456', - attributes: { - dataSourceVersion: undefined, - }, - }); - setIndexPatterns(indexPatternsMock); - const vis = { - data: { - indexPattern: { - id: '123', - dataSourceRef: { - id: '456', - }, - }, - }, - } as Vis; - expect(await isEligibleForDataSource(vis)).toEqual(false); - }); - it('returns false if the Vis indexPattern has a dataSourceRef with an empty string version', async () => { - const indexPatternsMock = dataPluginMock.createStartContract().indexPatterns; - indexPatternsMock.getDataSource = jest.fn().mockReturnValue({ - id: '456', - attributes: { - dataSourceVersion: '', - }, - }); - setIndexPatterns(indexPatternsMock); - const vis = { - data: { - indexPattern: { - id: '123', - dataSourceRef: { - id: '456', - }, - }, - }, - } as Vis; - expect(await isEligibleForDataSource(vis)).toEqual(false); - }); - }); }); diff --git a/src/plugins/vis_augmenter/public/utils/utils.ts b/src/plugins/vis_augmenter/public/utils/utils.ts index 0ae3c9ec93aa..ce44964e6173 100644 --- a/src/plugins/vis_augmenter/public/utils/utils.ts +++ b/src/plugins/vis_augmenter/public/utils/utils.ts @@ -4,7 +4,6 @@ */ import { get, isEmpty } from 'lodash'; -import semver from 'semver'; import { Vis } from '../../../../plugins/visualizations/public'; import { formatExpression, @@ -21,13 +20,10 @@ import { VisLayerErrorTypes, } from '../'; import { PLUGIN_AUGMENTATION_ENABLE_SETTING } from '../../common/constants'; -import { getUISettings, getIndexPatterns } from '../services'; +import { getUISettings } from '../services'; import { IUiSettingsClient } from '../../../../core/public'; -export const isEligibleForVisLayers = async ( - vis: Vis, - uiSettingsClient?: IUiSettingsClient -): Promise => { +export const isEligibleForVisLayers = (vis: Vis, uiSettingsClient?: IUiSettingsClient): boolean => { // Only support a date histogram const dateHistograms = vis.data?.aggs?.byTypeName?.('date_histogram'); if (!Array.isArray(dateHistograms) || dateHistograms.length !== 1) return false; @@ -57,9 +53,6 @@ export const isEligibleForVisLayers = async ( ) return false; - // Check if the vis datasource is eligible for the augmentation - if (!(await isEligibleForDataSource(vis))) return false; - // Checks if the augmentation setting is enabled const config = uiSettingsClient ?? getUISettings(); return config.get(PLUGIN_AUGMENTATION_ENABLE_SETTING); @@ -170,6 +163,7 @@ export const getAnyErrors = (visLayers: VisLayer[], visTitle: string): Error | u * @param visLayers the produced VisLayers containing details if the resource has been deleted * @param visualizationsLoader the visualizations saved object loader to handle deletion */ + export const cleanupStaleObjects = ( augmentVisSavedObjs: ISavedAugmentVis[], visLayers: VisLayer[], @@ -193,17 +187,3 @@ export const cleanupStaleObjects = ( loader?.delete(objIdsToDelete); } }; - -/** - * Returns true if the Vis is eligible to be used with the DataSource feature. - * @param vis - The Vis to check - * @returns true if the Vis is eligible for the DataSource feature, false otherwise - */ -export const isEligibleForDataSource = async (vis: Vis) => { - const dataSourceRef = vis.data.indexPattern?.dataSourceRef; - if (!dataSourceRef) return true; - const dataSource = await getIndexPatterns().getDataSource(dataSourceRef.id); - if (!dataSource || !dataSource.attributes) return false; - const version = semver.coerce(dataSource.attributes.dataSourceVersion); - return version ? semver.satisfies(version, '>=1.0.0') : false; -}; diff --git a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx index f83f0e0b77d6..ac7f795c586e 100644 --- a/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx +++ b/src/plugins/vis_augmenter/public/view_events_flyout/actions/view_events_option_action.tsx @@ -46,7 +46,7 @@ export class ViewEventsOptionAction implements Action { const vis = (embeddable as VisualizeEmbeddable).vis; return ( vis !== undefined && - (await isEligibleForVisLayers(vis)) && + isEligibleForVisLayers(vis) && !isEmpty((embeddable as VisualizeEmbeddable).visLayers) ); } diff --git a/src/plugins/vis_type_vislib/public/line_to_expression.ts b/src/plugins/vis_type_vislib/public/line_to_expression.ts index e8d207017c00..8650c6013801 100644 --- a/src/plugins/vis_type_vislib/public/line_to_expression.ts +++ b/src/plugins/vis_type_vislib/public/line_to_expression.ts @@ -32,7 +32,7 @@ export const toExpressionAst = async (vis: Vis, params: any) => { if ( params.visLayers == null || Object.keys(params.visLayers).length === 0 || - !(await isEligibleForVisLayers(vis)) + !isEligibleForVisLayers(vis) ) { // Render using vislib instead of vega-lite const visConfig = { ...vis.params, dimensions }; diff --git a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts index 7bf996c148ea..605c88067211 100644 --- a/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts +++ b/src/plugins/visualizations/public/embeddable/visualize_embeddable.ts @@ -541,7 +541,7 @@ export class VisualizeEmbeddable this.visAugmenterConfig?.visLayerResourceIds ); - if (!isEmpty(augmentVisSavedObjs) && !aborted && (await isEligibleForVisLayers(this.vis))) { + if (!isEmpty(augmentVisSavedObjs) && !aborted && isEligibleForVisLayers(this.vis)) { const visLayersPipeline = buildPipelineFromAugmentVisSavedObjs(augmentVisSavedObjs); // The initial input for the pipeline will just be an empty arr of VisLayers. As plugin // expression functions are ran, they will incrementally append their generated VisLayers to it. diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_id_consumer_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_id_consumer_wrapper.test.ts index c8212d9cc6b1..eca47fbb5b72 100644 --- a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_id_consumer_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_id_consumer_wrapper.test.ts @@ -36,6 +36,8 @@ describe('workspace_id_consumer integration test', () => { let createdBarWorkspace: WorkspaceAttributes = { id: '', }; + const deleteWorkspace = (workspaceId: string) => + osdTestServer.request.delete(root, `/api/workspaces/${workspaceId}`); beforeAll(async () => { const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ adjustTimeout: (t: number) => jest.setTimeout(t), @@ -75,6 +77,10 @@ describe('workspace_id_consumer integration test', () => { }).then((resp) => resp.body.result); }, 30000); afterAll(async () => { + await Promise.all([ + deleteWorkspace(createdFooWorkspace.id), + deleteWorkspace(createdBarWorkspace.id), + ]); await root.shutdown(); await opensearchServer.stop(); }); @@ -144,10 +150,35 @@ describe('workspace_id_consumer integration test', () => { `/api/saved_objects/${config.type}/${packageInfo.version}` ); - // workspaces arrtibutes should not be append + // workspaces attributes should not be append expect(!getConfigResult.body.workspaces).toEqual(true); }); + it('should return error when create with a not existing workspace', async () => { + await clearFooAndBar(); + const createResultWithNonExistRequestWorkspace = await osdTestServer.request + .post(root, `/w/not_exist_workspace_id/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + }) + .expect(400); + + expect(createResultWithNonExistRequestWorkspace.body.message).toEqual( + 'Exist invalid workspaces' + ); + + const createResultWithNonExistOptionsWorkspace = await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + workspaces: ['not_exist_workspace_id'], + }) + .expect(400); + expect(createResultWithNonExistOptionsWorkspace.body.message).toEqual( + 'Exist invalid workspaces' + ); + }); + it('bulk create', async () => { await clearFooAndBar(); const createResultFoo = await osdTestServer.request @@ -178,6 +209,37 @@ describe('workspace_id_consumer integration test', () => { ); }); + it('should return error when bulk create with a not existing workspace', async () => { + await clearFooAndBar(); + const bulkCreateResultWithNonExistRequestWorkspace = await osdTestServer.request + .post(root, `/w/not_exist_workspace_id/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(400); + + expect(bulkCreateResultWithNonExistRequestWorkspace.body.message).toEqual( + 'Exist invalid workspaces' + ); + + const bulkCreateResultWithNonExistOptionsWorkspace = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=not_exist_workspace_id`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(400); + + expect(bulkCreateResultWithNonExistOptionsWorkspace.body.message).toEqual( + 'Exist invalid workspaces' + ); + }); + it('checkConflicts when importing ndjson', async () => { await clearFooAndBar(); const createResultFoo = await osdTestServer.request @@ -282,7 +344,7 @@ describe('workspace_id_consumer integration test', () => { .get(root, `/w/not_exist_workspace_id/api/saved_objects/_find?type=${dashboard.type}`) .expect(400); - expect(findResult.body.message).toEqual('Invalid workspaces'); + expect(findResult.body.message).toEqual('Exist invalid workspaces'); }); it('import within workspace', async () => { @@ -312,5 +374,151 @@ describe('workspace_id_consumer integration test', () => { expect(importWithWorkspacesResult.body.success).toEqual(true); expect(findResult.body.saved_objects[0].workspaces).toEqual([createdFooWorkspace.id]); }); + + it('get', async () => { + await clearFooAndBar(); + await osdTestServer.request.delete( + root, + `/api/saved_objects/${config.type}/${packageInfo.version}` + ); + const createResultFoo = await osdTestServer.request + .post(root, `/w/${createdFooWorkspace.id}/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/w/${createdBarWorkspace.id}/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + await osdTestServer.request + .post(root, `/api/saved_objects/${config.type}/${packageInfo.version}`) + .send({ + attributes: { + legacyConfig: 'foo', + }, + }) + .expect(200); + + const getResultWithRequestWorkspace = await osdTestServer.request + .get(root, `/w/${createdFooWorkspace.id}/api/saved_objects/${dashboard.type}/foo`) + .expect(200); + expect(getResultWithRequestWorkspace.body.id).toEqual('foo'); + expect(getResultWithRequestWorkspace.body.workspaces).toEqual([createdFooWorkspace.id]); + + const getResultWithoutRequestWorkspace = await osdTestServer.request + .get(root, `/api/saved_objects/${dashboard.type}/bar`) + .expect(200); + expect(getResultWithoutRequestWorkspace.body.id).toEqual('bar'); + + const getGlobalResultWithinWorkspace = await osdTestServer.request + .get( + root, + `/w/${createdFooWorkspace.id}/api/saved_objects/${config.type}/${packageInfo.version}` + ) + .expect(200); + expect(getGlobalResultWithinWorkspace.body.id).toEqual(packageInfo.version); + + await osdTestServer.request + .get(root, `/w/${createdFooWorkspace.id}/api/saved_objects/${dashboard.type}/bar`) + .expect(403); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + await osdTestServer.request.delete( + root, + `/api/saved_objects/${config.type}/${packageInfo.version}` + ); + }); + + it('bulk get', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/w/${createdFooWorkspace.id}/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/w/${createdBarWorkspace.id}/api/saved_objects/_bulk_create`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const payload = [ + { id: 'foo', type: 'dashboard' }, + { id: 'bar', type: 'dashboard' }, + ]; + const bulkGetResultWithWorkspace = await osdTestServer.request + .post(root, `/w/${createdFooWorkspace.id}/api/saved_objects/_bulk_get`) + .send(payload) + .expect(200); + + expect(bulkGetResultWithWorkspace.body.saved_objects.length).toEqual(2); + expect(bulkGetResultWithWorkspace.body.saved_objects[0].id).toEqual('foo'); + expect(bulkGetResultWithWorkspace.body.saved_objects[0].workspaces).toEqual([ + createdFooWorkspace.id, + ]); + expect(bulkGetResultWithWorkspace.body.saved_objects[0]?.error).toBeUndefined(); + expect(bulkGetResultWithWorkspace.body.saved_objects[1].id).toEqual('bar'); + expect(bulkGetResultWithWorkspace.body.saved_objects[1].workspaces).toBeUndefined(); + expect(bulkGetResultWithWorkspace.body.saved_objects[1]?.error).toMatchInlineSnapshot(` + Object { + "error": "Forbidden", + "message": "Saved object does not belong to the workspace", + "statusCode": 403, + } + `); + + const bulkGetResultWithoutWorkspace = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_get`) + .send(payload) + .expect(200); + + expect(bulkGetResultWithoutWorkspace.body.saved_objects.length).toEqual(2); + expect(bulkGetResultWithoutWorkspace.body.saved_objects[0].id).toEqual('foo'); + expect(bulkGetResultWithoutWorkspace.body.saved_objects[0].workspaces).toEqual([ + createdFooWorkspace.id, + ]); + expect(bulkGetResultWithoutWorkspace.body.saved_objects[0]?.error).toBeUndefined(); + expect(bulkGetResultWithoutWorkspace.body.saved_objects[1].id).toEqual('bar'); + expect(bulkGetResultWithoutWorkspace.body.saved_objects[1].workspaces).toEqual([ + createdBarWorkspace.id, + ]); + expect(bulkGetResultWithoutWorkspace.body.saved_objects[1]?.error).toBeUndefined(); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); }); }); diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts index 82c943545aca..e3eddb443990 100644 --- a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts @@ -250,7 +250,7 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { perPage: 999, page: 1, }) - ).rejects.toMatchInlineSnapshot(`[Error: Invalid workspaces]`); + ).rejects.toMatchInlineSnapshot(`[Error: Exist invalid workspaces]`); }); it('should return consistent inner workspace data when user permitted', async () => { @@ -349,21 +349,16 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { }); describe('create', () => { - it('should throw forbidden error when workspace not permitted and create called', async () => { - let error; - try { - await notPermittedSavedObjectedClient.create( + it('should throw bad request error when workspace is invalid and create called', async () => { + await expect( + notPermittedSavedObjectedClient.create( 'dashboard', {}, { workspaces: ['workspace-1'], } - ); - } catch (e) { - error = e; - } - - expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + ) + ).rejects.toMatchInlineSnapshot(`[Error: Exist invalid workspaces]`); }); it('should able to create saved objects into permitted workspaces after create called', async () => { @@ -427,7 +422,7 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(createResult.error).toBeUndefined(); }); - it('should throw forbidden error when user create a workspce and is not OSD admin', async () => { + it('should throw forbidden error when user create a workspace and is not OSD admin', async () => { let error; try { await permittedSavedObjectedClient.create('workspace', {}, {}); @@ -468,17 +463,12 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { }); describe('bulkCreate', () => { - it('should throw forbidden error when workspace not permitted and bulkCreate called', async () => { - let error; - try { - await notPermittedSavedObjectedClient.bulkCreate([{ type: 'dashboard', attributes: {} }], { + it('should throw bad request error when workspace is invalid and bulkCreate called', async () => { + await expect( + notPermittedSavedObjectedClient.bulkCreate([{ type: 'dashboard', attributes: {} }], { workspaces: ['workspace-1'], - }); - } catch (e) { - error = e; - } - - expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }) + ).rejects.toMatchInlineSnapshot(`[Error: Exist invalid workspaces]`); }); it('should able to create saved objects into permitted workspaces after bulkCreate called', async () => { @@ -506,7 +496,6 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { ], { overwrite: true, - workspaces: ['workspace-1'], } ); } catch (e) { diff --git a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts index 570d701d7c63..5d9a4094336e 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.test.ts @@ -8,6 +8,7 @@ import { SavedObject } from '../../../../core/public'; import { httpServerMock, savedObjectsClientMock, coreMock } from '../../../../core/server/mocks'; import { WorkspaceIdConsumerWrapper } from './workspace_id_consumer_wrapper'; import { workspaceClientMock } from '../workspace_client.mock'; +import { SavedObjectsErrorHelpers } from '../../../../core/server'; describe('WorkspaceIdConsumerWrapper', () => { const requestHandlerContext = coreMock.createRequestHandlerContext(); @@ -37,8 +38,15 @@ describe('WorkspaceIdConsumerWrapper', () => { describe('create', () => { beforeEach(() => { mockedClient.create.mockClear(); + mockedWorkspaceClient.get.mockClear(); + mockedWorkspaceClient.list.mockClear(); }); it(`Should add workspaces parameters when create`, async () => { + mockedWorkspaceClient.get.mockImplementationOnce((requestContext, id) => { + return { + success: true, + }; + }); await wrapperClient.create('dashboard', { name: 'foo', }); @@ -67,13 +75,54 @@ describe('WorkspaceIdConsumerWrapper', () => { expect(mockedClient.create.mock.calls[0][2]?.hasOwnProperty('workspaces')).toEqual(false); }); + + it(`Should throw error when passing in invalid workspaces`, async () => { + const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(mockedWorkspaceClient); + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(mockRequest, {}); + const mockedWrapperClient = workspaceIdConsumerWrapper.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: mockRequest, + }); + + mockedWorkspaceClient.list.mockResolvedValueOnce({ + success: true, + result: { + workspaces: [ + { + id: 'foo', + }, + ], + }, + }); + + expect( + mockedWrapperClient.create( + 'dashboard', + { + name: 'foo', + }, + { workspaces: ['zoo', 'noo'] } + ) + ).rejects.toMatchInlineSnapshot(`[Error: Exist invalid workspaces]`); + expect(mockedWorkspaceClient.get).toBeCalledTimes(0); + expect(mockedWorkspaceClient.list).toBeCalledTimes(1); + }); }); describe('bulkCreate', () => { beforeEach(() => { mockedClient.bulkCreate.mockClear(); + mockedWorkspaceClient.get.mockClear(); + mockedWorkspaceClient.list.mockClear(); }); it(`Should add workspaces parameters when bulk create`, async () => { + mockedWorkspaceClient.get.mockImplementationOnce((requestContext, id) => { + return { + success: true, + }; + }); await wrapperClient.bulkCreate([ getSavedObject({ id: 'foo', @@ -87,6 +136,23 @@ describe('WorkspaceIdConsumerWrapper', () => { } ); }); + + it(`Should throw error when passing in invalid workspaces`, async () => { + mockedWorkspaceClient.get.mockImplementationOnce((requestContext, id) => { + return { + success: false, + }; + }); + expect( + wrapperClient.bulkCreate([ + getSavedObject({ + id: 'foo', + }), + ]) + ).rejects.toMatchInlineSnapshot(`[Error: Exist invalid workspaces]`); + expect(mockedWorkspaceClient.get).toBeCalledTimes(1); + expect(mockedWorkspaceClient.list).toBeCalledTimes(0); + }); }); describe('checkConflict', () => { @@ -173,7 +239,7 @@ describe('WorkspaceIdConsumerWrapper', () => { type: ['dashboard', 'visualization'], workspaces: ['foo', 'not-exist'], }) - ).rejects.toMatchInlineSnapshot(`[Error: Invalid workspaces]`); + ).rejects.toMatchInlineSnapshot(`[Error: Exist invalid workspaces]`); expect(mockedWorkspaceClient.get).toBeCalledTimes(0); expect(mockedWorkspaceClient.list).toBeCalledTimes(1); }); @@ -196,4 +262,498 @@ describe('WorkspaceIdConsumerWrapper', () => { }); }); }); + + describe('get', () => { + beforeEach(() => { + mockedClient.get.mockClear(); + }); + + it(`Should get object belonging to options.workspaces`, async () => { + const savedObject = { + type: 'dashboard', + id: 'dashboard_id', + attributes: {}, + references: [], + workspaces: ['foo'], + }; + mockedClient.get.mockResolvedValueOnce(savedObject); + const result = await wrapperClient.get(savedObject.type, savedObject.id, { + workspaces: savedObject.workspaces, + }); + expect(mockedClient.get).toBeCalledWith(savedObject.type, savedObject.id, { + workspaces: savedObject.workspaces, + }); + expect(result).toEqual(savedObject); + }); + + it(`Should get object belonging to the workspace in request`, async () => { + const savedObject = { + type: 'dashboard', + id: 'dashboard_id', + attributes: {}, + references: [], + workspaces: ['foo'], + }; + mockedClient.get.mockResolvedValueOnce(savedObject); + const result = await wrapperClient.get(savedObject.type, savedObject.id); + expect(mockedClient.get).toBeCalledWith(savedObject.type, savedObject.id, {}); + expect(result).toEqual(savedObject); + }); + + it(`Should get object if the object type is workspace`, async () => { + const savedObject = { + type: 'workspace', + id: 'workspace_id', + attributes: {}, + references: [], + }; + mockedClient.get.mockResolvedValueOnce(savedObject); + const result = await wrapperClient.get(savedObject.type, savedObject.id); + expect(mockedClient.get).toBeCalledWith(savedObject.type, savedObject.id, {}); + expect(result).toEqual(savedObject); + }); + + it(`Should get object if the object type is config`, async () => { + const savedObject = { + type: 'config', + id: 'config_id', + attributes: {}, + references: [], + }; + mockedClient.get.mockResolvedValueOnce(savedObject); + const result = await wrapperClient.get(savedObject.type, savedObject.id); + expect(mockedClient.get).toBeCalledWith(savedObject.type, savedObject.id, {}); + expect(result).toEqual(savedObject); + }); + + it(`Should get object when there is no workspace in options/request`, async () => { + const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(mockedWorkspaceClient); + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(mockRequest, {}); + const mockedWrapperClient = workspaceIdConsumerWrapper.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: mockRequest, + }); + const savedObject = { + type: 'dashboard', + id: 'dashboard_id', + attributes: {}, + references: [], + }; + mockedClient.get.mockResolvedValueOnce(savedObject); + const result = await mockedWrapperClient.get(savedObject.type, savedObject.id); + expect(mockedClient.get).toBeCalledWith(savedObject.type, savedObject.id, {}); + expect(result).toEqual(savedObject); + }); + + it(`Should throw error when the object is not belong to the workspace`, async () => { + const savedObject = { + type: 'dashboard', + id: 'dashboard_id', + attributes: {}, + references: [], + workspaces: ['bar'], + }; + mockedClient.get.mockResolvedValueOnce(savedObject); + expect(wrapperClient.get(savedObject.type, savedObject.id)).rejects.toMatchInlineSnapshot( + `[Error: Saved object does not belong to the workspace]` + ); + expect(mockedClient.get).toBeCalledWith(savedObject.type, savedObject.id, {}); + }); + + it(`Should throw error when the object does not exist`, async () => { + mockedClient.get.mockRejectedValueOnce(SavedObjectsErrorHelpers.createGenericNotFoundError()); + expect(wrapperClient.get('type', 'id')).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + expect(mockedClient.get).toHaveBeenCalledTimes(1); + }); + + it(`Should throw error when the options.workspaces has more than one workspace.`, async () => { + const savedObject = { + type: 'dashboard', + id: 'dashboard_id', + attributes: {}, + references: [], + workspaces: ['bar'], + }; + const options = { workspaces: ['foo', 'bar'] }; + expect( + wrapperClient.get(savedObject.type, savedObject.id, options) + ).rejects.toMatchInlineSnapshot(`[Error: Multiple workspace parameters: Bad Request]`); + expect(mockedClient.get).not.toBeCalled(); + }); + + it(`Should get data source when user is data source admin`, async () => { + const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(mockedWorkspaceClient); + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(mockRequest, { isDataSourceAdmin: true, requestWorkspaceId: 'foo' }); + const mockedWrapperClient = workspaceIdConsumerWrapper.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: mockRequest, + }); + const savedObject = { + type: 'data-source', + id: 'data-source_id', + attributes: {}, + references: [], + }; + mockedClient.get.mockResolvedValueOnce(savedObject); + const result = await mockedWrapperClient.get(savedObject.type, savedObject.id); + expect(mockedClient.get).toBeCalledWith(savedObject.type, savedObject.id, {}); + expect(result).toEqual(savedObject); + }); + + it(`Should throw error when the object is global data source`, async () => { + const savedObject = { + type: 'data-source', + id: 'data-source_id', + attributes: {}, + references: [], + }; + mockedClient.get.mockResolvedValueOnce(savedObject); + mockedClient.get.mockResolvedValueOnce(savedObject); + expect(wrapperClient.get(savedObject.type, savedObject.id)).rejects.toMatchInlineSnapshot( + `[Error: Saved object does not belong to the workspace]` + ); + expect(mockedClient.get).toBeCalledWith(savedObject.type, savedObject.id, {}); + }); + }); + + describe('bulkGet', () => { + const payload = [ + { id: 'dashboard_id', type: 'dashboard' }, + { id: 'dashboard_error_id', type: 'dashboard' }, + { id: 'visualization_id', type: 'visualization' }, + { id: 'global_data_source_id', type: 'data-source' }, + { id: 'data_source_id', type: 'data-source' }, + ]; + const savedObjects = [ + { + type: 'dashboard', + id: 'dashboard_id', + attributes: { description: 'description' }, + references: ['reference_id'], + workspaces: ['foo'], + }, + { + type: 'dashboard', + id: 'dashboard_error_id', + attributes: {}, + references: [], + error: { + statusCode: 404, + error: 'Not Found', + message: 'Saved object [dashboard/dashboard_error_id] not found', + }, + }, + { + type: 'visualization', + id: 'visualization_id', + attributes: { description: 'description' }, + references: ['reference_id'], + workspaces: ['bar'], + }, + { + type: 'config', + id: 'config_id', + attributes: {}, + references: [], + }, + { + type: 'workspace', + id: 'workspace_id', + attributes: {}, + references: [], + }, + { + type: 'data-source', + id: 'global_data_source_id', + attributes: {}, + references: [], + }, + { + type: 'data-source', + id: 'data_source_id', + attributes: {}, + references: [], + workspaces: ['foo'], + }, + ]; + const options = { workspaces: ['foo'] }; + beforeEach(() => { + mockedClient.bulkGet.mockClear(); + }); + + it(`Should bulkGet objects belonging to options.workspaces`, async () => { + mockedClient.bulkGet.mockResolvedValueOnce({ saved_objects: savedObjects }); + const result = await wrapperClient.bulkGet(payload, options); + expect(mockedClient.bulkGet).toBeCalledWith(payload, options); + expect(result).toMatchInlineSnapshot(` + Object { + "saved_objects": Array [ + Object { + "attributes": Object { + "description": "description", + }, + "id": "dashboard_id", + "references": Array [ + "reference_id", + ], + "type": "dashboard", + "workspaces": Array [ + "foo", + ], + }, + Object { + "attributes": Object {}, + "error": Object { + "error": "Not Found", + "message": "Saved object [dashboard/dashboard_error_id] not found", + "statusCode": 404, + }, + "id": "dashboard_error_id", + "references": Array [], + "type": "dashboard", + }, + Object { + "attributes": Object {}, + "error": Object { + "error": "Forbidden", + "message": "Saved object does not belong to the workspace", + "statusCode": 403, + }, + "id": "visualization_id", + "references": Array [], + "type": "visualization", + }, + Object { + "attributes": Object {}, + "id": "config_id", + "references": Array [], + "type": "config", + }, + Object { + "attributes": Object {}, + "id": "workspace_id", + "references": Array [], + "type": "workspace", + }, + Object { + "attributes": Object {}, + "error": Object { + "error": "Forbidden", + "message": "Saved object does not belong to the workspace", + "statusCode": 403, + }, + "id": "global_data_source_id", + "references": Array [], + "type": "data-source", + }, + Object { + "attributes": Object {}, + "id": "data_source_id", + "references": Array [], + "type": "data-source", + "workspaces": Array [ + "foo", + ], + }, + ], + } + `); + }); + + it(`Should bulkGet objects belonging to the workspace in request`, async () => { + mockedClient.bulkGet.mockResolvedValueOnce({ saved_objects: savedObjects }); + const result = await wrapperClient.bulkGet(payload); + expect(mockedClient.bulkGet).toBeCalledWith(payload, {}); + expect(result).toMatchInlineSnapshot(` + Object { + "saved_objects": Array [ + Object { + "attributes": Object { + "description": "description", + }, + "id": "dashboard_id", + "references": Array [ + "reference_id", + ], + "type": "dashboard", + "workspaces": Array [ + "foo", + ], + }, + Object { + "attributes": Object {}, + "error": Object { + "error": "Not Found", + "message": "Saved object [dashboard/dashboard_error_id] not found", + "statusCode": 404, + }, + "id": "dashboard_error_id", + "references": Array [], + "type": "dashboard", + }, + Object { + "attributes": Object {}, + "error": Object { + "error": "Forbidden", + "message": "Saved object does not belong to the workspace", + "statusCode": 403, + }, + "id": "visualization_id", + "references": Array [], + "type": "visualization", + }, + Object { + "attributes": Object {}, + "id": "config_id", + "references": Array [], + "type": "config", + }, + Object { + "attributes": Object {}, + "id": "workspace_id", + "references": Array [], + "type": "workspace", + }, + Object { + "attributes": Object {}, + "error": Object { + "error": "Forbidden", + "message": "Saved object does not belong to the workspace", + "statusCode": 403, + }, + "id": "global_data_source_id", + "references": Array [], + "type": "data-source", + }, + Object { + "attributes": Object {}, + "id": "data_source_id", + "references": Array [], + "type": "data-source", + "workspaces": Array [ + "foo", + ], + }, + ], + } + `); + }); + + it(`Should bulkGet objects when there is no workspace in options/request`, async () => { + const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(mockedWorkspaceClient); + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(mockRequest, {}); + const mockedWrapperClient = workspaceIdConsumerWrapper.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: mockRequest, + }); + mockedClient.bulkGet.mockResolvedValueOnce({ saved_objects: savedObjects }); + const result = await mockedWrapperClient.bulkGet(payload); + expect(mockedClient.bulkGet).toBeCalledWith(payload, {}); + expect(result).toEqual({ saved_objects: savedObjects }); + }); + + it(`Should throw error when the objects do not exist`, async () => { + mockedClient.bulkGet.mockRejectedValueOnce( + SavedObjectsErrorHelpers.createGenericNotFoundError() + ); + expect(wrapperClient.bulkGet(payload)).rejects.toMatchInlineSnapshot(`[Error: Not Found]`); + expect(mockedClient.bulkGet).toBeCalledWith(payload, {}); + }); + + it(`Should throw error when the options.workspaces has more than one workspace.`, async () => { + expect( + wrapperClient.bulkGet(payload, { workspaces: ['foo', 'var'] }) + ).rejects.toMatchInlineSnapshot(`[Error: Multiple workspace parameters: Bad Request]`); + expect(mockedClient.bulkGet).not.toBeCalled(); + }); + + it(`Should bulkGet data source when user is data source admin`, async () => { + const workspaceIdConsumerWrapper = new WorkspaceIdConsumerWrapper(mockedWorkspaceClient); + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(mockRequest, { isDataSourceAdmin: true, requestWorkspaceId: 'foo' }); + const mockedWrapperClient = workspaceIdConsumerWrapper.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: mockRequest, + }); + + mockedClient.bulkGet.mockResolvedValueOnce({ saved_objects: savedObjects }); + const result = await mockedWrapperClient.bulkGet(payload); + expect(mockedClient.bulkGet).toBeCalledWith(payload, {}); + expect(result).toMatchInlineSnapshot(` + Object { + "saved_objects": Array [ + Object { + "attributes": Object { + "description": "description", + }, + "id": "dashboard_id", + "references": Array [ + "reference_id", + ], + "type": "dashboard", + "workspaces": Array [ + "foo", + ], + }, + Object { + "attributes": Object {}, + "error": Object { + "error": "Not Found", + "message": "Saved object [dashboard/dashboard_error_id] not found", + "statusCode": 404, + }, + "id": "dashboard_error_id", + "references": Array [], + "type": "dashboard", + }, + Object { + "attributes": Object {}, + "error": Object { + "error": "Forbidden", + "message": "Saved object does not belong to the workspace", + "statusCode": 403, + }, + "id": "visualization_id", + "references": Array [], + "type": "visualization", + }, + Object { + "attributes": Object {}, + "id": "config_id", + "references": Array [], + "type": "config", + }, + Object { + "attributes": Object {}, + "id": "workspace_id", + "references": Array [], + "type": "workspace", + }, + Object { + "attributes": Object {}, + "id": "global_data_source_id", + "references": Array [], + "type": "data-source", + }, + Object { + "attributes": Object {}, + "id": "data_source_id", + "references": Array [], + "type": "data-source", + "workspaces": Array [ + "foo", + ], + }, + ], + } + `); + }); + }); }); diff --git a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts index 90820c835d47..b9edaecd2c9d 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_id_consumer_wrapper.ts @@ -14,13 +14,27 @@ import { OpenSearchDashboardsRequest, SavedObjectsFindOptions, SavedObjectsErrorHelpers, + SavedObjectsClientWrapperOptions, + SavedObject, + SavedObjectsBulkGetObject, + SavedObjectsBulkResponse, } from '../../../../core/server'; import { IWorkspaceClientImpl } from '../types'; +import { validateIsWorkspaceDataSourceAndConnectionObjectType } from '../../common/utils'; const UI_SETTINGS_SAVED_OBJECTS_TYPE = 'config'; type WorkspaceOptions = Pick | undefined; +const generateSavedObjectsForbiddenError = () => + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + i18n.translate('workspace.id_consumer.saved_objects.forbidden', { + defaultMessage: 'Saved object does not belong to the workspace', + }) + ) + ); + export class WorkspaceIdConsumerWrapper { private formatWorkspaceIdParams( request: OpenSearchDashboardsRequest, @@ -48,25 +62,100 @@ export class WorkspaceIdConsumerWrapper { return type === UI_SETTINGS_SAVED_OBJECTS_TYPE; } + private async checkWorkspacesExist( + workspaces: SavedObject['workspaces'] | null, + wrapperOptions: SavedObjectsClientWrapperOptions + ) { + if (workspaces?.length) { + let invalidWorkspaces: string[] = []; + // If only has one workspace, we should use get to optimize performance + if (workspaces.length === 1) { + const workspaceGet = await this.workspaceClient.get( + { request: wrapperOptions.request }, + workspaces[0] + ); + if (!workspaceGet.success) { + invalidWorkspaces = [workspaces[0]]; + } + } else { + const workspaceList = await this.workspaceClient.list( + { + request: wrapperOptions.request, + }, + { + perPage: 9999, + } + ); + if (workspaceList.success) { + const workspaceIdsSet = new Set( + workspaceList.result.workspaces.map((workspace) => workspace.id) + ); + invalidWorkspaces = workspaces.filter( + (targetWorkspace) => !workspaceIdsSet.has(targetWorkspace) + ); + } + } + + if (invalidWorkspaces.length > 0) { + throw SavedObjectsErrorHelpers.decorateBadRequestError( + new Error( + i18n.translate('workspace.id_consumer.invalid', { + defaultMessage: 'Exist invalid workspaces', + }) + ) + ); + } + } + } + + private validateObjectInAWorkspace( + object: SavedObject, + workspace: string, + request: OpenSearchDashboardsRequest + ) { + // Keep the original object error + if (!!object?.error) { + return true; + } + // Data source is a workspace level object, validate if the request has access to the data source within the requested workspace. + if (validateIsWorkspaceDataSourceAndConnectionObjectType(object.type)) { + if (!!getWorkspaceState(request).isDataSourceAdmin) { + return true; + } + // Deny access if the object is a global data source (no workspaces assigned) + if (!object.workspaces || object.workspaces.length === 0) { + return false; + } + } + /* + * Allow access if the requested workspace matches one of the object's assigned workspaces + * This ensures that the user can only access data sources within their current workspace + */ + if (object.workspaces && object.workspaces.length > 0) { + return object.workspaces.includes(workspace); + } + // Allow access if the object is a global object (object.workspaces is null/[]) + return true; + } + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { return { ...wrapperOptions.client, - create: (type: string, attributes: T, options: SavedObjectsCreateOptions = {}) => - wrapperOptions.client.create( - type, - attributes, - this.isConfigType(type) - ? options - : this.formatWorkspaceIdParams(wrapperOptions.request, options) - ), - bulkCreate: ( + create: async (type: string, attributes: T, options: SavedObjectsCreateOptions = {}) => { + const finalOptions = this.isConfigType(type) + ? options + : this.formatWorkspaceIdParams(wrapperOptions.request, options); + await this.checkWorkspacesExist(finalOptions?.workspaces, wrapperOptions); + return wrapperOptions.client.create(type, attributes, finalOptions); + }, + bulkCreate: async ( objects: Array>, options: SavedObjectsCreateOptions = {} - ) => - wrapperOptions.client.bulkCreate( - objects, - this.formatWorkspaceIdParams(wrapperOptions.request, options) - ), + ) => { + const finalOptions = this.formatWorkspaceIdParams(wrapperOptions.request, options); + await this.checkWorkspacesExist(finalOptions?.workspaces, wrapperOptions); + return wrapperOptions.client.bulkCreate(objects, finalOptions); + }, checkConflicts: ( objects: SavedObjectsCheckConflictsObject[] = [], options: SavedObjectsBaseOptions = {} @@ -84,50 +173,65 @@ export class WorkspaceIdConsumerWrapper { this.isConfigType(options.type as string) && options.sortField === 'buildNum' ? options : this.formatWorkspaceIdParams(wrapperOptions.request, options); - if (finalOptions.workspaces?.length) { - let isAllTargetWorkspaceExisting = false; - // If only has one workspace, we should use get to optimize performance - if (finalOptions.workspaces.length === 1) { - const workspaceGet = await this.workspaceClient.get( - { request: wrapperOptions.request }, - finalOptions.workspaces[0] - ); - if (workspaceGet.success) { - isAllTargetWorkspaceExisting = true; - } - } else { - const workspaceList = await this.workspaceClient.list( - { - request: wrapperOptions.request, - }, - { - perPage: 9999, - } - ); - if (workspaceList.success) { - const workspaceIdsSet = new Set( - workspaceList.result.workspaces.map((workspace) => workspace.id) - ); - isAllTargetWorkspaceExisting = finalOptions.workspaces.every((targetWorkspace) => - workspaceIdsSet.has(targetWorkspace) - ); - } - } + await this.checkWorkspacesExist(finalOptions?.workspaces, wrapperOptions); + return wrapperOptions.client.find(finalOptions); + }, + bulkGet: async ( + objects: SavedObjectsBulkGetObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise> => { + const { workspaces } = this.formatWorkspaceIdParams(wrapperOptions.request, options); + if (!!workspaces && workspaces.length > 1) { + // Version 2.18 does not support the passing of multiple workspaces. + throw SavedObjectsErrorHelpers.createBadRequestError('Multiple workspace parameters'); + } - if (!isAllTargetWorkspaceExisting) { - throw SavedObjectsErrorHelpers.decorateBadRequestError( - new Error( - i18n.translate('workspace.id_consumer.invalid', { - defaultMessage: 'Invalid workspaces', - }) - ) - ); - } + const objectToBulkGet = await wrapperOptions.client.bulkGet(objects, options); + + if (workspaces?.length === 1) { + return { + ...objectToBulkGet, + saved_objects: objectToBulkGet.saved_objects.map((object) => { + return this.validateObjectInAWorkspace(object, workspaces[0], wrapperOptions.request) + ? object + : { + id: object.id, + type: object.type, + attributes: {} as T, + references: [], + error: { + ...generateSavedObjectsForbiddenError().output.payload, + }, + }; + }), + }; } - return wrapperOptions.client.find(finalOptions); + + return objectToBulkGet; + }, + get: async ( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> => { + const { workspaces } = this.formatWorkspaceIdParams(wrapperOptions.request, options); + if (!!workspaces && workspaces.length > 1) { + // Version 2.18 does not support the passing of multiple workspaces. + throw SavedObjectsErrorHelpers.createBadRequestError('Multiple workspace parameters'); + } + + const objectToGet = await wrapperOptions.client.get(type, id, options); + + if ( + workspaces?.length === 1 && + !this.validateObjectInAWorkspace(objectToGet, workspaces[0], wrapperOptions.request) + ) { + throw generateSavedObjectsForbiddenError(); + } + + // Allow access if no specific workspace is requested. + return objectToGet; }, - bulkGet: wrapperOptions.client.bulkGet, - get: wrapperOptions.client.get, update: wrapperOptions.client.update, bulkUpdate: wrapperOptions.client.bulkUpdate, addToNamespaces: wrapperOptions.client.addToNamespaces, diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts index e9f5c5c2a409..55098d6e2b27 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts @@ -652,127 +652,6 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { } `); }); - - it('should validate data source or data connection workspace field', async () => { - const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); - let errorCatched; - try { - await wrapper.get('data-source', 'workspace-1-data-source'); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - - try { - await wrapper.get('data-connection', 'workspace-1-data-connection'); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - - let result = await wrapper.get('data-source', 'workspace-2-data-source'); - expect(result).toEqual( - expect.objectContaining({ - attributes: { - title: 'Workspace 2 data source', - }, - id: 'workspace-2-data-source', - type: 'data-source', - workspaces: ['mock-request-workspace-id'], - }) - ); - result = await wrapper.get('data-connection', 'workspace-2-data-connection'); - expect(result).toEqual( - expect.objectContaining({ - attributes: { - title: 'Workspace 2 data connection', - }, - id: 'workspace-2-data-connection', - type: 'data-connection', - workspaces: ['mock-request-workspace-id'], - }) - ); - }); - - it('should not validate data source or data connection when not in workspace', async () => { - const { wrapper, requestMock } = generateWorkspaceSavedObjectsClientWrapper(); - updateWorkspaceState(requestMock, { requestWorkspaceId: undefined }); - let result = await wrapper.get('data-source', 'workspace-1-data-source'); - expect(result).toEqual({ - type: DATA_SOURCE_SAVED_OBJECT_TYPE, - id: 'workspace-1-data-source', - attributes: { title: 'Workspace 1 data source' }, - workspaces: ['workspace-1'], - references: [], - }); - result = await wrapper.get('data-connection', 'workspace-1-data-connection'); - expect(result).toEqual({ - type: DATA_CONNECTION_SAVED_OBJECT_TYPE, - id: 'workspace-1-data-connection', - attributes: { title: 'Workspace 1 data connection' }, - workspaces: ['workspace-1'], - references: [], - }); - }); - - it('should not validate data source when user is data source admin', async () => { - const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(DATASOURCE_ADMIN); - const result = await wrapper.get('data-source', 'workspace-1-data-source'); - expect(result).toEqual({ - type: DATA_SOURCE_SAVED_OBJECT_TYPE, - id: 'workspace-1-data-source', - attributes: { title: 'Workspace 1 data source' }, - workspaces: ['workspace-1'], - references: [], - }); - }); - - it('should throw permission error when tried to access a global data source or data connection', async () => { - const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); - let errorCatched; - try { - await wrapper.get('data-source', 'global-data-source'); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - try { - await wrapper.get('data-connection', 'global-data-connection'); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - }); - - it('should throw permission error when tried to access a empty workspaces global data source or data connection', async () => { - const { wrapper, requestMock } = generateWorkspaceSavedObjectsClientWrapper(); - updateWorkspaceState(requestMock, { requestWorkspaceId: undefined }); - let errorCatched; - try { - await wrapper.get('data-source', 'global-data-source-empty-workspaces'); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - try { - await wrapper.get('data-connection', 'global-data-connection-empty-workspaces'); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - }); }); describe('bulk get', () => { it("should call permission validate with object's workspace and throw permission error", async () => { @@ -837,166 +716,6 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { {} ); }); - it('should validate data source or data connection workspace field', async () => { - const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); - let errorCatched; - try { - await wrapper.bulkGet([ - { - type: 'data-source', - id: 'workspace-1-data-source', - }, - ]); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - - try { - await wrapper.bulkGet([ - { - type: 'data-connection', - id: 'workspace-1-data-connection', - }, - ]); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - - let result = await await wrapper.bulkGet([ - { - type: 'data-source', - id: 'workspace-2-data-source', - }, - ]); - expect(result).toEqual({ - saved_objects: [ - { - attributes: { - title: 'Workspace 2 data source', - }, - id: 'workspace-2-data-source', - type: 'data-source', - workspaces: ['mock-request-workspace-id'], - references: [], - }, - ], - }); - - result = await await wrapper.bulkGet([ - { - type: 'data-connection', - id: 'workspace-2-data-connection', - }, - ]); - expect(result).toEqual({ - saved_objects: [ - { - attributes: { - title: 'Workspace 2 data connection', - }, - id: 'workspace-2-data-connection', - type: 'data-connection', - workspaces: ['mock-request-workspace-id'], - references: [], - }, - ], - }); - }); - - it('should not validate data source or data connection when not in workspace', async () => { - const { wrapper, requestMock } = generateWorkspaceSavedObjectsClientWrapper(); - updateWorkspaceState(requestMock, { requestWorkspaceId: undefined }); - let result = await wrapper.bulkGet([ - { - type: 'data-source', - id: 'workspace-1-data-source', - }, - ]); - expect(result).toEqual({ - saved_objects: [ - { - attributes: { - title: 'Workspace 1 data source', - }, - id: 'workspace-1-data-source', - type: 'data-source', - workspaces: ['workspace-1'], - references: [], - }, - ], - }); - - result = await wrapper.bulkGet([ - { - type: 'data-connection', - id: 'workspace-1-data-connection', - }, - ]); - expect(result).toEqual({ - saved_objects: [ - { - attributes: { - title: 'Workspace 1 data connection', - }, - id: 'workspace-1-data-connection', - type: 'data-connection', - workspaces: ['workspace-1'], - references: [], - }, - ], - }); - }); - - it('should throw permission error when tried to bulk get global data source or data connection', async () => { - const { wrapper, requestMock } = generateWorkspaceSavedObjectsClientWrapper(); - updateWorkspaceState(requestMock, { requestWorkspaceId: undefined }); - let errorCatched; - try { - await wrapper.bulkGet([{ type: 'data-source', id: 'global-data-source' }]); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - try { - await wrapper.bulkGet([{ type: 'data-connection', id: 'global-data-connection' }]); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - }); - - it('should throw permission error when tried to bulk get a empty workspace global data source or data connection', async () => { - const { wrapper, requestMock } = generateWorkspaceSavedObjectsClientWrapper(); - updateWorkspaceState(requestMock, { requestWorkspaceId: undefined }); - let errorCatched; - try { - await wrapper.bulkGet([ - { type: 'data-source', id: 'global-data-source-empty-workspaces' }, - ]); - } catch (e) { - errorCatched = e; - } - expect(errorCatched?.message).toEqual( - 'Invalid data source permission, please associate it to current workspace' - ); - try { - await wrapper.bulkGet([ - { type: 'data-connection', id: 'global-data-connection-empty-workspaces' }, - ]); - } catch (e) { - errorCatched = e; - } - }); }); describe('find', () => { it('should call client.find with consistent params when ACLSearchParams and workspaceOperator not provided', async () => { diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 162f7a488ad2..0adc27b39a43 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -61,15 +61,6 @@ const generateSavedObjectsPermissionError = () => ) ); -const generateDataSourcePermissionError = () => - SavedObjectsErrorHelpers.decorateForbiddenError( - new Error( - i18n.translate('workspace.saved_objects.data_source.invalidate', { - defaultMessage: 'Invalid data source permission, please associate it to current workspace', - }) - ) - ); - const generateOSDAdminPermissionError = () => SavedObjectsErrorHelpers.decorateForbiddenError( new Error( @@ -205,32 +196,6 @@ export class WorkspaceSavedObjectsClientWrapper { return hasPermission; } - // Data source is a workspace level object, validate if the request has access to the data source within the requested workspace. - private validateDataSourcePermissions = ( - object: SavedObject, - request: OpenSearchDashboardsRequest - ) => { - const requestWorkspaceId = getWorkspaceState(request).requestWorkspaceId; - // Deny access if the object is a global data source (no workspaces assigned) - if (!object.workspaces || object.workspaces.length === 0) { - return false; - } - /** - * Allow access if no specific workspace is requested. - * This typically occurs when retrieving data sources or performing operations - * that don't require a specific workspace, such as pages within the - * Data Administration navigation group that include a data source picker. - */ - if (!requestWorkspaceId) { - return true; - } - /* - * Allow access if the requested workspace matches one of the object's assigned workspaces - * This ensures that the user can only access data sources within their current workspace - */ - return object.workspaces.includes(requestWorkspaceId); - }; - private getWorkspaceTypeEnabledClient(request: OpenSearchDashboardsRequest) { return this.getScopedClient?.(request, { includedHiddenTypes: [WORKSPACE_TYPE], @@ -462,21 +427,6 @@ export class WorkspaceSavedObjectsClientWrapper { ): Promise> => { const objectToGet = await wrapperOptions.client.get(type, id, options); - if (validateIsWorkspaceDataSourceAndConnectionObjectType(objectToGet.type)) { - if (isDataSourceAdmin) { - ACLAuditor?.increment(ACLAuditorStateKey.VALIDATE_SUCCESS, 1); - return objectToGet; - } - const hasPermission = this.validateDataSourcePermissions( - objectToGet, - wrapperOptions.request - ); - if (!hasPermission) { - ACLAuditor?.increment(ACLAuditorStateKey.VALIDATE_FAILURE, 1); - throw generateDataSourcePermissionError(); - } - } - if ( !(await this.validateWorkspacesAndSavedObjectsPermissions( objectToGet, @@ -504,14 +454,6 @@ export class WorkspaceSavedObjectsClientWrapper { ); for (const object of objectToBulkGet.saved_objects) { - if (validateIsWorkspaceDataSourceAndConnectionObjectType(object.type)) { - const hasPermission = this.validateDataSourcePermissions(object, wrapperOptions.request); - if (!hasPermission) { - ACLAuditor?.increment(ACLAuditorStateKey.VALIDATE_FAILURE, 1); - throw generateDataSourcePermissionError(); - } - } - if ( !(await this.validateWorkspacesAndSavedObjectsPermissions( object, diff --git a/yarn.lock b/yarn.lock index 5b3dec208a45..69ddeeeabd5b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1294,7 +1294,7 @@ resolved "https://registry.yarnpkg.com/@colors/colors/-/colors-1.5.0.tgz#bb504579c1cae923e6576a4f5da43d25f97bdbd9" integrity sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ== -"@cypress/request@^2.88.10": +"@cypress/request@2.88.12": version "2.88.12" resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.12.tgz#ba4911431738494a85e93fb04498cb38bc55d590" integrity sha512-tOn+0mDZxASFM+cuAP9szGUGPI1HwWVSvdzm7V4cCsPdFTx6qMj29CwaQmRAMIEhORIUBFBsYROYJcveK4uOjA== @@ -2594,15 +2594,15 @@ version "1.0.6" resolved "https://github.com/opensearch-project/opensearch-dashboards-test-library/archive/refs/tags/1.0.6.tar.gz#f2f489832a75191e243c6d2b42d49047265d9ce3" -"@opensearch-project/opensearch@^2.9.0": - version "2.9.0" - resolved "https://registry.yarnpkg.com/@opensearch-project/opensearch/-/opensearch-2.9.0.tgz#319b4d174540b6d000c31477a56618e5054c6fcb" - integrity sha512-BXPWSBME1rszZ8OvtBVQ9F6kLiZSENDSFPawbPa1fv0GouuQfWxkKSI9TcnfGLp869fgLTEIfeC5Qexd4RbAYw== +"@opensearch-project/opensearch@^2.13.0": + version "2.13.0" + resolved "https://registry.yarnpkg.com/@opensearch-project/opensearch/-/opensearch-2.13.0.tgz#e60c1a3a3dd059562f1d901aa8d3659035cb1781" + integrity sha512-Bu3jJ7pKzumbMMeefu7/npAWAvFu5W9SlbBow1ulhluqUpqc7QoXe0KidDrMy7Dy3BQrkI6llR3cWL4lQTZOFw== dependencies: aws4 "^1.11.0" debug "^4.3.1" hpagent "^1.2.0" - json11 "^1.0.4" + json11 "^2.0.0" ms "^2.1.3" secure-json-parse "^2.4.0" @@ -2929,6 +2929,11 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@tootallnate/quickjs-emscripten@^0.23.0": + version "0.23.0" + resolved "https://registry.yarnpkg.com/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz#db4ecfd499a9765ab24002c3b696d02e6d32a12c" + integrity sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA== + "@tsd/typescript@~4.7.3": version "4.7.4" resolved "https://registry.yarnpkg.com/@tsd/typescript/-/typescript-4.7.4.tgz#f1e4e6c3099a174a0cb7aa51cf53f34f6494e528" @@ -3534,7 +3539,7 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@12.20.24", "@types/node@16.9.1", "@types/node@^14.14.31", "@types/node@~18.7.0": +"@types/node@*", "@types/node@12.20.24", "@types/node@16.9.1", "@types/node@^16.18.39", "@types/node@~18.7.0": version "18.7.23" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.7.23.tgz#75c580983846181ebe5f4abc40fe9dfb2d65665f" integrity sha512-DWNcCHolDq0ZKGizjx2DZjR/PqsYwAcYUJmfMWqtVU2MBMG5Mo+xFZrhGId5r/O5HOuMPyQEcM6KUBp5lBZZBg== @@ -4367,6 +4372,13 @@ agent-base@6: dependencies: debug "4" +agent-base@^7.0.2, agent-base@^7.1.0, agent-base@^7.1.1: + version "7.1.1" + resolved "https://registry.yarnpkg.com/agent-base/-/agent-base-7.1.1.tgz#bdbded7dfb096b751a2a087eeeb9664725b2e317" + integrity sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA== + dependencies: + debug "^4.3.4" + agentkeepalive@^3.4.1, agentkeepalive@^4.2.1, agentkeepalive@^4.5.0: version "4.5.0" resolved "https://registry.yarnpkg.com/agentkeepalive/-/agentkeepalive-4.5.0.tgz#2673ad1389b3c418c5a20c5d7364f93ca04be923" @@ -4815,6 +4827,13 @@ ast-types-flow@^0.0.7: resolved "https://registry.yarnpkg.com/ast-types-flow/-/ast-types-flow-0.0.7.tgz#f70b735c6bca1a5c9c22d982c3e39e7feba3bdad" integrity sha1-9wtzXGvKGlycItmCw+Oef+ujva0= +ast-types@^0.13.4: + version "0.13.4" + resolved "https://registry.yarnpkg.com/ast-types/-/ast-types-0.13.4.tgz#ee0d77b343263965ecc3fb62da16e7222b2b6782" + integrity sha512-x1FCFnFifvYDDzTaLII71vG5uvDwgtmDTEVWAxrgeiR8VjMONcCXJx7E+USjDtHlwFmt9MysbqgF9b9Vjr6w+w== + dependencies: + tslib "^2.0.1" + astral-regex@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/astral-regex/-/astral-regex-1.0.0.tgz#6c8c3fb827dd43ee3918f27b82782ab7658a6fd9" @@ -4927,7 +4946,7 @@ axe-core@^4.0.2, axe-core@^4.3.5: resolved "https://registry.yarnpkg.com/axe-core/-/axe-core-4.4.1.tgz#7dbdc25989298f9ad006645cd396782443757413" integrity sha512-gd1kmb21kwNuWr6BQz8fv6GNECPBnUasepcoLbekws23NVBLODdsClRZ+bQ8+9Uomf3Sm3+Vwn0oYG9NvwnJCw== -axios@^1.6.1, axios@^1.6.5: +axios@^1.6.1: version "1.7.7" resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== @@ -4936,6 +4955,15 @@ axios@^1.6.1, axios@^1.6.5: form-data "^4.0.0" proxy-from-env "^1.1.0" +axios@^1.7.4: + version "1.7.8" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.8.tgz#1997b1496b394c21953e68c14aaa51b7b5de3d6e" + integrity sha512-Uu0wb7KNqK2t5K+YQyVCLM76prD5sRFjKHbJYCP1J7JFGEQ6nN7HWn9+04LAeiJ3ji54lgS/gZCH1oxyrf1SPw== + dependencies: + follow-redirects "^1.15.6" + form-data "^4.0.0" + proxy-from-env "^1.1.0" + axobject-query@^2.2.0: version "2.2.0" resolved "https://registry.yarnpkg.com/axobject-query/-/axobject-query-2.2.0.tgz#943d47e10c0b704aa42275e20edf3722648989be" @@ -5123,6 +5151,11 @@ basic-auth@^2.0.1: dependencies: safe-buffer "5.1.2" +basic-ftp@^5.0.2: + version "5.0.5" + resolved "https://registry.yarnpkg.com/basic-ftp/-/basic-ftp-5.0.5.tgz#14a474f5fffecca1f4f406f1c26b18f800225ac0" + integrity sha512-4Bcg1P8xhUuqcii/S0Z9wiHIrQVPMermM1any+MX5GeGD7faD3/msQUDGLol9wOcz4/jbg/WJnGqoJF6LiBdtg== + batch-processor@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/batch-processor/-/batch-processor-1.0.0.tgz#75c95c32b748e0850d10c2b168f6bdbe9891ace8" @@ -5492,6 +5525,17 @@ call-bind@^1.0.0, call-bind@^1.0.2, call-bind@^1.0.4, call-bind@^1.0.5: get-intrinsic "^1.2.1" set-function-length "^1.1.1" +call-bind@^1.0.7: + version "1.0.7" + resolved "https://registry.yarnpkg.com/call-bind/-/call-bind-1.0.7.tgz#06016599c40c56498c18769d2730be242b6fa3b9" + integrity sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + set-function-length "^1.2.1" + callsites@^3.0.0: version "3.1.0" resolved "https://registry.yarnpkg.com/callsites/-/callsites-3.1.0.tgz#b3630abd8943432f54b3f0519238e33cd7df2f73" @@ -5710,16 +5754,16 @@ chrome-trace-event@^1.0.2: resolved "https://registry.yarnpkg.com/chrome-trace-event/-/chrome-trace-event-1.0.3.tgz#1015eced4741e15d06664a957dbbf50d041e26ac" integrity sha512-p3KULyQg4S7NIHixdwbGX+nFHkoBiA4YQmyWtjb8XngSKV124nJmRysgAeujbUVb15vh+RvFUfCPqU7rXk+hZg== -chromedriver@^121.0.1: - version "121.0.2" - resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-121.0.2.tgz#208909a61e9d510913107ea6faf34bcdd72cdced" - integrity sha512-58MUSCEE3oB3G3Y/Jo3URJ2Oa1VLHcVBufyYt7vNfGrABSJm7ienQLF9IQ8LPDlPVgLUXt2OBfggK3p2/SlEBg== +chromedriver@^131.0.1: + version "131.0.1" + resolved "https://registry.yarnpkg.com/chromedriver/-/chromedriver-131.0.1.tgz#bfbf47f6c2ad7a65c154ff47d321bd8c33b52a77" + integrity sha512-LHRh+oaNU1WowJjAkWsviN8pTzQYJDbv/FvJyrQ7XhjKdIzVh/s3GV1iU7IjMTsxIQnBsTjx+9jWjzCWIXC7ug== dependencies: "@testim/chrome-version" "^1.1.4" - axios "^1.6.5" + axios "^1.7.4" compare-versions "^6.1.0" extract-zip "^2.0.1" - https-proxy-agent "^5.0.1" + proxy-agent "^6.4.0" proxy-from-env "^1.1.0" tcp-port-used "^1.0.2" @@ -6017,11 +6061,16 @@ commander@^4.0.1: resolved "https://registry.yarnpkg.com/commander/-/commander-4.1.1.tgz#9fd602bd936294e9e9ef46a3f4d6964044b18068" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== -commander@^5.0.0, commander@^5.1.0: +commander@^5.0.0: version "5.1.0" resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae" integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg== +commander@^6.2.1: + version "6.2.1" + resolved "https://registry.yarnpkg.com/commander/-/commander-6.2.1.tgz#0792eb682dfbc325999bb2b84fddddba110ac73c" + integrity sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA== + comment-stripper@^0.0.4: version "0.0.4" resolved "https://registry.yarnpkg.com/comment-stripper/-/comment-stripper-0.0.4.tgz#e8d61366d362779ea225c764f05cca6c950f8a2c" @@ -6498,14 +6547,14 @@ cyclist@^1.0.1: resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9" integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk= -cypress@9.5.4: - version "9.5.4" - resolved "https://registry.yarnpkg.com/cypress/-/cypress-9.5.4.tgz#49d9272f62eba12f2314faf29c2a865610e87550" - integrity sha512-6AyJAD8phe7IMvOL4oBsI9puRNOWxZjl8z1lgixJMcgJ85JJmyKeP6uqNA0dI1z14lmJ7Qklf2MOgP/xdAqJ/Q== +cypress@12.17.4: + version "12.17.4" + resolved "https://registry.yarnpkg.com/cypress/-/cypress-12.17.4.tgz#b4dadf41673058493fa0d2362faa3da1f6ae2e6c" + integrity sha512-gAN8Pmns9MA5eCDFSDJXWKUpaL3IDd89N9TtIupjYnzLSmlpVr+ZR+vb4U/qaMp+lB6tBvAmt7504c3Z4RU5KQ== dependencies: - "@cypress/request" "^2.88.10" + "@cypress/request" "2.88.12" "@cypress/xvfb" "^1.2.4" - "@types/node" "^14.14.31" + "@types/node" "^16.18.39" "@types/sinonjs__fake-timers" "8.1.1" "@types/sizzle" "^2.3.2" arch "^2.2.0" @@ -6517,12 +6566,12 @@ cypress@9.5.4: check-more-types "^2.24.0" cli-cursor "^3.1.0" cli-table3 "~0.6.1" - commander "^5.1.0" + commander "^6.2.1" common-tags "^1.8.0" dayjs "^1.10.4" - debug "^4.3.2" + debug "^4.3.4" enquirer "^2.3.6" - eventemitter2 "^6.4.3" + eventemitter2 "6.4.7" execa "4.1.0" executable "^4.1.1" extract-zip "2.0.1" @@ -6535,12 +6584,13 @@ cypress@9.5.4: listr2 "^3.8.3" lodash "^4.17.21" log-symbols "^4.0.0" - minimist "^1.2.6" + minimist "^1.2.8" ospath "^1.2.2" pretty-bytes "^5.6.0" + process "^0.11.10" proxy-from-env "1.0.0" request-progress "^3.0.0" - semver "^7.3.2" + semver "^7.5.3" supports-color "^8.1.1" tmp "~0.2.1" untildify "^4.0.0" @@ -6769,6 +6819,11 @@ dashify@^0.1.0: resolved "https://registry.yarnpkg.com/dashify/-/dashify-0.1.0.tgz#107daf9cca5e326e30a8b39ffa5048b6684922ea" integrity sha1-EH2vnMpeMm4wqLOf+lBItmhJIuo= +data-uri-to-buffer@^6.0.2: + version "6.0.2" + resolved "https://registry.yarnpkg.com/data-uri-to-buffer/-/data-uri-to-buffer-6.0.2.tgz#8a58bb67384b261a38ef18bea1810cb01badd28b" + integrity sha512-7hvf7/GW8e86rW0ptuwS3OcBGDjIi6SZva7hCyWC0yYry2cOPmLIjXAUHI6DK2HsnwJd9ifmt57i8eV2n4YNpw== + data-urls@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/data-urls/-/data-urls-2.0.0.tgz#156485a72963a970f5d5821aaf642bef2bf2db9b" @@ -6953,6 +7008,15 @@ define-data-property@^1.0.1, define-data-property@^1.1.1: gopd "^1.0.1" has-property-descriptors "^1.0.0" +define-data-property@^1.1.4: + version "1.1.4" + resolved "https://registry.yarnpkg.com/define-data-property/-/define-data-property-1.1.4.tgz#894dc141bb7d3060ae4366f6a0107e68fbe48c5e" + integrity sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A== + dependencies: + es-define-property "^1.0.0" + es-errors "^1.3.0" + gopd "^1.0.1" + define-properties@^1.1.3, define-properties@^1.1.4, define-properties@^1.2.0: version "1.2.1" resolved "https://registry.yarnpkg.com/define-properties/-/define-properties-1.2.1.tgz#10781cc616eb951a80a034bafcaa7377f6af2b6c" @@ -6967,6 +7031,15 @@ defined@^1.0.0: resolved "https://registry.yarnpkg.com/defined/-/defined-1.0.0.tgz#c98d9bcef75674188e110969151199e39b1fa693" integrity sha1-yY2bzvdWdBiOEQlpFRGZ45sfppM= +degenerator@^5.0.0: + version "5.0.1" + resolved "https://registry.yarnpkg.com/degenerator/-/degenerator-5.0.1.tgz#9403bf297c6dad9a1ece409b37db27954f91f2f5" + integrity sha512-TllpMR/t0M5sqCXfj85i4XaAzxmS5tVA16dqvdkMwGmzI+dXLXnw3J+3Vdv7VKw+ThlTMboK6i9rnZ6Nntj5CQ== + dependencies: + ast-types "^0.13.4" + escodegen "^2.1.0" + esprima "^4.0.1" + del-cli@^3.0.1: version "3.0.1" resolved "https://registry.yarnpkg.com/del-cli/-/del-cli-3.0.1.tgz#2d27ff260204b5104cadeda86f78f180a4ebe89a" @@ -7673,6 +7746,18 @@ es-array-method-boxes-properly@^1.0.0: resolved "https://registry.yarnpkg.com/es-array-method-boxes-properly/-/es-array-method-boxes-properly-1.0.0.tgz#873f3e84418de4ee19c5be752990b2e44718d09e" integrity sha512-wd6JXUmyHmt8T5a2xreUwKcGPq6f1f+WwIJkijUqiGcJz1qqnZgP6XIK+QyIWU5lT7imeNxUll48bziG+TSYcA== +es-define-property@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/es-define-property/-/es-define-property-1.0.0.tgz#c7faefbdff8b2696cf5f46921edfb77cc4ba3845" + integrity sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ== + dependencies: + get-intrinsic "^1.2.4" + +es-errors@^1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/es-errors/-/es-errors-1.3.0.tgz#05f75a25dab98e4fb1dcd5e1472c0546d5057c8f" + integrity sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw== + es-get-iterator@^1.1.1: version "1.1.2" resolved "https://registry.yarnpkg.com/es-get-iterator/-/es-get-iterator-1.1.2.tgz#9234c54aba713486d7ebde0220864af5e2b283f7" @@ -7808,6 +7893,17 @@ escodegen@^2.0.0: optionalDependencies: source-map "~0.6.1" +escodegen@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/escodegen/-/escodegen-2.1.0.tgz#ba93bbb7a43986d29d6041f99f5262da773e2e17" + integrity sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w== + dependencies: + esprima "^4.0.1" + estraverse "^5.2.0" + esutils "^2.0.2" + optionalDependencies: + source-map "~0.6.1" + eslint-config-prettier@^6.11.0: version "6.15.0" resolved "https://registry.yarnpkg.com/eslint-config-prettier/-/eslint-config-prettier-6.15.0.tgz#7f93f6cb7d45a92f1537a70ecc06366e1ac6fed9" @@ -8197,10 +8293,10 @@ event-target-shim@^5.0.0: resolved "https://registry.yarnpkg.com/event-target-shim/-/event-target-shim-5.0.1.tgz#5d4d3ebdf9583d63a5333ce2deb7480ab2b05789" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== -eventemitter2@^6.4.3: - version "6.4.9" - resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.9.tgz#41f2750781b4230ed58827bc119d293471ecb125" - integrity sha512-JEPTiaOt9f04oa6NOkc4aH+nVp5I3wEjpHbIPqfgCdD5v5bUzy7xQqwcVO2aDQgOWhI28da57HksMrzK9HlRxg== +eventemitter2@6.4.7: + version "6.4.7" + resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.7.tgz#a7f6c4d7abf28a14c1ef3442f21cb306a054271d" + integrity sha512-tYUSVOGeQPKt/eC1ABfhHy5Xd96N3oIijJvN3O9+TsC28T5V9yX9oEfEK5faP0EFSNVOG97qtAS68GBrQB2hDg== eventemitter2@~0.4.13: version "0.4.14" @@ -8822,6 +8918,15 @@ fs-constants@^1.0.0: resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== +fs-extra@^11.2.0: + version "11.2.0" + resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-11.2.0.tgz#e70e17dfad64232287d01929399e0ea7c86b0e5b" + integrity sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + fs-extra@^9.1.0: version "9.1.0" resolved "https://registry.yarnpkg.com/fs-extra/-/fs-extra-9.1.0.tgz#5954460c764a8da2094ba3554bf839e6b9a7c86d" @@ -8937,6 +9042,17 @@ get-intrinsic@^1.0.1, get-intrinsic@^1.0.2, get-intrinsic@^1.1.0, get-intrinsic@ has-symbols "^1.0.3" hasown "^2.0.0" +get-intrinsic@^1.2.4: + version "1.2.4" + resolved "https://registry.yarnpkg.com/get-intrinsic/-/get-intrinsic-1.2.4.tgz#e385f5a4b5227d449c3eabbad05494ef0abbeadd" + integrity sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ== + dependencies: + es-errors "^1.3.0" + function-bind "^1.1.2" + has-proto "^1.0.1" + has-symbols "^1.0.3" + hasown "^2.0.0" + get-nonce@^1.0.0: version "1.0.1" resolved "https://registry.yarnpkg.com/get-nonce/-/get-nonce-1.0.1.tgz#fdf3f0278073820d2ce9426c18f07481b1e0cdf3" @@ -8977,6 +9093,16 @@ get-symbol-description@^1.0.0: call-bind "^1.0.2" get-intrinsic "^1.1.1" +get-uri@^6.0.1: + version "6.0.3" + resolved "https://registry.yarnpkg.com/get-uri/-/get-uri-6.0.3.tgz#0d26697bc13cf91092e519aa63aa60ee5b6f385a" + integrity sha512-BzUrJBS9EcUb4cFol8r4W3v1cPsSyajLSthNkz5BxbpDcHN5tIrM10E2eNvfnvBn3DaT3DUgx0OpsBKkaOpanw== + dependencies: + basic-ftp "^5.0.2" + data-uri-to-buffer "^6.0.2" + debug "^4.3.4" + fs-extra "^11.2.0" + get-value@^3.0.0: version "3.0.1" resolved "https://registry.yarnpkg.com/get-value/-/get-value-3.0.1.tgz#5efd2a157f1d6a516d7524e124ac52d0a39ef5a8" @@ -9479,6 +9605,13 @@ has-property-descriptors@^1.0.0: dependencies: get-intrinsic "^1.1.1" +has-property-descriptors@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz#963ed7d071dc7bf5f084c5bfbe0d1b6222586854" + integrity sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg== + dependencies: + es-define-property "^1.0.0" + has-proto@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/has-proto/-/has-proto-1.0.1.tgz#1885c1305538958aff469fef37937c22795408e0" @@ -9803,6 +9936,14 @@ http-proxy-agent@^4.0.1: agent-base "6" debug "4" +http-proxy-agent@^7.0.0, http-proxy-agent@^7.0.1: + version "7.0.2" + resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz#9a8b1f246866c028509486585f62b8f2c18c270e" + integrity sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig== + dependencies: + agent-base "^7.1.0" + debug "^4.3.4" + http-signature@~1.3.6: version "1.3.6" resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.3.6.tgz#cb6fbfdf86d1c974f343be94e87f7fc128662cf9" @@ -9825,7 +9966,7 @@ https-browserify@^1.0.0: resolved "https://registry.yarnpkg.com/https-browserify/-/https-browserify-1.0.0.tgz#ec06c10e0a34c0f2faf199f7fd7fc78fffd03c73" integrity sha1-7AbBDgo0wPL68Zn3/X/Hj//QPHM= -https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: +https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0: version "5.0.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz#c59ef224a04fe8b754f3db0063a25ea30d0005d6" integrity sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA== @@ -9833,6 +9974,14 @@ https-proxy-agent@5.0.1, https-proxy-agent@^5.0.0, https-proxy-agent@^5.0.1: agent-base "6" debug "4" +https-proxy-agent@^7.0.3, https-proxy-agent@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-7.0.5.tgz#9e8b5013873299e11fab6fd548405da2d6c602b2" + integrity sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw== + dependencies: + agent-base "^7.0.2" + debug "4" + human-signals@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-1.1.1.tgz#c5b1cd14f50aeae09ab6c59fe63ba3395fe4dfa3" @@ -10128,6 +10277,14 @@ ip-address@^6.3.0: lodash.repeat "4.1.0" sprintf-js "1.1.2" +ip-address@^9.0.5: + version "9.0.5" + resolved "https://registry.yarnpkg.com/ip-address/-/ip-address-9.0.5.tgz#117a960819b08780c3bd1f14ef3c1cc1d3f3ea5a" + integrity sha512-zHtQzGojZXTwZTHQqra+ETKd4Sn3vgi7uBmlPoXVWZqYvuKmtI0l/VZTjqGmJY9x88GGOaZ9+G9ES8hC4T4X8g== + dependencies: + jsbn "1.1.0" + sprintf-js "^1.1.3" + ip-cidr@^2.1.0: version "2.1.5" resolved "https://registry.yarnpkg.com/ip-cidr/-/ip-cidr-2.1.5.tgz#67fd02ee001d6ac0f253a1d577e4170a8f7d480b" @@ -11393,7 +11550,7 @@ json-stringify-safe@5.0.1, json-stringify-safe@^5.0.1, json-stringify-safe@~5.0. resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" integrity sha1-Epai1Y/UXxmg9s4B1lcB4sc1tus= -json11@^1.0.4, json11@^2.0.0: +json11@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/json11/-/json11-2.0.0.tgz#06c4ad0a40b50c5de99a87f6d3028593137e5641" integrity sha512-VuKJKUSPEJape+daTm70Nx7vdcdorf4S6LCyN2z0jUVH4UrQ4ftXo2kC0bnHpCREmxHuHqCNVPA75BjI3CB6Ag== @@ -12022,6 +12179,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lru-cache@^7.14.1: + version "7.18.3" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-7.18.3.tgz#f793896e0fd0e954a59dfdd82f0773808df6aa89" + integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== + lru-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/lru-queue/-/lru-queue-0.1.0.tgz#2738bd9f0d3cf4f84490c5736c48699ac632cda3" @@ -12418,6 +12580,11 @@ minimist@^1.1.1, minimist@^1.2.0, minimist@^1.2.5, minimist@^1.2.6: resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.6.tgz#8637a5b759ea0d6e98702cfb3a9283323c93af44" integrity sha512-Jsjnk4bw3YJqYzbdyBiNsPWHPfO++UGG749Cxs6peCu5Xg4nrena6OVxOYxrQTqww0Jmwt+Ref8rggumkTLz9Q== +minimist@^1.2.8: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + minipass-collect@^1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/minipass-collect/-/minipass-collect-1.0.2.tgz#22b813bf745dc6edba2576b940022ad6edc8c617" @@ -12724,6 +12891,11 @@ nested-error-stacks@^2.0.0, nested-error-stacks@^2.1.0: resolved "https://registry.yarnpkg.com/nested-error-stacks/-/nested-error-stacks-2.1.1.tgz#26c8a3cee6cc05fbcf1e333cd2fc3e003326c0b5" integrity sha512-9iN1ka/9zmX1ZvLV9ewJYEk9h7RyRRtqdK0woXcqohu8EWIerfPUjYJPg0ULy0UqP7cslmdGc8xKDJcojlKiaw== +netmask@^2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/netmask/-/netmask-2.0.2.tgz#8b01a07644065d536383835823bc52004ebac5e7" + integrity sha512-dBpDMdxv9Irdq66304OLfEmQ9tbNRFnFTuZiLo+bD+r332bBmMJ8GBLXklIXXgxd3+v9+KUnZaUR5PJMa75Gsg== + newtype-ts@^0.2.4: version "0.2.4" resolved "https://registry.yarnpkg.com/newtype-ts/-/newtype-ts-0.2.4.tgz#a02a8f160a3d179f871848d687a93de73a964a41" @@ -13338,6 +13510,28 @@ p-try@^2.0.0: resolved "https://registry.yarnpkg.com/p-try/-/p-try-2.2.0.tgz#cb2868540e313d61de58fafbe35ce9004d5540e6" integrity sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ== +pac-proxy-agent@^7.0.1: + version "7.0.2" + resolved "https://registry.yarnpkg.com/pac-proxy-agent/-/pac-proxy-agent-7.0.2.tgz#0fb02496bd9fb8ae7eb11cfd98386daaac442f58" + integrity sha512-BFi3vZnO9X5Qt6NRz7ZOaPja3ic0PhlsmCRYLOpN11+mWBCR6XJDqW5RF3j8jm4WGGQZtBA+bTfxYzeKW73eHg== + dependencies: + "@tootallnate/quickjs-emscripten" "^0.23.0" + agent-base "^7.0.2" + debug "^4.3.4" + get-uri "^6.0.1" + http-proxy-agent "^7.0.0" + https-proxy-agent "^7.0.5" + pac-resolver "^7.0.1" + socks-proxy-agent "^8.0.4" + +pac-resolver@^7.0.1: + version "7.0.1" + resolved "https://registry.yarnpkg.com/pac-resolver/-/pac-resolver-7.0.1.tgz#54675558ea368b64d210fd9c92a640b5f3b8abb6" + integrity sha512-5NPgf87AT2STgwa2ntRMr45jTKrYBGkVU36yT0ig/n/GMAa3oPqhZfIQ2kMEimReg0+t9kZViDVZ83qfVUlckg== + dependencies: + degenerator "^5.0.0" + netmask "^2.0.2" + package-hash@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/package-hash/-/package-hash-4.0.0.tgz#3537f654665ec3cc38827387fc904c163c54f506" @@ -13938,6 +14132,20 @@ property-information@^5.0.0, property-information@^5.3.0: dependencies: xtend "^4.0.0" +proxy-agent@^6.4.0: + version "6.4.0" + resolved "https://registry.yarnpkg.com/proxy-agent/-/proxy-agent-6.4.0.tgz#b4e2dd51dee2b377748aef8d45604c2d7608652d" + integrity sha512-u0piLU+nCOHMgGjRbimiXmA9kM/L9EHh3zL81xCdp7m+Y2pHIsnmbdDoEDoAz5geaonNR6q6+yOPQs6n4T6sBQ== + dependencies: + agent-base "^7.0.2" + debug "^4.3.4" + http-proxy-agent "^7.0.1" + https-proxy-agent "^7.0.3" + lru-cache "^7.14.1" + pac-proxy-agent "^7.0.1" + proxy-from-env "^1.1.0" + socks-proxy-agent "^8.0.2" + proxy-from-env@1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-1.0.0.tgz#33c50398f70ea7eb96d21f7b817630a55791c7ee" @@ -14000,20 +14208,15 @@ pumpify@^1.3.3, pumpify@^1.3.5: inherits "^2.0.3" pump "^2.0.0" -punycode@1.3.2: - version "1.3.2" - resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.3.2.tgz#9653a036fb7c1ee42342f2325cceefea3926c48d" - integrity sha1-llOgNvt8HuQjQvIyXM7v6jkmxI0= - punycode@2.x.x, punycode@^2.1.0, punycode@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" integrity sha512-XRsRjdf+j5ml+y/6GKHPZbrF/8p2Yga0JPtdqTIY2Xe5ohJPD9saDJJLPvp9+NSBprVvevdXZybnj2cv8OEd0A== -punycode@^1.2.4: +punycode@^1.2.4, punycode@^1.4.1: version "1.4.1" resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" - integrity sha1-wNWmOycYgArY4esPpSachN1BhF4= + integrity sha512-jmYNElW7yvO7TV33CjSmvSiE2yco3bV2czu/OzDKdMNVZQWfxCblURLhf+47syQRBntjfLdd/H0egrzIG+oaFQ== qs@^6.11.0: version "6.11.0" @@ -14022,6 +14225,13 @@ qs@^6.11.0: dependencies: side-channel "^1.0.4" +qs@^6.12.3: + version "6.13.0" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.13.0.tgz#6ca3bd58439f7e245655798997787b0d88a51906" + integrity sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg== + dependencies: + side-channel "^1.0.6" + qs@~6.10.3: version "6.10.5" resolved "https://registry.yarnpkg.com/qs/-/qs-6.10.5.tgz#974715920a80ff6a262264acd2c7e6c2a53282b4" @@ -15403,6 +15613,18 @@ set-function-length@^1.1.1: gopd "^1.0.1" has-property-descriptors "^1.0.0" +set-function-length@^1.2.1: + version "1.2.2" + resolved "https://registry.yarnpkg.com/set-function-length/-/set-function-length-1.2.2.tgz#aac72314198eaed975cf77b2c3b6b880695e5449" + integrity sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg== + dependencies: + define-data-property "^1.1.4" + es-errors "^1.3.0" + function-bind "^1.1.2" + get-intrinsic "^1.2.4" + gopd "^1.0.1" + has-property-descriptors "^1.0.2" + set-function-name@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/set-function-name/-/set-function-name-2.0.1.tgz#12ce38b7954310b9f61faa12701620a0c882793a" @@ -15490,6 +15712,16 @@ side-channel@^1.0.3, side-channel@^1.0.4: get-intrinsic "^1.0.2" object-inspect "^1.9.0" +side-channel@^1.0.6: + version "1.0.6" + resolved "https://registry.yarnpkg.com/side-channel/-/side-channel-1.0.6.tgz#abd25fb7cd24baf45466406b1096b7831c9215f2" + integrity sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA== + dependencies: + call-bind "^1.0.7" + es-errors "^1.3.0" + get-intrinsic "^1.2.4" + object-inspect "^1.13.1" + signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: version "3.0.7" resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.7.tgz#a9a1767f8af84155114eaabd73f99273c8f59ad9" @@ -15581,6 +15813,28 @@ slide@~1.1.3: resolved "https://registry.yarnpkg.com/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc= +smart-buffer@^4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/smart-buffer/-/smart-buffer-4.2.0.tgz#6e1d71fa4f18c05f7d0ff216dd16a481d0e8d9ae" + integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== + +socks-proxy-agent@^8.0.2, socks-proxy-agent@^8.0.4: + version "8.0.4" + resolved "https://registry.yarnpkg.com/socks-proxy-agent/-/socks-proxy-agent-8.0.4.tgz#9071dca17af95f483300316f4b063578fa0db08c" + integrity sha512-GNAq/eg8Udq2x0eNiFkr9gRg5bA7PXEWagQdeRX4cPSG+X/8V38v637gim9bjFptMk1QWsCTr0ttrJEiXbNnRw== + dependencies: + agent-base "^7.1.1" + debug "^4.3.4" + socks "^2.8.3" + +socks@^2.8.3: + version "2.8.3" + resolved "https://registry.yarnpkg.com/socks/-/socks-2.8.3.tgz#1ebd0f09c52ba95a09750afe3f3f9f724a800cb5" + integrity sha512-l5x7VUUWbjVFbafGLxPWkYsHIhEvmF85tbIeFZWc8ZPtoMyybuEhL7Jye/ooC4/d48FgOjSJXgsF/AJPYCW8Zw== + dependencies: + ip-address "^9.0.5" + smart-buffer "^4.2.0" + sonic-boom@^1.0.2: version "1.4.1" resolved "https://registry.yarnpkg.com/sonic-boom/-/sonic-boom-1.4.1.tgz#d35d6a74076624f12e6f917ade7b9d75e918f53e" @@ -15793,6 +16047,11 @@ sprintf-js@1.1.2, sprintf-js@^1.1.1: resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.2.tgz#da1765262bf8c0f571749f2ad6c26300207ae673" integrity sha512-VE0SOVEHCk7Qc8ulkWw3ntAzXuqf7S2lvwQaDLRnUeIEaKNQJzV6BwmLKhOqT61aGhfUMrXeaBk+oDGCzvhcug== +sprintf-js@^1.1.3: + version "1.1.3" + resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.1.3.tgz#4914b903a2f8b685d17fdf78a70e917e872e444a" + integrity sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA== + sprintf-js@~1.0.2: version "1.0.3" resolved "https://registry.yarnpkg.com/sprintf-js/-/sprintf-js-1.0.3.tgz#04e6926f662895354f3dd015203633b857297e2c" @@ -16899,6 +17158,11 @@ tslib@^2.0.0, tslib@^2.0.3, tslib@^2.1.0, tslib@^2.2.0, tslib@^2.3.0, tslib@^2.3 resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.6.2.tgz#703ac29425e7b37cd6fd456e92404d46d1f3e4ae" integrity sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q== +tslib@^2.0.1: + version "2.8.1" + resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.8.1.tgz#612efe4ed235d567e8aba5f2a5fab70280ade83f" + integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== + tslib@~2.4.0: version "2.4.1" resolved "https://registry.yarnpkg.com/tslib/-/tslib-2.4.1.tgz#0d0bfbaac2880b91e22df0768e55be9753a5b17e" @@ -17350,21 +17614,13 @@ url-parse@^1.5.10, url-parse@^1.5.3: querystringify "^2.1.1" requires-port "^1.0.0" -url@0.10.3: - version "0.10.3" - resolved "https://registry.yarnpkg.com/url/-/url-0.10.3.tgz#021e4d9c7705f21bbf37d03ceb58767402774c64" - integrity sha512-hzSUW2q06EqL1gKM/a+obYHLIO6ct2hwPuviqTTOcfFVc61UbfJ2Q32+uGL/HCPxKqrdGB5QUwIe7UqlDgwsOQ== - dependencies: - punycode "1.3.2" - querystring "0.2.0" - -url@^0.11.0: - version "0.11.0" - resolved "https://registry.yarnpkg.com/url/-/url-0.11.0.tgz#3838e97cfc60521eb73c525a8e55bfdd9e2e28f1" - integrity sha1-ODjpfPxgUh63PFJajlW/3Z4uKPE= +url@0.10.3, url@^0.11.0, url@^0.11.4: + version "0.11.4" + resolved "https://registry.yarnpkg.com/url/-/url-0.11.4.tgz#adca77b3562d56b72746e76b330b7f27b6721f3c" + integrity sha512-oCwdVC7mTuWiPyjLUz/COz5TLk6wgp0RCsN+wHZ2Ekneac9w8uuV0njcbbie2ME+Vs+d6duwmYuR3HgQXs1fOg== dependencies: - punycode "1.3.2" - querystring "0.2.0" + punycode "^1.4.1" + qs "^6.12.3" use-callback-ref@^1.2.3, use-callback-ref@^1.2.5: version "1.2.5"