diff --git a/.github/workflows/add-untriaged.yml b/.github/workflows/add-untriaged.yml index 9dcc7020d245..9f4a14bfb11f 100644 --- a/.github/workflows/add-untriaged.yml +++ b/.github/workflows/add-untriaged.yml @@ -6,7 +6,7 @@ on: jobs: apply-label: - runs-on: ubuntu-latest + runs-on: arc-runner-set steps: - uses: actions/github-script@v6 with: diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index c8dfef417daa..7fd7ea23e641 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -7,7 +7,7 @@ on: jobs: backport: - runs-on: ubuntu-latest + runs-on: arc-runner-set permissions: contents: write pull-requests: write @@ -31,7 +31,7 @@ jobs: app_id: ${{ secrets.APP_ID }} private_key: ${{ secrets.APP_PRIVATE_KEY }} # opensearch-trigger-bot installation ID - installation_id: 22958780 + installation_id: 41494816 - name: Backport uses: VachaShah/backport@v2.2.0 diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index c954520a645d..16c2d3f4011b 100644 --- a/.github/workflows/build_and_test_workflow.yml +++ b/.github/workflows/build_and_test_workflow.yml @@ -25,7 +25,7 @@ env: TEST_OPENSEARCH_TRANSPORT_PORT: 9403 TEST_OPENSEARCH_PORT: 9400 OSD_SNAPSHOT_SKIP_VERIFY_CHECKSUM: true - NODE_OPTIONS: "--max-old-space-size=6144 --dns-result-order=ipv4first" + NODE_OPTIONS: '--max-old-space-size=6144 --dns-result-order=ipv4first' jobs: build-lint-test: @@ -33,13 +33,11 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [arc-runner-set] group: [1, 2, 3, 4] include: - - os: ubuntu-latest + - os: arc-runner-set name: Linux - - os: windows-latest - name: Windows runs-on: ${{ matrix.os }} steps: - name: Configure git's autocrlf (Windows only) @@ -53,7 +51,7 @@ jobs: with: minimum-size: 16GB maximum-size: 64GB - disk-root: "C:" + disk-root: 'C:' - name: Checkout code uses: actions/checkout@v3 @@ -100,13 +98,13 @@ jobs: - name: Run linter # ciGroup 1 of unit-tests is shorter and Linux is faster - if: matrix.group == 1 && matrix.os == 'ubuntu-latest' + if: matrix.group == 1 && matrix.os == 'arc-runner-set' id: linter run: yarn lint - name: Validate NOTICE file # ciGroup 1 of unit-tests is shorter and Linux is faster - if: matrix.group == 1 && matrix.os == 'ubuntu-latest' + if: matrix.group == 1 && matrix.os == 'arc-runner-set' id: notice-validate run: yarn notice:validate @@ -138,13 +136,11 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [arc-runner-set] group: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] include: - - os: ubuntu-latest + - os: arc-runner-set name: Linux - - os: windows-latest - name: Windows runs-on: ${{ matrix.os }} steps: - run: echo Running functional tests for ciGroup${{ matrix.group }} @@ -160,7 +156,7 @@ jobs: with: minimum-size: 16GB maximum-size: 64GB - disk-root: "C:" + disk-root: 'C:' - name: Checkout code uses: actions/checkout@v3 @@ -254,7 +250,7 @@ jobs: with: minimum-size: 16GB maximum-size: 64GB - disk-root: "C:" + disk-root: 'C:' - name: Checkout code uses: actions/checkout@v3 @@ -322,31 +318,16 @@ jobs: strategy: matrix: include: - - os: ubuntu-latest + - os: arc-runner-set name: Linux x64 ext: tar.gz suffix: linux-x64 script: build-platform --linux --skip-os-packages - - os: ubuntu-latest + - os: arc-runner-set name: Linux ARM64 ext: tar.gz suffix: linux-arm64 script: build-platform --linux-arm --skip-os-packages - - os: macos-latest - name: macOS x64 - ext: tar.gz - suffix: darwin-x64 - script: build-platform --darwin --skip-os-packages - - os: macos-latest - name: macOS ARM64 - ext: tar.gz - suffix: darwin-arm64 - script: build-platform --darwin-arm --skip-os-packages - - os: windows-latest - name: Windows x64 - ext: zip - suffix: windows-x64 - script: build-platform --windows --skip-os-packages runs-on: ${{ matrix.os }} defaults: run: @@ -364,7 +345,7 @@ jobs: with: minimum-size: 16GB maximum-size: 64GB - disk-root: "C:" + disk-root: 'C:' - name: Checkout code uses: actions/checkout@v3 @@ -430,8 +411,9 @@ jobs: retention-days: 1 bwc-tests: + if: false needs: [build-min-artifact-tests] - runs-on: ubuntu-latest + runs-on: arc-runner-set container: image: docker://opensearchstaging/ci-runner:ci-runner-rockylinux8-opensearch-dashboards-integtest-v2 options: --user 1001 @@ -441,7 +423,19 @@ jobs: working-directory: ./artifacts strategy: matrix: - version: [osd-2.0.0, osd-2.1.0, osd-2.2.0, osd-2.3.0, osd-2.4.0, osd-2.5.0, osd-2.6.0, osd-2.7.0, osd-2.8.0, osd-2.9.0] + version: + [ + osd-2.0.0, + osd-2.1.0, + osd-2.2.0, + osd-2.3.0, + osd-2.4.0, + osd-2.5.0, + osd-2.6.0, + osd-2.7.0, + osd-2.8.0, + osd-2.9.0, + ] steps: - name: Checkout code uses: actions/checkout@v3 diff --git a/.github/workflows/changelog_verifier.yml b/.github/workflows/changelog_verifier.yml index 0890ea8b8fbb..fbcf411779af 100644 --- a/.github/workflows/changelog_verifier.yml +++ b/.github/workflows/changelog_verifier.yml @@ -7,7 +7,7 @@ on: jobs: # Enforces the update of a changelog file on every pull request verify-changelog: - runs-on: ubuntu-latest + runs-on: arc-runner-set steps: - uses: actions/checkout@v3 with: diff --git a/.github/workflows/create_doc_issue.yml b/.github/workflows/create_doc_issue.yml index 299aa576b3bc..2a117288a9e1 100644 --- a/.github/workflows/create_doc_issue.yml +++ b/.github/workflows/create_doc_issue.yml @@ -9,7 +9,7 @@ env: jobs: create-issue: if: ${{ github.event.label.name == 'needs-documentation' }} - runs-on: ubuntu-latest + runs-on: arc-runner-set name: Create Documentation Issue steps: - name: GitHub App token diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index 14975e23b17a..ec904ffdfcd4 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -3,7 +3,7 @@ name: Run cypress tests # trigger on every PR for all branches on: pull_request: - branches: [ '**' ] + branches: ['**'] paths-ignore: - '**/*.md' workflow_dispatch: @@ -15,6 +15,8 @@ on: type: string test_branch: description: 'Cypress test branch (default: source branch)' + # remove this default value + default: 'workspace' required: false type: string specs: @@ -43,11 +45,11 @@ env: jobs: cypress-tests: - runs-on: ubuntu-latest + runs-on: arc-runner-set strategy: fail-fast: false matrix: - group: [1, 2, 3, 4, 5, 6, 7, 8, 9] + group: [1, 2, 3, 4, 5, 6, 7, 8, 9] container: image: docker://opensearchstaging/ci-runner:ci-runner-rockylinux8-opensearch-dashboards-integtest-v2 options: --user 1001 @@ -156,7 +158,7 @@ jobs: name: ftr-cypress-screenshots path: ${{ env.FTR_PATH }}/cypress/screenshots retention-days: 1 - + - uses: actions/upload-artifact@v3 if: always() with: @@ -184,7 +186,7 @@ jobs: with: issue-number: ${{ inputs.pr_number }} comment-author: 'github-actions[bot]' - body-includes: "${{ env.COMMENT_TAG }}" + body-includes: '${{ env.COMMENT_TAG }}' - name: Add comment on the PR uses: peter-evans/create-or-update-comment@v3 @@ -205,6 +207,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/delete_backport_branch.yml b/.github/workflows/delete_backport_branch.yml index 387a124b8cb6..99c174e8e1af 100644 --- a/.github/workflows/delete_backport_branch.yml +++ b/.github/workflows/delete_backport_branch.yml @@ -6,7 +6,7 @@ on: jobs: delete-branch: - runs-on: ubuntu-latest + runs-on: arc-runner-set if: startsWith(github.event.pull_request.head.ref,'backport/') steps: - name: Delete merged branch diff --git a/.github/workflows/github-workflow-badger.yml b/.github/workflows/github-workflow-badger.yml index 2a0debf326ed..f30cb6ee18c0 100644 --- a/.github/workflows/github-workflow-badger.yml +++ b/.github/workflows/github-workflow-badger.yml @@ -7,7 +7,7 @@ on: jobs: call-action: - runs-on: ubuntu-latest + runs-on: arc-runner-set permissions: pull-requests: write steps: diff --git a/.github/workflows/links_checker.yml b/.github/workflows/links_checker.yml index c02921d96f91..ffd276bd76a0 100644 --- a/.github/workflows/links_checker.yml +++ b/.github/workflows/links_checker.yml @@ -12,7 +12,7 @@ on: jobs: linkchecker: - runs-on: ubuntu-latest + runs-on: arc-runner-set steps: - uses: actions/checkout@v2 diff --git a/CHANGELOG.md b/CHANGELOG.md index b6d9397a53d6..5d07308278f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -43,6 +43,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Multiple Datasource] Add api registry and allow it to be added into client config in data source plugin ([#5895](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5895)) - [Multiple Datasource] Concatenate data source name with index pattern name and change delimiter to double colon ([#5907](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5907)) - [Multiple Datasource] Refactor client and legacy client to use authentication registry ([#5881](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5881)) +- [Workspace] Optional workspaces params in repository ([#5949](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5949)) ### 🐛 Bug Fixes diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 9797335e3cce..0e5beac120c0 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -274,4 +274,10 @@ # opensearchDashboards.survey.url: "https://survey.opensearch.org" # Set the value of this setting to true to enable plugin augmentation on Dashboard -# vis_augmenter.pluginAugmentationEnabled: true \ No newline at end of file +# vis_augmenter.pluginAugmentationEnabled: true + +# Set the value to true enable workspace feature +# workspace.enabled: false +# Set the value to false to disable permission check on workspace +# Permission check depends on OpenSearch Dashboards has authentication enabled, set it to false if no authentication is configured +# workspace.permission.enabled: true diff --git a/cypress/integration/with-security/check_advanced_settings.js b/cypress/integration/with-security/check_advanced_settings.js index 9ca41207724e..379362063e92 100644 --- a/cypress/integration/with-security/check_advanced_settings.js +++ b/cypress/integration/with-security/check_advanced_settings.js @@ -13,7 +13,7 @@ const loginPage = new LoginPage(cy); describe('verify the advanced settings are saved', () => { beforeEach(() => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); loginPage.enterUserName('admin'); loginPage.enterPassword('admin'); loginPage.submit(); diff --git a/cypress/integration/with-security/helpers/generate_data.js b/cypress/integration/with-security/helpers/generate_data.js index dcd711fc7c18..c2c4d2dbe57d 100755 --- a/cypress/integration/with-security/helpers/generate_data.js +++ b/cypress/integration/with-security/helpers/generate_data.js @@ -13,7 +13,7 @@ const loginPage = new LoginPage(cy); describe('Generating BWC test data with security', () => { beforeEach(() => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); loginPage.enterUserName('admin'); loginPage.enterPassword('admin'); loginPage.submit(); @@ -29,7 +29,7 @@ describe('Generating BWC test data with security', () => { }); it('adds advanced settings', () => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); cy.get('[data-test-subj="advancedSetting-editField-theme:darkMode"]').click(); cy.get('[data-test-subj="advancedSetting-editField-timeline:max_buckets"]').type( '{selectAll}4' diff --git a/cypress/integration/without-security/check_advanced_settings.js b/cypress/integration/without-security/check_advanced_settings.js index 9268d86a16e5..0094d53835b0 100644 --- a/cypress/integration/without-security/check_advanced_settings.js +++ b/cypress/integration/without-security/check_advanced_settings.js @@ -9,7 +9,7 @@ const miscUtils = new MiscUtils(cy); describe('verify the advanced settings are saved', () => { beforeEach(() => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); }); it('the dark mode is on', () => { diff --git a/cypress/integration/without-security/helpers/generate_data.js b/cypress/integration/without-security/helpers/generate_data.js index 47e9c2f5f5ed..3aff136a70e0 100755 --- a/cypress/integration/without-security/helpers/generate_data.js +++ b/cypress/integration/without-security/helpers/generate_data.js @@ -12,7 +12,7 @@ describe('Generating BWC test data without security', () => { miscUtils.visitPage('app'); }); it('adds advanced settings', () => { - miscUtils.visitPage('app/management/opensearch-dashboards/settings'); + miscUtils.visitPage('app/settings'); cy.get('[data-test-subj="advancedSetting-editField-theme:darkMode"]').click(); cy.get('[data-test-subj="advancedSetting-editField-timeline:max_buckets"]').type( '{selectAll}4' diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 4744ab34cfd3..792d5195c4c6 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -245,6 +245,16 @@ export interface App { * ``` */ exactRoute?: boolean; + + /** + * The dependencies of one application, required feature will be automatic select and can't + * be unselect in the workspace configuration. + */ + dependencies?: { + [key: string]: { + type: 'required' | 'optional'; + }; + }; } /** diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index b6ce429528a7..566a6b7095e5 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -43,7 +43,9 @@ const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { getHeaderComponent: jest.fn(), navLinks: { + setNavLinks: jest.fn(), getNavLinks$: jest.fn(), + getAllNavLinks$: jest.fn(), has: jest.fn(), get: jest.fn(), getAll: jest.fn(), diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index be879bb4b5e9..193b77107ffd 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -41,6 +41,7 @@ import { notificationServiceMock } from '../notifications/notifications_service. import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { ChromeService } from './chrome_service'; import { getAppInfo } from '../application/utils'; +import { workspacesServiceMock } from '../workspace/workspaces_service.mock'; class FakeApp implements App { public title: string; @@ -70,6 +71,7 @@ function defaultStartDeps(availableApps?: App[]) { injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createStartContract(), uiSettings: uiSettingsServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), }; if (availableApps) { diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 57c9f11d9061..60516d6a1537 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -34,7 +34,7 @@ import { FormattedMessage } from '@osd/i18n/react'; import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { EuiLink } from '@elastic/eui'; -import { mountReactNode } from '../utils/mount'; +import { mountReactNode } from '../utils'; import { InternalApplicationStart } from '../application'; import { DocLinksStart } from '../doc_links'; import { HttpStart } from '../http'; @@ -48,7 +48,7 @@ import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; -import { Branding } from '../'; +import { Branding, WorkspacesStart } from '../'; import { getLogos } from '../../common'; import type { Logos } from '../../common/types'; @@ -96,9 +96,15 @@ export interface StartDeps { injectedMetadata: InjectedMetadataStart; notifications: NotificationsStart; uiSettings: IUiSettingsClient; + workspaces: WorkspacesStart; } -type CollapsibleNavHeaderRender = () => JSX.Element | null; +type CollapsibleNavHeaderRender = (context: { + basePath: HttpStart['basePath']; + getUrlForApp: InternalApplicationStart['getUrlForApp']; + navigateToUrl: InternalApplicationStart['navigateToUrl']; + workspaces: WorkspacesStart; +}) => JSX.Element | null; /** @internal */ export class ChromeService { @@ -166,6 +172,7 @@ export class ChromeService { injectedMetadata, notifications, uiSettings, + workspaces, }: StartDeps): Promise { this.initVisibility(application); @@ -196,6 +203,16 @@ export class ChromeService { localStorage.setItem(IS_LOCKED_KEY, `${isLocked}`); }; + const collapsibleNavHeaderRender = () => + this.collapsibleNavHeaderRender + ? this.collapsibleNavHeaderRender({ + basePath: http.basePath, + workspaces, + getUrlForApp: application.getUrlForApp, + navigateToUrl: application.navigateToUrl, + }) + : null; + const getIsNavDrawerLocked$ = isNavDrawerLocked$.pipe(takeUntil(this.stop$)); const logos = getLogos(injectedMetadata.getBranding(), http.basePath.serverBasePath); @@ -259,7 +276,6 @@ export class ChromeService { badge$={badge$.pipe(takeUntil(this.stop$))} basePath={http.basePath} breadcrumbs$={breadcrumbs$.pipe(takeUntil(this.stop$))} - customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))} opensearchDashboardsDocLink={docLinks.links.opensearchDashboards.introduction} forceAppSwitcherNavigation$={navLinks.getForceAppSwitcherNavigation$()} helpExtension$={helpExtension$.pipe(takeUntil(this.stop$))} @@ -268,6 +284,7 @@ export class ChromeService { isVisible$={this.isVisible$} opensearchDashboardsVersion={injectedMetadata.getOpenSearchDashboardsVersion()} navLinks$={navLinks.getNavLinks$()} + customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))} recentlyAccessed$={recentlyAccessed.get$()} navControlsLeft$={navControls.getLeft$()} navControlsCenter$={navControls.getCenter$()} @@ -279,7 +296,7 @@ export class ChromeService { branding={injectedMetadata.getBranding()} logos={logos} survey={injectedMetadata.getSurvey()} - collapsibleNavHeaderRender={this.collapsibleNavHeaderRender} + collapsibleNavHeaderRender={collapsibleNavHeaderRender} /> ), diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index cddd45234514..19e2fd2eddab 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -93,8 +93,10 @@ export interface ChromeNavLink { * Disables a link from being clickable. * * @internalRemarks - * This is only used by the ML and Graph plugins currently. They use this field + * This is used by the ML and Graph plugins. They use this field * to disable the nav link when the license is expired. + * This is also used by recently visited category in left menu + * to disable "No recently visited items". */ readonly disabled?: boolean; @@ -102,6 +104,11 @@ export interface ChromeNavLink { * Hides a link from the navigation. */ readonly hidden?: boolean; + + /** + * Links can be navigated through url. + */ + readonly externalLink?: boolean; } /** @public */ diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index 3fe2b57676e0..8f4f5dbfa4d6 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -32,18 +32,12 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App } from '../../application'; import { BehaviorSubject } from 'rxjs'; +import { ChromeNavLink } from 'opensearch-dashboards/public'; const availableApps = new Map([ ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], - [ - 'app2', - { - id: 'app2', - order: -10, - title: 'App 2', - euiIconType: 'canvasApp', - }, - ], + ['app2', { id: 'app2', order: -10, title: 'App 2', euiIconType: 'canvasApp' }], + ['app3', { id: 'app3', order: 10, title: 'App 3', icon: 'app3' }], ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], ]); @@ -66,7 +60,110 @@ describe('NavLinksService', () => { start = service.start({ application: mockAppService, http: mockHttp }); }); - describe('#getNavLinks$()', () => { + describe('#getAllNavLinks$()', () => { + it('does not include `chromeless` applications', async () => { + expect( + await start + .getAllNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).not.toContain('chromelessApp'); + }); + + it('sorts navLinks by `order` property', async () => { + expect( + await start + .getAllNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).toEqual(['app2', 'app1', 'app3']); + }); + + it('emits multiple values', async () => { + const navLinkIds$ = start.getAllNavLinks$().pipe(map((links) => links.map((l) => l.id))); + const emittedLinks: string[][] = []; + navLinkIds$.subscribe((r) => emittedLinks.push(r)); + start.update('app1', { href: '/foo' }); + + service.stop(); + expect(emittedLinks).toEqual([ + ['app2', 'app1', 'app3'], + ['app2', 'app1', 'app3'], + ]); + }); + + it('completes when service is stopped', async () => { + const last$ = start.getAllNavLinks$().pipe(takeLast(1)).toPromise(); + service.stop(); + await expect(last$).resolves.toBeInstanceOf(Array); + }); + }); + + describe('#getNavLinks$() when non null', () => { + // set nav links, nav link with order smaller than 0 will be filtered + beforeEach(() => { + const navLinks = new Map(); + start.getAllNavLinks$().subscribe((links) => + links.forEach((link) => { + if (link.order !== undefined && link.order >= 0) { + navLinks.set(link.id, link); + } + }) + ); + start.setNavLinks(navLinks); + }); + + it('does not include `app2` applications', async () => { + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).not.toContain('app2'); + }); + + it('sorts navLinks by `order` property', async () => { + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).toEqual(['app1', 'app3']); + }); + + it('emits multiple values', async () => { + const navLinkIds$ = start.getNavLinks$().pipe(map((links) => links.map((l) => l.id))); + const emittedLinks: string[][] = []; + navLinkIds$.subscribe((r) => emittedLinks.push(r)); + start.update('app1', { href: '/foo' }); + + service.stop(); + expect(emittedLinks).toEqual([ + ['app1', 'app3'], + ['app1', 'app3'], + ]); + }); + + it('completes when service is stopped', async () => { + const last$ = start.getNavLinks$().pipe(takeLast(1)).toPromise(); + service.stop(); + await expect(last$).resolves.toBeInstanceOf(Array); + }); + }); + + describe('#getNavLinks$() when null', () => { it('does not include `chromeless` applications', async () => { expect( await start @@ -79,7 +176,19 @@ describe('NavLinksService', () => { ).not.toContain('chromelessApp'); }); - it('sorts navlinks by `order` property', async () => { + it('include `app2` applications', async () => { + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).toContain('app2'); + }); + + it('sorts navLinks by `order` property', async () => { expect( await start .getNavLinks$() @@ -88,7 +197,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1']); + ).toEqual(['app2', 'app1', 'app3']); }); it('emits multiple values', async () => { @@ -99,8 +208,8 @@ describe('NavLinksService', () => { service.stop(); expect(emittedLinks).toEqual([ - ['app2', 'app1'], - ['app2', 'app1'], + ['app2', 'app1', 'app3'], + ['app2', 'app1', 'app3'], ]); }); @@ -123,7 +232,7 @@ describe('NavLinksService', () => { describe('#getAll()', () => { it('returns a sorted array of navlinks', () => { - expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1']); + expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1', 'app3']); }); }); @@ -148,7 +257,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1']); + ).toEqual(['app2', 'app1', 'app3']); }); it('does nothing on chromeless applications', async () => { @@ -161,7 +270,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1']); + ).toEqual(['app2', 'app1', 'app3']); }); it('removes all other links', async () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 93c138eac62c..d4c899a57be8 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -53,6 +53,16 @@ export interface ChromeNavLinks { */ getNavLinks$(): Observable>>; + /** + * Get an observable for a sorted list of all navlinks. + */ + getAllNavLinks$(): Observable>>; + + /** + * Set navlinks. + */ + setNavLinks(navLinks: ReadonlyMap): void; + /** * Get the state of a navlink at this point in time. * @param id @@ -132,7 +142,10 @@ export class NavLinksService { // manual link modifications to be able to re-apply then after every // availableApps$ changes. const linkUpdaters$ = new BehaviorSubject([]); - const navLinks$ = new BehaviorSubject>(new Map()); + const displayedNavLinks$ = new BehaviorSubject | undefined>( + undefined + ); + const allNavLinks$ = new BehaviorSubject>(new Map()); combineLatest([appLinks$, linkUpdaters$]) .pipe( @@ -140,28 +153,41 @@ export class NavLinksService { return linkUpdaters.reduce((links, updater) => updater(links), appLinks); }) ) - .subscribe((navlinks) => { - navLinks$.next(navlinks); + .subscribe((navLinks) => { + allNavLinks$.next(navLinks); }); const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { getNavLinks$: () => { - return navLinks$.pipe(map(sortNavLinks), takeUntil(this.stop$)); + return combineLatest([allNavLinks$, displayedNavLinks$]).pipe( + map(([allNavLinks, displayedNavLinks]) => + displayedNavLinks === undefined ? sortLinks(allNavLinks) : sortLinks(displayedNavLinks) + ), + takeUntil(this.stop$) + ); + }, + + setNavLinks: (navLinks: ReadonlyMap) => { + displayedNavLinks$.next(navLinks); + }, + + getAllNavLinks$: () => { + return allNavLinks$.pipe(map(sortLinks), takeUntil(this.stop$)); }, get(id: string) { - const link = navLinks$.value.get(id); + const link = allNavLinks$.value.get(id); return link && link.properties; }, getAll() { - return sortNavLinks(navLinks$.value); + return sortLinks(allNavLinks$.value); }, has(id: string) { - return navLinks$.value.has(id); + return allNavLinks$.value.has(id); }, showOnly(id: string) { @@ -209,9 +235,9 @@ export class NavLinksService { } } -function sortNavLinks(navLinks: ReadonlyMap) { +function sortLinks(links: ReadonlyMap) { return sortBy( - [...navLinks.values()].map((link) => link.properties), + [...links.values()].map((link) => ('properties' in link ? link.properties : link)), 'order' ); } diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index 7b4e3ba472dc..3cc044260d1c 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap @@ -55,9 +55,11 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` BasePath { "basePath": "/test", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -119,8 +121,9 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "thrownError": null, } } + getUrlForApp={[MockFunction]} homeHref="/" - id="collapsibe-nav" + id="collapsible-nav" isLocked={false} isNavOpen={true} logos={ @@ -195,7 +198,6 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { - "euiIconType": "inputOutput", "id": "opensearchDashboards", "label": "OpenSearch Dashboards", "order": 1000, @@ -240,7 +242,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "managementApp", "id": "management", "label": "Management", - "order": 5000, + "order": 6000, }, "data-test-subj": "monitoring", "href": "monitoring", @@ -251,7 +253,6 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { - "euiIconType": "inputOutput", "id": "opensearchDashboards", "label": "OpenSearch Dashboards", "order": 1000, @@ -265,7 +266,6 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { - "euiIconType": "inputOutput", "id": "opensearchDashboards", "label": "OpenSearch Dashboards", "order": 1000, @@ -420,7 +420,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` + +
+
+ +
+ +
+ + + +
+
+ +
+ +
+ + + OpenSearch Dashboards + + +
+
+
+
+
+
+
+
+
- - - - -

- Recently viewed -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" - > -
-
- -
-
- -
-
-
- - - -
-
-
-
-
-
-
-
- -
-
@@ -814,14 +620,14 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiFlexItem eui-yScroll" > @@ -848,15 +654,15 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__title" id="mockId__title" > - OpenSearch Dashboards + Recently Visited } className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="/test/ui/logos/opensearch_mark_on_light.svg" - data-test-subj="collapsibleNavGroup-opensearchDashboards" + data-test-opensearch-logo="clock" + data-test-subj="collapsibleNavGroup-recentlyVisited" id="mockId" initialIsOpen={true} isLoading={false} @@ -866,8 +672,8 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
@@ -935,7 +741,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiTitle euiTitle--xxsmall euiCollapsibleNavGroup__title" id="mockId__title" > - OpenSearch Dashboards + Recently Visited
@@ -963,33 +769,25 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__children" >
    - discover + recent 1 @@ -1040,41 +838,10 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` -
  • - - - visualize - - -
  • -
    - - dashboard + recent 2 @@ -1110,14 +877,14 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -1144,15 +911,15 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__title" id="mockId__title" > - Observability + OpenSearch Dashboards } className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="logoObservability" - data-test-subj="collapsibleNavGroup-observability" + data-test-opensearch-logo="/test/ui/logos/opensearch_mark_on_light.svg" + data-test-subj="collapsibleNavGroup-opensearchDashboards" id="mockId" initialIsOpen={true} isLoading={false} @@ -1162,8 +929,8 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
    @@ -1231,7 +998,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiTitle euiTitle--xxsmall euiCollapsibleNavGroup__title" id="mockId__title" > - Observability + OpenSearch Dashboards
    @@ -1259,25 +1026,33 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__children" >
      - metrics + discover @@ -1328,10 +1103,10 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` - logs + visualize -
    -
    -
    -
- - - - - - - +
  • + + + dashboard + + +
  • + + + + + + + + + + +
    + + + + + + + +

    + Observability +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="logoObservability" + data-test-subj="collapsibleNavGroup-observability" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" + > +
    +
    + +
    +
    + +
    +
    +
    + + + +
    +
    +
    +
    +
    +
    +
    +
    +
    - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" + +
    -
    -
    +
    + + +
    + + - - - - - + + +

    + Recently Visited +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="clock" + data-test-subj="collapsibleNavGroup-recentlyVisited" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" + > +
    +
    - -
    + + + + + - +
    - -

    + + + +

    +
    + +
    - Recently viewed - - + +

    + Recently Visited +

    +
    +
    +
    - -
    - - - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    + - -
    -

    - No recently viewed items -

    -
    -
    + +
  • + +
  • +
    + +
    - +
    -
    +
    -
    -
    -
    - - - -
    -
    - -
    +
    + + - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" + +
    -
    -
    +
    + + +
    + + - - - - - + + +

    + Recently Visited +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="clock" + data-test-subj="collapsibleNavGroup-recentlyVisited" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" + > +
    +
    - -
    + + + + + - +
    - -

    + + + +

    +
    + +
    - Recently viewed - - + +

    + Recently Visited +

    +
    +
    +
    - -
    - - - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - - + + + recent + + + + + + +
    +
    -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + + - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" + +
    -
    -
    +
    + + +
    + + - - - - - + + +

    + Recently Visited +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="clock" + data-test-subj="collapsibleNavGroup-recentlyVisited" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" + > +
    +
    - -
    + + + + + - +
    - -

    + + + +

    +
    + +
    - Recently viewed - - + +

    + Recently Visited +

    +
    +
    +
    - -
    - - - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - - + + + recent + + + + + + +
    +
    -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + +
    - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" + +
    -
    -
    +
    + + +
    + + - - - - - + + +

    + Recently Visited +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="clock" + data-test-subj="collapsibleNavGroup-recentlyVisited" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" + > +
    +
    - -
    + + + + + - +
    - -

    + + + +

    +
    + +
    - Recently viewed - - + +

    + Recently Visited +

    +
    +
    +
    - -
    - - - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - - -
    -
    -
    - -
    -
    -
    -
    - -
    -
    - -
    + + + recent + + + + + + +
    +
    +
    + +
    +
    +
    +
    - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" + +
    -
    -
    +
    + + +
    + + - - - - - + + +

    + Recently Visited +

    +
    +
    + + } + className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" + data-test-opensearch-logo="clock" + data-test-subj="collapsibleNavGroup-recentlyVisited" + id="mockId" + initialIsOpen={true} + isLoading={false} + isLoadingMessage={false} + onToggle={[Function]} + paddingSize="none" + > +
    +
    - -
    + + + + + - +
    - -

    + + + +

    +
    + +
    - Recently viewed - - + +

    + Recently Visited +

    +
    +
    +
    - -
    - - - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - - + + + recent + + + + + + +
    +
    -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + +
    + +
    +
    + +
    + +
    + + + +
    +
    + +
    + +
    + + + OpenSearch Dashboards + + +
    +
    +
    +
    +
    +
    +
    +
    +
    - - - - -

    - Recently viewed -

    -
    -
    - - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
    -
    -
    - -
    -
    - -
    -
    + + +
    + -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + +
    {}, navigateToApp: () => Promise.resolve(), navigateToUrl: () => Promise.resolve(), + getUrlForApp: jest.fn(), customNavLink$: new BehaviorSubject(undefined), branding, logos: getLogos(branding, mockBasePath.serverBasePath), @@ -175,7 +176,7 @@ describe('CollapsibleNav', () => { ); expectShownNavLinksCount(component, 3); clickGroup(component, 'opensearchDashboards'); - clickGroup(component, 'recentlyViewed'); + clickGroup(component, 'recentlyVisited'); expectShownNavLinksCount(component, 1); component.setProps({ isNavOpen: false }); expectNavIsClosed(component); @@ -205,7 +206,7 @@ describe('CollapsibleNav', () => { }, }); - component.find('[data-test-subj="collapsibleNavGroup-recentlyViewed"] a').simulate('click'); + component.find('[data-test-subj="collapsibleNavGroup-recentlyVisited"] a').simulate('click'); expect(onClose.callCount).toEqual(1); expectNavIsClosed(component); component.setProps({ isNavOpen: true }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 9c9223aa501b..352c901d608d 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -32,8 +32,10 @@ import './collapsible_nav.scss'; import { EuiCollapsibleNav, EuiCollapsibleNavGroup, + EuiFlexGroup, EuiFlexItem, EuiHorizontalRule, + EuiIcon, EuiListGroup, EuiListGroupItem, EuiShowFor, @@ -44,15 +46,21 @@ import { groupBy, sortBy } from 'lodash'; import React, { Fragment, useRef } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; +import { DEFAULT_APP_CATEGORIES } from '../../../../utils'; import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; import { AppCategory } from '../../../../types'; -import { InternalApplicationStart } from '../../../application/types'; +import { InternalApplicationStart } from '../../../application'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link'; -import type { Logos } from '../../../../common/types'; +import type { Logos } from '../../../../common'; +import { + createEuiListItem, + createRecentChromeNavLink, + emptyRecentlyVisited, + CollapsibleNavLink, +} from './nav_link'; -function getAllCategories(allCategorizedLinks: Record) { +function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; for (const [key, value] of Object.entries(allCategorizedLinks)) { @@ -62,14 +70,28 @@ function getAllCategories(allCategorizedLinks: Record) return allCategories; } -function getOrderedCategories( - mainCategories: Record, +function getSortedLinksAndCategories( + uncategorizedLinks: CollapsibleNavLink[], categoryDictionary: ReturnType -) { - return sortBy( - Object.keys(mainCategories), - (categoryName) => categoryDictionary[categoryName]?.order +): Array { + // uncategorized links and categories are ranked according the order + // if order is not defined, categories will be placed above uncategorized links + const categories = Object.values(categoryDictionary).filter( + (category) => category !== undefined + ) as AppCategory[]; + const uncategorizedLinksWithOrder = uncategorizedLinks.filter((link) => link.order !== null); + const uncategorizedLinksWithoutOrder = uncategorizedLinks.filter((link) => link.order === null); + const categoriesWithOrder = categories.filter((category) => category.order !== null); + const categoriesWithoutOrder = categories.filter((category) => category.order === null); + const sortedLinksAndCategories = sortBy( + [...uncategorizedLinksWithOrder, ...categoriesWithOrder], + 'order' ); + return [ + ...sortedLinksAndCategories, + ...categoriesWithoutOrder, + ...uncategorizedLinksWithoutOrder, + ]; } function getCategoryLocalStorageKey(id: string) { @@ -99,6 +121,7 @@ interface Props { storage?: Storage; onIsLockedUpdate: OnIsLockedUpdate; closeNav: () => void; + getUrlForApp: InternalApplicationStart['getUrlForApp']; navigateToApp: InternalApplicationStart['navigateToApp']; navigateToUrl: InternalApplicationStart['navigateToUrl']; customNavLink$: Rx.Observable; @@ -115,21 +138,37 @@ export function CollapsibleNav({ storage = window.localStorage, onIsLockedUpdate, closeNav, + getUrlForApp, navigateToApp, navigateToUrl, logos, ...observables }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + let customNavLink = useObservable(observables.customNavLink$, undefined); + if (customNavLink) { + customNavLink = { ...customNavLink, externalLink: true }; + } const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); - const customNavLink = useObservable(observables.customNavLink$, undefined); + const allNavLinks: CollapsibleNavLink[] = [...navLinks]; + if (recentlyAccessed.length) { + allNavLinks.push( + ...recentlyAccessed.map((link) => createRecentChromeNavLink(link, navLinks, basePath)) + ); + } else { + allNavLinks.push(emptyRecentlyVisited); + } const appId = useObservable(observables.appId$, ''); const lockRef = useRef(null); - const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); - const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; + const groupedNavLinks = groupBy(allNavLinks, (link) => link?.category?.id); + const { undefined: uncategorizedLinks = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); - const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); - const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { + const sortedLinksAndCategories = getSortedLinksAndCategories( + uncategorizedLinks, + categoryDictionary + ); + + const readyForEUI = (link: CollapsibleNavLink, needsIcon: boolean = false) => { return createEuiListItem({ link, appId, @@ -140,6 +179,13 @@ export function CollapsibleNav({ }); }; + const defaultHeaderName = i18n.translate( + 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', + { + defaultMessage: 'OpenSearch Dashboards', + } + ); + return ( - {collapsibleNavHeaderRender && collapsibleNavHeaderRender()} + {collapsibleNavHeaderRender ? ( + collapsibleNavHeaderRender() + ) : ( + + + + + + + + {defaultHeaderName} + + + + + )} + {customNavLink && ( @@ -169,7 +231,6 @@ export function CollapsibleNav({ navigateToApp, dataTestSubj: 'collapsibleNavCustomNavLink', onClick: closeNav, - externalLink: true, }), ]} maxWidth="none" @@ -184,103 +245,53 @@ export function CollapsibleNav({ )} - {/* Recently viewed */} - setIsCategoryOpen('recentlyViewed', isCategoryOpen, storage)} - data-test-subj="collapsibleNavGroup-recentlyViewed" - > - {recentlyAccessed.length > 0 ? ( - { - // TODO #64541 - // Can remove icon from recent links completely - const { iconType, onClick, ...hydratedLink } = createRecentNavLink( - link, - navLinks, - basePath, - navigateToUrl - ); - - return { - ...hydratedLink, - 'data-test-subj': 'collapsibleNavAppLink--recent', - onClick: (event) => { - if (!isModifiedOrPrevented(event)) { - closeNav(); - onClick(event); - } - }, - }; - })} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - className="osdCollapsibleNav__recentsListGroup" - /> - ) : ( - -

    - {i18n.translate('core.ui.EmptyRecentlyViewed', { - defaultMessage: 'No recently viewed items', - })} -

    -
    - )} -
    - - - - {/* OpenSearchDashboards, Observability, Security, and Management sections */} - {orderedCategories.map((categoryName) => { - const category = categoryDictionary[categoryName]!; - const opensearchLinkLogo = - category.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; + {sortedLinksAndCategories.map((item, i) => { + if (!('href' in item)) { + // CollapsibleNavLink has href property, while AppCategory does not have + const category = item; + const opensearchLinkLogo = + category.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id + ? logos.Mark.url + : category.euiIconType; - return ( - setIsCategoryOpen(category.id, isCategoryOpen, storage)} - data-test-subj={`collapsibleNavGroup-${category.id}`} - data-test-opensearch-logo={opensearchLinkLogo} - > - readyForEUI(link))} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - /> - - ); + return ( + + setIsCategoryOpen(category.id, isCategoryOpen, storage) + } + data-test-subj={`collapsibleNavGroup-${category.id}`} + data-test-opensearch-logo={opensearchLinkLogo} + > + readyForEUI(link))} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + /> + + ); + } else { + return ( + + + + + + ); + } })} - {/* Things with no category (largely for custom plugins) */} - {unknowns.map((link, i) => ( - - - - - - ))} - {/* Docking button only for larger screens that can support it*/} diff --git a/src/core/public/chrome/ui/header/header.test.tsx b/src/core/public/chrome/ui/header/header.test.tsx index c829361ff5c1..97c13aff36a2 100644 --- a/src/core/public/chrome/ui/header/header.test.tsx +++ b/src/core/public/chrome/ui/header/header.test.tsx @@ -70,6 +70,8 @@ function mockProps() { loadingCount$: new BehaviorSubject(0), onIsLockedUpdate: () => {}, branding: {}, + exitWorkspace: () => {}, + getWorkspaceUrl: (id: string) => '', survey: '/', logos: chromeServiceMock.createStartContract().logos, }; diff --git a/src/core/public/chrome/ui/header/header.tsx b/src/core/public/chrome/ui/header/header.tsx index 2ca0f2548942..cca0ca9e685f 100644 --- a/src/core/public/chrome/ui/header/header.tsx +++ b/src/core/public/chrome/ui/header/header.tsx @@ -52,7 +52,7 @@ import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, } from '../..'; -import { InternalApplicationStart } from '../../../application/types'; +import { InternalApplicationStart } from '../../../application'; import { HttpStart } from '../../../http'; import { ChromeHelpExtension, ChromeBranding } from '../../chrome_service'; import { OnIsLockedUpdate } from './'; @@ -256,6 +256,7 @@ export function Header({ isNavOpen={isNavOpen} homeHref={homeHref} basePath={basePath} + getUrlForApp={application.getUrlForApp} navigateToApp={application.navigateToApp} navigateToUrl={application.navigateToUrl} onIsLockedUpdate={onIsLockedUpdate} diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 38d31dbc09c9..65dcc07c8b9b 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -31,14 +31,14 @@ import { EuiIcon } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React from 'react'; -import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; +import { AppCategory, ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; import { HttpStart } from '../../../http'; -import { InternalApplicationStart } from '../../../application/types'; import { relativeToAbsolute } from '../../nav_links/to_nav_link'; export const isModifiedOrPrevented = (event: React.MouseEvent) => event.metaKey || event.altKey || event.ctrlKey || event.shiftKey || event.defaultPrevented; +export type CollapsibleNavLink = ChromeNavLink | RecentNavLink; // TODO: replace hard-coded values with a registration function, so that apps can control active nav links similar to breadcrumbs const aliasedApps: { [key: string]: string[] } = { discover: ['data-explorer'], @@ -48,13 +48,12 @@ export const isActiveNavLink = (appId: string | undefined, linkId: string): bool !!(appId === linkId || aliasedApps[linkId]?.includes(appId || '')); interface Props { - link: ChromeNavLink; + link: CollapsibleNavLink; appId?: string; basePath?: HttpStart['basePath']; dataTestSubj: string; onClick?: Function; navigateToApp: CoreStart['application']['navigateToApp']; - externalLink?: boolean; } // TODO #64541 @@ -68,9 +67,8 @@ export function createEuiListItem({ onClick = () => {}, navigateToApp, dataTestSubj, - externalLink = false, }: Props) { - const { href, id, title, disabled, euiIconType, icon, tooltip } = link; + const { href, id, title, disabled, euiIconType, icon, tooltip, externalLink } = link; return { label: tooltip ?? title, @@ -101,14 +99,16 @@ export function createEuiListItem({ }; } -export interface RecentNavLink { - href: string; - label: string; - title: string; - 'aria-label': string; - iconType?: string; - onClick: React.MouseEventHandler; -} +export type RecentNavLink = Omit; + +const recentlyVisitedCategory: AppCategory = { + id: 'recentlyVisited', + label: i18n.translate('core.ui.recentlyVisited.label', { + defaultMessage: 'Recently Visited', + }), + order: 0, + euiIconType: 'clock', +}; /** * Add saved object type info to recently links @@ -120,11 +120,10 @@ export interface RecentNavLink { * @param navLinks * @param basePath */ -export function createRecentNavLink( +export function createRecentChromeNavLink( recentLink: ChromeRecentlyAccessedHistoryItem, navLinks: ChromeNavLink[], - basePath: HttpStart['basePath'], - navigateToUrl: InternalApplicationStart['navigateToUrl'] + basePath: HttpStart['basePath'] ): RecentNavLink { const { link, label } = recentLink; const href = relativeToAbsolute(basePath.prepend(link)); @@ -143,16 +142,20 @@ export function createRecentNavLink( return { href, - label, + id: recentLink.id, + externalLink: true, + category: recentlyVisitedCategory, title: titleAndAriaLabel, - 'aria-label': titleAndAriaLabel, - iconType: navLink?.euiIconType, - /* Use href and onClick to support "open in new tab" and SPA navigation in the same link */ - onClick(event: React.MouseEvent) { - if (event.button === 0 && !isModifiedOrPrevented(event)) { - event.preventDefault(); - navigateToUrl(href); - } - }, }; } + +// As emptyRecentlyVisited is disabled, values for id, href and baseUrl does not affect +export const emptyRecentlyVisited: RecentNavLink = { + id: '', + href: '', + disabled: true, + category: recentlyVisitedCategory, + title: i18n.translate('core.ui.EmptyRecentlyVisited', { + defaultMessage: 'No recently visited items', + }), +}; diff --git a/src/core/public/core_app/errors/url_overflow.test.ts b/src/core/public/core_app/errors/url_overflow.test.ts index b2eee9c17d58..fe9cb8dca661 100644 --- a/src/core/public/core_app/errors/url_overflow.test.ts +++ b/src/core/public/core_app/errors/url_overflow.test.ts @@ -102,7 +102,7 @@ describe('url overflow detection', () => { option in advanced settings diff --git a/src/core/public/core_app/errors/url_overflow.tsx b/src/core/public/core_app/errors/url_overflow.tsx index 6dbfa96fff46..1de6fe785cf9 100644 --- a/src/core/public/core_app/errors/url_overflow.tsx +++ b/src/core/public/core_app/errors/url_overflow.tsx @@ -92,7 +92,7 @@ export const setupUrlOverflowDetection = ({ basePath, history, toasts, uiSetting values={{ storeInSessionStorageParam: state:storeInSessionStorage, advancedSettingsLink: ( - + = ({ basePath }) = values={{ storeInSessionStorageConfig: state:storeInSessionStorage, opensearchDashboardsSettingsLink: ( - + { expect(new BasePath('/foo/bar', '/foo').serverBasePath).toEqual('/foo'); }); }); + + describe('workspaceBasePath', () => { + it('get path with workspace', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').get()).toEqual( + '/foo/bar/workspace' + ); + }); + + it('getBasePath with workspace provided', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').getBasePath()).toEqual('/foo/bar'); + }); + + it('prepend with workspace provided', () => { + expect(new BasePath('/foo/bar', '/foo/bar', '/workspace').prepend('/prepend')).toEqual( + '/foo/bar/workspace/prepend' + ); + }); + + it('prepend with workspace provided but calls without workspace', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/workspace').prepend('/prepend', { + withoutWorkspace: true, + }) + ).toEqual('/foo/bar/prepend'); + }); + + it('remove with workspace provided', () => { + expect( + new BasePath('/foo/bar', '/foo/bar', '/workspace').remove('/foo/bar/workspace/remove') + ).toEqual('/remove'); + }); + }); }); diff --git a/src/core/public/http/base_path.ts b/src/core/public/http/base_path.ts index b31504676dba..254e4e2e6ad8 100644 --- a/src/core/public/http/base_path.ts +++ b/src/core/public/http/base_path.ts @@ -29,37 +29,47 @@ */ import { modifyUrl } from '@osd/std'; +import type { PrependOptions } from './types'; export class BasePath { constructor( private readonly basePath: string = '', - public readonly serverBasePath: string = basePath + public readonly serverBasePath: string = basePath, + private readonly workspaceBasePath: string = '' ) {} public get = () => { + return `${this.basePath}${this.workspaceBasePath}`; + }; + + public getBasePath = () => { return this.basePath; }; - public prepend = (path: string): string => { - if (!this.basePath) return path; + public prepend = (path: string, prependOptions?: PrependOptions): string => { + const { withoutWorkspace } = prependOptions || {}; + const basePath = withoutWorkspace ? this.basePath : this.get(); + if (!basePath) return path; return modifyUrl(path, (parts) => { if (!parts.hostname && parts.pathname && parts.pathname.startsWith('/')) { - parts.pathname = `${this.basePath}${parts.pathname}`; + parts.pathname = `${basePath}${parts.pathname}`; } }); }; - public remove = (path: string): string => { - if (!this.basePath) { + public remove = (path: string, prependOptions?: PrependOptions): string => { + const { withoutWorkspace } = prependOptions || {}; + const basePath = withoutWorkspace ? this.basePath : this.get(); + if (!basePath) { return path; } - if (path === this.basePath) { + if (path === basePath) { return '/'; } - if (path.startsWith(`${this.basePath}/`)) { - return path.slice(this.basePath.length); + if (path.startsWith(`${basePath}/`)) { + return path.slice(basePath.length); } return path; diff --git a/src/core/public/http/http_service.mock.ts b/src/core/public/http/http_service.mock.ts index 8c10d10017e5..934e4cbc9394 100644 --- a/src/core/public/http/http_service.mock.ts +++ b/src/core/public/http/http_service.mock.ts @@ -39,7 +39,7 @@ export type HttpSetupMock = jest.Mocked & { anonymousPaths: jest.Mocked; }; -const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ +const createServiceMock = ({ basePath = '', workspaceBasePath = '' } = {}): HttpSetupMock => ({ fetch: jest.fn(), get: jest.fn(), head: jest.fn(), @@ -48,7 +48,7 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ patch: jest.fn(), delete: jest.fn(), options: jest.fn(), - basePath: new BasePath(basePath), + basePath: new BasePath(basePath, undefined, workspaceBasePath), anonymousPaths: { register: jest.fn(), isAnonymous: jest.fn(), @@ -58,14 +58,14 @@ const createServiceMock = ({ basePath = '' } = {}): HttpSetupMock => ({ intercept: jest.fn(), }); -const createMock = ({ basePath = '' } = {}) => { +const createMock = ({ basePath = '', workspaceBasePath = '' } = {}) => { const mocked: jest.Mocked> = { setup: jest.fn(), start: jest.fn(), stop: jest.fn(), }; - mocked.setup.mockReturnValue(createServiceMock({ basePath })); - mocked.start.mockReturnValue(createServiceMock({ basePath })); + mocked.setup.mockReturnValue(createServiceMock({ basePath, workspaceBasePath })); + mocked.start.mockReturnValue(createServiceMock({ basePath, workspaceBasePath })); return mocked; }; diff --git a/src/core/public/http/http_service.test.ts b/src/core/public/http/http_service.test.ts index e60e506dfc0a..5671064e4c52 100644 --- a/src/core/public/http/http_service.test.ts +++ b/src/core/public/http/http_service.test.ts @@ -74,6 +74,32 @@ describe('#setup()', () => { // We don't verify that this Observable comes from Fetch#getLoadingCount$() to avoid complex mocking expect(loadingServiceSetup.addLoadingCountSource).toHaveBeenCalledWith(expect.any(Observable)); }); + + it('setup basePath without workspaceId provided in window.location.href', () => { + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const httpService = new HttpService(); + const setupResult = httpService.setup({ fatalErrors, injectedMetadata }); + expect(setupResult.basePath.get()).toEqual(''); + }); + + it('setup basePath with workspaceId provided in window.location.href', () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + const injectedMetadata = injectedMetadataServiceMock.createSetupContract(); + const fatalErrors = fatalErrorsServiceMock.createSetupContract(); + const httpService = new HttpService(); + const setupResult = httpService.setup({ fatalErrors, injectedMetadata }); + expect(setupResult.basePath.get()).toEqual('/w/workspaceId'); + windowSpy.mockRestore(); + }); }); describe('#stop()', () => { diff --git a/src/core/public/http/http_service.ts b/src/core/public/http/http_service.ts index f26323f261aa..c2caf18be880 100644 --- a/src/core/public/http/http_service.ts +++ b/src/core/public/http/http_service.ts @@ -36,6 +36,8 @@ import { AnonymousPathsService } from './anonymous_paths_service'; import { LoadingCountService } from './loading_count_service'; import { Fetch } from './fetch'; import { CoreService } from '../../types'; +import { getWorkspaceIdFromUrl } from '../utils'; +import { WORKSPACE_PATH_PREFIX } from '../../utils/constants'; interface HttpDeps { injectedMetadata: InjectedMetadataSetup; @@ -50,9 +52,15 @@ export class HttpService implements CoreService { public setup({ injectedMetadata, fatalErrors }: HttpDeps): HttpSetup { const opensearchDashboardsVersion = injectedMetadata.getOpenSearchDashboardsVersion(); + let workspaceBasePath = ''; + const workspaceId = getWorkspaceIdFromUrl(window.location.href); + if (workspaceId) { + workspaceBasePath = `${WORKSPACE_PATH_PREFIX}/${workspaceId}`; + } const basePath = new BasePath( injectedMetadata.getBasePath(), - injectedMetadata.getServerBasePath() + injectedMetadata.getServerBasePath(), + workspaceBasePath ); const fetchService = new Fetch({ basePath, opensearchDashboardsVersion }); const loadingCount = this.loadingCount.setup({ fatalErrors }); diff --git a/src/core/public/http/types.ts b/src/core/public/http/types.ts index f2573a6badd5..709494963162 100644 --- a/src/core/public/http/types.ts +++ b/src/core/public/http/types.ts @@ -87,25 +87,40 @@ export interface HttpSetup { */ export type HttpStart = HttpSetup; +/** + * prepend options + * + * withoutWorkspace option will prepend a relative url with only basePath + * workspaceId will rewrite the /w/{workspaceId} part, if workspace id is an empty string, prepend will remove the workspaceId part + */ +export interface PrependOptions { + withoutWorkspace?: boolean; +} + /** * APIs for manipulating the basePath on URL segments. * @public */ export interface IBasePath { /** - * Gets the `basePath` string. + * Gets the `basePath + workspace` string. */ get: () => string; /** - * Prepends `path` with the basePath. + * Gets the `basePath + */ + getBasePath: () => string; + + /** + * Prepends `path` with the basePath + workspace. */ - prepend: (url: string) => string; + prepend: (url: string, prependOptions?: PrependOptions) => string; /** - * Removes the prepended basePath from the `path`. + * Removes the prepended basePath + workspace from the `path`. */ - remove: (url: string) => string; + remove: (url: string, prependOptions?: PrependOptions) => string; /** * Returns the server's root basePath as configured, without any namespace prefix. diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 4e889ff82e6a..1c55911d0ab3 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -104,6 +104,7 @@ export { StringValidation, StringValidationRegex, StringValidationRegexString, + WorkspaceObject, WorkspaceAttribute, } from '../types'; @@ -351,4 +352,12 @@ export { export { __osdBootstrap__ } from './osd_bootstrap'; -export { WorkspacesStart, WorkspacesSetup } from './workspace'; +export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; + +export { + WorkspacePermissionMode, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, + WORKSPACE_TYPE, + cleanWorkspaceId, +} from '../utils'; diff --git a/src/core/public/saved_objects/saved_objects_client.test.ts b/src/core/public/saved_objects/saved_objects_client.test.ts index cc3405f246c5..c3cde2f6d6c3 100644 --- a/src/core/public/saved_objects/saved_objects_client.test.ts +++ b/src/core/public/saved_objects/saved_objects_client.test.ts @@ -293,8 +293,8 @@ describe('SavedObjectsClient', () => { expect(result.attributes).toBe(attributes); }); - test('makes HTTP call with ID', () => { - savedObjectsClient.create('index-pattern', attributes, { id: 'myId' }); + test('makes HTTP call with ID', async () => { + await savedObjectsClient.create('index-pattern', attributes, { id: 'myId' }); expect(http.fetch.mock.calls).toMatchInlineSnapshot(` Array [ Array [ @@ -311,8 +311,8 @@ describe('SavedObjectsClient', () => { `); }); - test('makes HTTP call without ID', () => { - savedObjectsClient.create('index-pattern', attributes); + test('makes HTTP call without ID', async () => { + await savedObjectsClient.create('index-pattern', attributes); expect(http.fetch.mock.calls).toMatchInlineSnapshot(` Array [ Array [ @@ -445,7 +445,7 @@ describe('SavedObjectsClient', () => { expect(result.total).toBe(1); }); - test('makes HTTP call correctly mapping options into snake case query parameters', () => { + test('makes HTTP call correctly mapping options into snake case query parameters', async () => { const options = { defaultSearchOperator: 'OR' as const, fields: ['title'], @@ -458,7 +458,7 @@ describe('SavedObjectsClient', () => { type: 'index-pattern', }; - savedObjectsClient.find(options); + await savedObjectsClient.find(options); expect(http.fetch.mock.calls).toMatchInlineSnapshot(` Array [ Array [ @@ -488,7 +488,7 @@ describe('SavedObjectsClient', () => { `); }); - test('ignores invalid options', () => { + test('ignores invalid options', async () => { const options = { invalid: true, namespace: 'default', @@ -496,7 +496,7 @@ describe('SavedObjectsClient', () => { }; // @ts-expect-error - savedObjectsClient.find(options); + await savedObjectsClient.find(options); expect(http.fetch.mock.calls).toMatchInlineSnapshot(` Array [ Array [ diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 6e5482614e40..7681117c7977 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -61,6 +61,7 @@ export interface SavedObjectsCreateOptions { /** {@inheritDoc SavedObjectsMigrationVersion} */ migrationVersion?: SavedObjectsMigrationVersion; references?: SavedObjectReference[]; + workspaces?: string[]; } /** @@ -183,6 +184,11 @@ const getObjectsToFetch = (queue: BatchQueueEntry[]): ObjectTypeAndId[] => { export class SavedObjectsClient { private http: HttpSetup; private batchQueue: BatchQueueEntry[]; + /** + * if currentWorkspaceId is undefined, it means + * we should not carry out workspace info when doing any operation. + */ + private currentWorkspaceId: string | undefined; /** * Throttled processing of get requests into bulk requests at 100ms interval @@ -227,6 +233,15 @@ export class SavedObjectsClient { this.batchQueue = []; } + private _getCurrentWorkspace(): string | undefined { + return this.currentWorkspaceId; + } + + public setCurrentWorkspace(workspaceId: string): boolean { + this.currentWorkspaceId = workspaceId; + return true; + } + /** * Persists an object * @@ -235,7 +250,7 @@ export class SavedObjectsClient { * @param options * @returns */ - public create = ( + public create = async ( type: string, attributes: T, options: SavedObjectsCreateOptions = {} @@ -248,6 +263,13 @@ export class SavedObjectsClient { const query = { overwrite: options.overwrite, }; + const currentWorkspaceId = this._getCurrentWorkspace(); + let finalWorkspaces; + if (options.hasOwnProperty('workspaces')) { + finalWorkspaces = options.workspaces; + } else if (typeof currentWorkspaceId === 'string') { + finalWorkspaces = [currentWorkspaceId]; + } const createRequest: Promise> = this.savedObjectsFetch(path, { method: 'POST', @@ -256,6 +278,11 @@ export class SavedObjectsClient { attributes, migrationVersion: options.migrationVersion, references: options.references, + ...(finalWorkspaces + ? { + workspaces: finalWorkspaces, + } + : {}), }), }); @@ -328,7 +355,7 @@ export class SavedObjectsClient { * @property {object} [options.hasReference] - { type, id } * @returns A find result with objects matching the specified search. */ - public find = ( + public find = async ( options: SavedObjectsFindOptions ): Promise> => { const path = this.getPath(['_find']); @@ -345,9 +372,29 @@ export class SavedObjectsClient { filter: 'filter', namespaces: 'namespaces', preference: 'preference', + workspaces: 'workspaces', + flags: 'flags', }; - const renamedQuery = renameKeys(renameMap, options); + const currentWorkspaceId = this._getCurrentWorkspace(); + let finalWorkspaces; + if (options.hasOwnProperty('workspaces')) { + finalWorkspaces = options.workspaces; + } else if (typeof currentWorkspaceId === 'string') { + finalWorkspaces = Array.from(new Set([currentWorkspaceId])); + } + + const renamedQuery = renameKeys, any>( + renameMap, + { + ...options, + ...(finalWorkspaces + ? { + workspaces: finalWorkspaces, + } + : {}), + } + ); const query = pick.apply(null, [renamedQuery, ...Object.values(renameMap)]) as Partial< Record >; diff --git a/src/core/public/saved_objects/saved_objects_service.mock.ts b/src/core/public/saved_objects/saved_objects_service.mock.ts index 47bd146058f7..00ca44072958 100644 --- a/src/core/public/saved_objects/saved_objects_service.mock.ts +++ b/src/core/public/saved_objects/saved_objects_service.mock.ts @@ -41,6 +41,7 @@ const createStartContractMock = () => { find: jest.fn(), get: jest.fn(), update: jest.fn(), + setCurrentWorkspace: jest.fn(), }, }; return mock; diff --git a/src/core/public/ui_settings/ui_settings_service.mock.ts b/src/core/public/ui_settings/ui_settings_service.mock.ts index 8458c86d6774..2d9cead9682b 100644 --- a/src/core/public/ui_settings/ui_settings_service.mock.ts +++ b/src/core/public/ui_settings/ui_settings_service.mock.ts @@ -33,7 +33,7 @@ import type { PublicMethodsOf } from '@osd/utility-types'; import { UiSettingsService } from './'; import { IUiSettingsClient } from './types'; -const createSetupContractMock = () => { +const createUiSettingsClientMock = () => { const setupContract: jest.Mocked = { getAll: jest.fn(), get: jest.fn(), @@ -66,12 +66,13 @@ const createMock = () => { stop: jest.fn(), }; - mocked.setup.mockReturnValue(createSetupContractMock()); + mocked.setup.mockReturnValue(createUiSettingsClientMock()); + mocked.start.mockReturnValue(createUiSettingsClientMock()); return mocked; }; export const uiSettingsServiceMock = { create: createMock, - createSetupContract: createSetupContractMock, - createStartContract: createSetupContractMock, + createSetupContract: createUiSettingsClientMock, + createStartContract: createUiSettingsClientMock, }; diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index 7676b9482aac..6786739cd1d6 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -31,3 +31,11 @@ export { shareWeakReplay } from './share_weak_replay'; export { Sha256 } from './crypto'; export { MountWrapper, mountReactNode } from './mount'; +export { + WORKSPACE_PATH_PREFIX, + WORKSPACE_TYPE, + formatUrlWithWorkspaceId, + getWorkspaceIdFromUrl, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, +} from '../../utils'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index ae56c035eb3a..ab8bda09730a 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -5,13 +5,12 @@ import { BehaviorSubject } from 'rxjs'; import type { PublicMethodsOf } from '@osd/utility-types'; - import { WorkspacesService } from './workspaces_service'; -import { WorkspaceAttribute } from '..'; +import { WorkspaceObject } from '..'; const currentWorkspaceId$ = new BehaviorSubject(''); -const workspaceList$ = new BehaviorSubject([]); -const currentWorkspace$ = new BehaviorSubject(null); +const workspaceList$ = new BehaviorSubject([]); +const currentWorkspace$ = new BehaviorSubject(null); const initialized$ = new BehaviorSubject(false); const createWorkspacesSetupContractMock = () => ({ diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index cc19b3c79229..b743701cb900 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -5,12 +5,9 @@ import { BehaviorSubject, combineLatest } from 'rxjs'; import { isEqual } from 'lodash'; +import { CoreService, WorkspaceObject } from '../../types'; -import { CoreService, WorkspaceAttribute } from '../../types'; - -type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; - -interface WorkspaceObservables { +export interface WorkspaceObservables { /** * Indicates the current activated workspace id, the value should be changed every time * when switching to a different workspace diff --git a/src/core/server/http/index.ts b/src/core/server/http/index.ts index 14397456afd6..bf89d14ddc5e 100644 --- a/src/core/server/http/index.ts +++ b/src/core/server/http/index.ts @@ -74,6 +74,7 @@ export { RouteValidationResultFactory, DestructiveRouteMethod, SafeRouteMethod, + ensureRawRequest, } from './router'; export { BasePathProxyServer } from './base_path_proxy_server'; export { OnPreRoutingHandler, OnPreRoutingToolkit } from './lifecycle/on_pre_routing'; diff --git a/src/core/server/http/integration_tests/core_services.test.ts b/src/core/server/http/integration_tests/core_services.test.ts index c489d98cf708..b248a67ef50c 100644 --- a/src/core/server/http/integration_tests/core_services.test.ts +++ b/src/core/server/http/integration_tests/core_services.test.ts @@ -520,7 +520,7 @@ describe('http service', () => { }); const coreStart = await root.start(); - opensearch = coreStart.opensearch; + opensearch = coreStart?.opensearch; const { header } = await osdTestServer.request.get(root, '/new-platform/').expect(401); @@ -556,7 +556,7 @@ describe('http service', () => { }); const coreStart = await root.start(); - opensearch = coreStart.opensearch; + opensearch = coreStart?.opensearch; const { header } = await osdTestServer.request.get(root, '/new-platform/').expect(401); diff --git a/src/core/server/index.ts b/src/core/server/index.ts index f8526a96a7c2..502433aaf83a 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -220,6 +220,7 @@ export { SessionStorageFactory, DestructiveRouteMethod, SafeRouteMethod, + ensureRawRequest, } from './http'; export { @@ -321,6 +322,17 @@ export { exportSavedObjectsToStream, importSavedObjectsFromStream, resolveSavedObjectsImportErrors, + SavedObjectsShareObjects, + SavedObjectsAddToWorkspacesOptions, + SavedObjectsAddToWorkspacesResponse, + SavedObjectsDeleteByWorkspaceOptions, + SavedObjectsDeleteFromWorkspacesOptions, + SavedObjectsDeleteFromWorkspacesResponse, + Permissions, + ACL, + Principals, + TransformedPermission, + PrincipalType, } from './saved_objects'; export { @@ -348,7 +360,14 @@ export { } from './metrics'; export { AppCategory, WorkspaceAttribute } from '../types'; -export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils'; +export { + DEFAULT_APP_CATEGORIES, + WorkspacePermissionMode, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, + WORKSPACE_TYPE, + PERSONAL_WORKSPACE_ID_PREFIX, +} from '../utils'; export { SavedObject, diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts index cf7e1d8246a7..952a74a76940 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.test.ts @@ -128,6 +128,7 @@ describe('getSortedObjectsForExport()', () => { index-pattern, search, ], + workspaces: undefined, }, ], ], @@ -218,6 +219,7 @@ describe('getSortedObjectsForExport()', () => { index-pattern, search, ], + workspaces: undefined, }, ], ], @@ -368,6 +370,7 @@ describe('getSortedObjectsForExport()', () => { index-pattern, search, ], + workspaces: undefined, }, ], ], @@ -459,6 +462,7 @@ describe('getSortedObjectsForExport()', () => { index-pattern, search, ], + workspaces: undefined, }, ], ], @@ -666,6 +670,7 @@ describe('getSortedObjectsForExport()', () => { ], Object { namespace: undefined, + workspaces: undefined, }, ], ], @@ -784,6 +789,7 @@ describe('getSortedObjectsForExport()', () => { ], Object { namespace: undefined, + workspaces: undefined, }, ], Array [ diff --git a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts index 7bf6e9f6ccdc..189318522bec 100644 --- a/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts +++ b/src/core/server/saved_objects/export/get_sorted_objects_for_export.ts @@ -60,6 +60,8 @@ export interface SavedObjectsExportOptions { excludeExportDetails?: boolean; /** optional namespace to override the namespace used by the savedObjectsClient. */ namespace?: string; + /** optional workspaces to override the workspaces used by the savedObjectsClient. */ + workspaces?: string[]; } /** @@ -87,6 +89,7 @@ async function fetchObjectsToExport({ exportSizeLimit, savedObjectsClient, namespace, + workspaces, }: { objects?: SavedObjectsExportOptions['objects']; types?: string[]; @@ -94,6 +97,7 @@ async function fetchObjectsToExport({ exportSizeLimit: number; savedObjectsClient: SavedObjectsClientContract; namespace?: string; + workspaces?: string[]; }) { if ((types?.length ?? 0) > 0 && (objects?.length ?? 0) > 0) { throw Boom.badRequest(`Can't specify both "types" and "objects" properties when exporting`); @@ -105,7 +109,7 @@ async function fetchObjectsToExport({ if (typeof search === 'string') { throw Boom.badRequest(`Can't specify both "search" and "objects" properties when exporting`); } - const bulkGetResult = await savedObjectsClient.bulkGet(objects, { namespace }); + const bulkGetResult = await savedObjectsClient.bulkGet(objects, { namespace, workspaces }); const erroredObjects = bulkGetResult.saved_objects.filter((obj) => !!obj.error); if (erroredObjects.length) { const err = Boom.badRequest(); @@ -121,6 +125,7 @@ async function fetchObjectsToExport({ search, perPage: exportSizeLimit, namespaces: namespace ? [namespace] : undefined, + ...(workspaces ? { workspaces } : {}), }); if (findResponse.total > exportSizeLimit) { throw Boom.badRequest(`Can't export more than ${exportSizeLimit} objects`); @@ -153,6 +158,7 @@ export async function exportSavedObjectsToStream({ includeReferencesDeep = false, excludeExportDetails = false, namespace, + workspaces, }: SavedObjectsExportOptions) { const rootObjects = await fetchObjectsToExport({ types, @@ -161,6 +167,7 @@ export async function exportSavedObjectsToStream({ savedObjectsClient, exportSizeLimit, namespace, + workspaces, }); let exportedObjects: Array> = []; let missingReferences: SavedObjectsExportResultDetails['missingReferences'] = []; diff --git a/src/core/server/saved_objects/import/check_conflicts.ts b/src/core/server/saved_objects/import/check_conflicts.ts index 830f7f55d7c5..f36bcf3a8a92 100644 --- a/src/core/server/saved_objects/import/check_conflicts.ts +++ b/src/core/server/saved_objects/import/check_conflicts.ts @@ -44,6 +44,7 @@ interface CheckConflictsParams { ignoreRegularConflicts?: boolean; retries?: SavedObjectsImportRetry[]; createNewCopies?: boolean; + workspaces?: string[]; } const isUnresolvableConflict = (error: SavedObjectError) => @@ -56,6 +57,7 @@ export async function checkConflicts({ ignoreRegularConflicts, retries = [], createNewCopies, + workspaces, }: CheckConflictsParams) { const filteredObjects: Array> = []; const errors: SavedObjectsImportError[] = []; @@ -77,6 +79,7 @@ export async function checkConflicts({ }); const checkConflictsResult = await savedObjectsClient.checkConflicts(objectsToCheck, { namespace, + workspaces, }); const errorMap = checkConflictsResult.errors.reduce( (acc, { type, id, error }) => acc.set(`${type}:${id}`, error), diff --git a/src/core/server/saved_objects/import/create_saved_objects.ts b/src/core/server/saved_objects/import/create_saved_objects.ts index 6fd08520281e..4bb07150aef8 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -41,6 +41,7 @@ interface CreateSavedObjectsParams { overwrite?: boolean; dataSourceId?: string; dataSourceTitle?: string; + workspaces?: string[]; } interface CreateSavedObjectsResult { createdObjects: Array>; @@ -60,6 +61,7 @@ export const createSavedObjects = async ({ overwrite, dataSourceId, dataSourceTitle, + workspaces, }: CreateSavedObjectsParams): Promise> => { // filter out any objects that resulted in errors const errorSet = accumulatedErrors.reduce( @@ -169,6 +171,7 @@ export const createSavedObjects = async ({ const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, { namespace, overwrite, + workspaces, }); expectedResults = bulkCreateResponse.saved_objects; } @@ -176,7 +179,7 @@ export const createSavedObjects = async ({ // remap results to reflect the object IDs that were submitted for import // this ensures that consumers understand the results const remappedResults = expectedResults.map>((result) => { - const { id } = objectIdMap.get(`${result.type}:${result.id}`)!; + const { id } = objectIdMap.get(`${result.type}:${result.id}`) || ({} as SavedObject); // also, include a `destinationId` field if the object create attempt was made with a different ID return { ...result, id, ...(id !== result.id && { destinationId: result.id }) }; }); diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index 3dda6931bd1e..dcb8d685d42c 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -42,7 +42,7 @@ import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { importSavedObjectsFromStream } from './import_saved_objects'; import { collectSavedObjects } from './collect_saved_objects'; -import { regenerateIds } from './regenerate_ids'; +import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids'; import { validateReferences } from './validate_references'; import { checkConflicts } from './check_conflicts'; import { checkOriginConflicts } from './check_origin_conflicts'; @@ -70,6 +70,7 @@ describe('#importSavedObjectsFromStream', () => { importIdMap: new Map(), }); getMockFn(regenerateIds).mockReturnValue(new Map()); + getMockFn(regenerateIdsWithReference).mockReturnValue(Promise.resolve(new Map())); getMockFn(validateReferences).mockResolvedValue([]); getMockFn(checkConflicts).mockResolvedValue({ errors: [], @@ -278,6 +279,15 @@ describe('#importSavedObjectsFromStream', () => { ]), }); getMockFn(validateReferences).mockResolvedValue([errors[1]]); + getMockFn(regenerateIdsWithReference).mockResolvedValue( + Promise.resolve( + new Map([ + ['foo', {}], + ['bar', {}], + ['baz', {}], + ]) + ) + ); getMockFn(checkConflicts).mockResolvedValue({ errors: [errors[2]], filteredObjects, diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index a5744478fd7d..1009660a5ac6 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -38,7 +38,7 @@ import { validateReferences } from './validate_references'; import { checkOriginConflicts } from './check_origin_conflicts'; import { createSavedObjects } from './create_saved_objects'; import { checkConflicts } from './check_conflicts'; -import { regenerateIds } from './regenerate_ids'; +import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids'; import { checkConflictsForDataSource } from './check_conflict_for_data_source'; /** @@ -57,6 +57,7 @@ export async function importSavedObjectsFromStream({ namespace, dataSourceId, dataSourceTitle, + workspaces, }: SavedObjectsImportOptions): Promise { let errorAccumulator: SavedObjectsImportError[] = []; const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); @@ -85,6 +86,13 @@ export async function importSavedObjectsFromStream({ // randomly generated id importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects, dataSourceId); } else { + importIdMap = await regenerateIdsWithReference({ + savedObjects: collectSavedObjectsResult.collectedObjects, + savedObjectsClient, + workspaces, + objectLimit, + importIdMap, + }); // in check conclict and override mode // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { @@ -92,6 +100,7 @@ export async function importSavedObjectsFromStream({ savedObjectsClient, namespace, ignoreRegularConflicts: overwrite, + workspaces, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); @@ -140,8 +149,10 @@ export async function importSavedObjectsFromStream({ importIdMap, overwrite, namespace, + ...(workspaces ? { workspaces } : {}), dataSourceId, dataSourceTitle, + ...(workspaces ? { workspaces } : {}), }; const createSavedObjectsResult = await createSavedObjects(createSavedObjectsParams); errorAccumulator = [...errorAccumulator, ...createSavedObjectsResult.errors]; diff --git a/src/core/server/saved_objects/import/regenerate_ids.ts b/src/core/server/saved_objects/import/regenerate_ids.ts index f1092bed7f55..522a150c4e6f 100644 --- a/src/core/server/saved_objects/import/regenerate_ids.ts +++ b/src/core/server/saved_objects/import/regenerate_ids.ts @@ -29,7 +29,8 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { SavedObject } from '../types'; +import { SavedObject, SavedObjectsClientContract } from '../types'; +import { SavedObjectsUtils } from '../service'; /** * Takes an array of saved objects and returns an importIdMap of randomly-generated new IDs. @@ -47,3 +48,40 @@ export const regenerateIds = (objects: SavedObject[], dataSourceId: string | und }, new Map()); return importIdMap; }; + +export const regenerateIdsWithReference = async (props: { + savedObjects: SavedObject[]; + savedObjectsClient: SavedObjectsClientContract; + workspaces?: string[]; + objectLimit: number; + importIdMap: Map; +}): Promise> => { + const { savedObjects, savedObjectsClient, workspaces, importIdMap } = props; + if (!workspaces || !workspaces.length) { + return savedObjects.reduce((acc, object) => { + return acc.set(`${object.type}:${object.id}`, { id: object.id, omitOriginId: false }); + }, importIdMap); + } + + const bulkGetResult = await savedObjectsClient.bulkGet( + savedObjects.map((item) => ({ type: item.type, id: item.id })) + ); + + return bulkGetResult.saved_objects.reduce((acc, object) => { + if (object.error?.statusCode === 404) { + acc.set(`${object.type}:${object.id}`, { id: object.id, omitOriginId: true }); + return acc; + } + + const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToSourceWorkspaces( + workspaces, + object.workspaces + ); + if (filteredWorkspaces.length) { + acc.set(`${object.type}:${object.id}`, { id: uuidv4(), omitOriginId: true }); + } else { + acc.set(`${object.type}:${object.id}`, { id: object.id, omitOriginId: false }); + } + return acc; + }, importIdMap); +}; diff --git a/src/core/server/saved_objects/import/resolve_import_errors.ts b/src/core/server/saved_objects/import/resolve_import_errors.ts index 09207c893043..a3d10c6f1ace 100644 --- a/src/core/server/saved_objects/import/resolve_import_errors.ts +++ b/src/core/server/saved_objects/import/resolve_import_errors.ts @@ -59,6 +59,7 @@ export async function resolveSavedObjectsImportErrors({ typeRegistry, namespace, createNewCopies, + workspaces, dataSourceId, dataSourceTitle, }: SavedObjectsResolveImportErrorsOptions): Promise { @@ -129,6 +130,7 @@ export async function resolveSavedObjectsImportErrors({ namespace, retries, createNewCopies, + workspaces, dataSourceId, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); @@ -161,6 +163,7 @@ export async function resolveSavedObjectsImportErrors({ importIdMap, namespace, overwrite, + workspaces, dataSourceId, dataSourceTitle, }; diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index 73bc548b1f24..2721e81db299 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -189,6 +189,8 @@ export interface SavedObjectsImportOptions { createNewCopies: boolean; dataSourceId?: string; dataSourceTitle?: string; + /** if specified, will import in given workspaces */ + workspaces?: string[]; } /** @@ -210,6 +212,8 @@ export interface SavedObjectsResolveImportErrorsOptions { namespace?: string; /** If true, will create new copies of import objects, each with a random `id` and undefined `originId`. */ createNewCopies: boolean; + /** if specified, will import in given workspaces, else will import as global object */ + workspaces?: string[]; dataSourceId?: string; dataSourceTitle?: string; } diff --git a/src/core/server/saved_objects/import/validate_references.ts b/src/core/server/saved_objects/import/validate_references.ts index fb75eb837443..545f26ebe1af 100644 --- a/src/core/server/saved_objects/import/validate_references.ts +++ b/src/core/server/saved_objects/import/validate_references.ts @@ -73,7 +73,10 @@ export async function getNonExistingReferenceAsKeys( } // Fetch references to see if they exist - const bulkGetOpts = Array.from(collector.values()).map((obj) => ({ ...obj, fields: ['id'] })); + const bulkGetOpts = Array.from(collector.values()).map((obj) => ({ + ...obj, + fields: ['id'], + })); const bulkGetResponse = await savedObjectsClient.bulkGet(bulkGetOpts, { namespace }); // Error handling diff --git a/src/core/server/saved_objects/index.ts b/src/core/server/saved_objects/index.ts index 06b2b65fd184..11809c5b88c9 100644 --- a/src/core/server/saved_objects/index.ts +++ b/src/core/server/saved_objects/index.ts @@ -84,3 +84,11 @@ export { export { savedObjectsConfig, savedObjectsMigrationConfig } from './saved_objects_config'; export { SavedObjectTypeRegistry, ISavedObjectTypeRegistry } from './saved_objects_type_registry'; + +export { + Permissions, + ACL, + Principals, + TransformedPermission, + PrincipalType, +} from './permission_control/acl'; diff --git a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap index f8ef47cae894..09e8ad8b5407 100644 --- a/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap +++ b/src/core/server/saved_objects/migrations/core/__snapshots__/build_active_mappings.test.ts.snap @@ -10,9 +10,11 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -36,6 +38,60 @@ Object { "originId": Object { "type": "keyword", }, + "permissions": Object { + "properties": Object { + "library_read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "library_write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "management": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + }, + }, "references": Object { "properties": Object { "id": Object { @@ -56,6 +112,9 @@ Object { "updated_at": Object { "type": "date", }, + "workspaces": Object { + "type": "keyword", + }, }, } `; @@ -69,11 +128,13 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -99,6 +160,60 @@ Object { "originId": Object { "type": "keyword", }, + "permissions": Object { + "properties": Object { + "library_read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "library_write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "management": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + }, + }, "references": Object { "properties": Object { "id": Object { @@ -134,6 +249,9 @@ Object { "updated_at": Object { "type": "date", }, + "workspaces": Object { + "type": "keyword", + }, }, } `; diff --git a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts index bf377a13a42e..05fb534f7a11 100644 --- a/src/core/server/saved_objects/migrations/core/build_active_mappings.ts +++ b/src/core/server/saved_objects/migrations/core/build_active_mappings.ts @@ -36,6 +36,7 @@ import crypto from 'crypto'; import { cloneDeep, mapValues } from 'lodash'; import { IndexMapping, + SavedObjectsFieldMapping, SavedObjectsMappingProperties, SavedObjectsTypeMappingDefinitions, } from './../../mappings'; @@ -137,6 +138,16 @@ function findChangedProp(actual: any, expected: any) { * @returns {IndexMapping} */ function defaultMapping(): IndexMapping { + const principals: SavedObjectsFieldMapping = { + properties: { + users: { + type: 'keyword', + }, + groups: { + type: 'keyword', + }, + }, + }; return { dynamic: 'strict', properties: { @@ -175,6 +186,18 @@ function defaultMapping(): IndexMapping { }, }, }, + workspaces: { + type: 'keyword', + }, + permissions: { + properties: { + read: principals, + write: principals, + management: principals, + library_read: principals, + library_write: principals, + }, + }, }, }; } diff --git a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts index 4bacfda3bd5a..08bc4162a807 100644 --- a/src/core/server/saved_objects/migrations/core/index_migrator.test.ts +++ b/src/core/server/saved_objects/migrations/core/index_migrator.test.ts @@ -82,6 +82,8 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', }, }, properties: { @@ -92,6 +94,63 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + workspaces: { + type: 'keyword', + }, + permissions: { + properties: { + library_read: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + library_write: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + management: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + read: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + write: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + }, + }, references: { type: 'nested', properties: { @@ -199,6 +258,8 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', }, }, properties: { @@ -210,6 +271,63 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + workspaces: { + type: 'keyword', + }, + permissions: { + properties: { + library_read: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + library_write: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + management: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + read: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + write: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + }, + }, references: { type: 'nested', properties: { @@ -260,6 +378,8 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', }, }, properties: { @@ -271,6 +391,63 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + workspaces: { + type: 'keyword', + }, + permissions: { + properties: { + library_read: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + library_write: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + management: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + read: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + write: { + properties: { + groups: { + type: 'keyword', + }, + users: { + type: 'keyword', + }, + }, + }, + }, + }, references: { type: 'nested', properties: { diff --git a/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap b/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap index baebb7848798..2748ad2eaf6a 100644 --- a/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap +++ b/src/core/server/saved_objects/migrations/opensearch_dashboards/__snapshots__/opensearch_dashboards_migrator.test.ts.snap @@ -10,9 +10,11 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -44,6 +46,60 @@ Object { "originId": Object { "type": "keyword", }, + "permissions": Object { + "properties": Object { + "library_read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "library_write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "management": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "read": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + "write": Object { + "properties": Object { + "groups": Object { + "type": "keyword", + }, + "users": Object { + "type": "keyword", + }, + }, + }, + }, + }, "references": Object { "properties": Object { "id": Object { @@ -64,6 +120,9 @@ Object { "updated_at": Object { "type": "date", }, + "workspaces": Object { + "type": "keyword", + }, }, } `; diff --git a/src/core/server/saved_objects/permission_control/acl.test.ts b/src/core/server/saved_objects/permission_control/acl.test.ts new file mode 100644 index 000000000000..3c71ac82bda1 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -0,0 +1,168 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Principals, Permissions, ACL } from './acl'; + +describe('SavedObjectTypeRegistry', () => { + let acl: ACL; + + it('test has permission', () => { + const principals: Principals = { + users: ['user1'], + groups: [], + }; + const permissions: Permissions = { + read: principals, + }; + acl = new ACL(permissions); + expect( + acl.hasPermission(['read'], { + users: ['user1'], + groups: [], + }) + ).toEqual(true); + expect( + acl.hasPermission(['read'], { + users: ['user2'], + groups: [], + }) + ).toEqual(false); + }); + + it('test add permission', () => { + acl = new ACL(); + const result1 = acl + .addPermission(['read'], { + users: ['user1'], + groups: [], + }) + .getPermissions(); + expect(result1?.read?.users).toEqual(['user1']); + + acl.resetPermissions(); + const result2 = acl + .addPermission(['write', 'management'], { + users: ['user2'], + groups: ['group1', 'group2'], + }) + .getPermissions(); + expect(result2?.write?.users).toEqual(['user2']); + expect(result2?.management?.groups).toEqual(['group1', 'group2']); + }); + + it('test remove permission', () => { + const principals1: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + const permissions1 = { + read: principals1, + write: principals1, + }; + acl = new ACL(permissions1); + const result1 = acl + .removePermission(['read'], { + users: ['user1'], + groups: [], + }) + .removePermission(['write'], { + users: [], + groups: ['group2'], + }) + .getPermissions(); + expect(result1?.read?.users).toEqual([]); + expect(result1?.write?.groups).toEqual(['group1']); + + const principals2: Principals = { + users: ['*'], + groups: ['*'], + }; + + const permissions2 = { + read: principals2, + write: principals2, + }; + + acl = new ACL(permissions2); + const result2 = acl + .removePermission(['read', 'write'], { + users: ['user1'], + groups: ['group1'], + }) + .getPermissions(); + expect(result2?.read?.users).toEqual(['*']); + expect(result2?.write?.groups).toEqual(['*']); + }); + + it('test transform permission', () => { + const principals: Principals = { + users: ['user1'], + groups: ['group1', 'group2'], + }; + const permissions = { + read: principals, + write: principals, + }; + acl = new ACL(permissions); + const result = acl.toFlatList(); + expect(result).toHaveLength(3); + expect(result).toEqual( + expect.arrayContaining([{ type: 'users', name: 'user1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group1', permissions: ['read', 'write'] }]) + ); + expect(result).toEqual( + expect.arrayContaining([{ type: 'groups', name: 'group2', permissions: ['read', 'write'] }]) + ); + }); + + it('test generate query DSL', () => { + const principals = { + users: ['user1'], + groups: ['group1'], + }; + const result = ACL.generateGetPermittedSavedObjectsQueryDSL(['read'], principals, 'workspace'); + expect(result).toEqual({ + query: { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user1'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group1'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + { + terms: { + type: ['workspace'], + }, + }, + ], + }, + }, + }); + }); +}); diff --git a/src/core/server/saved_objects/permission_control/acl.ts b/src/core/server/saved_objects/permission_control/acl.ts new file mode 100644 index 000000000000..1631b0cbef46 --- /dev/null +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -0,0 +1,249 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export enum PrincipalType { + Users = 'users', + Groups = 'groups', +} + +export interface Principals { + users?: string[]; + groups?: string[]; +} + +export type Permissions = Record; + +export interface TransformedPermission { + type: string; + name: string; + permissions: string[]; +} + +const addToPrincipals = (principals?: Principals, users?: string[], groups?: string[]) => { + if (!principals) { + principals = {}; + } + if (!!users) { + if (!principals.users) { + principals.users = []; + } + principals.users = Array.from(new Set([...principals.users, ...users])); + } + if (!!groups) { + if (!principals.groups) { + principals.groups = []; + } + principals.groups = Array.from(new Set([...principals.groups, ...groups])); + } + return principals; +}; + +const deleteFromPrincipals = (principals?: Principals, users?: string[], groups?: string[]) => { + if (!principals) { + return principals; + } + if (!!users && !!principals.users) { + principals.users = principals.users.filter((item) => !users.includes(item)); + } + if (!!groups && !!principals.groups) { + principals.groups = principals.groups.filter((item) => !groups.includes(item)); + } + return principals; +}; + +const checkPermission = (currentPrincipals: Principals | undefined, principals: Principals) => { + return ( + (currentPrincipals?.users && + principals?.users && + checkPermissionForSinglePrincipalType(currentPrincipals.users, principals.users)) || + (currentPrincipals?.groups && + principals.groups && + checkPermissionForSinglePrincipalType(currentPrincipals.groups, principals.groups)) + ); +}; + +const checkPermissionForSinglePrincipalType = ( + currentPrincipalArray: string[], + principalArray: string[] +) => { + return ( + currentPrincipalArray && + principalArray && + (currentPrincipalArray.includes('*') || + principalArray.some((item) => currentPrincipalArray.includes(item))) + ); +}; + +export class ACL { + private permissions?: Permissions; + constructor(initialPermissions?: Permissions) { + this.permissions = initialPermissions || {}; + } + + // parse the permissions object to check whether the specific principal has the specific permission types or not + public hasPermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || permissionTypes.length === 0 || !this.permissions || !principals) { + return false; + } + + const currentPermissions = this.permissions; + return permissionTypes.some((permissionType) => + checkPermission(currentPermissions[permissionType], principals) + ); + } + + // permissions object build function, add principal with specific permission to the object + public addPermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || !principals) { + return this; + } + if (!this.permissions) { + this.permissions = {}; + } + + for (const permissionType of permissionTypes) { + this.permissions[permissionType] = addToPrincipals( + this.permissions[permissionType], + principals.users, + principals.groups + ); + } + + return this; + } + + // permissions object build function, remove specific permission of specific principal from the object + public removePermission(permissionTypes: string[], principals: Principals) { + if (!permissionTypes || !principals) { + return this; + } + if (!this.permissions) { + this.permissions = {}; + } + + for (const permissionType of permissionTypes) { + const result = deleteFromPrincipals( + this.permissions![permissionType], + principals.users, + principals.groups + ); + if (result) { + this.permissions[permissionType] = result; + } + } + + return this; + } + + /** + * transform permissions format + * original permissions: { + * read: { + * users:['user1'] + * }, + * write:{ + * groups:['group1'] + * } + * } + * + * transformed permissions: [ + * {type:'users',name:'user1',permissions:['read']}, + * {type:'groups',name:'group1',permissions:['write']}, + * ] + */ + public toFlatList(): TransformedPermission[] { + const result: TransformedPermission[] = []; + if (!this.permissions) { + return result; + } + + for (const permissionType in this.permissions) { + if (Object.prototype.hasOwnProperty.call(this.permissions, permissionType)) { + const { users = [], groups = [] } = this.permissions[permissionType] ?? {}; + users.forEach((user) => { + const found = result.find((r) => r.type === PrincipalType.Users && r.name === user); + if (found) { + found.permissions.push(permissionType); + } else { + result.push({ type: PrincipalType.Users, name: user, permissions: [permissionType] }); + } + }); + groups.forEach((group) => { + const found = result.find((r) => r.type === PrincipalType.Groups && r.name === group); + if (found) { + found.permissions.push(permissionType); + } else { + result.push({ type: PrincipalType.Groups, name: group, permissions: [permissionType] }); + } + }); + } + } + + return result; + } + + public resetPermissions() { + // reset permissions + this.permissions = {}; + } + + // return the permissions object + public getPermissions() { + return this.permissions; + } + + /** + * generate query DSL by the specific conditions, used for fetching saved objects from the saved objects index + */ + public static generateGetPermittedSavedObjectsQueryDSL( + permissionTypes: string[], + principals: Principals, + savedObjectType?: string | string[] + ) { + if (!principals || !permissionTypes) { + return { + query: { + match_none: {}, + }, + }; + } + + const bool: any = { + filter: [], + }; + const subBool: any = { + should: [], + }; + + permissionTypes.forEach((permissionType) => { + Object.entries(principals).forEach(([principalType, principalsInCurrentType]) => { + subBool.should.push({ + terms: { + ['permissions.' + permissionType + `.${principalType}`]: principalsInCurrentType, + }, + }); + subBool.should.push({ + term: { + ['permissions.' + permissionType + `.${principalType}`]: '*', + }, + }); + }); + }); + + bool.filter.push({ + bool: subBool, + }); + + if (!!savedObjectType) { + bool.filter.push({ + terms: { + type: Array.isArray(savedObjectType) ? savedObjectType : [savedObjectType], + }, + }); + } + + return { query: { bool } }; + } +} diff --git a/src/core/server/saved_objects/routes/bulk_create.ts b/src/core/server/saved_objects/routes/bulk_create.ts index 5c2844d64813..056b1b795550 100644 --- a/src/core/server/saved_objects/routes/bulk_create.ts +++ b/src/core/server/saved_objects/routes/bulk_create.ts @@ -38,6 +38,9 @@ export const registerBulkCreateRoute = (router: IRouter) => { validate: { query: schema.object({ overwrite: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), body: schema.arrayOf( schema.object({ @@ -62,7 +65,13 @@ export const registerBulkCreateRoute = (router: IRouter) => { }, router.handleLegacyErrors(async (context, req, res) => { const { overwrite } = req.query; - const result = await context.core.savedObjects.client.bulkCreate(req.body, { overwrite }); + const workspaces = req.query.workspaces + ? Array().concat(req.query.workspaces) + : undefined; + const result = await context.core.savedObjects.client.bulkCreate(req.body, { + overwrite, + workspaces, + }); return res.ok({ body: result }); }) ); diff --git a/src/core/server/saved_objects/routes/copy.ts b/src/core/server/saved_objects/routes/copy.ts new file mode 100644 index 000000000000..7bace54db583 --- /dev/null +++ b/src/core/server/saved_objects/routes/copy.ts @@ -0,0 +1,82 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../http'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { exportSavedObjectsToStream } from '../export'; +import { validateObjects } from './utils'; +import { importSavedObjectsFromStream } from '../import'; + +export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => { + const { maxImportExportSize } = config; + + router.post( + { + path: '/_copy', + validate: { + body: schema.object({ + objects: schema.maybe( + schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }), + { maxSize: maxImportExportSize } + ) + ), + includeReferencesDeep: schema.boolean({ defaultValue: false }), + targetWorkspace: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObjectsClient = context.core.savedObjects.client; + const { objects, includeReferencesDeep, targetWorkspace } = req.body; + + // need to access the registry for type validation, can't use the schema for this + const supportedTypes = context.core.savedObjects.typeRegistry + .getImportableAndExportableTypes() + .map((t) => t.name); + + if (objects) { + const validationError = validateObjects(objects, supportedTypes); + if (validationError) { + return res.badRequest({ + body: { + message: validationError, + }, + }); + } + } + + const objectsListStream = await exportSavedObjectsToStream({ + savedObjectsClient, + objects, + exportSizeLimit: maxImportExportSize, + includeReferencesDeep, + excludeExportDetails: true, + }); + + const result = await importSavedObjectsFromStream({ + savedObjectsClient: context.core.savedObjects.client, + typeRegistry: context.core.savedObjects.typeRegistry, + readStream: objectsListStream, + objectLimit: maxImportExportSize, + overwrite: false, + createNewCopies: true, + workspaces: [targetWorkspace], + }); + + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/create.ts b/src/core/server/saved_objects/routes/create.ts index c8c330ba7774..4d22bd244a03 100644 --- a/src/core/server/saved_objects/routes/create.ts +++ b/src/core/server/saved_objects/routes/create.ts @@ -56,15 +56,23 @@ export const registerCreateRoute = (router: IRouter) => { ) ), initialNamespaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), + workspaces: schema.maybe(schema.arrayOf(schema.string(), { minSize: 1 })), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { type, id } = req.params; const { overwrite } = req.query; - const { attributes, migrationVersion, references, initialNamespaces } = req.body; + const { attributes, migrationVersion, references, initialNamespaces, workspaces } = req.body; - const options = { id, overwrite, migrationVersion, references, initialNamespaces }; + const options = { + id, + overwrite, + migrationVersion, + references, + initialNamespaces, + workspaces, + }; const result = await context.core.savedObjects.client.create(type, attributes, options); return res.ok({ body: result }); }) diff --git a/src/core/server/saved_objects/routes/export.ts b/src/core/server/saved_objects/routes/export.ts index 2c808b731b4e..9325b632e40f 100644 --- a/src/core/server/saved_objects/routes/export.ts +++ b/src/core/server/saved_objects/routes/export.ts @@ -57,12 +57,20 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) search: schema.maybe(schema.string()), includeReferencesDeep: schema.boolean({ defaultValue: false }), excludeExportDetails: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe(schema.arrayOf(schema.string())), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const savedObjectsClient = context.core.savedObjects.client; - const { type, objects, search, excludeExportDetails, includeReferencesDeep } = req.body; + const { + type, + objects, + search, + excludeExportDetails, + includeReferencesDeep, + workspaces, + } = req.body; const types = typeof type === 'string' ? [type] : type; // need to access the registry for type validation, can't use the schema for this @@ -98,6 +106,7 @@ export const registerExportRoute = (router: IRouter, config: SavedObjectConfig) exportSizeLimit: maxImportExportSize, includeReferencesDeep, excludeExportDetails, + workspaces, }); const docsToExport: string[] = await createPromiseFromStreams([ diff --git a/src/core/server/saved_objects/routes/find.ts b/src/core/server/saved_objects/routes/find.ts index dbc9bf9e3a0d..36fa7c2cd9f5 100644 --- a/src/core/server/saved_objects/routes/find.ts +++ b/src/core/server/saved_objects/routes/find.ts @@ -59,6 +59,9 @@ export const registerFindRoute = (router: IRouter) => { namespaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), }, }, @@ -67,6 +70,7 @@ export const registerFindRoute = (router: IRouter) => { const namespaces = typeof req.query.namespaces === 'string' ? [req.query.namespaces] : req.query.namespaces; + const workspaces = query.workspaces ? Array().concat(query.workspaces) : undefined; const result = await context.core.savedObjects.client.find({ perPage: query.per_page, @@ -81,6 +85,7 @@ export const registerFindRoute = (router: IRouter) => { fields: typeof query.fields === 'string' ? [query.fields] : query.fields, filter: query.filter, namespaces, + workspaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/import.ts b/src/core/server/saved_objects/routes/import.ts index 259551298748..1fc739ea168c 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -61,6 +61,9 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) overwrite: schema.boolean({ defaultValue: false }), createNewCopies: schema.boolean({ defaultValue: false }), dataSourceId: schema.maybe(schema.string({ defaultValue: '' })), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }, { validate: (object) => { @@ -108,6 +111,11 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) }); } + let workspaces = req.query.workspaces; + if (typeof workspaces === 'string') { + workspaces = [workspaces]; + } + const result = await importSavedObjectsFromStream({ savedObjectsClient: context.core.savedObjects.client, typeRegistry: context.core.savedObjects.typeRegistry, @@ -117,6 +125,7 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) createNewCopies, dataSourceId, dataSourceTitle, + workspaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/routes/index.ts b/src/core/server/saved_objects/routes/index.ts index 7149474e446c..00ee383ae38a 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -45,6 +45,8 @@ import { registerExportRoute } from './export'; import { registerImportRoute } from './import'; import { registerResolveImportErrorsRoute } from './resolve_import_errors'; import { registerMigrateRoute } from './migrate'; +import { registerCopyRoute } from './copy'; +import { registerShareRoute } from './share'; export function registerRoutes({ http, @@ -70,7 +72,9 @@ export function registerRoutes({ registerLogLegacyImportRoute(router, logger); registerExportRoute(router, config); registerImportRoute(router, config); + registerCopyRoute(router, config); registerResolveImportErrorsRoute(router, config); + registerShareRoute(router); const internalRouter = http.createRouter('/internal/saved_objects/'); diff --git a/src/core/server/saved_objects/routes/integration_tests/share.test.ts b/src/core/server/saved_objects/routes/integration_tests/share.test.ts new file mode 100644 index 000000000000..d5fe01ba4115 --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/share.test.ts @@ -0,0 +1,172 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import supertest from 'supertest'; +import { UnwrapPromise } from '@osd/utility-types'; +import { registerShareRoute } from '../share'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { createExportableType, setupServer } from '../test_utils'; +import { WORKSPACE_TYPE } from '../../../../utils/constants'; +import { typeRegistryMock } from '../../saved_objects_type_registry.mock'; + +type SetupServerReturn = UnwrapPromise>; + +describe('POST /api/saved_objects/_share', () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + let typeRegistry: ReturnType; + const allowedTypes = ['index-pattern', 'dashboard', 'settings']; + + beforeEach(async () => { + const clientResponse = [ + { + id: 'abc123', + type: 'index-pattern', + workspaces: ['ws-1', 'ws-2'], + }, + ]; + + const bulkGetResponse = { + saved_objects: [ + { + id: 'abc123', + type: 'index-pattern', + title: 'logstash-*', + version: 'foo', + references: [], + attributes: {}, + workspaces: ['ws-1'], + }, + ], + }; + + ({ server, httpSetup, handlerContext } = await setupServer()); + typeRegistry = handlerContext.savedObjects.typeRegistry; + typeRegistry.getAllTypes.mockReturnValue(allowedTypes.map(createExportableType)); + + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.addToWorkspaces.mockResolvedValue(clientResponse); + savedObjectsClient.bulkGet.mockImplementation(() => Promise.resolve(bulkGetResponse)); + + const router = httpSetup.createRouter('/api/saved_objects/'); + registerShareRoute(router); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('workspace itself are not allowed to share', async () => { + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_share') + .send({ + objects: [ + { + id: 'abc123', + type: WORKSPACE_TYPE, + }, + ], + targetWorkspaceIds: ['ws-2'], + }) + .expect(400); + + expect(result.body.message).toEqual( + `Trying to share object(s) with non-shareable types: ${WORKSPACE_TYPE}:abc123` + ); + }); + + it('ignore legacy saved objects when share', async () => { + const bulkGetResponse = { + saved_objects: [ + { + id: 'settings-1.0', + type: 'settings', + title: 'Advanced-settings', + version: 'foo', + references: [], + attributes: {}, + workspaces: undefined, + }, + ], + }; + savedObjectsClient.bulkGet.mockImplementation(() => Promise.resolve(bulkGetResponse)); + + const clientResponse = [ + { + id: 'settings-1.0', + type: 'settings', + workspaces: undefined, + }, + ]; + + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_share') + .send({ + objects: [ + { + id: 'settings-1.0', + type: 'settings', + }, + ], + targetWorkspaceIds: ['ws-2'], + }); + + expect(result.body).toEqual(clientResponse); + }); + + it('formats successful response', async () => { + const clientResponse = [ + { + id: 'abc123', + type: 'index-pattern', + workspaces: ['ws-1', 'ws-2'], + }, + ]; + const result = await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_share') + .send({ + objects: [ + { + id: 'abc123', + type: 'index-pattern', + }, + ], + targetWorkspaceIds: ['ws-2'], + }) + .expect(200); + + expect(result.body).toEqual(clientResponse); + }); + + it('calls upon savedObjectClient.addToWorkspaces', async () => { + await supertest(httpSetup.server.listener) + .post('/api/saved_objects/_share') + .send({ + objects: [ + { + id: 'abc123', + type: 'index-pattern', + }, + ], + targetWorkspaceIds: ['ws-2'], + }) + .expect(200); + + expect(savedObjectsClient.addToWorkspaces).toHaveBeenCalledWith( + [ + { + id: 'abc123', + type: 'index-pattern', + workspaces: ['ws-1'], + }, + ], + ['ws-2'], + { workspaces: undefined } + ); + }); +}); diff --git a/src/core/server/saved_objects/routes/resolve_import_errors.ts b/src/core/server/saved_objects/routes/resolve_import_errors.ts index 8e2113af6378..dedcc960a675 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -58,6 +58,9 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO validate: { query: schema.object({ createNewCopies: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), dataSourceId: schema.maybe(schema.string({ defaultValue: '' })), }), body: schema.object({ @@ -117,6 +120,11 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO }); } + let workspaces = req.query.workspaces; + if (typeof workspaces === 'string') { + workspaces = [workspaces]; + } + const result = await resolveSavedObjectsImportErrors({ typeRegistry: context.core.savedObjects.typeRegistry, savedObjectsClient: context.core.savedObjects.client, @@ -124,6 +132,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO retries: req.body.retries, objectLimit: maxImportExportSize, createNewCopies: req.query.createNewCopies, + workspaces, dataSourceId, dataSourceTitle, }); diff --git a/src/core/server/saved_objects/routes/share.ts b/src/core/server/saved_objects/routes/share.ts new file mode 100644 index 000000000000..544ae372871c --- /dev/null +++ b/src/core/server/saved_objects/routes/share.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../http'; +import { exportSavedObjectsToStream } from '../export'; +import { filterInvalidObjects } from './utils'; +import { collectSavedObjects } from '../import/collect_saved_objects'; +import { WORKSPACE_TYPE } from '../../../utils'; + +const SHARE_LIMIT = 10000; + +export const registerShareRoute = (router: IRouter) => { + router.post( + { + path: '/_share', + validate: { + body: schema.object({ + sourceWorkspaceId: schema.maybe(schema.string()), + objects: schema.arrayOf( + schema.object({ + id: schema.string(), + type: schema.string(), + }) + ), + targetWorkspaceIds: schema.arrayOf(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const savedObjectsClient = context.core.savedObjects.client; + const { sourceWorkspaceId, objects, targetWorkspaceIds } = req.body; + + // need to access the registry for type validation, can't use the schema for this + const supportedTypes = context.core.savedObjects.typeRegistry + .getAllTypes() + .filter((type) => type.name !== WORKSPACE_TYPE) + .map((t) => t.name); + + if (objects.length) { + const invalidObjects = filterInvalidObjects(objects, supportedTypes); + if (invalidObjects.length) { + return res.badRequest({ + body: { + message: `Trying to share object(s) with non-shareable types: ${invalidObjects + .map((obj) => `${obj.type}:${obj.id}`) + .join(', ')}`, + }, + }); + } + } + + const objectsListStream = await exportSavedObjectsToStream({ + savedObjectsClient, + objects, + exportSizeLimit: SHARE_LIMIT, + includeReferencesDeep: true, + excludeExportDetails: true, + }); + + const collectSavedObjectsResult = await collectSavedObjects({ + readStream: objectsListStream, + objectLimit: SHARE_LIMIT, + supportedTypes, + }); + + const savedObjects = collectSavedObjectsResult.collectedObjects; + + const sharedObjects = savedObjects + .filter((obj) => obj.workspaces && obj.workspaces.length > 0) + .map((obj) => ({ id: obj.id, type: obj.type, workspaces: obj.workspaces })); + + if (sharedObjects.length === 0) { + return res.ok({ + body: savedObjects.map((savedObject) => ({ + type: savedObject.type, + id: savedObject.id, + workspaces: savedObject.workspaces, + })), + }); + } + + const response = await savedObjectsClient.addToWorkspaces(sharedObjects, targetWorkspaceIds, { + workspaces: sourceWorkspaceId ? [sourceWorkspaceId] : undefined, + }); + return res.ok({ + body: response, + }); + }) + ); +}; diff --git a/src/core/server/saved_objects/routes/utils.test.ts b/src/core/server/saved_objects/routes/utils.test.ts index b959d0e4ed48..68bb86b83365 100644 --- a/src/core/server/saved_objects/routes/utils.test.ts +++ b/src/core/server/saved_objects/routes/utils.test.ts @@ -28,7 +28,12 @@ * under the License. */ -import { createSavedObjectsStreamFromNdJson, validateTypes, validateObjects } from './utils'; +import { + createSavedObjectsStreamFromNdJson, + validateTypes, + validateObjects, + filterInvalidObjects, +} from './utils'; import { Readable } from 'stream'; import { createPromiseFromStreams, createConcatStream } from '../../utils/streams'; @@ -165,3 +170,36 @@ describe('validateObjects', () => { ).toBeUndefined(); }); }); + +describe('filterInvalidObjects', () => { + const allowedTypes = ['config', 'index-pattern', 'dashboard']; + + it('returns invalid objects that types are not allowed', () => { + expect( + filterInvalidObjects( + [ + { id: '1', type: 'config' }, + { id: '1', type: 'not-allowed' }, + { id: '42', type: 'not-allowed-either' }, + ], + allowedTypes + ) + ).toMatchObject([ + { id: '1', type: 'not-allowed' }, + { id: '42', type: 'not-allowed-either' }, + ]); + }); + + it('returns empty array if all objects have allowed types', () => { + expect( + validateObjects( + [ + { id: '1', type: 'config' }, + { id: '2', type: 'config' }, + { id: '1', type: 'index-pattern' }, + ], + allowedTypes + ) + ).toEqual(undefined); + }); +}); diff --git a/src/core/server/saved_objects/routes/utils.ts b/src/core/server/saved_objects/routes/utils.ts index a4c9375e4716..769c02e0d7c9 100644 --- a/src/core/server/saved_objects/routes/utils.ts +++ b/src/core/server/saved_objects/routes/utils.ts @@ -27,7 +27,6 @@ * specific language governing permissions and limitations * under the License. */ - import { Readable } from 'stream'; import { SavedObject, SavedObjectsExportResultDetails } from 'src/core/server'; import { @@ -74,3 +73,10 @@ export function validateObjects( .join(', ')}`; } } + +export function filterInvalidObjects( + objects: Array<{ id: string; type: string }>, + supportedTypes: string[] +): Array<{ id: string; type: string }> { + return objects.filter((obj) => !supportedTypes.includes(obj.type)); +} diff --git a/src/core/server/saved_objects/saved_objects_service.ts b/src/core/server/saved_objects/saved_objects_service.ts index 43296f340d85..64c8b6a5fbc8 100644 --- a/src/core/server/saved_objects/saved_objects_service.ts +++ b/src/core/server/saved_objects/saved_objects_service.ts @@ -527,8 +527,10 @@ export class SavedObjectsService this.started = true; + const getScopedClient = clientProvider.getClient.bind(clientProvider); + return { - getScopedClient: clientProvider.getClient.bind(clientProvider), + getScopedClient, createScopedRepository: repositoryFactory.createScopedRepository, createInternalRepository: repositoryFactory.createInternalRepository, createSerializer: () => new SavedObjectsSerializer(this.typeRegistry), diff --git a/src/core/server/saved_objects/serialization/serializer.ts b/src/core/server/saved_objects/serialization/serializer.ts index ff840a1fac60..492379068cdb 100644 --- a/src/core/server/saved_objects/serialization/serializer.ts +++ b/src/core/server/saved_objects/serialization/serializer.ts @@ -73,7 +73,7 @@ export class SavedObjectsSerializer { */ public rawToSavedObject(doc: SavedObjectsRawDoc): SavedObjectSanitizedDoc { const { _id, _source, _seq_no, _primary_term } = doc; - const { type, namespace, namespaces, originId } = _source; + const { type, namespace, namespaces, originId, workspaces, permissions } = _source; const version = _seq_no != null || _primary_term != null @@ -91,6 +91,8 @@ export class SavedObjectsSerializer { ...(_source.migrationVersion && { migrationVersion: _source.migrationVersion }), ...(_source.updated_at && { updated_at: _source.updated_at }), ...(version && { version }), + ...(workspaces && { workspaces }), + ...(permissions && { permissions }), }; } @@ -112,6 +114,8 @@ export class SavedObjectsSerializer { updated_at, version, references, + workspaces, + permissions, } = savedObj; const source = { [type]: attributes, @@ -122,6 +126,8 @@ export class SavedObjectsSerializer { ...(originId && { originId }), ...(migrationVersion && { migrationVersion }), ...(updated_at && { updated_at }), + ...(workspaces && { workspaces }), + ...(permissions && { permissions }), }; return { diff --git a/src/core/server/saved_objects/serialization/types.ts b/src/core/server/saved_objects/serialization/types.ts index d10ec75cdf41..fee9f503dceb 100644 --- a/src/core/server/saved_objects/serialization/types.ts +++ b/src/core/server/saved_objects/serialization/types.ts @@ -28,6 +28,7 @@ * under the License. */ +import { Permissions } from '../permission_control/acl'; import { SavedObjectsMigrationVersion, SavedObjectReference } from '../types'; /** @@ -52,6 +53,8 @@ export interface SavedObjectsRawDocSource { updated_at?: string; references?: SavedObjectReference[]; originId?: string; + workspaces?: string[]; + permissions?: Permissions; [typeMapping: string]: any; } @@ -69,6 +72,8 @@ interface SavedObjectDoc { version?: string; updated_at?: string; originId?: string; + workspaces?: string[]; + permissions?: Permissions; } interface Referencable { diff --git a/src/core/server/saved_objects/service/lib/repository.mock.ts b/src/core/server/saved_objects/service/lib/repository.mock.ts index b9436b364f05..e02d4dd0b638 100644 --- a/src/core/server/saved_objects/service/lib/repository.mock.ts +++ b/src/core/server/saved_objects/service/lib/repository.mock.ts @@ -44,6 +44,9 @@ const create = (): jest.Mocked => ({ deleteFromNamespaces: jest.fn(), deleteByNamespace: jest.fn(), incrementCounter: jest.fn(), + addToWorkspaces: jest.fn(), + deleteByWorkspace: jest.fn(), + deleteFromWorkspaces: jest.fn(), }); export const savedObjectsRepositoryMock = { create }; diff --git a/src/core/server/saved_objects/service/lib/repository.test.js b/src/core/server/saved_objects/service/lib/repository.test.js index fb5d366dd454..b1bee265266c 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -168,7 +168,7 @@ describe('SavedObjectsRepository', () => { }); const getMockGetResponse = ( - { type, id, references, namespace: objectNamespace, originId }, + { type, id, references, namespace: objectNamespace, originId, workspaces }, namespace ) => { const namespaceId = objectNamespace === 'default' ? undefined : objectNamespace ?? namespace; @@ -182,6 +182,7 @@ describe('SavedObjectsRepository', () => { _source: { ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), + workspaces, ...(originId && { originId }), type, [type]: { title: 'Testing' }, @@ -429,6 +430,190 @@ describe('SavedObjectsRepository', () => { }); }); + describe('#addToWorkspaces', () => { + const id = 'some-id'; + const type = 'dashboard'; + const currentWs1 = 'public'; + const newWs1 = 'new-workspace-1'; + const newWs2 = 'new-workspace-2'; + + const sharedObjects = [ + { + type, + id, + workspaces: [currentWs1], + }, + ]; + + const getMockBulkUpdateResponse = (objects, newWorkspaces, errors = false) => ({ + errors, + items: objects.map(({ type, id, workspaces }) => ({ + update: { + _id: `${type}:${id}`, + ...mockVersionProps, + status: errors ? 404 : 200, + error: errors && { + type: 'document_missing_exception', + reason: `${type}:${id}: document missing`, + }, + get: { + _source: { + type, + id, + workspaces: [...new Set(workspaces.concat(newWorkspaces))], + }, + }, + result: 'updated', + }, + })), + }); + + const mockBulkGetResponse = (objects, _found = true) => { + objects.forEach((obj) => { + obj.found = _found; + }); + const mockResponse = getMockMgetResponse(objects); + client.mget.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); + }; + + const addToWorkspacesSuccess = async (objects, workspaces, options) => { + mockBulkGetResponse(objects); + client.bulk.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise( + getMockBulkUpdateResponse(objects, workspaces) + ) + ); + const result = await savedObjectsRepository.addToWorkspaces(objects, workspaces, options); + expect(client.mget).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(1); + return result; + }; + + describe('client calls', () => { + it(`should use OpenSearch get action then bulk update action`, async () => { + await addToWorkspacesSuccess(sharedObjects, [newWs1, newWs2]); + }); + + it(`_source_includes should have workspaces`, async () => { + await addToWorkspacesSuccess(sharedObjects, [newWs1, newWs2]); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ _source_includes: ['workspaces'] }), + expect.anything() + ); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await addToWorkspacesSuccess(sharedObjects, [newWs1, newWs2]); + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ refresh: 'wait_for' }), + expect.anything() + ); + }); + }); + + describe('errors', () => { + const expectNotFoundError = async (savedObjects, workspaces, options) => { + await expect( + savedObjectsRepository.addToWorkspaces(savedObjects, workspaces, options) + ).rejects.toThrowError(createGenericNotFoundError(savedObjects[0].type, id)); + }; + const expectBadRequestError = async (savedObjects, workspaces, message) => { + await expect( + savedObjectsRepository.addToWorkspaces(savedObjects, workspaces) + ).rejects.toThrowError(createBadRequestError(message)); + }; + + it(`throws when shared saved objects is empty`, async () => { + await expectBadRequestError([], [newWs1], 'shared savedObjects must not be an empty array'); + expect(client.mget).not.toHaveBeenCalled(); + }); + + it(`throws when type is invalid`, async () => { + const objects = [ + { + type: 'unknownType', + id: id, + workspace: [currentWs1], + }, + ]; + await expectNotFoundError(objects, [newWs1]); + expect(client.mget).not.toHaveBeenCalled(); + expect(client.bulk).not.toHaveBeenCalled(); + }); + + it(`throws when type is hidden`, async () => { + const objects = [ + { + type: HIDDEN_TYPE, + id: id, + workspace: [currentWs1], + }, + ]; + await expectNotFoundError(objects, [newWs1]); + expect(client.mget).not.toHaveBeenCalled(); + expect(client.bulk).not.toHaveBeenCalled(); + }); + + it(`throws when workspaces is an empty array`, async () => { + const test = async (workspaces) => { + const message = 'workspaces must be a non-empty array of strings'; + await expectBadRequestError(sharedObjects, workspaces, message); + expect(client.mget).not.toHaveBeenCalled(); + }; + await test([]); + }); + + it(`throws when OpenSearch is unable to find the document during mget`, async () => { + mockBulkGetResponse(sharedObjects, false); + await expectNotFoundError(sharedObjects, [newWs1, newWs2]); + expect(client.mget).toHaveBeenCalledTimes(1); + }); + + it(`throws when the document exists, but not in this workspace`, async () => { + mockBulkGetResponse(sharedObjects); + await expectNotFoundError(sharedObjects, [newWs1, newWs1], { + workspaces: 'some-other-workspace', + }); + expect(client.mget).toHaveBeenCalledTimes(1); + }); + + it(`throws when OpenSearch is unable to find the document during bulk update`, async () => { + const newWorkspaces = [newWs1, newWs2]; + mockBulkGetResponse(sharedObjects); + client.bulk.mockResolvedValue( + opensearchClientMock.createSuccessTransportRequestPromise( + getMockBulkUpdateResponse(sharedObjects, newWorkspaces, true) + ) + ); + await expectBadRequestError( + sharedObjects, + newWorkspaces, + `Add to workspace failed with: ${type}:${id}: document missing` + ); + expect(client.mget).toHaveBeenCalledTimes(1); + expect(client.bulk).toHaveBeenCalledTimes(1); + }); + }); + + describe('returns', () => { + it(`returns all existing and new workspaces on success`, async () => { + const result = await addToWorkspacesSuccess(sharedObjects, [newWs1, newWs2]); + result.forEach((ret) => { + expect(ret.workspaces).toEqual([currentWs1, newWs1, newWs2]); + }); + }); + + it(`succeeds when adding existing workspaces`, async () => { + const result = await addToWorkspacesSuccess(sharedObjects, [currentWs1]); + result.forEach((ret) => { + expect(ret.workspaces).toEqual([currentWs1]); + }); + }); + }); + }); + describe('#bulkCreate', () => { const obj1 = { type: 'config', @@ -444,6 +629,7 @@ describe('SavedObjectsRepository', () => { references: [{ name: 'ref_0', type: 'test', id: '2' }], }; const namespace = 'foo-namespace'; + const workspace = 'foo-workspace'; const getMockBulkCreateResponse = (objects, namespace) => { return { @@ -466,6 +652,7 @@ describe('SavedObjectsRepository', () => { }; const bulkCreateSuccess = async (objects, options) => { + const originalObjects = JSON.parse(JSON.stringify(objects)); const multiNamespaceObjects = objects.filter( ({ type, id }) => registry.isMultiNamespace(type) && id ); @@ -480,7 +667,9 @@ describe('SavedObjectsRepository', () => { opensearchClientMock.createSuccessTransportRequestPromise(response) ); const result = await savedObjectsRepository.bulkCreate(objects, options); - expect(client.mget).toHaveBeenCalledTimes(multiNamespaceObjects?.length ? 1 : 0); + expect(client.mget).toHaveBeenCalledTimes( + multiNamespaceObjects?.length || originalObjects?.some((item) => item.id) ? 1 : 0 + ); return result; }; @@ -538,7 +727,10 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects); expect(client.bulk).toHaveBeenCalledTimes(1); expect(client.mget).toHaveBeenCalledTimes(1); - const docs = [expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` })]; + const docs = [ + expect.objectContaining({ _id: `${obj1.type}:${obj1.id}` }), + expect.objectContaining({ _id: `${MULTI_NAMESPACE_TYPE}:${obj2.id}` }), + ]; expect(client.mget.mock.calls[0][0].body).toEqual({ docs }); }); @@ -683,6 +875,7 @@ describe('SavedObjectsRepository', () => { expect.anything() ); client.bulk.mockClear(); + client.mget.mockClear(); }; await test(undefined); await test(namespace); @@ -730,6 +923,16 @@ describe('SavedObjectsRepository', () => { await bulkCreateSuccess(objects, { namespace }); expectClientCallArgsAction(objects, { method: 'create', getId }); }); + + it(`adds workspaces to request body for any types`, async () => { + await bulkCreateSuccess([obj1, obj2], { workspaces: [workspace] }); + const expected = expect.objectContaining({ workspaces: [workspace] }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + }); }); describe('errors', () => { @@ -815,6 +1018,12 @@ describe('SavedObjectsRepository', () => { const response1 = { status: 200, docs: [ + { + found: true, + _source: { + type: obj1.type, + }, + }, { found: true, _source: { @@ -837,7 +1046,13 @@ describe('SavedObjectsRepository', () => { expect(client.bulk).toHaveBeenCalled(); expect(client.mget).toHaveBeenCalled(); - const body1 = { docs: [expect.objectContaining({ _id: `${obj.type}:${obj.id}` })] }; + const body1 = { + docs: [ + expect.objectContaining({ _id: `${obj1.type}:${obj1.id}` }), + expect.objectContaining({ _id: `${obj.type}:${obj.id}` }), + expect.objectContaining({ _id: `${obj2.type}:${obj2.id}` }), + ], + }; expect(client.mget).toHaveBeenCalledWith( expect.objectContaining({ body: body1 }), expect.anything() @@ -1846,9 +2061,17 @@ describe('SavedObjectsRepository', () => { const createSuccess = async (type, attributes, options) => { const result = await savedObjectsRepository.create(type, attributes, options); - expect(client.get).toHaveBeenCalledTimes( - registry.isMultiNamespace(type) && options.overwrite ? 1 : 0 - ); + let count = 0; + if (options?.overwrite && options?.id) { + /** + * workspace will call extra one to get latest status of current object + */ + count++; + } + if (registry.isMultiNamespace(type) && options.overwrite) { + count++; + } + expect(client.get).toHaveBeenCalledTimes(count); return result; }; @@ -2186,13 +2409,18 @@ describe('SavedObjectsRepository', () => { const type = 'index-pattern'; const id = 'logstash-*'; const namespace = 'foo-namespace'; + const workspaces = ['bar-workspace']; + + const mockGet = async (type, id, options) => { + const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace, workspaces); + client.get.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) + ); + }; const deleteSuccess = async (type, id, options) => { if (registry.isMultiNamespace(type)) { - const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace); - client.get.mockResolvedValueOnce( - opensearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) - ); + mockGet(type, id, options); } client.delete.mockResolvedValueOnce( opensearchClientMock.createSuccessTransportRequestPromise({ result: 'deleted' }) @@ -2468,6 +2696,418 @@ describe('SavedObjectsRepository', () => { }); }); + describe('#deleteByWorkspace', () => { + const workspace = 'bar-workspace'; + const mockUpdateResults = { + took: 15, + timed_out: false, + total: 3, + updated: 2, + deleted: 1, + batches: 1, + version_conflicts: 0, + noops: 0, + retries: { bulk: 0, search: 0 }, + throttled_millis: 0, + requests_per_second: -1.0, + throttled_until_millis: 0, + failures: [], + }; + + const deleteByWorkspaceSuccess = async (workspace, options) => { + client.updateByQuery.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(mockUpdateResults) + ); + const result = await savedObjectsRepository.deleteByWorkspace(workspace, options); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledTimes(1); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); + return result; + }; + + describe('client calls', () => { + it(`should use the OpenSearch updateByQuery action`, async () => { + await deleteByWorkspaceSuccess(workspace); + expect(client.updateByQuery).toHaveBeenCalledTimes(1); + }); + + it(`should use all indices for all types`, async () => { + await deleteByWorkspaceSuccess(workspace); + expect(client.updateByQuery).toHaveBeenCalledWith( + expect.objectContaining({ index: ['.opensearch_dashboards_test', 'custom'] }), + expect.anything() + ); + }); + }); + + describe('errors', () => { + it(`throws when workspace is not a string or is '*'`, async () => { + const test = async (workspace) => { + await expect(savedObjectsRepository.deleteByWorkspace(workspace)).rejects.toThrowError( + `workspace is required, and must be a string that is not equal to '*'` + ); + expect(client.updateByQuery).not.toHaveBeenCalled(); + }; + await test(undefined); + await test(null); + await test(['foo-workspace']); + await test(123); + await test(true); + await test(ALL_NAMESPACES_STRING); + }); + }); + + describe('returns', () => { + it(`returns the query results on success`, async () => { + const result = await deleteByWorkspaceSuccess(workspace); + expect(result).toEqual(mockUpdateResults); + }); + }); + + describe('search dsl', () => { + it(`constructs a query that have workspace as search critieria`, async () => { + await deleteByWorkspaceSuccess(workspace); + const allTypes = registry.getAllTypes().map((type) => type.name); + expect(getSearchDslNS.getSearchDsl).toHaveBeenCalledWith(mappings, registry, { + workspaces: [workspace], + type: allTypes, + }); + }); + }); + }); + + describe('#deleteFromWorkspace', () => { + const id = 'fake-id'; + const type = 'dashboard'; + + const mockGetResponse = (type, id, workspaces) => { + // mock a document that exists in two namespaces + const mockResponse = getMockGetResponse({ type, id, workspaces }); + client.get.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(mockResponse) + ); + }; + + const deleteFromWorkspacesSuccess = async ( + type, + id, + workspaces, + currentWorkspaces, + options + ) => { + mockGetResponse(type, id, currentWorkspaces); + client.delete.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'deleted', + }) + ); + client.update.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise({ + _id: `${type}:${id}`, + ...mockVersionProps, + result: 'updated', + }) + ); + + return await savedObjectsRepository.deleteFromWorkspaces(type, id, workspaces, options); + }; + + describe('client calls', () => { + describe('delete action', () => { + const deleteFromWorkspacesSuccessDelete = async (expectFn, options, _type = type) => { + const test = async (workspaces) => { + await deleteFromWorkspacesSuccess(_type, id, workspaces, workspaces, options); + expectFn(); + client.delete.mockClear(); + client.get.mockClear(); + }; + await test(['foo-workspace']); + await test(['foo-workspace', 'bar-workspace']); + }; + + it(`should use OpenSearch get action then delete action if the object has no workspaces remaining`, async () => { + const expectFn = () => { + expect(client.delete).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(1); + }; + await deleteFromWorkspacesSuccessDelete(expectFn); + }); + + it(`formats the OpenSearch requests`, async () => { + const expectFn = () => { + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); + + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + ...versionProperties, + }), + expect.anything() + ); + }; + await deleteFromWorkspacesSuccessDelete(expectFn); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + await deleteFromWorkspacesSuccessDelete(() => + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ) + ); + }); + + it(`should use default index`, async () => { + const expectFn = () => + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ index: '.opensearch_dashboards_test' }), + expect.anything() + ); + await deleteFromWorkspacesSuccessDelete(expectFn); + }); + + it(`should use custom index`, async () => { + const expectFn = () => + expect(client.delete).toHaveBeenCalledWith( + expect.objectContaining({ index: 'custom' }), + expect.anything() + ); + await deleteFromWorkspacesSuccessDelete(expectFn, {}, CUSTOM_INDEX_TYPE); + }); + }); + + describe('update action', () => { + const deleteFromWorkspacesSuccessUpdate = async (expectFn, options, _type = type) => { + const test = async (remaining) => { + const deleteWorkspace = 'deleted-workspace'; + const currentWorkspaces = [deleteWorkspace].concat(remaining); + await deleteFromWorkspacesSuccess( + _type, + id, + [deleteWorkspace], + currentWorkspaces, + options + ); + expectFn(); + client.get.mockClear(); + client.update.mockClear(); + }; + await test(['foo-workspace']); + await test(['foo-workspace', 'bar-workspace']); + }; + + it(`should use OpenSearch get action then update action if the object has one or more workspace remaining`, async () => { + const expectFn = () => { + expect(client.update).toHaveBeenCalledTimes(1); + expect(client.get).toHaveBeenCalledTimes(1); + }; + await deleteFromWorkspacesSuccessUpdate(expectFn); + }); + + it(`formats the OpenSearch requests`, async () => { + let ctr = 0; + const expectFn = () => { + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + }), + expect.anything() + ); + const workspaces = ctr++ === 0 ? ['foo-workspace'] : ['foo-workspace', 'bar-workspace']; + const versionProperties = { + if_seq_no: mockVersionProps._seq_no, + if_primary_term: mockVersionProps._primary_term, + }; + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + id: `${type}:${id}`, + ...versionProperties, + body: { doc: { ...mockTimestampFields, workspaces: workspaces } }, + }), + expect.anything() + ); + }; + await deleteFromWorkspacesSuccessUpdate(expectFn); + }); + + it(`defaults to a refresh setting of wait_for`, async () => { + const expectFn = () => + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ + refresh: 'wait_for', + }), + expect.anything() + ); + await deleteFromWorkspacesSuccessUpdate(expectFn); + }); + + it(`should use default index`, async () => { + const expectFn = () => + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ index: '.opensearch_dashboards_test' }), + expect.anything() + ); + await deleteFromWorkspacesSuccessUpdate(expectFn); + }); + + it(`should use custom index`, async () => { + const expectFn = () => + expect(client.update).toHaveBeenCalledWith( + expect.objectContaining({ index: 'custom' }), + expect.anything() + ); + await deleteFromWorkspacesSuccessUpdate(expectFn, {}, CUSTOM_INDEX_TYPE); + }); + }); + }); + + describe('errors', () => { + const expectNotFoundError = async (type, id, workspaces, options) => { + await expect( + savedObjectsRepository.deleteFromWorkspaces(type, id, workspaces, options) + ).rejects.toThrowError(createGenericNotFoundError(type, id)); + }; + const expectBadRequestError = async (type, id, workspaces, message) => { + await expect( + savedObjectsRepository.deleteFromWorkspaces(type, id, workspaces) + ).rejects.toThrowError(createBadRequestError(message)); + }; + + it(`throws when type is invalid`, async () => { + await expectNotFoundError('unknownType', id, ['foo', 'bar']); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); + }); + + it(`throws when workspaces is an empty array`, async () => { + const test = async (workspaces) => { + const message = 'workspaces must be a non-empty array of strings'; + await expectBadRequestError(type, id, workspaces, message); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); + }; + await test([]); + }); + + it(`throws when OpenSearch is unable to find the document during get`, async () => { + client.get.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise({ found: false }) + ); + await expectNotFoundError(type, id, ['foo', 'bar']); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).not.toHaveBeenCalled(); + expect(client.update).not.toHaveBeenCalled(); + }); + + it(`throws when OpenSearch is unable to find the index during get`, async () => { + client.get.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + await expectNotFoundError(type, id, ['foo', 'bar']); + expect(client.get).toHaveBeenCalledTimes(1); + }); + + it(`throws when OpenSearch is unable to find the document during delete`, async () => { + mockGetResponse(type, id, ['foo']); + client.delete.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise({ result: 'not_found' }) + ); + await expectNotFoundError(type, id, ['foo']); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); + }); + + it(`throws when OpenSearch is unable to find the index during delete`, async () => { + mockGetResponse(type, id, ['foo']); + client.delete.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise({ + error: { type: 'index_not_found_exception' }, + }) + ); + await expectNotFoundError(type, id, ['foo']); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); + }); + + it(`throws when OpenSearch returns an unexpected response`, async () => { + mockGetResponse(type, id, ['foo']); + client.delete.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise({ + result: 'something unexpected', + }) + ); + await expect( + savedObjectsRepository.deleteFromWorkspaces(type, id, ['foo']) + ).rejects.toThrowError('Unexpected OpenSearch DELETE response'); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.delete).toHaveBeenCalledTimes(1); + }); + + it(`throws when OpenSearch is unable to find the document during update`, async () => { + mockGetResponse(type, id, ['foo', 'bar']); + client.update.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise({}, { statusCode: 404 }) + ); + await expectNotFoundError(type, id, ['foo']); + expect(client.get).toHaveBeenCalledTimes(1); + expect(client.update).toHaveBeenCalledTimes(1); + }); + }); + + describe('returns', () => { + it(`returns an empty workspaces array on success (delete)`, async () => { + const test = async (workspaces) => { + const result = await deleteFromWorkspacesSuccess(type, id, workspaces, workspaces); + expect(result).toEqual({ workspaces: [] }); + client.delete.mockClear(); + }; + await test(['foo']); + await test(['foo', 'bar']); + }); + + it(`returns remaining workspaces on success (update)`, async () => { + const test = async (remaining) => { + const deletedWorkspace = 'delete-workspace'; + const currentNamespaces = [deletedWorkspace].concat(remaining); + const result = await deleteFromWorkspacesSuccess( + type, + id, + [deletedWorkspace], + currentNamespaces + ); + expect(result).toEqual({ workspaces: remaining }); + client.delete.mockClear(); + }; + await test(['foo']); + await test(['foo', 'bar']); + }); + + it(`succeeds when the document doesn't exist in all of the targeted workspaces`, async () => { + const workspaceToRemove = ['foo']; + const currentWorkspaces = ['bar']; + const result = await deleteFromWorkspacesSuccess( + type, + id, + workspaceToRemove, + currentWorkspaces + ); + expect(result).toEqual({ workspaces: currentWorkspaces }); + }); + }); + }); + describe('#find', () => { const generateSearchResults = (namespace) => { return { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index bccfd8ff2265..96308104d6a0 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -28,11 +28,12 @@ * under the License. */ -import { omit } from 'lodash'; +import { omit, intersection } from 'lodash'; import type { opensearchtypes } from '@opensearch-project/opensearch'; import uuid from 'uuid'; import type { ISavedObjectTypeRegistry } from '../../saved_objects_type_registry'; -import { OpenSearchClient, DeleteDocumentResponse } from '../../../opensearch/'; +import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; +import { DeleteDocumentResponse, OpenSearchClient } from '../../../opensearch/'; import { getRootPropertiesObjects, IndexMapping } from '../../mappings'; import { createRepositoryOpenSearchClient, @@ -40,43 +41,48 @@ import { } from './repository_opensearch_client'; import { getSearchDsl } from './search_dsl'; import { includedFields } from './included_fields'; -import { SavedObjectsErrorHelpers, DecoratedError } from './errors'; -import { decodeRequestVersion, encodeVersion, encodeHitVersion } from '../../version'; +import { DecoratedError, SavedObjectsErrorHelpers } from './errors'; +import { decodeRequestVersion, encodeHitVersion, encodeVersion } from '../../version'; import { IOpenSearchDashboardsMigrator } from '../../migrations'; import { - SavedObjectsSerializer, SavedObjectSanitizedDoc, SavedObjectsRawDoc, SavedObjectsRawDocSource, + SavedObjectsSerializer, } from '../../serialization'; import { + SavedObjectsAddToNamespacesOptions, + SavedObjectsAddToNamespacesResponse, + SavedObjectsAddToWorkspacesOptions, + SavedObjectsAddToWorkspacesResponse, SavedObjectsBulkCreateObject, SavedObjectsBulkGetObject, SavedObjectsBulkResponse, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateOptions, SavedObjectsBulkUpdateResponse, SavedObjectsCheckConflictsObject, SavedObjectsCheckConflictsResponse, SavedObjectsCreateOptions, + SavedObjectsDeleteByWorkspaceOptions, + SavedObjectsDeleteFromNamespacesOptions, + SavedObjectsDeleteFromNamespacesResponse, + SavedObjectsDeleteFromWorkspacesOptions, + SavedObjectsDeleteFromWorkspacesResponse, + SavedObjectsDeleteOptions, SavedObjectsFindResponse, SavedObjectsFindResult, + SavedObjectsShareObjects, SavedObjectsUpdateOptions, SavedObjectsUpdateResponse, - SavedObjectsBulkUpdateObject, - SavedObjectsBulkUpdateOptions, - SavedObjectsDeleteOptions, - SavedObjectsAddToNamespacesOptions, - SavedObjectsAddToNamespacesResponse, - SavedObjectsDeleteFromNamespacesOptions, - SavedObjectsDeleteFromNamespacesResponse, } from '../saved_objects_client'; import { + MutatingOperationRefreshSetting, SavedObject, SavedObjectsBaseOptions, SavedObjectsFindOptions, SavedObjectsMigrationVersion, - MutatingOperationRefreshSetting, } from '../../types'; -import { SavedObjectTypeRegistry } from '../../saved_objects_type_registry'; import { validateConvertFilterToKueryNode } from './filter_utils'; import { ALL_NAMESPACES_STRING, @@ -84,7 +90,6 @@ import { FIND_DEFAULT_PER_PAGE, SavedObjectsUtils, } from './utils'; - // BEWARE: The SavedObjectClient depends on the implementation details of the SavedObjectsRepository // so any breaking changes to this repository are considered breaking changes to the SavedObjectsClient. @@ -119,7 +124,8 @@ export interface SavedObjectsIncrementCounterOptions extends SavedObjectsBaseOpt * * @public */ -export interface SavedObjectsDeleteByNamespaceOptions extends SavedObjectsBaseOptions { +export interface SavedObjectsDeleteByNamespaceOptions + extends Omit { /** The OpenSearch supports only boolean flag for this operation */ refresh?: boolean; } @@ -243,6 +249,8 @@ export class SavedObjectsRepository { originId, initialNamespaces, version, + workspaces, + permissions, } = options; const namespace = normalizeNamespace(options.namespace); @@ -289,6 +297,8 @@ export class SavedObjectsRepository { migrationVersion, updated_at: time, ...(Array.isArray(references) && { references }), + ...(Array.isArray(workspaces) && { workspaces }), + ...(permissions && { permissions }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -355,15 +365,28 @@ export class SavedObjectsRepository { const method = object.id && overwrite ? 'index' : 'create'; const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); + /** + * Only when importing an object to a target workspace should we check if the object is workspace-specific. + */ + const requiresWorkspaceCheck = object.id; if (object.id == null) object.id = uuid.v1(); + let opensearchRequestIndexPayload = {}; + + if (requiresNamespacesCheck || requiresWorkspaceCheck) { + opensearchRequestIndexPayload = { + opensearchRequestIndex: bulkGetRequestIndexCounter, + }; + bulkGetRequestIndexCounter++; + } + return { tag: 'Right' as 'Right', value: { method, object, - ...(requiresNamespacesCheck && { opensearchRequestIndex: bulkGetRequestIndexCounter++ }), + ...opensearchRequestIndexPayload, }, }; }); @@ -374,7 +397,7 @@ export class SavedObjectsRepository { .map(({ value: { object: { type, id } } }) => ({ _id: this._serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), - _source: ['type', 'namespaces'], + _source: ['type', 'namespaces', 'workspaces'], })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -405,9 +428,10 @@ export class SavedObjectsRepository { if (opensearchRequestIndex !== undefined) { const indexFound = bulkGetResponse?.statusCode !== 404; const actualResult = indexFound - ? bulkGetResponse?.body.docs[opensearchRequestIndex] + ? bulkGetResponse?.body.docs?.[opensearchRequestIndex] : undefined; const docFound = indexFound && actualResult?.found === true; + let hasSetNamespace = false; // @ts-expect-error MultiGetHit._source is optional if (docFound && !this.rawDocExistsInNamespace(actualResult!, namespace)) { const { id, type } = object; @@ -422,11 +446,20 @@ export class SavedObjectsRepository { }, }, }; + } else { + hasSetNamespace = true; + if (this._registry.isSingleNamespace(object.type)) { + savedObjectNamespace = initialNamespaces ? initialNamespaces[0] : namespace; + } else if (this._registry.isMultiNamespace(object.type)) { + savedObjectNamespaces = initialNamespaces || getSavedObjectNamespaces(namespace); + } + } + if (!hasSetNamespace) { + savedObjectNamespaces = + initialNamespaces || + // @ts-expect-error MultiGetHit._source is optional + getSavedObjectNamespaces(namespace, docFound ? actualResult : undefined); } - savedObjectNamespaces = - initialNamespaces || - // @ts-expect-error MultiGetHit._source is optional - getSavedObjectNamespaces(namespace, docFound ? actualResult : undefined); // @ts-expect-error MultiGetHit._source is optional versionProperties = getExpectedVersionProperties(version, actualResult); } else { @@ -438,6 +471,12 @@ export class SavedObjectsRepository { versionProperties = getExpectedVersionProperties(version); } + let savedObjectWorkspaces = options.workspaces; + + if (expectedBulkGetResult.value.method !== 'create') { + savedObjectWorkspaces = object.workspaces; + } + const expectedResult = { opensearchRequestIndex: bulkRequestIndexCounter++, requestedId: object.id, @@ -452,6 +491,7 @@ export class SavedObjectsRepository { updated_at: time, references: object.references || [], originId: object.originId, + ...(savedObjectWorkspaces && { workspaces: savedObjectWorkspaces }), }) as SavedObjectSanitizedDoc ), }; @@ -549,7 +589,7 @@ export class SavedObjectsRepository { const bulkGetDocs = expectedBulkGetResults.filter(isRight).map(({ value: { type, id } }) => ({ _id: this._serializer.generateRawId(namespace, type, id), _index: this.getIndexForType(type), - _source: ['type', 'namespaces'], + _source: ['type', 'namespaces', 'workspaces'], })); const bulkGetResponse = bulkGetDocs.length ? await this.client.mget( @@ -572,13 +612,24 @@ export class SavedObjectsRepository { const { type, id, opensearchRequestIndex } = expectedResult.value; const doc = bulkGetResponse?.body.docs[opensearchRequestIndex]; if (doc?.found) { + let workspaceConflict = false; + if (options.workspaces) { + const transformedObject = this._serializer.rawToSavedObject(doc as SavedObjectsRawDoc); + const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToSourceWorkspaces( + options.workspaces, + transformedObject.workspaces + ); + if (filteredWorkspaces.length) { + workspaceConflict = true; + } + } errors.push({ id, type, error: { ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), // @ts-expect-error MultiGetHit._source is optional - ...(!this.rawDocExistsInNamespace(doc!, namespace) && { + ...((!this.rawDocExistsInNamespace(doc!, namespace) || workspaceConflict) && { metadata: { isNotOverwritable: true }, }), }, @@ -702,6 +753,55 @@ export class SavedObjectsRepository { return body; } + /** + * Deletes all objects from the provided workspace. It used when deleting a workspace. + * + * @param {string} workspace + * @param options SavedObjectsDeleteByWorkspaceOptions + * @returns {promise} - { took, timed_out, total, deleted, batches, version_conflicts, noops, retries, failures } + */ + async deleteByWorkspace( + workspace: string, + options: SavedObjectsDeleteByWorkspaceOptions = {} + ): Promise { + if (!workspace || typeof workspace !== 'string' || workspace === '*') { + throw new TypeError(`workspace is required, and must be a string that is not equal to '*'`); + } + + const allTypes = Object.keys(getRootPropertiesObjects(this._mappings)); + + const { body } = await this.client.updateByQuery( + { + index: this.getIndicesForTypes(allTypes), + refresh: options.refresh, + body: { + script: { + source: ` + if (!ctx._source.containsKey('workspaces')) { + ctx.op = "delete"; + } else { + ctx._source['workspaces'].removeAll(Collections.singleton(params['workspace'])); + if (ctx._source['workspaces'].empty) { + ctx.op = "delete"; + } + } + `, + lang: 'painless', + params: { workspace }, + }, + conflicts: 'proceed', + ...getSearchDsl(this._mappings, this._registry, { + workspaces: [workspace], + type: allTypes, + }), + }, + }, + { ignore: [404] } + ); + + return body; + } + /** * @param {object} [options={}] * @property {(string|Array)} [options.type] @@ -736,6 +836,9 @@ export class SavedObjectsRepository { typeToNamespacesMap, filter, preference, + workspaces, + ACLSearchParams, + flags, } = options; if (!type && !typeToNamespacesMap) { @@ -809,6 +912,9 @@ export class SavedObjectsRepository { typeToNamespacesMap, hasReference, kueryNode, + workspaces, + ACLSearchParams, + flags, }), }, }; @@ -862,7 +968,7 @@ export class SavedObjectsRepository { */ async bulkGet( objects: SavedObjectsBulkGetObject[] = [], - options: SavedObjectsBaseOptions = {} + options: Omit = {} ): Promise> { const namespace = normalizeNamespace(options.namespace); @@ -950,7 +1056,7 @@ export class SavedObjectsRepository { async get( type: string, id: string, - options: SavedObjectsBaseOptions = {} + options: Omit = {} ): Promise> { if (!this._allowedTypes.includes(type)) { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); @@ -976,7 +1082,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt } = body._source; + const { originId, updated_at: updatedAt, workspaces, permissions } = body._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -991,6 +1097,8 @@ export class SavedObjectsRepository { namespaces, ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), + ...(workspaces && { workspaces }), + ...(permissions && { permissions }), version: encodeHitVersion(body), attributes: body._source[type], references: body._source.references || [], @@ -1019,7 +1127,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { version, references, refresh = DEFAULT_REFRESH_SETTING } = options; + const { version, references, refresh = DEFAULT_REFRESH_SETTING, permissions } = options; const namespace = normalizeNamespace(options.namespace); let preflightResult: SavedObjectsRawDoc | undefined; @@ -1033,6 +1141,7 @@ export class SavedObjectsRepository { [type]: attributes, updated_at: time, ...(Array.isArray(references) && { references }), + ...(permissions && { permissions }), }; const { body, statusCode } = await this.client.update( @@ -1055,7 +1164,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId } = body.get?._source ?? {}; + const { originId, workspaces } = body.get?._source ?? {}; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { namespaces = body.get?._source.namespaces ?? [ @@ -1070,6 +1179,7 @@ export class SavedObjectsRepository { version: encodeHitVersion(body), namespaces, ...(originId && { originId }), + ...(workspaces && { workspaces }), references, attributes, }; @@ -1240,6 +1350,215 @@ export class SavedObjectsRepository { } } + /** + * Adds one or more workspaces to a given saved objects. This method and + * [`deleteFromWorkspaces`]{@link SavedObjectsRepository.deleteFromWorkspaces} are the only ways to change which workspace + * saved object is shared to. + * @param savedObjects saved objects that will shared to new workspaces + * @param workspaces new workspaces + */ + async addToWorkspaces( + savedObjects: SavedObjectsShareObjects[], + workspaces: string[], + options: SavedObjectsAddToWorkspacesOptions = {} + ): Promise { + if (!savedObjects.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'shared savedObjects must not be an empty array' + ); + } + + savedObjects.forEach(({ type, id }) => { + if (!this._allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + }); + + if (!workspaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'workspaces must be a non-empty array of strings' + ); + } + + const { refresh = DEFAULT_REFRESH_SETTING } = options; + const savedObjectsBulkResponse = await this.bulkGet(savedObjects); + + const errorObjects = savedObjectsBulkResponse.saved_objects.filter((obj) => !!obj.error); + if (errorObjects && errorObjects.length) { + const errors = errorObjects.map((errorObject) => errorObject.error?.message).join(','); + throw SavedObjectsErrorHelpers.decorateBadRequestError(new Error(errors)); + } + + // saved objects must exist in specified workspace + if (options.workspaces) { + const invalidObjects = savedObjectsBulkResponse.saved_objects.filter((obj) => { + if (obj.workspaces && obj.workspaces.length > 0) { + return intersection(obj.workspaces, options.workspaces).length === 0; + } + return true; + }); + if (invalidObjects && invalidObjects.length > 0) { + const [savedObj] = invalidObjects; + throw SavedObjectsErrorHelpers.createGenericNotFoundError(savedObj.type, savedObj.id); + } + } + + const docs = savedObjectsBulkResponse.saved_objects.map((obj) => { + const { type, id } = obj; + const rawId = this._serializer.generateRawId(undefined, type, id); + const time = this._getCurrentTime(); + + return [ + { + update: { + _id: rawId, + _index: this.getIndexForType(type), + }, + }, + { + script: { + source: ` + if (params.workspaces != null && ctx._source.workspaces != null) { + ctx._source.workspaces.addAll(params.workspaces); + HashSet workspacesSet = new HashSet(ctx._source.workspaces); + ctx._source.workspaces = new ArrayList(workspacesSet); + } + ctx._source.updated_at = params.time; + `, + lang: 'painless', + params: { + time, + workspaces, + }, + }, + }, + ]; + }); + + const bulkUpdateResponse = await this.client.bulk({ + refresh, + body: docs.flat(), + _source_includes: ['workspaces'], + }); + + if (bulkUpdateResponse.body.errors) { + const failures = bulkUpdateResponse.body.items + .map((item) => item.update?.error?.reason) + .join(','); + throw SavedObjectsErrorHelpers.createBadRequestError( + 'Add to workspace failed with: ' + failures + ); + } + + const savedObjectIdWorkspaceMap = bulkUpdateResponse.body.items.reduce((map, item) => { + return map.set(item.update?._id!, item.update?.get?._source.workspaces); + }, new Map()); + + return savedObjects.map((obj) => { + const rawId = this._serializer.generateRawId(undefined, obj.type, obj.id); + return { + type: obj.type, + id: obj.id, + workspaces: savedObjectIdWorkspaceMap.get(rawId), + } as SavedObjectsAddToWorkspacesResponse; + }); + } + + /** + * Removes one or more workspace from a given saved object. If no workspace remain, the saved object is deleted + * entirely. This method and [`addToWorkspaces`]{@link SavedObjectsRepository.addToWorkspaces} are the only ways to change which workspace a + * saved object is shared to. + */ + async deleteFromWorkspaces( + type: string, + id: string, + workspaces: string[], + options: SavedObjectsDeleteFromWorkspacesOptions = {} + ): Promise { + if (!this._allowedTypes.includes(type)) { + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + if (!workspaces.length) { + throw SavedObjectsErrorHelpers.createBadRequestError( + 'workspaces must be a non-empty array of strings' + ); + } + + const { refresh = DEFAULT_REFRESH_SETTING } = options; + + const rawId = this._serializer.generateRawId(undefined, type, id); + const savedObject = await this.get(type, id); + const existingWorkspaces = savedObject.workspaces; + // if there are somehow no existing workspaces, allow the operation to proceed and delete this saved object + const remainingWorkspaces = existingWorkspaces?.filter((x) => !workspaces.includes(x)); + + if (remainingWorkspaces?.length) { + // if there is 1 or more workspace remaining, update the saved object + const time = this._getCurrentTime(); + + const doc = { + updated_at: time, + workspaces: remainingWorkspaces, + }; + + const { statusCode } = await this.client.update( + { + id: rawId, + index: this.getIndexForType(type), + ...decodeRequestVersion(savedObject.version), + refresh, + + body: { + doc, + }, + }, + { + ignore: [404], + } + ); + + if (statusCode === 404) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + return { workspaces: doc.workspaces }; + } else { + // if there are no namespaces remaining, delete the saved object + const { body, statusCode } = await this.client.delete( + { + id: rawId, + index: this.getIndexForType(type), + refresh, + ...decodeRequestVersion(savedObject.version), + }, + { + ignore: [404], + } + ); + + const deleted = body.result === 'deleted'; + if (deleted) { + return { workspaces: [] }; + } + + const deleteDocNotFound = body.result === 'not_found'; + const deleteIndexNotFound = body.error && body.error.type === 'index_not_found_exception'; + if (deleteDocNotFound || deleteIndexNotFound) { + // see "404s from missing index" above + throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); + } + + throw new Error( + `Unexpected OpenSearch DELETE response: ${JSON.stringify({ + type, + id, + response: { body, statusCode }, + })}` + ); + } + } + /** * Updates multiple objects in bulk * @@ -1452,12 +1771,13 @@ export class SavedObjectsRepository { }; } - const { originId } = get._source; + const { originId, workspaces } = get._source; return { id, type, ...(namespaces && { namespaces }), ...(originId && { originId }), + ...(workspaces && { workspaces }), updated_at, version: encodeVersion(seqNo, primaryTerm), attributes, @@ -1754,7 +2074,7 @@ function getSavedObjectFromSource( id: string, doc: { _seq_no?: number; _primary_term?: number; _source: SavedObjectsRawDocSource } ): SavedObject { - const { originId, updated_at: updatedAt } = doc._source; + const { originId, updated_at: updatedAt, workspaces, permissions } = doc._source; let namespaces: string[] = []; if (!registry.isNamespaceAgnostic(type)) { @@ -1769,10 +2089,12 @@ function getSavedObjectFromSource( namespaces, ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), + ...(workspaces && { workspaces }), version: encodeHitVersion(doc), attributes: doc._source[type], references: doc._source.references || [], migrationVersion: doc._source.migrationVersion, + permissions, }; } diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts index 518e2ff56d0e..9c4cb9519102 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.test.ts @@ -506,6 +506,25 @@ describe('#getQueryParams', () => { ); }); }); + + describe('`flags` parameter', () => { + it('does not include flags when `flags` is not specified', () => { + const result = getQueryParams({ + registry, + search, + }); + expectResult(result, expect.not.objectContaining({ flags: expect.anything() })); + }); + + it('includes flags when specified', () => { + const result = getQueryParams({ + registry, + search, + flags: 'abc', + }); + expectResult(result, expect.objectContaining({ flags: expect.stringMatching('abc') })); + }); + }); }); describe('when using prefix search (query.bool.should)', () => { @@ -625,6 +644,27 @@ describe('#getQueryParams', () => { ]); }); }); + + describe('when using workspace search', () => { + it('using normal workspaces', () => { + const result: Result = getQueryParams({ + registry, + workspaces: ['foo'], + }); + expect(result.query.bool.filter[1]).toEqual({ + bool: { + should: [ + { + bool: { + must: [{ term: { workspaces: 'foo' } }], + }, + }, + ], + minimum_should_match: 1, + }, + }); + }); + }); }); describe('namespaces property', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 5bbb0a1fe24f..4f2170abad22 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts @@ -27,13 +27,14 @@ * specific language governing permissions and limitations * under the License. */ - // @ts-expect-error no ts import { opensearchKuery } from '../../../opensearch_query'; type KueryNode = any; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; import { ALL_NAMESPACES_STRING, DEFAULT_NAMESPACE_STRING } from '../utils'; +import { SavedObjectsFindOptions } from '../../../types'; +import { ACL } from '../../../permission_control/acl'; /** * Gets the types based on the type. Uses mappings to support @@ -128,6 +129,27 @@ function getClauseForType( }; } +/** + * Gets the clause that will filter for the workspace. + */ +function getClauseForWorkspace(workspace: string) { + if (workspace === '*') { + return { + bool: { + must: { + match_all: {}, + }, + }, + }; + } + + return { + bool: { + must: [{ term: { workspaces: workspace } }], + }, + }; +} + interface HasReferenceQueryParams { type: string; id: string; @@ -144,6 +166,9 @@ interface QueryParams { defaultSearchOperator?: string; hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; + workspaces?: string[]; + ACLSearchParams?: SavedObjectsFindOptions['ACLSearchParams']; + flags?: string; } export function getClauseForReference(reference: HasReferenceQueryParams) { @@ -200,6 +225,9 @@ export function getQueryParams({ defaultSearchOperator, hasReference, kueryNode, + workspaces, + ACLSearchParams, + flags, }: QueryParams) { const types = getTypes( registry, @@ -224,6 +252,17 @@ export function getQueryParams({ ], }; + if (workspaces) { + bool.filter.push({ + bool: { + should: workspaces.map((workspace) => { + return getClauseForWorkspace(workspace); + }), + minimum_should_match: 1, + }, + }); + } + if (search) { const useMatchPhrasePrefix = shouldUseMatchPhrasePrefix(search); const simpleQueryStringClause = getSimpleQueryStringClause({ @@ -232,6 +271,7 @@ export function getQueryParams({ searchFields, rootSearchFields, defaultSearchOperator, + flags, }); if (useMatchPhrasePrefix) { @@ -245,7 +285,47 @@ export function getQueryParams({ } } - return { query: { bool } }; + const result = { query: { bool } }; + + if (ACLSearchParams) { + const shouldClause: any = []; + if (ACLSearchParams.permissionModes && ACLSearchParams.principals) { + const permissionDSL = ACL.generateGetPermittedSavedObjectsQueryDSL( + ACLSearchParams.permissionModes, + ACLSearchParams.principals + ); + shouldClause.push(permissionDSL.query); + } + + if (ACLSearchParams.workspaces) { + shouldClause.push({ + terms: { + workspaces: ACLSearchParams.workspaces, + }, + }); + } + + if (shouldClause.length) { + bool.filter.push({ + bool: { + should: [ + /** + * TODO remove this clause once advanced settings has attached with permission + */ + { + term: { + type: 'config', + }, + }, + ...shouldClause, + ], + }, + }); + } + + return result; + } + return result; } // we only want to add match_phrase_prefix clauses @@ -340,18 +420,21 @@ const getSimpleQueryStringClause = ({ searchFields, rootSearchFields, defaultSearchOperator, + flags, }: { search: string; types: string[]; searchFields?: string[]; rootSearchFields?: string[]; defaultSearchOperator?: string; + flags?: string; }) => { return { simple_query_string: { query: search, ...getSimpleQueryStringTypeFields(types, searchFields, rootSearchFields), ...(defaultSearchOperator ? { default_operator: defaultSearchOperator } : {}), + ...(flags ? { flags } : {}), }, }; }; diff --git a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts index 8b54141a4c3c..ba8caff62401 100644 --- a/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts +++ b/src/core/server/saved_objects/service/lib/search_dsl/search_dsl.ts @@ -34,6 +34,7 @@ import { IndexMapping } from '../../../mappings'; import { getQueryParams } from './query_params'; import { getSortingParams } from './sorting_params'; import { ISavedObjectTypeRegistry } from '../../../saved_objects_type_registry'; +import { SavedObjectsFindOptions } from '../../../types'; type KueryNode = any; @@ -52,6 +53,9 @@ interface GetSearchDslOptions { id: string; }; kueryNode?: KueryNode; + workspaces?: string[]; + ACLSearchParams?: SavedObjectsFindOptions['ACLSearchParams']; + flags?: string; } export function getSearchDsl( @@ -71,6 +75,9 @@ export function getSearchDsl( typeToNamespacesMap, hasReference, kueryNode, + workspaces, + ACLSearchParams, + flags, } = options; if (!type) { @@ -93,6 +100,9 @@ export function getSearchDsl( defaultSearchOperator, hasReference, kueryNode, + workspaces, + ACLSearchParams, + flags, }), ...getSortingParams(mappings, type, sortField, sortOrder), }; diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index 4823e52d77c9..ca719887986d 100644 --- a/src/core/server/saved_objects/service/lib/utils.ts +++ b/src/core/server/saved_objects/service/lib/utils.ts @@ -80,4 +80,11 @@ export class SavedObjectsUtils { total: 0, saved_objects: [], }); + + public static filterWorkspacesAccordingToSourceWorkspaces( + targetWorkspaces?: string[], + baseWorkspaces?: string[] + ): string[] { + return targetWorkspaces?.filter((item) => !baseWorkspaces?.includes(item)) || []; + } } diff --git a/src/core/server/saved_objects/service/saved_objects_client.mock.ts b/src/core/server/saved_objects/service/saved_objects_client.mock.ts index d39f101b18e2..763cb1bb1e92 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.mock.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.mock.ts @@ -45,6 +45,7 @@ const create = () => update: jest.fn(), addToNamespaces: jest.fn(), deleteFromNamespaces: jest.fn(), + addToWorkspaces: jest.fn(), } as unknown) as jest.Mocked); export const savedObjectsClientMock = { create }; diff --git a/src/core/server/saved_objects/service/saved_objects_client.test.js b/src/core/server/saved_objects/service/saved_objects_client.test.js index d22ffa502f79..676b1a37e051 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.test.js +++ b/src/core/server/saved_objects/service/saved_objects_client.test.js @@ -207,3 +207,18 @@ test(`#deleteFromNamespaces`, async () => { expect(mockRepository.deleteFromNamespaces).toHaveBeenCalledWith(type, id, namespaces, options); expect(result).toBe(returnValue); }); + +test(`#deleteByWorkspace`, async () => { + const returnValue = Symbol(); + const mockRepository = { + deleteByWorkspace: jest.fn().mockResolvedValue(returnValue), + }; + const client = new SavedObjectsClient(mockRepository); + + const workspace = Symbol(); + const options = Symbol(); + const result = await client.deleteByWorkspace(workspace, options); + + expect(mockRepository.deleteByWorkspace).toHaveBeenCalledWith(workspace, options); + expect(result).toBe(returnValue); +}); diff --git a/src/core/server/saved_objects/service/saved_objects_client.ts b/src/core/server/saved_objects/service/saved_objects_client.ts index 5f92dacacf36..c9990977bb48 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -28,6 +28,7 @@ * under the License. */ +import { Permissions } from '../permission_control/acl'; import { ISavedObjectsRepository } from './lib'; import { SavedObject, @@ -68,6 +69,12 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; + /** permission control describe by ACL object */ + permissions?: Permissions; + /** + * workspaces the new created objects belong to + */ + workspaces?: string[]; } /** @@ -91,6 +98,10 @@ export interface SavedObjectsBulkCreateObject { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; + /** + * workspaces the objects belong to, will only be used when overwrite is enabled. + */ + workspaces?: string[]; } /** @@ -169,6 +180,8 @@ export interface SavedObjectsCheckConflictsResponse { }>; } +export type SavedObjectsShareObjects = Pick; + /** * * @public @@ -180,6 +193,8 @@ export interface SavedObjectsUpdateOptions extends SavedObjectsBaseOptions { references?: SavedObjectReference[]; /** The OpenSearch Refresh setting for this operation */ refresh?: MutatingOperationRefreshSetting; + /** permission control describe by ACL object */ + permissions?: Permissions; } /** @@ -193,6 +208,11 @@ export interface SavedObjectsAddToNamespacesOptions extends SavedObjectsBaseOpti refresh?: MutatingOperationRefreshSetting; } +export interface SavedObjectsAddToWorkspacesOptions extends SavedObjectsBaseOptions { + /** The OpenSearch Refresh setting for this operation */ + refresh?: MutatingOperationRefreshSetting; +} + /** * * @public @@ -202,6 +222,11 @@ export interface SavedObjectsAddToNamespacesResponse { namespaces: string[]; } +export interface SavedObjectsAddToWorkspacesResponse extends Pick { + /** The workspaces the object exists in after this operation is complete. */ + workspaces: string[]; +} + /** * * @public @@ -211,6 +236,16 @@ export interface SavedObjectsDeleteFromNamespacesOptions extends SavedObjectsBas refresh?: MutatingOperationRefreshSetting; } +export interface SavedObjectsDeleteFromWorkspacesOptions extends SavedObjectsBaseOptions { + /** The OpenSearch Refresh setting for this operation */ + refresh?: MutatingOperationRefreshSetting; +} + +export interface SavedObjectsDeleteByWorkspaceOptions extends SavedObjectsBaseOptions { + /** The OpenSearch supports only boolean flag for this operation */ + refresh?: boolean; +} + /** * * @public @@ -220,6 +255,11 @@ export interface SavedObjectsDeleteFromNamespacesResponse { namespaces: string[]; } +export interface SavedObjectsDeleteFromWorkspacesResponse { + /** The workspaces the object exists in after this operation is complete. An empty array indicates the object was deleted. */ + workspaces: string[]; +} + /** * * @public @@ -433,6 +473,33 @@ export class SavedObjectsClient { return await this._repository.deleteFromNamespaces(type, id, namespaces, options); } + /** + * Adds workspace to SavedObjects + * + * @param savedObjects + * @param workspaces + * @param options + */ + addToWorkspaces = async ( + savedObjects: SavedObjectsShareObjects[], + workspaces: string[], + options: SavedObjectsAddToWorkspacesOptions = {} + ): Promise => { + return await this._repository.addToWorkspaces(savedObjects, workspaces, options); + }; + + /** + * delete saved objects by workspace id + * @param workspace + * @param options + */ + deleteByWorkspace = async ( + workspace: string, + options: SavedObjectsDeleteByWorkspaceOptions = {} + ): Promise => { + return await this._repository.deleteByWorkspace(workspace, options); + }; + /** * Bulk Updates multiple SavedObject at once * diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 3e2553b8ce51..d5333fb40aea 100644 --- a/src/core/server/saved_objects/types.ts +++ b/src/core/server/saved_objects/types.ts @@ -45,6 +45,7 @@ export { } from './import/types'; import { SavedObject } from '../../types'; +import { Principals } from './permission_control/acl'; type KueryNode = any; @@ -91,6 +92,8 @@ export interface SavedObjectsFindOptions { search?: string; /** The fields to perform the parsed query against. See OpenSearch Simple Query String `fields` argument for more information */ searchFields?: string[]; + /** The enabled operators for OpenSearch Simple Query String. See OpenSearch Simple Query String `flags` argument for more information */ + flags?: string; /** * The fields to perform the parsed query against. Unlike the `searchFields` argument, these are expected to be root fields and will not * be modified. If used in conjunction with `searchFields`, both are concatenated together. @@ -110,6 +113,16 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; /** An optional OpenSearch preference value to be used for the query **/ preference?: string; + /** If specified, will only retrieve objects that are in the workspaces */ + workspaces?: string[]; + /** + * The params here will be combined with bool clause and is used for filtering with ACL structure. + */ + ACLSearchParams?: { + workspaces?: string[]; + principals?: Principals; + permissionModes?: string[]; + }; } /** @@ -119,6 +132,8 @@ export interface SavedObjectsFindOptions { export interface SavedObjectsBaseOptions { /** Specify the namespace for this operation */ namespace?: string; + /** Specify the workspaces for this operation */ + workspaces?: string[]; } /** diff --git a/src/core/server/ui_settings/saved_objects/ui_settings.ts b/src/core/server/ui_settings/saved_objects/ui_settings.ts index a56b12ed2063..bafc6ef53977 100644 --- a/src/core/server/ui_settings/saved_objects/ui_settings.ts +++ b/src/core/server/ui_settings/saved_objects/ui_settings.ts @@ -47,7 +47,7 @@ export const uiSettingsType: SavedObjectsType = { importableAndExportable: true, getInAppUrl() { return { - path: `/app/management/opensearch-dashboards/settings`, + path: `/app/settings`, uiCapabilitiesPath: 'advancedSettings.show', }; }, diff --git a/src/core/server/utils/index.ts b/src/core/server/utils/index.ts index d2c9e0086ad7..42b01e72b0d1 100644 --- a/src/core/server/utils/index.ts +++ b/src/core/server/utils/index.ts @@ -32,3 +32,4 @@ export * from './crypto'; export * from './from_root'; export * from './package_json'; export * from './streams'; +export { getWorkspaceIdFromUrl, cleanWorkspaceId } from '../../utils'; diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 81e1ed029ddc..c1c8ede27593 100644 --- a/src/core/types/saved_objects.ts +++ b/src/core/types/saved_objects.ts @@ -9,6 +9,7 @@ * GitHub history for details. */ +import { Permissions } from '../server/saved_objects/permission_control/acl'; /* * Licensed to Elasticsearch B.V. under one or more contributor * license agreements. See the NOTICE file distributed with @@ -113,6 +114,9 @@ export interface SavedObject { * space. */ originId?: string; + /** Workspace(s) that this saved object exists in. */ + workspaces?: string[]; + permissions?: Permissions; } export interface SavedObjectError { diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index c95b993edc74..d66a93fcc61d 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -10,5 +10,10 @@ export interface WorkspaceAttribute { features?: string[]; color?: string; icon?: string; + defaultVISTheme?: string; reserved?: boolean; } + +export interface WorkspaceObject extends WorkspaceAttribute { + readonly?: boolean; +} diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 73c2d6010846..1eecb44ae96f 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -4,3 +4,18 @@ */ export const WORKSPACE_TYPE = 'workspace'; + +export const WORKSPACE_PATH_PREFIX = '/w'; + +export enum WorkspacePermissionMode { + Read = 'read', + Write = 'write', + LibraryRead = 'library_read', + LibraryWrite = 'library_write', +} + +export const PUBLIC_WORKSPACE_ID = 'public'; + +export const MANAGEMENT_WORKSPACE_ID = 'management'; + +export const PERSONAL_WORKSPACE_ID_PREFIX = 'personal'; diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 3c0920624e1b..d417f22f8429 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -38,7 +38,6 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze label: i18n.translate('core.ui.opensearchDashboardsNavList.label', { defaultMessage: 'OpenSearch Dashboards', }), - euiIconType: 'inputOutput', order: 1000, }, enterpriseSearch: { @@ -65,12 +64,20 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze order: 4000, euiIconType: 'logoSecurity', }, + openSearchFeatures: { + id: 'openSearchFeatures', + label: i18n.translate('core.ui.openSearchFeaturesNavList.label', { + defaultMessage: 'OpenSearch Features', + }), + order: 5000, + euiIconType: 'folderClosed', + }, management: { id: 'management', label: i18n.translate('core.ui.managementNavList.label', { defaultMessage: 'Management', }), - order: 5000, + order: 6000, euiIconType: 'managementApp', }, }); diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index af4f9a17ae58..f2884c60581c 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,4 +37,12 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; -export { WORKSPACE_TYPE } from './constants'; +export { + WORKSPACE_PATH_PREFIX, + WorkspacePermissionMode, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, + WORKSPACE_TYPE, + PERSONAL_WORKSPACE_ID_PREFIX, +} from './constants'; +export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; diff --git a/src/core/utils/workspace.test.ts b/src/core/utils/workspace.test.ts new file mode 100644 index 000000000000..7d2a1f700c5f --- /dev/null +++ b/src/core/utils/workspace.test.ts @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId } from './workspace'; +import { httpServiceMock } from '../public/mocks'; + +describe('#getWorkspaceIdFromUrl', () => { + it('return workspace when there is a match', () => { + expect(getWorkspaceIdFromUrl('http://localhost/w/foo')).toEqual('foo'); + }); + + it('return empty when there is not a match', () => { + expect(getWorkspaceIdFromUrl('http://localhost/w2/foo')).toEqual(''); + }); +}); + +describe('#formatUrlWithWorkspaceId', () => { + const basePathWithoutWorkspaceBasePath = httpServiceMock.createSetupContract().basePath; + it('return url with workspace prefix when format with a id provided', () => { + expect( + formatUrlWithWorkspaceId('/app/dashboard', 'foo', basePathWithoutWorkspaceBasePath) + ).toEqual('http://localhost/w/foo/app/dashboard'); + }); + + it('return url without workspace prefix when format without a id', () => { + expect( + formatUrlWithWorkspaceId('/w/foo/app/dashboard', '', basePathWithoutWorkspaceBasePath) + ).toEqual('http://localhost/app/dashboard'); + }); +}); diff --git a/src/core/utils/workspace.ts b/src/core/utils/workspace.ts new file mode 100644 index 000000000000..c369f95d5817 --- /dev/null +++ b/src/core/utils/workspace.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WORKSPACE_PATH_PREFIX } from './constants'; +import { IBasePath } from '../public'; + +export const getWorkspaceIdFromUrl = (url: string): string => { + const regexp = /\/w\/([^\/]*)/; + const urlObject = new URL(url); + const matchedResult = urlObject.pathname.match(regexp); + if (matchedResult) { + return matchedResult[1]; + } + + return ''; +}; + +export const cleanWorkspaceId = (path: string) => { + return path.replace(/^\/w\/([^\/]*)/, ''); +}; + +export const formatUrlWithWorkspaceId = (url: string, workspaceId: string, basePath: IBasePath) => { + const newUrl = new URL(url, window.location.href); + /** + * Patch workspace id into path + */ + newUrl.pathname = basePath.remove(newUrl.pathname); + + if (workspaceId) { + newUrl.pathname = `${WORKSPACE_PATH_PREFIX}/${workspaceId}${newUrl.pathname}`; + } else { + newUrl.pathname = cleanWorkspaceId(newUrl.pathname); + } + + newUrl.pathname = basePath.prepend(newUrl.pathname, { + withoutWorkspace: true, + }); + + return newUrl.toString(); +}; diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap new file mode 100644 index 000000000000..3c5257e2e8d1 --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageWrapper should render normally 1`] = ` +
    +
    + Foo +
    +
    +`; diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts new file mode 100644 index 000000000000..3cf0cdd26c99 --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx new file mode 100644 index 000000000000..550eb3ee1cae --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { PageWrapper } from './page_wrapper'; + +describe('PageWrapper', () => { + it('should render normally', async () => { + const { findByText, container } = render(Foo); + await findByText('Foo'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx new file mode 100644 index 000000000000..1b1949c334e4 --- /dev/null +++ b/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPageContent } from '@elastic/eui'; +import React from 'react'; + +export const PageWrapper = (props: { children?: React.ReactChild }) => { + return ( + + ); +}; diff --git a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx index 7fa0b9ddd2c0..b4297bded154 100644 --- a/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx +++ b/src/plugins/advanced_settings/public/management_app/mount_management_section.tsx @@ -34,18 +34,19 @@ import { Router, Switch, Route } from 'react-router-dom'; import { i18n } from '@osd/i18n'; import { I18nProvider } from '@osd/i18n/react'; -import { StartServicesAccessor } from 'src/core/public'; +import { AppMountParameters, ChromeBreadcrumb, StartServicesAccessor } from 'src/core/public'; import { AdvancedSettings } from './advanced_settings'; -import { ManagementAppMountParams } from '../../../management/public'; import { ComponentRegistry } from '../types'; +import { reactRouterNavigate } from '../../../opensearch_dashboards_react/public'; +import { PageWrapper } from './components/page_wrapper'; import './index.scss'; const title = i18n.translate('advancedSettings.advancedSettingsLabel', { defaultMessage: 'Advanced settings', }); -const crumb = [{ text: title }]; +const crumb: ChromeBreadcrumb[] = [{ text: title }]; const readOnlyBadge = { text: i18n.translate('advancedSettings.badge.readOnly.text', { @@ -57,13 +58,18 @@ const readOnlyBadge = { iconType: 'glasses', }; -export async function mountManagementSection( +export async function mountAdvancedSettingsManagementSection( getStartServices: StartServicesAccessor, - params: ManagementAppMountParams, + params: AppMountParameters, componentRegistry: ComponentRegistry['start'] ) { - params.setBreadcrumbs(crumb); const [{ uiSettings, notifications, docLinks, application, chrome }] = await getStartServices(); + chrome.setBreadcrumbs([ + ...crumb.map((item) => ({ + ...item, + ...(item.href ? reactRouterNavigate(params.history, item.href) : {}), + })), + ]); const canSave = application.capabilities.advancedSettings.save as boolean; @@ -72,21 +78,23 @@ export async function mountManagementSection( } ReactDOM.render( - - - - - - - - - , + + + + + + + + + + + , params.element ); return () => { diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 608bfc6a25e7..91fe18612749 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -29,10 +29,11 @@ */ import { i18n } from '@osd/i18n'; -import { CoreSetup, Plugin } from 'opensearch-dashboards/public'; +import { AppMountParameters, CoreSetup, Plugin } from 'opensearch-dashboards/public'; import { FeatureCatalogueCategory } from '../../home/public'; import { ComponentRegistry } from './component_registry'; import { AdvancedSettingsSetup, AdvancedSettingsStart, AdvancedSettingsPluginSetup } from './types'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; const component = new ComponentRegistry(); @@ -42,18 +43,21 @@ const title = i18n.translate('advancedSettings.advancedSettingsLabel', { export class AdvancedSettingsPlugin implements Plugin { - public setup(core: CoreSetup, { management, home }: AdvancedSettingsPluginSetup) { - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - - opensearchDashboardsSection.registerApp({ + public setup(core: CoreSetup, { home }: AdvancedSettingsPluginSetup) { + core.application.register({ id: 'settings', title, - order: 3, - async mount(params) { - const { mountManagementSection } = await import( + order: 99, + category: DEFAULT_APP_CATEGORIES.management, + async mount(params: AppMountParameters) { + const { mountAdvancedSettingsManagementSection } = await import( './management_app/mount_management_section' ); - return mountManagementSection(core.getStartServices, params, component.start); + return mountAdvancedSettingsManagementSection( + core.getStartServices, + params, + component.start + ); }, }); @@ -66,7 +70,7 @@ export class AdvancedSettingsPlugin 'Customize your OpenSearch Dashboards experience — change the date format, turn on dark mode, and more.', }), icon: 'gear', - path: '/app/management/opensearch-dashboards/settings', + path: '/app/settings', showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); diff --git a/src/plugins/dashboard/opensearch_dashboards.json b/src/plugins/dashboard/opensearch_dashboards.json index 348a0c9fe9dc..30f8bddc3389 100644 --- a/src/plugins/dashboard/opensearch_dashboards.json +++ b/src/plugins/dashboard/opensearch_dashboards.json @@ -14,5 +14,5 @@ "optionalPlugins": ["home", "share", "usageCollection"], "server": true, "ui": true, - "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "home"] + "requiredBundles": ["opensearchDashboardsUtils", "opensearchDashboardsReact", "home", "savedObjectsManagement"] } diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index c9ffe147e5f8..a00b1e1429f4 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -223,9 +223,11 @@ exports[`dashboard listing hideWriteControls 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -857,9 +859,11 @@ exports[`dashboard listing hideWriteControls 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -932,6 +936,7 @@ exports[`dashboard listing hideWriteControls 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -1355,9 +1360,11 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1989,9 +1996,11 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2064,6 +2073,7 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -2548,9 +2558,11 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3182,9 +3194,11 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3257,6 +3271,7 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -3741,9 +3756,11 @@ exports[`dashboard listing renders table rows 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4375,9 +4392,11 @@ exports[`dashboard listing renders table rows 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4450,6 +4469,7 @@ exports[`dashboard listing renders table rows 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -4934,9 +4954,11 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -5568,9 +5590,11 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5643,6 +5667,7 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 1954051c9474..1fcd1c30b40b 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -211,9 +211,11 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -749,9 +751,11 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -810,6 +814,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -1168,9 +1173,11 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1706,9 +1713,11 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1767,6 +1776,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -2125,9 +2135,11 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2663,9 +2675,11 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2724,6 +2738,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -3082,9 +3097,11 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3620,9 +3637,11 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3681,6 +3700,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -4039,9 +4059,11 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4577,9 +4599,11 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4638,6 +4662,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -4996,9 +5021,11 @@ exports[`Dashboard top nav render with all components 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -5534,9 +5561,11 @@ exports[`Dashboard top nav render with all components 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5595,6 +5624,7 @@ exports[`Dashboard top nav render with all components 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx b/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx index 1cc58c78ebc1..a1eee11c465d 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/dashboard_top_nav.tsx @@ -89,7 +89,8 @@ const TopNav = ({ getTopNavConfig( currentAppState?.viewMode, navActions, - dashboardConfig.getHideWriteControls() + dashboardConfig.getHideWriteControls(), + services.application.capabilities.workspaces.enabled ) ); } diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/get_top_nav_config.ts b/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/get_top_nav_config.ts index f91f4d47a854..0b39b673f7e5 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/get_top_nav_config.ts +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/get_top_nav_config.ts @@ -42,7 +42,8 @@ import { NavAction } from '../../../../types'; export function getTopNavConfig( dashboardMode: ViewMode, actions: { [key: string]: NavAction }, - hideWriteControls: boolean + hideWriteControls: boolean, + workspaceEnabled?: boolean ) { switch (dashboardMode) { case ViewMode.VIEW: @@ -54,7 +55,9 @@ export function getTopNavConfig( : [ getFullScreenConfig(actions[TopNavIds.FULL_SCREEN]), getShareConfig(actions[TopNavIds.SHARE]), - getCloneConfig(actions[TopNavIds.CLONE]), + ...(workspaceEnabled + ? [getDuplicateConfig(actions[TopNavIds.DUPLICATE])] + : [getCloneConfig(actions[TopNavIds.CLONE])]), getEditConfig(actions[TopNavIds.ENTER_EDIT_MODE]), ]; case ViewMode.EDIT: @@ -158,6 +161,23 @@ function getCloneConfig(action: NavAction) { }; } +/** + * @returns {osdTopNavConfig} + */ +function getDuplicateConfig(action: NavAction) { + return { + id: 'duplicate', + label: i18n.translate('dashboard.topNave.duplicateButtonAriaLabel', { + defaultMessage: 'Duplicate', + }), + description: i18n.translate('dashboard.topNave.duplicateConfigDescription', { + defaultMessage: 'Duplicate your dashboard', + }), + testId: 'dashboardDuplicate', + run: action, + }; +} + /** * @returns {osdTopNavConfig} */ diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/top_nav_ids.ts b/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/top_nav_ids.ts index 0917f7632872..240fbd9ea8b1 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/top_nav_ids.ts +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/top_nav/top_nav_ids.ts @@ -35,6 +35,7 @@ export const TopNavIds = { EXIT_EDIT_MODE: 'exitEditMode', ENTER_EDIT_MODE: 'enterEditMode', CLONE: 'clone', + DUPLICATE: 'duplicate', FULL_SCREEN: 'fullScreenMode', VISUALIZE: 'visualize', ADD_EXISTING: 'addExisting', diff --git a/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap b/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap index 04120e429393..187c24ba1528 100644 --- a/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap +++ b/src/plugins/dashboard/public/application/embeddable/empty/__snapshots__/dashboard_empty_screen.test.tsx.snap @@ -12,9 +12,11 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -379,9 +381,11 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -756,9 +760,11 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx b/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx index 3f823f52676d..89de8591f96c 100644 --- a/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx +++ b/src/plugins/dashboard/public/application/utils/get_nav_actions.tsx @@ -8,9 +8,9 @@ import { i18n } from '@osd/i18n'; import { EUI_MODAL_CANCEL_BUTTON, EuiCheckboxGroup } from '@elastic/eui'; import { EuiCheckboxGroupIdToSelectedMap } from '@elastic/eui/src/components/form/checkbox/checkbox_group'; import { - SaveResult, - SavedObjectSaveOpts, getSavedObjectFinder, + SavedObjectSaveOpts, + SaveResult, showSaveModal, } from '../../../../saved_objects/public'; import { DashboardAppStateContainer, DashboardServices, NavAction } from '../../types'; @@ -24,15 +24,21 @@ import { import { EmbeddableFactoryNotFoundError, EmbeddableInput, - ViewMode, isErrorEmbeddable, openAddPanelFlyout, + ViewMode, } from '../../../../embeddable/public'; import { saveDashboard } from '../utils'; import { DashboardContainer } from '../embeddable/dashboard_container'; -import { DashboardConstants, createDashboardEditUrl } from '../../dashboard_constants'; +import { createDashboardEditUrl, DashboardConstants } from '../../dashboard_constants'; import { unhashUrl } from '../../../../opensearch_dashboards_utils/public'; import { Dashboard } from '../../dashboard'; +import { SavedObjectWithMetadata } from '../../../../saved_objects_management/common'; +import { + DuplicateMode, + showDuplicateModal, + duplicateSavedObjects, +} from '../../../../saved_objects_management/public'; interface UrlParamsSelectedMap { [UrlParams.SHOW_TOP_MENU]: boolean; @@ -54,6 +60,7 @@ export const getNavActions = ( currentContainer?: DashboardContainer ) => { const { + application, embeddable, data: { query: queryService }, notifications, @@ -65,10 +72,13 @@ export const getNavActions = ( share, dashboardConfig, dashboardCapabilities, + http, + workspaces, } = services; const navActions: { [key: string]: NavAction; } = {}; + const workspaceEnabled = application.capabilities.workspaces.enabled; if (!stateContainer) { return navActions; @@ -133,7 +143,7 @@ export const getNavActions = ( title={currentTitle} description={currentDescription} timeRestore={currentTimeRestore} - showCopyOnSave={savedDashboard.id ? true : false} + showCopyOnSave={!!savedDashboard.id} /> ); showSaveModal(dashboardSaveModal, I18nContext); @@ -166,6 +176,59 @@ export const getNavActions = ( showCloneModal(onClone, currentTitle); }; + if (workspaceEnabled) { + navActions[TopNavIds.DUPLICATE] = () => { + const onDuplicate = async ( + dashboardSavedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => { + const objectsToDuplicate = dashboardSavedObjects.map((obj) => ({ + id: obj.id, + type: obj.type, + })); + + try { + await duplicateSavedObjects( + http, + objectsToDuplicate, + includeReferencesDeep, + targetWorkspace + ); + + notifications.toasts.addSuccess({ + title: i18n.translate('dashboard.dashboardWasDuplicatedSuccessMessage', { + defaultMessage: 'Duplicate dashboard successfully', + }), + }); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('dashboard.dashboardWasNotDuplicatedDangerMessage', { + defaultMessage: 'Unable to duplicate dashboard', + }), + }); + } + }; + + const dashboardSavedObject = ({ + ...currentContainer, + ...savedDashboard, + } as unknown) as SavedObjectWithMetadata; + dashboardSavedObject.meta = { title: savedDashboard.title }; + + const showDuplicateModalProps = { + http, + workspaces, + onDuplicate, + notifications, + duplicateMode: DuplicateMode.Selected, + selectedSavedObjects: [dashboardSavedObject], + }; + + showDuplicateModal(showDuplicateModalProps, I18nContext); + }; + } + navActions[TopNavIds.ADD_EXISTING] = () => { if (currentContainer && !isErrorEmbeddable(currentContainer)) { openAddPanelFlyout({ @@ -203,7 +266,7 @@ export const getNavActions = ( }; if (share) { - // the share button is only availabale if "share" plugin contract enabled + // the share button is only available if "share" plugin contract enabled navActions[TopNavIds.SHARE] = (anchorElement) => { const EmbedUrlParamExtension = ({ setParamValue, diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index ee2c162733bc..6d6a08954fbe 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -43,9 +43,7 @@ export const dashboardSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/objects/savedDashboards/${encodeURIComponent( - obj.id - )}`; + return `/objects/savedDashboards/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { diff --git a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts index 688605821097..489ad154afa0 100644 --- a/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts +++ b/src/plugins/data/common/index_patterns/index_patterns/index_patterns.ts @@ -418,11 +418,7 @@ export class IndexPatternsService { ); if (!savedObject.version) { - throw new SavedObjectNotFound( - savedObjectType, - id, - 'management/opensearch-dashboards/indexPatterns' - ); + throw new SavedObjectNotFound(savedObjectType, id, 'indexPatterns'); } const spec = this.savedObjectToSpec(savedObject); diff --git a/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx b/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx index b09bc8adde6f..1a43ab22aaae 100644 --- a/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx +++ b/src/plugins/data/public/index_patterns/index_patterns/redirect_no_index_pattern.tsx @@ -42,9 +42,7 @@ export const onRedirectNoIndexPattern = ( overlays: CoreStart['overlays'] ) => () => { const canManageIndexPatterns = capabilities.management.opensearchDashboards.indexPatterns; - const redirectTarget = canManageIndexPatterns - ? '/management/opensearch-dashboards/indexPatterns' - : '/home'; + const redirectTarget = canManageIndexPatterns ? '/indexPatterns' : '/home'; let timeoutId: NodeJS.Timeout | undefined; if (timeoutId) { @@ -72,8 +70,8 @@ export const onRedirectNoIndexPattern = ( if (redirectTarget === '/home') { navigateToApp('home'); } else { - navigateToApp('management', { - path: `/opensearch-dashboards/indexPatterns?bannerMessage=${bannerMessage}`, + navigateToApp('indexPatterns', { + path: `?bannerMessage=${bannerMessage}`, }); } diff --git a/src/plugins/data/public/search/errors/painless_error.tsx b/src/plugins/data/public/search/errors/painless_error.tsx index 1522dcf97cb0..ee11d77b98f5 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -53,9 +53,7 @@ export class PainlessError extends OsdError { public getErrorMessage(application: ApplicationStart) { function onClick() { - application.navigateToApp('management', { - path: `/opensearch-dashboards/indexPatterns`, - }); + application.navigateToApp('indexPatterns'); } return ( diff --git a/src/plugins/data/server/saved_objects/index_patterns.ts b/src/plugins/data/server/saved_objects/index_patterns.ts index 5f0864bac926..391adf6a973f 100644 --- a/src/plugins/data/server/saved_objects/index_patterns.ts +++ b/src/plugins/data/server/saved_objects/index_patterns.ts @@ -43,15 +43,11 @@ export const indexPatternSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/indexPatterns/patterns/${encodeURIComponent( - obj.id - )}`; + return `/indexPatterns/patterns/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { - path: `/app/management/opensearch-dashboards/indexPatterns/patterns/${encodeURIComponent( - obj.id - )}`, + path: `/app/indexPatterns/patterns/${encodeURIComponent(obj.id)}`, uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }; }, diff --git a/src/plugins/data_source/server/saved_objects/data_source.ts b/src/plugins/data_source/server/saved_objects/data_source.ts index 9404a4bcf371..58cace8ada2d 100644 --- a/src/plugins/data_source/server/saved_objects/data_source.ts +++ b/src/plugins/data_source/server/saved_objects/data_source.ts @@ -17,11 +17,11 @@ export const dataSource: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`; + return `/dataSources/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { - path: `/app/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`, + path: `/app/dataSources/${encodeURIComponent(obj.id)}`, uiCapabilitiesPath: 'management.opensearchDashboards.dataSources', }; }, diff --git a/src/plugins/data_source_management/opensearch_dashboards.json b/src/plugins/data_source_management/opensearch_dashboards.json index cfcfdd2ce430..565ccff401dd 100644 --- a/src/plugins/data_source_management/opensearch_dashboards.json +++ b/src/plugins/data_source_management/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": false, "ui": true, - "requiredPlugins": ["management", "dataSource", "indexPatternManagement"], + "requiredPlugins": ["dataSource", "indexPatternManagement"], "optionalPlugins": [], "requiredBundles": ["opensearchDashboardsReact", "dataSource"], "extraPublicDirs": ["public/components/utils"] diff --git a/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx b/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx index 640eb1b369fd..cd6fc7c17ae2 100644 --- a/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx +++ b/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx @@ -56,11 +56,7 @@ export class DataSourceColumn implements IndexPatternTableColumn ?.map((dataSource) => { return { ...dataSource, - relativeUrl: basePath.prepend( - `/app/management/opensearch-dashboards/dataSources/${encodeURIComponent( - dataSource.id - )}` - ), + relativeUrl: basePath.prepend(`/app/dataSources/${encodeURIComponent(dataSource.id)}`), }; }) ?.reduce( diff --git a/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap b/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap new file mode 100644 index 000000000000..3c5257e2e8d1 --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageWrapper should render normally 1`] = ` +
    +
    + Foo +
    +
    +`; diff --git a/src/plugins/data_source_management/public/components/page_wrapper/index.ts b/src/plugins/data_source_management/public/components/page_wrapper/index.ts new file mode 100644 index 000000000000..3cf0cdd26c99 --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx new file mode 100644 index 000000000000..550eb3ee1cae --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { PageWrapper } from './page_wrapper'; + +describe('PageWrapper', () => { + it('should render normally', async () => { + const { findByText, container } = render(Foo); + await findByText('Foo'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx new file mode 100644 index 000000000000..1b1949c334e4 --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPageContent } from '@elastic/eui'; +import React from 'react'; + +export const PageWrapper = (props: { children?: React.ReactChild }) => { + return ( + + ); +}; diff --git a/src/plugins/data_source_management/public/management_app/index.ts b/src/plugins/data_source_management/public/management_app/index.ts index 5ccbfb947646..960adc7ba5a6 100644 --- a/src/plugins/data_source_management/public/management_app/index.ts +++ b/src/plugins/data_source_management/public/management_app/index.ts @@ -3,4 +3,4 @@ * SPDX-License-Identifier: Apache-2.0 */ -export { mountManagementSection } from './mount_management_section'; +export { mountDataSourcesManagementSection } from './mount_management_section'; diff --git a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx index 9fe1f2406382..f61113042458 100644 --- a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx @@ -3,33 +3,42 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { StartServicesAccessor } from 'src/core/public'; +import { + AppMountParameters, + ChromeBreadcrumb, + ScopedHistory, + StartServicesAccessor, +} from 'src/core/public'; import { I18nProvider } from '@osd/i18n/react'; import React from 'react'; import ReactDOM from 'react-dom'; import { Route, Router, Switch } from 'react-router-dom'; -import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { ManagementAppMountParams } from '../../../management/public'; - import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; import { CreateDataSourceWizardWithRouter } from '../components/create_data_source_wizard'; import { DataSourceTableWithRouter } from '../components/data_source_table'; -import { DataSourceManagementContext } from '../types'; +import { DataSourceManagementContext, DataSourceManagementStartDependencies } from '../types'; import { EditDataSourceWithRouter } from '../components/edit_data_source'; +import { PageWrapper } from '../components/page_wrapper'; +import { reactRouterNavigate } from '../../../opensearch_dashboards_react/public'; -export interface DataSourceManagementStartDependencies { - data: DataPublicPluginStart; -} - -export async function mountManagementSection( +export async function mountDataSourcesManagementSection( getStartServices: StartServicesAccessor, - params: ManagementAppMountParams + params: AppMountParameters ) { const [ { chrome, application, savedObjects, uiSettings, notifications, overlays, http, docLinks }, ] = await getStartServices(); + const setBreadcrumbsScoped = (crumbs: ChromeBreadcrumb[] = []) => { + const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ + ...item, + ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), + }); + + chrome.setBreadcrumbs([...crumbs.map((item) => wrapBreadcrumb(item, params.history))]); + }; + const deps: DataSourceManagementContext = { chrome, application, @@ -39,27 +48,29 @@ export async function mountManagementSection( overlays, http, docLinks, - setBreadcrumbs: params.setBreadcrumbs, + setBreadcrumbs: setBreadcrumbsScoped, }; ReactDOM.render( - - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + + , params.element ); diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 0c7123e47a94..2bf9d112ee7e 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -3,13 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { CoreSetup, CoreStart, Plugin } from '../../../core/public'; +import { + AppMountParameters, + CoreSetup, + CoreStart, + DEFAULT_APP_CATEGORIES, + Plugin, + StartServicesAccessor, +} from '../../../core/public'; import { PLUGIN_NAME } from '../common'; import { ManagementSetup } from '../../management/public'; import { IndexPatternManagementSetup } from '../../index_pattern_management/public'; import { DataSourceColumn } from './components/data_source_column/data_source_column'; +import { DataSourceManagementStartDependencies } from './types'; import { AuthenticationMethod, IAuthenticationMethodRegistery, @@ -43,14 +51,8 @@ export class DataSourceManagementPlugin public setup( core: CoreSetup, - { management, indexPatternManagement }: DataSourceManagementSetupDependencies + { indexPatternManagement }: DataSourceManagementSetupDependencies ) { - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - - if (!opensearchDashboardsSection) { - throw new Error('`opensearchDashboards` management section not found.'); - } - const savedObjectPromise = core .getStartServices() .then(([coreStart]) => coreStart.savedObjects); @@ -58,14 +60,18 @@ export class DataSourceManagementPlugin const column = new DataSourceColumn(savedObjectPromise, httpPromise); indexPatternManagement.columns.register(column); - opensearchDashboardsSection.registerApp({ + core.application.register({ id: DSM_APP_ID, title: PLUGIN_NAME, order: 1, - mount: async (params) => { - const { mountManagementSection } = await import('./management_app'); + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: async (params: AppMountParameters) => { + const { mountDataSourcesManagementSection } = await import('./management_app'); - return mountManagementSection(core.getStartServices, params); + return mountDataSourcesManagementSection( + core.getStartServices as StartServicesAccessor, + params + ); }, }); diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index d461daba82cc..65e1b604d02c 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -14,6 +14,7 @@ import { HttpSetup, } from 'src/core/public'; import { ManagementAppMountParams } from 'src/plugins/management/public'; +import { DataPublicPluginStart } from 'src/plugins/data/public'; import { SavedObjectAttributes } from 'src/core/types'; import { i18n } from '@osd/i18n'; import { SigV4ServiceName } from '../../data_source/common/data_sources'; @@ -115,3 +116,7 @@ export interface SigV4Content extends SavedObjectAttributes { region: string; service?: SigV4ServiceName; } + +export interface DataSourceManagementStartDependencies { + data: DataPublicPluginStart; +} diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index bb0b6ee1d981..e22f12b9234a 100644 --- a/src/plugins/dev_tools/public/plugin.ts +++ b/src/plugins/dev_tools/public/plugin.ts @@ -85,7 +85,7 @@ export class DevToolsPlugin implements Plugin { icon: '/ui/logos/opensearch_mark.svg', /* the order of dev tools, it shows as last item of management section */ order: 9070, - category: DEFAULT_APP_CATEGORIES.management, + category: DEFAULT_APP_CATEGORIES.openSearchFeatures, mount: async (params: AppMountParameters) => { const { element, history } = params; element.classList.add('devAppWrapper'); diff --git a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap index 1fa9680fa708..45e15f809f63 100644 --- a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap +++ b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap @@ -53,7 +53,7 @@ exports[`render 1`] = ` > + + +

    + +

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

    + +

    +
    +
    + + + } + savedObjectMetaData={[ + { + type: SEARCH_OBJECT_TYPE, + getIconForSavedObject: () => 'search', + name: i18n.translate('discover.savedSearch.savedObjectName', { + defaultMessage: 'Saved search', + }), + }, + ]} + onChoose={(id) => { + window.location.assign(props.makeUrl(id)); + props.onClose(); + }} + uiSettings={uiSettings} + savedObjects={savedObjects} + /> + + + + + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + + + + + +
    + ); +} + +OpenSearchPanel.propTypes = { + onClose: PropTypes.func.isRequired, + makeUrl: PropTypes.func.isRequired, +}; diff --git a/src/plugins/home/public/application/components/new_theme_modal.tsx b/src/plugins/home/public/application/components/new_theme_modal.tsx index e1520e3b67b1..7c275982decc 100644 --- a/src/plugins/home/public/application/components/new_theme_modal.tsx +++ b/src/plugins/home/public/application/components/new_theme_modal.tsx @@ -56,9 +56,7 @@ export const NewThemeModal: FC = ({ addBasePath, onClose }) => { modes. You or your administrator can change to the previous theme by visiting {advancedSettingsLink}." values={{ advancedSettingsLink: ( - + { let sampleDataSets; try { - sampleDataSets = await listSampleDataSets(dataSourceId); + sampleDataSets = await listSampleDataSets( + dataSourceId, + getServices().workspaces.currentWorkspaceId$.getValue() + ); } catch (fetchError) { this.toastNotifications.addDanger({ title: i18n.translate('home.sampleDataSet.unableToLoadListErrorMessage', { @@ -114,7 +117,12 @@ export class SampleDataSetCards extends React.Component { })); try { - await installSampleDataSet(id, targetSampleDataSet.defaultIndex, dataSourceId); + await installSampleDataSet( + id, + targetSampleDataSet.defaultIndex, + dataSourceId, + getServices().workspaces.currentWorkspaceId$.getValue() + ); } catch (fetchError) { if (this._isMounted) { this.setState((prevState) => ({ @@ -162,7 +170,12 @@ export class SampleDataSetCards extends React.Component { })); try { - await uninstallSampleDataSet(id, targetSampleDataSet.defaultIndex, dataSourceId); + await uninstallSampleDataSet( + id, + targetSampleDataSet.defaultIndex, + dataSourceId, + getServices().workspaces.currentWorkspaceId$.getValue() + ); } catch (fetchError) { if (this._isMounted) { this.setState((prevState) => ({ diff --git a/src/plugins/home/public/application/opensearch_dashboards_services.ts b/src/plugins/home/public/application/opensearch_dashboards_services.ts index 60f9e70621ff..48ce9925a327 100644 --- a/src/plugins/home/public/application/opensearch_dashboards_services.ts +++ b/src/plugins/home/public/application/opensearch_dashboards_services.ts @@ -37,6 +37,7 @@ import { SavedObjectsClientContract, IUiSettingsClient, ApplicationStart, + WorkspacesStart, } from 'opensearch-dashboards/public'; import { UiStatsMetricType } from '@osd/analytics'; import { TelemetryPluginStart } from '../../../telemetry/public'; @@ -73,6 +74,7 @@ export interface HomeOpenSearchDashboardsServices { getBranding: () => HomePluginBranding; }; dataSource?: DataSourcePluginStart; + workspaces: WorkspacesStart; } let services: HomeOpenSearchDashboardsServices | null = null; diff --git a/src/plugins/home/public/application/sample_data_client.js b/src/plugins/home/public/application/sample_data_client.js index 045736c428f6..7334c14a7033 100644 --- a/src/plugins/home/public/application/sample_data_client.js +++ b/src/plugins/home/public/application/sample_data_client.js @@ -36,13 +36,13 @@ function clearIndexPatternsCache() { getServices().indexPatternService.clearCache(); } -export async function listSampleDataSets(dataSourceId) { - const query = buildQuery(dataSourceId); +export async function listSampleDataSets(dataSourceId, workspaceId) { + const query = buildQuery(dataSourceId, workspaceId); return await getServices().http.get(sampleDataUrl, { query }); } -export async function installSampleDataSet(id, sampleDataDefaultIndex, dataSourceId) { - const query = buildQuery(dataSourceId); +export async function installSampleDataSet(id, sampleDataDefaultIndex, dataSourceId, workspaceId) { + const query = buildQuery(dataSourceId, workspaceId); await getServices().http.post(`${sampleDataUrl}/${id}`, { query }); if (getServices().uiSettings.isDefault('defaultIndex')) { @@ -52,8 +52,13 @@ export async function installSampleDataSet(id, sampleDataDefaultIndex, dataSourc clearIndexPatternsCache(); } -export async function uninstallSampleDataSet(id, sampleDataDefaultIndex, dataSourceId) { - const query = buildQuery(dataSourceId); +export async function uninstallSampleDataSet( + id, + sampleDataDefaultIndex, + dataSourceId, + workspaceId +) { + const query = buildQuery(dataSourceId, workspaceId); await getServices().http.delete(`${sampleDataUrl}/${id}`, { query }); const uiSettings = getServices().uiSettings; @@ -68,12 +73,16 @@ export async function uninstallSampleDataSet(id, sampleDataDefaultIndex, dataSou clearIndexPatternsCache(); } -function buildQuery(dataSourceId) { +function buildQuery(dataSourceId, workspaceId) { const query = {}; if (dataSourceId) { query.data_source_id = dataSourceId; } + if (workspaceId) { + query.workspace_id = workspaceId; + } + return query; } diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index 1538156a801e..bf815a30c74d 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -122,6 +122,7 @@ export class HomePublicPlugin featureCatalogue: this.featuresCatalogueRegistry, injectedMetadata: coreStart.injectedMetadata, dataSource, + workspaces: coreStart.workspaces, }); coreStart.chrome.docTitle.change( i18n.translate('home.pageTitle', { defaultMessage: 'Home' }) diff --git a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts index 75e9ea50ff87..1a4ebd2a5e72 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/ecommerce/index.ts @@ -33,7 +33,7 @@ import { i18n } from '@osd/i18n'; import { getSavedObjects } from './saved_objects'; import { fieldMappings } from './field_mappings'; import { SampleDatasetSchema, AppLinkSchema } from '../../lib/sample_dataset_registry_types'; -import { getSavedObjectsWithDataSource, appendDataSourceId } from '../util'; +import { addPrefixTo } from '../util'; const ecommerceName = i18n.translate('home.sampleData.ecommerceSpecTitle', { defaultMessage: 'Sample eCommerce orders', @@ -55,13 +55,11 @@ export const ecommerceSpecProvider = function (): SampleDatasetSchema { darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/ecommerce/dashboard_dark.png', hasNewThemeImages: true, overviewDashboard: DASHBOARD_ID, - getDataSourceIntegratedDashboard: appendDataSourceId(DASHBOARD_ID), + getDashboardWithPrefix: addPrefixTo(DASHBOARD_ID), appLinks: initialAppLinks, defaultIndex: DEFAULT_INDEX, - getDataSourceIntegratedDefaultIndex: appendDataSourceId(DEFAULT_INDEX), + getDataSourceIntegratedDefaultIndex: addPrefixTo(DEFAULT_INDEX), savedObjects: getSavedObjects(), - getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => - getSavedObjectsWithDataSource(getSavedObjects(), dataSourceId, dataSourceTitle), dataIndices: [ { id: 'ecommerce', diff --git a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts index 415d98027c4f..2e42b78e5305 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/flights/index.ts @@ -33,7 +33,7 @@ import { i18n } from '@osd/i18n'; import { getSavedObjects } from './saved_objects'; import { fieldMappings } from './field_mappings'; import { SampleDatasetSchema, AppLinkSchema } from '../../lib/sample_dataset_registry_types'; -import { getSavedObjectsWithDataSource, appendDataSourceId } from '../util'; +import { addPrefixTo } from '../util'; const flightsName = i18n.translate('home.sampleData.flightsSpecTitle', { defaultMessage: 'Sample flight data', @@ -55,13 +55,11 @@ export const flightsSpecProvider = function (): SampleDatasetSchema { darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/flights/dashboard_dark.png', hasNewThemeImages: true, overviewDashboard: DASHBOARD_ID, - getDataSourceIntegratedDashboard: appendDataSourceId(DASHBOARD_ID), + getDashboardWithPrefix: addPrefixTo(DASHBOARD_ID), appLinks: initialAppLinks, defaultIndex: DEFAULT_INDEX, - getDataSourceIntegratedDefaultIndex: appendDataSourceId(DEFAULT_INDEX), + getDataSourceIntegratedDefaultIndex: addPrefixTo(DEFAULT_INDEX), savedObjects: getSavedObjects(), - getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => - getSavedObjectsWithDataSource(getSavedObjects(), dataSourceId, dataSourceTitle), dataIndices: [ { id: 'flights', diff --git a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts index 0e8eaf99d411..5c3cc9bf6861 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/logs/index.ts @@ -33,7 +33,7 @@ import { i18n } from '@osd/i18n'; import { getSavedObjects } from './saved_objects'; import { fieldMappings } from './field_mappings'; import { SampleDatasetSchema, AppLinkSchema } from '../../lib/sample_dataset_registry_types'; -import { appendDataSourceId, getSavedObjectsWithDataSource } from '../util'; +import { addPrefixTo } from '../util'; const logsName = i18n.translate('home.sampleData.logsSpecTitle', { defaultMessage: 'Sample web logs', @@ -55,13 +55,11 @@ export const logsSpecProvider = function (): SampleDatasetSchema { darkPreviewImagePath: '/plugins/home/assets/sample_data_resources/logs/dashboard_dark.png', hasNewThemeImages: true, overviewDashboard: DASHBOARD_ID, - getDataSourceIntegratedDashboard: appendDataSourceId(DASHBOARD_ID), + getDashboardWithPrefix: addPrefixTo(DASHBOARD_ID), appLinks: initialAppLinks, defaultIndex: DEFAULT_INDEX, - getDataSourceIntegratedDefaultIndex: appendDataSourceId(DEFAULT_INDEX), + getDataSourceIntegratedDefaultIndex: addPrefixTo(DEFAULT_INDEX), savedObjects: getSavedObjects(), - getDataSourceIntegratedSavedObjects: (dataSourceId?: string, dataSourceTitle?: string) => - getSavedObjectsWithDataSource(getSavedObjects(), dataSourceId, dataSourceTitle), dataIndices: [ { id: 'logs', diff --git a/src/plugins/home/server/services/sample_data/data_sets/util.ts b/src/plugins/home/server/services/sample_data/data_sets/util.ts index 46022f1c22d3..26736d503ce6 100644 --- a/src/plugins/home/server/services/sample_data/data_sets/util.ts +++ b/src/plugins/home/server/services/sample_data/data_sets/util.ts @@ -4,60 +4,74 @@ */ import { SavedObject } from 'opensearch-dashboards/server'; +import { cloneDeep } from 'lodash'; -export const appendDataSourceId = (id: string) => { - return (dataSourceId?: string) => (dataSourceId ? `${dataSourceId}_` + id : id); +const withPrefix = (...args: Array) => (id: string) => { + const prefix = args.filter(Boolean).join('_'); + if (prefix) { + return `${prefix}_${id}`; + } + return id; }; -export const getSavedObjectsWithDataSource = ( - saveObjectList: SavedObject[], - dataSourceId?: string, - dataSourceTitle?: string -): SavedObject[] => { - if (dataSourceId) { - return saveObjectList.map((saveObject) => { - saveObject.id = `${dataSourceId}_` + saveObject.id; - // update reference - if (saveObject.type === 'dashboard') { - saveObject.references.map((reference) => { - if (reference.id) { - reference.id = `${dataSourceId}_` + reference.id; - } - }); +export const addPrefixTo = (id: string) => (...args: Array) => { + return withPrefix(...args)(id); +}; + +const overrideSavedObjectId = (savedObject: SavedObject, idGenerator: (id: string) => string) => { + savedObject.id = idGenerator(savedObject.id); + // update reference + if (savedObject.type === 'dashboard') { + savedObject.references.map((reference) => { + if (reference.id) { + reference.id = idGenerator(reference.id); } + }); + } - // update reference - if (saveObject.type === 'visualization' || saveObject.type === 'search') { - const searchSourceString = saveObject.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; - const visStateString = saveObject.attributes?.visState; + // update reference + if (savedObject.type === 'visualization' || savedObject.type === 'search') { + const searchSourceString = savedObject.attributes?.kibanaSavedObjectMeta?.searchSourceJSON; + const visStateString = savedObject.attributes?.visState; - if (searchSourceString) { - const searchSource = JSON.parse(searchSourceString); - if (searchSource.index) { - searchSource.index = `${dataSourceId}_` + searchSource.index; - saveObject.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( - searchSource - ); - } - } + if (searchSourceString) { + const searchSource = JSON.parse(searchSourceString); + if (searchSource.index) { + searchSource.index = idGenerator(searchSource.index); + savedObject.attributes.kibanaSavedObjectMeta.searchSourceJSON = JSON.stringify( + searchSource + ); + } + } - if (visStateString) { - const visState = JSON.parse(visStateString); - const controlList = visState.params?.controls; - if (controlList) { - controlList.map((control) => { - if (control.indexPattern) { - control.indexPattern = `${dataSourceId}_` + control.indexPattern; - } - }); + if (visStateString) { + const visState = JSON.parse(visStateString); + const controlList = visState.params?.controls; + if (controlList) { + controlList.map((control) => { + if (control.indexPattern) { + control.indexPattern = idGenerator(control.indexPattern); } - saveObject.attributes.visState = JSON.stringify(visState); - } + }); } + savedObject.attributes.visState = JSON.stringify(visState); + } + } +}; + +export const getDataSourceIntegratedSavedObjects = ( + savedObjectList: SavedObject[], + dataSourceId?: string, + dataSourceTitle?: string +): SavedObject[] => { + savedObjectList = cloneDeep(savedObjectList); + if (dataSourceId) { + return savedObjectList.map((savedObject) => { + overrideSavedObjectId(savedObject, withPrefix(dataSourceId)); // update reference - if (saveObject.type === 'index-pattern') { - saveObject.references = [ + if (savedObject.type === 'index-pattern') { + savedObject.references = [ { id: `${dataSourceId}`, type: 'data-source', @@ -68,17 +82,29 @@ export const getSavedObjectsWithDataSource = ( if (dataSourceTitle) { if ( - saveObject.type === 'dashboard' || - saveObject.type === 'visualization' || - saveObject.type === 'search' + savedObject.type === 'dashboard' || + savedObject.type === 'visualization' || + savedObject.type === 'search' ) { - saveObject.attributes.title = saveObject.attributes.title + `_${dataSourceTitle}`; + savedObject.attributes.title = savedObject.attributes.title + `_${dataSourceTitle}`; } } - return saveObject; + return savedObject; }); } - return saveObjectList; + return savedObjectList; +}; + +export const getWorkspaceIntegratedSavedObjects = ( + savedObjectList: SavedObject[], + workspaceId?: string +) => { + const savedObjectListCopy = cloneDeep(savedObjectList); + + savedObjectListCopy.forEach((savedObject) => { + overrideSavedObjectId(savedObject, withPrefix(workspaceId)); + }); + return savedObjectListCopy; }; diff --git a/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts b/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts index 5f6d036d6b39..33b997c4303a 100644 --- a/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts +++ b/src/plugins/home/server/services/sample_data/lib/sample_dataset_registry_types.ts @@ -89,7 +89,7 @@ export interface SampleDatasetSchema { // saved object id of main dashboard for sample data set overviewDashboard: string; - getDataSourceIntegratedDashboard: (dataSourceId?: string) => string; + getDashboardWithPrefix: (...args: Array) => string; appLinks: AppLinkSchema[]; // saved object id of default index-pattern for sample data set @@ -99,10 +99,6 @@ export interface SampleDatasetSchema { // OpenSearch Dashboards saved objects (index patter, visualizations, dashboard, ...) // Should provide a nice demo of OpenSearch Dashboards's functionality with the sample data set savedObjects: Array>; - getDataSourceIntegratedSavedObjects: ( - dataSourceId?: string, - dataSourceTitle?: string - ) => Array>; dataIndices: DataIndexSchema[]; status?: string | undefined; statusMsg?: unknown; diff --git a/src/plugins/home/server/services/sample_data/routes/install.test.ts b/src/plugins/home/server/services/sample_data/routes/install.test.ts index ad7b421c23d5..590edb5980ff 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.test.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.test.ts @@ -157,4 +157,67 @@ describe('sample data install route', () => { }, }); }); + + it('handler calls expected api with the given request with workspace', async () => { + const mockWorkspaceId = 'workspace'; + + const mockClient = jest.fn().mockResolvedValue(true); + + const mockSOClientGetResponse = { + saved_objects: [ + { + type: 'dashboard', + id: '12345', + namespaces: ['default'], + attributes: { title: 'dashboard' }, + }, + ], + }; + const mockSOClient = { + bulkCreate: jest.fn().mockResolvedValue(mockSOClientGetResponse), + get: jest.fn().mockResolvedValue(mockSOClientGetResponse), + }; + + const mockContext = { + core: { + opensearch: { + legacy: { + client: { callAsCurrentUser: mockClient }, + }, + }, + savedObjects: { client: mockSOClient }, + }, + }; + const mockBody = { id: 'flights' }; + const mockQuery = { workspace: mockWorkspaceId }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + params: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + createInstallRoute( + mockCoreSetup.http.createRouter(), + sampleDatasets, + mockLogger, + mockUsageTracker + ); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.post.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient.mock.calls[1][1].body.settings).toMatchObject({ + index: { number_of_shards: 1 }, + }); + + expect(mockResponse.ok).toBeCalled(); + expect(mockResponse.ok.mock.calls[0][0]).toMatchObject({ + body: { + opensearchIndicesCreated: { opensearch_dashboards_sample_data_flights: 13059 }, + opensearchDashboardsSavedObjectsLoaded: 20, + }, + }); + }); }); diff --git a/src/plugins/home/server/services/sample_data/routes/install.ts b/src/plugins/home/server/services/sample_data/routes/install.ts index 279357fc1977..38fb7f3fbe21 100644 --- a/src/plugins/home/server/services/sample_data/routes/install.ts +++ b/src/plugins/home/server/services/sample_data/routes/install.ts @@ -39,6 +39,10 @@ import { } from '../lib/translate_timestamp'; import { loadData } from '../lib/load_data'; import { SampleDataUsageTracker } from '../usage/usage'; +import { + getDataSourceIntegratedSavedObjects, + getWorkspaceIntegratedSavedObjects, +} from '../data_sets/util'; const insertDataIntoIndex = ( dataIndexConfig: any, @@ -113,12 +117,14 @@ export function createInstallRoute( query: schema.object({ now: schema.maybe(schema.string()), data_source_id: schema.maybe(schema.string()), + workspace_id: schema.maybe(schema.string()), }), }, }, async (context, req, res) => { const { params, query } = req; const dataSourceId = query.data_source_id; + const workspaceId = query.workspace_id; const sampleDataset = sampleDatasets.find(({ id }) => id === params.id); if (!sampleDataset) { @@ -198,14 +204,22 @@ export function createInstallRoute( } let createResults; - const savedObjectsList = dataSourceId - ? sampleDataset.getDataSourceIntegratedSavedObjects(dataSourceId, dataSourceTitle) - : sampleDataset.savedObjects; + let savedObjectsList = sampleDataset.savedObjects; + if (workspaceId) { + savedObjectsList = getWorkspaceIntegratedSavedObjects(savedObjectsList, workspaceId); + } + if (dataSourceId) { + savedObjectsList = getDataSourceIntegratedSavedObjects( + savedObjectsList, + dataSourceId, + dataSourceTitle + ); + } try { createResults = await context.core.savedObjects.client.bulkCreate( savedObjectsList.map(({ version, ...savedObject }) => savedObject), - { overwrite: true } + { overwrite: true, workspaces: workspaceId ? [workspaceId] : undefined } ); } catch (err) { const errMsg = `bulkCreate failed, error: ${err.message}`; diff --git a/src/plugins/home/server/services/sample_data/routes/list.test.ts b/src/plugins/home/server/services/sample_data/routes/list.test.ts index 70201fafd06b..d8fb572da128 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.test.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.test.ts @@ -119,4 +119,109 @@ describe('sample data list route', () => { `${mockDataSourceId}_7adfa750-4c81-11e8-b3d7-01146121b73d` ); }); + + it('handler calls expected api with the given request with workspace', async () => { + const mockWorkspaceId = 'workspace'; + const mockClient = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce({ count: 1 }); + + const mockSOClientGetResponse = { + saved_objects: [ + { + type: 'dashboard', + id: `${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d`, + namespaces: ['default'], + attributes: { title: 'dashboard' }, + }, + ], + }; + const mockSOClient = { get: jest.fn().mockResolvedValue(mockSOClientGetResponse) }; + + const mockContext = { + core: { + opensearch: { + legacy: { + client: { callAsCurrentUser: mockClient }, + }, + }, + savedObjects: { client: mockSOClient }, + }, + }; + + const mockBody = {}; + const mockQuery = { workspace_id: mockWorkspaceId }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + body: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + createListRoute(mockCoreSetup.http.createRouter(), sampleDatasets); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.get.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient).toBeCalledTimes(2); + expect(mockResponse.ok).toBeCalled(); + expect(mockSOClient.get.mock.calls[0][1]).toMatch( + `${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d` + ); + }); + + it('handler calls expected api with the given request with workspace and data source', async () => { + const mockWorkspaceId = 'workspace'; + const mockDataSourceId = 'dataSource'; + const mockClient = jest.fn().mockResolvedValueOnce(true).mockResolvedValueOnce({ count: 1 }); + + const mockSOClientGetResponse = { + saved_objects: [ + { + type: 'dashboard', + id: `${mockDataSourceId}_${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d`, + namespaces: ['default'], + attributes: { title: 'dashboard' }, + }, + ], + }; + const mockSOClient = { get: jest.fn().mockResolvedValue(mockSOClientGetResponse) }; + + const mockContext = { + dataSource: { + opensearch: { + legacy: { + getClient: (id) => { + return { + callAPI: mockClient, + }; + }, + }, + }, + }, + core: { + savedObjects: { client: mockSOClient }, + }, + }; + + const mockBody = {}; + const mockQuery = { workspace_id: mockWorkspaceId, data_source_id: mockDataSourceId }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + body: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + createListRoute(mockCoreSetup.http.createRouter(), sampleDatasets); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.get.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient).toBeCalledTimes(2); + expect(mockResponse.ok).toBeCalled(); + expect(mockSOClient.get.mock.calls[0][1]).toMatch( + `${mockDataSourceId}_${mockWorkspaceId}_7adfa750-4c81-11e8-b3d7-01146121b73d` + ); + }); }); diff --git a/src/plugins/home/server/services/sample_data/routes/list.ts b/src/plugins/home/server/services/sample_data/routes/list.ts index 5d4b036a9ead..431ab9437d55 100644 --- a/src/plugins/home/server/services/sample_data/routes/list.ts +++ b/src/plugins/home/server/services/sample_data/routes/list.ts @@ -42,11 +42,15 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc { path: '/api/sample_data', validate: { - query: schema.object({ data_source_id: schema.maybe(schema.string()) }), + query: schema.object({ + data_source_id: schema.maybe(schema.string()), + workspace_id: schema.maybe(schema.string()), + }), }, }, async (context, req, res) => { const dataSourceId = req.query.data_source_id; + const workspaceId = req.query.workspace_id; const registeredSampleDatasets = sampleDatasets.map((sampleDataset) => { return { @@ -56,7 +60,7 @@ export const createListRoute = (router: IRouter, sampleDatasets: SampleDatasetSc previewImagePath: sampleDataset.previewImagePath, darkPreviewImagePath: sampleDataset.darkPreviewImagePath, hasNewThemeImages: sampleDataset.hasNewThemeImages, - overviewDashboard: sampleDataset.getDataSourceIntegratedDashboard(dataSourceId), + overviewDashboard: sampleDataset.getDashboardWithPrefix(dataSourceId, workspaceId), appLinks: sampleDataset.appLinks, defaultIndex: sampleDataset.getDataSourceIntegratedDefaultIndex(dataSourceId), dataIndices: sampleDataset.dataIndices.map(({ id }) => ({ id })), diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts index 7d9797d752cb..c12e39ba1634 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.test.ts @@ -98,4 +98,35 @@ describe('sample data uninstall route', () => { expect(mockClient).toBeCalled(); expect(mockSOClient.delete).toBeCalled(); }); + + it('handler calls expected api with the given request with workspace', async () => { + const mockWorkspaceId = 'workspace'; + const mockContext = { + core: { + opensearch: { + legacy: { + client: { callAsCurrentUser: mockClient }, + }, + }, + savedObjects: { client: mockSOClient }, + }, + }; + const mockBody = { id: 'flights' }; + const mockQuery = { workspace_id: mockWorkspaceId }; + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest({ + params: mockBody, + query: mockQuery, + }); + const mockResponse = httpServerMock.createResponseFactory(); + + createUninstallRoute(mockCoreSetup.http.createRouter(), sampleDatasets, mockUsageTracker); + + const mockRouter = mockCoreSetup.http.createRouter.mock.results[0].value; + const handler = mockRouter.delete.mock.calls[0][1]; + + await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse); + + expect(mockClient).toBeCalled(); + expect(mockSOClient.delete).toBeCalled(); + }); }); diff --git a/src/plugins/home/server/services/sample_data/routes/uninstall.ts b/src/plugins/home/server/services/sample_data/routes/uninstall.ts index d5a09ce56070..95398e63683c 100644 --- a/src/plugins/home/server/services/sample_data/routes/uninstall.ts +++ b/src/plugins/home/server/services/sample_data/routes/uninstall.ts @@ -34,6 +34,10 @@ import { IRouter } from 'src/core/server'; import { SampleDatasetSchema } from '../lib/sample_dataset_registry_types'; import { createIndexName } from '../lib/create_index_name'; import { SampleDataUsageTracker } from '../usage/usage'; +import { + getDataSourceIntegratedSavedObjects, + getWorkspaceIntegratedSavedObjects, +} from '../data_sets/util'; export function createUninstallRoute( router: IRouter, @@ -47,12 +51,14 @@ export function createUninstallRoute( params: schema.object({ id: schema.string() }), query: schema.object({ data_source_id: schema.maybe(schema.string()), + workspace_id: schema.maybe(schema.string()), }), }, }, async (context, request, response) => { const sampleDataset = sampleDatasets.find(({ id }) => id === request.params.id); const dataSourceId = request.query.data_source_id; + const workspaceId = request.query.workspace_id; if (!sampleDataset) { return response.notFound(); @@ -78,9 +84,13 @@ export function createUninstallRoute( } } - const savedObjectsList = dataSourceId - ? sampleDataset.getDataSourceIntegratedSavedObjects(dataSourceId) - : sampleDataset.savedObjects; + let savedObjectsList = sampleDataset.savedObjects; + if (workspaceId) { + savedObjectsList = getWorkspaceIntegratedSavedObjects(savedObjectsList, workspaceId); + } + if (dataSourceId) { + savedObjectsList = getDataSourceIntegratedSavedObjects(savedObjectsList, dataSourceId); + } const deletePromises = savedObjectsList.map(({ type, id }) => context.core.savedObjects.client.delete(type, id) diff --git a/src/plugins/index_pattern_management/opensearch_dashboards.json b/src/plugins/index_pattern_management/opensearch_dashboards.json index 611f122c8c16..d4bf3a976d5e 100644 --- a/src/plugins/index_pattern_management/opensearch_dashboards.json +++ b/src/plugins/index_pattern_management/opensearch_dashboards.json @@ -4,6 +4,6 @@ "server": true, "ui": true, "optionalPlugins": ["dataSource"], - "requiredPlugins": ["management", "data", "urlForwarding"], + "requiredPlugins": ["data", "urlForwarding"], "requiredBundles": ["opensearchDashboardsReact", "opensearchDashboardsUtils"] } diff --git a/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx b/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx index af37e6ddb719..db2d45f10db1 100644 --- a/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx +++ b/src/plugins/index_pattern_management/public/management_app/mount_management_section.tsx @@ -34,11 +34,19 @@ import { Router, Switch, Route } from 'react-router-dom'; import { i18n } from '@osd/i18n'; import { I18nProvider } from '@osd/i18n/react'; -import { StartServicesAccessor } from 'src/core/public'; - +import { + AppMountParameters, + ChromeBreadcrumb, + ScopedHistory, + StartServicesAccessor, +} from 'src/core/public'; import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; -import { OpenSearchDashboardsContextProvider } from '../../../opensearch_dashboards_react/public'; -import { ManagementAppMountParams } from '../../../management/public'; + +import { EuiPage, EuiPageBody } from '@elastic/eui'; +import { + OpenSearchDashboardsContextProvider, + reactRouterNavigate, +} from '../../../opensearch_dashboards_react/public'; import { IndexPatternTableWithRouter, EditIndexPatternContainer, @@ -60,7 +68,7 @@ const readOnlyBadge = { export async function mountManagementSection( getStartServices: StartServicesAccessor, - params: ManagementAppMountParams, + params: AppMountParameters, getMlCardState: () => MlCardState, dataSource?: DataSourcePluginSetup ) { @@ -77,6 +85,17 @@ export async function mountManagementSection( chrome.setBadge(readOnlyBadge); } + const setBreadcrumbsScope = (crumbs: ChromeBreadcrumb[] = [], appHistory?: ScopedHistory) => { + const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ + ...item, + ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), + }); + + chrome.setBreadcrumbs([ + ...crumbs.map((item) => wrapBreadcrumb(item, appHistory || params.history)), + ]); + }; + const deps: IndexPatternManagmentContext = { chrome, application, @@ -88,33 +107,37 @@ export async function mountManagementSection( docLinks, data, indexPatternManagementStart: indexPatternManagementStart as IndexPatternManagementStart, - setBreadcrumbs: params.setBreadcrumbs, + setBreadcrumbs: setBreadcrumbsScope, getMlCardState, dataSourceEnabled, hideLocalCluster, }; ReactDOM.render( - - - - - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + + + + + + + , params.element ); diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 98eaab6160ee..f7b4461a10f7 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -29,7 +29,15 @@ */ import { i18n } from '@osd/i18n'; -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + AppMountParameters, + ChromeBreadcrumb, + ScopedHistory, +} from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { DataSourcePluginSetup, DataSourcePluginStart } from 'src/plugins/data_source/public'; import { UrlForwardingSetup } from '../../url_forwarding/public'; @@ -39,10 +47,11 @@ import { IndexPatternManagementServiceStart, } from './service'; -import { ManagementSetup } from '../../management/public'; +import { ManagementAppMountParams } from '../../management/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { reactRouterNavigate } from '../../opensearch_dashboards_react/public'; export interface IndexPatternManagementSetupDependencies { - management: ManagementSetup; urlForwarding: UrlForwardingSetup; dataSource?: DataSourcePluginSetup; } @@ -78,15 +87,9 @@ export class IndexPatternManagementPlugin core: CoreSetup, dependencies: IndexPatternManagementSetupDependencies ) { - const { urlForwarding, management, dataSource } = dependencies; - - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; + const newAppPath = IPM_APP_ID; + const { urlForwarding, dataSource } = dependencies; - if (!opensearchDashboardsSection) { - throw new Error('`opensearchDashboards` management section not found.'); - } - - const newAppPath = `management/opensearch-dashboards/${IPM_APP_ID}`; const legacyPatternsPath = 'management/opensearch-dashboards/index_patterns'; urlForwarding.forwardApp( @@ -99,16 +102,41 @@ export class IndexPatternManagementPlugin return pathInApp && `/patterns${pathInApp}`; }); - opensearchDashboardsSection.registerApp({ + // register it under Library + core.application.register({ id: IPM_APP_ID, title: sectionsHeader, - order: 0, - mount: async (params) => { + order: 8100, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: async (params: AppMountParameters) => { const { mountManagementSection } = await import('./management_app'); + const [coreStart] = await core.getStartServices(); + + const setBreadcrumbsScope = ( + crumbs: ChromeBreadcrumb[] = [], + appHistory?: ScopedHistory + ) => { + const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ + ...item, + ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), + }); + + coreStart.chrome.setBreadcrumbs([ + ...crumbs.map((item) => wrapBreadcrumb(item, appHistory || params.history)), + ]); + }; + + const managementParams: ManagementAppMountParams = { + element: params.element, + history: params.history, + setBreadcrumbs: setBreadcrumbsScope, + basePath: params.appBasePath, + }; + return mountManagementSection( core.getStartServices, - params, + managementParams, () => this.indexPatternManagementService.environmentService.getEnvironment().ml(), dataSource ); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 15b6c6bff057..76cb54dc8cb8 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -106,7 +106,7 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { } function renderSearchBar(): ReactElement | null { - // Validate presense of all required fields + // Validate presence of all required fields if (!showSearchBar || !props.data) return null; const { SearchBar } = props.data.ui; return ; diff --git a/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap b/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap index 9df3bb12caec..db7484e21379 100644 --- a/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap +++ b/src/plugins/opensearch_dashboards_overview/public/components/getting_started/__snapshots__/getting_started.test.tsx.snap @@ -171,7 +171,7 @@ exports[`GettingStarted dark mode on 1`] = ` = ({ addBasePath, isDarkTheme, apps }) => - + = ({ addBasePath, path }) => { diff --git a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx index 2e27ebd0cb6b..fcd417a42826 100644 --- a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx +++ b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.test.tsx @@ -200,7 +200,7 @@ describe('OverviewPageHeader toolbar items - Management', () => { return component.find({ className: 'osdOverviewPageHeader__actionButton', - href: '/app/management', + href: '/app/settings', }); }; diff --git a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx index a636f7ecdb7d..e27a99fc4d44 100644 --- a/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx +++ b/src/plugins/opensearch_dashboards_react/public/overview_page/overview_page_header/overview_page_header.tsx @@ -136,7 +136,7 @@ export const OverviewPageHeader: FC = ({ className="osdOverviewPageHeader__actionButton" flush="both" iconType="gear" - href={addBasePath('/app/management')} + href={addBasePath('/app/settings')} > {i18n.translate( 'opensearch-dashboards-react.osdOverviewPageHeader.stackManagementButtonLabel', diff --git a/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx index 438971862c79..0df7289caf75 100644 --- a/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx +++ b/src/plugins/opensearch_dashboards_react/public/table_list_view/table_list_view.tsx @@ -315,7 +315,7 @@ class TableListView extends React.ComponentlistingLimit, advancedSettingsLink: ( - + ): Promise { return http.post('/api/saved_objects/_export', { body: JSON.stringify({ + ...body, type: types, search, includeReferencesDeep, diff --git a/src/plugins/saved_objects_management/public/lib/fetch_export_objects.ts b/src/plugins/saved_objects_management/public/lib/fetch_export_objects.ts index b2e2ea0f9165..43afcfec3056 100644 --- a/src/plugins/saved_objects_management/public/lib/fetch_export_objects.ts +++ b/src/plugins/saved_objects_management/public/lib/fetch_export_objects.ts @@ -33,10 +33,12 @@ import { HttpStart } from 'src/core/public'; export async function fetchExportObjects( http: HttpStart, objects: any[], - includeReferencesDeep: boolean = false + includeReferencesDeep: boolean = false, + body?: Record ): Promise { return http.post('/api/saved_objects/_export', { body: JSON.stringify({ + ...body, objects, includeReferencesDeep, }), diff --git a/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts b/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts index 6eaaac7d35f2..374f2720b537 100644 --- a/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts +++ b/src/plugins/saved_objects_management/public/lib/get_saved_object_counts.ts @@ -34,13 +34,14 @@ export interface SavedObjectCountOptions { typesToInclude: string[]; namespacesToInclude?: string[]; searchString?: string; + workspaces?: string[]; } export async function getSavedObjectCounts( http: HttpStart, options: SavedObjectCountOptions -): Promise> { - return await http.post>( +): Promise>> { + return await http.post>>( `/api/opensearch-dashboards/management/saved_objects/scroll/counts`, { body: JSON.stringify(options) } ); diff --git a/src/plugins/saved_objects_management/public/lib/import_file.ts b/src/plugins/saved_objects_management/public/lib/import_file.ts index 3753a8251e10..bcf1b6911b0f 100644 --- a/src/plugins/saved_objects_management/public/lib/import_file.ts +++ b/src/plugins/saved_objects_management/public/lib/import_file.ts @@ -40,12 +40,12 @@ interface ImportResponse { export async function importFile( http: HttpStart, file: File, - { createNewCopies, overwrite }: ImportMode, + { createNewCopies, overwrite, workspaces }: ImportMode, selectedDataSourceId?: string ) { const formData = new FormData(); formData.append('file', file); - const query = createNewCopies ? { createNewCopies } : { overwrite }; + const query = createNewCopies ? { createNewCopies, workspaces } : { overwrite, workspaces }; if (selectedDataSourceId) { query.dataSourceId = selectedDataSourceId; } diff --git a/src/plugins/saved_objects_management/public/lib/index.ts b/src/plugins/saved_objects_management/public/lib/index.ts index fae58cad3eb2..80630b8780e7 100644 --- a/src/plugins/saved_objects_management/public/lib/index.ts +++ b/src/plugins/saved_objects_management/public/lib/index.ts @@ -57,3 +57,4 @@ export { extractExportDetails, SavedObjectsExportResultDetails } from './extract export { createFieldList } from './create_field_list'; export { getAllowedTypes } from './get_allowed_types'; export { filterQuery } from './filter_query'; +export { duplicateSavedObjects } from './duplicate_saved_objects'; diff --git a/src/plugins/saved_objects_management/public/lib/parse_query.test.ts b/src/plugins/saved_objects_management/public/lib/parse_query.test.ts index a940cf3ebbca..731bb73a4d70 100644 --- a/src/plugins/saved_objects_management/public/lib/parse_query.test.ts +++ b/src/plugins/saved_objects_management/public/lib/parse_query.test.ts @@ -39,6 +39,8 @@ describe('getQueryText', () => { return [{ value: 'lala' }, { value: 'lolo' }]; } else if (field === 'namespaces') { return [{ value: 'default' }]; + } else if (field === 'workspaces') { + return [{ value: 'workspaces' }]; } return []; }, @@ -47,6 +49,7 @@ describe('getQueryText', () => { queryText: 'foo bar', visibleTypes: 'lala', visibleNamespaces: 'default', + visibleWorkspaces: 'workspaces', }); }); }); diff --git a/src/plugins/saved_objects_management/public/lib/parse_query.ts b/src/plugins/saved_objects_management/public/lib/parse_query.ts index 24c35d500aaa..3db3f7fcee1c 100644 --- a/src/plugins/saved_objects_management/public/lib/parse_query.ts +++ b/src/plugins/saved_objects_management/public/lib/parse_query.ts @@ -33,12 +33,15 @@ import { Query } from '@elastic/eui'; interface ParsedQuery { queryText?: string; visibleTypes?: string[]; + visibleNamespaces?: string[]; + visibleWorkspaces?: string[]; } export function parseQuery(query: Query): ParsedQuery { let queryText: string | undefined; let visibleTypes: string[] | undefined; let visibleNamespaces: string[] | undefined; + let visibleWorkspaces: string[] | undefined; if (query) { if (query.ast.getTermClauses().length) { @@ -53,11 +56,15 @@ export function parseQuery(query: Query): ParsedQuery { if (query.ast.getFieldClauses('namespaces')) { visibleNamespaces = query.ast.getFieldClauses('namespaces')[0].value as string[]; } + if (query.ast.getFieldClauses('workspaces')) { + visibleWorkspaces = query.ast.getFieldClauses('workspaces')[0].value as string[]; + } } return { queryText, visibleTypes, visibleNamespaces, + visibleWorkspaces, }; } diff --git a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts index 585102ee5b8e..eec81ffd7f4c 100644 --- a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts +++ b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts @@ -90,12 +90,13 @@ async function callResolveImportErrorsApi( file: File, retries: any, createNewCopies: boolean, + workspaces?: string[], selectedDataSourceId?: string ): Promise { const formData = new FormData(); formData.append('file', file); formData.append('retries', JSON.stringify(retries)); - const query = createNewCopies ? { createNewCopies } : {}; + const query = createNewCopies ? { createNewCopies, workspaces } : { workspaces }; if (selectedDataSourceId) { query.dataSourceId = selectedDataSourceId; } @@ -171,6 +172,7 @@ export async function resolveImportErrors({ http, getConflictResolutions, state, + workspaces, selectedDataSourceId, }: { http: HttpStart; @@ -185,6 +187,7 @@ export async function resolveImportErrors({ file?: File; importMode: { createNewCopies: boolean; overwrite: boolean }; }; + workspaces?: string[]; selectedDataSourceId: string; }) { const retryDecisionCache = new Map(); @@ -275,6 +278,7 @@ export async function resolveImportErrors({ file!, retries, createNewCopies, + workspaces, selectedDataSourceId ); importCount = response.successCount; // reset the success count since we retry all successful results each time diff --git a/src/plugins/saved_objects_management/public/management_section/index.ts b/src/plugins/saved_objects_management/public/management_section/index.ts index 333bee71b0c0..1f29fa548559 100644 --- a/src/plugins/saved_objects_management/public/management_section/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/index.ts @@ -29,3 +29,4 @@ */ export { mountManagementSection } from './mount_section'; +export { showDuplicateModal, SavedObjectsDuplicateModal, DuplicateMode } from './objects_table'; diff --git a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx index 967dd93290d2..f4ad1b3b4add 100644 --- a/src/plugins/saved_objects_management/public/management_section/mount_section.tsx +++ b/src/plugins/saved_objects_management/public/management_section/mount_section.tsx @@ -32,10 +32,10 @@ import React, { lazy, Suspense } from 'react'; import ReactDOM from 'react-dom'; import { Router, Switch, Route } from 'react-router-dom'; import { I18nProvider } from '@osd/i18n/react'; -import { i18n } from '@osd/i18n'; import { EuiLoadingSpinner } from '@elastic/eui'; -import { CoreSetup } from 'src/core/public'; -import { ManagementAppMountParams } from '../../../management/public'; +import { AppMountParameters, CoreSetup } from 'src/core/public'; +import { ManagementAppMountParams } from 'src/plugins/management/public'; +import { PageWrapper } from './page_wrapper'; import { StartDependencies, SavedObjectsManagementPluginStart } from '../plugin'; import { ISavedObjectsManagementServiceRegistry } from '../services'; import { getAllowedTypes } from './../lib'; @@ -43,28 +43,29 @@ import { getAllowedTypes } from './../lib'; interface MountParams { core: CoreSetup; serviceRegistry: ISavedObjectsManagementServiceRegistry; - mountParams: ManagementAppMountParams; + appMountParams?: AppMountParameters; + title: string; + allowedObjectTypes?: string[]; dataSourceEnabled: boolean; hideLocalCluster: boolean; } -let allowedObjectTypes: string[] | undefined; - -const title = i18n.translate('savedObjectsManagement.objects.savedObjectsTitle', { - defaultMessage: 'Saved Objects', -}); - const SavedObjectsEditionPage = lazy(() => import('./saved_objects_edition_page')); const SavedObjectsTablePage = lazy(() => import('./saved_objects_table_page')); export const mountManagementSection = async ({ core, - mountParams, + appMountParams, serviceRegistry, + title, + allowedObjectTypes, dataSourceEnabled, hideLocalCluster, }: MountParams) => { const [coreStart, { data, uiActions }, pluginStart] = await core.getStartServices(); - const { element, history, setBreadcrumbs } = mountParams; + const usedMountParams = appMountParams || ({} as ManagementAppMountParams); + const { element, history } = usedMountParams; + const { chrome } = coreStart; + const setBreadcrumbs = chrome.setBreadcrumbs; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); } @@ -90,31 +91,36 @@ export const mountManagementSection = async ({ }> - + + + }> - + + + diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index d18762f4912f..2b1506545635 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -203,9 +203,13 @@ exports[`SavedObjectsTable should render normally 1`] = ` >
    diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index e15e72d6a2cb..f18ced5be1f5 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -169,9 +169,11 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -246,6 +248,7 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` }, ], }, + "workspaces": undefined, }, ], ], diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap index 038e1aaf2d8f..6b9d1038f445 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/header.test.tsx.snap @@ -11,11 +11,7 @@ exports[`Header should render normally 1`] = ` >

    - + Saved Objects

    diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap index 43cf0823c827..b8718285766e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap @@ -711,7 +711,7 @@ exports[`Relationships should render augment-vis objects normally 1`] = ` Object { "id": "1", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedVisualizations/1", + "editUrl": "/objects/savedVisualizations/1", "icon": "visualizeApp", "inAppUrl": Object { "path": "/edit/1", @@ -845,7 +845,7 @@ exports[`Relationships should render dashboards normally 1`] = ` Object { "id": "1", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedVisualizations/1", + "editUrl": "/objects/savedVisualizations/1", "icon": "visualizeApp", "inAppUrl": Object { "path": "/app/visualize#/edit/1", @@ -859,7 +859,7 @@ exports[`Relationships should render dashboards normally 1`] = ` Object { "id": "2", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedVisualizations/2", + "editUrl": "/objects/savedVisualizations/2", "icon": "visualizeApp", "inAppUrl": Object { "path": "/app/visualize#/edit/2", @@ -1028,7 +1028,7 @@ exports[`Relationships should render index patterns normally 1`] = ` Object { "id": "1", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedSearches/1", + "editUrl": "/objects/savedSearches/1", "icon": "search", "inAppUrl": Object { "path": "/app/discover#//1", @@ -1042,7 +1042,7 @@ exports[`Relationships should render index patterns normally 1`] = ` Object { "id": "2", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedVisualizations/2", + "editUrl": "/objects/savedVisualizations/2", "icon": "visualizeApp", "inAppUrl": Object { "path": "/app/visualize#/edit/2", @@ -1181,10 +1181,10 @@ exports[`Relationships should render searches normally 1`] = ` Object { "id": "1", "meta": Object { - "editUrl": "/management/opensearch-dashboards/indexPatterns/patterns/1", + "editUrl": "/indexPatterns/patterns/1", "icon": "indexPatternApp", "inAppUrl": Object { - "path": "/app/management/opensearch-dashboards/indexPatterns/patterns/1", + "path": "/app/indexPatterns/patterns/1", "uiCapabilitiesPath": "management.opensearchDashboards.indexPatterns", }, "title": "My Index Pattern", @@ -1195,7 +1195,7 @@ exports[`Relationships should render searches normally 1`] = ` Object { "id": "2", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedVisualizations/2", + "editUrl": "/objects/savedVisualizations/2", "icon": "visualizeApp", "inAppUrl": Object { "path": "/app/visualize#/edit/2", @@ -1334,7 +1334,7 @@ exports[`Relationships should render visualizations normally 1`] = ` Object { "id": "1", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedDashboards/1", + "editUrl": "/objects/savedDashboards/1", "icon": "dashboardApp", "inAppUrl": Object { "path": "/app/opensearch-dashboards#/dashboard/1", @@ -1348,7 +1348,7 @@ exports[`Relationships should render visualizations normally 1`] = ` Object { "id": "2", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedDashboards/2", + "editUrl": "/objects/savedDashboards/2", "icon": "dashboardApp", "inAppUrl": Object { "path": "/app/opensearch-dashboards#/dashboard/2", diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap index e22f7f3a0128..c287960ef472 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/table.test.tsx.snap @@ -167,7 +167,7 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "data-test-subj": "savedObjectsTableAction-relationships", "description": "View the relationships this saved object has to other saved objects", "icon": "kqlSelector", - "name": "Relationships", + "name": "View object relationships", "onClick": [Function], "type": "icon", }, @@ -184,10 +184,10 @@ exports[`Table prevents saved objects from being deleted 1`] = ` "attributes": Object {}, "id": "1", "meta": Object { - "editUrl": "#/management/opensearch-dashboards/indexPatterns/patterns/1", + "editUrl": "#/indexPatterns/patterns/1", "icon": "indexPatternApp", "inAppUrl": Object { - "path": "/management/opensearch-dashboards/indexPatterns/patterns/1", + "path": "/indexPatterns/patterns/1", "uiCapabilitiesPath": "management.opensearchDashboards.indexPatterns", }, "title": "MyIndexPattern*", @@ -392,7 +392,7 @@ exports[`Table should render normally 1`] = ` "data-test-subj": "savedObjectsTableAction-relationships", "description": "View the relationships this saved object has to other saved objects", "icon": "kqlSelector", - "name": "Relationships", + "name": "View object relationships", "onClick": [Function], "type": "icon", }, @@ -409,10 +409,10 @@ exports[`Table should render normally 1`] = ` "attributes": Object {}, "id": "1", "meta": Object { - "editUrl": "#/management/opensearch-dashboards/indexPatterns/patterns/1", + "editUrl": "#/indexPatterns/patterns/1", "icon": "indexPatternApp", "inAppUrl": Object { - "path": "/management/opensearch-dashboards/indexPatterns/patterns/1", + "path": "/indexPatterns/patterns/1", "uiCapabilitiesPath": "management.opensearchDashboards.indexPatterns", }, "title": "MyIndexPattern*", diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx new file mode 100644 index 000000000000..5a3a7e6f57f0 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/duplicate_modal.tsx @@ -0,0 +1,469 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import { FormattedMessage } from '@osd/i18n/react'; +import { groupBy } from 'lodash'; +import { + EuiButton, + EuiButtonEmpty, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiComboBox, + EuiFormRow, + EuiCheckbox, + EuiComboBoxOptionOption, + EuiInMemoryTable, + EuiToolTip, + EuiIcon, + EuiCallOut, + EuiText, +} from '@elastic/eui'; +import { + HttpSetup, + NotificationsStart, + WorkspaceAttribute, + WorkspacesStart, +} from 'opensearch-dashboards/public'; +import { i18n } from '@osd/i18n'; +import { SavedObjectWithMetadata } from '../../../../common'; +import { getSavedObjectLabel, SAVED_OBJECT_TYPE_WORKSPACE } from '../../../../public'; + +type WorkspaceOption = EuiComboBoxOptionOption; + +export enum DuplicateMode { + Selected = 'selected', + All = 'all', +} +export interface ShowDuplicateModalProps { + onDuplicate: ( + savedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => Promise; + http: HttpSetup; + workspaces: WorkspacesStart; + duplicateMode: DuplicateMode; + notifications: NotificationsStart; + selectedSavedObjects: SavedObjectWithMetadata[]; +} + +interface Props extends ShowDuplicateModalProps { + onClose: () => void; +} + +interface State { + allSelectedObjects: SavedObjectWithMetadata[]; + workspaceOptions: WorkspaceOption[]; + allWorkspaceOptions: WorkspaceOption[]; + targetWorkspaceOption: WorkspaceOption[]; + isLoading: boolean; + isIncludeReferencesDeepChecked: boolean; + savedObjectTypeInfoMap: Map; +} + +function capitalizeFirstLetter(str: string): string { + return str.charAt(0).toUpperCase() + str.slice(1); +} + +export class SavedObjectsDuplicateModal extends React.Component { + private isMounted = false; + + constructor(props: Props) { + super(props); + + this.state = { + allSelectedObjects: this.props.selectedSavedObjects, + workspaceOptions: [], + allWorkspaceOptions: [], + targetWorkspaceOption: [], + isLoading: false, + isIncludeReferencesDeepChecked: true, + savedObjectTypeInfoMap: new Map(), + }; + } + + workspaceToOption = ( + workspace: WorkspaceAttribute, + currentWorkspaceName?: string + ): WorkspaceOption => { + // add (current) after current workspace name + let workspaceName = workspace.name; + if (workspace.name === currentWorkspaceName) { + workspaceName += ' (current)'; + } + return { + label: workspaceName, + key: workspace.id, + value: workspace, + }; + }; + + async componentDidMount() { + const { workspaces } = this.props; + const currentWorkspace = workspaces.currentWorkspace$.value; + const currentWorkspaceName = currentWorkspace?.name; + const targetWorkspaces = this.getTargetWorkspaces(); + + // current workspace is the first option + const workspaceOptions = [ + ...(currentWorkspace ? [this.workspaceToOption(currentWorkspace, currentWorkspaceName)] : []), + ...targetWorkspaces + .filter((workspace: WorkspaceAttribute) => workspace.name !== currentWorkspaceName) + .map((workspace: WorkspaceAttribute) => + this.workspaceToOption(workspace, currentWorkspaceName) + ), + ]; + + this.setState({ + workspaceOptions, + allWorkspaceOptions: workspaceOptions, + }); + + const { duplicateMode } = this.props; + if (duplicateMode === DuplicateMode.All) { + const { allSelectedObjects } = this.state; + const categorizedObjects = groupBy(allSelectedObjects, (object) => object.type); + const savedObjectTypeInfoMap = new Map(); + for (const [savedObjectType, savedObjects] of Object.entries(categorizedObjects)) { + savedObjectTypeInfoMap.set(savedObjectType, [savedObjects.length, true]); + } + this.setState({ savedObjectTypeInfoMap }); + } + + this.isMounted = true; + } + + componentWillUnmount() { + this.isMounted = false; + } + + getTargetWorkspaces = () => { + const { workspaces } = this.props; + const workspaceList = workspaces.workspaceList$.value; + return workspaceList.filter((workspace) => !workspace.libraryReadonly); + }; + + duplicateSavedObjects = async (savedObjects: SavedObjectWithMetadata[]) => { + this.setState({ + isLoading: true, + }); + + const targetWorkspace = this.state.targetWorkspaceOption[0].key; + + await this.props.onDuplicate( + savedObjects, + this.state.isIncludeReferencesDeepChecked, + targetWorkspace! + ); + + if (this.isMounted) { + this.setState({ + isLoading: false, + }); + } + }; + + onSearchWorkspaceChange = (searchValue: string) => { + this.setState({ + workspaceOptions: this.state.allWorkspaceOptions.filter((item) => + item.label.includes(searchValue) + ), + }); + }; + + onTargetWorkspaceChange = (targetWorkspaceOption: WorkspaceOption[]) => { + this.setState({ + targetWorkspaceOption, + }); + }; + + changeIncludeReferencesDeep = () => { + this.setState((state) => ({ + isIncludeReferencesDeepChecked: !state.isIncludeReferencesDeepChecked, + })); + }; + + changeIncludeSavedObjectType = (savedObjectType: string) => { + const { savedObjectTypeInfoMap } = this.state; + const savedObjectTypeInfo = savedObjectTypeInfoMap.get(savedObjectType); + if (savedObjectTypeInfo) { + const [count, checked] = savedObjectTypeInfo; + savedObjectTypeInfoMap.set(savedObjectType, [count, !checked]); + this.setState({ savedObjectTypeInfoMap }); + } + }; + + renderDuplicateObjectCategory = ( + savedObjectType: string, + savedObjectTypeCount: number, + savedObjectTypeChecked: boolean + ) => { + return ( + + } + checked={savedObjectTypeChecked} + onChange={() => this.changeIncludeSavedObjectType(savedObjectType)} + /> + ); + }; + + renderDuplicateObjectCategories = () => { + const { savedObjectTypeInfoMap } = this.state; + const checkboxList: JSX.Element[] = []; + savedObjectTypeInfoMap.forEach( + ([savedObjectTypeCount, savedObjectTypeChecked], savedObjectType) => + checkboxList.push( + this.renderDuplicateObjectCategory( + savedObjectType, + savedObjectTypeCount, + savedObjectTypeChecked + ) + ) + ); + return checkboxList; + }; + + isSavedObjectTypeIncluded = (savedObjectType: string) => { + const { savedObjectTypeInfoMap } = this.state; + const savedObjectTypeInfo = savedObjectTypeInfoMap.get(savedObjectType); + return savedObjectTypeInfo && savedObjectTypeInfo[1]; + }; + + render() { + const { + workspaceOptions, + targetWorkspaceOption, + isIncludeReferencesDeepChecked, + allSelectedObjects, + } = this.state; + const { duplicateMode, onClose } = this.props; + const targetWorkspaceId = targetWorkspaceOption?.at(0)?.key; + let selectedObjects = allSelectedObjects; + if (duplicateMode === DuplicateMode.All) { + selectedObjects = selectedObjects.filter((item) => this.isSavedObjectTypeIncluded(item.type)); + } + const includedSelectedObjects = selectedObjects.filter((item) => + !!targetWorkspaceId && !!item.workspaces + ? !item.workspaces.includes(targetWorkspaceId) + : item.type !== SAVED_OBJECT_TYPE_WORKSPACE + ); + + const ignoredSelectedObjectsLength = selectedObjects.length - includedSelectedObjects.length; + + let confirmDuplicateButtonEnabled = false; + if (!!targetWorkspaceId && includedSelectedObjects.length > 0) { + confirmDuplicateButtonEnabled = true; + } + + const warningMessageForOnlyOneSavedObject = ( +

    + 1 saved object will not be + copied, because it has already existed in the selected workspace or it is worksapce itself. +

    + ); + const warningMessageForMultipleSavedObjects = ( +

    + {ignoredSelectedObjectsLength} saved objects will{' '} + not be copied, because they have already existed in the + selected workspace or they are workspaces themselves. +

    + ); + + const ignoreSomeObjectsChildren: React.ReactChild = ( + <> + + {ignoredSelectedObjectsLength === 1 + ? warningMessageForOnlyOneSavedObject + : warningMessageForMultipleSavedObjects} + + + + ); + + return ( + + + + + + + + + + } + > + <> + + {'Specify a workspace where the objects will be duplicated.'} + + + + + + + + {duplicateMode === DuplicateMode.All && this.renderDuplicateObjectCategories()} + {duplicateMode === DuplicateMode.All && } + + + } + > + <> + + { + 'We recommended duplicating related objects to ensure your duplicated objects will continue to function.' + } + + + + } + checked={isIncludeReferencesDeepChecked} + onChange={this.changeIncludeReferencesDeep} + /> + + + + + {ignoredSelectedObjectsLength === 0 ? null : ignoreSomeObjectsChildren} +

    + +

    + + ( + + + + ), + }, + { + field: 'id', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.idColumnName', + { + defaultMessage: 'Id', + } + ), + }, + { + field: 'meta.title', + name: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicateModal.titleColumnName', + { defaultMessage: 'Title' } + ), + }, + ]} + pagination={true} + sorting={false} + /> +
    + + + + + + + this.duplicateSavedObjects(includedSelectedObjects)} + isLoading={this.state.isLoading} + disabled={!confirmDuplicateButtonEnabled} + > + + + +
    + ); + } +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index f170713e0238..36a236451d66 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -54,7 +54,12 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; -import { OverlayStart, HttpStart } from 'src/core/public'; +import { + OverlayStart, + HttpStart, + NotificationsStart, + SavedObjectsClientContract, +} from 'src/core/public'; import { ClusterSelector } from '../../../../../data_source_management/public'; import { IndexPatternsContract, @@ -93,6 +98,7 @@ export interface FlyoutProps { overlays: OverlayStart; http: HttpStart; search: DataPublicPluginStart['search']; + workspaces?: string[]; dataSourceEnabled: boolean; hideLocalCluster: boolean; savedObjects: SavedObjectsClientContract; @@ -189,13 +195,21 @@ export class Flyout extends Component { * Does the initial import of a file, resolveImportErrors then handles errors and retries */ import = async () => { - const { http } = this.props; + const { http, workspaces } = this.props; const { file, importMode, selectedDataSourceId } = this.state; this.setState({ status: 'loading', error: undefined }); // Import the file try { - const response = await importFile(http, file!, importMode, selectedDataSourceId); + const response = await importFile( + http, + file!, + { + ...importMode, + workspaces, + }, + selectedDataSourceId + ); this.setState(processImportResponse(response), () => { // Resolve import errors right away if there's no index patterns to match // This will ask about overwriting each object, etc @@ -251,12 +265,14 @@ export class Flyout extends Component { status: 'loading', loadingMessage: undefined, }); + const { workspaces } = this.props; try { const updatedState = await resolveImportErrors({ http: this.props.http, state: this.state, getConflictResolutions: this.getConflictResolutions, + workspaces, selectedDataSourceId: this.state.selectedDataSourceId, }); this.setState(updatedState); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx index 1b0f40e9cd02..d98fe7257fbd 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.test.tsx @@ -38,8 +38,14 @@ describe('Header', () => { onExportAll: () => {}, onImport: () => {}, onRefresh: () => {}, - totalCount: 4, + onCopy: () => {}, + onDuplicate: () => {}, + title: 'Saved Objects', + selectedCount: 0, + objectCount: 4, filteredCount: 2, + showDuplicateAll: false, + hideImport: false, }; const component = shallow(
    ); @@ -47,3 +53,45 @@ describe('Header', () => { expect(component).toMatchSnapshot(); }); }); + +describe('Header - workspace enabled', () => { + it('should render `Duplicate All` button when workspace enabled', () => { + const props = { + onExportAll: () => {}, + onImport: () => {}, + onRefresh: () => {}, + onCopy: () => {}, + onDuplicate: () => {}, + title: 'Saved Objects', + selectedCount: 0, + objectCount: 4, + filteredCount: 2, + showDuplicateAll: true, + hideImport: false, + }; + + const component = shallow(
    ); + + expect(component.find('EuiButtonEmpty[data-test-subj="duplicateObjects"]').exists()).toBe(true); + }); + + it('should hide `Import` button for application home state', () => { + const props = { + onExportAll: () => {}, + onImport: () => {}, + onRefresh: () => {}, + onCopy: () => {}, + onDuplicate: () => {}, + title: 'Saved Objects', + selectedCount: 0, + objectCount: 4, + filteredCount: 2, + showDuplicateAll: true, + hideImport: true, + }; + + const component = shallow(
    ); + + expect(component.find('EuiButtonEmpty[data-test-subj="importObjects"]').exists()).toBe(false); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx index a22e349d5240..003ec95ee7dc 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/header.tsx @@ -43,29 +43,49 @@ import { FormattedMessage } from '@osd/i18n/react'; export const Header = ({ onExportAll, onImport, + onDuplicate, onRefresh, filteredCount, + title, + objectCount, + hideImport = false, + showDuplicateAll = false, }: { onExportAll: () => void; onImport: () => void; + onDuplicate: () => void; onRefresh: () => void; filteredCount: number; + title: string; + objectCount: number; + hideImport: boolean; + showDuplicateAll: boolean; }) => ( -

    - -

    +

    {title}

    + {showDuplicateAll && ( + + + + + + )} - - - - - + {!hideImport && ( + + + + + + )} { id: '1', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedSearches/1', + editUrl: '/objects/savedSearches/1', icon: 'search', inAppUrl: { path: '/app/discover#//1', @@ -67,7 +67,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', + editUrl: '/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -85,9 +85,9 @@ describe('Relationships', () => { meta: { title: 'MyIndexPattern*', icon: 'indexPatternApp', - editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '#/indexPatterns/patterns/1', inAppUrl: { - path: '/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -120,10 +120,10 @@ describe('Relationships', () => { id: '1', relationship: 'child', meta: { - editUrl: '/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '/indexPatterns/patterns/1', icon: 'indexPatternApp', inAppUrl: { - path: '/app/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/app/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, title: 'My Index Pattern', @@ -134,7 +134,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', + editUrl: '/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -152,7 +152,7 @@ describe('Relationships', () => { meta: { title: 'MySearch', icon: 'search', - editUrl: '/management/opensearch-dashboards/objects/savedSearches/1', + editUrl: '/objects/savedSearches/1', inAppUrl: { path: '/discover/1', uiCapabilitiesPath: 'discover.show', @@ -187,7 +187,7 @@ describe('Relationships', () => { id: '1', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', + editUrl: '/objects/savedDashboards/1', icon: 'dashboardApp', inAppUrl: { path: '/app/opensearch-dashboards#/dashboard/1', @@ -201,7 +201,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/2', + editUrl: '/objects/savedDashboards/2', icon: 'dashboardApp', inAppUrl: { path: '/app/opensearch-dashboards#/dashboard/2', @@ -219,7 +219,7 @@ describe('Relationships', () => { meta: { title: 'MyViz', icon: 'visualizeApp', - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', + editUrl: '/objects/savedVisualizations/1', inAppUrl: { path: '/edit/1', uiCapabilitiesPath: 'visualize.show', @@ -256,7 +256,7 @@ describe('Relationships', () => { meta: { title: 'MyViz', icon: 'visualizeApp', - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', + editUrl: '/objects/savedVisualizations/1', inAppUrl: { path: '/edit/1', uiCapabilitiesPath: 'visualize.show', @@ -272,7 +272,7 @@ describe('Relationships', () => { meta: { title: 'MyAugmentVisObject', icon: 'savedObject', - editUrl: '/management/opensearch-dashboards/objects/savedAugmentVis/1', + editUrl: '/objects/savedAugmentVis/1', }, }, close: jest.fn(), @@ -303,7 +303,7 @@ describe('Relationships', () => { id: '1', relationship: 'child', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', + editUrl: '/objects/savedVisualizations/1', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/1', @@ -317,7 +317,7 @@ describe('Relationships', () => { id: '2', relationship: 'child', meta: { - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', + editUrl: '/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -335,7 +335,7 @@ describe('Relationships', () => { meta: { title: 'MyDashboard', icon: 'dashboardApp', - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', + editUrl: '/objects/savedDashboards/1', inAppUrl: { path: '/dashboard/1', uiCapabilitiesPath: 'dashboard.show', @@ -375,7 +375,7 @@ describe('Relationships', () => { meta: { title: 'MyDashboard', icon: 'dashboardApp', - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', + editUrl: '/objects/savedDashboards/1', inAppUrl: { path: '/dashboard/1', uiCapabilitiesPath: 'dashboard.show', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/show_duplicate_modal.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/show_duplicate_modal.tsx new file mode 100644 index 000000000000..f783a3e95bad --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/show_duplicate_modal.tsx @@ -0,0 +1,64 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { I18nStart } from 'opensearch-dashboards/public'; +import { SavedObjectsDuplicateModal, ShowDuplicateModalProps } from './duplicate_modal'; + +/** + * Represents the result of trying to duplicate the saved object. + * Contains `error` prop if something unexpected happened (e.g. network error). + * Contains an `id` if persisting was successful. If `id` and + * `error` are undefined, persisting was not successful, but the + * modal can still recover (e.g. the name of the saved object was already taken). + */ + +export function showDuplicateModal( + showDuplicateModalProps: ShowDuplicateModalProps, + I18nContext: I18nStart['Context'] +) { + const container = document.createElement('div'); + const closeModal = () => { + ReactDOM.unmountComponentAtNode(container); + document.body.removeChild(container); + }; + + const { + http, + workspaces, + onDuplicate, + duplicateMode, + notifications, + selectedSavedObjects, + } = showDuplicateModalProps; + + const onDuplicateConfirmed: ShowDuplicateModalProps['onDuplicate'] = async (...args) => { + await onDuplicate(...args); + closeModal(); + }; + + const duplicateModal = ( + + ); + + document.body.appendChild(container); + + ReactDOM.render({duplicateModal}, container); +} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 7e5bb318f4d0..6c620cd5cadd 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx @@ -37,6 +37,7 @@ import { actionServiceMock } from '../../../services/action_service.mock'; import { columnServiceMock } from '../../../services/column_service.mock'; import { SavedObjectsManagementAction } from '../../..'; import { Table, TableProps } from './table'; +import { WorkspaceAttribute } from 'opensearch-dashboards/public'; const defaultProps: TableProps = { basePath: httpServiceMock.createSetupContract().basePath, @@ -51,9 +52,9 @@ const defaultProps: TableProps = { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '#/indexPatterns/patterns/1', inAppUrl: { - path: '/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -91,9 +92,9 @@ const defaultProps: TableProps = { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '#/indexPatterns/patterns/1', inAppUrl: { - path: '/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -115,6 +116,36 @@ describe('Table', () => { expect(component).toMatchSnapshot(); }); + it('should render gotoApp link correctly for workspace', () => { + const item = { + id: 'dashboard-1', + type: 'dashboard', + workspaces: ['ws-1'], + attributes: {}, + references: [], + meta: { + title: `My-Dashboard-test`, + icon: 'indexPatternApp', + editUrl: '/objects/savedDashboards/dashboard-1', + inAppUrl: { + path: '/app/dashboards#/view/dashboard-1', + uiCapabilitiesPath: 'dashboard.show', + }, + }, + }; + const props = { + ...defaultProps, + availableWorkspaces: [{ id: 'ws-1', name: 'My workspace' } as WorkspaceAttribute], + items: [item], + }; + const component = shallowWithI18nProvider(); + + const table = component.find('EuiBasicTable'); + const columns = table.prop('columns') as any[]; + const content = columns[1].render('My-Dashboard-test', item); + expect(content.props.href).toEqual('/w/ws-1/app/dashboards#/view/dashboard-1'); + }); + it('should handle query parse error', () => { const onQueryChangeMock = jest.fn(); const customizedProps = { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx index 636933d449df..c948669596a8 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.tsx @@ -28,7 +28,7 @@ * under the License. */ -import { IBasePath } from 'src/core/public'; +import { IBasePath, WorkspaceAttribute } from 'src/core/public'; import React, { PureComponent, Fragment } from 'react'; import moment from 'moment'; import { @@ -46,6 +46,7 @@ import { EuiText, EuiTableFieldDataColumnType, EuiTableActionsColumnType, + EuiButtonIcon, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -56,12 +57,12 @@ import { SavedObjectsManagementAction, SavedObjectsManagementColumnServiceStart, } from '../../../services'; +import { WORKSPACE_PATH_PREFIX } from '../../../../../../core/public/utils'; export interface TableProps { basePath: IBasePath; actionRegistry: SavedObjectsManagementActionServiceStart; columnRegistry: SavedObjectsManagementColumnServiceStart; - namespaceRegistry: SavedObjectsManagementNamespaceServiceStart; selectedSavedObjects: SavedObjectWithMetadata[]; selectionConfig: { onSelectionChange: (selection: SavedObjectWithMetadata[]) => void; @@ -69,6 +70,8 @@ export interface TableProps { filters: any[]; canDelete: boolean; onDelete: () => void; + onDuplicateSelected: () => void; + onDuplicateSingle: (object: SavedObjectWithMetadata) => void; onActionRefresh: (object: SavedObjectWithMetadata) => void; onExport: (includeReferencesDeep: boolean) => void; goInspectObject: (obj: SavedObjectWithMetadata) => void; @@ -77,12 +80,14 @@ export interface TableProps { items: SavedObjectWithMetadata[]; itemId: string | (() => string); totalItemCount: number; - onQueryChange: (query: any, filterFields: string[]) => void; + onQueryChange: (query: any, filterFields?: string[]) => void; onTableChange: (table: any) => void; isSearching: boolean; onShowRelationships: (object: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; dateFormat: string; + availableWorkspaces?: WorkspaceAttribute[]; + showDuplicate: boolean; } interface TableState { @@ -167,6 +172,8 @@ export class Table extends PureComponent { filters, selectionConfig: selection, onDelete, + onDuplicateSelected, + onDuplicateSingle, onActionRefresh, selectedSavedObjects, onTableChange, @@ -175,10 +182,13 @@ export class Table extends PureComponent { basePath, actionRegistry, columnRegistry, - namespaceRegistry, dateFormat, + availableWorkspaces, + showDuplicate, } = this.props; + const visibleWsIds = availableWorkspaces?.map((ws) => ws.id) || []; + const pagination = { pageIndex, pageSize, @@ -226,13 +236,20 @@ export class Table extends PureComponent { sortable: false, 'data-test-subj': 'savedObjectsTableRowTitle', render: (title: string, object: SavedObjectWithMetadata) => { - const { path = '' } = object.meta.inAppUrl || {}; + let { path = '' } = object.meta.inAppUrl || {}; const canGoInApp = this.props.canGoInApp(object); if (!canGoInApp) { return {title || getDefaultTitle(object)}; } + if (object.workspaces) { + // first workspace login user have permission + const [workspaceId] = object.workspaces.filter((wsId) => visibleWsIds.includes(wsId)); + path = workspaceId ? `${WORKSPACE_PATH_PREFIX}/${workspaceId}${path}` : path; + } return ( - {title || getDefaultTitle(object)} + + {title || getDefaultTitle(object)} + ); }, } as EuiTableFieldDataColumnType>, @@ -281,7 +298,7 @@ export class Table extends PureComponent { { name: i18n.translate( 'savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionName', - { defaultMessage: 'Relationships' } + { defaultMessage: 'View object relationships' } ), description: i18n.translate( 'savedObjectsManagement.objectsTable.table.columnActions.viewRelationshipsActionDescription', @@ -295,6 +312,25 @@ export class Table extends PureComponent { onClick: (object) => onShowRelationships(object), 'data-test-subj': 'savedObjectsTableAction-relationships', }, + ...(showDuplicate + ? [ + { + name: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionName', + { defaultMessage: 'Duplicate' } + ), + description: i18n.translate( + 'savedObjectsManagement.objectsTable.table.columnActions.duplicateActionDescription', + { defaultMessage: 'Duplicate this saved object' } + ), + type: 'icon', + icon: 'copyClipboard', + isPrimary: true, + onClick: (object: SavedObjectWithMetadata) => onDuplicateSingle(object), + 'data-test-subj': 'savedObjectsTableAction-duplicate', + }, + ] + : []), ...actionRegistry.getAll().map((action) => { return { ...action.euiAction, @@ -351,6 +387,78 @@ export class Table extends PureComponent { const activeActionContents = this.state.activeAction?.render() ?? null; + const tools = [ + + + , + + + } + > + + } + checked={this.state.isIncludeReferencesDeepChecked} + onChange={this.toggleIsIncludeReferencesDeepChecked} + /> + + + + + + + , + ]; + + const duplicateButton = ( + + ); + + if (showDuplicate) { + tools.splice(1, 0, duplicateButton); + } + return ( {activeActionContents} @@ -358,63 +466,7 @@ export class Table extends PureComponent { box={{ 'data-test-subj': 'savedObjectSearchBar' }} filters={filters as any} onChange={this.onChange} - toolsRight={[ - - - , - - - } - > - - } - checked={this.state.isIncludeReferencesDeepChecked} - onChange={this.toggleIsIncludeReferencesDeepChecked} - /> - - - - - - - , - ]} + toolsRight={tools} /> {queryParseError} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts b/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts index b2153648057f..3270414b9e9f 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/index.ts @@ -29,3 +29,4 @@ */ export { SavedObjectsTable } from './saved_objects_table'; +export { showDuplicateModal, SavedObjectsDuplicateModal, DuplicateMode } from './components'; diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx index 5a6bf0713d95..feea9d65d93a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.test.tsx @@ -48,6 +48,7 @@ import { notificationServiceMock, savedObjectsServiceMock, applicationServiceMock, + workspacesServiceMock, } from '../../../../../core/public/mocks'; import { dataPluginMock } from '../../../../data/public/mocks'; import { serviceRegistryMock } from '../../services/service_registry.mock'; @@ -102,6 +103,7 @@ describe('SavedObjectsTable', () => { let notifications: ReturnType; let savedObjects: ReturnType; let search: ReturnType['search']; + let workspaces: ReturnType; const shallowRender = (overrides: Partial = {}) => { return (shallowWithI18nProvider( @@ -121,6 +123,7 @@ describe('SavedObjectsTable', () => { notifications = notificationServiceMock.createStartContract(); savedObjects = savedObjectsServiceMock.createStartContract(); search = dataPluginMock.createStartContract().search; + workspaces = workspacesServiceMock.createStartContract(); const applications = applicationServiceMock.createStartContract(); applications.capabilities = { @@ -132,6 +135,9 @@ describe('SavedObjectsTable', () => { edit: false, delete: false, }, + workspaces: { + enabled: false, + }, }; http.post.mockResolvedValue([]); @@ -154,6 +160,7 @@ describe('SavedObjectsTable', () => { savedObjectsClient: savedObjects.client, indexPatterns: dataPluginMock.createStartContract().indexPatterns, http, + workspaces, overlays, notifications, applications, @@ -172,9 +179,9 @@ describe('SavedObjectsTable', () => { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', + editUrl: '#/indexPatterns/patterns/1', inAppUrl: { - path: '/management/opensearch-dashboards/indexPatterns/patterns/1', + path: '/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -185,7 +192,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MySearch`, icon: 'search', - editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', + editUrl: '/objects/savedSearches/2', inAppUrl: { path: '/discover/2', uiCapabilitiesPath: 'discover.show', @@ -198,7 +205,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MyDashboard`, icon: 'dashboardApp', - editUrl: '/management/opensearch-dashboards/objects/savedDashboards/3', + editUrl: '/objects/savedDashboards/3', inAppUrl: { path: '/dashboard/3', uiCapabilitiesPath: 'dashboard.show', @@ -211,7 +218,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MyViz`, icon: 'visualizeApp', - editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/4', + editUrl: '/objects/savedVisualizations/4', inAppUrl: { path: '/edit/4', uiCapabilitiesPath: 'visualize.show', @@ -279,7 +286,7 @@ describe('SavedObjectsTable', () => { await component.instance().onExport(true); - expect(fetchExportObjectsMock).toHaveBeenCalledWith(http, mockSelectedSavedObjects, true); + expect(fetchExportObjectsMock).toHaveBeenCalledWith(http, mockSelectedSavedObjects, true, {}); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ title: 'Your file is downloading in the background', }); @@ -322,7 +329,7 @@ describe('SavedObjectsTable', () => { await component.instance().onExport(true); - expect(fetchExportObjectsMock).toHaveBeenCalledWith(http, mockSelectedSavedObjects, true); + expect(fetchExportObjectsMock).toHaveBeenCalledWith(http, mockSelectedSavedObjects, true, {}); expect(notifications.toasts.addWarning).toHaveBeenCalledWith({ title: 'Your file is downloading in the background. ' + @@ -363,7 +370,8 @@ describe('SavedObjectsTable', () => { http, allowedTypes, undefined, - true + true, + {} ); expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ @@ -393,7 +401,8 @@ describe('SavedObjectsTable', () => { http, allowedTypes, 'test*', - true + true, + {} ); expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ @@ -460,7 +469,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MySearch`, icon: 'search', - editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', + editUrl: '/objects/savedSearches/2', inAppUrl: { path: '/discover/2', uiCapabilitiesPath: 'discover.show', @@ -475,7 +484,7 @@ describe('SavedObjectsTable', () => { type: 'search', meta: { title: 'MySearch', - editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', + editUrl: '/objects/savedSearches/2', icon: 'search', inAppUrl: { path: '/discover/2', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 955482cc0676..7f437a3ad6cf 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -61,11 +61,14 @@ import { FormattedMessage } from '@osd/i18n/react'; import { SavedObjectsClientContract, SavedObjectsFindOptions, + WorkspacesStart, HttpStart, OverlayStart, NotificationsStart, ApplicationStart, + WorkspaceAttribute, } from 'src/core/public'; +import { Subscription } from 'rxjs'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; import { IndexPatternsContract } from '../../../../data/public'; import { @@ -81,6 +84,7 @@ import { findObject, extractExportDetails, SavedObjectsExportResultDetails, + duplicateSavedObjects, } from '../../lib'; import { SavedObjectWithMetadata } from '../../types'; import { @@ -89,14 +93,15 @@ import { SavedObjectsManagementColumnServiceStart, SavedObjectsManagementNamespaceServiceStart, } from '../../services'; -import { Header, Table, Flyout, Relationships } from './components'; -import { DataPublicPluginStart } from '../../../../../plugins/data/public'; +import { Header, Table, Flyout, Relationships, SavedObjectsDuplicateModal } from './components'; +import { DataPublicPluginStart } from '../../../../data/public'; +import { PUBLIC_WORKSPACE_ID } from '../../../../../core/public'; +import { DuplicateMode } from './'; interface ExportAllOption { id: string; label: string; } - export interface SavedObjectsTableProps { allowedTypes: string[]; serviceRegistry: ISavedObjectsManagementServiceRegistry; @@ -106,6 +111,7 @@ export interface SavedObjectsTableProps { savedObjectsClient: SavedObjectsClientContract; indexPatterns: IndexPatternsContract; http: HttpStart; + workspaces: WorkspacesStart; search: DataPublicPluginStart['search']; overlays: OverlayStart; notifications: NotificationsStart; @@ -114,6 +120,7 @@ export interface SavedObjectsTableProps { goInspectObject: (obj: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; dateFormat: string; + title: string; dataSourceEnabled: boolean; hideLocalCluster: boolean; } @@ -123,10 +130,13 @@ export interface SavedObjectsTableState { page: number; perPage: number; savedObjects: SavedObjectWithMetadata[]; - savedObjectCounts: Record; + savedObjectCounts: Record>; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; + duplicateSelectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; + duplicateMode: DuplicateMode; + isShowingDuplicateModal: boolean; isSearching: boolean; filteredItemCount: number; isShowingRelationships: boolean; @@ -137,26 +147,37 @@ export interface SavedObjectsTableState { exportAllOptions: ExportAllOption[]; exportAllSelectedOptions: Record; isIncludeReferencesDeepChecked: boolean; + currentWorkspaceId: string | null; + availableWorkspaces?: WorkspaceAttribute[]; + workspaceEnabled: boolean; } export class SavedObjectsTable extends Component { private _isMounted = false; + private currentWorkspaceIdSubscription?: Subscription; + private workspacesSubscription?: Subscription; + private workspacesEnabledSubscription?: Subscription; constructor(props: SavedObjectsTableProps) { super(props); + const typeCounts = props.allowedTypes.reduce((typeToCountMap, type) => { + typeToCountMap[type] = 0; + return typeToCountMap; + }, {} as Record); + this.state = { totalCount: 0, page: 0, perPage: props.perPageConfig || 50, savedObjects: [], - savedObjectCounts: props.allowedTypes.reduce((typeToCountMap, type) => { - typeToCountMap[type] = 0; - return typeToCountMap; - }, {} as Record), + savedObjectCounts: { type: typeCounts } as Record>, activeQuery: Query.parse(''), selectedSavedObjects: [], + duplicateSelectedSavedObjects: [], isShowingImportFlyout: false, + duplicateMode: DuplicateMode.Selected, + isShowingDuplicateModal: false, isSearching: false, filteredItemCount: 0, isShowingRelationships: false, @@ -167,11 +188,49 @@ export class SavedObjectsTable extends Component ws.id); + } else { + return [currentWorkspaceId]; + } + } + } + + private get wsNameIdLookup() { + const { availableWorkspaces } = this.state; + // Assumption: workspace name is unique across the system + return availableWorkspaces?.reduce((map, ws) => { + return map.set(ws.name, ws.id); + }, new Map()); + } + + private formatWorkspaceIdParams( + obj: T + ): T | Omit { + const { workspaces, ...others } = obj; + if (workspaces) { + return obj; + } + return others; + } + componentDidMount() { this._isMounted = true; + + this.fetchWorkspace(); this.fetchSavedObjects(); this.fetchCounts(); } @@ -179,25 +238,36 @@ export class SavedObjectsTable extends Component { const { allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleNamespaces } = parseQuery(this.state.activeQuery); + const { queryText, visibleTypes, visibleNamespaces, visibleWorkspaces } = parseQuery( + this.state.activeQuery + ); const filteredTypes = filterQuery(allowedTypes, visibleTypes); const availableNamespaces = namespaceRegistry.getAll()?.map((ns) => ns.id) || []; - const filteredCountOptions: SavedObjectCountOptions = { + const filteredCountOptions: SavedObjectCountOptions = this.formatWorkspaceIdParams({ typesToInclude: filteredTypes, searchString: queryText, - }; + workspaces: this.workspaceIdQuery, + }); if (availableNamespaces.length) { const filteredNamespaces = filterQuery(availableNamespaces, visibleNamespaces); filteredCountOptions.namespacesToInclude = filteredNamespaces; } + if (visibleWorkspaces?.length) { + filteredCountOptions.workspaces = visibleWorkspaces.map( + (wsName) => this.wsNameIdLookup?.get(wsName) || PUBLIC_WORKSPACE_ID + ); + } // These are the saved objects visible in the table. const filteredSavedObjectCounts = await getSavedObjectCounts( @@ -221,10 +291,11 @@ export class SavedObjectsTable extends Component { + const workspace = this.props.workspaces; + this.currentWorkspaceIdSubscription = workspace.currentWorkspaceId$.subscribe((workspaceId) => + this.setState({ + currentWorkspaceId: workspaceId, + }) + ); + + this.workspacesSubscription = workspace.workspaceList$.subscribe((workspaceList) => { + this.setState({ availableWorkspaces: workspaceList }); + }); + }; + fetchSavedObject = (type: string, id: string) => { this.setState({ isSearching: true }, () => this.debouncedFetchObject(type, id)); }; @@ -253,17 +337,18 @@ export class SavedObjectsTable extends Component { const { activeQuery: query, page, perPage } = this.state; const { notifications, http, allowedTypes, namespaceRegistry } = this.props; - const { queryText, visibleTypes, visibleNamespaces } = parseQuery(query); + const { queryText, visibleTypes, visibleNamespaces, visibleWorkspaces } = parseQuery(query); const filteredTypes = filterQuery(allowedTypes, visibleTypes); // "searchFields" is missing from the "findOptions" but gets injected via the API. // The API extracts the fields from each uiExports.savedObjectsManagement "defaultSearchField" attribute - const findOptions: SavedObjectsFindOptions = { + const findOptions: SavedObjectsFindOptions = this.formatWorkspaceIdParams({ search: queryText ? `${queryText}*` : undefined, perPage, page: page + 1, fields: ['id'], type: filteredTypes, - }; + workspaces: this.workspaceIdQuery, + }); const availableNamespaces = namespaceRegistry.getAll()?.map((ns) => ns.id) || []; if (availableNamespaces.length) { @@ -271,6 +356,13 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || PUBLIC_WORKSPACE_ID + ); + findOptions.workspaces = workspaceIds; + } + if (findOptions.type.length > 1) { findOptions.sortField = 'type'; } @@ -405,7 +497,14 @@ export class SavedObjectsTable extends Component { + this.setState({ isShowingDuplicateModal: true }); + }; + + hideDuplicateModal = () => { + this.setState({ isShowingDuplicateModal: false }); + }; + + renderDuplicateModal() { + const { workspaces, http, notifications } = this.props; + const { isShowingDuplicateModal, duplicateSelectedSavedObjects, duplicateMode } = this.state; + + if (!isShowingDuplicateModal) { + return null; + } + + const onDuplicate = async ( + savedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => { + const objectsToDuplicate = savedObjects.map((obj) => ({ id: obj.id, type: obj.type })); + let result; + try { + result = await duplicateSavedObjects( + http, + objectsToDuplicate, + includeReferencesDeep, + targetWorkspace + ); + if (result.success) { + notifications.toasts.addSuccess({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.successNotification', + { + defaultMessage: + 'Duplicate ' + savedObjects.length.toString() + ' saved objects successfully', + } + ), + }); + } else if (result.errors) { + const errorsIds = result.errors.map((item: { id: string }) => item.id); + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', + { + defaultMessage: + 'Unable to duplicate ' + + savedObjects.length.toString() + + ' saved objects. These objects cannot be duplicated:' + + errorsIds.join(','), + } + ), + }); + } else { + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', + { + defaultMessage: + 'Unable to duplicate ' + savedObjects.length.toString() + ' saved objects', + } + ), + }); + } + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate( + 'savedObjectsManagement.objectsTable.duplicate.dangerNotification', + { + defaultMessage: + 'Unable to duplicate ' + savedObjects.length.toString() + ' saved objects', + } + ), + }); + } + this.hideDuplicateModal(); + await this.refreshObjects(); + }; + + return ( + + ); + } + renderRelationships() { if (!this.state.isShowingRelationships) { return null; @@ -802,6 +996,9 @@ export class SavedObjectsTable extends Component { + return this.workspaceIdQuery?.includes(ws.id); + }) + .map((ws) => { + return { + name: ws.name, + value: ws.name, + view: `${ws.name} (${wsCounts[ws.id] || 0})`, + }; + }); + + filters.push({ + type: 'field_value_selection', + field: 'workspaces', + name: + namespaceRegistry.getAlias() || + i18n.translate('savedObjectsManagement.objectsTable.table.workspaceFilterName', { + defaultMessage: 'Workspaces', + }), + multiSelect: 'or', + options: wsFilterOptions, + }); + } + + // workspace enable and no workspace is selected + const hideImport = workspaceEnabled && !workspaceId; + return ( {this.renderFlyout()} {this.renderRelationships()} {this.renderDeleteConfirmModal()} {this.renderExportAllOptionsModal()} + {this.renderDuplicateModal()}
    this.setState({ isShowingExportAllOptionsModal: true })} onImport={this.showImportFlyout} + hideImport={hideImport} + showDuplicateAll={workspaceEnabled} + onDuplicate={() => + this.setState({ + duplicateSelectedSavedObjects: savedObjects, + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.All, + }) + } onRefresh={this.refreshObjects} filteredCount={filteredItemCount} + title={this.props.title} + objectCount={savedObjects.length} /> @@ -879,6 +1119,20 @@ export class SavedObjectsTable extends Component + this.setState({ + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.Selected, + duplicateSelectedSavedObjects: selectedSavedObjects, + }) + } + onDuplicateSingle={(object) => + this.setState({ + duplicateSelectedSavedObjects: [object], + isShowingDuplicateModal: true, + duplicateMode: DuplicateMode.Selected, + }) + } onActionRefresh={this.refreshObject} goInspectObject={this.props.goInspectObject} pageIndex={page} @@ -889,6 +1143,8 @@ export class SavedObjectsTable extends Component diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap new file mode 100644 index 000000000000..3c5257e2e8d1 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageWrapper should render normally 1`] = ` +
    +
    + Foo +
    +
    +`; diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts b/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts new file mode 100644 index 000000000000..3cf0cdd26c99 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx new file mode 100644 index 000000000000..550eb3ee1cae --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { PageWrapper } from './page_wrapper'; + +describe('PageWrapper', () => { + it('should render normally', async () => { + const { findByText, container } = render(Foo); + await findByText('Foo'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx new file mode 100644 index 000000000000..1b1949c334e4 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPageContent } from '@elastic/eui'; +import React from 'react'; + +export const PageWrapper = (props: { children?: React.ReactChild }) => { + return ( + + ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index b3ef976d8283..260445097391 100644 --- a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx +++ b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx @@ -30,13 +30,13 @@ import React, { useEffect } from 'react'; import { get } from 'lodash'; -import { i18n } from '@osd/i18n'; import { CoreStart, ChromeBreadcrumb } from 'src/core/public'; import { DataPublicPluginStart } from '../../../data/public'; import { ISavedObjectsManagementServiceRegistry, SavedObjectsManagementActionServiceStart, SavedObjectsManagementColumnServiceStart, + SavedObjectsManagementNamespaceServiceStart, } from '../services'; import { SavedObjectsTable } from './objects_table'; @@ -49,6 +49,7 @@ const SavedObjectsTablePage = ({ columnRegistry, namespaceRegistry, setBreadcrumbs, + title, dataSourceEnabled, hideLocalCluster, }: { @@ -60,6 +61,7 @@ const SavedObjectsTablePage = ({ columnRegistry: SavedObjectsManagementColumnServiceStart; namespaceRegistry: SavedObjectsManagementNamespaceServiceStart; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + title: string; dataSourceEnabled: boolean; hideLocalCluster: boolean; }) => { @@ -70,13 +72,11 @@ const SavedObjectsTablePage = ({ useEffect(() => { setBreadcrumbs([ { - text: i18n.translate('savedObjectsManagement.breadcrumb.index', { - defaultMessage: 'Saved objects', - }), - href: '/', + text: title, + href: undefined, }, ]); - }, [setBreadcrumbs]); + }, [setBreadcrumbs, title]); return ( diff --git a/src/plugins/saved_objects_management/public/plugin.test.ts b/src/plugins/saved_objects_management/public/plugin.test.ts index c8e762f73dcc..149cee7c5c86 100644 --- a/src/plugins/saved_objects_management/public/plugin.test.ts +++ b/src/plugins/saved_objects_management/public/plugin.test.ts @@ -28,12 +28,23 @@ * under the License. */ +const mountManagementSectionMock = jest.fn(); +jest.doMock('./management_section', () => ({ + mountManagementSection: mountManagementSectionMock, +})); +import { waitFor } from '@testing-library/dom'; import { coreMock } from '../../../core/public/mocks'; import { homePluginMock } from '../../home/public/mocks'; import { managementPluginMock } from '../../management/public/mocks'; import { dataPluginMock } from '../../data/public/mocks'; import { uiActionsPluginMock } from '../../ui_actions/public/mocks'; import { SavedObjectsManagementPlugin } from './plugin'; +import { + MANAGE_LIBRARY_TITLE_WORDINGS, + SAVED_QUERIES_WORDINGS, + SAVED_SEARCHES_WORDINGS, +} from './constants'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; describe('SavedObjectsManagementPlugin', () => { let plugin: SavedObjectsManagementPlugin; @@ -50,12 +61,22 @@ describe('SavedObjectsManagementPlugin', () => { const homeSetup = homePluginMock.createSetupContract(); const managementSetup = managementPluginMock.createSetupContract(); const uiActionsSetup = uiActionsPluginMock.createSetupContract(); + const registerMock = jest.fn((params) => params.mount({} as any, {} as any)); - await plugin.setup(coreSetup, { - home: homeSetup, - management: managementSetup, - uiActions: uiActionsSetup, - }); + await plugin.setup( + { + ...coreSetup, + application: { + ...coreSetup.application, + register: registerMock, + }, + }, + { + home: homeSetup, + management: managementSetup, + uiActions: uiActionsSetup, + } + ); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledTimes(1); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledWith( @@ -63,6 +84,38 @@ describe('SavedObjectsManagementPlugin', () => { id: 'saved_objects', }) ); + expect(registerMock).toBeCalledWith( + expect.objectContaining({ + id: 'objects', + title: MANAGE_LIBRARY_TITLE_WORDINGS, + order: 10000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }) + ); + expect(registerMock).toBeCalledWith( + expect.objectContaining({ + id: 'objects_searches', + title: SAVED_SEARCHES_WORDINGS, + order: 8000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }) + ); + expect(registerMock).toBeCalledWith( + expect.objectContaining({ + id: 'objects_query', + title: SAVED_QUERIES_WORDINGS, + order: 8001, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + }) + ); + waitFor( + () => { + expect(mountManagementSectionMock).toBeCalledTimes(3); + }, + { + container: document.body, + } + ); }); }); }); diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index d03342a4f1d7..ee4fe046240d 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -29,7 +29,7 @@ */ import { i18n } from '@osd/i18n'; -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { VisBuilderStart } from '../../vis_builder/public'; @@ -56,6 +56,12 @@ import { } from './services'; import { registerServices } from './register_services'; import { bootstrap } from './ui_actions_bootstrap'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { + MANAGE_LIBRARY_TITLE_WORDINGS, + SAVED_QUERIES_WORDINGS, + SAVED_SEARCHES_WORDINGS, +} from './constants'; export interface SavedObjectsManagementPluginSetup { actions: SavedObjectsManagementActionServiceSetup; @@ -100,9 +106,70 @@ export class SavedObjectsManagementPlugin private namespaceService = new SavedObjectsManagementNamespaceService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); + private registerLibrarySubApp( + coreSetup: CoreSetup, + dataSourceEnabled: boolean, + hideLocalCluster: boolean + ) { + const core = coreSetup; + const mountWrapper = ({ + title, + allowedObjectTypes, + }: { + title: string; + allowedObjectTypes?: string[]; + }) => async (appMountParams: AppMountParameters) => { + const { mountManagementSection } = await import('./management_section'); + return mountManagementSection({ + core, + serviceRegistry: this.serviceRegistry, + appMountParams, + title, + allowedObjectTypes, + dataSourceEnabled, + hideLocalCluster, + }); + }; + + /** + * Register saved objects overview & saved search & saved query here + */ + core.application.register({ + id: 'objects', + title: MANAGE_LIBRARY_TITLE_WORDINGS, + order: 10000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: MANAGE_LIBRARY_TITLE_WORDINGS, + }), + }); + + core.application.register({ + id: 'objects_searches', + title: SAVED_SEARCHES_WORDINGS, + order: 8000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: SAVED_SEARCHES_WORDINGS, + allowedObjectTypes: ['search'], + }), + }); + + core.application.register({ + id: 'objects_query', + title: SAVED_QUERIES_WORDINGS, + order: 8001, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: SAVED_QUERIES_WORDINGS, + allowedObjectTypes: ['query'], + }), + }); + } + public setup( core: CoreSetup, - { home, management, uiActions, dataSource }: SetupDependencies + { home, uiActions, dataSource }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); const columnSetup = this.columnService.setup(); @@ -119,37 +186,20 @@ export class SavedObjectsManagementPlugin 'Import, export, and manage your saved searches, visualizations, and dashboards.', }), icon: 'savedObjectsApp', - path: '/app/management/opensearch-dashboards/objects', + path: '/app/objects', showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); } - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - opensearchDashboardsSection.registerApp({ - id: 'objects', - title: i18n.translate('savedObjectsManagement.managementSectionLabel', { - defaultMessage: 'Saved objects', - }), - order: 1, - mount: async (mountParams) => { - const { mountManagementSection } = await import('./management_section'); - return mountManagementSection({ - core, - serviceRegistry: this.serviceRegistry, - mountParams, - dataSourceEnabled: !!dataSource, - hideLocalCluster: dataSource?.hideLocalCluster ?? false, - }); - }, - }); - // sets up the context mappings and registers any triggers/actions for the plugin bootstrap(uiActions); // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); + this.registerLibrarySubApp(core, !!dataSource, dataSource?.hideLocalCluster ?? false); + return { actions: actionSetup, columns: columnSetup, diff --git a/src/plugins/saved_objects_management/server/routes/find.ts b/src/plugins/saved_objects_management/server/routes/find.ts index dd49fc7575df..61211532e96c 100644 --- a/src/plugins/saved_objects_management/server/routes/find.ts +++ b/src/plugins/saved_objects_management/server/routes/find.ts @@ -64,6 +64,9 @@ export const registerFindRoute = ( fields: schema.oneOf([schema.string(), schema.arrayOf(schema.string())], { defaultValue: [], }), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }), }, }, @@ -94,6 +97,7 @@ export const registerFindRoute = ( ...req.query, fields: undefined, searchFields: [...searchFields], + workspaces: req.query.workspaces ? Array().concat(req.query.workspaces) : undefined, }); const savedObjects = await Promise.all( diff --git a/src/plugins/saved_objects_management/server/routes/scroll_count.ts b/src/plugins/saved_objects_management/server/routes/scroll_count.ts index 63233748a896..635ae41780f1 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_count.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_count.ts @@ -41,12 +41,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { typesToInclude: schema.arrayOf(schema.string()), namespacesToInclude: schema.maybe(schema.arrayOf(schema.string())), searchString: schema.maybe(schema.string()), + workspaces: schema.maybe(schema.arrayOf(schema.string())), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { client } = context.core.savedObjects; - const counts = { + const counts: Record> = { type: {}, }; @@ -58,11 +59,18 @@ export const registerScrollForCountRoute = (router: IRouter) => { const requestHasNamespaces = Array.isArray(req.body.namespacesToInclude) && req.body.namespacesToInclude.length; + const requestHasWorkspaces = Array.isArray(req.body.workspaces) && req.body.workspaces.length; + if (requestHasNamespaces) { counts.namespaces = {}; findOptions.namespaces = req.body.namespacesToInclude; } + if (requestHasWorkspaces) { + counts.workspaces = {}; + findOptions.workspaces = req.body.workspaces; + } + if (req.body.searchString) { findOptions.search = `${req.body.searchString}*`; findOptions.searchFields = ['title']; @@ -82,6 +90,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { counts.namespaces[ns]++; }); } + if (requestHasWorkspaces) { + const resultWorkspaces = result.workspaces || ['public']; + resultWorkspaces.forEach((ws) => { + counts.workspaces[ws] = counts.workspaces[ws] || 0; + counts.workspaces[ws]++; + }); + } counts.type[type] = counts.type[type] || 0; counts.type[type]++; }); @@ -99,6 +114,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { } } + const workspacesToInclude = req.body.workspaces || []; + for (const ws of workspacesToInclude) { + if (!counts.workspaces[ws]) { + counts.workspaces[ws] = 0; + } + } + return res.ok({ body: counts, }); diff --git a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap index 5d71bc774cff..1576310d60e9 100644 --- a/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap +++ b/src/plugins/telemetry_management_section/public/components/__snapshots__/telemetry_management_section.test.tsx.snap @@ -314,9 +314,11 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts index 52188d52998a..558649f900bd 100644 --- a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts +++ b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts @@ -15,9 +15,7 @@ export const augmentVisSavedObjectType: SavedObjectsType = { return `augment-vis-${obj?.attributes?.originPlugin}`; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/objects/savedAugmentVis/${encodeURIComponent( - obj.id - )}`; + return `/objects/savedAugmentVis/${encodeURIComponent(obj.id)}`; }, }, mappings: { diff --git a/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts b/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts index 029557010bee..2d329227491c 100644 --- a/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts +++ b/src/plugins/vis_builder/server/saved_objects/vis_builder_app.ts @@ -20,8 +20,7 @@ export const visBuilderSavedObjectType: SavedObjectsType = { defaultSearchField: 'title', importableAndExportable: true, getTitle: ({ attributes: { title } }: SavedObject) => title, - getEditUrl: ({ id }: SavedObject) => - `/management/opensearch-dashboards/objects/savedVisBuilder/${encodeURIComponent(id)}`, + getEditUrl: ({ id }: SavedObject) => `/objects/savedVisBuilder/${encodeURIComponent(id)}`, getInAppUrl({ id }: SavedObject) { return { path: `/app/${PLUGIN_ID}${EDIT_PATH}/${encodeURIComponent(id)}`, diff --git a/src/plugins/visualizations/server/saved_objects/visualization.ts b/src/plugins/visualizations/server/saved_objects/visualization.ts index 15a926b3f81d..4e46c83db157 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization.ts @@ -43,9 +43,7 @@ export const visualizationSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/objects/savedVisualizations/${encodeURIComponent( - obj.id - )}`; + return `/objects/savedVisualizations/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { diff --git a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx index d0a1755f275e..5f99b685bf5c 100644 --- a/src/plugins/visualize/public/application/components/visualize_top_nav.tsx +++ b/src/plugins/visualize/public/application/components/visualize_top_nav.tsx @@ -204,7 +204,7 @@ const TopNav = ({ /** * Most visualizations have all search bar components enabled. * Some visualizations have fewer options, but all visualizations have the search bar. - * That's is why the showSearchBar prop is set. + * That is why the showSearchBar prop is set. * All visualizations also have the timepicker\autorefresh component, * it is enabled by default in the TopNavMenu component. */ diff --git a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx index 562f96b872ba..8ca5e3a7f4b3 100644 --- a/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx +++ b/src/plugins/visualize/public/application/utils/get_top_nav_config.tsx @@ -30,26 +30,29 @@ import React from 'react'; import { i18n } from '@osd/i18n'; - import { TopNavMenuData } from 'src/plugins/navigation/public'; import { AppMountParameters } from 'opensearch-dashboards/public'; import { VISUALIZE_EMBEDDABLE_TYPE, VisualizeInput } from '../../../../visualizations/public'; import { - showSaveModal, + OnSaveProps, SavedObjectSaveModalOrigin, SavedObjectSaveOpts, - OnSaveProps, + showSaveModal, } from '../../../../saved_objects/public'; import { unhashUrl } from '../../../../opensearch_dashboards_utils/public'; - import { - VisualizeServices, VisualizeAppStateContainer, VisualizeEditorVisInstance, + VisualizeServices, } from '../types'; import { VisualizeConstants } from '../visualize_constants'; import { getEditBreadcrumbs } from './breadcrumbs'; import { EmbeddableStateTransfer } from '../../../../embeddable/public'; +import { + duplicateSavedObjects, + SavedObjectWithMetadata, +} from '../../../../saved_objects_management/public/'; +import { DuplicateMode, showDuplicateModal } from '../../../../saved_objects_management/public'; interface TopNavConfigParams { hasUnsavedChanges: boolean; @@ -91,10 +94,15 @@ export const getTopNavConfig = ( visualizeCapabilities, i18n: { Context: I18nContext }, dashboard, + http, + notifications, + workspaces, }: VisualizeServices ) => { + const workspaceEnabled = application.capabilities.workspaces.enabled; const { vis, embeddableHandler } = visInstance; const savedVis = 'savedVis' in visInstance ? visInstance.savedVis : undefined; + /** * Called when the user clicks "Save" button. */ @@ -245,6 +253,89 @@ export const getTopNavConfig = ( // disable the Share button if no action specified disableButton: !share || !!embeddableId, }, + ...(savedVis?.id && workspaceEnabled + ? [ + { + id: 'duplicate', + label: i18n.translate('visualize.topNavMenu.duplicateVisualizationButtonLabel', { + defaultMessage: 'duplicate', + }), + description: i18n.translate( + 'visualize.topNavMenu.duplicateVisualizationButtonAriaLabel', + { + defaultMessage: 'Duplicate Visualization', + } + ), + testId: 'visualizeDuplicateButton', + disableButton: hasUnappliedChanges, + tooltip() { + if (hasUnappliedChanges) { + return i18n.translate( + 'visualize.topNavMenu.duplicateVisualizationDisabledButtonTooltip', + { + defaultMessage: 'Apply or Discard your changes before duplicating', + } + ); + } + }, + run: (anchorElement: HTMLElement) => { + const onDuplicate = async ( + visualizationSavedObjects: SavedObjectWithMetadata[], + includeReferencesDeep: boolean, + targetWorkspace: string + ) => { + const objectsToDuplicate = visualizationSavedObjects.map((obj) => ({ + id: obj.id, + type: obj.type, + })); + + try { + await duplicateSavedObjects( + http, + objectsToDuplicate, + includeReferencesDeep, + targetWorkspace + ); + notifications.toasts.addSuccess({ + title: i18n.translate('visualize.topNavMenu.duplicate.successNotification', { + defaultMessage: 'Duplicate visualization successfully', + }), + }); + } catch (e) { + notifications.toasts.addDanger({ + title: i18n.translate('visualize.topNavMenu.duplicate.dangerNotification', { + defaultMessage: 'Unable to duplicate visualization', + }), + }); + } + }; + + const visualizationSavedObject = ({ + ...embeddableHandler, + ...savedVis, + } as unknown) as SavedObjectWithMetadata; + visualizationSavedObject.meta = { title: savedVis.title }; // meta is missing in savedVis + + const showDuplicateModalProps = { + http, + workspaces, + onDuplicate, + notifications, + duplicateMode: DuplicateMode.Selected, + selectedSavedObjects: [visualizationSavedObject], + }; + + onAppLeave((actions) => { + return actions.default(); + }); + + if (savedVis) { + showDuplicateModal(showDuplicateModalProps, I18nContext); + } + }, + }, + ] + : []), ...(originatingApp === 'dashboards' || originatingApp === 'canvas' ? [ { diff --git a/src/plugins/visualize/public/plugin.ts b/src/plugins/visualize/public/plugin.ts index c146efef1fab..47a38d63986d 100644 --- a/src/plugins/visualize/public/plugin.ts +++ b/src/plugins/visualize/public/plugin.ts @@ -41,7 +41,6 @@ import { PluginInitializerContext, ScopedHistory, } from 'opensearch-dashboards/public'; - import { Storage, createOsdUrlTracker, diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index b6bd7b00f676..426055588905 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,4 +3,21 @@ * SPDX-License-Identifier: Apache-2.0 */ +export const WORKSPACE_CREATE_APP_ID = 'workspace_create'; +export const WORKSPACE_LIST_APP_ID = 'workspace_list'; +export const WORKSPACE_UPDATE_APP_ID = 'workspace_update'; +export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; +// These features will be checked and disabled in checkbox on default. +export const DEFAULT_CHECKED_FEATURES_IDS = [WORKSPACE_UPDATE_APP_ID, WORKSPACE_OVERVIEW_APP_ID]; +export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; +export const PATHS = { + create: '/create', + overview: '/overview', + update: '/update', + list: '/list', +}; +export const WORKSPACE_OP_TYPE_CREATE = 'create'; +export const WORKSPACE_OP_TYPE_UPDATE = 'update'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; +export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = + 'workspace_conflict_control'; diff --git a/src/plugins/workspace/config.ts b/src/plugins/workspace/config.ts index 79412f5c02ee..9c40a690fdb7 100644 --- a/src/plugins/workspace/config.ts +++ b/src/plugins/workspace/config.ts @@ -7,6 +7,9 @@ import { schema, TypeOf } from '@osd/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), + permission: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), }); export type ConfigSchema = TypeOf; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 40a7eb5c3f9f..cf23e0307808 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -2,10 +2,8 @@ "id": "workspace", "version": "opensearchDashboards", "server": true, - "ui": false, - "requiredPlugins": [ - "savedObjects" - ], - "optionalPlugins": [], - "requiredBundles": [] + "ui": true, + "requiredPlugins": ["savedObjects"], + "optionalPlugins": ["savedObjectsManagement", "indexPatternManagement"], + "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx new file mode 100644 index 000000000000..db83d8eeaacf --- /dev/null +++ b/src/plugins/workspace/public/application.tsx @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, ScopedHistory } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +import { WorkspaceListApp } from './components/workspace_list_app'; +import { WorkspaceCreatorApp } from './components/workspace_creator_app'; +import { WorkspaceUpdaterApp } from './components/workspace_updater_app'; +import { WorkspaceOverviewApp } from './components/workspace_overview_app'; +import { WorkspaceFatalError } from './components/workspace_fatal_error'; +import { Services } from './types'; + +export const renderListApp = ( + { element, history, appBasePath }: AppMountParameters, + services: Services +) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; +export const renderCreatorApp = ( + { element, history, appBasePath }: AppMountParameters, + services: Services +) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + +export const renderUpdateApp = ( + { element, history, appBasePath }: AppMountParameters, + services: Services +) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + +export const renderOverviewApp = ( + { element, history, appBasePath }: AppMountParameters, + services: Services +) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + +export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { + const { element } = params; + const history = params.history as ScopedHistory<{ error?: string }>; + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx new file mode 100644 index 000000000000..da76b82f4c8c --- /dev/null +++ b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiButton, + EuiButtonEmpty, + EuiFieldText, + EuiModal, + EuiModalBody, + EuiModalFooter, + EuiModalHeader, + EuiModalHeaderTitle, + EuiSpacer, + EuiText, +} from '@elastic/eui'; + +interface DeleteWorkspaceModalProps { + selectedItems: string[]; + onClose: () => void; + onConfirm: () => void; +} + +export function DeleteWorkspaceModal(props: DeleteWorkspaceModalProps) { + const [value, setValue] = useState(''); + const { onClose, onConfirm, selectedItems } = props; + + return ( + + + Delete workspace + + + +
    +

    The following workspace will be permanently deleted. This action cannot be undone.

    +
      + {selectedItems.map((item) => ( +
    • {item}
    • + ))} +
    + + + To confirm your action, type delete. + + setValue(e.target.value)} + /> +
    +
    + + + Cancel + + Delete + + +
    + ); +} diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/index.ts b/src/plugins/workspace/public/components/delete_workspace_modal/index.ts new file mode 100644 index 000000000000..3466e180c54a --- /dev/null +++ b/src/plugins/workspace/public/components/delete_workspace_modal/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export * from './delete_workspace_modal'; diff --git a/src/plugins/workspace/public/components/index.ts b/src/plugins/workspace/public/components/index.ts new file mode 100644 index 000000000000..0facc9de43d4 --- /dev/null +++ b/src/plugins/workspace/public/components/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspacePermissionSetting } from './workspace_creator'; diff --git a/src/plugins/workspace/public/components/routes.ts b/src/plugins/workspace/public/components/routes.ts new file mode 100644 index 000000000000..9c2d568db021 --- /dev/null +++ b/src/plugins/workspace/public/components/routes.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PATHS } from '../../common/constants'; + +import { WorkspaceCreator } from './workspace_creator'; +import { WorkspaceUpdater } from './workspace_updater'; +import { WorkspaceOverview } from './workspace_overview'; +import { WorkspaceList } from './workspace_list'; + +export interface RouteConfig { + path: string; + Component: React.ComponentType; + label: string; + exact?: boolean; +} + +export const ROUTES: RouteConfig[] = [ + { + path: PATHS.create, + Component: WorkspaceCreator, + label: 'Create', + }, + { + path: PATHS.overview, + Component: WorkspaceOverview, + label: 'Overview', + }, + { + path: PATHS.update, + Component: WorkspaceUpdater, + label: 'Update', + }, + { + path: PATHS.list, + Component: WorkspaceList, + label: 'List', + }, +]; diff --git a/src/plugins/workspace/public/components/utils/common.ts b/src/plugins/workspace/public/components/utils/common.ts new file mode 100644 index 000000000000..cadd938e36d9 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/common.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const debounce = (func: Function, delay: number) => { + let timerId: NodeJS.Timeout; + + return (...args: any) => { + if (!timerId) { + func(...args); + } + clearTimeout(timerId); + + timerId = setTimeout(() => func(...args), delay); + }; +}; diff --git a/src/plugins/workspace/public/components/utils/feature.test.ts b/src/plugins/workspace/public/components/utils/feature.test.ts new file mode 100644 index 000000000000..87554ef54ecb --- /dev/null +++ b/src/plugins/workspace/public/components/utils/feature.test.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + isFeatureDependBySelectedFeatures, + getFinalFeatureIdsByDependency, + generateFeatureDependencyMap, +} from './feature'; + +describe('feature utils', () => { + describe('isFeatureDependBySelectedFeatures', () => { + it('should return true', () => { + expect(isFeatureDependBySelectedFeatures('a', ['b'], { b: ['a'] })).toBe(true); + expect(isFeatureDependBySelectedFeatures('a', ['b'], { b: ['a', 'c'] })).toBe(true); + }); + it('should return false', () => { + expect(isFeatureDependBySelectedFeatures('a', ['b'], { b: ['c'] })).toBe(false); + expect(isFeatureDependBySelectedFeatures('a', ['b'], {})).toBe(false); + }); + }); + + describe('getFinalFeatureIdsByDependency', () => { + it('should return consistent feature ids', () => { + expect(getFinalFeatureIdsByDependency(['a'], { a: ['b'] }, ['c', 'd'])).toStrictEqual([ + 'c', + 'd', + 'a', + 'b', + ]); + expect(getFinalFeatureIdsByDependency(['a'], { a: ['b', 'e'] }, ['c', 'd'])).toStrictEqual([ + 'c', + 'd', + 'a', + 'b', + 'e', + ]); + }); + }); + + it('should generate consistent features dependency map', () => { + expect( + generateFeatureDependencyMap([ + { id: 'a', dependencies: { b: { type: 'required' }, c: { type: 'optional' } } }, + { id: 'b', dependencies: { c: { type: 'required' } } }, + ]) + ).toEqual({ + a: ['b'], + b: ['c'], + }); + }); +}); diff --git a/src/plugins/workspace/public/components/utils/feature.ts b/src/plugins/workspace/public/components/utils/feature.ts new file mode 100644 index 000000000000..3da6027e83d3 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/feature.ts @@ -0,0 +1,60 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { App } from '../../../../../core/public'; + +export const isFeatureDependBySelectedFeatures = ( + featureId: string, + selectedFeatureIds: string[], + featureDependencies: { [key: string]: string[] } +) => + selectedFeatureIds.some((selectedFeatureId) => + (featureDependencies[selectedFeatureId] || []).some((dependencies) => + dependencies.includes(featureId) + ) + ); + +/** + * + * Generate new feature id list based the old feature id list + * and feature dependencies map. The feature dependency map may + * has duplicate ids with old feature id list. Use set here to + * get the unique feature ids. + * + * @param featureIds a feature id list need to add based old feature id list + * @param featureDependencies a feature dependencies map to get depended feature ids + * @param oldFeatureIds a feature id list that represent current feature id selection states + */ +export const getFinalFeatureIdsByDependency = ( + featureIds: string[], + featureDependencies: { [key: string]: string[] }, + oldFeatureIds: string[] = [] +) => + Array.from( + new Set([ + ...oldFeatureIds, + ...featureIds.reduce( + (pValue, featureId) => [...pValue, ...(featureDependencies[featureId] || [])], + featureIds + ), + ]) + ); + +export const generateFeatureDependencyMap = ( + allFeatures: Array> +) => + allFeatures.reduce<{ [key: string]: string[] }>( + (pValue, { id, dependencies }) => + dependencies + ? { + ...pValue, + [id]: [ + ...(pValue[id] || []), + ...Object.keys(dependencies).filter((key) => dependencies[key].type === 'required'), + ], + } + : pValue, + {} + ); diff --git a/src/plugins/workspace/public/components/utils/path.test.ts b/src/plugins/workspace/public/components/utils/path.test.ts new file mode 100644 index 000000000000..d8bdf361d723 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/path.test.ts @@ -0,0 +1,18 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { join } from './path'; + +describe('path utils', () => { + it('should join paths', () => { + expect(join('/', '/')).toBe('/'); + expect(join('/', '/foo')).toBe('/foo'); + expect(join('foo', '/bar')).toBe('foo/bar'); + expect(join('foo', 'bar')).toBe('foo/bar'); + expect(join('foo', 'bar/baz')).toBe('foo/bar/baz'); + expect(join('/foo', 'bar/baz')).toBe('/foo/bar/baz'); + expect(join('/foo/', 'bar/baz')).toBe('/foo/bar/baz'); + }); +}); diff --git a/src/plugins/workspace/public/components/utils/path.ts b/src/plugins/workspace/public/components/utils/path.ts new file mode 100644 index 000000000000..1086a84b6d05 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/path.ts @@ -0,0 +1,17 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export function join(base: string, ...paths: string[]) { + const normalized = [base] + .concat(...paths) + .join('/') + .split('/') + .filter(Boolean) + .join('/'); + if (base.startsWith('/')) { + return `/${normalized}`; + } + return normalized; +} diff --git a/src/plugins/workspace/public/components/utils/workspace.ts b/src/plugins/workspace/public/components/utils/workspace.ts new file mode 100644 index 000000000000..a0b49c520b01 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_UPDATE_APP_ID } from '../../../common/constants'; +import { CoreStart } from '../../../../../core/public'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; + +type Core = Pick; + +export const switchWorkspace = ({ application, http }: Core, id: string) => { + const newUrl = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + id, + http.basePath + ); + if (newUrl) { + window.location.href = newUrl; + } +}; + +export const updateWorkspace = ({ application, http }: Core, id: string) => { + const newUrl = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_UPDATE_APP_ID, { + absolute: true, + }), + id, + http.basePath + ); + if (newUrl) { + window.location.href = newUrl; + } +}; diff --git a/src/plugins/workspace/public/components/utils/workspace_column.tsx b/src/plugins/workspace/public/components/utils/workspace_column.tsx new file mode 100644 index 000000000000..0d1899959cfe --- /dev/null +++ b/src/plugins/workspace/public/components/utils/workspace_column.tsx @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiText } from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { i18n } from '@osd/i18n'; +import { WorkspaceAttribute, CoreSetup } from '../../../../../core/public'; +import { + SavedObjectsManagementColumn, + SavedObjectsManagementRecord, +} from '../../../../saved_objects_management/public'; + +interface WorkspaceColumnProps { + coreSetup: CoreSetup; + workspaces?: string[]; + record: SavedObjectsManagementRecord; +} + +function WorkspaceColumn({ coreSetup, workspaces, record }: WorkspaceColumnProps) { + const workspaceList = useObservable(coreSetup.workspaces.workspaceList$); + + const wsLookUp = workspaceList?.reduce((map, ws) => { + return map.set(ws.id, ws.name); + }, new Map()); + + const workspaceNames = workspaces?.map((wsId) => wsLookUp?.get(wsId)).join(' | '); + + return {workspaceNames}; +} + +export function getWorkspaceColumn( + coreSetup: CoreSetup +): SavedObjectsManagementColumn { + return { + id: 'workspace_column', + euiColumn: { + align: 'left', + field: 'workspaces', + name: i18n.translate('savedObjectsManagement.objectsTable.table.columnWorkspacesName', { + defaultMessage: 'Workspaces', + }), + render: (workspaces: string[], record: SavedObjectsManagementRecord) => { + return ; + }, + }, + loadData: () => { + return Promise.resolve(undefined); + }, + }; +} diff --git a/src/plugins/workspace/public/components/workspace_creator/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx new file mode 100644 index 000000000000..83d87debad6e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceCreator } from './workspace_creator'; +export { WorkspacePermissionSetting } from './workspace_permission_setting_panel'; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_bottom_bar.tsx new file mode 100644 index 000000000000..79f1f92c8685 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_bottom_bar.tsx @@ -0,0 +1,105 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBottomBar, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { WORKSPACE_OP_TYPE_CREATE, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; +import { WorkspaceCancelModal } from './workspace_cancel_modal'; + +interface WorkspaceBottomBarProps { + formId: string; + opType?: string; + numberOfErrors: number; + application: ApplicationStart; +} + +// Number of saved changes will be implemented in workspace update page PR +export const WorkspaceBottomBar = ({ + formId, + opType, + numberOfErrors, + application, +}: WorkspaceBottomBarProps) => { + const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); + const closeCancelModal = () => setIsCancelModalVisible(false); + const showCancelModal = () => setIsCancelModalVisible(true); + + return ( +
    + + + + + + + {opType === WORKSPACE_OP_TYPE_UPDATE ? ( + + {i18n.translate('workspace.form.bottomBar.unsavedChanges', { + defaultMessage: '1 Unsaved change(s)', + })} + + ) : ( + + {i18n.translate('workspace.form.bottomBar.errors', { + defaultMessage: `${numberOfErrors} Error(s)`, + })} + + )} + + + + + + {i18n.translate('workspace.form.bottomBar.cancel', { + defaultMessage: 'Cancel', + })} + + + {opType === WORKSPACE_OP_TYPE_CREATE && ( + + {i18n.translate('workspace.form.bottomBar.createWorkspace', { + defaultMessage: 'Create workspace', + })} + + )} + {opType === WORKSPACE_OP_TYPE_UPDATE && ( + + {i18n.translate('workspace.form.bottomBar.saveChanges', { + defaultMessage: 'Save changes', + })} + + )} + + + + + +
    + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_cancel_modal.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_cancel_modal.tsx new file mode 100644 index 000000000000..040e46f9ddfc --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_cancel_modal.tsx @@ -0,0 +1,49 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { i18n } from '@osd/i18n'; +import { EuiConfirmModal } from '@elastic/eui'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { WORKSPACE_LIST_APP_ID } from '../../../common/constants'; + +interface WorkspaceCancelModalProps { + visible: boolean; + application: ApplicationStart; + closeCancelModal: () => void; +} + +export const WorkspaceCancelModal = ({ + application, + visible, + closeCancelModal, +}: WorkspaceCancelModalProps) => { + if (!visible) { + return null; + } + + return ( + application?.navigateToApp(WORKSPACE_LIST_APP_ID)} + cancelButtonText={i18n.translate('workspace.form.cancelButtonText.', { + defaultMessage: 'Continue editing', + })} + confirmButtonText={i18n.translate('workspace.form.confirmButtonText.', { + defaultMessage: 'Discard changes', + })} + buttonColor="danger" + defaultFocusedButton="confirm" + > + {i18n.translate('workspace.form.cancelModal.body', { + defaultMessage: 'This will discard all changes. Are you sure?', + })} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx new file mode 100644 index 000000000000..9ba73b8e8b32 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -0,0 +1,244 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { PublicAppInfo } from 'opensearch-dashboards/public'; +import { fireEvent, render, waitFor } from '@testing-library/react'; +import { BehaviorSubject } from 'rxjs'; +import { WorkspaceCreator as WorkspaceCreatorComponent } from './workspace_creator'; +import { coreMock } from '../../../../../core/public/mocks'; +import { createOpenSearchDashboardsReactContext } from '../../../../opensearch_dashboards_react/public'; + +const workspaceClientCreate = jest + .fn() + .mockReturnValue({ result: { id: 'successResult' }, success: true }); + +const navigateToApp = jest.fn(); +const notificationToastsAddSuccess = jest.fn(); +const notificationToastsAddDanger = jest.fn(); +const PublicAPPInfoMap = new Map([ + ['app1', { id: 'app1', title: 'app1' }], + ['app2', { id: 'app2', title: 'app2', category: { id: 'category1', label: 'category1' } }], + ['app3', { id: 'app3', category: { id: 'category1', label: 'category1' } }], + ['app4', { id: 'app4', category: { id: 'category2', label: 'category2' } }], + ['app5', { id: 'app5', category: { id: 'category2', label: 'category2' } }], +]); + +const mockCoreStart = coreMock.createStart(); + +const WorkspaceCreator = (props: any) => { + const { Provider } = createOpenSearchDashboardsReactContext({ + ...mockCoreStart, + ...{ + application: { + ...mockCoreStart.application, + capabilities: { + ...mockCoreStart.application.capabilities, + workspaces: { + permissionEnabled: true, + }, + }, + navigateToApp, + getUrlForApp: jest.fn(), + applications$: new BehaviorSubject>(PublicAPPInfoMap as any), + }, + http: { + ...mockCoreStart.http, + basePath: { + ...mockCoreStart.http.basePath, + remove: jest.fn(), + prepend: jest.fn(), + }, + }, + notifications: { + ...mockCoreStart.notifications, + toasts: { + ...mockCoreStart.notifications.toasts, + addDanger: notificationToastsAddDanger, + addSuccess: notificationToastsAddSuccess, + }, + }, + workspaceClient: { + ...mockCoreStart.workspaces, + create: workspaceClientCreate, + }, + }, + }); + + return ( + + + + ); +}; + +function clearMockedFunctions() { + workspaceClientCreate.mockClear(); + notificationToastsAddDanger.mockClear(); + notificationToastsAddSuccess.mockClear(); +} + +describe('WorkspaceCreator', () => { + beforeEach(() => clearMockedFunctions()); + const { location } = window; + const setHrefSpy = jest.fn((href) => href); + + beforeAll(() => { + if (window.location) { + // @ts-ignore + delete window.location; + } + window.location = {} as Location; + Object.defineProperty(window.location, 'href', { + get: () => 'http://localhost/', + set: setHrefSpy, + }); + }); + + afterAll(() => { + window.location = location; + }); + + it('cannot create workspace when name empty', async () => { + const { getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('cannot create workspace with invalid name', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: '~' }, + }); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('cannot create workspace with invalid description', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); + fireEvent.input(descriptionInput, { + target: { value: '~' }, + }); + expect(workspaceClientCreate).not.toHaveBeenCalled(); + }); + + it('cancel create workspace', async () => { + const { findByText, getByTestId } = render(); + fireEvent.click(getByTestId('workspaceForm-bottomBar-cancelButton')); + await findByText('Discard changes?'); + fireEvent.click(getByTestId('confirmModalConfirmButton')); + expect(navigateToApp).toHaveBeenCalled(); + }); + + it('create workspace with detailed information', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + const descriptionInput = getByTestId('workspaceForm-workspaceDetails-descriptionInputText'); + fireEvent.input(descriptionInput, { + target: { value: 'test workspace description' }, + }); + const colorSelector = getByTestId( + 'euiColorPickerAnchor workspaceForm-workspaceDetails-colorPicker' + ); + fireEvent.input(colorSelector, { + target: { value: '#000000' }, + }); + const iconSelector = getByTestId('workspaceForm-workspaceDetails-iconSelector'); + fireEvent.click(iconSelector); + fireEvent.click(getByTestId('workspaceForm-workspaceDetails-iconSelector-Glasses')); + const defaultVISThemeSelector = getByTestId( + 'workspaceForm-workspaceDetails-defaultVISThemeSelector' + ); + fireEvent.click(defaultVISThemeSelector); + fireEvent.change(defaultVISThemeSelector, { target: { value: 'categorical' } }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + icon: 'Glasses', + color: '#000000', + description: 'test workspace description', + defaultVISTheme: 'categorical', + }), + expect.any(Array) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('create workspace with customized features', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-app1')); + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-category1')); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + features: expect.arrayContaining(['app1', 'app2', 'app3']), + }), + expect.any(Array) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('create workspace with customized permissions', async () => { + const { getByTestId, getByText } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByText('Users & Permissions')); + fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); + const userIdInput = getByTestId('workspaceForm-permissionSettingPanel-0-userId'); + fireEvent.click(userIdInput); + fireEvent.input(getByTestId('comboBoxSearchInput'), { + target: { value: 'test user id' }, + }); + fireEvent.blur(getByTestId('comboBoxSearchInput')); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + }), + expect.arrayContaining([expect.objectContaining({ type: 'user', userId: 'test user id' })]) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('should show danger toasts after create workspace failed', async () => { + workspaceClientCreate.mockReturnValue({ result: { id: 'failResult' }, success: false }); + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalled(); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx new file mode 100644 index 000000000000..22663aad1dc0 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -0,0 +1,89 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceForm, WorkspaceFormSubmitData } from './workspace_form'; +import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_CREATE } from '../../../common/constants'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { WorkspaceClient } from '../../workspace_client'; + +export const WorkspaceCreator = () => { + const { + services: { application, notifications, http, workspaceClient }, + } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); + + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormSubmitData) => { + let result; + try { + const { permissions, ...attributes } = data; + result = await workspaceClient.create(attributes, permissions); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.create.success', { + defaultMessage: 'Create workspace successfully', + }), + }); + if (application && http) { + window.location.href = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + result.result.id, + http.basePath + ); + } + return; + } + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: result?.error, + }); + }, + [notifications?.toasts, http, application, workspaceClient] + ); + + return ( + + + + + + {application && ( + + )} + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx new file mode 100644 index 000000000000..b2ff40855a9d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -0,0 +1,726 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useState, FormEventHandler, useRef, useMemo, useEffect } from 'react'; +import { groupBy } from 'lodash'; +import { + EuiPanel, + EuiSpacer, + EuiTitle, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiSelect, + EuiText, + EuiFlexItem, + htmlIdGenerator, + EuiCheckbox, + EuiCheckboxGroup, + EuiCheckboxGroupProps, + EuiCheckboxProps, + EuiFieldTextProps, + EuiColorPicker, + EuiColorPickerProps, + EuiHorizontalRule, + EuiFlexGroup, + EuiTab, + EuiTabs, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { + App, + AppNavLinkStatus, + ApplicationStart, + DEFAULT_APP_CATEGORIES, + MANAGEMENT_WORKSPACE_ID, +} from '../../../../../core/public'; +import { useApplications } from '../../hooks'; +import { DEFAULT_CHECKED_FEATURES_IDS } from '../../../common/constants'; +import { + isFeatureDependBySelectedFeatures, + getFinalFeatureIdsByDependency, + generateFeatureDependencyMap, +} from '../utils/feature'; +import { WorkspaceBottomBar } from './workspace_bottom_bar'; +import { WorkspaceIconSelector } from './workspace_icon_selector'; +import { + WorkspacePermissionSetting, + WorkspacePermissionItemType, + WorkspacePermissionSettingPanel, +} from './workspace_permission_setting_panel'; +import { featureMatchesConfig } from '../../utils'; + +enum WorkspaceFormTabs { + NotSelected, + FeatureVisibility, + UsersAndPermissions, +} + +interface WorkspaceFeature extends Pick { + id: string; + name: string; +} + +interface WorkspaceFeatureGroup { + name: string; + features: WorkspaceFeature[]; +} + +export interface WorkspaceFormSubmitData { + name: string; + description?: string; + features?: string[]; + color?: string; + icon?: string; + defaultVISTheme?: string; + permissions: WorkspacePermissionSetting[]; +} + +export interface WorkspaceFormData extends WorkspaceFormSubmitData { + id: string; + reserved?: boolean; +} + +type WorkspaceFormErrors = Omit<{ [key in keyof WorkspaceFormData]?: string }, 'permissions'> & { + permissions?: string[]; +}; + +const isWorkspaceFeatureGroup = ( + featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup +): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; + +const isValidWorkspacePermissionSetting = ( + setting: Partial +): setting is WorkspacePermissionSetting => + !!setting.modes && + setting.modes.length > 0 && + ((setting.type === WorkspacePermissionItemType.User && !!setting.userId) || + (setting.type === WorkspacePermissionItemType.Group && !!setting.group)); + +const isDefaultCheckedFeatureId = (id: string) => { + return DEFAULT_CHECKED_FEATURES_IDS.indexOf(id) > -1; +}; + +const appendDefaultFeatureIds = (ids: string[]) => { + // concat default checked ids and unique the result + return Array.from(new Set(ids.concat(DEFAULT_CHECKED_FEATURES_IDS))); +}; + +const isValidNameOrDescription = (input?: string) => { + if (!input) { + return true; + } + const regex = /^[0-9a-zA-Z()_\[\]\-\s]+$/; + return regex.test(input); +}; + +const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { + let numberOfErrors = 0; + if (formErrors.name) { + numberOfErrors += 1; + } + if (formErrors.description) { + numberOfErrors += 1; + } + if (formErrors.permissions) { + numberOfErrors += formErrors.permissions.length; + } + return numberOfErrors; +}; + +const isUserOrGroupPermissionSettingDuplicated = ( + permissionSettings: Array>, + permissionSettingToCheck: WorkspacePermissionSetting +) => + permissionSettings.some( + (permissionSetting) => + (permissionSettingToCheck.type === WorkspacePermissionItemType.User && + permissionSetting.type === WorkspacePermissionItemType.User && + permissionSettingToCheck.userId === permissionSetting.userId) || + (permissionSettingToCheck.type === WorkspacePermissionItemType.Group && + permissionSetting.type === WorkspacePermissionItemType.Group && + permissionSettingToCheck.group === permissionSetting.group) + ); + +const workspaceHtmlIdGenerator = htmlIdGenerator(); + +const defaultVISThemeOptions = [{ value: 'categorical', text: 'Categorical' }]; + +interface WorkspaceFormProps { + application: ApplicationStart; + onSubmit?: (formData: WorkspaceFormSubmitData) => void; + defaultValues?: WorkspaceFormData; + opType?: string; + permissionEnabled?: boolean; + permissionLastAdminItemDeletable?: boolean; +} + +export const WorkspaceForm = ({ + application, + onSubmit, + defaultValues, + opType, + permissionEnabled, + permissionLastAdminItemDeletable, +}: WorkspaceFormProps) => { + const applications = useApplications(application); + const workspaceNameReadOnly = defaultValues?.reserved; + const [name, setName] = useState(defaultValues?.name); + const [description, setDescription] = useState(defaultValues?.description); + const [color, setColor] = useState(defaultValues?.color); + const [icon, setIcon] = useState(defaultValues?.icon); + const [defaultVISTheme, setDefaultVISTheme] = useState(defaultValues?.defaultVISTheme); + const isEditingManagementWorkspace = defaultValues?.id === MANAGEMENT_WORKSPACE_ID; + + // feature visibility section will be hidden in management workspace + // permission section will be hidden when permission is not enabled + const [selectedTab, setSelectedTab] = useState( + !isEditingManagementWorkspace + ? WorkspaceFormTabs.FeatureVisibility + : permissionEnabled + ? WorkspaceFormTabs.UsersAndPermissions + : WorkspaceFormTabs.NotSelected + ); + const [numberOfErrors, setNumberOfErrors] = useState(0); + // The matched feature id list based on original feature config, + // the feature category will be expanded to list of feature ids + const defaultFeatures = useMemo(() => { + // The original feature list, may contain feature id and category wildcard like @management, etc. + const defaultOriginalFeatures = defaultValues?.features ?? []; + return applications.filter(featureMatchesConfig(defaultOriginalFeatures)).map((app) => app.id); + }, [defaultValues?.features, applications]); + + const defaultFeaturesRef = useRef(defaultFeatures); + defaultFeaturesRef.current = defaultFeatures; + + useEffect(() => { + // When applications changed, reset form feature selection to original value + setSelectedFeatureIds(appendDefaultFeatureIds(defaultFeaturesRef.current)); + }, [applications]); + + const [selectedFeatureIds, setSelectedFeatureIds] = useState( + appendDefaultFeatureIds(defaultFeatures) + ); + const [permissionSettings, setPermissionSettings] = useState< + Array> + >( + defaultValues?.permissions && defaultValues.permissions.length > 0 + ? defaultValues.permissions + : [] + ); + + const [formErrors, setFormErrors] = useState({}); + const formIdRef = useRef(); + const getFormData = () => ({ + name, + description, + features: selectedFeatureIds, + color, + icon, + defaultVISTheme, + permissions: permissionSettings, + }); + const getFormDataRef = useRef(getFormData); + getFormDataRef.current = getFormData; + + const featureOrGroups = useMemo(() => { + const transformedApplications = applications.map((app) => { + if (app.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { + return { + ...app, + category: { + ...app.category, + label: i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', + }), + }, + }; + } + return app; + }); + const category2Applications = groupBy(transformedApplications, 'category.label'); + return Object.keys(category2Applications).reduce< + Array + >((previousValue, currentKey) => { + const apps = category2Applications[currentKey]; + const features = apps + .filter( + ({ navLinkStatus, chromeless, category }) => + navLinkStatus !== AppNavLinkStatus.hidden && + !chromeless && + category?.id !== DEFAULT_APP_CATEGORIES.management.id + ) + .map(({ id, title, dependencies }) => ({ + id, + name: title, + dependencies, + })); + if (features.length === 0) { + return previousValue; + } + if (currentKey === 'undefined') { + return [...previousValue, ...features]; + } + return [ + ...previousValue, + { + name: apps[0].category?.label || '', + features, + }, + ]; + }, []); + }, [applications]); + + const allFeatures = useMemo( + () => + featureOrGroups.reduce( + (previousData, currentData) => [ + ...previousData, + ...(isWorkspaceFeatureGroup(currentData) ? currentData.features : [currentData]), + ], + [] + ), + [featureOrGroups] + ); + + const featureDependencies = useMemo(() => generateFeatureDependencyMap(allFeatures), [ + allFeatures, + ]); + + if (!formIdRef.current) { + formIdRef.current = workspaceHtmlIdGenerator(); + } + + const handleFeatureChange = useCallback( + (featureId) => { + setSelectedFeatureIds((previousData) => { + if (!previousData.includes(featureId)) { + return getFinalFeatureIdsByDependency([featureId], featureDependencies, previousData); + } + + if (isFeatureDependBySelectedFeatures(featureId, previousData, featureDependencies)) { + return previousData; + } + + return previousData.filter((selectedId) => selectedId !== featureId); + }); + }, + [featureDependencies] + ); + + const handleFeatureCheckboxChange = useCallback( + (e) => { + handleFeatureChange(e.target.id); + }, + [handleFeatureChange] + ); + + const handleFeatureGroupChange = useCallback( + (e) => { + for (const featureOrGroup of featureOrGroups) { + if (isWorkspaceFeatureGroup(featureOrGroup) && featureOrGroup.name === e.target.id) { + const groupFeatureIds = featureOrGroup.features.map((feature) => feature.id); + setSelectedFeatureIds((previousData) => { + const notExistsIds = groupFeatureIds.filter((id) => !previousData.includes(id)); + if (notExistsIds.length > 0) { + return getFinalFeatureIdsByDependency( + notExistsIds, + featureDependencies, + previousData + ); + } + let groupRemainFeatureIds = groupFeatureIds; + const outGroupFeatureIds = previousData.filter( + (featureId) => !groupFeatureIds.includes(featureId) + ); + + while (true) { + const lastRemainFeatures = groupRemainFeatureIds.length; + groupRemainFeatureIds = groupRemainFeatureIds.filter((featureId) => + isFeatureDependBySelectedFeatures( + featureId, + [...outGroupFeatureIds, ...groupRemainFeatureIds], + featureDependencies + ) + ); + if (lastRemainFeatures === groupRemainFeatureIds.length) { + break; + } + } + + return [...outGroupFeatureIds, ...groupRemainFeatureIds]; + }); + } + } + }, + [featureOrGroups, featureDependencies] + ); + + const handleFormSubmit = useCallback( + (e) => { + e.preventDefault(); + let currentFormErrors: WorkspaceFormErrors = {}; + const formData = getFormDataRef.current(); + if (!formData.name) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.empty', { + defaultMessage: "Name can't be empty.", + }), + }; + } + if (!isValidNameOrDescription(formData.name)) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.invalid', { + defaultMessage: 'Invalid workspace name', + }), + }; + } + if (!isValidNameOrDescription(formData.description)) { + currentFormErrors = { + ...currentFormErrors, + description: i18n.translate('workspace.form.detail.description.invalid', { + defaultMessage: 'Invalid workspace description', + }), + }; + } + const permissionErrors: string[] = new Array(formData.permissions.length); + for (let i = 0; i < formData.permissions.length; i++) { + const permission = formData.permissions[i]; + if (isValidWorkspacePermissionSetting(permission)) { + if ( + isUserOrGroupPermissionSettingDuplicated(formData.permissions.slice(0, i), permission) + ) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', { + defaultMessage: 'Duplicate permission setting', + }); + continue; + } + continue; + } + if (!permission.type) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.type', { + defaultMessage: 'Invalid type', + }); + continue; + } + if (!permission.modes || permission.modes.length === 0) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.modes', { + defaultMessage: 'Invalid permission modes', + }); + continue; + } + if (permission.type === WorkspacePermissionItemType.User && !permission.userId) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.userId', { + defaultMessage: 'Invalid userId', + }); + continue; + } + if (permission.type === WorkspacePermissionItemType.Group && !permission.group) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', { + defaultMessage: 'Invalid user group', + }); + continue; // this line is need for more conditions + } + } + if (permissionErrors.some((error) => !!error)) { + currentFormErrors = { + ...currentFormErrors, + permissions: permissionErrors, + }; + } + const currentNumberOfErrors = getNumberOfErrors(currentFormErrors); + setFormErrors(currentFormErrors); + setNumberOfErrors(currentNumberOfErrors); + if (currentNumberOfErrors > 0) { + return; + } + + const featureConfigChanged = + formData.features.length !== defaultFeatures.length || + formData.features.some((feat) => !defaultFeatures.includes(feat)); + + if (!featureConfigChanged) { + // If feature config not changed, set workspace feature config to the original value. + // The reason why we do this is when a workspace feature is configured by wildcard, + // such as `['@management']` or `['*']`. The form value `formData.features` will be + // expanded to array of individual feature id, if the feature hasn't changed, we will + // set the feature config back to the original value so that category wildcard won't + // expanded to feature ids + formData.features = defaultValues?.features ?? []; + } + + const permissions = formData.permissions.filter(isValidWorkspacePermissionSetting); + onSubmit?.({ ...formData, name: formData.name!, permissions }); + }, + [defaultFeatures, onSubmit, defaultValues?.features] + ); + + const handleNameInputChange = useCallback['onChange']>((e) => { + setName(e.target.value); + }, []); + + const handleDescriptionInputChange = useCallback['onChange']>((e) => { + setDescription(e.target.value); + }, []); + + const handleColorChange = useCallback['onChange']>((text) => { + setColor(text); + }, []); + + const handleIconChange = useCallback((newIcon: string) => { + setIcon(newIcon); + }, []); + + const handleTabFeatureClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.FeatureVisibility); + }, []); + + const handleTabPermissionClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.UsersAndPermissions); + }, []); + + const onDefaultVISThemeChange = (e: React.ChangeEvent) => { + setDefaultVISTheme(e.target.value); + }; + + const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { + defaultMessage: 'Workspace Details', + }); + const featureVisibilityTitle = i18n.translate('workspace.form.featureVisibility.title', { + defaultMessage: 'Feature Visibility', + }); + const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { + defaultMessage: 'Users & Permissions', + }); + const libraryCategoryLabel = i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', + }); + const categoryToDescription: { [key: string]: string } = { + [libraryCategoryLabel]: i18n.translate( + 'workspace.form.featureVisibility.libraryCategory.Description', + { + defaultMessage: 'Workspace-owned library items', + } + ), + }; + + return ( + + + +

    {workspaceDetailsTitle}

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

    {featureVisibilityTitle}

    +
    + + + {featureOrGroups.map((featureOrGroup) => { + const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; + const selectedIds = selectedFeatureIds.filter((id) => + (isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.features + : [featureOrGroup] + ).find((item) => item.id === id) + ); + const featureOrGroupId = isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.name + : featureOrGroup.id; + return ( + + +
    + + {featureOrGroup.name} + + {isWorkspaceFeatureGroup(featureOrGroup) && ( + {categoryToDescription[featureOrGroup.name] ?? ''} + )} +
    +
    + + 0 ? ` (${selectedIds.length}/${features.length})` : '' + }`} + checked={selectedIds.length > 0} + disabled={ + !isWorkspaceFeatureGroup(featureOrGroup) && + isDefaultCheckedFeatureId(featureOrGroup.id) + } + indeterminate={ + isWorkspaceFeatureGroup(featureOrGroup) && + selectedIds.length > 0 && + selectedIds.length < features.length + } + data-test-subj={`workspaceForm-workspaceFeatureVisibility-${featureOrGroupId}`} + /> + {isWorkspaceFeatureGroup(featureOrGroup) && ( + ({ + id: item.id, + label: item.name, + disabled: isDefaultCheckedFeatureId(item.id), + }))} + idToSelectedMap={selectedIds.reduce( + (previousValue, currentValue) => ({ + ...previousValue, + [currentValue]: true, + }), + {} + )} + onChange={handleFeatureChange} + style={{ marginLeft: 40 }} + data-test-subj={`workspaceForm-workspaceFeatureVisibility-featureWithCategory-${featureOrGroupId}`} + /> + )} + +
    + ); + })} +
    + )} + + {selectedTab === WorkspaceFormTabs.UsersAndPermissions && ( + + +

    {usersAndPermissionsTitle}

    +
    + + +
    + )} + + +
    + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx new file mode 100644 index 000000000000..06b0a224a258 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_icon_selector.tsx @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; + +import { EuiFlexGroup, EuiFlexItem, EuiIcon, EuiSuperSelect, EuiText } from '@elastic/eui'; + +const icons = ['Glasses', 'Search', 'Bell', 'Package']; + +export const WorkspaceIconSelector = ({ + color, + value, + onChange, +}: { + color?: string; + value?: string; + onChange: (value: string) => void; +}) => { + const options = icons.map((item) => ({ + value: item, + inputDisplay: ( + + + + + + {item} + + + ), + })); + return ( + onChange(icon)} + /> + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx new file mode 100644 index 000000000000..d95b062db3c4 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_permission_setting_panel.tsx @@ -0,0 +1,417 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import { + EuiFlexGroup, + EuiComboBox, + EuiFlexItem, + EuiButton, + EuiButtonIcon, + EuiButtonGroup, + EuiFormRow, + EuiText, + EuiSpacer, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { WorkspacePermissionMode } from '../../../../../core/public'; + +export enum WorkspacePermissionItemType { + User = 'user', + Group = 'group', +} + +export type WorkspacePermissionSetting = + | { type: WorkspacePermissionItemType.User; userId: string; modes: WorkspacePermissionMode[] } + | { type: WorkspacePermissionItemType.Group; group: string; modes: WorkspacePermissionMode[] }; + +enum PermissionModeId { + Read = 'read', + ReadAndWrite = 'read+write', + Admin = 'admin', +} + +const permissionModeOptions = [ + { + id: PermissionModeId.Read, + label: i18n.translate('workspace.form.permissionSettingPanel.permissionModeOptions.read', { + defaultMessage: 'Read', + }), + }, + { + id: PermissionModeId.ReadAndWrite, + label: i18n.translate( + 'workspace.form.permissionSettingPanel.permissionModeOptions.readAndWrite', + { + defaultMessage: 'Read & Write', + } + ), + }, + { + id: PermissionModeId.Admin, + label: i18n.translate('workspace.form.permissionSettingPanel.permissionModeOptions.admin', { + defaultMessage: 'Admin', + }), + }, +]; + +const optionIdToWorkspacePermissionModesMap: { + [key: string]: WorkspacePermissionMode[]; +} = { + [PermissionModeId.Read]: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + [PermissionModeId.ReadAndWrite]: [ + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Read, + ], + [PermissionModeId.Admin]: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], +}; + +const generateWorkspacePermissionItemKey = ( + item: Partial, + index?: number +) => + [ + ...(item.type ?? []), + ...(item.type === WorkspacePermissionItemType.User ? [item.userId] : []), + ...(item.type === WorkspacePermissionItemType.Group ? [item.group] : []), + ...(item.modes ?? []), + index, + ].join('-'); + +// default permission mode is read +const getPermissionModeId = (modes: WorkspacePermissionMode[]) => { + for (const key in optionIdToWorkspacePermissionModesMap) { + if (optionIdToWorkspacePermissionModesMap[key].every((mode) => modes?.includes(mode))) { + return key; + } + } + return PermissionModeId.Read; +}; + +interface WorkspacePermissionSettingInputProps { + index: number; + deletable: boolean; + type: WorkspacePermissionItemType; + userId?: string; + group?: string; + modes?: WorkspacePermissionMode[]; + onGroupOrUserIdChange: ( + groupOrUserId: + | { type: WorkspacePermissionItemType.User; userId?: string } + | { type: WorkspacePermissionItemType.Group; group?: string }, + index: number + ) => void; + onPermissionModesChange: ( + WorkspacePermissionMode: WorkspacePermissionMode[], + index: number + ) => void; + onDelete: (index: number) => void; +} + +const WorkspacePermissionSettingInput = ({ + index, + type, + userId, + group, + modes, + deletable, + onDelete, + onGroupOrUserIdChange, + onPermissionModesChange, +}: WorkspacePermissionSettingInputProps) => { + const groupOrUserIdSelectedOptions = useMemo( + () => (group || userId ? [{ label: (group || userId) as string }] : []), + [group, userId] + ); + + const permissionModesSelectedId = useMemo(() => getPermissionModeId(modes ?? []), [modes]); + const handleGroupOrUserIdCreate = useCallback( + (groupOrUserId) => { + onGroupOrUserIdChange( + type === WorkspacePermissionItemType.Group + ? { type, group: groupOrUserId } + : { type, userId: groupOrUserId }, + index + ); + }, + [index, type, onGroupOrUserIdChange] + ); + + const handleGroupOrUserIdChange = useCallback( + (options) => { + if (options.length === 0) { + onGroupOrUserIdChange({ type }, index); + } + }, + [index, type, onGroupOrUserIdChange] + ); + + const handlePermissionModeOptionChange = useCallback( + (id: string) => { + if (optionIdToWorkspacePermissionModesMap[id]) { + onPermissionModesChange([...optionIdToWorkspacePermissionModesMap[id]], index); + } + }, + [index, onPermissionModesChange] + ); + + const handleDelete = useCallback(() => { + onDelete(index); + }, [index, onDelete]); + + return ( + + + + + + + + + + + + ); +}; + +interface WorkspacePermissionSettingPanelProps { + errors?: string[]; + lastAdminItemDeletable: boolean; + permissionSettings: Array>; + onChange?: (value: Array>) => void; +} + +interface UserOrGroupSectionProps + extends Omit { + title: string; + nonDeletableIndex: number; + type: WorkspacePermissionItemType; +} + +const UserOrGroupSection = ({ + type, + title, + errors, + onChange, + permissionSettings, + nonDeletableIndex, +}: UserOrGroupSectionProps) => { + const transformedValue = useMemo(() => { + if (!permissionSettings) { + return []; + } + const result: Array> = []; + /** + * One workspace permission setting may include multi setting options, + * for loop the workspace permission setting array to separate it to multi rows. + **/ + for (let i = 0; i < permissionSettings.length; i++) { + const valueItem = permissionSettings[i]; + // Incomplete workspace permission setting don't need to separate to multi rows + if ( + !valueItem.modes || + !valueItem.type || + (valueItem.type === 'user' && !valueItem.userId) || + (valueItem.type === 'group' && !valueItem.group) + ) { + result.push(valueItem); + continue; + } + /** + * For loop the option id to workspace permission modes map, + * if one settings includes all permission modes in a specific option, + * add these permission modes to the result array. + */ + for (const key in optionIdToWorkspacePermissionModesMap) { + if (!Object.prototype.hasOwnProperty.call(optionIdToWorkspacePermissionModesMap, key)) { + continue; + } + const modesForCertainPermissionId = optionIdToWorkspacePermissionModesMap[key]; + if (modesForCertainPermissionId.every((mode) => valueItem.modes?.includes(mode))) { + result.push({ ...valueItem, modes: modesForCertainPermissionId }); + } + } + } + return result; + }, [permissionSettings]); + + // default permission mode is read + const handleAddNewOne = useCallback(() => { + onChange?.([ + ...(transformedValue ?? []), + { type, modes: optionIdToWorkspacePermissionModesMap[PermissionModeId.Read] }, + ]); + }, [onChange, type, transformedValue]); + + const handleDelete = useCallback( + (index: number) => { + onChange?.((transformedValue ?? []).filter((_item, itemIndex) => itemIndex !== index)); + }, + [onChange, transformedValue] + ); + + const handlePermissionModesChange = useCallback< + WorkspacePermissionSettingInputProps['onPermissionModesChange'] + >( + (modes, index) => { + onChange?.( + (transformedValue ?? []).map((item, itemIndex) => + index === itemIndex ? { ...item, modes } : item + ) + ); + }, + [onChange, transformedValue] + ); + + const handleGroupOrUserIdChange = useCallback< + WorkspacePermissionSettingInputProps['onGroupOrUserIdChange'] + >( + (userOrGroupIdWithType, index) => { + onChange?.( + (transformedValue ?? []).map((item, itemIndex) => + index === itemIndex + ? { ...userOrGroupIdWithType, ...(item.modes ? { modes: item.modes } : {}) } + : item + ) + ); + }, + [onChange, transformedValue] + ); + + // assume that group items are always deletable + return ( +
    + + {title} + + + {transformedValue?.map((item, index) => ( + + + + + + ))} + + {i18n.translate('workspace.form.permissionSettingPanel.addNew', { + defaultMessage: 'Add New', + })} + +
    + ); +}; + +export const WorkspacePermissionSettingPanel = ({ + errors, + onChange, + permissionSettings, + lastAdminItemDeletable, +}: WorkspacePermissionSettingPanelProps) => { + const [userPermissionSettings, setUserPermissionSettings] = useState< + Array> + >( + permissionSettings?.filter( + (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.User + ) ?? [] + ); + const [groupPermissionSettings, setGroupPermissionSettings] = useState< + Array> + >( + permissionSettings?.filter( + (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.Group + ) ?? [] + ); + + useEffect(() => { + onChange?.([...userPermissionSettings, ...groupPermissionSettings]); + }, [onChange, userPermissionSettings, groupPermissionSettings]); + + const nonDeletableIndex = useMemo(() => { + let userNonDeletableIndex = -1; + let groupNonDeletableIndex = -1; + const newPermissionSettings = [...userPermissionSettings, ...groupPermissionSettings]; + if (!lastAdminItemDeletable) { + const adminPermissionSettings = newPermissionSettings.filter( + (permission) => getPermissionModeId(permission.modes ?? []) === PermissionModeId.Admin + ); + if (adminPermissionSettings.length === 1) { + if (adminPermissionSettings[0].type === WorkspacePermissionItemType.User) { + userNonDeletableIndex = userPermissionSettings.findIndex( + (permission) => getPermissionModeId(permission.modes ?? []) === PermissionModeId.Admin + ); + } else { + groupNonDeletableIndex = groupPermissionSettings.findIndex( + (permission) => getPermissionModeId(permission.modes ?? []) === PermissionModeId.Admin + ); + } + } + } + return { userNonDeletableIndex, groupNonDeletableIndex }; + }, [userPermissionSettings, groupPermissionSettings, lastAdminItemDeletable]); + + const { userNonDeletableIndex, groupNonDeletableIndex } = nonDeletableIndex; + + return ( +
    + + + +
    + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator_app.tsx b/src/plugins/workspace/public/components/workspace_creator_app.tsx new file mode 100644 index 000000000000..b74359929352 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceCreator } from './workspace_creator'; + +export const WorkspaceCreatorApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceCreateTitle', { + defaultMessage: 'Create workspace', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap new file mode 100644 index 000000000000..594066e959f7 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap @@ -0,0 +1,180 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` render error with callout 1`] = ` +
    +
    +
    +
    +
    + +
    +

    + + Something went wrong + +

    + +
    +
    +

    + + The workspace you want to go can not be found, try go back to home. + +

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

    + + Something went wrong + +

    + +
    +
    +

    + + The workspace you want to go can not be found, try go back to home. + +

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

    + +

    + } + actions={[ + + + , + ]} + /> + {props.error ? : null} +
    +
    +
    + ); +} diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx new file mode 100644 index 000000000000..201f1928b8fc --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -0,0 +1,207 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiPageContent, + EuiLink, + EuiButton, + EuiInMemoryTable, + EuiTableSelectionType, + EuiSearchBarProps, +} from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { of } from 'rxjs'; +import { i18n } from '@osd/i18n'; +import { WorkspaceAttribute } from '../../../../../core/public'; + +import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; +import { switchWorkspace, updateWorkspace } from '../utils/workspace'; +import { debounce } from '../utils/common'; + +import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; + +import { cleanWorkspaceId } from '../../../../../core/public'; + +const WORKSPACE_LIST_PAGE_DESCRIPTIOIN = i18n.translate('workspace.list.description', { + defaultMessage: + 'Workspace allow you to save and organize library items, such as index patterns, visualizations, dashboards, saved searches, and share them with other OpenSearch Dashboards users. You can control which features are visible in each workspace, and which users and groups have read and write access to the library items in the workspace.', +}); + +export const WorkspaceList = () => { + const { + services: { workspaces, application, http }, + } = useOpenSearchDashboards(); + + const initialSortField = 'name'; + const initialSortDirection = 'asc'; + const workspaceList = useObservable(workspaces?.workspaceList$ ?? of([]), []); + const [queryInput, setQueryInput] = useState(''); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 5, + pageSizeOptions: [5, 10, 20], + }); + + // Will be uesed when updating table actions + const [, setSelection] = useState([]); + + const handleSwitchWorkspace = useCallback( + (id: string) => { + if (application && http) { + switchWorkspace({ application, http }, id); + } + }, + [application, http] + ); + + const handleUpdateWorkspace = useCallback( + (id: string) => { + if (application && http) { + updateWorkspace({ application, http }, id); + } + }, + [application, http] + ); + + const searchResult = useMemo(() => { + if (queryInput) { + const normalizedQuery = queryInput.toLowerCase(); + const result = workspaceList.filter((item) => { + return ( + item.id.toLowerCase().indexOf(normalizedQuery) > -1 || + item.name.toLowerCase().indexOf(normalizedQuery) > -1 + ); + }); + return result; + } + return workspaceList; + }, [workspaceList, queryInput]); + + const columns = [ + { + field: 'name', + name: 'Name', + sortable: true, + render: (name: string, item: WorkspaceAttribute) => ( + + handleSwitchWorkspace(item.id)}>{name} + + ), + }, + { + field: 'id', + name: 'ID', + sortable: true, + }, + { + field: 'description', + name: 'Description', + truncateText: true, + }, + { + field: 'features', + name: 'Features', + isExpander: true, + hasActions: true, + }, + { + name: 'Actions', + field: '', + actions: [ + { + name: 'Edit', + icon: 'pencil', + type: 'icon', + description: 'edit workspace', + onClick: ({ id }: WorkspaceAttribute) => handleUpdateWorkspace(id), + }, + ], + }, + ]; + + const workspaceCreateUrl = useMemo(() => { + if (!application || !http) { + return ''; + } + + return cleanWorkspaceId( + application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }) + ); + }, [application, http]); + + const debouncedSetQueryInput = useMemo(() => { + return debounce(setQueryInput, 300); + }, [setQueryInput]); + + const handleSearchInput: EuiSearchBarProps['onChange'] = ({ query }) => { + debouncedSetQueryInput(query?.text ?? ''); + }; + + const search: EuiSearchBarProps = { + onChange: handleSearchInput, + box: { + incremental: true, + }, + toolsRight: [ + + Create workspace + , + ], + }; + + const selectionValue: EuiTableSelectionType = { + selectable: () => true, + onSelectionChange: (selection) => { + setSelection(selection); + }, + }; + + return ( + + + + + + setPagination((prev) => { + return { ...prev, pageIndex: index, pageSize: size }; + }) + } + pagination={pagination} + sorting={{ + sort: { + field: initialSortField, + direction: initialSortDirection, + }, + }} + isSelectable={true} + selection={selectionValue} + search={search} + /> + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_list_app.tsx b/src/plugins/workspace/public/components/workspace_list_app.tsx new file mode 100644 index 000000000000..a9da8dc49fae --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceList } from './workspace_list'; + +export const WorkspaceListApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceListTitle', { + defaultMessage: 'Workspace List', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx new file mode 100644 index 000000000000..05ada930abc4 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -0,0 +1,234 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { useObservable } from 'react-use'; +import { + EuiCollapsibleNavGroup, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import type { EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; + +import { + ApplicationStart, + HttpSetup, + MANAGEMENT_WORKSPACE_ID, + WorkspaceAttribute, + WorkspacesStart, +} from '../../../../../core/public'; +import { + WORKSPACE_CREATE_APP_ID, + WORKSPACE_LIST_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, +} from '../../../common/constants'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { cleanWorkspaceId } from '../../../../../core/public'; + +interface Props { + getUrlForApp: ApplicationStart['getUrlForApp']; + navigateToUrl: ApplicationStart['navigateToUrl']; + basePath: HttpSetup['basePath']; + workspaces: WorkspacesStart; +} + +function getFilteredWorkspaceList( + workspaceList: WorkspaceAttribute[], + currentWorkspace: WorkspaceAttribute | null +): WorkspaceAttribute[] { + // list top5 workspaces except management workspace, place current workspace at the top + return [ + ...(currentWorkspace ? [currentWorkspace] : []), + ...workspaceList.filter( + (workspace) => + workspace.id !== MANAGEMENT_WORKSPACE_ID && workspace.id !== currentWorkspace?.id + ), + ].slice(0, 5); +} + +export const WorkspaceMenu = ({ basePath, getUrlForApp, workspaces, navigateToUrl }: Props) => { + const [isPopoverOpen, setPopover] = useState(false); + const currentWorkspace = useObservable(workspaces.currentWorkspace$, null); + const workspaceList = useObservable(workspaces.workspaceList$, []); + + const defaultHeaderName = i18n.translate( + 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', + { + defaultMessage: 'OpenSearch Dashboards', + } + ); + const managementWorkspaceName = i18n.translate( + 'core.ui.primaryNav.workspacePickerMenu.managementWorkspaceName', + { + defaultMessage: 'Management', + } + ); + const filteredWorkspaceList = getFilteredWorkspaceList(workspaceList, currentWorkspace); + const currentWorkspaceName = currentWorkspace?.name ?? defaultHeaderName; + + const onButtonClick = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + + const workspaceToItem = (workspace: WorkspaceAttribute, index: number) => { + const href = formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: false, + }), + workspace.id, + basePath + ); + const name = + currentWorkspace !== null && index === 0 ? ( + + {workspace.name} + + ) : ( + workspace.name + ); + return { + href, + name, + key: index.toString(), + icon: , + }; + }; + + const getWorkspaceListItems = () => { + const workspaceListItems: EuiContextMenuPanelItemDescriptor[] = filteredWorkspaceList.map( + (workspace, index) => workspaceToItem(workspace, index) + ); + const length = workspaceListItems.length; + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.createWorkspace', { + defaultMessage: 'Create workspace', + }), + key: length.toString(), + onClick: () => { + navigateToUrl( + cleanWorkspaceId( + getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }) + ) + ); + setPopover(false); + }, + }); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.allWorkspace', { + defaultMessage: 'All workspaces', + }), + key: (length + 1).toString(), + onClick: () => { + navigateToUrl( + cleanWorkspaceId( + getUrlForApp(WORKSPACE_LIST_APP_ID, { + absolute: false, + }) + ) + ); + setPopover(false); + }, + }); + return workspaceListItems; + }; + + const currentWorkspaceButton = ( + + + + + + + + {currentWorkspaceName} + + + + + + + + ); + + const currentWorkspaceTitle = ( + + + + + + + {currentWorkspaceName} + + + + + + + ); + + const panels = [ + { + id: 0, + title: currentWorkspaceTitle, + items: [ + { + name: ( + + + {i18n.translate('core.ui.primaryNav.workspacePickerMenu.workspaceList', { + defaultMessage: 'Workspaces', + })} + + + ), + icon: 'folderClosed', + panel: 1, + }, + { + name: managementWorkspaceName, + icon: 'managementApp', + href: formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: false, + }), + MANAGEMENT_WORKSPACE_ID, + basePath + ), + }, + ], + }, + { + id: 1, + title: 'Workspaces', + items: getWorkspaceListItems(), + }, + ]; + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_overview.tsx b/src/plugins/workspace/public/components/workspace_overview.tsx new file mode 100644 index 000000000000..5a7f9d4117c5 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_overview.tsx @@ -0,0 +1,32 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { EuiPageHeader, EuiPanel, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { useObservable } from 'react-use'; +import { of } from 'rxjs'; +import { ApplicationStart } from '../../../../core/public'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; + +export const WorkspaceOverview = () => { + const { + services: { workspaces }, + } = useOpenSearchDashboards<{ application: ApplicationStart }>(); + + const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); + + return ( + <> + + + +

    Workspace

    +
    + + {JSON.stringify(currentWorkspace)} +
    + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_overview_app.tsx b/src/plugins/workspace/public/components/workspace_overview_app.tsx new file mode 100644 index 000000000000..d452600648b4 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_overview_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceOverview } from './workspace_overview'; + +export const WorkspaceOverviewApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceOverviewTitle', { + defaultMessage: 'Workspace Overview', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_updater/index.tsx b/src/plugins/workspace/public/components/workspace_updater/index.tsx new file mode 100644 index 000000000000..711f19fd25f6 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater/index.tsx @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceUpdater } from './workspace_updater'; diff --git a/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx new file mode 100644 index 000000000000..59268fa7d917 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -0,0 +1,206 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useEffect, useState } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiPageContent, + EuiButton, + EuiPanel, + EuiSpacer, +} from '@elastic/eui'; +import { useObservable } from 'react-use'; +import { i18n } from '@osd/i18n'; +import { of } from 'rxjs'; +import { WorkspaceAttribute } from 'opensearch-dashboards/public'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { + WorkspaceForm, + WorkspaceFormSubmitData, + WorkspaceFormData, +} from '../workspace_creator/workspace_form'; +import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_OP_TYPE_UPDATE } from '../../../common/constants'; +import { DeleteWorkspaceModal } from '../delete_workspace_modal'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { WorkspaceClient } from '../../workspace_client'; +import { WorkspacePermissionSetting } from '../'; + +interface WorkspaceWithPermission extends WorkspaceAttribute { + permissions?: WorkspacePermissionSetting[]; +} + +function getFormDataFromWorkspace( + currentWorkspace: WorkspaceAttribute | null | undefined +): WorkspaceFormData { + const currentWorkspaceWithPermission = (currentWorkspace || {}) as WorkspaceWithPermission; + return { + ...currentWorkspaceWithPermission, + permissions: currentWorkspaceWithPermission.permissions || [], + }; +} +export const WorkspaceUpdater = () => { + const { + services: { application, workspaces, notifications, http, workspaceClient }, + } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); + + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; + + const currentWorkspace = useObservable(workspaces ? workspaces.currentWorkspace$ : of(null)); + const hideDeleteButton = !!currentWorkspace?.reserved; // hide delete button for reserved workspace + const [deleteWorkspaceModalVisible, setDeleteWorkspaceModalVisible] = useState(false); + const [currentWorkspaceFormData, setCurrentWorkspaceFormData] = useState( + getFormDataFromWorkspace(currentWorkspace) + ); + + useEffect(() => { + setCurrentWorkspaceFormData(getFormDataFromWorkspace(currentWorkspace)); + }, [currentWorkspace]); + + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormSubmitData) => { + let result; + if (!currentWorkspace) { + notifications?.toasts.addDanger({ + title: i18n.translate('Cannot find current workspace', { + defaultMessage: 'Cannot update workspace', + }), + }); + return; + } + try { + const { permissions, ...attributes } = data; + result = await workspaceClient.update(currentWorkspace?.id, attributes, permissions); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.update.failed', { + defaultMessage: 'Failed to update workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.update.success', { + defaultMessage: 'Update workspace successfully', + }), + }); + if (application && http) { + window.location.href = + formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + currentWorkspace.id, + http.basePath + ) || ''; + } + return; + } + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.update.failed', { + defaultMessage: 'Failed to update workspace', + }), + text: result?.error, + }); + }, + [notifications?.toasts, currentWorkspace, application, http, workspaceClient] + ); + + if (!currentWorkspaceFormData.name) { + return null; + } + const deleteWorkspace = async () => { + if (currentWorkspace?.id) { + let result; + try { + result = await workspaceClient.delete(currentWorkspace?.id); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.delete.failed', { + defaultMessage: 'Failed to delete workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return setDeleteWorkspaceModalVisible(false); + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.delete.success', { + defaultMessage: 'Delete workspace successfully', + }), + }); + setDeleteWorkspaceModalVisible(false); + if (http && application) { + const homeUrl = application.getUrlForApp('home', { + path: '/', + absolute: false, + }); + const targetUrl = http.basePath.prepend(http.basePath.remove(homeUrl), { + withoutWorkspace: true, + }); + await application.navigateToUrl(targetUrl); + } + } else { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.delete.failed', { + defaultMessage: 'Failed to delete workspace', + }), + text: result?.error, + }); + } + } + }; + + return ( + + + setDeleteWorkspaceModalVisible(true)}> + Delete + , + ] + } + /> + + + {deleteWorkspaceModalVisible && ( + + setDeleteWorkspaceModalVisible(false)} + selectedItems={currentWorkspace?.name ? [currentWorkspace.name] : []} + /> + + )} + {application && ( + + )} + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_updater_app.tsx b/src/plugins/workspace/public/components/workspace_updater_app.tsx new file mode 100644 index 000000000000..89ad15028f82 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceUpdater } from './workspace_updater'; + +export const WorkspaceUpdaterApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceUpdateTitle', { + defaultMessage: 'Workspace Update', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/hooks.ts b/src/plugins/workspace/public/hooks.ts new file mode 100644 index 000000000000..e84ee46507ef --- /dev/null +++ b/src/plugins/workspace/public/hooks.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public'; +import { useObservable } from 'react-use'; +import { useMemo } from 'react'; + +export function useApplications(application: ApplicationStart) { + const applications = useObservable(application.applications$); + return useMemo(() => { + const apps: PublicAppInfo[] = []; + applications?.forEach((app) => { + apps.push(app); + }); + return apps; + }, [applications]); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts new file mode 100644 index 000000000000..9a476b60d208 --- /dev/null +++ b/src/plugins/workspace/public/plugin.test.ts @@ -0,0 +1,161 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Observable, Subscriber, of } from 'rxjs'; +import { waitFor } from '@testing-library/dom'; +import { ChromeNavLink } from 'opensearch-dashboards/public'; +import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; +import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; +import { WorkspacePlugin } from './plugin'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { savedObjectsManagementPluginMock } from '../../saved_objects_management/public/mocks'; + +describe('Workspace plugin', () => { + beforeEach(() => { + WorkspaceClientMock.mockClear(); + Object.values(workspaceClientMock).forEach((item) => item.mockClear()); + }); + it('#setup', async () => { + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup( + { + ...setupMock, + chrome: chromeServiceMock.createSetupContract(), + }, + { savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract() } + ); + expect(setupMock.application.register).toBeCalledTimes(5); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); + }); + + it('#setup when workspace id is in url and enterWorkspace return error', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: false, + error: 'error', + }); + const setupMock = coreMock.createSetup(); + const applicationStartMock = applicationServiceMock.createStartContract(); + const chromeStartMock = chromeServiceMock.createStartContract(); + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: applicationStartMock, + chrome: chromeStartMock, + }, + {}, + {}, + ]) as any; + }); + + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup( + { + ...setupMock, + chrome: chromeServiceMock.createSetupContract(), + }, + { savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract() } + ); + expect(setupMock.application.register).toBeCalledTimes(5); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); + expect(setupMock.getStartServices).toBeCalledTimes(1); + await waitFor( + () => { + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: 'error', + }, + }); + }, + { + container: document.body, + } + ); + windowSpy.mockRestore(); + }); + + it('#setup when workspace id is in url and enterWorkspace return success', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: true, + error: 'error', + }); + const setupMock = coreMock.createSetup(); + const applicationStartMock = applicationServiceMock.createStartContract(); + let currentAppIdSubscriber: Subscriber | undefined; + setupMock.getStartServices.mockImplementation(() => { + return Promise.resolve([ + { + application: { + ...applicationStartMock, + currentAppId$: new Observable((subscriber) => { + currentAppIdSubscriber = subscriber; + }), + }, + }, + {}, + {}, + ]) as any; + }); + + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup( + { + ...setupMock, + chrome: chromeServiceMock.createSetupContract(), + }, + { savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract() } + ); + currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); + windowSpy.mockRestore(); + }); + + it('#start filter nav links according to workspace feature', () => { + const workspacePlugin = new WorkspacePlugin(); + const coreStart = coreMock.createStart(); + const navLinksService = coreStart.chrome.navLinks; + const devToolsNavLink = { + id: 'dev_tools', + category: { id: 'management', label: 'Management' }, + }; + const discoverNavLink = { + id: 'discover', + category: { id: 'opensearchDashboards', label: 'Library' }, + }; + const workspace = { + id: 'test', + name: 'test', + features: ['dev_tools'], + }; + const allNavLinks = of([devToolsNavLink, discoverNavLink] as ChromeNavLink[]); + const filteredNavLinksMap = new Map(); + filteredNavLinksMap.set(devToolsNavLink.id, devToolsNavLink as ChromeNavLink); + navLinksService.getAllNavLinks$.mockReturnValue(allNavLinks); + coreStart.workspaces.currentWorkspace$.next(workspace); + workspacePlugin.start(coreStart); + expect(navLinksService.setNavLinks).toHaveBeenCalledWith(filteredNavLinksMap); + }); +}); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 18e84e3a6f35..6a5da6a84efb 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -3,16 +3,238 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { Plugin } from '../../../core/public'; +import { i18n } from '@osd/i18n'; +import type { Subscription } from 'rxjs'; +import { combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { + AppMountParameters, + AppNavLinkStatus, + ChromeNavLink, + CoreSetup, + CoreStart, + Plugin, + WorkspaceObject, + DEFAULT_APP_CATEGORIES, +} from '../../../core/public'; +import { + WORKSPACE_LIST_APP_ID, + WORKSPACE_UPDATE_APP_ID, + WORKSPACE_CREATE_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_FATAL_ERROR_APP_ID, +} from '../common/constants'; +import { SavedObjectsManagementPluginSetup } from '../../saved_objects_management/public'; +import { getWorkspaceColumn } from './components/utils/workspace_column'; +import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; +import { WorkspaceClient } from './workspace_client'; +import { renderWorkspaceMenu } from './render_workspace_menu'; +import { Services } from './types'; +import { featureMatchesConfig } from './utils'; + +interface WorkspacePluginSetupDeps { + savedObjectsManagement?: SavedObjectsManagementPluginSetup; +} + +export class WorkspacePlugin implements Plugin<{}, {}, WorkspacePluginSetupDeps> { + private coreStart?: CoreStart; + private currentWorkspaceSubscription?: Subscription; + + private getWorkspaceIdFromURL(): string | null { + return getWorkspaceIdFromUrl(window.location.href); + } + + public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacePluginSetupDeps) { + core.chrome.registerCollapsibleNavHeader(renderWorkspaceMenu); + const workspaceClient = new WorkspaceClient(core.http, core.workspaces); + await workspaceClient.init(); + /** + * Retrieve workspace id from url + */ + const workspaceId = this.getWorkspaceIdFromURL(); + + if (workspaceId) { + const result = await workspaceClient.enterWorkspace(workspaceId); + if (!result.success) { + /** + * Fatal error service does not support customized actions + * So we have to use a self-hosted page to show the errors and redirect. + */ + (async () => { + const [{ application, chrome }] = await core.getStartServices(); + chrome.setIsVisible(false); + application.navigateToApp(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: result.error, + }, + }); + })(); + } else { + /** + * If the workspace id is valid and user is currently on workspace_fatal_error page, + * we should redirect user to overview page of workspace. + */ + (async () => { + const [{ application }] = await core.getStartServices(); + const currentAppIdSubscription = application.currentAppId$.subscribe((currentAppId) => { + if (currentAppId === WORKSPACE_FATAL_ERROR_APP_ID) { + application.navigateToApp(WORKSPACE_OVERVIEW_APP_ID); + } + currentAppIdSubscription.unsubscribe(); + }); + })(); + } + } + /** + * register workspace column into saved objects table + */ + savedObjectsManagement?.columns.register(getWorkspaceColumn(core)); + + type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; + const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { + const [coreStart] = await core.getStartServices(); + const services = { + ...coreStart, + workspaceClient, + }; + + return renderApp(params, services); + }; + + // create + core.application.register({ + id: WORKSPACE_CREATE_APP_ID, + title: i18n.translate('workspace.settings.workspaceCreate', { + defaultMessage: 'Create Workspace', + }), + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderCreatorApp } = await import('./application'); + return mountWorkspaceApp(params, renderCreatorApp); + }, + }); + + // overview + core.application.register({ + id: WORKSPACE_OVERVIEW_APP_ID, + title: i18n.translate('workspace.settings.workspaceOverview', { + defaultMessage: 'Overview', + }), + order: 0, + euiIconType: 'grid', + navLinkStatus: !!workspaceId ? AppNavLinkStatus.default : AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderOverviewApp } = await import('./application'); + return mountWorkspaceApp(params, renderOverviewApp); + }, + }); + + // update + core.application.register({ + id: WORKSPACE_UPDATE_APP_ID, + title: i18n.translate('workspace.settings.workspaceUpdate', { + defaultMessage: 'Workspace Settings', + }), + euiIconType: 'managementApp', + navLinkStatus: !!workspaceId ? AppNavLinkStatus.default : AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderUpdateApp } = await import('./application'); + return mountWorkspaceApp(params, renderUpdateApp); + }, + }); + + // list + core.application.register({ + id: WORKSPACE_LIST_APP_ID, + title: '', + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderListApp } = await import('./application'); + return mountWorkspaceApp(params, renderListApp); + }, + }); + + // workspace fatal error + core.application.register({ + id: WORKSPACE_FATAL_ERROR_APP_ID, + title: '', + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderFatalErrorApp } = await import('./application'); + return mountWorkspaceApp(params, renderFatalErrorApp); + }, + }); -export class WorkspacePlugin implements Plugin<{}, {}, {}> { - public async setup() { return {}; } - public start() { + private _changeSavedObjectCurrentWorkspace() { + if (this.coreStart) { + return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { + if (currentWorkspaceId) { + this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); + } + }); + } + } + + private filterByWorkspace(workspace: WorkspaceObject | null, allNavLinks: ChromeNavLink[]) { + if (!workspace) return allNavLinks; + const features = workspace.features ?? ['*']; + return allNavLinks.filter(featureMatchesConfig(features)); + } + + private filterNavLinks(core: CoreStart) { + const navLinksService = core.chrome.navLinks; + const allNavLinks$ = navLinksService.getAllNavLinks$(); + const currentWorkspace$ = core.workspaces.currentWorkspace$; + combineLatest([ + allNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), + currentWorkspace$, + ]).subscribe(([allNavLinks, currentWorkspace]) => { + const filteredNavLinks = this.filterByWorkspace(currentWorkspace, allNavLinks); + const navLinks = new Map(); + filteredNavLinks.forEach((chromeNavLink) => { + navLinks.set(chromeNavLink.id, chromeNavLink); + }); + navLinksService.setNavLinks(navLinks); + }); + } + + /** + * The category "Opensearch Dashboards" needs to be renamed as "Library" + * when workspace feature flag is on, we need to do it here and generate + * a new item without polluting the original ChromeNavLink. + */ + private changeCategoryNameByWorkspaceFeatureFlag(chromeLinks: ChromeNavLink[]): ChromeNavLink[] { + return chromeLinks.map((item) => { + if (item.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { + return { + ...item, + category: { + ...item.category, + label: i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', + }), + }, + }; + } + return item; + }); + } + + public start(core: CoreStart) { + this.coreStart = core; + + this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); + if (core) { + this.filterNavLinks(core); + } return {}; } - public stop() {} + public stop() { + this.currentWorkspaceSubscription?.unsubscribe(); + } } diff --git a/src/plugins/workspace/public/render_workspace_menu.tsx b/src/plugins/workspace/public/render_workspace_menu.tsx new file mode 100644 index 000000000000..e373b4e18f72 --- /dev/null +++ b/src/plugins/workspace/public/render_workspace_menu.tsx @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { ApplicationStart, HttpSetup, WorkspacesStart } from '../../../core/public'; +import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; + +export function renderWorkspaceMenu({ + basePath, + getUrlForApp, + workspaces, + navigateToUrl, +}: { + getUrlForApp: ApplicationStart['getUrlForApp']; + basePath: HttpSetup['basePath']; + workspaces: WorkspacesStart; + navigateToUrl: ApplicationStart['navigateToUrl']; +}) { + return ( + + ); +} diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts new file mode 100644 index 000000000000..1b3f38e50857 --- /dev/null +++ b/src/plugins/workspace/public/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart } from '../../../core/public'; +import { WorkspaceClient } from './workspace_client'; + +export type Services = CoreStart & { workspaceClient: WorkspaceClient }; diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts new file mode 100644 index 000000000000..510a775cd745 --- /dev/null +++ b/src/plugins/workspace/public/utils.test.ts @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { featureMatchesConfig } from './utils'; + +describe('workspace utils: featureMatchesConfig', () => { + it('feature configured with `*` should match any features', () => { + const match = featureMatchesConfig(['*']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should NOT match the config if feature id not matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + }); + + it('should match the config if feature id matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', 'visualize']); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should match the config if feature category matches', () => { + const match = featureMatchesConfig(['discover', 'dashboards', '@management', 'visualize']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); + + it('should match any features but not the excluded feature id', () => { + const match = featureMatchesConfig(['*', '!discover']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(false); + }); + + it('should match any features but not the excluded feature category', () => { + const match = featureMatchesConfig(['*', '!@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect( + match({ id: 'discover', category: { id: 'opensearchDashboards', label: 'Library' } }) + ).toBe(true); + }); + + it('should NOT match the excluded feature category', () => { + const match = featureMatchesConfig(['!@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + }); + + it('should match features of a category but NOT the excluded feature', () => { + const match = featureMatchesConfig(['@management', '!dev_tools']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + false + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); + + it('a config presents later in the config array should override the previous config', () => { + // though `dev_tools` is excluded, but this config will override by '@management' as dev_tools has category 'management' + const match = featureMatchesConfig(['!dev_tools', '@management']); + expect(match({ id: 'dev_tools', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + expect(match({ id: 'integrations', category: { id: 'management', label: 'Management' } })).toBe( + true + ); + }); +}); diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts new file mode 100644 index 000000000000..f7c59dbfc53c --- /dev/null +++ b/src/plugins/workspace/public/utils.ts @@ -0,0 +1,56 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppCategory } from '../../../core/public'; + +/** + * Given a list of feature config, check if a feature matches config + * Rules: + * 1. `*` matches any feature + * 2. config starts with `@` matches category, for example, @management matches any feature of `management` category + * 3. to match a specific feature, just use the feature id, such as `discover` + * 4. to exclude feature or category, use `!@management` or `!discover` + * 5. the order of featureConfig array matters, from left to right, the later config override the previous config, + * for example, ['!@management', '*'] matches any feature because '*' overrides the previous setting: '!@management' + */ +export const featureMatchesConfig = (featureConfigs: string[]) => ({ + id, + category, +}: { + id: string; + category?: AppCategory; +}) => { + let matched = false; + + for (const featureConfig of featureConfigs) { + // '*' matches any feature + if (featureConfig === '*') { + matched = true; + } + + // The config starts with `@` matches a category + if (category && featureConfig === `@${category.id}`) { + matched = true; + } + + // The config matches a feature id + if (featureConfig === id) { + matched = true; + } + + // If a config starts with `!`, such feature or category will be excluded + if (featureConfig.startsWith('!')) { + if (category && featureConfig === `!@${category.id}`) { + matched = false; + } + + if (featureConfig === `!${id}`) { + matched = false; + } + } + } + + return matched; +}; diff --git a/src/plugins/workspace/public/workspace_client.mock.ts b/src/plugins/workspace/public/workspace_client.mock.ts new file mode 100644 index 000000000000..2ceeae5627d1 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.mock.ts @@ -0,0 +1,25 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export const workspaceClientMock = { + init: jest.fn(), + enterWorkspace: jest.fn(), + getCurrentWorkspaceId: jest.fn(), + getCurrentWorkspace: jest.fn(), + create: jest.fn(), + delete: jest.fn(), + list: jest.fn(), + get: jest.fn(), + update: jest.fn(), + stop: jest.fn(), +}; + +export const WorkspaceClientMock = jest.fn(function () { + return workspaceClientMock; +}); + +jest.doMock('./workspace_client', () => ({ + WorkspaceClient: WorkspaceClientMock, +})); diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts new file mode 100644 index 000000000000..7d05c3f22458 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -0,0 +1,181 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { httpServiceMock, workspacesServiceMock } from '../../../core/public/mocks'; +import { WorkspaceClient } from './workspace_client'; + +const getWorkspaceClient = () => { + const httpSetupMock = httpServiceMock.createSetupContract(); + const workspaceMock = workspacesServiceMock.createSetupContract(); + return { + httpSetupMock, + workspaceMock, + workspaceClient: new WorkspaceClient(httpSetupMock, workspaceMock), + }; +}; + +describe('#WorkspaceClient', () => { + it('#init', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + await workspaceClient.init(); + expect(workspaceMock.initialized$.getValue()).toEqual(true); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#enterWorkspace', async () => { + const { workspaceClient, httpSetupMock, workspaceMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: false, + }); + const result = await workspaceClient.enterWorkspace('foo'); + expect(result.success).toEqual(false); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + }); + const successResult = await workspaceClient.enterWorkspace('foo'); + expect(workspaceMock.currentWorkspaceId$.getValue()).toEqual('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'GET', + }); + expect(successResult.success).toEqual(true); + }); + + it('#getCurrentWorkspaceId', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + }); + await workspaceClient.enterWorkspace('foo'); + expect(await workspaceClient.getCurrentWorkspaceId()).toEqual({ + success: true, + result: 'foo', + }); + }); + + it('#getCurrentWorkspace', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + }, + }); + await workspaceClient.enterWorkspace('foo'); + expect(await workspaceClient.getCurrentWorkspace()).toEqual({ + success: true, + result: { + name: 'foo', + }, + }); + }); + + it('#create', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.create({ + name: 'foo', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces', { + method: 'POST', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#delete', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.delete('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'DELETE', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#list', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [], + }, + }); + await workspaceClient.list({ + perPage: 999, + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + + it('#get', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + await workspaceClient.get('foo'); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'GET', + }); + }); + + it('#update', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [], + }, + }); + await workspaceClient.update('foo', { + name: 'foo', + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'PUT', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); +}); diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts new file mode 100644 index 000000000000..811d26cbf48d --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.ts @@ -0,0 +1,292 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + HttpFetchError, + HttpFetchOptions, + HttpSetup, + WorkspaceAttribute, + WorkspacesSetup, +} from '../../../core/public'; +import { WorkspacePermissionMode } from '../../../core/public'; + +const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +const join = (...uriComponents: Array) => + uriComponents + .filter((comp): comp is string => Boolean(comp)) + .map(encodeURIComponent) + .join('/'); + +type IResponse = + | { + result: T; + success: true; + } + | { + success: false; + error?: string; + }; + +type WorkspacePermissionItem = { + modes: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Read + | WorkspacePermissionMode.Write + >; +} & ({ type: 'user'; userId: string } | { type: 'group'; group: string }); + +interface WorkspaceFindOptions { + page?: number; + perPage?: number; + search?: string; + searchFields?: string[]; + sortField?: string; + sortOrder?: string; + permissionModes?: WorkspacePermissionMode[]; +} + +/** + * Workspaces is OpenSearchDashboards's visualize mechanism allowing admins to + * organize related features + * + * @public + */ +export class WorkspaceClient { + private http: HttpSetup; + private workspaces: WorkspacesSetup; + + constructor(http: HttpSetup, workspaces: WorkspacesSetup) { + this.http = http; + this.workspaces = workspaces; + } + + /** + * Initialize workspace list + */ + public async init() { + await this.updateWorkspaceList(); + this.workspaces.initialized$.next(true); + } + + /** + * Add a non-throw-error fetch method for internal use. + */ + private safeFetch = async ( + path: string, + options: HttpFetchOptions + ): Promise> => { + try { + return await this.http.fetch>(path, options); + } catch (error: unknown) { + if (error instanceof HttpFetchError) { + return { + success: false, + error: error.body?.message || error.body?.error || error.message, + }; + } + + if (error instanceof Error) { + return { + success: false, + error: error.message, + }; + } + + return { + success: false, + error: 'Unknown error', + }; + } + }; + + private getPath(...path: Array): string { + return [WORKSPACES_API_BASE_URL, join(...path)].filter((item) => item).join('/'); + } + + private async updateWorkspaceList(): Promise { + const result = await this.list({ + perPage: 999, + }); + + if (result?.success) { + const resultWithWritePermission = await this.list({ + perPage: 999, + permissionModes: [WorkspacePermissionMode.LibraryWrite], + }); + if (resultWithWritePermission?.success) { + const workspaceIdsWithWritePermission = resultWithWritePermission.result.workspaces.map( + (workspace: WorkspaceAttribute) => workspace.id + ); + let workspaces = result.result.workspaces; + workspaces = result.result.workspaces.map((workspace: WorkspaceAttribute) => ({ + ...workspace, + libraryReadonly: !workspaceIdsWithWritePermission.includes(workspace.id), + })); + this.workspaces.workspaceList$.next(workspaces); + } + } + } + + public async enterWorkspace(id: string): Promise> { + const workspaceResp = await this.get(id); + if (workspaceResp.success) { + this.workspaces.currentWorkspaceId$.next(id); + return { + success: true, + result: null, + }; + } else { + return workspaceResp; + } + } + + public async getCurrentWorkspaceId(): Promise> { + const currentWorkspaceId = this.workspaces.currentWorkspaceId$.getValue(); + if (!currentWorkspaceId) { + return { + success: false, + error: 'You are not in any workspace yet.', + }; + } + + return { + success: true, + result: currentWorkspaceId, + }; + } + + public async getCurrentWorkspace(): Promise> { + const currentWorkspaceIdResp = await this.getCurrentWorkspaceId(); + if (currentWorkspaceIdResp.success) { + const currentWorkspaceResp = await this.get(currentWorkspaceIdResp.result); + return currentWorkspaceResp; + } else { + return currentWorkspaceIdResp; + } + } + + /** + * Persists an workspace + * + * @param attributes + * @returns + */ + public async create( + attributes: Omit, + permissions?: WorkspacePermissionItem[] + ): Promise> { + const path = this.getPath(); + + const result = await this.safeFetch(path, { + method: 'POST', + body: JSON.stringify({ + attributes, + permissions, + }), + }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + /** + * Deletes a workspace + * + * @param id + * @returns + */ + public async delete(id: string): Promise> { + const result = await this.safeFetch(this.getPath(id), { method: 'DELETE' }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + /** + * Search for workspaces + * + * @param {object} [options={}] + * @property {string} options.search + * @property {string} options.searchFields - see OpenSearch Simple Query String + * Query field argument for more information + * @property {integer} [options.page=1] + * @property {integer} [options.perPage=20] + * @property {array} options.fields + * @property {string array} permissionModes + * @returns A find result with workspaces matching the specified search. + */ + public list( + options?: WorkspaceFindOptions + ): Promise< + IResponse<{ + workspaces: WorkspaceAttribute[]; + total: number; + per_page: number; + page: number; + }> + > { + const path = this.getPath('_list'); + return this.safeFetch(path, { + method: 'POST', + body: JSON.stringify(options || {}), + }); + } + + /** + * Fetches a single workspace + * + * @param {string} id + * @returns The workspace for the given id. + */ + public get(id: string): Promise> { + const path = this.getPath(id); + return this.safeFetch(path, { + method: 'GET', + }); + } + + /** + * Updates a workspace + * + * @param {string} id + * @param {object} attributes + * @returns + */ + public async update( + id: string, + attributes: Partial, + permissions?: WorkspacePermissionItem[] + ): Promise> { + const path = this.getPath(id); + const body = { + attributes, + permissions, + }; + + const result = await this.safeFetch(path, { + method: 'PUT', + body: JSON.stringify(body), + }); + + if (result.success) { + await this.updateWorkspaceList(); + } + + return result; + } + + public stop() { + this.workspaces.workspaceList$.unsubscribe(); + this.workspaces.currentWorkspaceId$.unsubscribe(); + } +} diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index e14aa3de16a3..36f1b4e6d8a4 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -5,6 +5,7 @@ import { WorkspaceAttribute } from 'src/core/types'; import * as osdTestServer from '../../../../core/test_helpers/osd_server'; +import { WORKSPACE_TYPE } from '../../../../core/server'; const omitId = (object: T): Omit => { const { id, ...others } = object; @@ -27,14 +28,18 @@ describe('workspace service', () => { osd: { workspace: { enabled: true, + permission: { + enabled: false, + }, }, + migrations: { skip: false }, }, }, }); opensearchServer = await startOpenSearch(); const startOSDResp = await startOpenSearchDashboards(); root = startOSDResp.root; - }); + }, 30000); afterAll(async () => { await root.shutdown(); await opensearchServer.stop(); @@ -49,7 +54,11 @@ describe('workspace service', () => { .expect(200); await Promise.all( listResult.body.result.workspaces.map((item: WorkspaceAttribute) => - osdTestServer.request.delete(root, `/api/workspaces/${item.id}`).expect(200) + // workspace delete API will not able to delete reserved workspace + // to clean up the test data, change it saved objects delete API + osdTestServer.request + .delete(root, `/api/saved_objects/${WORKSPACE_TYPE}/${item.id}`) + .expect(200) ) ); }); @@ -111,6 +120,39 @@ describe('workspace service', () => { expect(getResult.body.success).toEqual(true); expect(getResult.body.result.name).toEqual('updated'); }); + it('update with permission', async () => { + const permission = { + userId: 'foo', + type: 'user', + modes: ['read', 'library_read'], + }; + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + }) + .expect(200); + + await osdTestServer.request + .put(root, `/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omitId(testWorkspace), + name: 'updated', + }, + permissions: permission, + }) + .expect(200); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + + expect(getResult.body.success).toEqual(true); + expect(getResult.body.result.name).toEqual('updated'); + expect(getResult.body.result.permissions[0]).toEqual(permission); + }); it('delete', async () => { const result: any = await osdTestServer.request .post(root, `/api/workspaces`) @@ -119,6 +161,16 @@ describe('workspace service', () => { }) .expect(200); + await osdTestServer.request + .post(root, `/api/saved_objects/index-pattern/logstash-*`) + .send({ + attributes: { + title: 'logstash-*', + }, + workspaces: [result.body.result.id], + }) + .expect(200); + await osdTestServer.request .delete(root, `/api/workspaces/${result.body.result.id}`) .expect(200); @@ -129,6 +181,29 @@ describe('workspace service', () => { ); expect(getResult.body.success).toEqual(false); + + // saved objects been deleted + await osdTestServer.request + .get(root, `/api/saved_objects/index-pattern/logstash-*`) + .expect(404); + }); + it('delete reserved workspace', async () => { + const reservedWorkspace: WorkspaceAttribute = { ...testWorkspace, reserved: true }; + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(reservedWorkspace), + }) + .expect(200); + + const deleteResult = await osdTestServer.request + .delete(root, `/api/workspaces/${result.body.result.id}`) + .expect(200); + + expect(deleteResult.body.success).toEqual(false); + expect(deleteResult.body.error).toEqual( + `Reserved workspace ${result.body.result.id} is not allowed to delete.` + ); }); it('list', async () => { await osdTestServer.request diff --git a/src/plugins/workspace/server/permission_control/client.mock.ts b/src/plugins/workspace/server/permission_control/client.mock.ts new file mode 100644 index 000000000000..278a2fcbccf9 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.mock.ts @@ -0,0 +1,13 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { SavedObjectsPermissionControlContract } from './client'; + +export const savedObjectsPermissionControlMock: SavedObjectsPermissionControlContract = { + validate: jest.fn(), + batchValidate: jest.fn(), + getPrincipalsOfObjects: jest.fn(), + getPermittedWorkspaceIds: jest.fn(), + setup: jest.fn(), +}; diff --git a/src/plugins/workspace/server/permission_control/client.ts b/src/plugins/workspace/server/permission_control/client.ts new file mode 100644 index 000000000000..b141ec2e7313 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.ts @@ -0,0 +1,156 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { i18n } from '@osd/i18n'; +import { + OpenSearchDashboardsRequest, + Principals, + SavedObject, + WORKSPACE_TYPE, +} from '../../../../core/server'; +import { + ACL, + TransformedPermission, + SavedObjectsBulkGetObject, + SavedObjectsServiceStart, + Logger, +} from '../../../../core/server'; +import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../../common/constants'; +import { getPrincipalsFromRequest } from '../utils'; + +export type SavedObjectsPermissionControlContract = Pick< + SavedObjectsPermissionControl, + keyof SavedObjectsPermissionControl +>; + +export type SavedObjectsPermissionModes = string[]; + +export class SavedObjectsPermissionControl { + private readonly logger: Logger; + private _getScopedClient?: SavedObjectsServiceStart['getScopedClient']; + private getScopedClient(request: OpenSearchDashboardsRequest) { + return this._getScopedClient?.(request, { + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + includedHiddenTypes: [WORKSPACE_TYPE], + }); + } + + constructor(logger: Logger) { + this.logger = logger; + } + + private async bulkGetSavedObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[] + ) { + return (await this.getScopedClient?.(request)?.bulkGet(savedObjects))?.saved_objects || []; + } + public async setup(getScopedClient: SavedObjectsServiceStart['getScopedClient']) { + this._getScopedClient = getScopedClient; + } + + private logNotPermitted( + savedObjects: Array, 'id' | 'type' | 'workspaces' | 'permissions'>>, + principals: Principals, + permissionModes: SavedObjectsPermissionModes + ) { + this.logger.debug( + `Authorization failed, principals: ${JSON.stringify( + principals + )} has no [${permissionModes}] permissions on the requested saved object: ${JSON.stringify( + savedObjects.map((savedObject) => ({ + id: savedObject.id, + type: savedObject.type, + workspaces: savedObject.workspaces, + permissions: savedObject.permissions, + })) + )}` + ); + } + + public validateSavedObjectsACL( + savedObjects: Array, 'id' | 'type' | 'workspaces' | 'permissions'>>, + principals: Principals, + permissionModes: SavedObjectsPermissionModes + ) { + const notPermittedSavedObjects: Array, + 'id' | 'type' | 'workspaces' | 'permissions' + >> = []; + const hasAllPermission = savedObjects.every((savedObject) => { + // for object that doesn't contain ACL like config, return true + if (!savedObject.permissions) { + return true; + } + + const aclInstance = new ACL(savedObject.permissions); + const hasPermission = aclInstance.hasPermission(permissionModes, principals); + if (!hasPermission) { + notPermittedSavedObjects.push(savedObject); + } + return hasPermission; + }); + if (!hasAllPermission) { + this.logNotPermitted(notPermittedSavedObjects, principals, permissionModes); + } + return hasAllPermission; + } + + public async validate( + request: OpenSearchDashboardsRequest, + savedObject: SavedObjectsBulkGetObject, + permissionModes: SavedObjectsPermissionModes + ) { + return await this.batchValidate(request, [savedObject], permissionModes); + } + + /** + * In batch validate case, the logic is a.withPermission && b.withPermission + * @param request + * @param savedObjects + * @param permissionModes + * @returns + */ + public async batchValidate( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[], + permissionModes: SavedObjectsPermissionModes + ) { + const savedObjectsGet = await this.bulkGetSavedObjects(request, savedObjects); + if (!savedObjectsGet) { + return { + success: false, + error: i18n.translate('savedObjects.permission.notFound', { + defaultMessage: 'Can not find target saved objects.', + }), + }; + } + + if (savedObjectsGet.length === 1 && !!savedObjectsGet[0].error) { + return { + success: false, + error: savedObjectsGet[0].error, + }; + } + + const principals = getPrincipalsFromRequest(request); + return { + success: true, + result: this.validateSavedObjectsACL(savedObjectsGet, principals, permissionModes), + }; + } + + public async getPrincipalsOfObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[] + ): Promise> { + const detailedSavedObjects = await this.bulkGetSavedObjects(request, savedObjects); + return detailedSavedObjects.reduce((total, current) => { + return { + ...total, + [current.id]: new ACL(current.permissions).toFlatList(), + }; + }, {}); + } +} diff --git a/src/plugins/workspace/server/permission_control/routes/index.ts b/src/plugins/workspace/server/permission_control/routes/index.ts new file mode 100644 index 000000000000..8bf96a159d34 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/routes/index.ts @@ -0,0 +1,22 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { HttpServiceSetup } from '../../../../../core/server'; +import { SavedObjectsPermissionControlContract } from '../client'; +import { registerListRoute } from './principals'; +import { registerValidateRoute } from './validate'; + +export function registerPermissionCheckRoutes({ + http, + permissionControl, +}: { + http: HttpServiceSetup; + permissionControl: SavedObjectsPermissionControlContract; +}) { + const router = http.createRouter(); + + registerValidateRoute(router, permissionControl); + registerListRoute(router, permissionControl); +} diff --git a/src/plugins/workspace/server/permission_control/routes/principals.ts b/src/plugins/workspace/server/permission_control/routes/principals.ts new file mode 100644 index 000000000000..a10517684d08 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/routes/principals.ts @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../../../../core/server'; +import { SavedObjectsPermissionControlContract } from '../client'; +import { WORKSPACES_API_BASE_URL } from '../../routes'; + +export const registerListRoute = ( + router: IRouter, + permissionControl: SavedObjectsPermissionControlContract +) => { + router.post( + { + path: `${WORKSPACES_API_BASE_URL}/principals`, + validate: { + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const result = await permissionControl.getPrincipalsOfObjects(req, req.body.objects); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/plugins/workspace/server/permission_control/routes/validate.ts b/src/plugins/workspace/server/permission_control/routes/validate.ts new file mode 100644 index 000000000000..37a5b0d3144e --- /dev/null +++ b/src/plugins/workspace/server/permission_control/routes/validate.ts @@ -0,0 +1,41 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../../../../core/server'; +import { SavedObjectsPermissionControlContract } from '../client'; +import { WORKSPACES_API_BASE_URL } from '../../routes'; + +export const registerValidateRoute = ( + router: IRouter, + permissionControl: SavedObjectsPermissionControlContract +) => { + router.post( + { + path: `${WORKSPACES_API_BASE_URL}/validate/{type}/{id}`, + validate: { + params: schema.object({ + type: schema.string(), + id: schema.string(), + }), + body: schema.object({ + permissionModes: schema.arrayOf(schema.string()), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { type, id } = req.params; + const result = await permissionControl.validate( + req, + { + type, + id, + }, + req.body.permissionModes + ); + return res.ok({ body: result }); + }) + ); +}; diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 38e8a3c18f8c..02e9d5a27dfd 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -2,47 +2,129 @@ * Copyright OpenSearch Contributors * SPDX-License-Identifier: Apache-2.0 */ - +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { PluginInitializerContext, CoreSetup, + CoreStart, Plugin, Logger, - CoreStart, + SavedObjectsClient, + WORKSPACE_TYPE, } from '../../../core/server'; import { IWorkspaceClientImpl } from './types'; -import { WorkspaceClient } from './workspace_client'; +import { WorkspaceClientWithSavedObject } from './workspace_client'; +import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; import { registerRoutes } from './routes'; +import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; +import { + SavedObjectsPermissionControl, + SavedObjectsPermissionControlContract, +} from './permission_control/client'; +import { registerPermissionCheckRoutes } from './permission_control/routes'; +import { ConfigSchema } from '../config'; +import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; +import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; +import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; export class WorkspacePlugin implements Plugin<{}, {}> { private readonly logger: Logger; private client?: IWorkspaceClientImpl; + private permissionControl?: SavedObjectsPermissionControlContract; + private readonly config$: Observable; + private workspaceSavedObjectsClientWrapper?: WorkspaceSavedObjectsClientWrapper; + private workspaceConflictControl?: WorkspaceConflictSavedObjectsClientWrapper; + + private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { + /** + * Proxy all {basePath}/w/{workspaceId}{osdPath*} paths to {basePath}{osdPath*} + */ + setupDeps.http.registerOnPreRouting(async (request, response, toolkit) => { + const workspaceId = getWorkspaceIdFromUrl(request.url.toString()); + + if (workspaceId) { + const requestUrl = new URL(request.url.toString()); + requestUrl.pathname = cleanWorkspaceId(requestUrl.pathname); + return toolkit.rewriteUrl(requestUrl.toString()); + } + return toolkit.next(); + }); + } constructor(initializerContext: PluginInitializerContext) { this.logger = initializerContext.logger.get('plugins', 'workspace'); + this.config$ = initializerContext.config.create(); } public async setup(core: CoreSetup) { this.logger.debug('Setting up Workspaces service'); + const config: ConfigSchema = await this.config$.pipe(first()).toPromise(); + const isPermissionControlEnabled = + config.permission.enabled === undefined ? true : config.permission.enabled; - this.client = new WorkspaceClient(core); + this.client = new WorkspaceClientWithSavedObject(core, this.logger); await this.client.setup(core); + this.logger.info('Workspace permission control enabled:' + isPermissionControlEnabled); + if (isPermissionControlEnabled) { + this.permissionControl = new SavedObjectsPermissionControl(this.logger); + + registerPermissionCheckRoutes({ + http: core.http, + permissionControl: this.permissionControl, + }); + + this.workspaceSavedObjectsClientWrapper = new WorkspaceSavedObjectsClientWrapper( + this.permissionControl + ); + + core.savedObjects.addClientWrapper( + 0, + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + this.workspaceSavedObjectsClientWrapper.wrapperFactory + ); + } + + this.proxyWorkspaceTrafficToRealHandler(core); + this.workspaceConflictControl = new WorkspaceConflictSavedObjectsClientWrapper(); + + core.savedObjects.addClientWrapper( + -1, + WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + this.workspaceConflictControl.wrapperFactory + ); + registerRoutes({ http: core.http, logger: this.logger, client: this.client as IWorkspaceClientImpl, }); + core.savedObjects.setClientFactoryProvider( + (repositoryFactory) => ({ includedHiddenTypes }: { includedHiddenTypes?: string[] }) => + new SavedObjectsClient(repositoryFactory.createInternalRepository(includedHiddenTypes)) + ); + + core.capabilities.registerProvider(() => ({ + workspaces: { + enabled: true, + permissionEnabled: isPermissionControlEnabled, + }, + })); + return { client: this.client, }; } public start(core: CoreStart) { - this.logger.debug('Starting Workspace service'); + this.logger.debug('Starting SavedObjects service'); + this.permissionControl?.setup(core.savedObjects.getScopedClient); this.client?.setSavedObjects(core.savedObjects); + this.workspaceSavedObjectsClientWrapper?.setScopedClient(core.savedObjects.getScopedClient); + this.workspaceConflictControl?.setSerializer(core.savedObjects.createSerializer()); return { client: this.client as IWorkspaceClientImpl, diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 5789aa0481fa..42ff4bba6001 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -4,10 +4,32 @@ */ import { schema } from '@osd/config-schema'; -import { CoreSetup, Logger } from '../../../../core/server'; -import { IWorkspaceClientImpl } from '../types'; +import { ensureRawRequest } from '../../../../core/server'; -const WORKSPACES_API_BASE_URL = '/api/workspaces'; +import { CoreSetup, Logger, WorkspacePermissionMode } from '../../../../core/server'; +import { IWorkspaceClientImpl, WorkspacePermissionItem } from '../types'; + +export const WORKSPACES_API_BASE_URL = '/api/workspaces'; + +const workspacePermissionMode = schema.oneOf([ + schema.literal(WorkspacePermissionMode.Read), + schema.literal(WorkspacePermissionMode.Write), + schema.literal(WorkspacePermissionMode.LibraryRead), + schema.literal(WorkspacePermissionMode.LibraryWrite), +]); + +const workspacePermission = schema.oneOf([ + schema.object({ + type: schema.literal('user'), + userId: schema.string(), + modes: schema.arrayOf(workspacePermissionMode), + }), + schema.object({ + type: schema.literal('group'), + group: schema.string(), + modes: schema.arrayOf(workspacePermissionMode), + }), +]); const workspaceAttributesSchema = schema.object({ description: schema.maybe(schema.string()), @@ -15,6 +37,7 @@ const workspaceAttributesSchema = schema.object({ features: schema.maybe(schema.arrayOf(schema.string())), color: schema.maybe(schema.string()), icon: schema.maybe(schema.string()), + reserved: schema.maybe(schema.boolean()), defaultVISTheme: schema.maybe(schema.string()), }); @@ -39,6 +62,7 @@ export function registerRoutes({ page: schema.number({ min: 0, defaultValue: 1 }), sortField: schema.maybe(schema.string()), searchFields: schema.maybe(schema.arrayOf(schema.string())), + permissionModes: schema.maybe(schema.arrayOf(workspacePermissionMode)), }), }, }, @@ -78,6 +102,9 @@ export function registerRoutes({ }, id ); + if (!result.success) { + return res.ok({ body: result }); + } return res.ok({ body: result, @@ -90,11 +117,31 @@ export function registerRoutes({ validate: { body: schema.object({ attributes: workspaceAttributesSchema, + permissions: schema.maybe( + schema.oneOf([workspacePermission, schema.arrayOf(workspacePermission)]) + ), }), }, }, router.handleLegacyErrors(async (context, req, res) => { - const { attributes } = req.body; + const { attributes, permissions: permissionsInRequest } = req.body; + const rawRequest = ensureRawRequest(req); + const authInfo = rawRequest?.auth?.credentials?.authInfo as { user_name?: string } | null; + let permissions: WorkspacePermissionItem[] = []; + if (permissionsInRequest) { + permissions = Array.isArray(permissionsInRequest) + ? permissionsInRequest + : [permissionsInRequest]; + } + + // Assign workspace owner to current user + if (!!authInfo?.user_name) { + permissions.push({ + type: 'user', + userId: authInfo.user_name, + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }); + } const result = await client.create( { @@ -102,7 +149,10 @@ export function registerRoutes({ request: req, logger, }, - attributes + { + ...attributes, + ...(permissions.length ? { permissions } : {}), + } ); return res.ok({ body: result }); }) @@ -116,12 +166,19 @@ export function registerRoutes({ }), body: schema.object({ attributes: workspaceAttributesSchema, + permissions: schema.maybe( + schema.oneOf([workspacePermission, schema.arrayOf(workspacePermission)]) + ), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { id } = req.params; - const { attributes } = req.body; + const { attributes, permissions } = req.body; + let finalPermissions: WorkspacePermissionItem[] = []; + if (permissions) { + finalPermissions = Array.isArray(permissions) ? permissions : [permissions]; + } const result = await client.update( { @@ -130,7 +187,10 @@ export function registerRoutes({ logger, }, id, - attributes + { + ...attributes, + ...(finalPermissions.length ? { permissions: finalPermissions } : {}), + } ); return res.ok({ body: result }); }) diff --git a/src/plugins/workspace/server/saved_objects/index.ts b/src/plugins/workspace/server/saved_objects/index.ts index 51653c50681e..e47be61b0cd2 100644 --- a/src/plugins/workspace/server/saved_objects/index.ts +++ b/src/plugins/workspace/server/saved_objects/index.ts @@ -4,3 +4,4 @@ */ export { workspace } from './workspace'; +export { WorkspaceSavedObjectsClientWrapper } from './workspace_saved_objects_client_wrapper'; diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts new file mode 100644 index 000000000000..570dbb6a59e8 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts @@ -0,0 +1,341 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObject } from 'src/core/types'; +import { isEqual } from 'lodash'; +import * as osdTestServer from '../../../../../core/test_helpers/osd_server'; + +const dashboard: Omit = { + type: 'dashboard', + attributes: {}, + references: [], +}; + +interface WorkspaceAttributes { + id: string; + name?: string; +} + +describe('saved_objects_wrapper_for_check_workspace_conflict integration test', () => { + let root: ReturnType; + let opensearchServer: osdTestServer.TestOpenSearchUtils; + let createdFooWorkspace: WorkspaceAttributes = { + id: '', + }; + let createdBarWorkspace: WorkspaceAttributes = { + id: '', + }; + beforeAll(async () => { + const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + osd: { + workspace: { + enabled: true, + }, + }, + }, + }); + opensearchServer = await startOpenSearch(); + const startOSDResp = await startOpenSearchDashboards(); + root = startOSDResp.root; + const createWorkspace = (workspaceAttribute: Omit) => + osdTestServer.request.post(root, `/api/workspaces`).send({ + attributes: workspaceAttribute, + }); + + createdFooWorkspace = await createWorkspace({ + name: 'foo', + }).then((resp) => resp.body.result); + createdBarWorkspace = await createWorkspace({ + name: 'bar', + }).then((resp) => resp.body.result); + }, 30000); + afterAll(async () => { + await root.shutdown(); + await opensearchServer.stop(); + }); + + const deleteItem = async (object: Pick) => { + expect( + [200, 404].includes( + (await osdTestServer.request.delete(root, `/api/saved_objects/${object.type}/${object.id}`)) + .statusCode + ) + ).toEqual(true); + }; + + const getItem = async (object: Pick) => { + return await osdTestServer.request + .get(root, `/api/saved_objects/${object.type}/${object.id}`) + .expect(200); + }; + + const clearFooAndBar = async () => { + await deleteItem({ + type: dashboard.type, + id: 'foo', + }); + await deleteItem({ + type: dashboard.type, + id: 'bar', + }); + }; + + describe('workspace related CRUD', () => { + it('create', async () => { + const createResult = await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + workspaces: [createdFooWorkspace.id], + }) + .expect(200); + + expect(createResult.body.workspaces).toEqual([createdFooWorkspace.id]); + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + + it('create-with-override', async () => { + const createResult = await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + workspaces: [createdFooWorkspace.id], + }) + .expect(200); + + await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}/${createResult.body.id}?overwrite=true`) + .send({ + attributes: dashboard.attributes, + workspaces: [createdBarWorkspace.id], + }) + .expect(409); + + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + + it('bulk create', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdFooWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdBarWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + expect((createResultFoo.body.saved_objects as any[]).some((item) => item.error)).toEqual( + false + ); + expect( + (createResultFoo.body.saved_objects as any[]).every((item) => + isEqual(item.workspaces, [createdFooWorkspace.id]) + ) + ).toEqual(true); + expect((createResultBar.body.saved_objects as any[]).some((item) => item.error)).toEqual( + false + ); + expect( + (createResultBar.body.saved_objects as any[]).every((item) => + isEqual(item.workspaces, [createdBarWorkspace.id]) + ) + ).toEqual(true); + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('bulk create with conflict', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdFooWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdBarWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + /** + * overwrite with workspaces + */ + const overwriteWithWorkspacesResult = await osdTestServer.request + .post( + root, + `/api/saved_objects/_bulk_create?overwrite=true&workspaces=${createdFooWorkspace.id}` + ) + .send([ + { + ...dashboard, + id: 'bar', + }, + { + ...dashboard, + id: 'foo', + attributes: { + title: 'foo', + }, + }, + ]) + .expect(200); + + expect(overwriteWithWorkspacesResult.body.saved_objects[0].error.statusCode).toEqual(409); + expect(overwriteWithWorkspacesResult.body.saved_objects[1].attributes.title).toEqual('foo'); + expect(overwriteWithWorkspacesResult.body.saved_objects[1].workspaces).toEqual([ + createdFooWorkspace.id, + ]); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('checkConflicts when importing ndjson', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdFooWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdBarWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const getResultFoo = await getItem({ + type: dashboard.type, + id: 'foo', + }); + const getResultBar = await getItem({ + type: dashboard.type, + id: 'bar', + }); + + /** + * import with workspaces when conflicts + */ + const importWithWorkspacesResult = await osdTestServer.request + .post( + root, + `/api/saved_objects/_import?workspaces=${createdFooWorkspace.id}&overwrite=false` + ) + .attach( + 'file', + Buffer.from( + [JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n'), + 'utf-8' + ), + 'tmp.ndjson' + ) + .expect(200); + + expect(importWithWorkspacesResult.body.success).toEqual(false); + expect(importWithWorkspacesResult.body.errors.length).toEqual(1); + expect(importWithWorkspacesResult.body.errors[0].id).toEqual('foo'); + expect(importWithWorkspacesResult.body.errors[0].error.type).toEqual('conflict'); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('find by workspaces', async () => { + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdFooWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=${createdBarWorkspace.id}`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const findResult = await osdTestServer.request + .get( + root, + `/api/saved_objects/_find?workspaces=${createdBarWorkspace.id}&type=${dashboard.type}` + ) + .expect(200); + + expect(findResult.body.total).toEqual(1); + expect(findResult.body.saved_objects[0].workspaces).toEqual([createdBarWorkspace.id]); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + }); +}); diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts new file mode 100644 index 000000000000..8af960e68e2c --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts @@ -0,0 +1,643 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ISavedObjectsRepository, SavedObjectsClientContract } from 'src/core/server'; + +import { + createTestServers, + TestOpenSearchUtils, + TestOpenSearchDashboardsUtils, + TestUtils, +} from '../../../../../core/test_helpers/osd_server'; +import { SavedObjectsErrorHelpers } from '../../../../../core/server'; +import { httpServerMock } from '../../../../../../src/core/server/mocks'; +import * as utilsExports from '../../utils'; + +const repositoryKit = (() => { + const savedObjects: Array<{ type: string; id: string }> = []; + return { + create: async ( + repository: ISavedObjectsRepository, + ...params: Parameters + ) => { + let result; + try { + result = params[2]?.id ? await repository.get(params[0], params[2].id) : undefined; + } catch (_e) { + // ignore error when get failed + } + if (!result) { + result = await repository.create(...params); + } + savedObjects.push(result); + return result; + }, + clearAll: async (repository: ISavedObjectsRepository) => { + for (let i = 0; i < savedObjects.length; i++) { + try { + await repository.delete(savedObjects[i].type, savedObjects[i].id); + } catch (_e) { + // Ignore delete error + } + } + }, + }; +})(); + +const permittedRequest = httpServerMock.createOpenSearchDashboardsRequest(); +const notPermittedRequest = httpServerMock.createOpenSearchDashboardsRequest(); + +describe('WorkspaceSavedObjectsClientWrapper', () => { + let internalSavedObjectsRepository: ISavedObjectsRepository; + let servers: TestUtils; + let opensearchServer: TestOpenSearchUtils; + let osd: TestOpenSearchDashboardsUtils; + let permittedSavedObjectedClient: SavedObjectsClientContract; + let notPermittedSavedObjectedClient: SavedObjectsClientContract; + + beforeAll(async function () { + servers = createTestServers({ + adjustTimeout: (t) => { + jest.setTimeout(t); + }, + settings: { + osd: { + workspace: { + enabled: true, + }, + migrations: { skip: false }, + }, + }, + }); + opensearchServer = await servers.startOpenSearch(); + osd = await servers.startOpenSearchDashboards(); + + internalSavedObjectsRepository = osd.coreStart.savedObjects.createInternalRepository(); + + await repositoryKit.create( + internalSavedObjectsRepository, + 'workspace', + {}, + { + id: 'workspace-1', + permissions: { + library_read: { users: ['foo'] }, + library_write: { users: ['foo'] }, + }, + } + ); + + await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + id: 'inner-workspace-dashboard-1', + workspaces: ['workspace-1'], + } + ); + + await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + id: 'acl-controlled-dashboard-2', + permissions: { + read: { users: ['foo'], groups: [] }, + write: { users: ['foo'], groups: [] }, + }, + } + ); + + jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockImplementation((request) => { + if (request === notPermittedRequest) { + return { users: ['bar'] }; + } + return { users: ['foo'] }; + }); + + permittedSavedObjectedClient = osd.coreStart.savedObjects.getScopedClient(permittedRequest); + notPermittedSavedObjectedClient = osd.coreStart.savedObjects.getScopedClient( + notPermittedRequest + ); + }); + + afterAll(async () => { + await repositoryKit.clearAll(internalSavedObjectsRepository); + await opensearchServer.stop(); + await osd.stop(); + + jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockRestore(); + }); + + describe('get', () => { + it('should throw forbidden error when user not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.get('dashboard', 'inner-workspace-dashboard-1'); + } catch (e) { + error = e; + } + expect(error).not.toBeUndefined(); + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.get('dashboard', 'acl-controlled-dashboard-2'); + } catch (e) { + error = e; + } + expect(error).not.toBeUndefined(); + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should return consistent dashboard when user permitted', async () => { + expect( + (await permittedSavedObjectedClient.get('dashboard', 'inner-workspace-dashboard-1')).error + ).toBeUndefined(); + expect( + (await permittedSavedObjectedClient.get('dashboard', 'acl-controlled-dashboard-2')).error + ).toBeUndefined(); + }); + }); + + describe('bulkGet', () => { + it('should throw forbidden error when user not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1' }, + ]); + } catch (e) { + error = e; + } + expect(error).not.toBeUndefined(); + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'acl-controlled-dashboard-2' }, + ]); + } catch (e) { + error = e; + } + expect(error).not.toBeUndefined(); + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should return consistent dashboard when user permitted', async () => { + expect( + ( + await permittedSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1' }, + ]) + ).saved_objects.length + ).toEqual(1); + expect( + ( + await permittedSavedObjectedClient.bulkGet([ + { type: 'dashboard', id: 'acl-controlled-dashboard-2' }, + ]) + ).saved_objects.length + ).toEqual(1); + }); + }); + + describe('find', () => { + it('should throw not authorized error when user not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.find({ + type: 'dashboard', + workspaces: ['workspace-1'], + perPage: 999, + page: 1, + }); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isNotAuthorizedError(error)).toBe(true); + }); + + it('should return consistent inner workspace data when user permitted', async () => { + const result = await permittedSavedObjectedClient.find({ + type: 'dashboard', + workspaces: ['workspace-1'], + perPage: 999, + page: 1, + }); + + expect(result.saved_objects.some((item) => item.id === 'inner-workspace-dashboard-1')).toBe( + true + ); + }); + }); + + describe('create', () => { + it('should throw forbidden error when workspace not permitted and create called', async () => { + let error; + try { + await notPermittedSavedObjectedClient.create( + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should able to create saved objects into permitted workspaces after create called', async () => { + const createResult = await permittedSavedObjectedClient.create( + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + expect(createResult.error).toBeUndefined(); + await permittedSavedObjectedClient.delete('dashboard', createResult.id); + }); + + it('should throw forbidden error when create with override', async () => { + let error; + try { + await notPermittedSavedObjectedClient.create( + 'dashboard', + {}, + { + id: 'inner-workspace-dashboard-1', + overwrite: true, + } + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should able to create with override', async () => { + const createResult = await permittedSavedObjectedClient.create( + 'dashboard', + {}, + { + id: 'inner-workspace-dashboard-1', + overwrite: true, + workspaces: ['workspace-1'], + } + ); + + expect(createResult.error).toBeUndefined(); + }); + }); + + describe('bulkCreate', () => { + it('should throw forbidden error when workspace not permitted and bulkCreate called', async () => { + let error; + try { + await notPermittedSavedObjectedClient.bulkCreate([{ type: 'dashboard', attributes: {} }], { + workspaces: ['workspace-1'], + }); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should able to create saved objects into permitted workspaces after bulkCreate called', async () => { + const objectId = new Date().getTime().toString(16).toUpperCase(); + const result = await permittedSavedObjectedClient.bulkCreate( + [{ type: 'dashboard', attributes: {}, id: objectId }], + { + workspaces: ['workspace-1'], + } + ); + expect(result.saved_objects.length).toEqual(1); + await permittedSavedObjectedClient.delete('dashboard', objectId); + }); + + it('should throw forbidden error when create with override', async () => { + let error; + try { + await notPermittedSavedObjectedClient.bulkCreate( + [ + { + id: 'inner-workspace-dashboard-1', + type: 'dashboard', + attributes: {}, + }, + ], + { + overwrite: true, + workspaces: ['workspace-1'], + } + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should able to bulk create with override', async () => { + const createResult = await permittedSavedObjectedClient.bulkCreate( + [ + { + id: 'inner-workspace-dashboard-1', + type: 'dashboard', + attributes: {}, + }, + ], + { + overwrite: true, + workspaces: ['workspace-1'], + } + ); + + expect(createResult.saved_objects).toHaveLength(1); + }); + }); + + describe('update', () => { + it('should throw forbidden error when data not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.update( + 'dashboard', + 'inner-workspace-dashboard-1', + {} + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.update('dashboard', 'acl-controlled-dashboard-2', {}); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should update saved objects for permitted workspaces', async () => { + expect( + (await permittedSavedObjectedClient.update('dashboard', 'inner-workspace-dashboard-1', {})) + .error + ).toBeUndefined(); + expect( + (await permittedSavedObjectedClient.update('dashboard', 'acl-controlled-dashboard-2', {})) + .error + ).toBeUndefined(); + }); + }); + + describe('bulkUpdate', () => { + it('should throw forbidden error when data not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.bulkUpdate( + [{ type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }], + {} + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.bulkUpdate( + [{ type: 'dashboard', id: 'acl-controlled-dashboard-2', attributes: {} }], + {} + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should bulk update saved objects for permitted workspaces', async () => { + expect( + ( + await permittedSavedObjectedClient.bulkUpdate([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }, + ]) + ).saved_objects.length + ).toEqual(1); + expect( + ( + await permittedSavedObjectedClient.bulkUpdate([ + { type: 'dashboard', id: 'inner-workspace-dashboard-1', attributes: {} }, + ]) + ).saved_objects.length + ).toEqual(1); + }); + }); + + describe('delete', () => { + it('should throw forbidden error when data not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.delete('dashboard', 'inner-workspace-dashboard-1'); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + + error = undefined; + try { + await notPermittedSavedObjectedClient.delete('dashboard', 'acl-controlled-dashboard-2'); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should be able to delete permitted data', async () => { + const createResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + workspaces: ['workspace-1'], + } + ); + + await permittedSavedObjectedClient.delete('dashboard', createResult.id); + + let error; + try { + error = await permittedSavedObjectedClient.get('dashboard', createResult.id); + } catch (e) { + error = e; + } + expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(true); + }); + + it('should be able to delete acl controlled permitted data', async () => { + const createResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + permissions: { + read: { users: ['foo'] }, + write: { users: ['foo'] }, + }, + } + ); + + await permittedSavedObjectedClient.delete('dashboard', createResult.id); + + let error; + try { + error = await permittedSavedObjectedClient.get('dashboard', createResult.id); + } catch (e) { + error = e; + } + expect(SavedObjectsErrorHelpers.isNotFoundError(error)).toBe(true); + }); + }); + + describe('addToWorkspaces', () => { + it('should throw forbidden error when workspace not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.addToWorkspaces( + [{ type: 'dashboard', id: 'acl-controlled-dashboard-2' }], + ['workspace-1'] + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should throw forbidden error when object ACL not permitted', async () => { + const createResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + permissions: { + read: { users: ['foo'] }, + }, + } + ); + let error; + try { + await permittedSavedObjectedClient.addToWorkspaces( + [{ type: 'dashboard', id: createResult.id }], + ['workspace-1'] + ); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should be able to add to target workspaces', async () => { + const createResult = await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + workspaces: ['workspace-2'], + permissions: { + read: { users: ['foo'] }, + write: { users: ['foo'] }, + }, + } + ); + await permittedSavedObjectedClient.addToWorkspaces( + [{ type: 'dashboard', id: createResult.id }], + ['workspace-1'] + ); + + const result = await permittedSavedObjectedClient.find({ + type: 'dashboard', + workspaces: ['workspace-1'], + perPage: 999, + page: 1, + }); + + expect(result.saved_objects.some((item) => item.id === createResult.id)).toBe(true); + }); + }); + + describe('deleteByWorkspace', () => { + it('should throw forbidden error when workspace not permitted', async () => { + let error; + try { + await notPermittedSavedObjectedClient.deleteByWorkspace('workspace-1'); + } catch (e) { + error = e; + } + + expect(SavedObjectsErrorHelpers.isForbiddenError(error)).toBe(true); + }); + + it('should delete workspace inner data when workspace permitted', async () => { + await repositoryKit.create( + internalSavedObjectsRepository, + 'workspace', + {}, + { + id: 'workspace-3', + permissions: { + library_read: { users: ['foo'] }, + library_write: { users: ['foo'] }, + }, + } + ); + + await repositoryKit.create( + internalSavedObjectsRepository, + 'dashboard', + {}, + { + workspaces: ['workspace-3'], + } + ); + + await permittedSavedObjectedClient.deleteByWorkspace('workspace-3'); + + // Wait for delete be effected + await new Promise((resolve) => { + setTimeout(resolve, 1000); + }); + + expect( + ( + await permittedSavedObjectedClient.find({ + type: 'dashboard', + workspaces: ['workspace-3'], + perPage: 999, + page: 1, + }) + ).saved_objects.length + ).toEqual(0); + }); + }); +}); diff --git a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts new file mode 100644 index 000000000000..cac06d789822 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.test.ts @@ -0,0 +1,351 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObject } from '../../../../core/public'; +import { httpServerMock, savedObjectsClientMock, coreMock } from '../../../../core/server/mocks'; +import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects_wrapper_for_check_workspace_conflict'; +import { SavedObjectsSerializer } from '../../../../core/server'; + +describe('WorkspaceConflictSavedObjectsClientWrapper', () => { + const requestHandlerContext = coreMock.createRequestHandlerContext(); + const wrapperInstance = new WorkspaceConflictSavedObjectsClientWrapper(); + const mockedClient = savedObjectsClientMock.create(); + const wrapperClient = wrapperInstance.wrapperFactory({ + client: mockedClient, + typeRegistry: requestHandlerContext.savedObjects.typeRegistry, + request: httpServerMock.createOpenSearchDashboardsRequest(), + }); + const savedObjectsSerializer = new SavedObjectsSerializer( + requestHandlerContext.savedObjects.typeRegistry + ); + const getSavedObject = (savedObject: Partial) => { + const payload: SavedObject = { + references: [], + id: '', + type: 'dashboard', + attributes: {}, + ...savedObject, + }; + + return payload; + }; + wrapperInstance.setSerializer(savedObjectsSerializer); + describe('createWithWorkspaceConflictCheck', () => { + it(`Should reserve the workspace params when overwrite with empty workspaces`, async () => { + mockedClient.get.mockResolvedValueOnce( + getSavedObject({ + id: 'dashboard:foo', + workspaces: ['foo'], + }) + ); + + await wrapperClient.create( + 'dashboard', + { + name: 'foo', + }, + { + id: 'dashboard:foo', + overwrite: true, + workspaces: [], + } + ); + + expect(mockedClient.create).toBeCalledWith( + expect.anything(), + expect.anything(), + expect.objectContaining({ + workspaces: ['foo'], + }) + ); + }); + + it(`Should return error when overwrite with conflict workspaces`, async () => { + mockedClient.get.mockResolvedValueOnce( + getSavedObject({ + id: 'dashboard:foo', + workspaces: ['foo'], + }) + ); + + await expect( + wrapperClient.create( + 'dashboard', + { + name: 'foo', + }, + { + id: 'dashboard:foo', + overwrite: true, + workspaces: ['bar'], + } + ) + ).rejects.toThrowError('Saved object [dashboard/dashboard:foo] conflict'); + }); + }); + + describe('bulkCreateWithWorkspaceConflictCheck', () => { + beforeEach(() => { + mockedClient.bulkCreate.mockClear(); + }); + it(`Should create objects when no workspaces and id present`, async () => { + mockedClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [], + }); + await wrapperClient.bulkCreate([ + getSavedObject({ + id: 'foo', + }), + ]); + + expect(mockedClient.bulkGet).not.toBeCalled(); + expect(mockedClient.bulkCreate).toBeCalledWith( + [{ attributes: {}, id: 'foo', references: [], type: 'dashboard' }], + {} + ); + }); + + it(`Should create objects when not overwrite`, async () => { + mockedClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [], + }); + await wrapperClient.bulkCreate([ + getSavedObject({ + id: 'foo', + workspaces: ['foo'], + }), + ]); + + expect(mockedClient.bulkGet).not.toBeCalled(); + expect(mockedClient.bulkCreate).toBeCalledWith( + [{ attributes: {}, id: 'foo', references: [], type: 'dashboard', workspaces: ['foo'] }], + {} + ); + }); + + it(`Should check conflict on workspace when overwrite`, async () => { + mockedClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: [ + getSavedObject({ + id: 'foo', + workspaces: ['foo'], + }), + getSavedObject({ + id: 'bar', + workspaces: ['foo', 'bar'], + }), + ], + }); + mockedClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + getSavedObject({ + id: 'foo', + workspaces: ['foo'], + }), + getSavedObject({ + id: 'bar', + workspaces: ['foo', 'bar'], + }), + getSavedObject({ + id: 'baz', + workspaces: ['baz'], + }), + getSavedObject({ + id: 'qux', + error: { + statusCode: 404, + message: 'object not found', + error: 'object not found', + }, + }), + ], + }); + const result = await wrapperClient.bulkCreate( + [ + getSavedObject({ + id: 'foo', + }), + getSavedObject({ + id: 'bar', + }), + getSavedObject({ + id: 'baz', + }), + getSavedObject({ + id: 'qux', + }), + ], + { + overwrite: true, + workspaces: ['foo'], + } + ); + + expect(mockedClient.bulkGet).toBeCalled(); + expect(mockedClient.bulkCreate).toBeCalledWith( + [ + { attributes: {}, id: 'foo', references: [], type: 'dashboard', workspaces: ['foo'] }, + { + attributes: {}, + id: 'bar', + references: [], + type: 'dashboard', + workspaces: ['foo', 'bar'], + }, + { + attributes: {}, + id: 'qux', + references: [], + type: 'dashboard', + }, + ], + { + overwrite: true, + workspaces: ['foo'], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "saved_objects": Array [ + Object { + "attributes": Object {}, + "error": Object { + "error": "Conflict", + "message": "Saved object [dashboard/baz] conflict", + "metadata": Object { + "isNotOverwritable": true, + }, + "statusCode": 409, + }, + "id": "baz", + "references": Array [], + "type": "dashboard", + }, + Object { + "attributes": Object {}, + "id": "foo", + "references": Array [], + "type": "dashboard", + "workspaces": Array [ + "foo", + ], + }, + Object { + "attributes": Object {}, + "id": "bar", + "references": Array [], + "type": "dashboard", + "workspaces": Array [ + "foo", + "bar", + ], + }, + ], + } + `); + }); + }); + + describe('checkConflictWithWorkspaceConflictCheck', () => { + beforeEach(() => { + mockedClient.bulkGet.mockClear(); + }); + + it(`Return early when no objects`, async () => { + const result = await wrapperClient.checkConflicts([]); + expect(result.errors).toEqual([]); + expect(mockedClient.bulkGet).not.toBeCalled(); + }); + + it(`Should filter out workspace conflict objects`, async () => { + mockedClient.checkConflicts.mockResolvedValueOnce({ + errors: [], + }); + mockedClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [ + getSavedObject({ + id: 'foo', + workspaces: ['foo'], + }), + getSavedObject({ + id: 'bar', + workspaces: ['foo', 'bar'], + }), + getSavedObject({ + id: 'baz', + workspaces: ['baz'], + }), + getSavedObject({ + id: 'qux', + error: { + statusCode: 404, + message: 'object not found', + error: 'object not found', + }, + }), + ], + }); + const result = await wrapperClient.checkConflicts( + [ + getSavedObject({ + id: 'foo', + }), + getSavedObject({ + id: 'bar', + }), + getSavedObject({ + id: 'baz', + }), + getSavedObject({ + id: 'qux', + }), + ], + { + workspaces: ['foo'], + } + ); + + expect(mockedClient.bulkGet).toBeCalled(); + expect(mockedClient.checkConflicts).toBeCalledWith( + [ + { attributes: {}, id: 'foo', references: [], type: 'dashboard' }, + { + attributes: {}, + id: 'bar', + references: [], + type: 'dashboard', + }, + { + attributes: {}, + id: 'qux', + references: [], + type: 'dashboard', + }, + ], + { + workspaces: ['foo'], + } + ); + expect(result).toMatchInlineSnapshot(` + Object { + "errors": Array [ + Object { + "error": Object { + "error": "Conflict", + "message": "Saved object [dashboard/baz] conflict", + "metadata": Object { + "isNotOverwritable": true, + }, + "statusCode": 409, + }, + "id": "baz", + "type": "dashboard", + }, + ], + } + `); + }); + }); +}); diff --git a/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts new file mode 100644 index 000000000000..fe8586a76acf --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/saved_objects_wrapper_for_check_workspace_conflict.ts @@ -0,0 +1,308 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import Boom from '@hapi/boom'; +import { + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkResponse, + SavedObjectsClientWrapperFactory, + SavedObjectsCreateOptions, + SavedObjectsErrorHelpers, + SavedObjectsUtils, + SavedObjectsSerializer, + SavedObjectsCheckConflictsObject, + SavedObjectsCheckConflictsResponse, +} from '../../../../core/server'; + +const errorContent = (error: Boom.Boom) => error.output.payload; + +export class WorkspaceConflictSavedObjectsClientWrapper { + private _serializer?: SavedObjectsSerializer; + public setSerializer(serializer: SavedObjectsSerializer) { + this._serializer = serializer; + } + private getRawId(props: { namespace?: string; id: string; type: string }) { + return ( + this._serializer?.generateRawId(props.namespace, props.type, props.id) || + `${props.type}:${props.id}` + ); + } + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const createWithWorkspaceConflictCheck = async ( + type: string, + attributes: T, + options: SavedObjectsCreateOptions = {} + ) => { + const { workspaces, id, overwrite } = options; + let savedObjectWorkspaces = options?.workspaces; + + /** + * Check if overwrite with id + * If so, need to reserve the workspace params + */ + if (id && overwrite) { + let currentItem; + try { + currentItem = await wrapperOptions.client.get(type, id); + } catch (e) { + // If item can not be found, supress the error and create the object + } + if (currentItem) { + if ( + SavedObjectsUtils.filterWorkspacesAccordingToSourceWorkspaces( + workspaces, + currentItem.workspaces + ).length + ) { + throw SavedObjectsErrorHelpers.createConflictError(type, id); + } else { + savedObjectWorkspaces = currentItem.workspaces; + } + } + } + + return await wrapperOptions.client.create(type, attributes, { + ...options, + workspaces: savedObjectWorkspaces, + }); + }; + + const bulkCreateWithWorkspaceConflictCheck = async ( + objects: Array>, + options: SavedObjectsCreateOptions = {} + ): Promise> => { + const { overwrite, namespace } = options; + /** + * When overwrite, filter out all the objects that have ids + */ + const bulkGetDocs = overwrite + ? objects + .filter((object) => !!object.id) + .map((object) => { + const { type, id } = object; + /** + * It requires a check when overwriting objects to target workspaces + */ + return { + type, + id: id as string, + fields: ['id', 'workspaces'], + }; + }) + : []; + const objectsConflictWithWorkspace: SavedObject[] = []; + const objectsMapWorkspaces: Record = {}; + if (bulkGetDocs.length) { + /** + * Get latest status of objects + */ + const bulkGetResult = await wrapperOptions.client.bulkGet(bulkGetDocs); + + bulkGetResult.saved_objects.forEach((object) => { + /** + * Skip the items with error, wrapperOptions.client will handle the error + */ + if (!object.error && object.id) { + /** + * When it is about to overwrite a object into options.workspace. + * We need to check if the options.workspaces is the subset of object.workspaces, + * Or it will be treated as a conflict + */ + const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToSourceWorkspaces( + options.workspaces, + object.workspaces + ); + const { id, type } = object; + if (filteredWorkspaces.length) { + /** + * options.workspaces is not a subset of object.workspaces, + * Add the item into conflict array. + */ + objectsConflictWithWorkspace.push({ + id, + type, + attributes: {}, + references: [], + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + metadata: { isNotOverwritable: true }, + }, + }); + } else { + /** + * options.workspaces is a subset of object's workspaces + * Add the workspaces status into a objectId -> workspaces pairs for later use. + */ + objectsMapWorkspaces[this.getRawId({ namespace, type, id })] = object.workspaces; + } + } + }); + } + + /** + * Get all the objects that do not conflict on workspaces + */ + const objectsNoWorkspaceConflictError = objects.filter( + (item) => + !objectsConflictWithWorkspace.find( + (errorItems) => + this.getRawId({ namespace, type: errorItems.type, id: errorItems.id }) === + this.getRawId({ namespace, type: item.type, id: item.id as string }) + ) + ); + + /** + * Add the workspaces params back based on objects' workspaces value in index. + */ + const objectsPayload = objectsNoWorkspaceConflictError.map((item) => { + if (item.id) { + const workspacesParamsInIndex = + objectsMapWorkspaces[ + this.getRawId({ + namespace, + id: item.id, + type: item.type, + }) + ]; + if (workspacesParamsInIndex) { + item.workspaces = workspacesParamsInIndex; + } + } + + return item; + }); + + /** + * Bypass those objects that are not conflict on workspaces check. + */ + const realBulkCreateResult = await wrapperOptions.client.bulkCreate(objectsPayload, options); + + /** + * Merge the workspaceConflict result and real client bulkCreate result. + */ + return { + ...realBulkCreateResult, + saved_objects: [ + ...objectsConflictWithWorkspace, + ...(realBulkCreateResult?.saved_objects || []), + ], + } as SavedObjectsBulkResponse; + }; + + const checkConflictWithWorkspaceConflictCheck = async ( + objects: SavedObjectsCheckConflictsObject[] = [], + options: SavedObjectsBaseOptions = {} + ) => { + const objectsConflictWithWorkspace: SavedObjectsCheckConflictsResponse['errors'] = []; + /** + * Fail early when no objects + */ + if (objects.length === 0) { + return { errors: [] }; + } + + /** + * Workspace conflict only happens when target workspaces params present. + */ + if (options.workspaces) { + const bulkGetDocs: any[] = objects.map((object) => { + const { type, id } = object; + + return { + type, + id, + fields: ['id', 'workspaces'], + }; + }); + + if (bulkGetDocs.length) { + const bulkGetResult = await wrapperOptions.client.bulkGet(bulkGetDocs); + + bulkGetResult.saved_objects.forEach((object) => { + const { id, type } = object; + /** + * Skip the error ones, real checkConflict in repository will handle that. + */ + if (!object.error) { + let workspaceConflict = false; + const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToSourceWorkspaces( + options.workspaces, + object.workspaces + ); + if (filteredWorkspaces.length) { + workspaceConflict = true; + } + if (workspaceConflict) { + objectsConflictWithWorkspace.push({ + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + metadata: { isNotOverwritable: true }, + }, + }); + } + } + }); + } + } + + const objectsNoWorkspaceConflictError = objects.filter( + (item) => + !objectsConflictWithWorkspace.find( + (errorItems) => + this.getRawId({ + namespace: options.namespace, + type: errorItems.type, + id: errorItems.id, + }) === + this.getRawId({ + namespace: options.namespace, + type: item.type, + id: item.id as string, + }) + ) + ); + + /** + * Bypass those objects that are not conflict on workspaces + */ + const realBulkCreateResult = await wrapperOptions.client.checkConflicts( + objectsNoWorkspaceConflictError, + options + ); + + /** + * Merge results from two conflict check. + */ + const result: SavedObjectsCheckConflictsResponse = { + ...realBulkCreateResult, + errors: [...objectsConflictWithWorkspace, ...realBulkCreateResult.errors], + }; + + return result; + }; + + return { + ...wrapperOptions.client, + create: createWithWorkspaceConflictCheck, + bulkCreate: bulkCreateWithWorkspaceConflictCheck, + checkConflicts: checkConflictWithWorkspaceConflictCheck, + delete: wrapperOptions.client.delete, + find: wrapperOptions.client.find, + bulkGet: wrapperOptions.client.bulkGet, + get: wrapperOptions.client.get, + update: wrapperOptions.client.update, + bulkUpdate: wrapperOptions.client.bulkUpdate, + errors: wrapperOptions.client.errors, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + }; + }; + + constructor() {} +} diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts new file mode 100644 index 000000000000..41a4c7ee7407 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -0,0 +1,551 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { intersection } from 'lodash'; + +import { + OpenSearchDashboardsRequest, + SavedObject, + SavedObjectsAddToWorkspacesOptions, + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkGetObject, + SavedObjectsBulkResponse, + SavedObjectsClientWrapperFactory, + SavedObjectsCreateOptions, + SavedObjectsDeleteOptions, + SavedObjectsFindOptions, + SavedObjectsShareObjects, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateResponse, + SavedObjectsBulkUpdateOptions, + WORKSPACE_TYPE, + WorkspacePermissionMode, + SavedObjectsDeleteByWorkspaceOptions, + SavedObjectsErrorHelpers, + SavedObjectsServiceStart, + SavedObjectsClientContract, +} from '../../../../core/server'; +import { SavedObjectsPermissionControlContract } from '../permission_control/client'; +import { getPrincipalsFromRequest } from '../utils'; +import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../../common/constants'; + +// Can't throw unauthorized for now, the page will be refreshed if unauthorized +const generateWorkspacePermissionError = () => + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + i18n.translate('workspace.permission.invalidate', { + defaultMessage: 'Invalid workspace permission', + }) + ) + ); + +const generateSavedObjectsPermissionError = () => + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + i18n.translate('saved_objects.permission.invalidate', { + defaultMessage: 'Invalid saved objects permission', + }) + ) + ); + +export class WorkspaceSavedObjectsClientWrapper { + private getScopedClient?: SavedObjectsServiceStart['getScopedClient']; + private formatWorkspacePermissionModeToStringArray( + permission: WorkspacePermissionMode | WorkspacePermissionMode[] + ): string[] { + if (Array.isArray(permission)) { + return permission; + } + + return [permission]; + } + + private async validateObjectsPermissions( + objects: Array>, + request: OpenSearchDashboardsRequest, + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + ) { + // PermissionMode here is an array which is merged by workspace type required permission and other saved object required permission. + // So we only need to do one permission check no matter its type. + for (const { id, type } of objects) { + const validateResult = await this.permissionControl.validate( + request, + { + type, + id, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + ); + if (!validateResult?.result) { + return false; + } + } + return true; + } + + // validate if the `request` has the specified permission(`permissionMode`) to the given `workspaceIds` + private validateMultiWorkspacesPermissions = async ( + workspacesIds: string[], + request: OpenSearchDashboardsRequest, + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + ) => { + // for attributes and options passed in this function, the num of workspaces may be 0.This case should not be passed permission check. + if (workspacesIds.length === 0) { + return false; + } + const workspaces = workspacesIds.map((id) => ({ id, type: WORKSPACE_TYPE })); + return await this.validateObjectsPermissions(workspaces, request, permissionMode); + }; + + private validateAtLeastOnePermittedWorkspaces = async ( + workspaces: string[] | undefined, + request: OpenSearchDashboardsRequest, + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + ) => { + // for attributes and options passed in this function, the num of workspaces attribute may be 0.This case should not be passed permission check. + if (!workspaces || workspaces.length === 0) { + return false; + } + for (const workspaceId of workspaces) { + const validateResult = await this.permissionControl.validate( + request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + ); + if (validateResult?.result) { + return true; + } + } + return false; + }; + + /** + * check if the type include workspace + * Workspace permission check is totally different from object permission check. + * @param type + * @returns + */ + private isRelatedToWorkspace(type: string | string[]): boolean { + return type === WORKSPACE_TYPE || (Array.isArray(type) && type.includes(WORKSPACE_TYPE)); + } + + private async validateWorkspacesAndSavedObjectsPermissions( + savedObject: Pick, + request: OpenSearchDashboardsRequest, + workspacePermissionModes: WorkspacePermissionMode[], + objectPermissionModes: WorkspacePermissionMode[], + validateAllWorkspaces = true + ) { + // Advanced settings have no permissions and workspaces, so we need to skip it. + if (!savedObject.workspaces && !savedObject.permissions) { + return true; + } + + let hasPermission = false; + // Check permission based on object's workspaces. + // If workspacePermissionModes is passed with an empty array, we need to skip this validation and continue to validate object ACL. + if (savedObject.workspaces && workspacePermissionModes.length > 0) { + const workspacePermissionValidator = validateAllWorkspaces + ? this.validateMultiWorkspacesPermissions + : this.validateAtLeastOnePermittedWorkspaces; + hasPermission = await workspacePermissionValidator( + savedObject.workspaces, + request, + workspacePermissionModes + ); + } + // If already has permissions based on workspaces, we don't need to check object's ACL(defined by permissions attribute) + // So return true immediately + if (hasPermission) { + return true; + } + // Check permission based on object's ACL(defined by permissions attribute) + if (savedObject.permissions) { + hasPermission = await this.permissionControl.validateSavedObjectsACL( + [savedObject], + getPrincipalsFromRequest(request), + objectPermissionModes + ); + } + return hasPermission; + } + + private getWorkspaceTypeEnabledClient(request: OpenSearchDashboardsRequest) { + return this.getScopedClient?.(request, { + includedHiddenTypes: [WORKSPACE_TYPE], + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + }) as SavedObjectsClientContract; + } + + public setScopedClient(getScopedClient: SavedObjectsServiceStart['getScopedClient']) { + this.getScopedClient = getScopedClient; + } + + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const deleteWithWorkspacePermissionControl = async ( + type: string, + id: string, + options: SavedObjectsDeleteOptions = {} + ) => { + const objectToDeleted = await wrapperOptions.client.get(type, id, options); + if ( + !(await this.validateWorkspacesAndSavedObjectsPermissions( + objectToDeleted, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write] + )) + ) { + throw generateSavedObjectsPermissionError(); + } + return await wrapperOptions.client.delete(type, id, options); + }; + + // validate `objectToUpdate` if can update with workspace permission, which is used for update and bulkUpdate + const validateUpdateWithWorkspacePermission = async ( + objectToUpdate: SavedObject + ): Promise => { + return await this.validateWorkspacesAndSavedObjectsPermissions( + objectToUpdate, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write], + false + ); + }; + + const updateWithWorkspacePermissionControl = async ( + type: string, + id: string, + attributes: Partial, + options: SavedObjectsUpdateOptions = {} + ): Promise> => { + const objectToUpdate = await wrapperOptions.client.get(type, id, options); + const permitted = await validateUpdateWithWorkspacePermission(objectToUpdate); + if (!permitted) { + throw generateSavedObjectsPermissionError(); + } + return await wrapperOptions.client.update(type, id, attributes, options); + }; + + const bulkUpdateWithWorkspacePermissionControl = async ( + objects: Array>, + options?: SavedObjectsBulkUpdateOptions + ): Promise> => { + const objectsToUpdate = await wrapperOptions.client.bulkGet(objects, options); + + for (const object of objectsToUpdate.saved_objects) { + const permitted = await validateUpdateWithWorkspacePermission(object); + if (!permitted) { + throw generateSavedObjectsPermissionError(); + } + } + + return await wrapperOptions.client.bulkUpdate(objects, options); + }; + + const bulkCreateWithWorkspacePermissionControl = async ( + objects: Array>, + options: SavedObjectsCreateOptions = {} + ): Promise> => { + const hasTargetWorkspaces = options?.workspaces && options.workspaces.length > 0; + + if ( + hasTargetWorkspaces && + !(await this.validateMultiWorkspacesPermissions( + options.workspaces ?? [], + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite] + )) + ) { + throw generateSavedObjectsPermissionError(); + } + + /** + * + * If target workspaces parameter exists, we don't need to do permission validation again. + * The bulk create method in repository doesn't allow extends workspaces with override. + * If target workspaces parameter doesn't exists, we need to check if has permission to object's workspaces or ACL. + * + */ + if (!hasTargetWorkspaces && options.overwrite) { + for (const object of objects) { + const { type, id } = object; + if (id) { + let rawObject; + try { + rawObject = await wrapperOptions.client.get(type, id); + } catch (error) { + // If object is not found, we will skip the validation of this object. + if (SavedObjectsErrorHelpers.isNotFoundError(error as Error)) { + continue; + } else { + throw error; + } + } + if ( + !(await this.validateWorkspacesAndSavedObjectsPermissions( + rawObject, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write], + false + )) + ) { + throw generateWorkspacePermissionError(); + } + } + } + } + + return await wrapperOptions.client.bulkCreate(objects, options); + }; + + const createWithWorkspacePermissionControl = async ( + type: string, + attributes: T, + options?: SavedObjectsCreateOptions + ) => { + const hasTargetWorkspaces = options?.workspaces && options.workspaces.length > 0; + + if ( + hasTargetWorkspaces && + !(await this.validateMultiWorkspacesPermissions( + options?.workspaces ?? [], + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite] + )) + ) { + throw generateWorkspacePermissionError(); + } + + /** + * + * If target workspaces parameter exists, we don't need to do permission validation again. + * The create method in repository doesn't allow extends workspaces with override. + * If target workspaces parameter doesn't exists, we need to check if has permission to object's workspaces or ACL. + * + */ + if ( + options?.overwrite && + options.id && + !hasTargetWorkspaces && + !(await this.validateWorkspacesAndSavedObjectsPermissions( + await wrapperOptions.client.get(type, options.id), + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write], + false + )) + ) { + throw generateWorkspacePermissionError(); + } + + return await wrapperOptions.client.create(type, attributes, options); + }; + + const getWithWorkspacePermissionControl = async ( + type: string, + id: string, + options: SavedObjectsBaseOptions = {} + ): Promise> => { + const objectToGet = await wrapperOptions.client.get(type, id, options); + + if ( + !(await this.validateWorkspacesAndSavedObjectsPermissions( + objectToGet, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Read, WorkspacePermissionMode.Write], + false + )) + ) { + throw generateSavedObjectsPermissionError(); + } + return objectToGet; + }; + + const bulkGetWithWorkspacePermissionControl = async ( + objects: SavedObjectsBulkGetObject[] = [], + options: SavedObjectsBaseOptions = {} + ): Promise> => { + const objectToBulkGet = await wrapperOptions.client.bulkGet(objects, options); + + for (const object of objectToBulkGet.saved_objects) { + if ( + !(await this.validateWorkspacesAndSavedObjectsPermissions( + object, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write, WorkspacePermissionMode.Read], + false + )) + ) { + throw generateSavedObjectsPermissionError(); + } + } + + return objectToBulkGet; + }; + + const findWithWorkspacePermissionControl = async ( + options: SavedObjectsFindOptions + ) => { + const principals = getPrincipalsFromRequest(wrapperOptions.request); + if (!options.ACLSearchParams) { + options.ACLSearchParams = {}; + } + + if (this.isRelatedToWorkspace(options.type)) { + // Find all "read" saved objects by object's ACL for default + options.ACLSearchParams.permissionModes = options.ACLSearchParams.permissionModes ?? [ + WorkspacePermissionMode.Read, + WorkspacePermissionMode.Write, + ]; + options.ACLSearchParams.principals = principals; + } else { + /** + * Workspace is a hidden type so that we need to + * initialize a new saved objects client with workspace enabled to retrieve all the workspaces with permission. + */ + const permittedWorkspaceIds = ( + await this.getWorkspaceTypeEnabledClient(wrapperOptions.request).find({ + type: WORKSPACE_TYPE, + perPage: 999, + ACLSearchParams: { + principals, + /** + * The permitted workspace ids will be passed to the options.workspaces + * or options.ACLSearchParams.workspaces. These two were indicated the saved + * objects data inner specific workspaces. We use Library related permission here. + * For outside passed permission modes, it may contains other permissions. Add a intersection + * here to make sure only Library related permission modes will be used. + */ + permissionModes: options.ACLSearchParams.permissionModes + ? intersection(options.ACLSearchParams.permissionModes, [ + WorkspacePermissionMode.LibraryRead, + WorkspacePermissionMode.LibraryWrite, + ]) + : [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.LibraryWrite], + }, + }) + ).saved_objects.map((item) => item.id); + + if (options.workspaces) { + const permittedWorkspaces = options.workspaces.filter((item) => + (permittedWorkspaceIds || []).includes(item) + ); + if (!permittedWorkspaces.length) { + /** + * If user does not have any one workspace access + * deny the request + */ + throw SavedObjectsErrorHelpers.decorateNotAuthorizedError( + new Error( + i18n.translate('workspace.permission.invalidate', { + defaultMessage: 'Invalid workspace permission', + }) + ) + ); + } + + /** + * Overwrite the options.workspaces when user has access on partial workspaces. + */ + options.workspaces = permittedWorkspaces; + } else { + /** + * Select all the docs that + * 1. ACL matches read / write / user passed permission OR + * 2. workspaces matches library_read or library_write OR + * 3. Advanced settings + */ + options.workspaces = undefined; + options.ACLSearchParams.workspaces = permittedWorkspaceIds; + options.ACLSearchParams.permissionModes = options.ACLSearchParams.permissionModes ?? [ + WorkspacePermissionMode.Read, + WorkspacePermissionMode.Write, + ]; + options.ACLSearchParams.principals = principals; + } + } + + return await wrapperOptions.client.find(options); + }; + + const addToWorkspacesWithPermissionControl = async ( + objects: SavedObjectsShareObjects[], + targetWorkspaces: string[], + options: SavedObjectsAddToWorkspacesOptions = {} + ) => { + // target workspaces + const workspacePermitted = await this.validateMultiWorkspacesPermissions( + targetWorkspaces, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite] + ); + if (!workspacePermitted) { + throw generateWorkspacePermissionError(); + } + + // saved_objects + const validateResult = await this.permissionControl.batchValidate( + wrapperOptions.request, + objects.map((savedObj) => ({ + ...savedObj, + })), + [WorkspacePermissionMode.Write] + ); + + if (!validateResult.result) { + throw generateSavedObjectsPermissionError(); + } + + return await wrapperOptions.client.addToWorkspaces(objects, targetWorkspaces, options); + }; + + const deleteByWorkspaceWithPermissionControl = async ( + workspace: string, + options: SavedObjectsDeleteByWorkspaceOptions = {} + ) => { + if ( + !(await this.validateMultiWorkspacesPermissions([workspace], wrapperOptions.request, [ + WorkspacePermissionMode.LibraryWrite, + ])) + ) { + throw generateWorkspacePermissionError(); + } + + return await wrapperOptions.client.deleteByWorkspace(workspace, options); + }; + + return { + ...wrapperOptions.client, + get: getWithWorkspacePermissionControl, + checkConflicts: wrapperOptions.client.checkConflicts, + find: findWithWorkspacePermissionControl, + bulkGet: bulkGetWithWorkspacePermissionControl, + errors: wrapperOptions.client.errors, + addToNamespaces: wrapperOptions.client.addToNamespaces, + deleteFromNamespaces: wrapperOptions.client.deleteFromNamespaces, + create: createWithWorkspacePermissionControl, + bulkCreate: bulkCreateWithWorkspacePermissionControl, + delete: deleteWithWorkspacePermissionControl, + update: updateWithWorkspacePermissionControl, + bulkUpdate: bulkUpdateWithWorkspacePermissionControl, + addToWorkspaces: addToWorkspacesWithPermissionControl, + deleteByWorkspace: deleteByWorkspaceWithPermissionControl, + }; + }; + + constructor(private readonly permissionControl: SavedObjectsPermissionControlContract) {} +} diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 0f60597a7a8a..f603d5a1b01e 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -9,10 +9,15 @@ import { RequestHandlerContext, SavedObjectsFindResponse, CoreSetup, + WorkspacePermissionMode, WorkspaceAttribute, SavedObjectsServiceStart, } from '../../../core/server'; +export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { + permissions?: WorkspacePermissionItem[]; +} + export interface WorkspaceFindOptions { page?: number; perPage?: number; @@ -20,6 +25,7 @@ export interface WorkspaceFindOptions { searchFields?: string[]; sortField?: string; sortOrder?: string; + permissionModes?: WorkspacePermissionMode[]; } export interface IRequestDetail { @@ -46,14 +52,14 @@ export interface IWorkspaceClientImpl { /** * Create a workspace * @param requestDetail {@link IRequestDetail} - * @param payload {@link WorkspaceAttribute} + * @param payload {@link WorkspaceAttributeWithPermission} * @returns a Promise with a new-created id for the workspace * @public */ create( requestDetail: IRequestDetail, - payload: Omit - ): Promise>; + payload: Omit + ): Promise>; /** * List workspaces * @param requestDetail {@link IRequestDetail} @@ -67,7 +73,7 @@ export interface IWorkspaceClientImpl { ): Promise< IResponse< { - workspaces: WorkspaceAttribute[]; + workspaces: WorkspaceAttributeWithPermission[]; } & Pick > >; @@ -75,22 +81,25 @@ export interface IWorkspaceClientImpl { * Get the detail of a given workspace id * @param requestDetail {@link IRequestDetail} * @param id workspace id - * @returns a Promise with the detail of {@link WorkspaceAttribute} + * @returns a Promise with the detail of {@link WorkspaceAttributeWithPermission} * @public */ - get(requestDetail: IRequestDetail, id: string): Promise>; + get( + requestDetail: IRequestDetail, + id: string + ): Promise>; /** * Update the detail of a given workspace * @param requestDetail {@link IRequestDetail} * @param id workspace id - * @param payload {@link WorkspaceAttribute} + * @param payload {@link WorkspaceAttributeWithPermission} * @returns a Promise with a boolean result indicating if the update operation successed. * @public */ update( requestDetail: IRequestDetail, id: string, - payload: Omit + payload: Omit ): Promise>; /** * Delete a given workspace @@ -117,3 +126,17 @@ export type IResponse = success: false; error?: string; }; + +export type WorkspacePermissionItem = { + modes: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Read + | WorkspacePermissionMode.Write + >; +} & ({ type: 'user'; userId: string } | { type: 'group'; group: string }); + +export interface AuthInfo { + backend_roles?: string[]; + user_name?: string; +} diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index 89bfabd52657..d2ff66e8b486 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -4,6 +4,13 @@ */ import crypto from 'crypto'; +import { + ensureRawRequest, + OpenSearchDashboardsRequest, + Principals, + PrincipalType, +} from '../../../core/server'; +import { AuthInfo } from './types'; /** * Generate URL friendly random ID @@ -11,3 +18,31 @@ import crypto from 'crypto'; export const generateRandomId = (size: number) => { return crypto.randomBytes(size).toString('base64url').slice(0, size); }; + +export const getPrincipalsFromRequest = (request: OpenSearchDashboardsRequest): Principals => { + const rawRequest = ensureRawRequest(request); + const authInfo = rawRequest?.auth?.credentials?.authInfo as AuthInfo | null; + const payload: Principals = {}; + if (!authInfo) { + /** + * Login user have access to all the workspaces when no authentication is presented. + * The logic will be used when users create workspaces with authentication enabled but turn off authentication for any reason. + */ + return payload; + } + if (!authInfo?.backend_roles?.length && !authInfo.user_name) { + /** + * It means OSD can not recognize who the user is even if authentication is enabled, + * use a fake user that won't be granted permission explicitly. + */ + payload[PrincipalType.Users] = [`_user_fake_${Date.now()}_`]; + return payload; + } + if (authInfo?.backend_roles) { + payload[PrincipalType.Groups] = authInfo.backend_roles; + } + if (authInfo?.user_name) { + payload[PrincipalType.Users] = [authInfo.user_name]; + } + return payload; +}; diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index 890cf9bdd8a0..434a84417cdd 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -10,12 +10,119 @@ import type { CoreSetup, WorkspaceAttribute, SavedObjectsServiceStart, + Logger, + Permissions, + OpenSearchDashboardsRequest, } from '../../../core/server'; -import { WORKSPACE_TYPE } from '../../../core/server'; -import { IWorkspaceClientImpl, WorkspaceFindOptions, IResponse, IRequestDetail } from './types'; +import { + ACL, + DEFAULT_APP_CATEGORIES, + MANAGEMENT_WORKSPACE_ID, + PUBLIC_WORKSPACE_ID, + WORKSPACE_TYPE, + WorkspacePermissionMode, + PERSONAL_WORKSPACE_ID_PREFIX, +} from '../../../core/server'; +import { + IWorkspaceClientImpl, + WorkspaceFindOptions, + IResponse, + IRequestDetail, + WorkspaceAttributeWithPermission, + WorkspacePermissionItem, +} from './types'; import { workspace } from './saved_objects'; -import { generateRandomId } from './utils'; -import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; +import { generateRandomId, getPrincipalsFromRequest } from './utils'; +import { + WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + WORKSPACE_UPDATE_APP_ID, +} from '../common/constants'; + +const validatePermissionModesCombinations = [ + [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], // Read + [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Read], // Write + [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], // Admin +]; + +const isValidPermissionModesCombination = (permissionModes: string[]) => + validatePermissionModesCombinations.some( + (combination) => + combination.length === permissionModes.length && + combination.every((mode) => permissionModes.includes(mode)) + ); +const validatePermissions = (permissions: WorkspacePermissionItem[]) => { + const existsUsersOrGroups: { [key: string]: boolean } = {}; + for (const permission of permissions) { + const key = `${permission.type}${permission.type === 'user' ? `-${permission.userId}` : ''}${ + permission.type === 'group' ? `-${permission.group}` : '' + }`; + if (existsUsersOrGroups[key]) { + throw new Error(DUPLICATE_PERMISSION_SETTING); + } + existsUsersOrGroups[key] = true; + if (!isValidPermissionModesCombination(permission.modes)) { + throw new Error(INVALID_PERMISSION_MODES_COMBINATION); + } + } +}; + +const convertToACL = ( + workspacePermissions: WorkspacePermissionItem | WorkspacePermissionItem[] +) => { + workspacePermissions = Array.isArray(workspacePermissions) + ? workspacePermissions + : [workspacePermissions]; + + const acl = new ACL(); + + workspacePermissions.forEach((permission) => { + switch (permission.type) { + case 'user': + acl.addPermission(permission.modes, { users: [permission.userId] }); + return; + case 'group': + acl.addPermission(permission.modes, { groups: [permission.group] }); + return; + } + }); + + return acl.getPermissions() || {}; +}; + +const isValidWorkspacePermissionMode = (mode: string): mode is WorkspacePermissionMode => + Object.values(WorkspacePermissionMode).some((modeValue) => modeValue === mode); + +const isWorkspacePermissionItem = ( + test: WorkspacePermissionItem | null +): test is WorkspacePermissionItem => test !== null; + +const convertFromACL = (permissions: Permissions) => { + const acl = new ACL(permissions); + + return acl + .toFlatList() + .map(({ name, permissions: modes, type }) => { + const validModes = modes.filter(isValidWorkspacePermissionMode); + switch (type) { + case 'users': + return { + type: 'user', + modes: validModes, + userId: name, + } as const; + case 'groups': + return { + type: 'group', + modes: validModes, + group: name, + } as const; + default: + return null; + } + }) + .filter(isWorkspacePermissionItem); +}; const WORKSPACE_ID_SIZE = 6; @@ -23,12 +130,27 @@ const DUPLICATE_WORKSPACE_NAME_ERROR = i18n.translate('workspace.duplicate.name. defaultMessage: 'workspace name has already been used, try with a different name', }); -export class WorkspaceClient implements IWorkspaceClientImpl { +const RESERVED_WORKSPACE_NAME_ERROR = i18n.translate('workspace.reserved.name.error', { + defaultMessage: 'reserved workspace name cannot be changed', +}); + +const DUPLICATE_PERMISSION_SETTING = i18n.translate('workspace.invalid.permission.error', { + defaultMessage: 'Duplicate permission setting', +}); + +const INVALID_PERMISSION_MODES_COMBINATION = i18n.translate('workspace.invalid.permission.error', { + defaultMessage: 'Invalid workspace permission mode combination', +}); + +export class WorkspaceClientWithSavedObject implements IWorkspaceClientImpl { private setupDep: CoreSetup; + private logger: Logger; + private savedObjects?: SavedObjectsServiceStart; - constructor(core: CoreSetup) { + constructor(core: CoreSetup, logger: Logger) { this.setupDep = core; + this.logger = logger; } private getScopedClientWithoutPermission( @@ -49,15 +171,113 @@ export class WorkspaceClient implements IWorkspaceClientImpl { } private getFlattenedResultWithSavedObject( savedObject: SavedObject - ): WorkspaceAttribute { + ): WorkspaceAttributeWithPermission { return { ...savedObject.attributes, + permissions: savedObject.permissions ? convertFromACL(savedObject.permissions) : undefined, id: savedObject.id, }; } private formatError(error: Error | any): string { return error.message || error.error || 'Error'; } + private async checkAndCreateWorkspace( + savedObjectClient: SavedObjectsClientContract | undefined, + workspaceId: string, + workspaceAttribute: Omit, + permissions?: Permissions + ) { + try { + await savedObjectClient?.get(WORKSPACE_TYPE, workspaceId); + } catch (error) { + this.logger.debug(error?.toString() || ''); + this.logger.info(`Workspace ${workspaceId} is not found, create it by using internal user`); + try { + const createResult = await savedObjectClient?.create(WORKSPACE_TYPE, workspaceAttribute, { + id: workspaceId, + permissions, + }); + if (createResult?.id) { + this.logger.info(`Created workspace ${createResult.id}.`); + } + } catch (e) { + this.logger.error(`Create ${workspaceId} workspace error: ${e?.toString() || ''}`); + } + } + } + private async setupPublicWorkspace(savedObjectClient?: SavedObjectsClientContract) { + const publicWorkspaceACL = new ACL().addPermission( + [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + { + users: ['*'], + } + ); + return this.checkAndCreateWorkspace( + savedObjectClient, + PUBLIC_WORKSPACE_ID, + { + name: i18n.translate('workspaces.public.workspace.default.name', { + defaultMessage: 'Global workspace', + }), + features: ['*', `!@${DEFAULT_APP_CATEGORIES.management.id}`], + reserved: true, + }, + publicWorkspaceACL.getPermissions() + ); + } + private async setupManagementWorkspace(savedObjectClient?: SavedObjectsClientContract) { + const managementWorkspaceACL = new ACL().addPermission( + [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + { + users: ['*'], + } + ); + const DSM_APP_ID = 'dataSources'; + const DEV_TOOLS_APP_ID = 'dev_tools'; + + return this.checkAndCreateWorkspace( + savedObjectClient, + MANAGEMENT_WORKSPACE_ID, + { + name: i18n.translate('workspaces.management.workspace.default.name', { + defaultMessage: 'Management', + }), + features: [ + `@${DEFAULT_APP_CATEGORIES.management.id}`, + WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_UPDATE_APP_ID, + DSM_APP_ID, + DEV_TOOLS_APP_ID, + ], + reserved: true, + }, + managementWorkspaceACL.getPermissions() + ); + } + private async setupPersonalWorkspace( + request: OpenSearchDashboardsRequest, + savedObjectClient?: SavedObjectsClientContract + ) { + const principals = getPrincipalsFromRequest(request); + const personalWorkspaceACL = new ACL().addPermission( + [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + { + users: principals.users, + } + ); + return this.checkAndCreateWorkspace( + savedObjectClient, + `${PERSONAL_WORKSPACE_ID_PREFIX}-${principals.users?.[0] || ''}`, + { + name: i18n.translate('workspaces.personal.workspace.default.name', { + defaultMessage: 'Personal workspace', + }), + features: ['*', `!@${DEFAULT_APP_CATEGORIES.management.id}`], + reserved: true, + }, + personalWorkspaceACL.getPermissions() + ); + } public async setup(core: CoreSetup): Promise> { this.setupDep.savedObjects.registerType(workspace); return { @@ -67,10 +287,10 @@ export class WorkspaceClient implements IWorkspaceClientImpl { } public async create( requestDetail: IRequestDetail, - payload: Omit + payload: Omit ): ReturnType { try { - const attributes = payload; + const { permissions, ...attributes } = payload; const id = generateRandomId(WORKSPACE_ID_SIZE); const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); const existingWorkspaceRes = await this.getScopedClientWithoutPermission(requestDetail)?.find( @@ -78,16 +298,23 @@ export class WorkspaceClient implements IWorkspaceClientImpl { type: WORKSPACE_TYPE, search: attributes.name, searchFields: ['name'], + flags: 'NONE', // disable all operators, treat workspace as literal string } ); if (existingWorkspaceRes && existingWorkspaceRes.total > 0) { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } + + if (permissions) { + validatePermissions(permissions); + } + const result = await client.create>( WORKSPACE_TYPE, attributes, { id, + permissions: permissions ? convertToACL(permissions) : undefined, } ); return { @@ -108,15 +335,73 @@ export class WorkspaceClient implements IWorkspaceClientImpl { options: WorkspaceFindOptions ): ReturnType { try { - const { - saved_objects: savedObjects, - ...others - } = await this.getSavedObjectClientsFromRequestDetail(requestDetail).find( - { - ...options, - type: WORKSPACE_TYPE, - } + const { permissionModes, ...restOptions } = options; + const resultResp = await this.getSavedObjectClientsFromRequestDetail(requestDetail).find< + WorkspaceAttribute + >({ + ...restOptions, + type: WORKSPACE_TYPE, + ...(permissionModes ? { ACLSearchParams: { permissionModes } } : {}), + }); + const { saved_objects: resultSavedObjects, ...others } = resultResp; + let savedObjects = resultSavedObjects; + const scopedClientWithoutPermissionCheck = this.getScopedClientWithoutPermission( + requestDetail ); + const tasks: Array> = []; + + /** + * Setup public workspace if public workspace can not be found + */ + const hasPublicWorkspace = savedObjects.some((item) => item.id === PUBLIC_WORKSPACE_ID); + + if (!hasPublicWorkspace) { + tasks.push(this.setupPublicWorkspace(scopedClientWithoutPermissionCheck)); + } + + /** + * Setup management workspace if management workspace can not be found + */ + const hasManagementWorkspace = savedObjects.some( + (item) => item.id === MANAGEMENT_WORKSPACE_ID + ); + if (!hasManagementWorkspace) { + tasks.push(this.setupManagementWorkspace(scopedClientWithoutPermissionCheck)); + } + + /** + * Setup personal workspace + */ + const principals = getPrincipalsFromRequest(requestDetail.request); + /** + * Only when authentication is enabled will personal workspace be created. + * and the personal workspace id will be like "personal-{userId}" + */ + if (principals.users && principals.users?.[0]) { + const hasPersonalWorkspace = savedObjects.find( + (item) => `${PERSONAL_WORKSPACE_ID_PREFIX}-${principals.users?.[0] || ''}` === item.id + ); + if (!hasPersonalWorkspace) { + tasks.push( + this.setupPersonalWorkspace(requestDetail.request, scopedClientWithoutPermissionCheck) + ); + } + } + try { + await Promise.all(tasks); + if (tasks.length) { + const retryFindResp = await this.getSavedObjectClientsFromRequestDetail( + requestDetail + ).find({ + ...restOptions, + type: WORKSPACE_TYPE, + ...(permissionModes ? { ACLSearchParams: { permissionModes } } : {}), + }); + savedObjects = retryFindResp.saved_objects; + } + } catch (e) { + this.logger.error(`Some error happened when initializing reserved workspace: ${e}`); + } return { success: true, result: { @@ -134,7 +419,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { public async get( requestDetail: IRequestDetail, id: string - ): Promise> { + ): Promise> { try { const result = await this.getSavedObjectClientsFromRequestDetail(requestDetail).get< WorkspaceAttribute @@ -153,13 +438,16 @@ export class WorkspaceClient implements IWorkspaceClientImpl { public async update( requestDetail: IRequestDetail, id: string, - payload: Omit + payload: Omit ): Promise> { - const attributes = payload; + const { permissions, ...attributes } = payload; try { const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); const workspaceInDB: SavedObject = await client.get(WORKSPACE_TYPE, id); if (workspaceInDB.attributes.name !== attributes.name) { + if (workspaceInDB.attributes.reserved) { + throw new Error(RESERVED_WORKSPACE_NAME_ERROR); + } const existingWorkspaceRes = await this.getScopedClientWithoutPermission( requestDetail )?.find({ @@ -167,12 +455,24 @@ export class WorkspaceClient implements IWorkspaceClientImpl { search: attributes.name, searchFields: ['name'], fields: ['_id'], + flags: 'NONE', // disable all operators, treat workspace as literal string }); if (existingWorkspaceRes && existingWorkspaceRes.total > 0) { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } } - await client.update>(WORKSPACE_TYPE, id, attributes, {}); + + if (permissions) { + validatePermissions(permissions); + } + + await client.create>(WORKSPACE_TYPE, attributes, { + id, + permissions: permissions ? convertToACL(permissions) : undefined, + overwrite: true, + version: workspaceInDB.version, + }); + return { success: true, result: true, @@ -186,7 +486,23 @@ export class WorkspaceClient implements IWorkspaceClientImpl { } public async delete(requestDetail: IRequestDetail, id: string): Promise> { try { - await this.getSavedObjectClientsFromRequestDetail(requestDetail).delete(WORKSPACE_TYPE, id); + const savedObjectClient = this.getSavedObjectClientsFromRequestDetail(requestDetail); + const workspaceInDB: SavedObject = await savedObjectClient.get( + WORKSPACE_TYPE, + id + ); + if (workspaceInDB.attributes.reserved) { + return { + success: false, + error: i18n.translate('workspace.deleteReservedWorkspace.errorMessage', { + defaultMessage: 'Reserved workspace {id} is not allowed to delete.', + values: { id: workspaceInDB.id }, + }), + }; + } + await savedObjectClient.deleteByWorkspace(id); + // delete workspace itself at last, deleteByWorkspace depends on the workspace to do permission check + await savedObjectClient.delete(WORKSPACE_TYPE, id); return { success: true, result: true, diff --git a/test/accessibility/apps/management.ts b/test/accessibility/apps/management.ts index 4a60dc1a4862..b9297d1e5c13 100644 --- a/test/accessibility/apps/management.ts +++ b/test/accessibility/apps/management.ts @@ -43,7 +43,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { await opensearchDashboardsServer.uiSettings.update({ defaultIndex: 'logstash-*', }); - await PageObjects.settings.navigateTo(); + await PageObjects.common.navigateToApp('management'); }); after(async () => { diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index a82d4e792cdc..065541a36d77 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -73,8 +73,7 @@ export default function ({ getService }: FtrProviderContext) { score: 0, updated_at: '2017-09-21T18:51:23.794Z', meta: { - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', + editUrl: '/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/dd7caf20-9efd-11e7-acb3-3dab96693fab', @@ -237,8 +236,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -256,8 +254,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'dashboardApp', title: 'Dashboard', - editUrl: - '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -275,8 +272,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -286,8 +282,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -305,11 +300,9 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: - '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: - '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index f0af2d8d9e79..77e838cfed42 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -94,11 +94,9 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'saved_objects*', icon: 'indexPatternApp', - editUrl: - '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: - '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -111,8 +109,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'VisualizationFromSavedSearch', icon: 'visualizeApp', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -137,11 +134,9 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: - '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: - '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -154,8 +149,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -199,8 +193,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -215,8 +208,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -239,8 +231,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -255,8 +246,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -300,8 +290,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -316,8 +305,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'dashboardApp', title: 'Dashboard', - editUrl: - '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -342,8 +330,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -386,8 +373,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -402,8 +388,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -428,8 +413,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 3b6e8a243556..6701ae0fc94c 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -112,8 +112,7 @@ export default function ({ getService, getPageObjects }) { describe('is false', () => { before(async () => { - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.common.navigateToApp('settings'); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); }); @@ -127,8 +126,7 @@ export default function ({ getService, getPageObjects }) { }); after(async () => { - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); await PageObjects.header.clickDashboard(); }); diff --git a/test/functional/apps/dashboard/time_zones.js b/test/functional/apps/dashboard/time_zones.js index 4c82cfe8006c..225e0bf1d034 100644 --- a/test/functional/apps/dashboard/time_zones.js +++ b/test/functional/apps/dashboard/time_zones.js @@ -51,7 +51,6 @@ export default function ({ getService, getPageObjects }) { await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); await PageObjects.savedObjects.importFile( path.join(__dirname, 'exports', 'timezonetest_6_2_4.json'), @@ -77,7 +76,6 @@ export default function ({ getService, getPageObjects }) { it('Changing timezone changes dashboard timestamp and shows the same data', async () => { await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.setAdvancedSettingsSelect('dateFormat:tz', 'Etc/GMT+5'); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.loadSavedDashboard('time zone test'); diff --git a/test/functional/apps/management/_create_index_pattern_wizard.js b/test/functional/apps/management/_create_index_pattern_wizard.js index 75c19a581e1c..61fb36b34f2d 100644 --- a/test/functional/apps/management/_create_index_pattern_wizard.js +++ b/test/functional/apps/management/_create_index_pattern_wizard.js @@ -41,7 +41,6 @@ export default function ({ getService, getPageObjects }) { before(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); }); diff --git a/test/functional/apps/management/_handle_alias.js b/test/functional/apps/management/_handle_alias.js index 1b0908fd102a..00eecea74c31 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -60,7 +60,6 @@ export default function ({ getService, getPageObjects }) { }); it('should be able to create index pattern without time field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.createIndexPattern('alias1*', null); }); @@ -74,7 +73,6 @@ export default function ({ getService, getPageObjects }) { }); it('should be able to create index pattern with timefield', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.createIndexPattern('alias2*', 'date'); }); diff --git a/test/functional/apps/management/_handle_version_conflict.js b/test/functional/apps/management/_handle_version_conflict.js index ac457074a3e6..1813bec22241 100644 --- a/test/functional/apps/management/_handle_version_conflict.js +++ b/test/functional/apps/management/_handle_version_conflict.js @@ -55,7 +55,6 @@ export default function ({ getService, getPageObjects }) { }); it('Should be able to surface version conflict notification while creating scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); @@ -84,7 +83,6 @@ export default function ({ getService, getPageObjects }) { it('Should be able to surface version conflict notification while changing field format', async function () { const fieldName = 'geo.srcdest'; - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); log.debug('Starting openControlsByName (' + fieldName + ')'); diff --git a/test/functional/apps/management/_import_objects.js b/test/functional/apps/management/_import_objects.js index a4a919aedcd9..f481960b2f77 100644 --- a/test/functional/apps/management/_import_objects.js +++ b/test/functional/apps/management/_import_objects.js @@ -46,7 +46,6 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await opensearchArchiver.load('management'); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); }); @@ -215,7 +214,6 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await opensearchArchiver.load('saved_objects_imports'); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); }); diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.js index b7214590ebd4..4d1be27f87fa 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.js @@ -41,14 +41,9 @@ export default function ({ getService, getPageObjects }) { describe('creating and deleting default index', function describeIndexTests() { before(function () { // Delete .kibana index and then wait for OpenSearch Dashboards to re-create it - return opensearchDashboardsServer.uiSettings - .replace({}) - .then(function () { - return PageObjects.settings.navigateTo(); - }) - .then(function () { - return PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); - }); + return opensearchDashboardsServer.uiSettings.replace({}).then(function () { + return PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); + }); }); describe('special character handling', () => { @@ -66,7 +61,6 @@ export default function ({ getService, getPageObjects }) { }); after(async () => { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); }); }); @@ -129,7 +123,7 @@ export default function ({ getService, getPageObjects }) { return retry.try(function tryingForTime() { return browser.getCurrentUrl().then(function (currentUrl) { log.debug('currentUrl = ' + currentUrl); - expect(currentUrl).to.contain('management/opensearch-dashboards/indexPatterns'); + expect(currentUrl).to.contain('indexPatterns'); }); }); }); diff --git a/test/functional/apps/management/_index_pattern_filter.js b/test/functional/apps/management/_index_pattern_filter.js index 40588f74df3b..6157f6a95a40 100644 --- a/test/functional/apps/management/_index_pattern_filter.js +++ b/test/functional/apps/management/_index_pattern_filter.js @@ -39,7 +39,6 @@ export default function ({ getService, getPageObjects }) { before(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); }); @@ -52,7 +51,6 @@ export default function ({ getService, getPageObjects }) { }); it('should filter indexed fields', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.getFieldTypes(); diff --git a/test/functional/apps/management/_index_patterns_empty.ts b/test/functional/apps/management/_index_patterns_empty.ts index 27c7861f68b6..2ce33ff28383 100644 --- a/test/functional/apps/management/_index_patterns_empty.ts +++ b/test/functional/apps/management/_index_patterns_empty.ts @@ -45,7 +45,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { await opensearchArchiver.unload('logstash_functional'); await opensearchArchiver.unload('makelogs'); await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); }); after(async () => { diff --git a/test/functional/apps/management/_mgmt_import_saved_objects.js b/test/functional/apps/management/_mgmt_import_saved_objects.js index c5f852bae5c0..c04fa88b0dec 100644 --- a/test/functional/apps/management/_mgmt_import_saved_objects.js +++ b/test/functional/apps/management/_mgmt_import_saved_objects.js @@ -42,7 +42,6 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { await opensearchArchiver.load('empty_opensearch_dashboards'); await opensearchArchiver.load('discover'); - await PageObjects.settings.navigateTo(); }); afterEach(async function () { diff --git a/test/functional/apps/management/_opensearch_dashboards_settings.js b/test/functional/apps/management/_opensearch_dashboards_settings.js index 0e310953e8a2..637f7073d517 100644 --- a/test/functional/apps/management/_opensearch_dashboards_settings.js +++ b/test/functional/apps/management/_opensearch_dashboards_settings.js @@ -39,12 +39,10 @@ export default function ({ getService, getPageObjects }) { before(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await PageObjects.settings.createIndexPattern('logstash-*'); }); after(async function afterAll() { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); @@ -90,7 +88,6 @@ export default function ({ getService, getPageObjects }) { }); it('setting to true change is preserved', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage'); const storeInSessionStorage = await PageObjects.settings.getAdvancedSettingCheckbox( @@ -113,8 +110,7 @@ export default function ({ getService, getPageObjects }) { it("changing 'state:storeInSessionStorage' also takes effect without full page reload", async () => { await PageObjects.dashboard.preserveCrossAppState(); - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage'); await PageObjects.header.clickDashboard(); const [globalState, appState] = await getStateFromUrl(); diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 8a4659630ee1..3ef74f39cfb9 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -75,13 +75,11 @@ export default function ({ getService, getPageObjects }) { }); after(async function afterAll() { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); it('should not allow saving of invalid scripts', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); @@ -99,7 +97,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_Pain_reg'; it('should create and edit scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -133,7 +130,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_Pain1'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -255,7 +251,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painString'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -352,7 +347,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painBool'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -452,7 +446,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painDate'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); diff --git a/test/functional/apps/management/_scripted_fields_filter.js b/test/functional/apps/management/_scripted_fields_filter.js index b1714c425aac..55ec8895608c 100644 --- a/test/functional/apps/management/_scripted_fields_filter.js +++ b/test/functional/apps/management/_scripted_fields_filter.js @@ -58,7 +58,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_pain1'; it('should filter scripted fields', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); diff --git a/test/functional/apps/management/_scripted_fields_preview.js b/test/functional/apps/management/_scripted_fields_preview.js index 304f757d006a..187d64458d49 100644 --- a/test/functional/apps/management/_scripted_fields_preview.js +++ b/test/functional/apps/management/_scripted_fields_preview.js @@ -38,8 +38,8 @@ export default function ({ getService, getPageObjects }) { describe('scripted fields preview', () => { before(async function () { await browser.setWindowSize(1200, 800); - await PageObjects.settings.navigateTo(); await PageObjects.settings.createIndexPattern(); + await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); @@ -48,7 +48,6 @@ export default function ({ getService, getPageObjects }) { }); after(async function afterAll() { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); diff --git a/test/functional/apps/management/_test_huge_fields.js b/test/functional/apps/management/_test_huge_fields.js index ef14142acbcf..85be8f38a7dd 100644 --- a/test/functional/apps/management/_test_huge_fields.js +++ b/test/functional/apps/management/_test_huge_fields.js @@ -45,7 +45,6 @@ export default function ({ getService, getPageObjects }) { false ); await opensearchArchiver.loadIfNeeded('large_fields'); - await PageObjects.settings.navigateTo(); await PageObjects.settings.createIndexPattern('testhuge', 'date'); }); diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts index 1534c710179b..64fe2bf199b0 100644 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -88,7 +88,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('allows to update the saved object when submitting', async () => { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); let objects = await PageObjects.savedObjects.getRowTitles(); @@ -154,7 +153,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, ]; - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); const objects = await PageObjects.savedObjects.getRowTitles(); diff --git a/test/functional/apps/visualize/_custom_branding.ts b/test/functional/apps/visualize/_custom_branding.ts index 37f07e932ee5..52cbc8e5fec9 100644 --- a/test/functional/apps/visualize/_custom_branding.ts +++ b/test/functional/apps/visualize/_custom_branding.ts @@ -46,7 +46,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized logo for opensearch overview header in dark mode', async () => { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('opensearch_dashboards_overview'); await testSubjects.existOrFail('osdOverviewPageHeaderLogo'); @@ -100,7 +100,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized logo in dark mode', async () => { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('home'); await testSubjects.existOrFail('welcomeCustomLogo'); @@ -179,13 +179,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('in dark mode', async () => { before(async function () { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('home'); }); after(async function () { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clearAdvancedSettings('theme:darkMode'); }); @@ -206,7 +206,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized mark logo button that navigates to home page', async () => { - await PageObjects.common.navigateToApp('settings'); + await PageObjects.settings.navigateTo(); await globalNav.clickHomeButton(); await PageObjects.header.waitUntilLoadingHasFinished(); const url = await browser.getCurrentUrl(); diff --git a/test/functional/apps/visualize/_lab_mode.js b/test/functional/apps/visualize/_lab_mode.js index 82ecbcb2a655..1ba36b4b9f90 100644 --- a/test/functional/apps/visualize/_lab_mode.js +++ b/test/functional/apps/visualize/_lab_mode.js @@ -47,8 +47,7 @@ export default function ({ getService, getPageObjects }) { log.info('found saved search before toggling enableLabs mode'); // Navigate to advanced setting and disable lab mode - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); // Expect the discover still to list that saved visualization in the open list @@ -61,8 +60,7 @@ export default function ({ getService, getPageObjects }) { after(async () => { await PageObjects.discover.closeLoadSaveSearchPanel(); - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); }); }); diff --git a/test/functional/apps/visualize/_tag_cloud.js b/test/functional/apps/visualize/_tag_cloud.js index a5123434115d..075e7fa22907 100644 --- a/test/functional/apps/visualize/_tag_cloud.js +++ b/test/functional/apps/visualize/_tag_cloud.js @@ -160,7 +160,6 @@ export default function ({ getService, getPageObjects }) { describe('formatted field', function () { before(async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.filterField(termsField); @@ -178,7 +177,6 @@ export default function ({ getService, getPageObjects }) { after(async function () { await filterBar.removeFilter(termsField); - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.filterField(termsField); diff --git a/test/functional/config.js b/test/functional/config.js index 87d4302b2a15..ac9ac6085d2b 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -101,10 +101,6 @@ export default async function ({ readConfigFile }) { management: { pathname: '/app/management', }, - /** @obsolete "management" should be instead of "settings" **/ - settings: { - pathname: '/app/management', - }, console: { pathname: '/app/dev_tools', hash: '/console', diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index af2bf046e3a9..1e0106229d3d 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -51,19 +51,19 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider await find.clickByDisplayedLinkText(text); } async clickOpenSearchDashboardsSettings() { - await testSubjects.click('settings'); + await PageObjects.common.navigateToApp('settings'); await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('managementSettingsTitle'); } async clickOpenSearchDashboardsSavedObjects() { - await testSubjects.click('objects'); + await PageObjects.common.navigateToApp('objects'); await PageObjects.savedObjects.waitTableIsLoaded(); } async clickOpenSearchDashboardsIndexPatterns() { log.debug('clickOpenSearchDashboardsIndexPatterns link'); - await testSubjects.click('indexPatterns'); + await PageObjects.common.navigateToApp('indexPatterns'); await PageObjects.header.waitUntilLoadingHasFinished(); }