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 a9175ad23f8b..2eb77832b90f 100644 --- a/.github/workflows/build_and_test_workflow.yml +++ b/.github/workflows/build_and_test_workflow.yml @@ -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) @@ -92,13 +90,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 @@ -130,13 +128,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 }} @@ -217,21 +213,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: windows-latest - name: Windows x64 - ext: zip - suffix: windows-x64 - script: build-platform --windows --skip-os-packages runs-on: ${{ matrix.os }} defaults: run: @@ -307,8 +298,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 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 5e78785f9b88..42894281fe4f 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -19,7 +19,7 @@ env: jobs: cypress-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 @@ -55,7 +55,8 @@ jobs: with: path: ${{ env.FTR_PATH }} repository: opensearch-project/opensearch-dashboards-functional-test - ref: '${{ github.base_ref }}' + # revert this to '${{ github.base_ref }}' + ref: 'workspace' - name: Get Cypress version id: cypress_version @@ -88,7 +89,7 @@ jobs: name: ftr-cypress-screenshots path: ${{ env.FTR_PATH }}/cypress/screenshots retention-days: 1 - + - uses: actions/upload-artifact@v3 if: always() with: @@ -101,4 +102,4 @@ jobs: with: name: ftr-cypress-results path: ${{ env.FTR_PATH }}/cypress/results - retention-days: 1 \ No newline at end of file + retention-days: 1 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 93c8227d9765..8abc72aeb779 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -38,6 +38,7 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - Adds Data explorer framework and implements Discover using it ([#4806](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4806)) - [Theme] Use themes' definitions to render the initial view ([#4936](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4936/)) - [Theme] Make `next` theme the default ([#4854](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/4854/)) +- [Workspace] Setup workspace skeleton and implement basic CRUD API ([#5075](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5075/)) ### 🐛 Bug Fixes diff --git a/config/opensearch_dashboards.yml b/config/opensearch_dashboards.yml index 9c6c040433b4..80baeb5b4ae0 100644 --- a/config/opensearch_dashboards.yml +++ b/config/opensearch_dashboards.yml @@ -273,3 +273,9 @@ # Set the value of this setting to true to enable plugin augmentation on Dashboard # 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 \ No newline at end of file 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/capabilities/capabilities_service.mock.ts b/src/core/public/application/capabilities/capabilities_service.mock.ts index 971a43d06d05..d4490b60901b 100644 --- a/src/core/public/application/capabilities/capabilities_service.mock.ts +++ b/src/core/public/application/capabilities/capabilities_service.mock.ts @@ -37,6 +37,7 @@ const createStartContractMock = (): jest.Mocked => ({ catalogue: {}, management: {}, navLinks: {}, + workspaces: {}, }), }); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 7398aad65009..792d5195c4c6 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -47,6 +47,7 @@ import { IUiSettingsClient } from '../ui_settings'; import { SavedObjectsStart } from '../saved_objects'; import { AppCategory } from '../../types'; import { ScopedHistory } from './scoped_history'; +import { WorkspacesStart } from '../workspace'; /** * Accessibility status of an application. @@ -244,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'; + }; + }; } /** @@ -334,6 +345,8 @@ export interface AppMountContext { injectedMetadata: { getInjectedVar: (name: string, defaultValue?: any) => unknown; }; + /** {@link WorkspacesService} */ + workspaces: WorkspacesStart; }; } diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 14b516ff95bc..f69c13c8fc0e 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -33,11 +33,19 @@ import type { PublicMethodsOf } from '@osd/utility-types'; import { ChromeBadge, ChromeBreadcrumb, ChromeService, InternalChromeStart } from './'; import { getLogosMock } from '../../common/mocks'; +const createSetupContractMock = () => { + return { + registerCollapsibleNavHeader: jest.fn(), + }; +}; + const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { getHeaderComponent: jest.fn(), navLinks: { getNavLinks$: jest.fn(), + getFilteredNavLinks$: jest.fn(), + setFilteredNavLinks: jest.fn(), has: jest.fn(), get: jest.fn(), getAll: jest.fn(), @@ -95,6 +103,7 @@ const createStartContractMock = () => { type ChromeServiceContract = PublicMethodsOf; const createMock = () => { const mocked: jest.Mocked = { + setup: jest.fn(), start: jest.fn(), stop: jest.fn(), }; @@ -105,4 +114,5 @@ const createMock = () => { export const chromeServiceMock = { create: createMock, createStartContract: createStartContractMock, + createSetupContract: createSetupContractMock, }; diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index f11b0f3965e6..7d60c333638f 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 = `${this.id} App`; @@ -67,6 +68,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 d094d86360ef..cdbc58723dbf 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,8 +96,16 @@ interface StartDeps { injectedMetadata: InjectedMetadataStart; notifications: NotificationsStart; uiSettings: IUiSettingsClient; + workspaces: WorkspacesStart; } +type CollapsibleNavHeaderRender = (context: { + basePath: HttpStart['basePath']; + getUrlForApp: InternalApplicationStart['getUrlForApp']; + navigateToUrl: InternalApplicationStart['navigateToUrl']; + workspaces: WorkspacesStart; +}) => JSX.Element | null; + /** @internal */ export class ChromeService { private isVisible$!: Observable; @@ -107,6 +115,7 @@ export class ChromeService { private readonly navLinks = new NavLinksService(); private readonly recentlyAccessed = new RecentlyAccessedService(); private readonly docTitle = new DocTitleService(); + private collapsibleNavHeaderRender?: CollapsibleNavHeaderRender; constructor(private readonly params: ConstructorParams) {} @@ -142,6 +151,14 @@ export class ChromeService { ); } + public setup() { + return { + registerCollapsibleNavHeader: (render: CollapsibleNavHeaderRender) => { + this.collapsibleNavHeaderRender = render; + }, + }; + } + public async start({ application, docLinks, @@ -149,6 +166,7 @@ export class ChromeService { injectedMetadata, notifications, uiSettings, + workspaces, }: StartDeps): Promise { this.initVisibility(application); @@ -179,6 +197,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); @@ -242,7 +270,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$))} @@ -250,7 +277,8 @@ export class ChromeService { homeHref={http.basePath.prepend('/app/home')} isVisible$={this.isVisible$} opensearchDashboardsVersion={injectedMetadata.getOpenSearchDashboardsVersion()} - navLinks$={navLinks.getNavLinks$()} + navLinks$={navLinks.getFilteredNavLinks$()} + customNavLink$={customNavLink$.pipe(takeUntil(this.stop$))} recentlyAccessed$={recentlyAccessed.get$()} navControlsLeft$={navControls.getLeft$()} navControlsCenter$={navControls.getCenter$()} @@ -262,6 +290,9 @@ export class ChromeService { branding={injectedMetadata.getBranding()} logos={logos} survey={injectedMetadata.getSurvey()} + collapsibleNavHeaderRender={ + this.collapsibleNavHeaderRender ? collapsibleNavHeaderRender : undefined + } /> ), @@ -325,6 +356,20 @@ export class ChromeService { } } +/** + * ChromeSetup allows plugins to customize the global chrome header UI rendering + * before the header UI is mounted. + * + * @example + * Customize the Collapsible Nav's (left nav menu) header section: + * ```ts + * core.chrome.registerCollapsibleNavHeader(() => ) + * ``` + */ +export interface ChromeSetup { + registerCollapsibleNavHeader: (render: CollapsibleNavHeaderRender) => void; +} + /** * ChromeStart allows plugins to customize the global chrome header UI and * enrich the UX with additional information about the current location of the diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index 4cd43362767c..2f230203c949 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -33,6 +33,7 @@ export { ChromeBreadcrumb, ChromeService, ChromeStart, + ChromeSetup, InternalChromeStart, ChromeHelpExtension, } from './chrome_service'; 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.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 93c138eac62c..ddbef0beed6d 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>>; + /** + * Set an observable for a sorted list of filtered navlinks. + */ + getFilteredNavLinks$(): Observable>>; + + /** + * Set filtered navlinks. + */ + setFilteredNavLinks(filteredNavLinks: ReadonlyMap): void; + /** * Get the state of a navlink at this point in time. * @param id @@ -116,6 +126,9 @@ type LinksUpdater = (navLinks: Map) => Map | undefined>( + undefined + ); public start({ application, http }: StartDeps): ChromeNavLinks { const appLinks$ = application.applications$.pipe( @@ -140,8 +153,8 @@ export class NavLinksService { return linkUpdaters.reduce((links, updater) => updater(links), appLinks); }) ) - .subscribe((navlinks) => { - navLinks$.next(navlinks); + .subscribe((navLinks) => { + navLinks$.next(navLinks); }); const forceAppSwitcherNavigation$ = new BehaviorSubject(false); @@ -151,6 +164,21 @@ export class NavLinksService { return navLinks$.pipe(map(sortNavLinks), takeUntil(this.stop$)); }, + setFilteredNavLinks: (filteredNavLinks: ReadonlyMap) => { + this.filteredNavLinks$.next(filteredNavLinks); + }, + + getFilteredNavLinks$: () => { + return combineLatest([navLinks$, this.filteredNavLinks$]).pipe( + map(([navLinks, filteredNavLinks]) => + filteredNavLinks === undefined + ? sortNavLinks(navLinks) + : sortChromeNavLinks(filteredNavLinks) + ), + takeUntil(this.stop$) + ); + }, + get(id: string) { const link = navLinks$.value.get(id); return link && link.properties; @@ -215,3 +243,10 @@ function sortNavLinks(navLinks: ReadonlyMap) { 'order' ); } + +function sortChromeNavLinks(chromeNavLinks: ReadonlyMap) { + return sortBy( + [...chromeNavLinks.values()].map((link) => link as Readonly), + '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..b732d2904e22 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 {}} @@ -77,48 +79,11 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "closed": false, "hasError": false, "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], + "observers": Array [], "thrownError": null, } } + getUrlForApp={[MockFunction]} homeHref="/" id="collapsibe-nav" isLocked={false} @@ -195,7 +160,6 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { - "euiIconType": "inputOutput", "id": "opensearchDashboards", "label": "OpenSearch Dashboards", "order": 1000, @@ -240,7 +204,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 +215,6 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { - "euiIconType": "inputOutput", "id": "opensearchDashboards", "label": "OpenSearch Dashboards", "order": 1000, @@ -265,7 +228,6 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` Object { "baseUrl": "/", "category": Object { - "euiIconType": "inputOutput", "id": "opensearchDashboards", "label": "OpenSearch Dashboards", "order": 1000, @@ -455,373 +417,69 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` type="button" />
- +
- - - -
-
-
-
-
- -
-
- - - - -

- 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" - > -
-
- -
-
- -
-
-
- + +
+ + +
- - -
+ + + OpenSearch Dashboards + + +
+ +
+
-
- + + - -
-
- -
-
- -
+ @@ -848,15 +506,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 +524,8 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
@@ -935,7 +593,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiTitle euiTitle--xxsmall euiCollapsibleNavGroup__title" id="mockId__title" > - OpenSearch Dashboards + Recently Visited
@@ -963,33 +621,25 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__children" >
    - discover + recent 1 @@ -1040,41 +690,10 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` -
  • - - - visualize - - -
  • -
    - - dashboard + recent 2 @@ -1110,14 +729,14 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -1144,15 +763,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 +781,8 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
    @@ -1231,7 +850,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiTitle euiTitle--xxsmall euiCollapsibleNavGroup__title" id="mockId__title" > - Observability + OpenSearch Dashboards
    @@ -1259,25 +878,33 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__children" >
      - metrics + discover @@ -1328,10 +955,10 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` - logs + visualize + + + + + +
    • + + + dashboard
    • @@ -1367,14 +1025,14 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -1401,15 +1059,15 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__title" id="mockId__title" > - Security + Observability } className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="logoSecurity" - data-test-subj="collapsibleNavGroup-securitySolution" + data-test-opensearch-logo="logoObservability" + data-test-subj="collapsibleNavGroup-observability" id="mockId" initialIsOpen={true} isLoading={false} @@ -1419,8 +1077,8 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
      @@ -1488,7 +1146,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiTitle euiTitle--xxsmall euiCollapsibleNavGroup__title" id="mockId__title" > - Security + Observability
      @@ -1516,17 +1174,25 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__children" >
        - siem + metrics + + + + + +
      • + + + logs
      • @@ -1585,14 +1282,14 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` @@ -1619,15 +1316,15 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__title" id="mockId__title" > - Management + Security } className="euiCollapsibleNavGroup euiCollapsibleNavGroup--withHeading" - data-test-opensearch-logo="managementApp" - data-test-subj="collapsibleNavGroup-management" + data-test-opensearch-logo="logoSecurity" + data-test-subj="collapsibleNavGroup-securitySolution" id="mockId" initialIsOpen={true} isLoading={false} @@ -1637,8 +1334,8 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` >
        @@ -1706,7 +1403,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiTitle euiTitle--xxsmall euiCollapsibleNavGroup__title" id="mockId__title" > - Management + Security
        @@ -1734,17 +1431,17 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` className="euiCollapsibleNavGroup__children" >
          - monitoring + siem @@ -1803,21 +1500,239 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` -
          + + + + + +

          + Management +

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

            + Management +

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

            - 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" +
            -
            +
            -
            +
            + + + - - - - - + + +

            + 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" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" - > -
+
+ + + +
+ + +
+ +
+ + + OpenSearch Dashboards + + +
+
+
+
+
+ +
+ +
+ -
+ + + + + +

+ 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" > -
- - - - -
- -
-
+ + +
+
+ -
- +
- - + + + recent + + + + + + +
+
-
+
- - - -
-
- -
-
- -
+
+ + - - + +
- - -

+ +
- 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" - > -
+
+ + + +
+ + +
+ +
+ + + OpenSearch Dashboards + + +
+
+
+
+
+ +
+ +
+ -
+ + + + + +

+ 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" > -
- - - - -
- -
-
+ + +
+
+ -
- +
- - + + + 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" - > -
-
+ + + +

+ 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" > -
- - - -
-
- -
-
+ + +
+
+ -
- +
- - + + + recent + + + + + + +
+
-
+
- - - - - - -
-
- -
+
+ + - - -`; - -exports[`CollapsibleNav without custom branding renders the nav bar in default mode 1`] = ` - + +`; + +exports[`CollapsibleNav without custom branding renders the nav bar in default mode 1`] = ` + - - - - -

- 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" +
-
+
-
+
+ + + - - - - - + + +

+ 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 + + + + + + +
+
-
+
- -
- -
-
- -
-
- -
+
+ +
- +
- -
    +
    - -
  • +
    -
    + + +
    + +
    - Manage cloud deployment - - -
  • -
    -
-
+ + + 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 8b178200114a..a5e6e3b7c092 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -32,8 +32,9 @@ import './collapsible_nav.scss'; import { EuiCollapsibleNav, EuiCollapsibleNavGroup, + EuiFlexGroup, EuiFlexItem, - EuiHorizontalRule, + EuiIcon, EuiListGroup, EuiListGroupItem, EuiShowFor, @@ -41,18 +42,23 @@ import { } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { groupBy, sortBy } from 'lodash'; -import React, { Fragment, useRef } from 'react'; +import React, { useRef } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; 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 { + 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)) { @@ -63,7 +69,7 @@ function getAllCategories(allCategorizedLinks: Record) } function getOrderedCategories( - mainCategories: Record, + mainCategories: Record, categoryDictionary: ReturnType ) { return sortBy( @@ -72,6 +78,30 @@ function getOrderedCategories( ); } +function getMergedNavLinks( + orderedCategories: string[], + uncategorizedLinks: CollapsibleNavLink[], + categoryDictionary: ReturnType +): Array { + const uncategorizedLinksWithOrder = sortBy( + uncategorizedLinks.filter((link) => link.order !== null), + 'order' + ); + const uncategorizedLinksWithoutOrder = uncategorizedLinks.filter((link) => link.order === null); + const orderedCategoryWithOrder = orderedCategories + .filter((categoryName) => categoryDictionary[categoryName]?.order !== null) + .map((categoryName) => ({ categoryName, order: categoryDictionary[categoryName]?.order })); + const orderedCategoryWithoutOrder = orderedCategories.filter( + (categoryName) => categoryDictionary[categoryName]?.order === null + ); + const mergedNavLinks = sortBy( + [...uncategorizedLinksWithOrder, ...orderedCategoryWithOrder], + 'order' + ).map((navLink) => ('categoryName' in navLink ? navLink.categoryName : navLink)); + // if order is not defined , categorized links will be placed before uncategorized links + return [...mergedNavLinks, ...orderedCategoryWithoutOrder, ...uncategorizedLinksWithoutOrder]; +} + function getCategoryLocalStorageKey(id: string) { return `core.navGroup.${id}`; } @@ -89,6 +119,7 @@ function setIsCategoryOpen(id: string, isOpen: boolean, storage: Storage) { interface Props { appId$: InternalApplicationStart['currentAppId$']; basePath: HttpStart['basePath']; + collapsibleNavHeaderRender?: () => JSX.Element | null; id: string; isLocked: boolean; isNavOpen: boolean; @@ -98,6 +129,7 @@ interface Props { storage?: Storage; onIsLockedUpdate: OnIsLockedUpdate; closeNav: () => void; + getUrlForApp: InternalApplicationStart['getUrlForApp']; navigateToApp: InternalApplicationStart['navigateToApp']; navigateToUrl: InternalApplicationStart['navigateToUrl']; customNavLink$: Rx.Observable; @@ -106,6 +138,7 @@ interface Props { export function CollapsibleNav({ basePath, + collapsibleNavHeaderRender, id, isLocked, isNavOpen, @@ -113,6 +146,7 @@ export function CollapsibleNav({ storage = window.localStorage, onIsLockedUpdate, closeNav, + getUrlForApp, navigateToApp, navigateToUrl, logos, @@ -120,14 +154,27 @@ export function CollapsibleNav({ }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); 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 mergedNavLinks = getMergedNavLinks( + orderedCategories, + uncategorizedLinks, + categoryDictionary + ); + + const readyForEUI = (link: CollapsibleNavLink, needsIcon: boolean = false) => { return createEuiListItem({ link, appId, @@ -138,6 +185,13 @@ export function CollapsibleNav({ }); }; + const defaultHeaderName = i18n.translate( + 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', + { + defaultMessage: 'OpenSearch Dashboards', + } + ); + return ( - {customNavLink && ( - - - - - - - - - - )} - - {/* 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" - /> + + {collapsibleNavHeaderRender ? ( + collapsibleNavHeaderRender() ) : ( - -

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

-
+ + + + + + + + {defaultHeaderName} + + + + )} -
- - - - {/* OpenSearchDashboards, Observability, Security, and Management sections */} - {orderedCategories.map((categoryName) => { - const category = categoryDictionary[categoryName]!; - const opensearchLinkLogo = - category.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; + {/* merged NavLinks */} + {mergedNavLinks.map((item, i) => { + if (typeof item === 'string') { + const category = categoryDictionary[item]!; + const opensearchLinkLogo = + category.id === 'opensearchDashboards' ? 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 acc7c6869145..698d6a1a1047 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 './'; @@ -72,6 +72,7 @@ export interface HeaderProps { appTitle$: Observable; badge$: Observable; breadcrumbs$: Observable; + collapsibleNavHeaderRender?: () => JSX.Element | null; customNavLink$: Observable; homeHref: string; isVisible$: Observable; @@ -105,6 +106,7 @@ export function Header({ branding, survey, logos, + collapsibleNavHeaderRender, ...observables }: HeaderProps) { const isVisible = useObservable(observables.isVisible$, false); @@ -246,6 +248,7 @@ export function Header({ ) => event.metaKey || event.altKey || event.ctrlKey || event.shiftKey || event.defaultPrevented; +export type CollapsibleNavLink = ChromeNavLink | RecentNavLink; interface Props { - link: ChromeNavLink; + link: ChromeNavLink | RecentNavLink; appId?: string; basePath?: HttpStart['basePath']; dataTestSubj: string; onClick?: Function; navigateToApp: CoreStart['application']['navigateToApp']; - externalLink?: boolean; } // TODO #64541 @@ -60,7 +59,6 @@ export function createEuiListItem({ onClick = () => {}, navigateToApp, dataTestSubj, - externalLink = false, }: Props) { const { href, id, title, disabled, euiIconType, icon, tooltip } = link; @@ -74,7 +72,7 @@ export function createEuiListItem({ } if ( - !externalLink && // ignore external links + !link.externalLink && // ignore external links event.button === 0 && // ignore everything but left clicks !isModifiedOrPrevented(event) ) { @@ -93,14 +91,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 @@ -112,11 +112,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)); @@ -135,16 +134,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: ( - + MockCoreApp jest.doMock('./core_app', () => ({ CoreApp: CoreAppConstructor, })); + +export const MockWorkspacesService = workspacesServiceMock.create(); +export const WorkspacesServiceConstructor = jest + .fn() + .mockImplementation(() => MockWorkspacesService); +jest.doMock('./workspace', () => ({ + WorkspacesService: WorkspacesServiceConstructor, +})); diff --git a/src/core/public/core_system.test.ts b/src/core/public/core_system.test.ts index b3acd696bc3d..24de9ea6b9ea 100644 --- a/src/core/public/core_system.test.ts +++ b/src/core/public/core_system.test.ts @@ -55,6 +55,8 @@ import { MockIntegrationsService, CoreAppConstructor, MockCoreApp, + WorkspacesServiceConstructor, + MockWorkspacesService, } from './core_system.test.mocks'; import { CoreSystem } from './core_system'; @@ -99,6 +101,7 @@ describe('constructor', () => { expect(RenderingServiceConstructor).toHaveBeenCalledTimes(1); expect(IntegrationsServiceConstructor).toHaveBeenCalledTimes(1); expect(CoreAppConstructor).toHaveBeenCalledTimes(1); + expect(WorkspacesServiceConstructor).toHaveBeenCalledTimes(1); }); it('passes injectedMetadata param to InjectedMetadataService', () => { @@ -223,6 +226,11 @@ describe('#setup()', () => { expect(MockIntegrationsService.setup).toHaveBeenCalledTimes(1); }); + it('calls workspaces#setup()', async () => { + await setupCore(); + expect(MockWorkspacesService.setup).toHaveBeenCalledTimes(1); + }); + it('calls coreApp#setup()', async () => { await setupCore(); expect(MockCoreApp.setup).toHaveBeenCalledTimes(1); @@ -310,6 +318,11 @@ describe('#start()', () => { expect(MockIntegrationsService.start).toHaveBeenCalledTimes(1); }); + it('calls workspaces#start()', async () => { + await startCore(); + expect(MockWorkspacesService.start).toHaveBeenCalledTimes(1); + }); + it('calls coreApp#start()', async () => { await startCore(); expect(MockCoreApp.start).toHaveBeenCalledTimes(1); @@ -364,6 +377,14 @@ describe('#stop()', () => { expect(MockIntegrationsService.stop).toHaveBeenCalled(); }); + it('calls workspaces.stop()', () => { + const coreSystem = createCoreSystem(); + + expect(MockWorkspacesService.stop).not.toHaveBeenCalled(); + coreSystem.stop(); + expect(MockWorkspacesService.stop).toHaveBeenCalled(); + }); + it('calls coreApp.stop()', () => { const coreSystem = createCoreSystem(); diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 8fe5a36ebb55..aef58371e337 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -54,6 +54,7 @@ import { ContextService } from './context'; import { IntegrationsService } from './integrations'; import { CoreApp } from './core_app'; import type { InternalApplicationSetup, InternalApplicationStart } from './application/types'; +import { WorkspacesService } from './workspace'; interface Params { rootDomElement: HTMLElement; @@ -110,6 +111,7 @@ export class CoreSystem { private readonly rootDomElement: HTMLElement; private readonly coreContext: CoreContext; + private readonly workspaces: WorkspacesService; private fatalErrorsSetup: FatalErrorsSetup | null = null; constructor(params: Params) { @@ -138,6 +140,7 @@ export class CoreSystem { this.rendering = new RenderingService(); this.application = new ApplicationService(); this.integrations = new IntegrationsService(); + this.workspaces = new WorkspacesService(); this.coreContext = { coreId: Symbol('core'), env: injectedMetadata.env }; @@ -160,6 +163,7 @@ export class CoreSystem { const http = this.http.setup({ injectedMetadata, fatalErrors: this.fatalErrorsSetup }); const uiSettings = this.uiSettings.setup({ http, injectedMetadata }); const notifications = this.notifications.setup({ uiSettings }); + const workspaces = this.workspaces.setup(); const pluginDependencies = this.plugins.getOpaqueIds(); const context = this.context.setup({ @@ -167,15 +171,18 @@ export class CoreSystem { }); const application = this.application.setup({ context, http }); this.coreApp.setup({ application, http, injectedMetadata, notifications }); + const chrome = this.chrome.setup(); const core: InternalCoreSetup = { application, context, + chrome, fatalErrors: this.fatalErrorsSetup, http, injectedMetadata, notifications, uiSettings, + workspaces, }; // Services that do not expose contracts at setup @@ -220,6 +227,7 @@ export class CoreSystem { targetDomElement: notificationsTargetDomElement, }); const application = await this.application.start({ http, overlays }); + const workspaces = this.workspaces.start(); const chrome = await this.chrome.start({ application, docLinks, @@ -227,6 +235,7 @@ export class CoreSystem { injectedMetadata, notifications, uiSettings, + workspaces, }); this.coreApp.start({ application, http, notifications, uiSettings }); @@ -242,6 +251,7 @@ export class CoreSystem { overlays, savedObjects, uiSettings, + workspaces, })); const core: InternalCoreStart = { @@ -256,6 +266,7 @@ export class CoreSystem { overlays, uiSettings, fatalErrors, + workspaces, }; await this.plugins.start(core); @@ -303,6 +314,7 @@ export class CoreSystem { this.chrome.stop(); this.i18n.stop(); this.application.stop(); + this.workspaces.stop(); this.rootDomElement.textContent = ''; } } diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index 27cfa9bf0581..f80d41631b9b 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -110,4 +110,36 @@ describe('BasePath', () => { 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 3b7dff71c811..98809d4c885b 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 03ef6b6392f9..1f26a26de8ef 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -64,6 +64,7 @@ import { ChromeNavLinkUpdateableFields, ChromeDocTitle, ChromeStart, + ChromeSetup, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, NavType, @@ -87,6 +88,7 @@ import { HandlerParameters, } from './context'; import { Branding } from '../types'; +import { WorkspacesStart, WorkspacesSetup } from './workspace'; export type { Logos } from '../common'; export { PackageInfo, EnvironmentMode } from '../server/types'; @@ -102,6 +104,7 @@ export { StringValidation, StringValidationRegex, StringValidationRegexString, + WorkspaceAttribute, } from '../types'; export { @@ -219,6 +222,7 @@ export interface CoreSetup; + /** {@link WorkspacesSetup} */ + workspaces: WorkspacesSetup; } /** @@ -293,6 +299,8 @@ export interface CoreStart { getInjectedVar: (name: string, defaultValue?: any) => unknown; getBranding: () => Branding; }; + /** {@link WorkspacesStart} */ + workspaces: WorkspacesStart; } export { @@ -341,3 +349,12 @@ export { }; export { __osdBootstrap__ } from './osd_bootstrap'; + +export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; + +export { + WorkspacePermissionMode, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, + WORKSPACE_TYPE, +} from '../utils'; diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index e863d627c801..3acc71424b91 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -47,6 +47,7 @@ import { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; import { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; import { contextServiceMock } from './context/context_service.mock'; import { injectedMetadataServiceMock } from './injected_metadata/injected_metadata_service.mock'; +import { workspacesServiceMock } from './workspace/workspaces_service.mock'; export { chromeServiceMock } from './chrome/chrome_service.mock'; export { docLinksServiceMock } from './doc_links/doc_links_service.mock'; @@ -60,6 +61,7 @@ export { uiSettingsServiceMock } from './ui_settings/ui_settings_service.mock'; export { savedObjectsServiceMock } from './saved_objects/saved_objects_service.mock'; export { scopedHistoryMock } from './application/scoped_history.mock'; export { applicationServiceMock } from './application/application_service.mock'; +export { workspacesServiceMock } from './workspace/workspaces_service.mock'; function createCoreSetupMock({ basePath = '', @@ -85,6 +87,7 @@ function createCoreSetupMock({ getInjectedVar: injectedMetadataServiceMock.createSetupContract().getInjectedVar, getBranding: injectedMetadataServiceMock.createSetupContract().getBranding, }, + workspaces: workspacesServiceMock.createSetupContract(), }; return mock; @@ -106,6 +109,7 @@ function createCoreStartMock({ basePath = '' } = {}) { getBranding: injectedMetadataServiceMock.createStartContract().getBranding, }, fatalErrors: fatalErrorsServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), }; return mock; diff --git a/src/core/public/plugins/plugin_context.ts b/src/core/public/plugins/plugin_context.ts index 42c40e91183f..6ef454abab46 100644 --- a/src/core/public/plugins/plugin_context.ts +++ b/src/core/public/plugins/plugin_context.ts @@ -111,6 +111,7 @@ export function createPluginSetupContext< registerMountContext: (contextName, provider) => deps.application.registerMountContext(plugin.opaqueId, contextName, provider), }, + chrome: deps.chrome, context: deps.context, fatalErrors: deps.fatalErrors, http: deps.http, @@ -121,6 +122,7 @@ export function createPluginSetupContext< getBranding: deps.injectedMetadata.getBranding, }, getStartServices: () => plugin.startDependencies, + workspaces: deps.workspaces, }; } @@ -168,5 +170,6 @@ export function createPluginStartContext< getBranding: deps.injectedMetadata.getBranding, }, fatalErrors: deps.fatalErrors, + workspaces: deps.workspaces, }; } diff --git a/src/core/public/plugins/plugins_service.test.ts b/src/core/public/plugins/plugins_service.test.ts index b2cf4e8880cf..b57573e155cb 100644 --- a/src/core/public/plugins/plugins_service.test.ts +++ b/src/core/public/plugins/plugins_service.test.ts @@ -58,6 +58,7 @@ import { CoreSetup, CoreStart, PluginInitializerContext } from '..'; import { docLinksServiceMock } from '../doc_links/doc_links_service.mock'; import { savedObjectsServiceMock } from '../saved_objects/saved_objects_service.mock'; import { contextServiceMock } from '../context/context_service.mock'; +import { workspacesServiceMock } from '../workspace/workspaces_service.mock'; export let mockPluginInitializers: Map; @@ -102,12 +103,14 @@ describe('PluginsService', () => { ]; mockSetupDeps = { application: applicationServiceMock.createInternalSetupContract(), + chrome: chromeServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), http: httpServiceMock.createSetupContract(), injectedMetadata: injectedMetadataServiceMock.createStartContract(), notifications: notificationServiceMock.createSetupContract(), uiSettings: uiSettingsServiceMock.createSetupContract(), + workspaces: workspacesServiceMock.createSetupContract(), }; mockSetupContext = { ...mockSetupDeps, @@ -127,6 +130,7 @@ describe('PluginsService', () => { uiSettings: uiSettingsServiceMock.createStartContract(), savedObjects: savedObjectsServiceMock.createStartContract(), fatalErrors: fatalErrorsServiceMock.createStartContract(), + workspaces: workspacesServiceMock.createStartContract(), }; mockStartContext = { ...mockStartDeps, 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/index.ts b/src/core/public/workspace/index.ts new file mode 100644 index 000000000000..4b9b2c86f649 --- /dev/null +++ b/src/core/public/workspace/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspacesStart, WorkspacesService, WorkspacesSetup } from './workspaces_service'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts new file mode 100644 index 000000000000..ae56c035eb3a --- /dev/null +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -0,0 +1,42 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject } from 'rxjs'; +import type { PublicMethodsOf } from '@osd/utility-types'; + +import { WorkspacesService } from './workspaces_service'; +import { WorkspaceAttribute } from '..'; + +const currentWorkspaceId$ = new BehaviorSubject(''); +const workspaceList$ = new BehaviorSubject([]); +const currentWorkspace$ = new BehaviorSubject(null); +const initialized$ = new BehaviorSubject(false); + +const createWorkspacesSetupContractMock = () => ({ + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, +}); + +const createWorkspacesStartContractMock = () => ({ + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, +}); + +export type WorkspacesServiceContract = PublicMethodsOf; +const createMock = (): jest.Mocked => ({ + setup: jest.fn().mockReturnValue(createWorkspacesSetupContractMock()), + start: jest.fn().mockReturnValue(createWorkspacesStartContractMock()), + stop: jest.fn(), +}); + +export const workspacesServiceMock = { + create: createMock, + createSetupContract: createWorkspacesSetupContractMock, + createStartContract: createWorkspacesStartContractMock, +}; diff --git a/src/core/public/workspace/workspaces_service.test.ts b/src/core/public/workspace/workspaces_service.test.ts new file mode 100644 index 000000000000..4eca97ef2ed2 --- /dev/null +++ b/src/core/public/workspace/workspaces_service.test.ts @@ -0,0 +1,71 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspacesService, WorkspacesStart } from './workspaces_service'; + +describe('WorkspacesService', () => { + let workspaces: WorkspacesService; + let workspacesStart: WorkspacesStart; + + beforeEach(() => { + workspaces = new WorkspacesService(); + workspacesStart = workspaces.start(); + }); + + afterEach(() => { + workspaces.stop(); + }); + + it('workspace initialized$ state is false by default', () => { + expect(workspacesStart.initialized$.value).toBe(false); + }); + + it('currentWorkspace is not set by default', () => { + expect(workspacesStart.currentWorkspace$.value).toBe(null); + expect(workspacesStart.currentWorkspaceId$.value).toBe(''); + }); + + it('workspaceList$ is empty by default', () => { + expect(workspacesStart.workspaceList$.value.length).toBe(0); + }); + + it('currentWorkspace is updated when currentWorkspaceId changes', () => { + expect(workspacesStart.currentWorkspace$.value).toBe(null); + + workspacesStart.initialized$.next(true); + workspacesStart.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + workspacesStart.currentWorkspaceId$.next('workspace-1'); + + expect(workspacesStart.currentWorkspace$.value).toEqual({ + id: 'workspace-1', + name: 'workspace 1', + }); + + workspacesStart.currentWorkspaceId$.next(''); + expect(workspacesStart.currentWorkspace$.value).toEqual(null); + }); + + it('should return error if the specified workspace id cannot be found', () => { + expect(workspacesStart.currentWorkspace$.hasError).toBe(false); + workspacesStart.initialized$.next(true); + workspacesStart.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + workspacesStart.currentWorkspaceId$.next('workspace-3'); + expect(workspacesStart.currentWorkspace$.hasError).toBe(true); + }); + + it('should stop all observables when workspace service stopped', () => { + workspaces.stop(); + expect(workspacesStart.currentWorkspaceId$.isStopped).toBe(true); + expect(workspacesStart.currentWorkspace$.isStopped).toBe(true); + expect(workspacesStart.workspaceList$.isStopped).toBe(true); + expect(workspacesStart.initialized$.isStopped).toBe(true); + }); +}); diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts new file mode 100644 index 000000000000..09d394f098ea --- /dev/null +++ b/src/core/public/workspace/workspaces_service.ts @@ -0,0 +1,107 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject, combineLatest } from 'rxjs'; +import { isEqual } from 'lodash'; + +import { CoreService, WorkspaceAttribute } from '../../types'; + +type WorkspaceObject = WorkspaceAttribute & { libraryReadonly?: boolean }; + +interface WorkspaceObservables { + /** + * Indicates the current activated workspace id, the value should be changed every time + * when switching to a different workspace + */ + currentWorkspaceId$: BehaviorSubject; + + /** + * The workspace that is derived from `currentWorkspaceId` and `workspaceList`, if + * `currentWorkspaceId` cannot be found from `workspaceList`, it will return an error + * + * This value MUST NOT set manually from outside of WorkspacesService + */ + currentWorkspace$: BehaviorSubject; + + /** + * The list of available workspaces. This workspace list should be set by whoever + * the workspace functionalities + */ + workspaceList$: BehaviorSubject; + + /** + * This is a flag which indicates the WorkspacesService module is initialized and ready + * for consuming by others. For example, the `workspaceList` has been set, etc + */ + initialized$: BehaviorSubject; +} + +enum WORKSPACE_ERROR_REASON_MAP { + WORKSPACE_STALED = 'WORKSPACE_STALED', +} + +export type WorkspacesSetup = WorkspaceObservables; +export type WorkspacesStart = WorkspaceObservables; + +export class WorkspacesService implements CoreService { + private currentWorkspaceId$ = new BehaviorSubject(''); + private workspaceList$ = new BehaviorSubject([]); + private currentWorkspace$ = new BehaviorSubject(null); + private initialized$ = new BehaviorSubject(false); + + constructor() { + combineLatest([this.initialized$, this.workspaceList$, this.currentWorkspaceId$]).subscribe( + ([workspaceInitialized, workspaceList, currentWorkspaceId]) => { + if (workspaceInitialized) { + const currentWorkspace = workspaceList.find((w) => w && w.id === currentWorkspaceId); + + /** + * Do a simple idempotent verification here + */ + if (!isEqual(currentWorkspace, this.currentWorkspace$.getValue())) { + this.currentWorkspace$.next(currentWorkspace ?? null); + } + + if (currentWorkspaceId && !currentWorkspace?.id) { + /** + * Current workspace is staled + */ + this.currentWorkspaceId$.error({ + reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, + }); + this.currentWorkspace$.error({ + reason: WORKSPACE_ERROR_REASON_MAP.WORKSPACE_STALED, + }); + } + } + } + ); + } + + public setup(): WorkspacesSetup { + return { + currentWorkspaceId$: this.currentWorkspaceId$, + currentWorkspace$: this.currentWorkspace$, + workspaceList$: this.workspaceList$, + initialized$: this.initialized$, + }; + } + + public start(): WorkspacesStart { + return { + currentWorkspaceId$: this.currentWorkspaceId$, + currentWorkspace$: this.currentWorkspace$, + workspaceList$: this.workspaceList$, + initialized$: this.initialized$, + }; + } + + public async stop() { + this.currentWorkspace$.unsubscribe(); + this.currentWorkspaceId$.unsubscribe(); + this.workspaceList$.unsubscribe(); + this.initialized$.unsubscribe(); + } +} diff --git a/src/core/server/capabilities/capabilities_service.mock.ts b/src/core/server/capabilities/capabilities_service.mock.ts index fb0785aac947..f8be8f22b70c 100644 --- a/src/core/server/capabilities/capabilities_service.mock.ts +++ b/src/core/server/capabilities/capabilities_service.mock.ts @@ -52,6 +52,7 @@ const createCapabilitiesMock = (): Capabilities => { navLinks: {}, management: {}, catalogue: {}, + workspaces: {}, }; }; diff --git a/src/core/server/capabilities/capabilities_service.test.ts b/src/core/server/capabilities/capabilities_service.test.ts index 80e22fdb6721..be28130afd4e 100644 --- a/src/core/server/capabilities/capabilities_service.test.ts +++ b/src/core/server/capabilities/capabilities_service.test.ts @@ -72,6 +72,7 @@ describe('CapabilitiesService', () => { "navLinks": Object { "myLink": true, }, + "workspaces": Object {}, } `); }); @@ -107,6 +108,7 @@ describe('CapabilitiesService', () => { "B": true, "C": true, }, + "workspaces": Object {}, } `); }); @@ -134,6 +136,7 @@ describe('CapabilitiesService', () => { }, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, } `); }); @@ -192,6 +195,7 @@ describe('CapabilitiesService', () => { "b": true, "c": false, }, + "workspaces": Object {}, } `); }); diff --git a/src/core/server/capabilities/capabilities_service.ts b/src/core/server/capabilities/capabilities_service.ts index b92166427271..5153cd5ded1d 100644 --- a/src/core/server/capabilities/capabilities_service.ts +++ b/src/core/server/capabilities/capabilities_service.ts @@ -123,6 +123,7 @@ const defaultCapabilities: Capabilities = { navLinks: {}, management: {}, catalogue: {}, + workspaces: {}, }; /** @internal */ diff --git a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts index b60a067982e0..3cab62478cbc 100644 --- a/src/core/server/capabilities/integration_tests/capabilities_service.test.ts +++ b/src/core/server/capabilities/integration_tests/capabilities_service.test.ts @@ -79,6 +79,7 @@ describe('CapabilitiesService', () => { "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, } `); }); @@ -101,6 +102,7 @@ describe('CapabilitiesService', () => { }, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, } `); }); diff --git a/src/core/server/capabilities/resolve_capabilities.test.ts b/src/core/server/capabilities/resolve_capabilities.test.ts index 25968d858e7a..88e2aa7a4f7c 100644 --- a/src/core/server/capabilities/resolve_capabilities.test.ts +++ b/src/core/server/capabilities/resolve_capabilities.test.ts @@ -42,6 +42,7 @@ describe('resolveCapabilities', () => { navLinks: {}, catalogue: {}, management: {}, + workspaces: {}, }; request = httpServerMock.createOpenSearchDashboardsRequest(); }); @@ -75,6 +76,7 @@ describe('resolveCapabilities', () => { }, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, } `); }); 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 ca55cc8dc1d5..01b24777b0bd 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -218,6 +218,7 @@ export { SessionStorageFactory, DestructiveRouteMethod, SafeRouteMethod, + ensureRawRequest, } from './http'; export { @@ -319,6 +320,17 @@ export { exportSavedObjectsToStream, importSavedObjectsFromStream, resolveSavedObjectsImportErrors, + SavedObjectsShareObjects, + SavedObjectsAddToWorkspacesOptions, + SavedObjectsAddToWorkspacesResponse, + SavedObjectsDeleteByWorkspaceOptions, + SavedObjectsDeleteFromWorkspacesOptions, + SavedObjectsDeleteFromWorkspacesResponse, + Permissions, + ACL, + Principals, + TransformedPermission, + PrincipalType, } from './saved_objects'; export { @@ -345,8 +357,15 @@ export { MetricsServiceStart, } from './metrics'; -export { AppCategory } from '../types'; -export { DEFAULT_APP_CATEGORIES } from '../utils'; +export { AppCategory, WorkspaceAttribute } from '../types'; +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..8ca085639f10 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, }); 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 a3a1eebbd2ab..b67cffce1e96 100644 --- a/src/core/server/saved_objects/import/create_saved_objects.ts +++ b/src/core/server/saved_objects/import/create_saved_objects.ts @@ -39,6 +39,7 @@ interface CreateSavedObjectsParams { importIdMap: Map; namespace?: string; overwrite?: boolean; + workspaces?: string[]; } interface CreateSavedObjectsResult { createdObjects: Array>; @@ -56,6 +57,7 @@ export const createSavedObjects = async ({ importIdMap, namespace, overwrite, + workspaces, }: CreateSavedObjectsParams): Promise> => { // filter out any objects that resulted in errors const errorSet = accumulatedErrors.reduce( @@ -103,6 +105,7 @@ export const createSavedObjects = async ({ const bulkCreateResponse = await savedObjectsClient.bulkCreate(objectsToCreate, { namespace, overwrite, + workspaces, }); expectedResults = bulkCreateResponse.saved_objects; } @@ -110,7 +113,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 de8fb34dfbed..4bf1717e895c 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'; @@ -68,6 +68,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: [], @@ -240,6 +241,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 cd250fc5f65f..fce50bab7abc 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 saved objects from given stream. See the {@link SavedObjectsImportOptions | options} for more @@ -54,6 +54,7 @@ export async function importSavedObjectsFromStream({ savedObjectsClient, typeRegistry, namespace, + workspaces, }: SavedObjectsImportOptions): Promise { let errorAccumulator: SavedObjectsImportError[] = []; const supportedTypes = typeRegistry.getImportableAndExportableTypes().map((type) => type.name); @@ -80,12 +81,20 @@ export async function importSavedObjectsFromStream({ if (createNewCopies) { importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects); } else { + importIdMap = await regenerateIdsWithReference({ + savedObjects: collectSavedObjectsResult.collectedObjects, + savedObjectsClient, + workspaces, + objectLimit, + importIdMap, + }); // Check single-namespace objects for conflicts in this namespace, and check multi-namespace objects for conflicts across all namespaces const checkConflictsParams = { objects: collectSavedObjectsResult.collectedObjects, savedObjectsClient, namespace, ignoreRegularConflicts: overwrite, + workspaces, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; @@ -118,6 +127,7 @@ export async function importSavedObjectsFromStream({ importIdMap, overwrite, namespace, + ...(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 672a8f030620..27b10e575f44 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. @@ -42,3 +43,40 @@ export const regenerateIds = (objects: SavedObject[]) => { }, 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.filterWorkspacesAccordingToBaseWorkspaces( + 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 162410c4ce9b..5bad2e4907b8 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, }: SavedObjectsResolveImportErrorsOptions): Promise { // throw a BadRequest error if we see invalid retries validateRetries(retries); @@ -126,6 +127,7 @@ export async function resolveSavedObjectsImportErrors({ namespace, retries, createNewCopies, + workspaces, }; const checkConflictsResult = await checkConflicts(checkConflictsParams); errorAccumulator = [...errorAccumulator, ...checkConflictsResult.errors]; @@ -157,6 +159,7 @@ export async function resolveSavedObjectsImportErrors({ importIdMap, namespace, overwrite, + workspaces, }; const { createdObjects, errors: bulkCreateErrors } = await createSavedObjects( createSavedObjectsParams diff --git a/src/core/server/saved_objects/import/types.ts b/src/core/server/saved_objects/import/types.ts index 88beacb9d2fd..924d1b18895a 100644 --- a/src/core/server/saved_objects/import/types.ts +++ b/src/core/server/saved_objects/import/types.ts @@ -187,6 +187,8 @@ export interface SavedObjectsImportOptions { 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[]; } /** @@ -208,6 +210,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[]; } export type CreatedObject = SavedObject & { destinationId?: string }; 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 b157feb0860e..2d221d2d47bc 100644 --- a/src/core/server/saved_objects/routes/import.ts +++ b/src/core/server/saved_objects/routes/import.ts @@ -60,6 +60,9 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) { overwrite: schema.boolean({ defaultValue: false }), createNewCopies: schema.boolean({ defaultValue: false }), + workspaces: schema.maybe( + schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) + ), }, { validate: (object) => { @@ -91,6 +94,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, @@ -98,6 +106,7 @@ export const registerImportRoute = (router: IRouter, config: SavedObjectConfig) objectLimit: maxImportExportSize, overwrite, createNewCopies, + 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 5e07125671f1..32d67b5ae8ab 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())]) + ), }), body: schema.object({ file: schema.stream(), @@ -98,6 +101,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, @@ -105,6 +113,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO retries: req.body.retries, objectLimit: maxImportExportSize, createNewCopies: req.query.createNewCopies, + workspaces, }); return res.ok({ body: result }); 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..816d1013e5be 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', @@ -466,6 +651,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 +666,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 +726,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 +874,7 @@ describe('SavedObjectsRepository', () => { expect.anything() ); client.bulk.mockClear(); + client.mget.mockClear(); }; await test(undefined); await test(namespace); @@ -815,6 +1007,12 @@ describe('SavedObjectsRepository', () => { const response1 = { status: 200, docs: [ + { + found: true, + _source: { + type: obj1.type, + }, + }, { found: true, _source: { @@ -837,7 +1035,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 +2050,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 +2398,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 +2685,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..3ee7889475d2 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); @@ -279,6 +287,26 @@ export class SavedObjectsRepository { } } + let savedObjectWorkspaces = workspaces; + + if (id && overwrite) { + try { + const currentItem = await this.get(type, id); + if ( + SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces( + workspaces, + currentItem.workspaces + ).length + ) { + throw SavedObjectsErrorHelpers.createConflictError(type, id); + } else { + savedObjectWorkspaces = currentItem.workspaces; + } + } catch (e) { + // this.get will throw an error when no items can be found + } + } + const migrated = this._migrator.migrateDocument({ id, type, @@ -289,6 +317,8 @@ export class SavedObjectsRepository { migrationVersion, updated_at: time, ...(Array.isArray(references) && { references }), + ...(Array.isArray(savedObjectWorkspaces) && { workspaces: savedObjectWorkspaces }), + ...(permissions && { permissions }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -355,15 +385,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 +417,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( @@ -402,12 +445,14 @@ export class SavedObjectsRepository { object: { initialNamespaces, version, ...object }, method, } = expectedBulkGetResult.value; + let savedObjectWorkspaces: string[] | undefined; 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 +467,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 +492,41 @@ export class SavedObjectsRepository { versionProperties = getExpectedVersionProperties(version); } + savedObjectWorkspaces = options.workspaces; + + if (expectedBulkGetResult.value.method !== 'create') { + const rawId = this._serializer.generateRawId(namespace, object.type, object.id); + const findObject = + bulkGetResponse?.statusCode !== 404 + ? bulkGetResponse?.body.docs?.find((item) => item._id === rawId) + : null; + if (findObject && findObject.found) { + const transformedObject = this._serializer.rawToSavedObject( + findObject as SavedObjectsRawDoc + ) as SavedObject; + const filteredWorkspaces = SavedObjectsUtils.filterWorkspacesAccordingToBaseWorkspaces( + options.workspaces, + transformedObject.workspaces + ); + if (filteredWorkspaces.length) { + const { id, type } = object; + return { + tag: 'Left' as 'Left', + error: { + id, + type, + error: { + ...errorContent(SavedObjectsErrorHelpers.createConflictError(type, id)), + metadata: { isNotOverwritable: true }, + }, + }, + }; + } else { + savedObjectWorkspaces = transformedObject.workspaces; + } + } + } + const expectedResult = { opensearchRequestIndex: bulkRequestIndexCounter++, requestedId: object.id, @@ -452,6 +541,7 @@ export class SavedObjectsRepository { updated_at: time, references: object.references || [], originId: object.originId, + workspaces: savedObjectWorkspaces, }) as SavedObjectSanitizedDoc ), }; @@ -549,7 +639,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 +662,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.filterWorkspacesAccordingToBaseWorkspaces( + 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 +803,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 +886,9 @@ export class SavedObjectsRepository { typeToNamespacesMap, filter, preference, + workspaces, + ACLSearchParams, + flags, } = options; if (!type && !typeToNamespacesMap) { @@ -809,6 +962,9 @@ export class SavedObjectsRepository { typeToNamespacesMap, hasReference, kueryNode, + workspaces, + ACLSearchParams, + flags, }), }, }; @@ -862,7 +1018,7 @@ export class SavedObjectsRepository { */ async bulkGet( objects: SavedObjectsBulkGetObject[] = [], - options: SavedObjectsBaseOptions = {} + options: Omit = {} ): Promise> { const namespace = normalizeNamespace(options.namespace); @@ -950,7 +1106,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 +1132,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 +1147,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 +1177,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 +1191,7 @@ export class SavedObjectsRepository { [type]: attributes, updated_at: time, ...(Array.isArray(references) && { references }), + ...(permissions && { permissions }), }; const { body, statusCode } = await this.client.update( @@ -1055,7 +1214,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 +1229,7 @@ export class SavedObjectsRepository { version: encodeHitVersion(body), namespaces, ...(originId && { originId }), + ...(workspaces && { workspaces }), references, attributes, }; @@ -1240,6 +1400,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 +1821,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 +2124,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 +2139,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..a4c1e602829c 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)', () => { 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..490c2b7083d2 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 filterWorkspacesAccordingToBaseWorkspaces( + 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..4f430d7f7134 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,8 @@ 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; } /** @@ -169,6 +172,8 @@ export interface SavedObjectsCheckConflictsResponse { }>; } +export type SavedObjectsShareObjects = Pick; + /** * * @public @@ -180,6 +185,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 +200,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 +214,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 +228,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 +247,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 +465,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..90ffcd311fca 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,15 @@ export interface SavedObjectsFindOptions { typeToNamespacesMap?: Map; /** An optional OpenSearch preference value to be used for the query **/ preference?: string; + 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 +131,7 @@ export interface SavedObjectsFindOptions { export interface SavedObjectsBaseOptions { /** Specify the namespace for this operation */ namespace?: string; + 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/capabilities.ts b/src/core/types/capabilities.ts index d964cd75b997..454f997de8f1 100644 --- a/src/core/types/capabilities.ts +++ b/src/core/types/capabilities.ts @@ -47,6 +47,9 @@ export interface Capabilities { /** Catalogue capabilities. Catalogue entries drive the visibility of the OpenSearch Dashboards homepage options. */ catalogue: Record; + /** Workspaces capabilities. */ + workspaces: Record; + /** Custom capabilities, registered by plugins. undefined if the key does not exist */ [key: string]: Record> | undefined; } diff --git a/src/core/types/index.ts b/src/core/types/index.ts index 9f620273e3b2..4afe9c537f75 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -39,3 +39,4 @@ export * from './ui_settings'; export * from './saved_objects'; export * from './serializable'; export * from './custom_branding'; +export * from './workspace'; diff --git a/src/core/types/saved_objects.ts b/src/core/types/saved_objects.ts index 81e1ed029ddc..fccce5f72947 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,8 @@ export interface SavedObject { * space. */ originId?: string; + workspaces?: string[]; + permissions?: Permissions; } export interface SavedObjectError { diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts new file mode 100644 index 000000000000..e99744183cac --- /dev/null +++ b/src/core/types/workspace.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export interface WorkspaceAttribute { + id: string; + name: string; + description?: string; + features?: string[]; + color?: string; + icon?: string; + defaultVISTheme?: string; + reserved?: boolean; +} diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts new file mode 100644 index 000000000000..1eecb44ae96f --- /dev/null +++ b/src/core/utils/constants.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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 b3b6ce4aab02..f2884c60581c 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,3 +37,12 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; +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 1b3c486615c8..b452b5c867ff 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 @@ -104,6 +104,7 @@ exports[`dashboard listing hideWriteControls 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -222,9 +223,11 @@ exports[`dashboard listing hideWriteControls 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -855,9 +858,11 @@ exports[`dashboard listing hideWriteControls 1`] = ` "basePath": BasePath { "basePath": "", "get": [Function], + "getBasePath": [Function], "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -930,6 +935,7 @@ exports[`dashboard listing hideWriteControls 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -993,6 +999,44 @@ exports[`dashboard listing hideWriteControls 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -1196,6 +1240,7 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -1314,9 +1359,11 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1947,9 +1994,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], @@ -2022,6 +2071,7 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -2085,6 +2135,44 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -2349,6 +2437,7 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -2467,9 +2556,11 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3100,9 +3191,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], @@ -3175,6 +3268,7 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -3238,6 +3332,44 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -3502,6 +3634,7 @@ exports[`dashboard listing renders table rows 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -3620,9 +3753,11 @@ exports[`dashboard listing renders table rows 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4253,9 +4388,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], @@ -4328,6 +4465,7 @@ exports[`dashboard listing renders table rows 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -4391,6 +4529,44 @@ exports[`dashboard listing renders table rows 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -4655,6 +4831,7 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -4773,9 +4950,11 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -5406,9 +5585,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], @@ -5481,6 +5662,7 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -5544,6 +5726,44 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > 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 101e0e520304..9b8b911685a9 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 @@ -104,6 +104,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -210,9 +211,11 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -747,9 +750,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], @@ -808,6 +813,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -859,6 +865,44 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -1021,6 +1065,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -1127,9 +1172,11 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1664,9 +1711,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], @@ -1725,6 +1774,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], }, }, @@ -1776,6 +1826,44 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -1938,6 +2026,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -2044,9 +2133,11 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2581,9 +2672,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], @@ -2642,6 +2735,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], }, }, @@ -2693,6 +2787,44 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -2855,6 +2987,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -2961,9 +3094,11 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3498,9 +3633,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], @@ -3559,6 +3696,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], }, }, @@ -3610,6 +3748,44 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -3772,6 +3948,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -3878,9 +4055,11 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4415,9 +4594,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], @@ -4476,6 +4657,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -4527,6 +4709,44 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > @@ -4689,6 +4909,7 @@ exports[`Dashboard top nav render with all components 1`] = ` "catalogue": Object {}, "management": Object {}, "navLinks": Object {}, + "workspaces": Object {}, }, "currentAppId$": Observable { "_isScalar": false, @@ -4795,9 +5016,11 @@ exports[`Dashboard top nav render with all components 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getFilteredNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setFilteredNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -5332,9 +5555,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], @@ -5393,6 +5618,7 @@ exports[`Dashboard top nav render with all components 1`] = ` "delete": [MockFunction], "find": [MockFunction], "get": [MockFunction], + "setCurrentWorkspace": [MockFunction], "update": [MockFunction], }, }, @@ -5444,6 +5670,44 @@ exports[`Dashboard top nav render with all components 1`] = ` "allowTrackUserAgent": [MockFunction], "reportUiStats": [MockFunction], }, + "workspaces": Object { + "currentWorkspace$": BehaviorSubject { + "_isScalar": false, + "_value": null, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentWorkspaceId$": BehaviorSubject { + "_isScalar": false, + "_value": "", + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "initialized$": BehaviorSubject { + "_isScalar": false, + "_value": false, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "workspaceList$": BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + }, } } > 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 58e81a337e7d..6b58c63bb5a5 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"], "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 941107d74638..d0c900effce2 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'; export interface DataSourceManagementSetupDependencies { management: ManagementSetup; @@ -20,16 +28,7 @@ const DSM_APP_ID = 'dataSources'; export class DataSourceManagementPlugin implements Plugin { - public setup( - core: CoreSetup, - { management, indexPatternManagement }: DataSourceManagementSetupDependencies - ) { - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - - if (!opensearchDashboardsSection) { - throw new Error('`opensearchDashboards` management section not found.'); - } - + public setup(core: CoreSetup, { indexPatternManagement }: DataSourceManagementSetupDependencies) { const savedObjectPromise = core .getStartServices() .then(([coreStart]) => coreStart.savedObjects); @@ -37,14 +36,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'); - - return mountManagementSection(core.getStartServices, params); + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: async (params: AppMountParameters) => { + const { mountDataSourcesManagementSection } = await import('./management_app'); + + 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 1bede8fbfca9..be2b7725d7ed 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 342dea206c30..54c90bd3ce92 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`] = ` > = ({ 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 162d1d0876c6..ff8345429d16 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,10 +34,18 @@ 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 { 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, @@ -59,7 +67,7 @@ const readOnlyBadge = { export async function mountManagementSection( getStartServices: StartServicesAccessor, - params: ManagementAppMountParams, + params: AppMountParameters, getMlCardState: () => MlCardState ) { const [ @@ -74,6 +82,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, @@ -85,32 +104,36 @@ export async function mountManagementSection( docLinks, data, indexPatternManagementStart: indexPatternManagementStart as IndexPatternManagementStart, - setBreadcrumbs: params.setBreadcrumbs, + setBreadcrumbs: setBreadcrumbsScope, getMlCardState, dataSourceEnabled, }; 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 cf68e043b76c..758c81ee538f 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 { 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; } @@ -75,15 +84,9 @@ export class IndexPatternManagementPlugin public setup( core: CoreSetup, - { management, urlForwarding }: IndexPatternManagementSetupDependencies + { urlForwarding }: IndexPatternManagementSetupDependencies ) { - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - - if (!opensearchDashboardsSection) { - throw new Error('`opensearchDashboards` management section not found.'); - } - - const newAppPath = `management/opensearch-dashboards/${IPM_APP_ID}`; + const newAppPath = IPM_APP_ID; const legacyPatternsPath = 'management/opensearch-dashboards/index_patterns'; urlForwarding.forwardApp( @@ -96,14 +99,39 @@ 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'); - return mountManagementSection(core.getStartServices, params, () => + 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, managementParams, () => this.indexPatternManagementService.environmentService.getEnvironment().ml() ); }, 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 33d63ec9d192..e63241b0c3bf 100644 --- a/src/plugins/saved_objects_management/public/lib/import_file.ts +++ b/src/plugins/saved_objects_management/public/lib/import_file.ts @@ -40,11 +40,11 @@ interface ImportResponse { export async function importFile( http: HttpStart, file: File, - { createNewCopies, overwrite }: ImportMode + { createNewCopies, overwrite, workspaces }: ImportMode ) { const formData = new FormData(); formData.append('file', file); - const query = createNewCopies ? { createNewCopies } : { overwrite }; + const query = createNewCopies ? { createNewCopies, workspaces } : { overwrite, workspaces }; return await http.post('/api/saved_objects/_import', { body: formData, headers: { 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 34b0dfa5be0b..f1f64a01c721 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 @@ -89,12 +89,13 @@ async function callResolveImportErrorsApi( http: HttpStart, file: File, retries: any, - createNewCopies: boolean + createNewCopies: boolean, + workspaces?: 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 }; return http.post('/api/saved_objects/_resolve_import_errors', { headers: { // Important to be undefined, it forces proper headers to be set for FormData @@ -167,6 +168,7 @@ export async function resolveImportErrors({ http, getConflictResolutions, state, + workspaces, }: { http: HttpStart; getConflictResolutions: ( @@ -180,6 +182,7 @@ export async function resolveImportErrors({ file?: File; importMode: { createNewCopies: boolean; overwrite: boolean }; }; + workspaces?: string[]; }) { const retryDecisionCache = new Map(); const replaceReferencesCache = new Map(); @@ -264,7 +267,13 @@ export async function resolveImportErrors({ } // Call API - const response = await callResolveImportErrorsApi(http, file!, retries, createNewCopies); + const response = await callResolveImportErrorsApi( + http, + file!, + retries, + createNewCopies, + workspaces + ); importCount = response.successCount; // reset the success count since we retry all successful results each time failedImports = []; for (const { error, ...obj } of response.errors || []) { 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 a1c7b5343eb1..6a92e71da702 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,24 +43,25 @@ import { getAllowedTypes } from './../lib'; interface MountParams { core: CoreSetup; serviceRegistry: ISavedObjectsManagementServiceRegistry; - mountParams: ManagementAppMountParams; + appMountParams?: AppMountParameters; + title: string; + allowedObjectTypes?: string[]; } -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, }: 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); } @@ -86,29 +87,34 @@ 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 c91fcbd7769e..9049272284a5 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], @@ -245,6 +247,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 ad082513b277..729a458fbb30 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 @@ -92,6 +92,7 @@ export interface FlyoutProps { overlays: OverlayStart; http: HttpStart; search: DataPublicPluginStart['search']; + workspaces?: string[]; } export interface FlyoutState { @@ -183,13 +184,16 @@ 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 } = this.state; this.setState({ status: 'loading', error: undefined }); // Import the file try { - const response = await importFile(http, file!, importMode); + const response = await importFile(http, file!, { + ...importMode, + workspaces, + }); 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 @@ -245,12 +249,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, }); this.setState(updatedState); } catch (e) { 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 2f78f307d165..059c623dfb68 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; } export interface SavedObjectsTableState { @@ -121,10 +128,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; @@ -135,26 +145,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, @@ -165,11 +186,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(); } @@ -177,25 +236,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( @@ -219,10 +289,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)); }; @@ -251,17 +335,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) { @@ -269,6 +354,13 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || PUBLIC_WORKSPACE_ID + ); + findOptions.workspaces = workspaceIds; + } + if (findOptions.type.length > 1) { findOptions.sortField = 'type'; } @@ -403,7 +495,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; @@ -796,6 +990,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} /> @@ -873,6 +1113,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} @@ -883,6 +1137,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 09937388ba57..3e8121c7c2f5 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, }: { coreStart: CoreStart; dataStart: DataPublicPluginStart; @@ -58,6 +59,7 @@ const SavedObjectsTablePage = ({ columnRegistry: SavedObjectsManagementColumnServiceStart; namespaceRegistry: SavedObjectsManagementNamespaceServiceStart; setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; + title: string; }) => { const capabilities = coreStart.application.capabilities; const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50); @@ -66,13 +68,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 14beb73386a8..dba28e05e98e 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 { VisBuilderStart } from '../../vis_builder/public'; import { ManagementSetup } from '../../management/public'; @@ -55,6 +55,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; @@ -98,9 +104,66 @@ export class SavedObjectsManagementPlugin private namespaceService = new SavedObjectsManagementNamespaceService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); + private registerLibrarySubApp( + coreSetup: CoreSetup + ) { + 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, + }); + }; + + /** + * 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 }: SetupDependencies + { home, uiActions }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); const columnSetup = this.columnService.setup(); @@ -117,35 +180,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, - }); - }, - }); - // 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); + 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 new file mode 100644 index 000000000000..ef0821c88619 --- /dev/null +++ b/src/plugins/workspace/common/constants.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * 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'; diff --git a/src/plugins/workspace/config.ts b/src/plugins/workspace/config.ts new file mode 100644 index 000000000000..70c87ac00cfc --- /dev/null +++ b/src/plugins/workspace/config.ts @@ -0,0 +1,15 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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 WorkspacePluginConfigType = TypeOf; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json new file mode 100644 index 000000000000..c6cc46bbe45f --- /dev/null +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -0,0 +1,11 @@ +{ + "id": "workspace", + "version": "opensearchDashboards", + "server": true, + "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/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..6be21538838f --- /dev/null +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WORKSPACE_OVERVIEW_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; + } +}; 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..73633950d6b0 --- /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', + }) + ); + 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']), + }) + ); + 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', + permissions: 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..6d475f43a659 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -0,0 +1,88 @@ +/* + * 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 { + result = await workspaceClient.create(data); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.create.success', { + defaultMessage: 'Create workspace successfully', + }), + }); + if (application && http) { + 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..d2a540db46ba --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx @@ -0,0 +1,704 @@ +/* + * 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 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)) { + 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..5599ae6bcf77 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -0,0 +1,127 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiPageContent, + EuiBasicTable, + EuiLink, + Direction, + CriteriaWithPagination, +} from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { useMemo, useCallback } from 'react'; +import { of } from 'rxjs'; +import { WorkspaceAttribute } from '../../../../../core/public'; + +import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; +import { switchWorkspace } from '../utils/workspace'; + +export const WorkspaceList = () => { + const { + services: { workspaces, application, http }, + } = useOpenSearchDashboards(); + + const [pageIndex, setPageIndex] = useState(0); + const [pageSize, setPageSize] = useState(5); + const [sortField, setSortField] = useState<'name' | 'id'>('name'); + const [sortDirection, setSortDirection] = useState('asc'); + + const workspaceList = useObservable(workspaces?.workspaceList$ ?? of([]), []); + + const pageOfItems = useMemo(() => { + return workspaceList + .sort((a, b) => { + const compare = a[sortField].localeCompare(b[sortField]); + return sortDirection === 'asc' ? compare : -compare; + }) + .slice(pageIndex * pageSize, (pageIndex + 1) * pageSize); + }, [workspaceList, pageIndex, pageSize, sortField, sortDirection]); + + const handleSwitchWorkspace = useCallback( + (id: string) => { + if (application && http) { + switchWorkspace({ application, http }, id); + } + }, + [application, http] + ); + + 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, + }, + ]; + + const onTableChange = ({ page, sort }: CriteriaWithPagination) => { + const { field, direction } = sort!; + const { index, size } = page; + + setPageIndex(index); + setPageSize(size); + setSortField(field as 'name' | 'id'); + setSortDirection(direction); + }; + + return ( + + + + + + + + + ); +}; 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..4c0868a8b373 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -0,0 +1,237 @@ +/* + * 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'; + +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( + formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }), + currentWorkspace?.id ?? '', + basePath + ) + ); + setPopover(false); + }, + }); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.allWorkspace', { + defaultMessage: 'All workspaces', + }), + key: (length + 1).toString(), + onClick: () => { + navigateToUrl( + formatUrlWithWorkspaceId( + getUrlForApp(WORKSPACE_LIST_APP_ID, { + absolute: false, + }), + currentWorkspace?.id ?? '', + basePath + ) + ); + 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..6a8d85d450dd --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_updater/workspace_updater.tsx @@ -0,0 +1,205 @@ +/* + * 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 { + result = await workspaceClient.update(currentWorkspace?.id, data); + } 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/index.ts b/src/plugins/workspace/public/index.ts new file mode 100644 index 000000000000..99161a7edbd7 --- /dev/null +++ b/src/plugins/workspace/public/index.ts @@ -0,0 +1,10 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspacePlugin } from './plugin'; + +export function plugin() { + return new WorkspacePlugin(); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts new file mode 100644 index 000000000000..3b0b54569cbd --- /dev/null +++ b/src/plugins/workspace/public/plugin.test.ts @@ -0,0 +1,134 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Observable, Subscriber } from 'rxjs'; +import { waitFor } from '@testing-library/dom'; +import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; +import { 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(); + }); +}); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts new file mode 100644 index 000000000000..5592a32390fd --- /dev/null +++ b/src/plugins/workspace/public/plugin.ts @@ -0,0 +1,240 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +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, + WorkspaceAttribute, + 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); + }, + }); + + return {}; + } + + private _changeSavedObjectCurrentWorkspace() { + if (this.coreStart) { + return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { + if (currentWorkspaceId) { + this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); + } + }); + } + } + + private filterByWorkspace(workspace: WorkspaceAttribute | 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 chromeNavLinks$ = navLinksService.getNavLinks$(); + const currentWorkspace$ = core.workspaces.currentWorkspace$; + combineLatest([ + chromeNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), + currentWorkspace$, + ]).subscribe(([chromeNavLinks, currentWorkspace]) => { + const filteredNavLinks = new Map(); + chromeNavLinks = this.filterByWorkspace(currentWorkspace, chromeNavLinks); + chromeNavLinks.forEach((chromeNavLink) => { + filteredNavLinks.set(chromeNavLink.id, chromeNavLink); + }); + navLinksService.setFilteredNavLinks(filteredNavLinks); + }); + } + + /** + * 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() { + 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..9f1a106a0a50 --- /dev/null +++ b/src/plugins/workspace/public/workspace_client.ts @@ -0,0 +1,294 @@ +/* + * 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 WorkspaceRoutePermissionItem = { + 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: WorkspaceRoutePermissionItem[]; + } + ): Promise> { + const path = this.getPath(); + + const result = await this.safeFetch(path, { + method: 'POST', + body: JSON.stringify({ + attributes, + }), + }); + + 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< + WorkspaceAttribute & { + permissions: WorkspaceRoutePermissionItem[]; + } + > + ): Promise> { + const path = this.getPath(id); + const body = { + attributes, + }; + + 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/index.ts b/src/plugins/workspace/server/index.ts new file mode 100644 index 000000000000..fe44b4d71757 --- /dev/null +++ b/src/plugins/workspace/server/index.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PluginConfigDescriptor, PluginInitializerContext } from '../../../core/server'; +import { WorkspacePlugin } from './plugin'; +import { configSchema } from '../config'; + +// This exports static code and TypeScript types, +// as well as, OpenSearch Dashboards Platform `plugin()` initializer. + +export function plugin(initializerContext: PluginInitializerContext) { + return new WorkspacePlugin(initializerContext); +} + +export const config: PluginConfigDescriptor = { + schema: configSchema, +}; + +export { WorkspaceFindOptions } from './types'; diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts new file mode 100644 index 000000000000..4d2b50e02f06 --- /dev/null +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -0,0 +1,192 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspaceAttribute } from 'src/core/types'; +import { omit } from 'lodash'; +import * as osdTestServer from '../../../../core/test_helpers/osd_server'; +import { WorkspaceRoutePermissionItem } from '../types'; +import { WorkspacePermissionMode } from '../../../../core/server'; +import { WORKSPACE_TYPE } from '../../../../core/server'; + +const testWorkspace: WorkspaceAttribute & { + permissions: WorkspaceRoutePermissionItem; +} = { + id: 'fake_id', + name: 'test_workspace', + description: 'test_workspace_description', +}; + +describe('workspace service', () => { + let root: ReturnType; + let opensearchServer: osdTestServer.TestOpenSearchUtils; + beforeAll(async () => { + const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + settings: { + 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(); + }); + describe('Workspace CRUD apis', () => { + afterEach(async () => { + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + // 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) + ) + ); + }); + it('create', async () => { + await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: testWorkspace, + }) + .expect(400); + + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .expect(200); + + expect(result.body.success).toEqual(true); + expect(typeof result.body.result.id).toBe('string'); + }); + it('get', async () => { + const result = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .expect(200); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + expect(getResult.body.result.name).toEqual(testWorkspace.name); + }); + it('update', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .expect(200); + + await osdTestServer.request + .put(root, `/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omit(testWorkspace, 'id'), + name: 'updated', + }, + }) + .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'); + }); + it('delete', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .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); + + const getResult = await osdTestServer.request.get( + root, + `/api/workspaces/${result.body.result.id}` + ); + + 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: omit(reservedWorkspace, 'id'), + }) + .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 + .post(root, `/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .expect(200); + + const listResult = await osdTestServer.request + .post(root, `/api/workspaces/_list`) + .send({ + page: 1, + }) + .expect(200); + expect(listResult.body.result.total).toEqual(1); + }); + }); +}); 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..7ba4a4f454be --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.ts @@ -0,0 +1,150 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { i18n } from '@osd/i18n'; +import { OpenSearchDashboardsRequest, Principals, SavedObject } 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], + }); + } + + 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 new file mode 100644 index 000000000000..a7cdb8c97d4b --- /dev/null +++ b/src/plugins/workspace/server/plugin.ts @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + Logger, + SavedObjectsClient, +} from '../../../core/server'; +import { IWorkspaceDBImpl } from './types'; +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 { WorkspacePluginConfigType } from '../config'; +import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; + +export class WorkspacePlugin implements Plugin<{}, {}> { + private readonly logger: Logger; + private client?: IWorkspaceDBImpl; + private permissionControl?: SavedObjectsPermissionControlContract; + private readonly config$: Observable; + + 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(); + this.config$ = initializerContext.config.create(); + } + + public async setup(core: CoreSetup) { + this.logger.debug('Setting up Workspaces service'); + const config: WorkspacePluginConfigType = await this.config$.pipe(first()).toPromise(); + const isPermissionControlEnabled = + config.permission.enabled === undefined ? true : config.permission.enabled; + + 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, + }); + + const workspaceSavedObjectsClientWrapper = new WorkspaceSavedObjectsClientWrapper( + this.permissionControl + ); + + core.savedObjects.addClientWrapper( + 0, + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + workspaceSavedObjectsClientWrapper.wrapperFactory + ); + } + + this.proxyWorkspaceTrafficToRealHandler(core); + + registerRoutes({ + http: core.http, + logger: this.logger, + client: this.client as IWorkspaceDBImpl, + }); + + core.savedObjects.setClientFactoryProvider((repositoryFactory) => () => + new SavedObjectsClient(repositoryFactory.createInternalRepository()) + ); + + core.capabilities.registerProvider(() => ({ + workspaces: { + enabled: true, + permissionEnabled: isPermissionControlEnabled, + }, + })); + + return { + client: this.client, + }; + } + + public start(core: CoreStart) { + this.logger.debug('Starting SavedObjects service'); + this.permissionControl?.setup(core.savedObjects.getScopedClient); + this.client?.setSavedObjects(core.savedObjects); + + return { + client: this.client as IWorkspaceDBImpl, + }; + } + + public stop() {} +} diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts new file mode 100644 index 000000000000..2701c2a767ab --- /dev/null +++ b/src/plugins/workspace/server/routes/index.ts @@ -0,0 +1,284 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { ensureRawRequest } from '../../../../core/server'; + +import { + ACL, + Permissions, + CoreSetup, + Logger, + WorkspacePermissionMode, +} from '../../../../core/server'; +import { IWorkspaceDBImpl, WorkspaceRoutePermissionItem } 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), + schema.literal(WorkspacePermissionMode.Read), + schema.literal(WorkspacePermissionMode.Write), +]); + +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()), + name: schema.string(), + 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()), + permissions: schema.maybe( + schema.oneOf([workspacePermission, schema.arrayOf(workspacePermission)]) + ), +}); + +const convertToACL = ( + workspacePermissions: WorkspaceRoutePermissionItem | WorkspaceRoutePermissionItem[] +) => { + 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 convertFromACL = (permissions: Permissions) => { + const acl = new ACL(permissions); + + return acl.toFlatList().map(({ name, permissions: modes, type }) => ({ + type: type === 'users' ? 'user' : 'group', + modes, + ...{ [type === 'users' ? 'userId' : 'group']: name }, + })); +}; + +export function registerRoutes({ + client, + logger, + http, +}: { + client: IWorkspaceDBImpl; + logger: Logger; + http: CoreSetup['http']; +}) { + const router = http.createRouter(); + router.post( + { + path: `${WORKSPACES_API_BASE_URL}/_list`, + validate: { + body: schema.object({ + search: schema.maybe(schema.string()), + sortOrder: schema.maybe(schema.string()), + perPage: schema.number({ min: 0, defaultValue: 20 }), + 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)), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const result = await client.list( + { + context, + request: req, + logger, + }, + req.body + ); + if (!result.success) { + return res.ok({ body: result }); + } + return res.ok({ + body: { + ...result, + result: { + ...result.result, + workspaces: result.result.workspaces.map((workspace) => ({ + ...workspace, + ...(workspace.permissions + ? { permissions: convertFromACL(workspace.permissions) } + : {}), + })), + }, + }, + }); + }) + ); + router.get( + { + path: `${WORKSPACES_API_BASE_URL}/{id}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { id } = req.params; + const result = await client.get( + { + context, + request: req, + logger, + }, + id + ); + if (!result.success) { + return res.ok({ body: result }); + } + + return res.ok({ + body: { + ...result, + result: { + ...result.result, + ...(result.result.permissions + ? { permissions: convertFromACL(result.result.permissions) } + : {}), + }, + }, + }); + }) + ); + router.post( + { + path: `${WORKSPACES_API_BASE_URL}`, + validate: { + body: schema.object({ + attributes: workspaceAttributesSchema, + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { attributes } = req.body; + const rawRequest = ensureRawRequest(req); + const authInfo = rawRequest?.auth?.credentials?.authInfo as { user_name?: string } | null; + const { permissions: permissionsInAttributes, ...others } = attributes; + let permissions: WorkspaceRoutePermissionItem[] = []; + if (permissionsInAttributes) { + permissions = Array.isArray(permissionsInAttributes) + ? permissionsInAttributes + : [permissionsInAttributes]; + } + + if (!!authInfo?.user_name) { + permissions.push({ + type: 'user', + userId: authInfo.user_name, + modes: [WorkspacePermissionMode.LibraryWrite], + }); + permissions.push({ + type: 'user', + userId: authInfo.user_name, + modes: [WorkspacePermissionMode.Write], + }); + } + + const result = await client.create( + { + context, + request: req, + logger, + }, + { + ...others, + ...(permissions.length ? { permissions: convertToACL(permissions) } : {}), + } + ); + return res.ok({ body: result }); + }) + ); + router.put( + { + path: `${WORKSPACES_API_BASE_URL}/{id?}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + body: schema.object({ + attributes: workspaceAttributesSchema, + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { id } = req.params; + const { attributes } = req.body; + const { permissions, ...others } = attributes; + let finalPermissions: WorkspaceRoutePermissionItem[] = []; + if (permissions) { + finalPermissions = Array.isArray(permissions) ? permissions : [permissions]; + } + + const result = await client.update( + { + context, + request: req, + logger, + }, + id, + { + ...others, + ...(finalPermissions.length ? { permissions: convertToACL(finalPermissions) } : {}), + } + ); + return res.ok({ body: result }); + }) + ); + router.delete( + { + path: `${WORKSPACES_API_BASE_URL}/{id?}`, + validate: { + params: schema.object({ + id: schema.string(), + }), + }, + }, + router.handleLegacyErrors(async (context, req, res) => { + const { id } = req.params; + + const result = await client.delete( + { + context, + request: req, + logger, + }, + id + ); + 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 new file mode 100644 index 000000000000..e47be61b0cd2 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/index.ts @@ -0,0 +1,7 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { workspace } from './workspace'; +export { WorkspaceSavedObjectsClientWrapper } from './workspace_saved_objects_client_wrapper'; 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/workspace.ts b/src/plugins/workspace/server/saved_objects/workspace.ts new file mode 100644 index 000000000000..a26e695d83cb --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace.ts @@ -0,0 +1,44 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsType, WORKSPACE_TYPE } from '../../../../core/server'; + +export const workspace: SavedObjectsType = { + name: WORKSPACE_TYPE, + namespaceType: 'agnostic', + hidden: false, + /** + * workspace won't appear in management page. + */ + mappings: { + dynamic: false, + properties: { + name: { + type: 'keyword', + }, + description: { + type: 'text', + }, + /** + * In opensearch, string[] is also mapped to text + */ + features: { + type: 'keyword', + }, + color: { + type: 'keyword', + }, + icon: { + type: 'keyword', + }, + defaultVISTheme: { + type: 'keyword', + }, + reserved: { + type: 'boolean', + }, + }, + }, +}; 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..e34e6b9bb607 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -0,0 +1,532 @@ +/* + * 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, +} from '../../../../core/server'; +import { SavedObjectsPermissionControlContract } from '../permission_control/client'; +import { getPrincipalsFromRequest } from '../utils'; + +// 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 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; + } + + 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 { + const permittedWorkspaceIds = ( + await wrapperOptions.client.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 new file mode 100644 index 000000000000..bf3337c4ae46 --- /dev/null +++ b/src/plugins/workspace/server/types.ts @@ -0,0 +1,90 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + Logger, + OpenSearchDashboardsRequest, + RequestHandlerContext, + SavedObjectsFindResponse, + CoreSetup, + WorkspacePermissionMode, + Permissions, + WorkspaceAttribute, + SavedObjectsServiceStart, +} from '../../../core/server'; + +export interface WorkspaceAttributeWithPermission extends WorkspaceAttribute { + permissions?: Permissions; +} + +export interface WorkspaceFindOptions { + page?: number; + perPage?: number; + search?: string; + searchFields?: string[]; + sortField?: string; + sortOrder?: string; + permissionModes?: WorkspacePermissionMode[]; +} + +export interface IRequestDetail { + request: OpenSearchDashboardsRequest; + context: RequestHandlerContext; + logger: Logger; +} + +export interface IWorkspaceDBImpl { + setup(dep: CoreSetup): Promise>; + setSavedObjects(savedObjects: SavedObjectsServiceStart): void; + create( + requestDetail: IRequestDetail, + payload: Omit + ): Promise>; + list( + requestDetail: IRequestDetail, + options: WorkspaceFindOptions + ): Promise< + IResponse< + { + workspaces: WorkspaceAttributeWithPermission[]; + } & Pick + > + >; + get( + requestDetail: IRequestDetail, + id: string + ): Promise>; + update( + requestDetail: IRequestDetail, + id: string, + payload: Omit + ): Promise>; + delete(requestDetail: IRequestDetail, id: string): Promise>; + destroy(): Promise>; +} + +export type IResponse = + | { + result: T; + success: true; + } + | { + success: false; + error?: string; + }; + +export type WorkspaceRoutePermissionItem = { + 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.test.ts b/src/plugins/workspace/server/utils.test.ts new file mode 100644 index 000000000000..119b8889f715 --- /dev/null +++ b/src/plugins/workspace/server/utils.test.ts @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { generateRandomId } from './utils'; + +describe('workspace utils', () => { + it('should generate id with the specified size', () => { + expect(generateRandomId(6)).toHaveLength(6); + }); + + it('should generate random IDs', () => { + const NUM_OF_ID = 10000; + const ids = new Set(); + for (let i = 0; i < NUM_OF_ID; i++) { + ids.add(generateRandomId(6)); + } + expect(ids.size).toBe(NUM_OF_ID); + }); +}); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts new file mode 100644 index 000000000000..d2ff66e8b486 --- /dev/null +++ b/src/plugins/workspace/server/utils.ts @@ -0,0 +1,48 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import crypto from 'crypto'; +import { + ensureRawRequest, + OpenSearchDashboardsRequest, + Principals, + PrincipalType, +} from '../../../core/server'; +import { AuthInfo } from './types'; + +/** + * Generate URL friendly random ID + */ +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 new file mode 100644 index 000000000000..fcc92a2e8edd --- /dev/null +++ b/src/plugins/workspace/server/workspace_client.ts @@ -0,0 +1,421 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { omit } from 'lodash'; +import type { + SavedObject, + SavedObjectsClientContract, + CoreSetup, + WorkspaceAttribute, + SavedObjectsServiceStart, + Logger, + Permissions, + OpenSearchDashboardsRequest, +} from '../../../core/server'; +import { + ACL, + DEFAULT_APP_CATEGORIES, + MANAGEMENT_WORKSPACE_ID, + PUBLIC_WORKSPACE_ID, + WORKSPACE_TYPE, + WorkspacePermissionMode, + PERSONAL_WORKSPACE_ID_PREFIX, +} from '../../../core/server'; +import { + IWorkspaceDBImpl, + WorkspaceFindOptions, + IResponse, + IRequestDetail, + WorkspaceAttributeWithPermission, +} from './types'; +import { workspace } from './saved_objects'; +import { generateRandomId, getPrincipalsFromRequest } from './utils'; +import { + WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + WORKSPACE_UPDATE_APP_ID, +} from '../common/constants'; + +const WORKSPACE_ID_SIZE = 6; + +const DUPLICATE_WORKSPACE_NAME_ERROR = i18n.translate('workspace.duplicate.name.error', { + defaultMessage: 'workspace name has already been used, try with a different name', +}); + +const RESERVED_WORKSPACE_NAME_ERROR = i18n.translate('workspace.reserved.name.error', { + defaultMessage: 'reserved workspace name cannot be changed', +}); + +export class WorkspaceClientWithSavedObject implements IWorkspaceDBImpl { + private setupDep: CoreSetup; + private logger: Logger; + + private savedObjects?: SavedObjectsServiceStart; + + constructor(core: CoreSetup, logger: Logger) { + this.setupDep = core; + this.logger = logger; + } + + private getScopedClientWithoutPermission( + requestDetail: IRequestDetail + ): SavedObjectsClientContract | undefined { + return this.savedObjects?.getScopedClient(requestDetail.request, { + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + }); + } + + private getSavedObjectClientsFromRequestDetail( + requestDetail: IRequestDetail + ): SavedObjectsClientContract { + return requestDetail.context.core.savedObjects.client; + } + private getFlattenedResultWithSavedObject( + savedObject: SavedObject + ): WorkspaceAttributeWithPermission { + return { + ...savedObject.attributes, + permissions: savedObject.permissions || {}, + 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 { + success: true, + result: true, + }; + } + public async create( + requestDetail: IRequestDetail, + payload: Omit + ): ReturnType { + try { + const { permissions, ...attributes } = payload; + const id = generateRandomId(WORKSPACE_ID_SIZE); + const client = this.getSavedObjectClientsFromRequestDetail(requestDetail); + const existingWorkspaceRes = await this.getScopedClientWithoutPermission(requestDetail)?.find( + { + 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); + } + const result = await client.create>( + WORKSPACE_TYPE, + attributes, + { + id, + permissions, + } + ); + return { + success: true, + result: { + id: result.id, + }, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async list( + requestDetail: IRequestDetail, + options: WorkspaceFindOptions + ): ReturnType { + try { + const { permissionModes, ...restOptions } = options; + const resultResp = await this.getSavedObjectClientsFromRequestDetail(requestDetail).find< + WorkspaceAttribute + >({ + ...restOptions, + type: WORKSPACE_TYPE, + ...(permissionModes ? { ACLSearchParams: { permissionModes } } : {}), + }); + const others = omit(resultResp, 'saved_objects'); + let savedObjects = resultResp.saved_objects; + 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: { + ...others, + workspaces: savedObjects.map((item) => this.getFlattenedResultWithSavedObject(item)), + }, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async get( + requestDetail: IRequestDetail, + id: string + ): Promise> { + try { + const result = await this.getSavedObjectClientsFromRequestDetail(requestDetail).get< + WorkspaceAttribute + >(WORKSPACE_TYPE, id); + return { + success: true, + result: this.getFlattenedResultWithSavedObject(result), + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async update( + requestDetail: IRequestDetail, + id: string, + payload: Omit + ): Promise> { + 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({ + type: WORKSPACE_TYPE, + 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.create>(WORKSPACE_TYPE, attributes, { + id, + permissions, + overwrite: true, + version: workspaceInDB.version, + }); + + return { + success: true, + result: true, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public async delete(requestDetail: IRequestDetail, id: string): Promise> { + try { + 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, + }; + } catch (e: unknown) { + return { + success: false, + error: this.formatError(e), + }; + } + } + public setSavedObjects(savedObjects: SavedObjectsServiceStart) { + this.savedObjects = savedObjects; + } + public async destroy(): Promise> { + 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/index.js b/test/api_integration/apis/index.js index 54ffe6e774a5..2d870d88251d 100644 --- a/test/api_integration/apis/index.js +++ b/test/api_integration/apis/index.js @@ -45,5 +45,6 @@ export default function ({ loadTestFile }) { loadTestFile(require.resolve('./stats')); loadTestFile(require.resolve('./ui_metric')); loadTestFile(require.resolve('./telemetry')); + loadTestFile(require.resolve('./workspace')); }); } 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/api_integration/apis/workspace/index.ts b/test/api_integration/apis/workspace/index.ts new file mode 100644 index 000000000000..553ee0dce1af --- /dev/null +++ b/test/api_integration/apis/workspace/index.ts @@ -0,0 +1,132 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import expect from '@osd/expect'; +import { WorkspaceAttribute } from 'opensearch-dashboards/server'; +import { omit } from 'lodash'; +import { FtrProviderContext } from '../../ftr_provider_context'; + +const testWorkspace: WorkspaceAttribute = { + id: 'fake_id', + name: 'test_workspace', + description: 'test_workspace_description', +}; + +export default function ({ getService }: FtrProviderContext) { + const supertest = getService('supertest'); + + describe('Workspace CRUD apis', () => { + afterEach(async () => { + const listResult = await supertest + .post(`/api/workspaces/_list`) + .send({ + page: 1, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + await Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + supertest + .delete(`/api/workspaces/${item.id}`) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200) + ) + ); + }); + it('create', async () => { + await supertest + .post(`/api/workspaces`) + .send({ + attributes: testWorkspace, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(400); + + const result: any = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + expect(result.body.success).equal(true); + expect(result.body.result.id).to.be.a('string'); + }); + it('get', async () => { + const result = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const getResult = await supertest.get(`/api/workspaces/${result.body.result.id}`); + expect(getResult.body.result.name).equal(testWorkspace.name); + }); + it('update', async () => { + const result: any = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + await supertest + .put(`/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omit(testWorkspace, 'id'), + name: 'updated', + }, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const getResult = await supertest.get(`/api/workspaces/${result.body.result.id}`); + + expect(getResult.body.success).equal(true); + expect(getResult.body.result.name).equal('updated'); + }); + it('delete', async () => { + const result: any = await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + await supertest + .delete(`/api/workspaces/${result.body.result.id}`) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const getResult = await supertest.get(`/api/workspaces/${result.body.result.id}`); + + expect(getResult.body.success).equal(false); + }); + it('list', async () => { + await supertest + .post(`/api/workspaces`) + .send({ + attributes: omit(testWorkspace, 'id'), + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + + const listResult = await supertest + .post(`/api/workspaces/_list`) + .send({ + page: 1, + }) + .set('osd-xsrf', 'opensearch-dashboards') + .expect(200); + expect(listResult.body.result.total).equal(1); + }); + }).tags('is:workspace'); +} 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 13a424bd7ea6..7c3e2f162779 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') @@ -75,7 +74,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 203345e38f91..b5a5cf4ac9a9 100644 --- a/test/functional/apps/management/_handle_alias.js +++ b/test/functional/apps/management/_handle_alias.js @@ -64,7 +64,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); }); @@ -77,7 +76,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 2c432964f309..50700a12b718 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 631b4e85cb8b..fe19eb141b9c 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 98cda687e23b..637f7073d517 100644 --- a/test/functional/apps/management/_opensearch_dashboards_settings.js +++ b/test/functional/apps/management/_opensearch_dashboards_settings.js @@ -40,11 +40,9 @@ export default function ({ getService, getPageObjects }) { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); await PageObjects.settings.createIndexPattern('logstash-*'); - await PageObjects.settings.navigateTo(); }); 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 fd290ce76b8a..f0b69344c472 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -78,13 +78,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(); @@ -102,7 +100,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()); @@ -136,7 +133,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()); @@ -256,7 +252,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()); @@ -351,7 +346,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()); @@ -447,7 +441,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 adeefa118345..187d64458d49 100644 --- a/test/functional/apps/management/_scripted_fields_preview.js +++ b/test/functional/apps/management/_scripted_fields_preview.js @@ -40,7 +40,6 @@ export default function ({ getService, getPageObjects }) { await browser.setWindowSize(1200, 800); await PageObjects.settings.createIndexPattern(); - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); @@ -49,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 d852ac484eaa..faca1ff394b1 100644 --- a/test/functional/apps/visualize/_lab_mode.js +++ b/test/functional/apps/visualize/_lab_mode.js @@ -53,8 +53,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 @@ -67,8 +66,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 b862208276bf..75c9c3f9b6fe 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -102,10 +102,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(); }