diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index 49cdbe165961..40c335dcca9c 100644 --- a/.github/workflows/build_and_test_workflow.yml +++ b/.github/workflows/build_and_test_workflow.yml @@ -282,7 +282,7 @@ jobs: JOB: ci${{ matrix.group }} CACHE_DIR: ciGroup${{ matrix.group }} - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: failure() with: name: failure-artifacts-ci${{ matrix.group }} @@ -393,7 +393,7 @@ jobs: id: plugin-ftr-tests run: node scripts/functional_tests.js --config test/plugin_functional/config.ts - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: failure() with: name: failure-artifacts-plugin-functional-${{ matrix.os }} @@ -506,7 +506,7 @@ jobs: - name: Build `${{ matrix.name }}` run: yarn ${{ matrix.script }} --release - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: success() with: name: ${{ matrix.suffix }}-${{ env.VERSION }} @@ -595,7 +595,7 @@ jobs: run: | ./bwctest.sh -s false -o ${{ env.OPENSEARCH_URL }} -d ${{ steps.download.outputs.download-path }}/opensearch-dashboards-${{ env.VERSION }}-linux-x64.tar.gz - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: ${{ failure() && steps.verify-opensearch-exists.outputs.version-exists == 'true' }} with: name: ${{ matrix.version }}-test-failures diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index 3d3b0b79b027..c15edeac5e35 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -265,50 +265,50 @@ jobs: # Screenshots are only captured on failure, will change this once we do visual regression tests - name: Upload FT repo screenshots - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: failure() && matrix.test_location == 'ftr' with: - name: ftr-cypress-screenshots + name: ftr-cypress-screenshots-${{ matrix.group }} path: ${{ env.FTR_PATH }}/cypress/screenshots retention-days: 1 - name: Upload FT repo videos - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() && matrix.test_location == 'ftr' with: - name: ftr-cypress-videos + name: ftr-cypress-videos-${{ matrix.group }} path: ${{ env.FTR_PATH }}/cypress/videos retention-days: 1 - name: Upload FT repo results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() && matrix.test_location == 'ftr' with: - name: ftr-cypress-results + name: ftr-cypress-results-${{ matrix.group }} path: ${{ env.FTR_PATH }}/cypress/results retention-days: 1 - name: Upload Dashboards screenshots if: failure() && matrix.test_location == 'source' - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: - name: dashboards-cypress-screenshots + name: dashboards-cypress-screenshots-${{ matrix.group }} path: cypress/screenshots retention-days: 1 - name: Upload Dashboards repo videos - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() && matrix.test_location == 'source' with: - name: dashboards-cypress-videos + name: dashboards-cypress-videos-${{ matrix.group }} path: cypress/videos retention-days: 1 - name: Upload Dashboards repo results - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 if: always() && matrix.test_location == 'source' with: - name: dashboards-cypress-results + name: dashboards-cypress-results-${{ matrix.group }} path: cypress/results retention-days: 1 @@ -346,6 +346,6 @@ jobs: '${{ env.SPEC }}' ``` - #### Link to results: + #### Link to results: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} edit-mode: replace diff --git a/.github/workflows/release_cypress_workflow.yml b/.github/workflows/release_cypress_workflow.yml index f58757b23f9f..bb9895d9f048 100644 --- a/.github/workflows/release_cypress_workflow.yml +++ b/.github/workflows/release_cypress_workflow.yml @@ -72,7 +72,7 @@ jobs: CI: 1 # avoid warnings like "tput: No value for $TERM and no -T specified" TERM: xterm - name: Run cypress tests (osd:ciGroup${{ matrix.spec_group }}) ${{ inputs.UNIQUE_ID}} + name: Run cypress tests (osd:ciGroup${{ matrix.spec_group }}) ${{ inputs.UNIQUE_ID}} steps: - name: Checkout code uses: actions/checkout@v2 @@ -130,7 +130,7 @@ jobs: mkdir -p $CWD/${{ env.OPENSEARCH_DIR }} source ${{ env.OSD_PATH }}/scripts/common/utils.sh open_artifact $CWD/${{ env.OPENSEARCH_DIR }} ${{ env.OPENSEARCH }} - + - name: Download and extract OpenSearch Dashboards artifacts run: | CWD=$(pwd) @@ -138,22 +138,22 @@ jobs: source ${{ env.OSD_PATH }}/scripts/common/utils.sh open_artifact $CWD/${{ env.DASHBOARDS_DIR }} ${{ env.DASHBOARDS }} - - name: Run Cypress tests + - name: Run Cypress tests run: | chown -R 1000:1000 `pwd` su `id -un 1000` -c "source ${{ env.OSD_PATH }}/scripts/cypress_tests.sh && run_dashboards_cypress_tests" # Screenshots are only captured on failures - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 if: failure() with: - name: release-osd-cypress-screenshots + name: release-osd-cypress-screenshots-${{ matrix.spec_group }} path: ${{ env.OSD_PATH }}/cypress/screenshots retention-days: 1 - - - uses: actions/upload-artifact@v3 + + - uses: actions/upload-artifact@v4 if: always() with: - name: release-osd-cypress-videos + name: release-osd-cypress-videos-${{ matrix.spec_group }} path: ${{ env.OSD_PATH }}/cypress/videos retention-days: 1 diff --git a/changelogs/fragments/8839.yml b/changelogs/fragments/8839.yml new file mode 100644 index 000000000000..27477e376254 --- /dev/null +++ b/changelogs/fragments/8839.yml @@ -0,0 +1,2 @@ +fix: +- Fix a typo while inspecting values for large numerals in OSD and the JS client ([#8839](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8839)) \ No newline at end of file diff --git a/changelogs/fragments/8848.yml b/changelogs/fragments/8848.yml new file mode 100644 index 000000000000..f8cc51214cf5 --- /dev/null +++ b/changelogs/fragments/8848.yml @@ -0,0 +1,2 @@ +fix: +- Fix template queries loading and update getSampleQuery interface ([#8848](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8848)) \ No newline at end of file diff --git a/changelogs/fragments/8851.yml b/changelogs/fragments/8851.yml new file mode 100644 index 000000000000..b006f3eee529 --- /dev/null +++ b/changelogs/fragments/8851.yml @@ -0,0 +1,3 @@ +feat: +- Add framework to show banner at the top in discover results canvas ([#8851](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8851)) +- Show indexed views in dataset selector ([#8851](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8851)) \ No newline at end of file diff --git a/changelogs/fragments/8855.yml b/changelogs/fragments/8855.yml new file mode 100644 index 000000000000..ad9835ebe292 --- /dev/null +++ b/changelogs/fragments/8855.yml @@ -0,0 +1,2 @@ +fix: +- Upgrade actions/upload-artifact to v4 ([#8855](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8855)) \ No newline at end of file diff --git a/changelogs/fragments/8857.yml b/changelogs/fragments/8857.yml new file mode 100644 index 000000000000..45d5fa5c5213 --- /dev/null +++ b/changelogs/fragments/8857.yml @@ -0,0 +1,2 @@ +fix: +- [Workspace][Bug] Fix inspect page url error. ([#8857](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8857)) \ No newline at end of file diff --git a/changelogs/fragments/8863.yml b/changelogs/fragments/8863.yml new file mode 100644 index 000000000000..51dc8d37cc2f --- /dev/null +++ b/changelogs/fragments/8863.yml @@ -0,0 +1,2 @@ +fix: +- Keep previous query result if current query result in error ([#8863](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8863)) \ No newline at end of file diff --git a/changelogs/fragments/8866.yml b/changelogs/fragments/8866.yml new file mode 100644 index 000000000000..9d328bf54e5b --- /dev/null +++ b/changelogs/fragments/8866.yml @@ -0,0 +1,2 @@ +fix: +- Hide Date Picker for Unsupported Types ([#8866](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8866)) \ No newline at end of file diff --git a/changelogs/fragments/8867.yml b/changelogs/fragments/8867.yml new file mode 100644 index 000000000000..384f388393c4 --- /dev/null +++ b/changelogs/fragments/8867.yml @@ -0,0 +1,2 @@ +fix: +- Add max height and scroll to error message body ([#8867](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8867)) \ No newline at end of file diff --git a/changelogs/fragments/8871.yml b/changelogs/fragments/8871.yml new file mode 100644 index 000000000000..032a928fd5c0 --- /dev/null +++ b/changelogs/fragments/8871.yml @@ -0,0 +1,2 @@ +fix: +- Search on page load out of sync state when clicking submit. ([#8871](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8871)) \ No newline at end of file diff --git a/changelogs/fragments/8882.yml b/changelogs/fragments/8882.yml new file mode 100644 index 000000000000..d6fe67ac7888 --- /dev/null +++ b/changelogs/fragments/8882.yml @@ -0,0 +1,2 @@ +security: +- [CVE-2024-21538] Bump `cross-spawn` from 6.0.5 and 7.0.3 to 7.0.5 ([#8882](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8882)) \ No newline at end of file diff --git a/changelogs/fragments/8883.yml b/changelogs/fragments/8883.yml new file mode 100644 index 000000000000..d9254d81c3cf --- /dev/null +++ b/changelogs/fragments/8883.yml @@ -0,0 +1,2 @@ +fix: +- Retain currently selected dataset when opening saved query without dataset info ([#8883](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8883)) \ No newline at end of file diff --git a/changelogs/fragments/8885.yml b/changelogs/fragments/8885.yml new file mode 100644 index 000000000000..5ba3f06558ca --- /dev/null +++ b/changelogs/fragments/8885.yml @@ -0,0 +1,2 @@ +doc: +- Fix OpenAPI documentation ([#8885](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8885)) \ No newline at end of file diff --git a/changelogs/fragments/8896.yml b/changelogs/fragments/8896.yml new file mode 100644 index 000000000000..a1a03c05f257 --- /dev/null +++ b/changelogs/fragments/8896.yml @@ -0,0 +1,2 @@ +feat: +- Added framework to get default query string using dataset and language combination ([#8896](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8896)) \ No newline at end of file diff --git a/changelogs/fragments/8899.yml b/changelogs/fragments/8899.yml new file mode 100644 index 000000000000..11030aecb552 --- /dev/null +++ b/changelogs/fragments/8899.yml @@ -0,0 +1,2 @@ +fix: +- Only support copy action for query templates ([#8899](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8899)) \ No newline at end of file diff --git a/changelogs/fragments/8902.yml b/changelogs/fragments/8902.yml new file mode 100644 index 000000000000..d4658d0296a7 --- /dev/null +++ b/changelogs/fragments/8902.yml @@ -0,0 +1,2 @@ +fix: +- Removed extra parameter ([#8902](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/8902)) \ No newline at end of file diff --git a/docs/openapi/saved_objects/saved_objects.yml b/docs/openapi/saved_objects/saved_objects.yml index bd1877545dc3..f54faa757072 100644 --- a/docs/openapi/saved_objects/saved_objects.yml +++ b/docs/openapi/saved_objects/saved_objects.yml @@ -423,7 +423,7 @@ paths: schema: type: object /api/saved_objects/_bulk_update: - post: + put: tags: - saved objects summary: Bulk update saved objects @@ -489,7 +489,7 @@ paths: schema: type: object /api/saved_objects/_bulk_get: - get: + post: tags: - saved objects summary: Bulk get saved objects diff --git a/package.json b/package.json index 7c8b3ec5cfa8..5bd2a4a5d09f 100644 --- a/package.json +++ b/package.json @@ -102,6 +102,7 @@ "**/cpy/globby": "^10.0.1", "**/d3-color": "^3.1.0", "**/elasticsearch/agentkeepalive": "^4.5.0", + "**/eslint/cross-spawn": "^7.0.5", "**/es5-ext": "^0.10.63", "**/fetch-mock/path-to-regexp": "^3.3.0", "**/follow-redirects": "^1.15.4", diff --git a/packages/osd-std/src/json.test.ts b/packages/osd-std/src/json.test.ts index 33abd71d91d2..0d4b900e0ca5 100644 --- a/packages/osd-std/src/json.test.ts +++ b/packages/osd-std/src/json.test.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import JSON11 from 'json11'; import { stringify, parse } from './json'; describe('json', () => { @@ -90,9 +91,55 @@ describe('json', () => { expect(stringify(input, replacer, 2)).toEqual(JSON.stringify(input, replacer, 2)); }); - it('can handle long numerals while parsing', () => { - const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; - const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + it('can handle positive long numerals while parsing', () => { + const longPositiveA = BigInt(Number.MAX_SAFE_INTEGER) * 2n; + const longPositiveB = BigInt(Number.MAX_SAFE_INTEGER) * 2n + 1n; + const text = + `{` + + // The space before and after the values, and the lack of spaces before comma are intentional + `"\\":${longPositiveA}": "[ ${longPositiveB.toString()}, ${longPositiveA.toString()} ]", ` + + `"positive": ${longPositiveA.toString()}, ` + + `"array": [ ${longPositiveB.toString()}, ${longPositiveA.toString()} ], ` + + `"negative": ${longPositiveB.toString()},` + + `"number": 102931203123987` + + `}`; + + const result = parse(text); + expect(result.positive).toBe(longPositiveA); + expect(result.negative).toBe(longPositiveB); + expect(result.array).toEqual([longPositiveB, longPositiveA]); + expect(result['":' + longPositiveA]).toBe( + `[ ${longPositiveB.toString()}, ${longPositiveA.toString()} ]` + ); + expect(result.number).toBe(102931203123987); + }); + + it('can handle negative long numerals while parsing', () => { + const longNegativeA = BigInt(Number.MIN_SAFE_INTEGER) * 2n; + const longNegativeB = BigInt(Number.MIN_SAFE_INTEGER) * 2n - 1n; + const text = + `{` + + // The space before and after the values, and the lack of spaces before comma are intentional + `"\\":${longNegativeA}": "[ ${longNegativeB.toString()}, ${longNegativeA.toString()} ]", ` + + `"positive": ${longNegativeA.toString()}, ` + + `"array": [ ${longNegativeB.toString()}, ${longNegativeA.toString()} ], ` + + `"negative": ${longNegativeB.toString()},` + + `"number": 102931203123987` + + `}`; + + const result = parse(text); + expect(result.positive).toBe(longNegativeA); + expect(result.negative).toBe(longNegativeB); + expect(result.array).toEqual([longNegativeB, longNegativeA]); + expect(result['":' + longNegativeA]).toBe( + `[ ${longNegativeB.toString()}, ${longNegativeA.toString()} ]` + ); + expect(result.number).toBe(102931203123987); + }); + + it('can handle mixed long numerals while parsing', () => { + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n + 1n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n - 1n; const text = `{` + // The space before and after the values, and the lack of spaces before comma are intentional @@ -113,6 +160,37 @@ describe('json', () => { expect(result.number).toBe(102931203123987); }); + it('does not use JSON11 when not needed', () => { + const spyParse = jest.spyOn(JSON11, 'parse'); + + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n + 1n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n - 1n; + const text = + `{` + + `"\\":${longPositive}": "[ ${longNegative.toString()}, ${longPositive.toString()} ]", ` + + `"number": 102931203123987` + + `}`; + parse(text); + + expect(spyParse).not.toHaveBeenCalled(); + }); + + it('uses JSON11 when dealing with long numerals', () => { + const spyParse = jest.spyOn(JSON11, 'parse'); + + const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n + 1n; + const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n - 1n; + const text = + `{` + + `"\\":${longPositive}": "[ ${longNegative.toString()}, ${longPositive.toString()} ]", ` + + `"positive": ${longPositive.toString()}, ` + + `"number": 102931203123987` + + `}`; + parse(text); + + expect(spyParse).toHaveBeenCalled(); + }); + it('can handle BigInt values while stringifying', () => { const longPositive = BigInt(Number.MAX_SAFE_INTEGER) * 2n; const longNegative = BigInt(Number.MIN_SAFE_INTEGER) * 2n; diff --git a/packages/osd-std/src/json.ts b/packages/osd-std/src/json.ts index 4dcd3eb03e65..79a148f625f7 100644 --- a/packages/osd-std/src/json.ts +++ b/packages/osd-std/src/json.ts @@ -69,7 +69,7 @@ export const parse = ( numeralsAreNumbers && typeof val === 'number' && isFinite(val) && - (val < Number.MAX_SAFE_INTEGER || val > Number.MAX_SAFE_INTEGER) + (val < Number.MIN_SAFE_INTEGER || val > Number.MAX_SAFE_INTEGER) ) { numeralsAreNumbers = false; } diff --git a/scripts/postinstall.js b/scripts/postinstall.js index 7865473ee494..59be50284dca 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -84,6 +84,15 @@ 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/dev/generate_release_note.ts b/src/dev/generate_release_note.ts index 1c85995f814b..5cfa4503537d 100644 --- a/src/dev/generate_release_note.ts +++ b/src/dev/generate_release_note.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { ToolingLog } from '@osd/dev-utils'; import { join, resolve } from 'path'; import { readFileSync, writeFileSync, Dirent, rm, rename, promises as fsPromises } from 'fs'; import { load as loadYaml } from 'js-yaml'; @@ -19,6 +20,11 @@ import { filePath, } from './generate_release_note_helper'; +const log = new ToolingLog({ + level: 'info', + writeTo: process.stdout, +}); + // Function to add content after the 'Unreleased' section in the changelog function addContentAfterUnreleased(path: string, newContent: string): void { let fileContent = readFileSync(path, 'utf8'); @@ -60,35 +66,63 @@ async function readFragments() { ) as unknown) as Changelog; const fragmentPaths = await readdir(fragmentDirPath, { withFileTypes: true }); + const failedFragments: string[] = []; + for (const fragmentFilename of fragmentPaths) { // skip non yml or yaml files if (!/\.ya?ml$/i.test(fragmentFilename.name)) { - // eslint-disable-next-line no-console - console.warn(`Skipping non yml or yaml file ${fragmentFilename.name}`); + log.info(`Skipping non yml or yaml file ${fragmentFilename.name}`); continue; } - const fragmentPath = join(fragmentDirPath, fragmentFilename.name); - const fragmentContents = readFileSync(fragmentPath, { encoding: 'utf-8' }); - - validateFragment(fragmentContents); - - const fragmentContentLines = fragmentContents.split('\n'); - // Adding a quotes to the second line and escaping exisiting " within the line - fragmentContentLines[1] = fragmentContentLines[1].replace(/-\s*(.*)/, (match, p1) => { - // Escape any existing quotes in the content - const escapedContent = p1.replace(/"/g, '\\"'); - return `- "${escapedContent}"`; - }); - - const processedFragmentContent = fragmentContentLines.join('\n'); - - const fragmentYaml = loadYaml(processedFragmentContent) as Changelog; - for (const [sectionKey, entries] of Object.entries(fragmentYaml)) { - sections[sectionKey as SectionKey].push(...entries); + try { + const fragmentPath = join(fragmentDirPath, fragmentFilename.name); + const fragmentContents = readFileSync(fragmentPath, { encoding: 'utf-8' }); + + try { + validateFragment(fragmentContents); + } catch (validationError) { + log.info(`Validation failed for ${fragmentFilename.name}: ${validationError.message}`); + failedFragments.push( + `${fragmentFilename.name} (Validation Error: ${validationError.message})` + ); + continue; + } + + const fragmentContentLines = fragmentContents.split('\n'); + // Adding a quotes to the second line and escaping existing " within the line + fragmentContentLines[1] = fragmentContentLines[1].replace(/-\s*(.*)/, (match, p1) => { + // Escape any existing quotes in the content + const escapedContent = p1.replace(/"/g, '\\"'); + return `- "${escapedContent}"`; + }); + + const processedFragmentContent = fragmentContentLines.join('\n'); + + try { + const fragmentYaml = loadYaml(processedFragmentContent) as Changelog; + for (const [sectionKey, entries] of Object.entries(fragmentYaml)) { + sections[sectionKey as SectionKey].push(...entries); + } + } catch (yamlError) { + log.info(`Failed to parse YAML in ${fragmentFilename.name}: ${yamlError.message}`); + failedFragments.push(`${fragmentFilename.name} (YAML Parse Error: ${yamlError.message})`); + continue; + } + } catch (error) { + log.info(`Failed to process ${fragmentFilename.name}: ${error.message}`); + failedFragments.push(`${fragmentFilename.name} (Processing Error: ${error.message})`); + continue; } } - return { sections, fragmentPaths }; + + if (failedFragments.length > 0) { + log.info('\nThe following changelog fragments were skipped due to errors:'); + failedFragments.forEach((fragment) => log.info(`- ${fragment}`)); + log.info('\nPlease review and fix these fragments for inclusion in the next release.\n'); + } + + return { sections, fragmentPaths, failedFragments }; } async function moveFragments(fragmentPaths: Dirent[], fragmentTempDirPath: string): Promise { @@ -128,16 +162,22 @@ function generateReleaseNote(changelogSections: string[]) { } (async () => { - const { sections, fragmentPaths } = await readFragments(); - // create folder for temp fragments - const fragmentTempDirPath = await fsPromises.mkdtemp(join(fragmentDirPath, 'tmp_fragments-')); - // move fragments to temp fragments folder - await moveFragments(fragmentPaths, fragmentTempDirPath); + const { sections, fragmentPaths, failedFragments } = await readFragments(); - const changelogSections = generateChangelog(sections); + // Only proceed if we have some valid fragments + if (Object.values(sections).some((section) => section.length > 0)) { + // create folder for temp fragments + const fragmentTempDirPath = await fsPromises.mkdtemp(join(fragmentDirPath, 'tmp_fragments-')); + // move fragments to temp fragments folder + await moveFragments(fragmentPaths, fragmentTempDirPath); - generateReleaseNote(changelogSections); + const changelogSections = generateChangelog(sections); + generateReleaseNote(changelogSections); - // remove temp fragments folder - await deleteFragments(fragmentTempDirPath); + // remove temp fragments folder + await deleteFragments(fragmentTempDirPath); + } else { + log.error('No valid changelog entries were found. Release notes generation aborted.'); + process.exit(1); + } })(); diff --git a/src/plugins/data/common/datasets/types.ts b/src/plugins/data/common/datasets/types.ts index e777eb8a45e8..ceffbd678218 100644 --- a/src/plugins/data/common/datasets/types.ts +++ b/src/plugins/data/common/datasets/types.ts @@ -247,6 +247,13 @@ export interface Dataset extends BaseDataset { timeFieldName?: string; /** Optional language to default to from the language selector */ language?: string; + /** Optional reference to the source dataset. Example usage is for indexed views to store the + * reference to the table dataset + */ + sourceDatasetRef?: { + id: string; + type: string; + }; } export interface DatasetField { diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index e39722118721..b120c69af694 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -493,6 +493,7 @@ export { QueryStart, PersistedLog, LanguageReference, + DatasetIndexedViewsService, } from './query'; export { AggsStart } from './search/aggs'; 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 f303fa6af56d..65c322acec6f 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 @@ -3,7 +3,14 @@ * SPDX-License-Identifier: Apache-2.0 */ import { EuiIconProps } from '@elastic/eui'; -import { Dataset, DatasetField, DatasetSearchOptions, DataStructure } from '../../../../common'; +import { + Dataset, + DatasetField, + DatasetSearchOptions, + DataStructure, + Query, + SavedObject, +} from '../../../../common'; import { IDataPluginServices } from '../../../types'; /** @@ -16,6 +23,18 @@ export interface DataStructureFetchOptions { paginationToken?: string; } +export interface DatasetIndexedView { + name: string; +} + +export interface DatasetIndexedViewsService { + getIndexedViews: (dataset: Dataset) => Promise; + /** + * Returns the data source saved object connected with the data connection object + */ + getConnectedDataSource: (dataset: Dataset) => Promise; +} + /** * Configuration for handling dataset operations. */ @@ -83,5 +102,13 @@ export interface DatasetTypeConfig { /** * Returns a list of sample queries for this dataset type */ - getSampleQueries?: (dataset: Dataset, language: string) => any; + getSampleQueries?: (dataset?: Dataset, language?: string) => Promise | any; + /** + * Service used for indexedViews related operations + */ + indexedViewsService?: DatasetIndexedViewsService; + /** + * Returns the initial query that is added to the query editor when a dataset is selected. + */ + getInitialQueryString?: (query: Query) => string | void; } diff --git a/src/plugins/data/public/query/query_string/index.ts b/src/plugins/data/public/query/query_string/index.ts index 96f473a3aea5..9ec21a485663 100644 --- a/src/plugins/data/public/query/query_string/index.ts +++ b/src/plugins/data/public/query/query_string/index.ts @@ -34,6 +34,7 @@ export { DatasetService, DatasetServiceContract, DatasetTypeConfig, + DatasetIndexedViewsService, } from './dataset_service'; export { LanguageServiceContract, diff --git a/src/plugins/data/public/query/query_string/language_service/language_service.mock.ts b/src/plugins/data/public/query/query_string/language_service/language_service.mock.ts index 936ff690353d..e481932883ca 100644 --- a/src/plugins/data/public/query/query_string/language_service/language_service.mock.ts +++ b/src/plugins/data/public/query/query_string/language_service/language_service.mock.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { createEditor, DQLBody, SingleLineInput } from '../../../ui'; import { LanguageServiceContract } from './language_service'; import { LanguageConfig } from './types'; @@ -14,7 +15,7 @@ const createSetupLanguageServiceMock = (): jest.Mocked title: 'DQL', search: {} as any, getQueryString: jest.fn(), - editor: {} as any, + editor: createEditor(SingleLineInput, SingleLineInput, [], DQLBody), fields: { filterable: true, visualizable: true, @@ -28,7 +29,7 @@ const createSetupLanguageServiceMock = (): jest.Mocked title: 'Lucene', search: {} as any, getQueryString: jest.fn(), - editor: {} as any, + editor: createEditor(SingleLineInput, SingleLineInput, [], DQLBody), fields: { filterable: true, visualizable: true, @@ -42,7 +43,9 @@ const createSetupLanguageServiceMock = (): jest.Mocked return { __enhance: jest.fn(), - registerLanguage: jest.fn(), + registerLanguage: jest.fn((language: LanguageConfig) => { + languages.set(language.id, language); + }), getLanguage: jest.fn((id: string) => languages.get(id)), getLanguages: jest.fn(() => Array.from(languages.values())), getDefaultLanguage: jest.fn(() => languages.get('kuery') || languages.values().next().value), diff --git a/src/plugins/data/public/query/query_string/language_service/lib/__snapshots__/query_result.test.tsx.snap b/src/plugins/data/public/query/query_string/language_service/lib/__snapshots__/query_result.test.tsx.snap index a0fd2861a2b4..f3d4e3df2c92 100644 --- a/src/plugins/data/public/query/query_string/language_service/lib/__snapshots__/query_result.test.tsx.snap +++ b/src/plugins/data/public/query/query_string/language_service/lib/__snapshots__/query_result.test.tsx.snap @@ -37,6 +37,8 @@ exports[`Query Result show error status with error message 2`] = ` className="eui-textBreakWord" style={ Object { + "maxHeight": "250px", + "overflowY": "auto", "width": "250px", } } diff --git a/src/plugins/data/public/query/query_string/language_service/lib/query_result.test.tsx b/src/plugins/data/public/query/query_string/language_service/lib/query_result.test.tsx index 9e735cd02d64..ea464f5ec68f 100644 --- a/src/plugins/data/public/query/query_string/language_service/lib/query_result.test.tsx +++ b/src/plugins/data/public/query/query_string/language_service/lib/query_result.test.tsx @@ -27,6 +27,17 @@ describe('Query Result', () => { expect(component).toMatchSnapshot(); }); + it('should not render if status is uninitialized', () => { + const props = { + queryStatus: { + status: ResultStatus.UNINITIALIZED, + startTime: Number.NEGATIVE_INFINITY, + }, + }; + const component = shallowWithIntl(); + expect(component.isEmptyRender()).toBe(true); + }); + it('shows ready status with complete message', () => { const props = { queryStatus: { diff --git a/src/plugins/data/public/query/query_string/language_service/lib/query_result.tsx b/src/plugins/data/public/query/query_string/language_service/lib/query_result.tsx index 2e8ab769e2e4..dff7faea36e3 100644 --- a/src/plugins/data/public/query/query_string/language_service/lib/query_result.tsx +++ b/src/plugins/data/public/query/query_string/language_service/lib/query_result.tsx @@ -58,24 +58,7 @@ export function QueryResult(props: { queryStatus: QueryStatus }) { }; }, [props.queryStatus.startTime]); - if (elapsedTime > BUFFER_TIME) { - if (props.queryStatus.status === ResultStatus.LOADING) { - const time = Math.floor(elapsedTime / 1000); - return ( - {}} - isLoading - data-test-subj="queryResultLoading" - > - {i18n.translate('data.query.languageService.queryResults.loadTime', { - defaultMessage: 'Loading {time} s', - values: { time }, - })} - - ); - } + if (elapsedTime > BUFFER_TIME && props.queryStatus.status === ResultStatus.LOADING) { const time = Math.floor(elapsedTime / 1000); return ( ERRORS -
+

diff --git a/src/plugins/data/public/query/query_string/query_string_manager.test.ts b/src/plugins/data/public/query/query_string/query_string_manager.test.ts index da054574ff85..4bce5d7159db 100644 --- a/src/plugins/data/public/query/query_string/query_string_manager.test.ts +++ b/src/plugins/data/public/query/query_string/query_string_manager.test.ts @@ -44,7 +44,6 @@ describe('QueryStringManager', () => { storage = new DataStorage(window.localStorage, 'opensearchDashboards.'); sessionStorage = new DataStorage(window.sessionStorage, 'opensearchDashboards.'); mockSearchInterceptor = {} as jest.Mocked; - service = new QueryStringManager( storage, sessionStorage, @@ -309,5 +308,37 @@ describe('QueryStringManager', () => { expect(result.dataset).toEqual(currentDataset); expect(result.query).toBeDefined(); }); + + test('getInitialQueryByLanguage returns the initial query from the dataset config if present', () => { + service.getDatasetService().getType = jest.fn().mockReturnValue({ + supportedLanguages: jest.fn(), + getInitialQueryString: jest.fn().mockImplementation(({ language }) => { + switch (language) { + case 'sql': + return 'default sql dataset query'; + case 'ppl': + return 'default ppl dataset query'; + } + }), + }); + + const sqlQuery = service.getInitialQueryByLanguage('sql'); + expect(sqlQuery).toHaveProperty('query', 'default sql dataset query'); + + const pplQuery = service.getInitialQueryByLanguage('ppl'); + expect(pplQuery).toHaveProperty('query', 'default ppl dataset query'); + }); + + test('getInitialQueryByLanguage returns the initial query from the language config if dataset does not provide one', () => { + service.getDatasetService().getType = jest.fn().mockReturnValue({ + supportedLanguages: jest.fn(), + }); + service.getLanguageService().getLanguage = jest.fn().mockReturnValue({ + getQueryString: jest.fn().mockReturnValue('default-language-service-query'), + }); + + const sqlQuery = service.getInitialQueryByLanguage('sql'); + expect(sqlQuery).toHaveProperty('query', 'default-language-service-query'); + }); }); }); diff --git a/src/plugins/data/public/query/query_string/query_string_manager.ts b/src/plugins/data/public/query/query_string/query_string_manager.ts index 33bfc7d5d10b..2bb5f41fbc19 100644 --- a/src/plugins/data/public/query/query_string/query_string_manager.ts +++ b/src/plugins/data/public/query/query_string/query_string_manager.ts @@ -63,6 +63,21 @@ export class QueryStringManager { return this.storage.get('userQueryString') || ''; } + private getInitialDatasetQueryString(query: Query) { + const { language, dataset } = query; + + const languageConfig = this.languageService.getLanguage(language); + let typeConfig; + + if (dataset) { + typeConfig = this.datasetService.getType(dataset.type); + } + + return ( + typeConfig?.getInitialQueryString?.(query) ?? (languageConfig?.getQueryString(query) || '') + ); + } + public getDefaultQuery(): Query { const defaultLanguageId = this.getDefaultLanguage(); const defaultQuery = this.getDefaultQueryString(); @@ -79,13 +94,11 @@ export class QueryStringManager { defaultDataset && this.languageService ) { - const language = this.languageService.getLanguage(defaultLanguageId); const newQuery = { ...query, dataset: defaultDataset }; - const newQueryString = language?.getQueryString(newQuery) || ''; return { ...newQuery, - query: newQueryString, + query: this.getInitialDatasetQueryString(newQuery), }; } @@ -244,13 +257,12 @@ export class QueryStringManager { // Both language and dataset provided - generate fresh query if (language && dataset) { - const languageService = this.languageService.getLanguage(language); const newQuery = { language, dataset, query: '', }; - newQuery.query = languageService?.getQueryString(newQuery) || ''; + newQuery.query = this.getInitialDatasetQueryString(newQuery); return newQuery; } @@ -274,12 +286,12 @@ export class QueryStringManager { */ public getInitialQueryByLanguage = (languageId: string) => { const curQuery = this.query$.getValue(); - const language = this.languageService.getLanguage(languageId); const newQuery = { ...curQuery, language: languageId, }; - const queryString = language?.getQueryString(newQuery) || ''; + + const queryString = this.getInitialDatasetQueryString(newQuery); this.languageService.setUserQueryString(queryString); return { @@ -296,17 +308,15 @@ export class QueryStringManager { const curQuery = this.query$.getValue(); // Use dataset's preferred language or fallback to current language const languageId = newDataset.language || curQuery.language; - const language = this.languageService.getLanguage(languageId); const newQuery = { ...curQuery, language: languageId, dataset: newDataset, }; - const queryString = language?.getQueryString(newQuery) || ''; return { ...newQuery, - query: queryString, + query: this.getInitialDatasetQueryString(newQuery), }; }; diff --git a/src/plugins/data/public/ui/dataset_selector/configurator.test.tsx b/src/plugins/data/public/ui/dataset_selector/configurator.test.tsx new file mode 100644 index 000000000000..462c6298a0a3 --- /dev/null +++ b/src/plugins/data/public/ui/dataset_selector/configurator.test.tsx @@ -0,0 +1,361 @@ +/* + * Copyright OpenSearch Contributors + * 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 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'; + +const getQueryMock = jest.fn().mockReturnValue({ + query: '', + language: 'lucene', + dataset: undefined, +} as Query); + +const languageService = { + getDefaultLanguage: () => ({ id: 'lucene', title: 'Lucene' }), + getLanguages: () => [ + { id: 'lucene', title: 'Lucene' }, + { id: 'kuery', title: 'DQL' }, + ], + getLanguage: (languageId: string) => { + const languages = [ + { id: 'lucene', title: 'Lucene' }, + { id: 'kuery', title: 'DQL' }, + ]; + return languages.find((lang) => lang.id === languageId); + }, + setUserQueryLanguage: jest.fn(), +}; + +const datasetService = { + getType: jest.fn().mockReturnValue({ + fetchFields: jest.fn().mockResolvedValue([{ name: 'timestamp', type: 'date' }]), + supportedLanguages: jest.fn().mockReturnValue(['kuery', 'lucene']), + indexedViewsService: { + getIndexedViews: jest.fn().mockResolvedValue([ + { name: 'view1', type: 'type1' }, + { name: 'view2', type: 'type2' }, + ]), + getConnectedDataSource: jest.fn().mockResolvedValue({ + id: 'test-connected-data-source-saved-obj', + attributes: { + title: 'test-connected-data-source-saved-obj', + }, + }), + }, + }), + addRecentDataset: jest.fn(), +}; + +const fetchFieldsMock = jest.fn().mockResolvedValue([{ name: 'timestamp', type: 'date' }]); + +const mockServices = { + getQueryService: () => ({ + queryString: { + getQuery: getQueryMock, + getLanguageService: () => languageService, + getDatasetService: () => datasetService, + fetchFields: fetchFieldsMock, + getUpdates$: jest.fn().mockReturnValue({ + subscribe: jest.fn().mockReturnValue({ unsubscribe: jest.fn() }), + }), + }, + }), + getIndexPatterns: jest.fn().mockResolvedValue([ + { + id: 'indexPattern1', + attributes: { + title: 'indexPattern1', + }, + }, + { + id: 'indexPattern2', + attributes: { + title: 'indexPattern2', + }, + }, + ]), +}; + +const mockBaseDataset: Dataset = { + id: 'mock-dataset', + title: 'Sample Dataset', + type: 'index-pattern', + timeFieldName: 'timestamp', + dataSource: { + id: 'test-connection-id', + meta: { supportsTimeFilter: true }, + title: 'mock-datasource', + type: 'DATA_SOURCE', + }, +}; + +const messages = { + 'app.welcome': 'Welcome to our application!', + 'app.logout': 'Log Out', +}; + +const mockOnConfirm = jest.fn(); +const mockOnCancel = jest.fn(); +const mockOnPrevious = jest.fn(); + +beforeEach(() => { + jest.clearAllMocks(); + setQueryService(mockServices.getQueryService()); + setIndexPatterns(mockServices.getIndexPatterns()); +}); + +describe('Configurator Component', () => { + it('should render the component with the correct title and description', () => { + render( + + {/* Wrap with IntlProvider */} + + + ); + + expect(screen.getByText('Step 2: Configure data')).toBeInTheDocument(); + expect( + screen.getByText('Configure selected data based on parameters available.') + ).toBeInTheDocument(); + }); + + it('should call onCancel when cancel button is clicked', () => { + render( + + + + ); + fireEvent.click(screen.getByText('Cancel')); + + expect(mockOnCancel).toHaveBeenCalledTimes(1); + }); + + it('should call onPrevious when previous button is clicked', () => { + render( + + + + ); + + fireEvent.click(screen.getByText('Back')); + + expect(mockOnPrevious).toHaveBeenCalledTimes(1); + }); + + it('should update state correctly when language is selected', async () => { + render( + + + + ); + const languageSelect = screen.getByText('Lucene'); + expect(languageSelect).toBeInTheDocument(); + expect(languageSelect.value).toBe('lucene'); + fireEvent.change(languageSelect, { target: { value: 'kuery' } }); + await waitFor(() => { + expect(languageSelect.value).toBe('kuery'); + }); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('should fetch indexed views on mount', async () => { + render( + + + + ); + + await waitFor(() => { + expect( + mockServices.getQueryService().queryString.getDatasetService().getType().indexedViewsService + .getIndexedViews + ).toHaveBeenCalledTimes(1); + }); + }); + + it('should display indexed views when query indexed view toggle is checked', async () => { + const container = render( + + + + ); + await waitFor(() => { + expect( + mockServices.getQueryService().queryString.getDatasetService().getType().indexedViewsService + .getIndexedViews + ).toHaveBeenCalledTimes(1); + }); + + fireEvent.click(container.getByText('Query indexed view')); + + await waitFor(() => { + expect(screen.getByText('view1')).toBeInTheDocument(); + expect(screen.getByText('view2')).toBeInTheDocument(); + }); + }); + + it('should update state correctly when indexed view is selected', async () => { + const container = render( + + + + ); + fireEvent.click(container.getByText('Query indexed view')); + await waitFor(() => { + expect( + mockServices.getQueryService().queryString.getDatasetService().getType().indexedViewsService + .getIndexedViews + ).toHaveBeenCalledTimes(1); + }); + const indexedViewSelector = screen.getByText('view1'); + expect(indexedViewSelector).toBeInTheDocument(); + expect(indexedViewSelector.value).toBe('view1'); + fireEvent.change(indexedViewSelector, { target: { value: 'view2' } }); + await waitFor(() => { + expect(indexedViewSelector.value).toBe('view2'); + }); + expect(mockOnConfirm).not.toHaveBeenCalled(); + }); + + it('should initialize selectedLanguage with the current language from queryString', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Lucene')).toBeInTheDocument(); + }); + }); + + it('should default selectedLanguage to the first language if currentLanguage is not supported', async () => { + mockServices.getQueryService().queryString.getQuery.mockReturnValue({ language: 'de' }); + + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Lucene')).toBeInTheDocument(); // Should default to 'Lucene' + }); + }); + + it('should display the supported language dropdown correctly', async () => { + render( + + + + ); + + await waitFor(() => { + expect(screen.getByText('Lucene')).toBeInTheDocument(); + expect(screen.getByText('DQL')).toBeInTheDocument(); + }); + }); + + it('should disable the confirm button when submit is disabled', async () => { + const mockDataset = { + ...mockBaseDataset, + timeFieldName: undefined, + type: 'index', + }; + const { container } = render( + + + + ); + + const submitButton = container.querySelector( + `button[data-test-subj="advancedSelectorConfirmButton"]` + ); // screen.getAllByTestId() // screen.getByRole('button', { name: /select data/i }); + await waitFor(() => { + expect(submitButton).toBeDisabled(); + }); + + const timeFieldSelect = container.querySelector( + `[data-test-subj="advancedSelectorTimeFieldSelect"]` + ); + fireEvent.change(timeFieldSelect!, { target: { value: 'timestamp' } }); + + await waitFor(() => { + expect(submitButton).toBeEnabled(); + }); + }); +}); diff --git a/src/plugins/data/public/ui/dataset_selector/configurator.tsx b/src/plugins/data/public/ui/dataset_selector/configurator.tsx index 2940c6b2baf0..0dba9107934c 100644 --- a/src/plugins/data/public/ui/dataset_selector/configurator.tsx +++ b/src/plugins/data/public/ui/dataset_selector/configurator.tsx @@ -8,20 +8,24 @@ import { EuiButtonEmpty, EuiFieldText, EuiForm, + EuiFormLabel, EuiFormRow, EuiModalBody, EuiModalFooter, EuiModalHeader, EuiModalHeaderTitle, EuiSelect, + EuiSpacer, + EuiSwitch, EuiText, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; -import React, { useEffect, useMemo, useState } from 'react'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { BaseDataset, DEFAULT_DATA, Dataset, DatasetField, Query } from '../../../common'; import { getIndexPatterns, getQueryService } from '../../services'; import { IDataPluginServices } from '../../types'; +import { DatasetIndexedView } from '../../query/query_string/dataset_service'; export const Configurator = ({ services, @@ -42,6 +46,15 @@ export const Configurator = ({ const indexPatternsService = getIndexPatterns(); const type = queryString.getDatasetService().getType(baseDataset.type); const languages = type?.supportedLanguages(baseDataset) || []; + const [shouldSelectIndexedView, setShouldSelectIndexedView] = useState(false); + + const [language, setLanguage] = useState(() => { + const currentLanguage = queryString.getQuery().language; + if (languages.includes(currentLanguage)) { + return currentLanguage; + } + return languages[0]; + }); const [dataset, setDataset] = useState(baseDataset); const [timeFields, setTimeFields] = useState([]); @@ -52,13 +65,29 @@ export const Configurator = ({ defaultMessage: "I don't want to use the time filter", } ); - const [language, setLanguage] = useState(() => { - const currentLanguage = queryString.getQuery().language; - if (languages.includes(currentLanguage)) { - return currentLanguage; - } - return languages[0]; - }); + const indexedViewsService = type?.indexedViewsService; + const [selectedIndexedView, setSelectedIndexedView] = useState(); + const [indexedViews, setIndexedViews] = useState([]); + const [isLoadingIndexedViews, setIsLoadingIndexedViews] = useState(false); + + useEffect(() => { + let isMounted = true; + const getIndexedViews = async () => { + if (indexedViewsService) { + setIsLoadingIndexedViews(true); + const fetchedIndexedViews = await indexedViewsService.getIndexedViews(baseDataset); + if (isMounted) { + setIsLoadingIndexedViews(false); + setIndexedViews(fetchedIndexedViews || []); + } + } + }; + + getIndexedViews(); + return () => { + isMounted = false; + }; + }, [indexedViewsService, baseDataset]); const submitDisabled = useMemo(() => { return ( @@ -91,6 +120,38 @@ export const Configurator = ({ fetchFields(); }, [baseDataset, indexPatternsService, queryString, timeFields.length]); + const updateDatasetForIndexedView = useCallback(async () => { + if (!indexedViewsService || !selectedIndexedView) { + return dataset; + } + + let connectedDataSource; + if (dataset.dataSource?.id) { + const connectedDataSourceSavedObj: any = await indexedViewsService.getConnectedDataSource( + dataset + ); + if (connectedDataSourceSavedObj) { + connectedDataSource = { + id: connectedDataSourceSavedObj.id, + title: connectedDataSourceSavedObj.attributes?.title, + type: 'DATA_SOURCE', + }; + } + } + + return { + ...dataset, + id: `${dataset.id}.${selectedIndexedView}`, + title: selectedIndexedView, + type: DEFAULT_DATA.SET_TYPES.INDEX, + sourceDatasetRef: { + id: dataset.id, + type: dataset.type, + }, + dataSource: connectedDataSource ?? dataset.dataSource, + }; + }, [indexedViewsService, selectedIndexedView, dataset]); + return ( <> @@ -123,6 +184,57 @@ export const Configurator = ({ > + {indexedViewsService && ( + <> + + + {i18n.translate( + 'data.explorer.datasetSelector.advancedSelector.configurator.showAvailableIndexedViewsLabel', + { + defaultMessage: 'Query indexed view', + } + )} + + } + onChange={(e) => setShouldSelectIndexedView(e.target.checked)} + /> + + {shouldSelectIndexedView && ( + + ({ + text: name, + value: name, + }))} + value={selectedIndexedView} + onChange={async (e) => { + const value = e.target.value; + setSelectedIndexedView(value); + }} + hasNoInitialSelection + /> + + )} + + )} { - await queryString.getDatasetService().cacheDataset(dataset, services); - onConfirm({ dataset, language }); + let newDataset = dataset; + if (shouldSelectIndexedView && selectedIndexedView) { + newDataset = await updateDatasetForIndexedView(); + } + await queryString.getDatasetService().cacheDataset(newDataset, services); + onConfirm({ dataset: newDataset, language }); }} fill disabled={submitDisabled} diff --git a/src/plugins/data/public/ui/dataset_selector/dataset_selector.tsx b/src/plugins/data/public/ui/dataset_selector/dataset_selector.tsx index 75ea695a2083..a88aea528e7e 100644 --- a/src/plugins/data/public/ui/dataset_selector/dataset_selector.tsx +++ b/src/plugins/data/public/ui/dataset_selector/dataset_selector.tsx @@ -82,7 +82,8 @@ export const DatasetSelector = ({ const { overlays } = services; const datasetService = getQueryService().queryString.getDatasetService(); const datasetIcon = - datasetService.getType(selectedDataset?.type || '')?.meta.icon.type || 'database'; + datasetService.getType(selectedDataset?.sourceDatasetRef?.type || selectedDataset?.type || '') + ?.meta.icon.type || 'database'; useEffect(() => { isMounted.current = true; diff --git a/src/plugins/data/public/ui/query_editor/query_editor.tsx b/src/plugins/data/public/ui/query_editor/query_editor.tsx index 2223b577b513..20650cca6acc 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor.tsx @@ -15,7 +15,7 @@ import { PopoverAnchorPosition, } from '@elastic/eui'; import classNames from 'classnames'; -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { monaco } from '@osd/monaco'; import { IDataPluginServices, @@ -74,6 +74,7 @@ export const QueryEditorUI: React.FC = (props) => { const inputRef = useRef(null); const headerRef = useRef(null); const bannerRef = useRef(null); + const bottomPanelRef = useRef(null); const queryControlsContainer = useRef(null); // TODO: https://github.com/opensearch-project/OpenSearch-Dashboards/issues/8801 const editorQuery = props.query; // local query state managed by the editor. Not to be confused by the app query state. @@ -113,6 +114,7 @@ export const QueryEditorUI: React.FC = (props) => { headerRef.current && bannerRef.current && queryControlsContainer.current && + bottomPanelRef.current && query.language && extensionMap && Object.keys(extensionMap).length > 0 @@ -130,6 +132,9 @@ export const QueryEditorUI: React.FC = (props) => { componentContainer={headerRef.current} bannerContainer={bannerRef.current} queryControlsContainer={queryControlsContainer.current} + bottomPanelContainer={bottomPanelRef.current} + query={query} + fetchStatus={props.queryStatus?.status} /> ); }; @@ -434,7 +439,7 @@ export const QueryEditorUI: React.FC = (props) => { queryString={queryString} onClickRecentQuery={onClickRecentQuery} /> - +

{renderQueryEditorExtensions()}
); diff --git a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.test.tsx b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.test.tsx index 289afadbac5e..13bb51ffea14 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.test.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.test.tsx @@ -15,9 +15,21 @@ jest.mock('react-dom', () => ({ type QueryEditorExtensionProps = ComponentProps; +const mockQuery = { + query: 'dummy query', + language: 'kuery', + dataset: { + id: 'db', + title: 'db', + type: 'index', + dataSource: { id: 'testId', type: 'DATA_SOURCE', title: 'testTitle' }, + }, +}; + describe('QueryEditorExtension', () => { const getComponentMock = jest.fn(); const getBannerMock = jest.fn(); + const getBottomPanelMock = jest.fn(); const isEnabledMock = jest.fn(); const defaultProps: QueryEditorExtensionProps = { @@ -27,15 +39,19 @@ describe('QueryEditorExtension', () => { isEnabled$: isEnabledMock, getComponent: getComponentMock, getBanner: getBannerMock, + getBottomPanel: getBottomPanelMock, }, dependencies: { language: 'Test', onSelectLanguage: jest.fn(), isCollapsed: false, setIsCollapsed: jest.fn(), + query: mockQuery, }, componentContainer: document.createElement('div'), bannerContainer: document.createElement('div'), + bottomPanelContainer: document.createElement('div'), + queryControlsContainer: document.createElement('div'), }; beforeEach(() => { @@ -46,26 +62,31 @@ describe('QueryEditorExtension', () => { isEnabledMock.mockReturnValue(of(true)); getComponentMock.mockReturnValue(
Test Component
); getBannerMock.mockReturnValue(
Test Banner
); + getBottomPanelMock.mockReturnValue(
Test Bottom panel
); const { getByText } = render(); await waitFor(() => { expect(getByText('Test Component')).toBeInTheDocument(); expect(getByText('Test Banner')).toBeInTheDocument(); + expect(getByText('Test Bottom panel')).toBeInTheDocument(); }); expect(isEnabledMock).toHaveBeenCalled(); expect(getComponentMock).toHaveBeenCalledWith(defaultProps.dependencies); + expect(getBottomPanelMock).toHaveBeenCalledWith(defaultProps.dependencies); }); it('does not render when isEnabled is false', async () => { isEnabledMock.mockReturnValue(of(false)); getComponentMock.mockReturnValue(
Test Component
); + getBottomPanelMock.mockReturnValue(
Test Bottom panel
); const { queryByText } = render(); await waitFor(() => { expect(queryByText('Test Component')).toBeNull(); + expect(queryByText('Test Bottom panel')).toBeNull(); }); expect(isEnabledMock).toHaveBeenCalled(); diff --git a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx index 95af159c785f..bcfa95357040 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extension.tsx @@ -7,13 +7,15 @@ import { EuiErrorBoundary } from '@elastic/eui'; import React, { useEffect, useMemo, useRef, useState } from 'react'; import ReactDOM from 'react-dom'; import { Observable } from 'rxjs'; -import { DataStructureMeta } from '../../../../common'; +import { DataStructureMeta, Query } from '../../../../common'; +import { ResultStatus } from '../../../query/query_string/language_service/lib'; interface QueryEditorExtensionProps { config: QueryEditorExtensionConfig; dependencies: QueryEditorExtensionDependencies; componentContainer: Element; bannerContainer: Element; + bottomPanelContainer: Element; queryControlsContainer: Element; } @@ -34,6 +36,14 @@ export interface QueryEditorExtensionDependencies { * Set whether the query editor is collapsed. */ setIsCollapsed: (isCollapsed: boolean) => void; + /** + * Currently set Query + */ + query: Query; + /** + * Fetch status for the currently running query + */ + fetchStatus?: ResultStatus; } export interface QueryEditorExtensionConfig { @@ -74,6 +84,12 @@ export interface QueryEditorExtensionConfig { getSearchBarButton?: ( dependencies: QueryEditorExtensionDependencies ) => React.ReactElement | null; + /** + * Returns the footer element that is rendered at the bottom of the query editor. + * @param dependencies - The dependencies required for the extension. + * @returns The component the query editor extension. + */ + getBottomPanel?: (dependencies: QueryEditorExtensionDependencies) => React.ReactElement | null; } const QueryEditorExtensionPortal: React.FC<{ container: Element }> = (props) => { if (!props.children) return null; @@ -103,6 +119,11 @@ export const QueryEditorExtension: React.FC = (props) props.dependencies, ]); + const bottomPanel = useMemo(() => props.config.getBottomPanel?.(props.dependencies), [ + props.config, + props.dependencies, + ]); + useEffect(() => { isMounted.current = true; return () => { @@ -130,6 +151,9 @@ export const QueryEditorExtension: React.FC = (props) {queryControlButtons} + + {bottomPanel} + ); }; diff --git a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extensions.test.tsx b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extensions.test.tsx index ec67a3a52dfb..8a18a82d3714 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extensions.test.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extensions.test.tsx @@ -19,6 +19,17 @@ jest.mock('./query_editor_extension', () => ({ )), })); +const mockQuery = { + query: 'dummy query', + language: 'kuery', + dataset: { + id: 'db', + title: 'db', + type: 'index', + dataSource: { id: 'testId', type: 'DATA_SOURCE', title: 'testTitle' }, + }, +}; + describe('QueryEditorExtensions', () => { const defaultProps: QueryEditorExtensionsProps = { componentContainer: document.createElement('div'), @@ -28,6 +39,8 @@ describe('QueryEditorExtensions', () => { onSelectLanguage: jest.fn(), isCollapsed: false, setIsCollapsed: jest.fn(), + query: mockQuery, + bottomPanelContainer: document.createElement('div'), }; beforeEach(() => { @@ -78,6 +91,7 @@ describe('QueryEditorExtensions', () => { onSelectLanguage: expect.any(Function), isCollapsed: false, setIsCollapsed: expect.any(Function), + query: mockQuery, }, }), expect.anything() diff --git a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extensions.tsx b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extensions.tsx index 90c7fbf51666..4c420adc0312 100644 --- a/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extensions.tsx +++ b/src/plugins/data/public/ui/query_editor/query_editor_extensions/query_editor_extensions.tsx @@ -14,6 +14,7 @@ interface QueryEditorExtensionsProps extends QueryEditorExtensionDependencies { configMap?: Record; componentContainer: Element; bannerContainer: Element; + bottomPanelContainer: Element; queryControlsContainer: Element; } @@ -22,6 +23,7 @@ const QueryEditorExtensions: React.FC = React.memo(( configMap, componentContainer, bannerContainer, + bottomPanelContainer, queryControlsContainer, ...dependencies } = props; @@ -62,6 +64,7 @@ const QueryEditorExtensions: React.FC = React.memo(( dependencies={dependencies} componentContainer={extensionComponentContainer} bannerContainer={bannerContainer} + bottomPanelContainer={bottomPanelContainer} queryControlsContainer={extensionQueryControlsContainer} /> ); 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 new file mode 100644 index 000000000000..62fe653bfd45 --- /dev/null +++ b/src/plugins/data/public/ui/query_editor/query_editor_top_row.test.tsx @@ -0,0 +1,158 @@ +/* + * Copyright OpenSearch Contributors + * 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 { getQueryService } from '../../services'; + +const startMock = coreMock.createStart(); + +jest.mock('../../services', () => ({ + getQueryService: jest.fn(), +})); + +startMock.uiSettings.get.mockImplementation((key: string) => { + switch (key) { + case UI_SETTINGS.TIMEPICKER_QUICK_RANGES: + return [ + { + from: 'now/d', + to: 'now/d', + display: 'Today', + }, + ]; + case 'dateFormat': + return 'MMM D, YYYY @ HH:mm:ss.SSS'; + case UI_SETTINGS.HISTORY_LIMIT: + return 10; + case UI_SETTINGS.TIMEPICKER_TIME_DEFAULTS: + return { + from: 'now-15m', + to: 'now', + }; + case UI_SETTINGS.QUERY_ENHANCEMENTS_ENABLED: + return true; + case 'theme:darkMode': + return true; + default: + throw new Error(`Unexpected config key: ${key}`); + } +}); + +const createMockWebStorage = () => ({ + clear: jest.fn(), + getItem: jest.fn(), + key: jest.fn(), + removeItem: jest.fn(), + setItem: jest.fn(), + length: 0, +}); + +const createMockStorage = () => ({ + storage: createMockWebStorage(), + get: jest.fn(), + set: jest.fn(), + remove: jest.fn(), + clear: jest.fn(), +}); + +const dataPlugin = dataPluginMock.createStartContract(true); + +function wrapQueryEditorTopRowInContext(testProps: any) { + const defaultOptions = { + onSubmit: jest.fn(), + onChange: jest.fn(), + isDirty: true, + screenTitle: 'Another Screen', + }; + + const mockLanguage: LanguageConfig = { + id: 'test-language', + title: 'Test Language', + search: {} as any, + getQueryString: jest.fn(), + editor: createEditor(SingleLineInput, SingleLineInput, [], DQLBody), + fields: {}, + showDocLinks: true, + editorSupportedAppNames: ['discover'], + hideDatePicker: true, + }; + dataPlugin.query.queryString.getLanguageService().registerLanguage(mockLanguage); + + const services = { + ...startMock, + data: dataPlugin, + appName: 'discover', + storage: createMockStorage(), + }; + + return ( + + + + + + ); +} + +describe('QueryEditorTopRow', () => { + const QUERY_EDITOR = '.osdQueryEditor'; + const DATE_PICKER = '.osdQueryEditor__datePickerWrapper'; + + beforeEach(() => { + jest.clearAllMocks(); + (getQueryService as jest.Mock).mockReturnValue(dataPlugin.query); + }); + + afterEach(() => { + cleanup(); + jest.resetModules(); + }); + + it('Should render query editor', async () => { + const { container } = render( + wrapQueryEditorTopRowInContext({ + showQueryEditor: true, + }) + ); + await waitFor(() => expect(container.querySelector(QUERY_EDITOR)).toBeTruthy()); + expect(container.querySelector(DATE_PICKER)).toBeTruthy(); + }); + + it('Should not render date picker if showDatePicker is false', async () => { + const { container } = render( + wrapQueryEditorTopRowInContext({ + showQueryEditor: true, + showDatePicker: false, + }) + ); + await waitFor(() => expect(container.querySelector(QUERY_EDITOR)).toBeTruthy()); + expect(container.querySelector(DATE_PICKER)).toBeFalsy(); + }); + + it('Should not render date picker if language does not support time field', async () => { + const query: Query = { + query: 'test query', + language: 'test-language', + }; + dataPlugin.query.queryString.getQuery = jest.fn().mockReturnValue(query); + const { container } = render( + wrapQueryEditorTopRowInContext({ + query, + showQueryEditor: false, + showDatePicker: true, + }) + ); + await waitFor(() => expect(container.querySelector(QUERY_EDITOR)).toBeTruthy()); + expect(container.querySelector(DATE_PICKER)).toBeFalsy(); + }); +}); 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 ab9b8c50e038..ea15fbfeeaa1 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 @@ -72,7 +72,7 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { const [isDateRangeInvalid, setIsDateRangeInvalid] = useState(false); const [isQueryEditorFocused, setIsQueryEditorFocused] = useState(false); const opensearchDashboards = useOpenSearchDashboards(); - const { uiSettings, storage, appName } = opensearchDashboards.services; + const { uiSettings, storage, appName, data } = opensearchDashboards.services; const queryLanguage = props.query && props.query.language; const persistedLog: PersistedLog | undefined = React.useMemo( @@ -225,7 +225,17 @@ export default function QueryEditorTopRow(props: QueryEditorTopRowProps) { } function shouldRenderDatePicker(): boolean { - return Boolean(props.showDatePicker ?? true) ?? (props.showAutoRefreshOnly && true); + 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) + ); } function shouldRenderQueryEditor(): boolean { diff --git a/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.test.tsx b/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.test.tsx new file mode 100644 index 000000000000..f004f6e7e5af --- /dev/null +++ b/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.test.tsx @@ -0,0 +1,231 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render, screen, fireEvent, waitFor } from '@testing-library/react'; +import { OpenSavedQueryFlyout } from './open_saved_query_flyout'; +import { createSavedQueryService } from '../../../public/query/saved_query/saved_query_service'; +import { applicationServiceMock, uiSettingsServiceMock } from '../../../../../core/public/mocks'; +import { SavedQueryAttributes } from '../../../public/query/saved_query/types'; +import '@testing-library/jest-dom'; +import { queryStringManagerMock } from '../../../../data/public/query/query_string/query_string_manager.mock'; +import { getQueryService } from '../../services'; + +const savedQueryAttributesWithTemplate: SavedQueryAttributes = { + title: 'foo', + description: 'bar', + query: { + language: 'kuery', + query: 'response:200', + dataset: 'my_dataset', + }, +}; + +const mockSavedObjectsClient = { + create: jest.fn(), + error: jest.fn(), + find: jest.fn(), + get: jest.fn(), + delete: jest.fn(), +}; + +mockSavedObjectsClient.create.mockReturnValue({ + id: 'foo', + attributes: { + ...savedQueryAttributesWithTemplate, + query: { + ...savedQueryAttributesWithTemplate.query, + }, + }, +}); + +jest.mock('./saved_query_card', () => ({ + SavedQueryCard: ({ + savedQuery = { + id: 'foo1', + attributes: savedQueryAttributesWithTemplate, + }, + onSelect, + handleQueryDelete, + }) => ( +
+
{savedQuery?.attributes?.title}
+ + +
+ ), +})); + +jest.mock('@osd/i18n', () => ({ + i18n: { + translate: jest.fn((id, { defaultMessage }) => defaultMessage), + }, +})); + +jest.mock('../../services', () => ({ + getQueryService: jest.fn(), +})); + +const mockSavedQueryService = createSavedQueryService( + // @ts-ignore + mockSavedObjectsClient, + { + application: applicationServiceMock.create(), + uiSettings: uiSettingsServiceMock.createStartContract(), + } +); + +const mockHandleQueryDelete = jest.fn(); +const mockOnQueryOpen = jest.fn(); +const mockOnClose = jest.fn(); + +const savedQueries = [ + { + id: '1', + attributes: { + title: 'Saved Query 1', + description: 'Description for Query 1', + query: { query: 'SELECT * FROM table1', language: 'sql' }, + }, + }, + { + id: '2', + attributes: { + title: 'Saved Query 2', + description: 'Description for Query 2', + query: { query: 'SELECT * FROM table2', language: 'sql' }, + }, + }, +]; + +jest.spyOn(mockSavedQueryService, 'getAllSavedQueries').mockResolvedValue(savedQueries); + +describe('OpenSavedQueryFlyout', () => { + beforeEach(() => { + jest.clearAllMocks(); + (getQueryService as jest.Mock).mockReturnValue({ + queryString: queryStringManagerMock.createSetupContract(), + }); + }); + + it('should render the flyout with correct tabs and content', async () => { + render( + + ); + + const savedQueriesTextElements = screen.getAllByText('Saved queries'); + + expect(savedQueriesTextElements).toHaveLength(2); + + await waitFor(() => screen.getByPlaceholderText('Search')); + + await waitFor(() => screen.getByText('Saved Query 1')); + await waitFor(() => screen.getByText('Saved Query 2')); + + const openQueryButton = screen.getByText('Open query'); + + fireEvent.change(screen.getByPlaceholderText('Search'), { target: { value: 'Saved Query 1' } }); + + await waitFor(() => screen.getByText('Saved Query 1')); + expect(screen.queryByText('Saved Query 2')).not.toBeInTheDocument(); + + fireEvent.click(screen.getByText('Saved Query 1')); + + expect(openQueryButton).toBeEnabled(); + }); + + it('should filter saved queries based on search input', async () => { + render( + + ); + + await waitFor(() => screen.getByText('Saved Query 1')); + await waitFor(() => screen.getByText('Saved Query 2')); + + const searchBar = screen.getByPlaceholderText('Search'); + fireEvent.change(searchBar, { target: { value: 'Saved Query 1' } }); + + expect(screen.getByText('Saved Query 1')).toBeInTheDocument(); + expect(screen.queryByText('Saved Query 2')).toBeNull(); + }); + + it('should select a query when clicking on it and enable the "Open query" button', async () => { + render( + + ); + + await waitFor(() => screen.getByText('Saved Query 1')); + + fireEvent.click(screen.getByText('Saved Query 1')); + + expect(screen.getByText('Open query')).toBeEnabled(); + }); + + it('should call handleQueryDelete when deleting a query', async () => { + mockHandleQueryDelete.mockResolvedValueOnce(); + render( + + ); + + await waitFor(() => screen.getByText('Saved Query 1')); + + const deleteButtons = screen.getAllByText('Delete'); + + fireEvent.click(deleteButtons[0]); + + await waitFor(() => { + expect(mockHandleQueryDelete).toHaveBeenCalledWith({ + id: '1', + attributes: { + description: 'Description for Query 1', + query: { + language: 'sql', + query: 'SELECT * FROM table1', + }, + title: 'Saved Query 1', + }, + }); + }); + expect(mockHandleQueryDelete).toHaveBeenCalledTimes(1); + }); + + it('should handle pagination controls correctly', async () => { + render( + + ); + + await waitFor(() => screen.getByText('Saved Query 1')); + + const pageSizeButton = await screen.findByText(/10/); + fireEvent.click(pageSizeButton); + + expect(mockSavedQueryService.getAllSavedQueries).toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.tsx b/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.tsx index c7f13f27db08..d9c2941adc8d 100644 --- a/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.tsx +++ b/src/plugins/data/public/ui/saved_query_flyouts/open_saved_query_flyout.tsx @@ -13,6 +13,7 @@ import { EuiFlyoutBody, EuiFlyoutFooter, EuiFlyoutHeader, + EuiLoadingSpinner, EuiSearchBar, EuiSearchBarProps, EuiSpacer, @@ -20,14 +21,18 @@ import { EuiTablePagination, EuiTitle, Pager, + copyToClipboard, } from '@elastic/eui'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { i18n } from '@osd/i18n'; +import { NotificationsStart } from 'opensearch-dashboards/public'; import { SavedQuery, SavedQueryService } from '../../query'; import { SavedQueryCard } from './saved_query_card'; +import { getQueryService } from '../../services'; export interface OpenSavedQueryFlyoutProps { savedQueryService: SavedQueryService; + notifications?: NotificationsStart; onClose: () => void; onQueryOpen: (query: SavedQuery) => void; handleQueryDelete: (query: SavedQuery) => Promise; @@ -42,13 +47,21 @@ interface SavedQuerySearchableItem { savedQuery: SavedQuery; } +enum OPEN_QUERY_TAB_ID { + SAVED_QUERIES = 'saved-queries', + QUERY_TEMPLATES = 'query-templates', +} + export function OpenSavedQueryFlyout({ savedQueryService, + notifications, onClose, onQueryOpen, handleQueryDelete, }: OpenSavedQueryFlyoutProps) { - const [selectedTabId, setSelectedTabId] = useState('mutable-saved-queries'); + const [selectedTabId, setSelectedTabId] = useState( + OPEN_QUERY_TAB_ID.SAVED_QUERIES + ); const [savedQueries, setSavedQueries] = useState([]); const [hasTemplateQueries, setHasTemplateQueries] = useState(false); const [itemsPerPage, setItemsPerPage] = useState(10); @@ -59,18 +72,46 @@ export function OpenSavedQueryFlyout({ const [languageFilterOptions, setLanguageFilterOptions] = useState([]); const [selectedQuery, setSelectedQuery] = useState(undefined); const [searchQuery, setSearchQuery] = useState(EuiSearchBar.Query.MATCH_ALL); + const [isLoading, setIsLoading] = useState(false); + const currentTabIdRef = useRef(selectedTabId); + const queryStringManager = getQueryService().queryString; const fetchAllSavedQueriesForSelectedTab = useCallback(async () => { - const allQueries = await savedQueryService.getAllSavedQueries(); - const templateQueriesPresent = allQueries.some((q) => q.attributes.isTemplate); - const queriesForSelectedTab = allQueries.filter( - (q) => - (selectedTabId === 'mutable-saved-queries' && !q.attributes.isTemplate) || - (selectedTabId === 'template-saved-queries' && q.attributes.isTemplate) - ); - setSavedQueries(queriesForSelectedTab); - setHasTemplateQueries(templateQueriesPresent); - }, [savedQueryService, selectedTabId, setSavedQueries]); + setIsLoading(true); + try { + const query = queryStringManager.getQuery(); + let templateQueries: any[] = []; + + // fetch sample query based on dataset type + if (query?.dataset?.type) { + templateQueries = + (await queryStringManager + .getDatasetService() + ?.getType(query.dataset.type) + ?.getSampleQueries?.()) || []; + + // Check if any sample query has isTemplate set to true + const hasTemplates = templateQueries.some((q) => q?.attributes?.isTemplate); + setHasTemplateQueries(hasTemplates); + } + + // Set queries based on the current tab + if (currentTabIdRef.current === OPEN_QUERY_TAB_ID.SAVED_QUERIES) { + const allQueries = await savedQueryService.getAllSavedQueries(); + const mutableSavedQueries = allQueries.filter((q) => !q.attributes.isTemplate); + if (currentTabIdRef.current === OPEN_QUERY_TAB_ID.SAVED_QUERIES) { + setSavedQueries(mutableSavedQueries); + } + } else if (currentTabIdRef.current === OPEN_QUERY_TAB_ID.QUERY_TEMPLATES) { + setSavedQueries(templateQueries); + } + } catch (e) { + // eslint-disable-next-line no-console + console.error('Error occurred while retrieving saved queries.', e); + } finally { + setIsLoading(false); + } + }, [savedQueryService, currentTabIdRef, setSavedQueries, queryStringManager]); const updatePageIndex = useCallback((index: number) => { pager.current.goToPageIndex(index); @@ -81,6 +122,7 @@ export function OpenSavedQueryFlyout({ fetchAllSavedQueriesForSelectedTab(); setSearchQuery(EuiSearchBar.Query.MATCH_ALL); updatePageIndex(0); + setSelectedQuery(undefined); }, [selectedTabId, fetchAllSavedQueriesForSelectedTab, updatePageIndex]); useEffect(() => { @@ -179,7 +221,13 @@ export function OpenSavedQueryFlyout({ onChange={onChange} /> - {queriesOnCurrentPage.length > 0 ? ( + {isLoading ? ( + + + + + + ) : queriesOnCurrentPage.length > 0 ? ( queriesOnCurrentPage.map((query) => ( )} - {queriesOnCurrentPage.length > 0 && ( + {!isLoading && queriesOnCurrentPage.length > 0 && ( { + if (!selectedQuery) { + return; + } + + if (selectedQuery?.attributes.isTemplate) { + copyToClipboard(selectedQuery.attributes.query.query as string); + notifications?.toasts.addSuccess({ + title: i18n.translate('data.openSavedQueryFlyout.queryCopied.title', { + defaultMessage: 'Query copied', + }), + text: i18n.translate('data.openSavedQueryFlyout.queryCopied.text', { + defaultMessage: 'Paste the query in the editor to modify and run.', + }), + }); + } else { + onQueryOpen({ + ...selectedQuery, + attributes: { + ...selectedQuery.attributes, + query: { + ...selectedQuery.attributes.query, + dataset: queryStringManager.getQuery().dataset, + }, + }, + }); + } + + onClose(); + }, [onClose, onQueryOpen, notifications, selectedQuery, queryStringManager]); + return ( @@ -251,7 +330,8 @@ export function OpenSavedQueryFlyout({ tabs={tabs} initialSelectedTab={tabs[0]} onTabClick={(tab) => { - setSelectedTabId(tab.id); + setSelectedTabId(tab.id as OPEN_QUERY_TAB_ID); + currentTabIdRef.current = tab.id as OPEN_QUERY_TAB_ID; }} /> @@ -266,14 +346,10 @@ export function OpenSavedQueryFlyout({ { - if (selectedQuery) { - onQueryOpen(selectedQuery); - onClose(); - } - }} + onClick={onQueryAction} + data-testid="open-query-action-button" > - Open query + {selectedTabId === OPEN_QUERY_TAB_ID.SAVED_QUERIES ? 'Open' : 'Copy'} query diff --git a/src/plugins/data/public/ui/saved_query_flyouts/save_query_flyout.tsx b/src/plugins/data/public/ui/saved_query_flyouts/save_query_flyout.tsx index c0356b864485..f62a60f7e9c7 100644 --- a/src/plugins/data/public/ui/saved_query_flyouts/save_query_flyout.tsx +++ b/src/plugins/data/public/ui/saved_query_flyouts/save_query_flyout.tsx @@ -43,7 +43,6 @@ export function SaveQueryFlyout({ savedQueryService={savedQueryService} showFilterOption={showFilterOption} showTimeFilterOption={showTimeFilterOption} - showDataSourceOption={true} setSaveAsNew={(shouldSaveAsNew) => setSaveAsNew(shouldSaveAsNew)} savedQuery={saveAsNew ? undefined : savedQuery} saveAsNew={saveAsNew} diff --git a/src/plugins/data/public/ui/saved_query_form/helpers.tsx b/src/plugins/data/public/ui/saved_query_form/helpers.tsx index 467eac2de475..ad3de3acde3f 100644 --- a/src/plugins/data/public/ui/saved_query_form/helpers.tsx +++ b/src/plugins/data/public/ui/saved_query_form/helpers.tsx @@ -57,7 +57,6 @@ interface Props { formUiType: 'Modal' | 'Flyout'; showFilterOption?: boolean; showTimeFilterOption?: boolean; - showDataSourceOption?: boolean; saveAsNew?: boolean; setSaveAsNew?: (shouldSaveAsNew: boolean) => void; cannotBeOverwritten?: boolean; @@ -70,7 +69,6 @@ export function useSaveQueryFormContent({ onClose, showFilterOption = true, showTimeFilterOption = true, - showDataSourceOption = false, formUiType, saveAsNew, setSaveAsNew, @@ -81,7 +79,6 @@ export function useSaveQueryFormContent({ const [description, setDescription] = useState(''); const [savedQueries, setSavedQueries] = useState([]); const [shouldIncludeFilters, setShouldIncludeFilters] = useState(true); - const [shouldIncludeDataSource, setShouldIncludeDataSource] = useState(true); // Defaults to false because saved queries are meant to be as portable as possible and loading // a saved query with a time filter will override whatever the current value of the global timepicker // is. We expect this option to be used rarely and only when the user knows they want this behavior. @@ -96,7 +93,6 @@ export function useSaveQueryFormContent({ setDescription(savedQuery?.description || ''); setShouldIncludeFilters(savedQuery ? !!savedQuery.filters : true); setIncludeTimefilter(!!savedQuery?.timefilter); - setShouldIncludeDataSource(savedQuery ? !!savedQuery.query.dataset : true); setFormErrors([]); }, [savedQuery]); @@ -147,18 +143,9 @@ export function useSaveQueryFormContent({ description, shouldIncludeFilters, shouldIncludeTimeFilter, - shouldIncludeDataSource, }); } - }, [ - validate, - onSave, - title, - description, - shouldIncludeFilters, - shouldIncludeTimeFilter, - shouldIncludeDataSource, - ]); + }, [validate, onSave, title, description, shouldIncludeFilters, shouldIncludeTimeFilter]); const onInputChange = useCallback((event) => { setEnabledSaveButton(Boolean(event.target.value)); @@ -229,21 +216,6 @@ export function useSaveQueryFormContent({ data-test-subj="saveQueryFormDescription" /> - {showDataSourceOption && ( - - { - setShouldIncludeDataSource(!shouldIncludeDataSource); - }} - data-test-subj="saveQueryFormIncludeDataSourceOption" - /> - - )} {showFilterOption && ( void; showFilterOption?: boolean; showTimeFilterOption?: boolean; - showDataSourceOption?: boolean; saveAsNew?: boolean; cannotBeOverwritten?: boolean; } @@ -63,7 +62,6 @@ export interface SavedQueryMeta { description: string; shouldIncludeFilters: boolean; shouldIncludeTimeFilter: boolean; - shouldIncludeDataSource: boolean; } export function SaveQueryForm({ @@ -74,7 +72,6 @@ export function SaveQueryForm({ onClose, showFilterOption = true, showTimeFilterOption = true, - showDataSourceOption = false, saveAsNew, setSaveAsNew, cannotBeOverwritten, @@ -87,7 +84,6 @@ export function SaveQueryForm({ onClose, showFilterOption, showTimeFilterOption, - showDataSourceOption, saveAsNew, setSaveAsNew, cannotBeOverwritten, diff --git a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx index 01f9b97e978f..94898bfe57a2 100644 --- a/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx +++ b/src/plugins/data/public/ui/saved_query_management/saved_query_management_component.tsx @@ -89,7 +89,7 @@ export function SavedQueryManagementComponent({ const [activePage, setActivePage] = useState(0); const cancelPendingListingRequest = useRef<() => void>(() => {}); const { - services: { overlays }, + services: { overlays, notifications }, } = useOpenSearchDashboards(); useEffect(() => { @@ -253,6 +253,7 @@ export function SavedQueryManagementComponent({ toMountPoint( openSavedQueryFlyout?.close().then()} onQueryOpen={onLoad} handleQueryDelete={handleDelete} diff --git a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts index 52c3f981296b..b172d8c42a76 100644 --- a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts +++ b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.test.ts @@ -47,6 +47,11 @@ describe('populateStateFromSavedQuery', () => { query: { query: 'test', language: 'kuery', + dataset: { + id: 'saved-query-dataset', + title: 'saved-query-dataset', + type: 'INDEX', + }, }, }, }; @@ -57,12 +62,15 @@ describe('populateStateFromSavedQuery', () => { dataMock.query.filterManager.getGlobalFilters = jest.fn().mockReturnValue([]); }); - it('should set query', async () => { + it('should set query with current dataset', async () => { const savedQuery: SavedQuery = { ...baseSavedQuery, }; populateStateFromSavedQuery(dataMock.query, savedQuery); - expect(dataMock.query.queryString.setQuery).toHaveBeenCalled(); + expect(dataMock.query.queryString.setQuery).toHaveBeenCalledWith({ + ...savedQuery.attributes.query, + dataset: dataMock.query.queryString.getQuery().dataset, + }); }); it('should set filters', async () => { diff --git a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts index 382fd382ac01..abab61dfe82e 100644 --- a/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts +++ b/src/plugins/data/public/ui/search_bar/lib/populate_state_from_saved_query.ts @@ -48,7 +48,11 @@ export const populateStateFromSavedQuery = (queryService: QueryStart, savedQuery } // query string - queryString.setQuery(savedQuery.attributes.query); + queryString.setQuery({ + ...savedQuery.attributes.query, + // We should keep the currently selected dataset intact + dataset: queryString.getQuery().dataset, + }); // filters const savedQueryFilters = savedQuery.attributes.filters || []; diff --git a/src/plugins/data/public/ui/search_bar/search_bar.tsx b/src/plugins/data/public/ui/search_bar/search_bar.tsx index 1f1b20b8c952..251a0dc86fa0 100644 --- a/src/plugins/data/public/ui/search_bar/search_bar.tsx +++ b/src/plugins/data/public/ui/search_bar/search_bar.tsx @@ -45,7 +45,6 @@ import { QueryEditorTopRow } from '../query_editor'; import QueryBarTopRow from '../query_string_input/query_bar_top_row'; import { SavedQueryMeta, SaveQueryForm } from '../saved_query_form'; import { FilterOptions } from '../filter_bar/filter_options'; -import { getUseNewSavedQueriesUI } from '../../services'; interface SearchBarInjectedDeps { opensearchDashboards: OpenSearchDashboardsReactContextValue; @@ -285,11 +284,8 @@ class SearchBarUI extends Component { public onSave = async (savedQueryMeta: SavedQueryMeta, saveAsNew = false) => { if (!this.state.query) return; - const query = cloneDeep(this.state.query); - if (getUseNewSavedQueriesUI() && !savedQueryMeta.shouldIncludeDataSource) { - delete query.dataset; - } + delete query.dataset; const savedQueryAttributes: SavedQueryAttributes = { title: savedQueryMeta.title, diff --git a/src/plugins/discover/public/application/components/no_results/no_results.tsx b/src/plugins/discover/public/application/components/no_results/no_results.tsx index b1ec382b4fd5..24a4b80c7204 100644 --- a/src/plugins/discover/public/application/components/no_results/no_results.tsx +++ b/src/plugins/discover/public/application/components/no_results/no_results.tsx @@ -174,6 +174,7 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam // } const [savedQueries, setSavedQueries] = useState([]); + const [sampleQueries, setSampleQueries] = useState([]); useEffect(() => { const fetchSavedQueries = async () => { @@ -186,6 +187,39 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam fetchSavedQueries(); }, [setSavedQueries, query, savedQuery]); + useEffect(() => { + // Samples for the language + const newSampleQueries: any = []; + if (query?.language) { + const languageSampleQueries = queryString.getLanguageService()?.getLanguage(query.language) + ?.sampleQueries; + if (Array.isArray(languageSampleQueries)) { + newSampleQueries.push(...languageSampleQueries); + } + } + + // Samples for the dataset type + if (query?.dataset?.type) { + const datasetType = queryString.getDatasetService()?.getType(query.dataset.type); + if (datasetType?.getSampleQueries) { + const sampleQueriesResponse = datasetType.getSampleQueries(query.dataset, query.language); + if (Array.isArray(sampleQueriesResponse)) { + setSampleQueries([...sampleQueriesResponse, ...newSampleQueries]); + } else if (sampleQueriesResponse instanceof Promise) { + sampleQueriesResponse + .then((datasetSampleQueries: any) => { + if (Array.isArray(datasetSampleQueries)) { + setSampleQueries([...datasetSampleQueries, ...newSampleQueries]); + } + }) + .catch((error: any) => { + // noop + }); + } + } + } + }, [queryString, query]); + const tabs = useMemo(() => { const buildSampleQueryBlock = (sampleTitle: string, sampleQuery: string) => { return ( @@ -197,25 +231,6 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam ); }; - - const sampleQueries = []; - - // Samples for the dataset type - if (query?.dataset?.type) { - const datasetSampleQueries = queryString - .getDatasetService() - ?.getType(query.dataset.type) - ?.getSampleQueries?.(query.dataset, query.language); - if (Array.isArray(datasetSampleQueries)) sampleQueries.push(...datasetSampleQueries); - } - - // Samples for the language - if (query?.language) { - const languageSampleQueries = queryString.getLanguageService()?.getLanguage(query.language) - ?.sampleQueries; - if (Array.isArray(languageSampleQueries)) sampleQueries.push(...languageSampleQueries); - } - return [ ...(sampleQueries.length > 0 ? [ @@ -229,7 +244,7 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam {sampleQueries .slice(0, 5) - .map((sampleQuery) => + .map((sampleQuery: any) => buildSampleQueryBlock(sampleQuery.title, sampleQuery.query) )} @@ -256,7 +271,7 @@ export const DiscoverNoResults = ({ queryString, query, savedQuery, timeFieldNam ] : []), ]; - }, [queryString, query, savedQueries]); + }, [savedQueries, sampleQueries]); return ( diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index cf834888b4f0..5fe1bac50891 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -77,8 +77,6 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalR useEffect(() => { const subscription = data$.subscribe((next) => { - if (next.status === ResultStatus.LOADING) return; - let shouldUpdateState = false; if (next.status !== fetchState.status) shouldUpdateState = true; @@ -86,7 +84,13 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalR if (next.bucketInterval && next.bucketInterval !== fetchState.bucketInterval) shouldUpdateState = true; if (next.chartData && next.chartData !== fetchState.chartData) shouldUpdateState = true; - if (next.rows && next.rows !== fetchState.rows) { + // we still want to show rows from the previous query while current query is loading or the current query results in error + if ( + next.status !== ResultStatus.LOADING && + next.status !== ResultStatus.ERROR && + next.rows && + next.rows !== fetchState.rows + ) { shouldUpdateState = true; setRows(next.rows); } @@ -153,30 +157,27 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalR timeFieldName={timeField} /> )} - {fetchState.status === ResultStatus.ERROR && ( - - )} {fetchState.status === ResultStatus.UNINITIALIZED && ( refetch$.next()} /> )} - {fetchState.status === ResultStatus.LOADING && } - {fetchState.status === ResultStatus.READY && isEnhancementsEnabled && ( - <> - - - - )} - {fetchState.status === ResultStatus.READY && !isEnhancementsEnabled && ( - - - - + {fetchState.status === ResultStatus.LOADING && !rows?.length && } + {fetchState.status === ResultStatus.ERROR && !rows?.length && ( + refetch$.next()} /> )} + {(fetchState.status === ResultStatus.READY || + (fetchState.status === ResultStatus.LOADING && !!rows?.length) || + (fetchState.status === ResultStatus.ERROR && !!rows?.length)) && + (isEnhancementsEnabled ? ( + <> + + + + ) : ( + + + + + ))} ) : ( <> 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 7f0d95296373..b76651899b61 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 @@ -110,6 +110,48 @@ describe('useSearch', () => { }); }); + it('should initialize with uninitialized state when dataset type config search on page load is disabled', async () => { + const services = createMockServices(); + (services.uiSettings.get as jest.Mock).mockReturnValueOnce(true); + (services.data.query.queryString.getDatasetService as jest.Mock).mockReturnValue({ + meta: { searchOnLoad: false }, + }); + (services.data.query.timefilter.timefilter.getRefreshInterval as jest.Mock).mockReturnValue({ + pause: true, + value: 10, + }); + + const { result, waitForNextUpdate } = renderHook(() => useSearch(services), { wrapper }); + expect(result.current.data$.getValue()).toEqual( + expect.objectContaining({ status: ResultStatus.UNINITIALIZED }) + ); + + await act(async () => { + await waitForNextUpdate(); + }); + }); + + it('should initialize with uninitialized state when dataset type config search on page load is enabled but the UI setting is disabled', async () => { + const services = createMockServices(); + (services.uiSettings.get as jest.Mock).mockReturnValueOnce(false); + (services.data.query.queryString.getDatasetService as jest.Mock).mockReturnValue({ + meta: { searchOnLoad: true }, + }); + (services.data.query.timefilter.timefilter.getRefreshInterval as jest.Mock).mockReturnValue({ + pause: true, + value: 10, + }); + + const { result, waitForNextUpdate } = renderHook(() => useSearch(services), { wrapper }); + expect(result.current.data$.getValue()).toEqual( + expect.objectContaining({ status: ResultStatus.UNINITIALIZED }) + ); + + await act(async () => { + await waitForNextUpdate(); + }); + }); + it('should update startTime when hook rerenders', async () => { const services = createMockServices(); @@ -139,23 +181,29 @@ describe('useSearch', () => { wrapper, }); + act(() => { + mockDatasetUpdates$.next({ + dataset: { id: 'new-dataset-id', title: 'New Dataset', type: 'INDEX_PATTERN' }, + }); + }); + act(() => { result.current.data$.next({ status: ResultStatus.READY }); }); + act(() => { + mockDatasetUpdates$.next({ + dataset: { id: 'new-dataset-id', title: 'New Dataset', type: 'INDEX_PATTERN' }, + }); + }); + expect(result.current.data$.getValue()).toEqual( expect.objectContaining({ status: ResultStatus.READY }) ); act(() => { mockDatasetUpdates$.next({ - dataset: { id: 'new-dataset-id', title: 'New Dataset', type: 'INDEX_PATTERN' }, - }); - mockDatasetUpdates$.next({ - dataset: { id: 'new-dataset-id', title: 'New Dataset', type: 'INDEX_PATTERN' }, - }); - mockDatasetUpdates$.next({ - dataset: { id: 'new-dataset-id2', title: 'New Dataset', type: 'INDEX_PATTERN' }, + dataset: { id: 'different-dataset-id', title: 'New Dataset', type: 'INDEX_PATTERN' }, }); }); @@ -164,7 +212,7 @@ describe('useSearch', () => { }); expect(result.current.data$.getValue()).toEqual( - expect.objectContaining({ status: ResultStatus.LOADING }) + expect.objectContaining({ status: ResultStatus.LOADING, rows: [] }) ); }); }); 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 b8480bb03245..158a9cd46074 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 @@ -115,24 +115,23 @@ export const useSearch = (services: DiscoverViewServices) => { requests: new RequestAdapter(), }; - const getDatasetAutoSearchOnPageLoadPreference = () => { - // Checks the searchOnpageLoadPreference for the current dataset if not specifed defaults to true - const datasetType = data.query.queryString.getQuery().dataset?.type; - - const datasetService = data.query.queryString.getDatasetService(); - - return !datasetType || (datasetService?.getType(datasetType)?.meta?.searchOnLoad ?? true); - }; - const shouldSearchOnPageLoad = useCallback(() => { + // Checks the searchOnpageLoadPreference for the current dataset if not specifed defaults to UI Settings + const { queryString } = data.query; + const { dataset } = queryString.getQuery(); + const typeConfig = dataset ? queryString.getDatasetService().getType(dataset.type) : undefined; + const datasetPreference = + typeConfig?.meta?.searchOnLoad ?? uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING); + // A saved search is created on every page load, so we check the ID to see if we're loading a // previously saved search or if it is just transient return ( - services.uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING) || + datasetPreference || + uiSettings.get(SEARCH_ON_PAGE_LOAD_SETTING) || savedSearch?.id !== undefined || timefilter.getRefreshInterval().pause === false ); - }, [savedSearch, services.uiSettings, timefilter]); + }, [data.query, savedSearch, uiSettings, timefilter]); const startTime = Date.now(); const data$ = useMemo( @@ -155,7 +154,7 @@ export const useSearch = (services: DiscoverViewServices) => { .getUpdates$() .pipe( pairwise(), - filter(([prev, curr]) => prev.dataset?.id === curr.dataset?.id) + filter(([prev, curr]) => prev.dataset?.id !== curr.dataset?.id) ) .subscribe(() => { data$.next({ @@ -164,6 +163,7 @@ export const useSearch = (services: DiscoverViewServices) => { ? ResultStatus.LOADING : ResultStatus.UNINITIALIZED, queryStatus: { startTime }, + rows: [], }); }); return () => subscription.unsubscribe(); @@ -186,11 +186,12 @@ export const useSearch = (services: DiscoverViewServices) => { const refetch$ = useMemo(() => new Subject(), []); const fetch = useCallback(async () => { + const currentTime = Date.now(); let dataset = indexPattern; if (!dataset) { data$.next({ status: shouldSearchOnPageLoad() ? ResultStatus.LOADING : ResultStatus.UNINITIALIZED, - queryStatus: { startTime }, + queryStatus: { startTime: currentTime }, }); return; } @@ -220,10 +221,7 @@ export const useSearch = (services: DiscoverViewServices) => { let elapsedMs; try { - // Only show loading indicator if we are fetching when the rows are empty - if (fetchStateRef.current.rows?.length === 0) { - data$.next({ status: ResultStatus.LOADING, queryStatus: { startTime } }); - } + data$.next({ status: ResultStatus.LOADING, queryStatus: { startTime: currentTime } }); // Initialize inspect adapter for search source inspectorAdapters.requests.reset(); @@ -341,16 +339,12 @@ export const useSearch = (services: DiscoverViewServices) => { services, sort, savedSearch?.searchSource, - startTime, data$, shouldSearchOnPageLoad, inspectorAdapters.requests, ]); useEffect(() => { - if (!getDatasetAutoSearchOnPageLoadPreference()) { - skipInitialFetch.current = true; - } const fetch$ = merge( refetch$, filterManager.getFetches$(), @@ -381,8 +375,6 @@ export const useSearch = (services: DiscoverViewServices) => { return () => { subscription.unsubscribe(); }; - // disabling the eslint since we are not adding getDatasetAutoSearchOnPageLoadPreference since this changes when dataset changes and these chnages are already part of data.query.queryString - // eslint-disable-next-line react-hooks/exhaustive-deps }, [ data$, data.query.queryString, diff --git a/src/plugins/query_enhancements/public/query_assist/utils/create_extension.test.tsx b/src/plugins/query_enhancements/public/query_assist/utils/create_extension.test.tsx index 4b0a3b215db3..fc790cc79c11 100644 --- a/src/plugins/query_enhancements/public/query_assist/utils/create_extension.test.tsx +++ b/src/plugins/query_enhancements/public/query_assist/utils/create_extension.test.tsx @@ -12,6 +12,7 @@ import { QueryEditorExtensionDependencies, QueryStringContract } from '../../../ import { dataPluginMock } from '../../../../data/public/mocks'; import { ConfigSchema } from '../../../common/config'; import { clearCache, createQueryAssistExtension } from './create_extension'; +import { ResultStatus } from '../../../../discover/public'; const coreSetupMock = coreMock.createSetup({ pluginStartDeps: { @@ -54,6 +55,8 @@ describe('CreateExtension', () => { onSelectLanguage: jest.fn(), isCollapsed: false, setIsCollapsed: jest.fn(), + query: mockQueryWithIndexPattern, + fetchStatus: ResultStatus.NO_RESULTS, }; afterEach(() => { jest.clearAllMocks(); diff --git a/src/plugins/saved_objects_management/common/types.ts b/src/plugins/saved_objects_management/common/types.ts index e66fae37724e..cd560886f2e1 100644 --- a/src/plugins/saved_objects_management/common/types.ts +++ b/src/plugins/saved_objects_management/common/types.ts @@ -58,4 +58,5 @@ export interface SavedObjectRelation { type: string; relationship: 'child' | 'parent'; meta: SavedObjectMetadata; + workspaces?: SavedObject['workspaces']; } diff --git a/src/plugins/saved_objects_management/public/utils.test.ts b/src/plugins/saved_objects_management/public/utils.test.ts index bcaed2bb9417..fb9a8e328f04 100644 --- a/src/plugins/saved_objects_management/public/utils.test.ts +++ b/src/plugins/saved_objects_management/public/utils.test.ts @@ -52,7 +52,7 @@ describe('Utils', () => { attributes: {}, references: [], meta: { - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/ID1', + editUrl: '/management/opensearch-dashboards/objects/dashboard/ID1', }, }; const savedObjectWithWorkspaces = { @@ -89,7 +89,7 @@ describe('Utils', () => { get: jest.fn().mockReturnValue(false), }; const result = formatInspectUrl(savedObject, mockCoreStart); - expect(result).toBe('/management/opensearch-dashboards/objects/savedDashboards/ID1'); + expect(result).toBe('/app/management/opensearch-dashboards/objects/dashboard/ID1'); }); it('formats URL correctly when useUpdatedUX is false, saved object does not belong to certain workspaces and not in current workspace', () => { @@ -98,7 +98,7 @@ describe('Utils', () => { get: jest.fn().mockReturnValue(false), }; const result = formatInspectUrl(savedObject, mockCoreStart); - expect(result).toBe('/management/opensearch-dashboards/objects/savedDashboards/ID1'); + expect(result).toBe('/app/management/opensearch-dashboards/objects/dashboard/ID1'); }); it('formats URL correctly when useUpdatedUX is true and in current workspace', () => { @@ -106,21 +106,21 @@ describe('Utils', () => { mockCoreStart.workspaces.currentWorkspace$.next(currentWorkspace); const result = formatInspectUrl(savedObjectWithWorkspaces, mockCoreStart); - expect(result).toBe('http://localhost/w/workspace1/app/objects/savedDashboards/ID1'); + expect(result).toBe('http://localhost/w/workspace1/app/objects/dashboard/ID1'); }); it('formats URL correctly when useUpdatedUX is true and saved object belongs to certain workspaces', () => { mockCoreStart.workspaces.workspaceList$.next([{ id: 'workspace1', name: 'workspace1' }]); const result = formatInspectUrl(savedObjectWithWorkspaces, mockCoreStart); - expect(result).toBe('http://localhost/w/workspace1/app/objects/savedDashboards/ID1'); + expect(result).toBe('http://localhost/w/workspace1/app/objects/dashboard/ID1'); }); it('formats URL correctly when useUpdatedUX is true and the object does not belong to any workspace', () => { mockCoreStart.workspaces.workspaceList$.next([{ id: 'workspace2', name: 'workspace2' }]); const result = formatInspectUrl(savedObjectWithWorkspaces, mockCoreStart); - expect(result).toBe('/app/objects/savedDashboards/ID1'); + expect(result).toBe('/app/objects/dashboard/ID1'); }); }); }); diff --git a/src/plugins/saved_objects_management/public/utils.ts b/src/plugins/saved_objects_management/public/utils.ts index 9dada18e8711..61f3670fed5c 100644 --- a/src/plugins/saved_objects_management/public/utils.ts +++ b/src/plugins/saved_objects_management/public/utils.ts @@ -25,9 +25,10 @@ export function formatInspectUrl( const useUpdatedUX = !!coreStart.uiSettings.get('home:useNewHomePage'); let finalEditUrl = editUrl; if (useUpdatedUX && finalEditUrl) { - finalEditUrl = finalEditUrl.replace(/^\/management\/opensearch-dashboards/, '/app'); + finalEditUrl = finalEditUrl.replace(/^\/management\/opensearch-dashboards/, ''); } if (finalEditUrl) { + finalEditUrl = `/app${finalEditUrl}`; const basePath = coreStart.http.basePath; let inAppUrl = basePath.prepend(finalEditUrl); const workspaceEnabled = coreStart.application.capabilities.workspaces.enabled; diff --git a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts index c9ee0a1e766d..b34e65dbc6f6 100644 --- a/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts +++ b/src/plugins/saved_objects_management/server/lib/find_relationships.test.ts @@ -72,12 +72,14 @@ describe('findRelationships', () => { id: 'ref-1', attributes: {}, references: [], + workspaces: ['workspace1'], }, { type: 'another-type', id: 'ref-2', attributes: {}, references: [], + workspaces: ['workspace1'], }, ], }); @@ -90,6 +92,7 @@ describe('findRelationships', () => { attributes: {}, score: 1, references: [], + workspaces: ['workspace1'], }, ], total: 1, @@ -130,18 +133,21 @@ describe('findRelationships', () => { relationship: 'child', type: 'some-type', meta: expect.any(Object), + workspaces: ['workspace1'], }, { id: 'ref-2', relationship: 'child', type: 'another-type', meta: expect.any(Object), + workspaces: ['workspace1'], }, { id: 'parent-id', relationship: 'parent', type: 'parent-type', meta: expect.any(Object), + workspaces: ['workspace1'], }, ]); }); diff --git a/src/plugins/saved_objects_management/server/lib/find_relationships.ts b/src/plugins/saved_objects_management/server/lib/find_relationships.ts index 4a4ed8155c8c..a2582ed7074f 100644 --- a/src/plugins/saved_objects_management/server/lib/find_relationships.ts +++ b/src/plugins/saved_objects_management/server/lib/find_relationships.ts @@ -95,5 +95,6 @@ function extractCommonProperties(savedObject: SavedObjectWithMetadata) { id: savedObject.id, type: savedObject.type, meta: savedObject.meta, + workspaces: savedObject.workspaces, }; } diff --git a/yarn.lock b/yarn.lock index cc9f4490818d..5b3dec208a45 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6295,21 +6295,10 @@ create-hmac@^1.1.0, create-hmac@^1.1.4, create-hmac@^1.1.7: safe-buffer "^5.0.1" sha.js "^2.4.8" -cross-spawn@^6.0.5: - version "6.0.5" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-6.0.5.tgz#4a5ec7c64dfae22c3a14124dbacdee846d80cbc4" - integrity sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ== - dependencies: - nice-try "^1.0.4" - path-key "^2.0.1" - semver "^5.5.0" - shebang-command "^1.2.0" - which "^1.2.9" - -cross-spawn@^7.0.0, cross-spawn@^7.0.3: - version "7.0.3" - resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" - integrity sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w== +cross-spawn@^6.0.5, cross-spawn@^7.0.0, cross-spawn@^7.0.3, cross-spawn@^7.0.5: + version "7.0.5" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.5.tgz#910aac880ff5243da96b728bc6521a5f6c2f2f82" + integrity sha512-ZVJrKKYunU38/76t0RMOulHOnUcbU9GbpWKAOZ0mhjr7CX6FVrH+4FrAapSOekrgFQ3f/8gwMEuIft0aKq6Hug== dependencies: path-key "^3.1.0" shebang-command "^2.0.0" @@ -12753,11 +12742,6 @@ next-tick@1, next-tick@^1.1.0: resolved "https://registry.yarnpkg.com/next-tick/-/next-tick-1.1.0.tgz#1836ee30ad56d67ef281b22bd199f709449b35eb" integrity sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ== -nice-try@^1.0.4: - version "1.0.5" - resolved "https://registry.yarnpkg.com/nice-try/-/nice-try-1.0.5.tgz#a3378a7696ce7d223e88fc9b764bd7ef1089e366" - integrity sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ== - nise@^1.5.2: version "1.5.3" resolved "https://registry.yarnpkg.com/nise/-/nise-1.5.3.tgz#9d2cfe37d44f57317766c6e9408a359c5d3ac1f7" @@ -13528,11 +13512,6 @@ path-is-inside@^1.0.2: resolved "https://registry.yarnpkg.com/path-is-inside/-/path-is-inside-1.0.2.tgz#365417dede44430d1c11af61027facf074bdfc53" integrity sha1-NlQX3t5EQw0cEa9hAn+s8HS9/FM= -path-key@^2.0.1: - version "2.0.1" - resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" - integrity sha1-QRyttXTFoUDTpLGRDUDYDMn0C0A= - path-key@^3.0.0, path-key@^3.1.0: version "3.1.1" resolved "https://registry.yarnpkg.com/path-key/-/path-key-3.1.1.tgz#581f6ade658cbba65a0d3380de7753295054f375" @@ -15376,7 +15355,7 @@ selenium-webdriver@^4.0.0-alpha.7: rimraf "^2.7.1" tmp "0.0.30" -"semver@2 || 3 || 4 || 5", semver@7.3.2, semver@^5.3.0, semver@^5.5.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1, semver@^5.7.2, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@~7.3.0: +"semver@2 || 3 || 4 || 5", semver@7.3.2, semver@^5.3.0, semver@^5.6.0, semver@^5.7.0, semver@^5.7.1, semver@^5.7.2, semver@^6.0.0, semver@^6.1.0, semver@^6.1.2, semver@^6.3.0, semver@^6.3.1, semver@^7.3.2, semver@^7.3.4, semver@^7.3.5, semver@^7.3.8, semver@^7.5.3, semver@^7.5.4, semver@~7.3.0: version "7.5.3" resolved "https://registry.yarnpkg.com/semver/-/semver-7.5.3.tgz#161ce8c2c6b4b3bdca6caadc9fa3317a4c4fe88e" integrity sha512-QBlUtyVk/5EeHbi7X0fw6liDZc7BBmEaSYn01fMU1OUYbf6GPsbTtd8WmnqbI20SeycoHSeiybkE/q1Q+qlThQ== @@ -15481,13 +15460,6 @@ shallowequal@^1.1.0: resolved "https://registry.yarnpkg.com/shallowequal/-/shallowequal-1.1.0.tgz#188d521de95b9087404fd4dcb68b13df0ae4e7f8" integrity sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ== -shebang-command@^1.2.0: - version "1.2.0" - resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" - integrity sha1-RKrGW2lbAzmJaMOfNj/uXer98eo= - dependencies: - shebang-regex "^1.0.0" - shebang-command@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-2.0.0.tgz#ccd0af4f8835fbdc265b82461aaf0c36663f34ea" @@ -15495,11 +15467,6 @@ shebang-command@^2.0.0: dependencies: shebang-regex "^3.0.0" -shebang-regex@^1.0.0: - version "1.0.0" - resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" - integrity sha1-2kL0l0DAtC2yypcoVxyxkMmO/qM= - shebang-regex@^3.0.0: version "3.0.0" resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-3.0.0.tgz#ae16f1644d873ecad843b0307b143362d4c42172" @@ -18259,7 +18226,7 @@ which-typed-array@^1.1.11, which-typed-array@^1.1.13, which-typed-array@^1.1.2: gopd "^1.0.1" has-tostringtag "^1.0.0" -which@^1.2.14, which@^1.2.9, which@^1.3.1: +which@^1.2.14, which@^1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ==