From 450ed0f87e56df5eb57196c586507b4aa4d35ab8 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 11 Sep 2023 10:49:44 +0800 Subject: [PATCH 01/34] [Feature] Setup workspace skeleton and implement basic CRUD API (#130) * feature: setup workspace skeleton and implement basic CRUD API on workspace Signed-off-by: Zhou Su * feat: remove useless required plugins and logger typo Signed-off-by: SuZhou-Joe * feat: setup public side skeleton Signed-off-by: SuZhou-Joe --------- Signed-off-by: Zhou Su Signed-off-by: SuZhou-Joe Co-authored-by: Zhou Su --- src/core/types/workspace.ts | 1 + src/plugins/workspace/server/routes/index.ts | 11 +++++++++-- 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index c95b993edc74..a139db92267e 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -11,4 +11,5 @@ export interface WorkspaceAttribute { color?: string; icon?: string; reserved?: boolean; + defaultVISTheme?: string; } diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index 7c090be675f8..8eb18d0ad3fd 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -79,6 +79,9 @@ export function registerRoutes({ }, id ); + if (!result.success) { + return res.ok({ body: result }); + } return res.ok({ body: result, @@ -103,7 +106,9 @@ export function registerRoutes({ request: req, logger, }, - attributes + { + ...attributes, + } ); return res.ok({ body: result }); }) @@ -131,7 +136,9 @@ export function registerRoutes({ logger, }, id, - attributes + { + ...attributes, + } ); return res.ok({ body: result }); }) From 93aa3598229b733c4354a562fb6c0db43bb83218 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 14 Sep 2023 17:46:53 +0800 Subject: [PATCH 02/34] [Workspace] Add ACL related functions for workspace (#146) * [Workspace] Add acl related functions for workspace Signed-off-by: gaobinlong * Minor change Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong --- .../build_active_mappings.test.ts.snap | 110 ++++++++++++++++++ .../migrations/core/build_active_mappings.ts | 19 +++ .../migrations/core/index_migrator.test.ts | 105 +++++++++++++++++ ...pensearch_dashboards_migrator.test.ts.snap | 55 +++++++++ .../permission_control/acl.test.ts | 2 +- .../saved_objects/permission_control/acl.ts | 2 +- 6 files changed, 291 insertions(+), 2 deletions(-) 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..6f67893104e7 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,6 +10,7 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -36,6 +37,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 { @@ -69,6 +124,7 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", @@ -99,6 +155,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 { 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 55b73daabc3e..efedd9351a22 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 @@ -171,6 +171,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: { @@ -209,6 +219,15 @@ function defaultMapping(): IndexMapping { }, }, }, + 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 8b1f5df9640a..f22234fc8996 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 @@ -227,6 +227,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -240,6 +241,40 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + permissions: { + properties: { + library_read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + library_write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + management: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + }, + }, references: { type: 'nested', properties: { @@ -344,6 +379,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -358,6 +394,40 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + permissions: { + properties: { + library_read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + library_write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + management: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + }, + }, references: { type: 'nested', properties: { @@ -405,6 +475,7 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', + permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -419,6 +490,40 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + permissions: { + properties: { + library_read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + library_write: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + management: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + read: { + properties: { + users: { type: 'keyword' }, + groups: { type: 'keyword' }, + }, + }, + write: { + properties: { + users: { type: 'keyword' }, + groups: { 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..5e39af788d79 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,6 +10,7 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", + "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -44,6 +45,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 { diff --git a/src/core/server/saved_objects/permission_control/acl.test.ts b/src/core/server/saved_objects/permission_control/acl.test.ts index 184c10a36aaa..a0aeb53e5d18 100644 --- a/src/core/server/saved_objects/permission_control/acl.test.ts +++ b/src/core/server/saved_objects/permission_control/acl.test.ts @@ -345,4 +345,4 @@ describe('acl', () => { }, }); }); -}); +}); \ No newline at end of file diff --git a/src/core/server/saved_objects/permission_control/acl.ts b/src/core/server/saved_objects/permission_control/acl.ts index 769304fe8736..d0ecc280f347 100644 --- a/src/core/server/saved_objects/permission_control/acl.ts +++ b/src/core/server/saved_objects/permission_control/acl.ts @@ -334,4 +334,4 @@ export class ACL { return { query: { bool } }; } -} +} \ No newline at end of file From bc555d2de293f4023d03abbdbdfcf05a88220c6b Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Tue, 19 Sep 2023 17:21:04 +0800 Subject: [PATCH 03/34] feat: add core workspace module (#145) The core workspace module(WorkspaceService) is a foundational component that enables the implementation of workspace features within OSD plugins. The purpose of the core workspace module is to provide a framework for workspace implementations. This module does not implement specific workspace functionality but provides the essential infrastructure for plugins to extend and customize workspace features, it maintains a shared workspace state(observables) across the entire application to ensure a consistent and up-to-date view of workspace-related information to all parts of the application. --------- Signed-off-by: Yulong Ruan --- src/core/types/workspace.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index a139db92267e..85bfc6711ad6 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -12,4 +12,5 @@ export interface WorkspaceAttribute { icon?: string; reserved?: boolean; defaultVISTheme?: string; + reserved?: boolean; } From 58766563d03d48de11fabc3fc71ef9dcd1448cea Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 20 Sep 2023 17:37:45 +0800 Subject: [PATCH 04/34] use self hosted runner and disable windows workflow (#182) disable github workflows running on windows for development (#161) --------- Signed-off-by: Yulong Ruan Signed-off-by: SuZhou-Joe Co-authored-by: SuZhou-Joe --- .github/workflows/build_and_test_workflow.yml | 23 ++----------------- .github/workflows/cypress_workflow.yml | 2 +- 2 files changed, 3 insertions(+), 22 deletions(-) diff --git a/.github/workflows/build_and_test_workflow.yml b/.github/workflows/build_and_test_workflow.yml index ec3c10c88779..1557a74f3a7a 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 name: Linux - - os: windows-latest - name: Windows runs-on: ${{ matrix.os }} steps: - name: Configure git's autocrlf (Windows only) @@ -138,13 +136,11 @@ jobs: strategy: fail-fast: false matrix: - os: [ubuntu-latest, windows-latest] + os: [arc-runner-set] group: [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] include: - os: ubuntu-latest name: Linux - - os: windows-latest - name: Windows runs-on: ${{ matrix.os }} steps: - run: echo Running functional tests for ciGroup${{ matrix.group }} @@ -332,21 +328,6 @@ jobs: ext: tar.gz suffix: linux-arm64 script: build-platform --linux-arm --skip-os-packages - - os: macos-latest - name: macOS x64 - ext: tar.gz - suffix: darwin-x64 - script: build-platform --darwin --skip-os-packages - - os: macos-latest - name: macOS ARM64 - ext: tar.gz - suffix: darwin-arm64 - script: build-platform --darwin-arm --skip-os-packages - - os: windows-latest - name: Windows x64 - ext: zip - suffix: windows-x64 - script: build-platform --windows --skip-os-packages runs-on: ${{ matrix.os }} defaults: run: diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index e354be1415ce..17b84f239ec9 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -43,7 +43,7 @@ env: jobs: cypress-tests: - runs-on: ubuntu-latest + runs-on: arc-runner-set strategy: fail-fast: false matrix: From fa68b39d110b93d4c4cf8d8b285c9332e86ac8c1 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Fri, 22 Sep 2023 17:39:08 +0800 Subject: [PATCH 05/34] fix backport workflow Signed-off-by: Yulong Ruan --- .github/workflows/backport.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/backport.yml b/.github/workflows/backport.yml index c8dfef417daa..5b4772a59978 100644 --- a/.github/workflows/backport.yml +++ b/.github/workflows/backport.yml @@ -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 From 1d8088ea0ccb467f8c8e29553f7c3a05a6f8a363 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Fri, 29 Sep 2023 07:38:42 +0800 Subject: [PATCH 06/34] [Workspace] Add optional workspaces parameter to all saved objects API (#185) * [Workspace] Add workspaces parameters to all saved objects API Signed-off-by: gaobinlong * feat: update snapshot Signed-off-by: SuZhou-Joe * feat: optimize logic when checkConflict and bulkCreate (#189) * feat: optimize logic when checkConflict and bulkCreate Signed-off-by: SuZhou-Joe * feat: add options.workspace check Signed-off-by: SuZhou-Joe * feat: throw error when workspace check error in repository create Signed-off-by: SuZhou-Joe * feat: modify judgement Signed-off-by: SuZhou-Joe * feat: always get objects from DB when create-with-override Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe * feat: call get when create with override Signed-off-by: SuZhou-Joe * feat: update test according to count Signed-off-by: SuZhou-Joe * feat: add integration test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * feat: regenerate ids when import Signed-off-by: SuZhou-Joe * feat: add more unit test Signed-off-by: SuZhou-Joe * feat: minor changes logic on repository Signed-off-by: SuZhou-Joe * feat: update unit test Signed-off-by: SuZhou-Joe * feat: update test Signed-off-by: SuZhou-Joe * feat: optimization according to comments Signed-off-by: SuZhou-Joe * feat: update test Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe --------- Signed-off-by: gaobinlong Signed-off-by: SuZhou-Joe Co-authored-by: SuZhou-Joe --- .../export/get_sorted_objects_for_export.ts | 4 +- .../import/import_saved_objects.test.ts | 12 +- .../import/import_saved_objects.ts | 12 +- .../import/regenerate_ids.test.ts | 71 +++- .../saved_objects/import/regenerate_ids.ts | 35 +- .../build_active_mappings.test.ts.snap | 8 + .../migrations/core/build_active_mappings.ts | 3 + .../migrations/core/index_migrator.test.ts | 12 + ...pensearch_dashboards_migrator.test.ts.snap | 4 + .../routes/integration_tests/find.test.ts | 34 ++ .../routes/resolve_import_errors.ts | 1 + .../lib/integration_tests/repository.test.ts | 310 ++++++++++++++++++ .../service/lib/repository.test.js | 147 ++++++++- .../saved_objects/service/lib/repository.ts | 47 ++- .../service/lib/search_dsl/query_params.ts | 14 + .../server/saved_objects/service/lib/utils.ts | 7 + .../service/saved_objects_client.ts | 2 +- 17 files changed, 701 insertions(+), 22 deletions(-) create mode 100644 src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts 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 ea944ff3307a..660f86846137 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 @@ -109,7 +109,9 @@ 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, + }); const erroredObjects = bulkGetResult.saved_objects.filter((obj) => !!obj.error); if (erroredObjects.length) { const err = Boom.badRequest(); diff --git a/src/core/server/saved_objects/import/import_saved_objects.test.ts b/src/core/server/saved_objects/import/import_saved_objects.test.ts index 3dda6931bd1e..dcb8d685d42c 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.test.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.test.ts @@ -42,7 +42,7 @@ import { typeRegistryMock } from '../saved_objects_type_registry.mock'; import { importSavedObjectsFromStream } from './import_saved_objects'; import { collectSavedObjects } from './collect_saved_objects'; -import { regenerateIds } from './regenerate_ids'; +import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids'; import { validateReferences } from './validate_references'; import { checkConflicts } from './check_conflicts'; import { checkOriginConflicts } from './check_origin_conflicts'; @@ -70,6 +70,7 @@ describe('#importSavedObjectsFromStream', () => { importIdMap: new Map(), }); getMockFn(regenerateIds).mockReturnValue(new Map()); + getMockFn(regenerateIdsWithReference).mockReturnValue(Promise.resolve(new Map())); getMockFn(validateReferences).mockResolvedValue([]); getMockFn(checkConflicts).mockResolvedValue({ errors: [], @@ -278,6 +279,15 @@ describe('#importSavedObjectsFromStream', () => { ]), }); getMockFn(validateReferences).mockResolvedValue([errors[1]]); + getMockFn(regenerateIdsWithReference).mockResolvedValue( + Promise.resolve( + new Map([ + ['foo', {}], + ['bar', {}], + ['baz', {}], + ]) + ) + ); getMockFn(checkConflicts).mockResolvedValue({ errors: [errors[2]], filteredObjects, diff --git a/src/core/server/saved_objects/import/import_saved_objects.ts b/src/core/server/saved_objects/import/import_saved_objects.ts index f2833c198e1b..0222223a1751 100644 --- a/src/core/server/saved_objects/import/import_saved_objects.ts +++ b/src/core/server/saved_objects/import/import_saved_objects.ts @@ -38,7 +38,7 @@ import { validateReferences } from './validate_references'; import { checkOriginConflicts } from './check_origin_conflicts'; import { createSavedObjects } from './create_saved_objects'; import { checkConflicts } from './check_conflicts'; -import { regenerateIds } from './regenerate_ids'; +import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids'; import { checkConflictsForDataSource } from './check_conflict_for_data_source'; /** @@ -87,6 +87,16 @@ export async function importSavedObjectsFromStream({ importIdMap = regenerateIds(collectSavedObjectsResult.collectedObjects, dataSourceId); } else { // in check conclict and override mode + + if (workspaces) { + 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, diff --git a/src/core/server/saved_objects/import/regenerate_ids.test.ts b/src/core/server/saved_objects/import/regenerate_ids.test.ts index c7dbfb8b50bc..895b90a89324 100644 --- a/src/core/server/saved_objects/import/regenerate_ids.test.ts +++ b/src/core/server/saved_objects/import/regenerate_ids.test.ts @@ -29,8 +29,10 @@ */ import { mockUuidv4 } from './__mocks__'; -import { regenerateIds } from './regenerate_ids'; +import { regenerateIds, regenerateIdsWithReference } from './regenerate_ids'; import { SavedObject } from '../types'; +import { savedObjectsClientMock } from '../service/saved_objects_client.mock'; +import { SavedObjectsBulkResponse } from '../service'; describe('#regenerateIds', () => { const objects = ([ @@ -68,3 +70,70 @@ describe('#regenerateIds', () => { `); }); }); + +describe('#regenerateIdsWithReference', () => { + const objects = ([ + { type: 'foo', id: '1' }, + { type: 'bar', id: '2' }, + { type: 'baz', id: '3' }, + ] as any) as SavedObject[]; + + test('returns expected values', async () => { + const mockedSavedObjectsClient = savedObjectsClientMock.create(); + mockUuidv4.mockReturnValueOnce('uuidv4 #1'); + const result: SavedObjectsBulkResponse = { + saved_objects: [ + { + error: { + statusCode: 404, + error: '', + message: '', + }, + id: '1', + type: 'foo', + attributes: {}, + references: [], + }, + { + id: '2', + type: 'bar', + attributes: {}, + references: [], + workspaces: ['bar'], + }, + { + id: '3', + type: 'baz', + attributes: {}, + references: [], + workspaces: ['foo'], + }, + ], + }; + mockedSavedObjectsClient.bulkGet.mockResolvedValue(result); + expect( + await regenerateIdsWithReference({ + savedObjects: objects, + savedObjectsClient: mockedSavedObjectsClient, + workspaces: ['bar'], + objectLimit: 1000, + importIdMap: new Map(), + }) + ).toMatchInlineSnapshot(` + Map { + "foo:1" => Object { + "id": "1", + "omitOriginId": true, + }, + "bar:2" => Object { + "id": "2", + "omitOriginId": false, + }, + "baz:3" => Object { + "id": "uuidv4 #1", + "omitOriginId": true, + }, + } + `); + }); +}); diff --git a/src/core/server/saved_objects/import/regenerate_ids.ts b/src/core/server/saved_objects/import/regenerate_ids.ts index f1092bed7f55..bf89fad824d6 100644 --- a/src/core/server/saved_objects/import/regenerate_ids.ts +++ b/src/core/server/saved_objects/import/regenerate_ids.ts @@ -29,7 +29,8 @@ */ import { v4 as uuidv4 } from 'uuid'; -import { SavedObject } from '../types'; +import { SavedObject, SavedObjectsClientContract } from '../types'; +import { SavedObjectsUtils } from '../service'; /** * Takes an array of saved objects and returns an importIdMap of randomly-generated new IDs. @@ -47,3 +48,35 @@ export const regenerateIds = (objects: SavedObject[], dataSourceId: string | und }, new Map()); return importIdMap; }; + +export const regenerateIdsWithReference = async (props: { + savedObjects: SavedObject[]; + savedObjectsClient: SavedObjectsClientContract; + workspaces: string[]; + objectLimit: number; + importIdMap: Map; +}): Promise> => { + const { savedObjects, savedObjectsClient, workspaces, importIdMap } = props; + + 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/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 6f67893104e7..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 @@ -14,6 +14,7 @@ Object { "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -111,6 +112,9 @@ Object { "updated_at": Object { "type": "date", }, + "workspaces": Object { + "type": "keyword", + }, }, } `; @@ -130,6 +134,7 @@ Object { "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -244,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 efedd9351a22..fac64bf78b01 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 @@ -219,6 +219,9 @@ function defaultMapping(): IndexMapping { }, }, }, + workspaces: { + type: 'keyword', + }, permissions: { properties: { read: 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 f22234fc8996..de2e14572278 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 @@ -231,6 +231,7 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', }, }, properties: { @@ -241,6 +242,9 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + workspaces: { + type: 'keyword', + }, permissions: { properties: { library_read: { @@ -383,6 +387,7 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', }, }, properties: { @@ -394,6 +399,9 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + workspaces: { + type: 'keyword', + }, permissions: { properties: { library_read: { @@ -479,6 +487,7 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', + workspaces: '2f4316de49999235636386fe51dc06c1', }, }, properties: { @@ -490,6 +499,9 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, + workspaces: { + type: 'keyword', + }, permissions: { properties: { library_read: { 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 5e39af788d79..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 @@ -14,6 +14,7 @@ Object { "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", + "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -119,6 +120,9 @@ Object { "updated_at": Object { "type": "date", }, + "workspaces": Object { + "type": "keyword", + }, }, } `; diff --git a/src/core/server/saved_objects/routes/integration_tests/find.test.ts b/src/core/server/saved_objects/routes/integration_tests/find.test.ts index fc21eefed434..b21425386400 100644 --- a/src/core/server/saved_objects/routes/integration_tests/find.test.ts +++ b/src/core/server/saved_objects/routes/integration_tests/find.test.ts @@ -288,4 +288,38 @@ describe('GET /api/saved_objects/_find', () => { defaultSearchOperator: 'OR', }); }); + + it('accepts the query parameter workspaces as a string', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&workspaces=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options).toEqual({ + defaultSearchOperator: 'OR', + perPage: 20, + page: 1, + type: ['index-pattern'], + workspaces: ['foo'], + }); + }); + + it('accepts the query parameter workspaces as an array', async () => { + await supertest(httpSetup.server.listener) + .get('/api/saved_objects/_find?type=index-pattern&workspaces=default&workspaces=foo') + .expect(200); + + expect(savedObjectsClient.find).toHaveBeenCalledTimes(1); + + const options = savedObjectsClient.find.mock.calls[0][0]; + expect(options).toEqual({ + perPage: 20, + page: 1, + type: ['index-pattern'], + workspaces: ['default', 'foo'], + defaultSearchOperator: 'OR', + }); + }); }); 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 6bc667eba0df..7d22e35a658d 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -135,6 +135,7 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO workspaces, dataSourceId, dataSourceTitle, + workspaces, }); return res.ok({ body: result }); diff --git a/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts b/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts new file mode 100644 index 000000000000..b601de985dc0 --- /dev/null +++ b/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts @@ -0,0 +1,310 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { SavedObject } from 'src/core/types'; +import { isEqual } from 'lodash'; +import * as osdTestServer from '../../../../../test_helpers/osd_server'; +import { Readable } from 'stream'; + +const dashboard: Omit = { + type: 'dashboard', + attributes: {}, + references: [], +}; + +describe('repository integration test', () => { + let root: ReturnType; + let opensearchServer: osdTestServer.TestOpenSearchUtils; + beforeAll(async () => { + const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ + adjustTimeout: (t: number) => jest.setTimeout(t), + }); + opensearchServer = await startOpenSearch(); + const startOSDResp = await startOpenSearchDashboards(); + root = startOSDResp.root; + }, 30000); + afterAll(async () => { + await root.shutdown(); + await opensearchServer.stop(); + }); + + const deleteItem = async (object: Pick) => { + expect( + [200, 404].includes( + (await osdTestServer.request.delete(root, `/api/saved_objects/${object.type}/${object.id}`)) + .statusCode + ) + ); + }; + + const getItem = async (object: Pick) => { + return await osdTestServer.request + .get(root, `/api/saved_objects/${object.type}/${object.id}`) + .expect(200); + }; + + const clearFooAndBar = async () => { + await deleteItem({ + type: dashboard.type, + id: 'foo', + }); + await deleteItem({ + type: dashboard.type, + id: 'bar', + }); + }; + + describe('workspace related CRUD', () => { + it('create', async () => { + const createResult = await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + workspaces: ['foo'], + }) + .expect(200); + + expect(createResult.body.workspaces).toEqual(['foo']); + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + + it('create-with-override', async () => { + const createResult = await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}`) + .send({ + attributes: dashboard.attributes, + workspaces: ['foo'], + }) + .expect(200); + + await osdTestServer.request + .post(root, `/api/saved_objects/${dashboard.type}/${createResult.body.id}?overwrite=true`) + .send({ + attributes: dashboard.attributes, + workspaces: ['bar'], + }) + .expect(409); + + await deleteItem({ + type: dashboard.type, + id: createResult.body.id, + }); + }); + + it('bulk create', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + expect((createResultFoo.body.saved_objects as any[]).some((item) => item.error)).toEqual( + false + ); + expect( + (createResultFoo.body.saved_objects as any[]).every((item) => + isEqual(item.workspaces, ['foo']) + ) + ).toEqual(true); + expect((createResultBar.body.saved_objects as any[]).some((item) => item.error)).toEqual( + false + ); + expect( + (createResultBar.body.saved_objects as any[]).every((item) => + isEqual(item.workspaces, ['bar']) + ) + ).toEqual(true); + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('bulk create with conflict', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + /** + * overwrite with workspaces + */ + const overwriteWithWorkspacesResult = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?overwrite=true&workspaces=foo`) + .send([ + { + ...dashboard, + id: 'bar', + }, + { + ...dashboard, + id: 'foo', + attributes: { + title: 'foo', + }, + }, + ]) + .expect(200); + + expect(overwriteWithWorkspacesResult.body.saved_objects[0].error.statusCode).toEqual(409); + expect(overwriteWithWorkspacesResult.body.saved_objects[1].attributes.title).toEqual('foo'); + expect(overwriteWithWorkspacesResult.body.saved_objects[1].workspaces).toEqual(['foo']); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('checkConflicts when importing ndjson', async () => { + await clearFooAndBar(); + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const getResultFoo = await getItem({ + type: dashboard.type, + id: 'foo', + }); + const getResultBar = await getItem({ + type: dashboard.type, + id: 'bar', + }); + + const readableStream = new Readable(); + readableStream.push( + `Content-Disposition: form-data; name="file"; filename="tmp.ndjson"\r\n\r\n` + ); + readableStream.push( + [JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n') + ); + readableStream.push(null); + + /** + * import with workspaces when conflicts + */ + const importWithWorkspacesResult = await osdTestServer.request + .post(root, `/api/saved_objects/_import?workspaces=foo&overwrite=false`) + .attach( + 'file', + Buffer.from( + [JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n'), + 'utf-8' + ), + 'tmp.ndjson' + ) + .expect(200); + + expect(importWithWorkspacesResult.body.success).toEqual(false); + expect(importWithWorkspacesResult.body.errors.length).toEqual(1); + expect(importWithWorkspacesResult.body.errors[0].id).toEqual('foo'); + expect(importWithWorkspacesResult.body.errors[0].error.type).toEqual('conflict'); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + + it('find by workspaces', async () => { + const createResultFoo = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) + .send([ + { + ...dashboard, + id: 'foo', + }, + ]) + .expect(200); + + const createResultBar = await osdTestServer.request + .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) + .send([ + { + ...dashboard, + id: 'bar', + }, + ]) + .expect(200); + + const findResult = await osdTestServer.request + .get(root, `/api/saved_objects/_find?workspaces=bar&type=${dashboard.type}`) + .expect(200); + + expect(findResult.body.total).toEqual(1); + expect(findResult.body.saved_objects[0].workspaces).toEqual(['bar']); + + await Promise.all( + [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => + deleteItem({ + type: item.type, + id: item.id, + }) + ) + ); + }); + }); +}); 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 96883e55320b..82bed74eca55 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -27,7 +27,6 @@ * specific language governing permissions and limitations * under the License. */ - import { SavedObjectsRepository } from './repository'; import * as getSearchDslNS from './search_dsl/search_dsl'; import { SavedObjectsErrorHelpers } from './errors'; @@ -54,6 +53,12 @@ const createGenericNotFoundError = (...args) => const createUnsupportedTypeError = (...args) => SavedObjectsErrorHelpers.createUnsupportedTypeError(...args).output.payload; +const omitWorkspace = (object) => { + const newObject = JSON.parse(JSON.stringify(object)); + delete newObject.workspaces; + return newObject; +}; + describe('SavedObjectsRepository', () => { let client; let savedObjectsRepository; @@ -182,6 +187,7 @@ describe('SavedObjectsRepository', () => { _source: { ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), + workspaces, ...(originId && { originId }), ...(permissions && { permissions }), type, @@ -493,7 +499,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 || options?.workspaces ? 1 : 0 + ); return result; }; @@ -696,6 +704,7 @@ describe('SavedObjectsRepository', () => { expect.anything() ); client.bulk.mockClear(); + client.mget.mockClear(); }; await test(undefined); await test(namespace); @@ -912,6 +921,74 @@ describe('SavedObjectsRepository', () => { const expectedError = expectErrorResult(obj3, { message: JSON.stringify(opensearchError) }); await bulkCreateError(obj3, opensearchError, expectedError); }); + + it(`returns error when there is a conflict with an existing saved object according to workspaces`, async () => { + const obj = { ...obj3, workspaces: ['foo'] }; + const response1 = { + status: 200, + docs: [ + { + found: true, + _id: `${obj1.type}:${obj1.id}`, + _source: { + type: obj1.type, + workspaces: ['bar'], + }, + }, + { + found: true, + _id: `${obj.type}:${obj.id}`, + _source: { + type: obj.type, + workspaces: obj.workspaces, + }, + }, + { + found: true, + _id: `${obj2.type}:${obj2.id}`, + _source: { + type: obj2.type, + }, + }, + ], + }; + client.mget.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(response1) + ); + const response2 = getMockBulkCreateResponse([obj1, obj, obj2]); + client.bulk.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(response2) + ); + + const options = { overwrite: true, workspaces: ['bar'] }; + const result = await savedObjectsRepository.bulkCreate([obj1, obj, obj2], options); + expect(client.bulk).toHaveBeenCalled(); + expect(client.mget).toHaveBeenCalled(); + + 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() + ); + const body2 = [...expectObjArgs(obj1)]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body: body2 }), + expect.anything() + ); + expect(result).toEqual({ + saved_objects: [ + expectSuccess(obj1), + expectErrorConflict(obj, { metadata: { isNotOverwritable: true } }), + expectErrorConflict(obj2, { metadata: { isNotOverwritable: true } }), + ], + }); + }); }); describe('migration', () => { @@ -1791,6 +1868,8 @@ describe('SavedObjectsRepository', () => { const obj5 = { type: MULTI_NAMESPACE_TYPE, id: 'five' }; const obj6 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'six' }; const obj7 = { type: NAMESPACE_AGNOSTIC_TYPE, id: 'seven' }; + const obj8 = { type: 'dashboard', id: 'eight', workspaces: ['foo'] }; + const obj9 = { type: 'dashboard', id: 'nine', workspaces: ['bar'] }; const namespace = 'foo-namespace'; const checkConflicts = async (objects, options) => @@ -1882,6 +1961,8 @@ describe('SavedObjectsRepository', () => { { found: false }, getMockGetResponse(obj6), { found: false }, + getMockGetResponse(obj7), + getMockGetResponse(obj8), ], }; client.mget.mockResolvedValue( @@ -1910,6 +1991,36 @@ describe('SavedObjectsRepository', () => { ], }); }); + + it(`expected results with workspaces`, async () => { + const objects = [obj8, obj9]; + const response = { + status: 200, + docs: [getMockGetResponse(obj8), getMockGetResponse(obj9)], + }; + client.mget.mockResolvedValue( + opensearchClientMock.createSuccessTransportRequestPromise(response) + ); + + const result = await checkConflicts(objects, { + workspaces: ['foo'], + }); + expect(client.mget).toHaveBeenCalledTimes(1); + expect(result).toEqual({ + errors: [ + { ...omitWorkspace(obj8), error: createConflictError(obj8.type, obj8.id) }, + { + ...omitWorkspace(obj9), + error: { + ...createConflictError(obj9.type, obj9.id), + metadata: { + isNotOverwritable: true, + }, + }, + }, + ], + }); + }); }); }); @@ -1946,9 +2057,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 && options.workspaces) { + /** + * 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; }; @@ -2210,6 +2329,21 @@ describe('SavedObjectsRepository', () => { expect(client.get).toHaveBeenCalled(); }); + it(`throws error when there is a conflict with an existing workspaces saved object`, async () => { + const response = getMockGetResponse({ workspaces: ['foo'], id }); + client.get.mockResolvedValueOnce( + opensearchClientMock.createSuccessTransportRequestPromise(response) + ); + await expect( + savedObjectsRepository.create('dashboard', attributes, { + id, + overwrite: true, + workspaces: ['bar'], + }) + ).rejects.toThrowError(createConflictError('dashboard', id)); + expect(client.get).toHaveBeenCalled(); + }); + it.todo(`throws when automatic index creation fails`); it.todo(`throws when an unexpected failure occurs`); @@ -2303,10 +2437,11 @@ describe('SavedObjectsRepository', () => { const type = 'index-pattern'; const id = 'logstash-*'; const namespace = 'foo-namespace'; + const workspaces = ['bar-workspace']; const deleteSuccess = async (type, id, options) => { if (registry.isMultiNamespace(type)) { - const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace); + const mockGetResponse = getMockGetResponse({ type, id }, options?.namespace, workspaces); client.get.mockResolvedValueOnce( opensearchClientMock.createSuccessTransportRequestPromise(mockGetResponse) ); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 15dcf7a6c122..be4a0ba6f29b 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -360,15 +360,28 @@ export class SavedObjectsRepository { const method = object.id && overwrite ? 'index' : 'create'; const requiresNamespacesCheck = object.id && this._registry.isMultiNamespace(object.type); + /** + * It requires a check when overwriting objects to target workspaces + */ + const requiresWorkspaceCheck = !!(object.id && options.workspaces); 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, }, }; }); @@ -379,7 +392,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( @@ -410,7 +423,7 @@ export class SavedObjectsRepository { if (opensearchRequestIndex !== undefined) { const indexFound = bulkGetResponse?.statusCode !== 404; const actualResult = indexFound - ? bulkGetResponse?.body.docs[opensearchRequestIndex] + ? bulkGetResponse?.body.docs?.[opensearchRequestIndex] : undefined; const docFound = indexFound && actualResult?.found === true; // @ts-expect-error MultiGetHit._source is optional @@ -562,7 +575,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( @@ -585,13 +598,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 }, }), }, @@ -926,7 +950,7 @@ export class SavedObjectsRepository { */ async bulkGet( objects: SavedObjectsBulkGetObject[] = [], - options: SavedObjectsBaseOptions = {} + options: Omit = {} ): Promise> { const namespace = normalizeNamespace(options.namespace); @@ -1014,7 +1038,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); @@ -1040,7 +1064,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt, permissions } = body._source; + const { originId, updated_at: updatedAt, permissions, workspaces } = body._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -1056,6 +1080,7 @@ export class SavedObjectsRepository { ...(originId && { originId }), ...(updatedAt && { updated_at: updatedAt }), ...(permissions && { permissions }), + ...(workspaces && { workspaces }), version: encodeHitVersion(body), attributes: body._source[type], references: body._source.references || [], @@ -1121,7 +1146,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 ?? [ @@ -1137,6 +1162,7 @@ export class SavedObjectsRepository { namespaces, ...(originId && { originId }), ...(permissions && { permissions }), + ...(workspaces && { workspaces }), references, attributes, }; @@ -1520,12 +1546,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, 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 b78c5a032992..186457145103 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 @@ -127,6 +127,20 @@ function getClauseForType( }, }; } +/** + * Gets the clause that will filter for the workspace. + */ +function getClauseForWorkspace(workspace: string) { + if (!workspace) { + return {}; + } + + return { + bool: { + must: [{ term: { workspaces: workspace } }], + }, + }; +} /** * Gets the clause that will filter for the workspace. diff --git a/src/core/server/saved_objects/service/lib/utils.ts b/src/core/server/saved_objects/service/lib/utils.ts index 4823e52d77c9..9fc4a6280b63 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[], + sourceWorkspaces?: string[] + ): string[] { + return targetWorkspaces?.filter((item) => !sourceWorkspaces?.includes(item)) || []; + } } 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 05069d9d887a..e1c3d16a9258 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -385,7 +385,7 @@ export class SavedObjectsClient { */ async bulkGet( objects: SavedObjectsBulkGetObject[] = [], - options: SavedObjectsBaseOptions = {} + options: Omit = {} ): Promise> { return await this._repository.bulkGet(objects, options); } From d89987de429eab72624af9b63555f75dd2bfd06a Mon Sep 17 00:00:00 2001 From: tygao Date: Tue, 10 Oct 2023 15:13:58 +0800 Subject: [PATCH 07/34] Register Advance Settings, Data Source management,Index Pattern management and SavedObject management as standalone app, retire dashboard management (#208) * feat: init retire dashboard management Signed-off-by: tygao * move index pattern to Library (#91) * move index pattern to libaray Signed-off-by: Hailong Cui * Remove it from Dashboards management when workspace is on Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui index pattern always show under library Signed-off-by: Hailong Cui * functional test Signed-off-by: Hailong Cui * feat: move data source / advanced settings / saved objects management out of Dashboard management Signed-off-by: SuZhou-Joe * feat: update test Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * fix: fix failed overview header ut Signed-off-by: tygao * fix: deeplink inside saved objects management page Signed-off-by: SuZhou-Joe * fix: unit test fail Signed-off-by: SuZhou-Joe * feat: add unit test for each page wrapper Signed-off-by: SuZhou-Joe * feat: some optimization Signed-off-by: SuZhou-Joe * remove management dependency Signed-off-by: Hailong Cui * test: update cypress config to use workspace branch Signed-off-by: tygao * Replace ManagementAppMountParams with AppMountParameters Signed-off-by: Hailong Cui --------- Signed-off-by: tygao Signed-off-by: Hailong Cui Signed-off-by: SuZhou-Joe Co-authored-by: Hailong Cui Co-authored-by: SuZhou-Joe --- .github/workflows/cypress_workflow.yml | 6 +- .../with-security/check_advanced_settings.js | 2 +- .../with-security/helpers/generate_data.js | 4 +- .../check_advanced_settings.js | 2 +- .../without-security/helpers/generate_data.js | 2 +- .../core_app/errors/url_overflow.test.ts | 2 +- .../public/core_app/errors/url_overflow.tsx | 2 +- .../core_app/errors/url_overflow_ui.tsx | 2 +- .../ui_settings/saved_objects/ui_settings.ts | 2 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 +++ .../components/page_wrapper/index.ts | 6 ++ .../page_wrapper/page_wrapper.test.tsx | 16 ++++ .../components/page_wrapper/page_wrapper.tsx | 21 +++++ .../mount_management_section.tsx | 55 ++++++----- .../advanced_settings/public/plugin.ts | 24 +++-- .../server/saved_objects/dashboard.ts | 4 +- .../index_patterns/index_patterns.ts | 6 +- .../redirect_no_index_pattern.tsx | 8 +- .../public/search/errors/painless_error.tsx | 4 +- .../server/saved_objects/index_patterns.ts | 8 +- .../server/saved_objects/data_source.ts | 4 +- .../opensearch_dashboards.json | 2 +- .../data_source_column/data_source_column.tsx | 6 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 +++ .../public/components/page_wrapper/index.ts | 6 ++ .../page_wrapper/page_wrapper.test.tsx | 16 ++++ .../components/page_wrapper/page_wrapper.tsx | 21 +++++ .../mount_management_section.tsx | 2 +- .../data_source_management/public/plugin.ts | 2 +- .../data_source_management/public/types.ts | 5 + .../open_search_panel.test.tsx.snap | 2 +- .../components/top_nav/open_search_panel.tsx | 2 +- .../discover/server/saved_objects/search.ts | 4 +- .../components/new_theme_modal.tsx | 4 +- .../opensearch_dashboards.json | 2 +- .../mount_management_section.tsx | 73 +++++++++----- .../index_pattern_management/public/plugin.ts | 29 +++--- .../getting_started.test.tsx.snap | 4 +- .../getting_started/getting_started.tsx | 6 +- .../overview_page_footer.tsx | 2 +- .../overview_page_header.test.tsx | 2 +- .../overview_page_header.tsx | 2 +- .../table_list_view/table_list_view.tsx | 2 +- .../saved_objects_management/README.md | 8 +- .../public/constants.ts | 41 ++++++++ .../management_section/mount_section.tsx | 68 ++++++++------ .../saved_objects_table.test.tsx.snap | 10 +- .../__snapshots__/header.test.tsx.snap | 8 +- .../__snapshots__/relationships.test.tsx.snap | 20 ++-- .../__snapshots__/table.test.tsx.snap | 8 +- .../objects_table/components/header.tsx | 9 +- .../components/relationships.test.tsx | 34 +++---- .../objects_table/components/table.test.tsx | 8 +- .../saved_objects_table.test.tsx | 14 +-- .../objects_table/saved_objects_table.tsx | 6 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 +++ .../management_section/page_wrapper/index.ts | 6 ++ .../page_wrapper/page_wrapper.test.tsx | 16 ++++ .../page_wrapper/page_wrapper.tsx | 21 +++++ .../saved_objects_table_page.tsx | 13 +-- .../saved_objects_management/public/plugin.ts | 94 ++++++++++++++----- .../server/saved_objects/augment_vis.ts | 4 +- .../server/saved_objects/vis_builder_app.ts | 3 +- .../server/saved_objects/visualization.ts | 4 +- .../apis/saved_objects_management/find.ts | 21 ++--- .../saved_objects_management/relationships.ts | 48 ++++------ .../dashboard/create_and_add_embeddables.js | 6 +- test/functional/apps/dashboard/time_zones.js | 2 - .../apps/management/_import_objects.js | 2 - .../_index_pattern_create_delete.js | 2 +- .../management/_mgmt_import_saved_objects.js | 1 - .../_opensearch_dashboards_settings.js | 6 +- .../apps/management/_scripted_fields.js | 7 -- .../management/_scripted_fields_filter.js | 1 - .../edit_saved_object.ts | 2 - .../apps/visualize/_custom_branding.ts | 10 +- test/functional/apps/visualize/_lab_mode.js | 6 +- test/functional/apps/visualize/_tag_cloud.js | 2 - test/functional/config.js | 4 - test/functional/page_objects/settings_page.ts | 6 +- 80 files changed, 577 insertions(+), 357 deletions(-) create mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap create mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts create mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx create mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx create mode 100644 src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap create mode 100644 src/plugins/data_source_management/public/components/page_wrapper/index.ts create mode 100644 src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx create mode 100644 src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx create mode 100644 src/plugins/saved_objects_management/public/constants.ts create mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap create mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts create mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx create mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index 17b84f239ec9..4bf8051ee82a 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -3,7 +3,7 @@ name: Run cypress tests # trigger on every PR for all branches on: pull_request: - branches: [ '**' ] + branches: ['**'] paths-ignore: - '**/*.md' workflow_dispatch: @@ -243,7 +243,7 @@ jobs: with: issue-number: ${{ inputs.pr_number }} comment-author: 'github-actions[bot]' - body-includes: "${{ env.COMMENT_TAG }}" + body-includes: '${{ env.COMMENT_TAG }}' - name: Add comment on the PR uses: peter-evans/create-or-update-comment@v3 @@ -264,6 +264,6 @@ jobs: '${{ env.SPEC }}' ``` - #### Link to results: + #### Link to results: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} edit-mode: replace diff --git a/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/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: ( - + +
+ 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..648382771ba8 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,24 @@ 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 { 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 +63,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 +83,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/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 3f31e7bd14b7..0cbd9daf302f 100644 --- a/src/plugins/data_source/server/saved_objects/data_source.ts +++ b/src/plugins/data_source/server/saved_objects/data_source.ts @@ -23,11 +23,11 @@ export const dataSource: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`; + return `/dataSources/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { - path: `/app/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`, + path: `/app/dataSources/${encodeURIComponent(obj.id)}`, uiCapabilitiesPath: 'management.opensearchDashboards.dataSources', }; }, diff --git a/src/plugins/data_source_management/opensearch_dashboards.json b/src/plugins/data_source_management/opensearch_dashboards.json index cfcfdd2ce430..565ccff401dd 100644 --- a/src/plugins/data_source_management/opensearch_dashboards.json +++ b/src/plugins/data_source_management/opensearch_dashboards.json @@ -3,7 +3,7 @@ "version": "opensearchDashboards", "server": false, "ui": true, - "requiredPlugins": ["management", "dataSource", "indexPatternManagement"], + "requiredPlugins": ["dataSource", "indexPatternManagement"], "optionalPlugins": [], "requiredBundles": ["opensearchDashboardsReact", "dataSource"], "extraPublicDirs": ["public/components/utils"] diff --git a/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx b/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx index 640eb1b369fd..cd6fc7c17ae2 100644 --- a/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx +++ b/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx @@ -56,11 +56,7 @@ export class DataSourceColumn implements IndexPatternTableColumn ?.map((dataSource) => { return { ...dataSource, - relativeUrl: basePath.prepend( - `/app/management/opensearch-dashboards/dataSources/${encodeURIComponent( - dataSource.id - )}` - ), + relativeUrl: basePath.prepend(`/app/dataSources/${encodeURIComponent(dataSource.id)}`), }; }) ?.reduce( diff --git a/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap b/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap new file mode 100644 index 000000000000..3c5257e2e8d1 --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageWrapper should render normally 1`] = ` +
+
+ Foo +
+
+`; diff --git a/src/plugins/data_source_management/public/components/page_wrapper/index.ts b/src/plugins/data_source_management/public/components/page_wrapper/index.ts new file mode 100644 index 000000000000..3cf0cdd26c99 --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx new file mode 100644 index 000000000000..550eb3ee1cae --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { PageWrapper } from './page_wrapper'; + +describe('PageWrapper', () => { + it('should render normally', async () => { + const { findByText, container } = render(Foo); + await findByText('Foo'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx new file mode 100644 index 000000000000..1b1949c334e4 --- /dev/null +++ b/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPageContent } from '@elastic/eui'; +import React from 'react'; + +export const PageWrapper = (props: { children?: React.ReactChild }) => { + return ( + + ); +}; diff --git a/src/plugins/data_source_management/public/management_app/mount_management_section.tsx b/src/plugins/data_source_management/public/management_app/mount_management_section.tsx index 6b421a32d2b2..fcbcf0130ec7 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 @@ -70,4 +70,4 @@ export async function mountManagementSection( chrome.docTitle.reset(); ReactDOM.unmountComponentAtNode(params.element); }; -} +} \ No newline at end of file diff --git a/src/plugins/data_source_management/public/plugin.ts b/src/plugins/data_source_management/public/plugin.ts index 12cab715b205..f8a88a6a9ca8 100644 --- a/src/plugins/data_source_management/public/plugin.ts +++ b/src/plugins/data_source_management/public/plugin.ts @@ -116,4 +116,4 @@ export class DataSourceManagementPlugin } public stop() {} -} +} \ No newline at end of file diff --git a/src/plugins/data_source_management/public/types.ts b/src/plugins/data_source_management/public/types.ts index bf0743468fd5..719d7702fcb9 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'; @@ -161,3 +162,7 @@ export interface SigV4Content extends SavedObjectAttributes { region: string; service?: SigV4ServiceName; } + +export interface DataSourceManagementStartDependencies { + data: DataPublicPluginStart; +} diff --git a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap index 1fa9680fa708..45e15f809f63 100644 --- a/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap +++ b/src/plugins/discover/public/application/components/top_nav/__snapshots__/open_search_panel.test.tsx.snap @@ -53,7 +53,7 @@ exports[`render 1`] = ` > = ({ addBasePath, onClose }) => { modes. You or your administrator can change to the previous theme by visiting {advancedSettingsLink}." values={{ advancedSettingsLink: ( - + , - params: ManagementAppMountParams, + params: AppMountParameters, getMlCardState: () => MlCardState, dataSource?: DataSourcePluginSetup ) { @@ -77,6 +85,17 @@ export async function mountManagementSection( chrome.setBadge(readOnlyBadge); } + const setBreadcrumbsScope = (crumbs: ChromeBreadcrumb[] = [], appHistory?: ScopedHistory) => { + const wrapBreadcrumb = (item: ChromeBreadcrumb, scopedHistory: ScopedHistory) => ({ + ...item, + ...(item.href ? reactRouterNavigate(scopedHistory, item.href) : {}), + }); + + chrome.setBreadcrumbs([ + ...crumbs.map((item) => wrapBreadcrumb(item, appHistory || params.history)), + ]); + }; + const deps: IndexPatternManagmentContext = { chrome, application, @@ -88,33 +107,37 @@ export async function mountManagementSection( docLinks, data, indexPatternManagementStart: indexPatternManagementStart as IndexPatternManagementStart, - setBreadcrumbs: params.setBreadcrumbs, + setBreadcrumbs: setBreadcrumbsScope, getMlCardState, dataSourceEnabled, hideLocalCluster, }; ReactDOM.render( - - - - - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + + + + + + + , params.element ); diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 98eaab6160ee..0b9443a735a1 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -29,7 +29,13 @@ */ import { i18n } from '@osd/i18n'; -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { + PluginInitializerContext, + CoreSetup, + CoreStart, + Plugin, + AppMountParameters, +} from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { DataSourcePluginSetup, DataSourcePluginStart } from 'src/plugins/data_source/public'; import { UrlForwardingSetup } from '../../url_forwarding/public'; @@ -39,10 +45,9 @@ import { IndexPatternManagementServiceStart, } from './service'; -import { ManagementSetup } from '../../management/public'; +import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; export interface IndexPatternManagementSetupDependencies { - management: ManagementSetup; urlForwarding: UrlForwardingSetup; dataSource?: DataSourcePluginSetup; } @@ -78,15 +83,9 @@ export class IndexPatternManagementPlugin core: CoreSetup, dependencies: IndexPatternManagementSetupDependencies ) { - const { urlForwarding, management, dataSource } = dependencies; - - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - - if (!opensearchDashboardsSection) { - throw new Error('`opensearchDashboards` management section not found.'); - } + const { urlForwarding, dataSource } = dependencies; - const newAppPath = `management/opensearch-dashboards/${IPM_APP_ID}`; + const newAppPath = IPM_APP_ID; const legacyPatternsPath = 'management/opensearch-dashboards/index_patterns'; urlForwarding.forwardApp( @@ -99,11 +98,13 @@ 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( 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: ( - + ; serviceRegistry: ISavedObjectsManagementServiceRegistry; - mountParams: ManagementAppMountParams; dataSourceEnabled: boolean; hideLocalCluster: boolean; + 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, dataSourceEnabled, hideLocalCluster, + 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); } @@ -90,31 +91,36 @@ export const mountManagementSection = async ({ }> - + + + }> - + + + diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index 1183c4cccd68..a76c1bfe32ab 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 @@ -316,10 +316,10 @@ exports[`SavedObjectsTable should render normally 1`] = ` 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*", @@ -329,7 +329,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "2", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedSearches/2", + "editUrl": "/objects/savedSearches/2", "icon": "search", "inAppUrl": Object { "path": "/discover/2", @@ -342,7 +342,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "3", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedDashboards/3", + "editUrl": "/objects/savedDashboards/3", "icon": "dashboardApp", "inAppUrl": Object { "path": "/dashboard/3", @@ -355,7 +355,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "4", "meta": Object { - "editUrl": "/management/opensearch-dashboards/objects/savedVisualizations/4", + "editUrl": "/objects/savedVisualizations/4", "icon": "visualizeApp", "inAppUrl": Object { "path": "/edit/4", 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..dace178024f2 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 @@ -10,13 +10,7 @@ exports[`Header should render normally 1`] = ` grow={false} > -

- -

+

void; onImport: () => void; onRefresh: () => void; filteredCount: number; + title: string; }) => ( -

- -

+

{title}

diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx index 1f21e5990c74..5afdbacf6dff 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx @@ -53,7 +53,7 @@ describe('Relationships', () => { 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/table.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/table.test.tsx index 7e5bb318f4d0..c8e378b93b92 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 @@ -51,9 +51,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 +91,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', }, }, 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..443026e92964 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 @@ -172,9 +172,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 +185,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 +198,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 +211,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', @@ -460,7 +460,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 +475,7 @@ describe('SavedObjectsTable', () => { type: 'search', meta: { title: 'MySearch', - editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', + editUrl: '/objects/savedSearches/2', icon: 'search', inAppUrl: { path: '/discover/2', diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 955482cc0676..edc538716ee2 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 @@ -116,6 +116,7 @@ export interface SavedObjectsTableProps { dateFormat: string; dataSourceEnabled: boolean; hideLocalCluster: boolean; + title: string; } export interface SavedObjectsTableState { @@ -545,9 +546,7 @@ export class SavedObjectsTable extends Component diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap new file mode 100644 index 000000000000..3c5257e2e8d1 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap @@ -0,0 +1,13 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PageWrapper should render normally 1`] = ` +
+
+ Foo +
+
+`; diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts b/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts new file mode 100644 index 000000000000..3cf0cdd26c99 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { PageWrapper } from './page_wrapper'; diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx new file mode 100644 index 000000000000..550eb3ee1cae --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { render } from '@testing-library/react'; +import { PageWrapper } from './page_wrapper'; + +describe('PageWrapper', () => { + it('should render normally', async () => { + const { findByText, container } = render(Foo); + await findByText('Foo'); + expect(container).toMatchSnapshot(); + }); +}); diff --git a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx new file mode 100644 index 000000000000..1b1949c334e4 --- /dev/null +++ b/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx @@ -0,0 +1,21 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiPageContent } from '@elastic/eui'; +import React from 'react'; + +export const PageWrapper = (props: { children?: React.ReactChild }) => { + return ( + + ); +}; diff --git a/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx b/src/plugins/saved_objects_management/public/management_section/saved_objects_table_page.tsx index b3ef976d8283..73b4dfaa638a 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'; @@ -51,6 +51,7 @@ const SavedObjectsTablePage = ({ setBreadcrumbs, dataSourceEnabled, hideLocalCluster, + title, }: { coreStart: CoreStart; dataStart: DataPublicPluginStart; @@ -62,6 +63,7 @@ const SavedObjectsTablePage = ({ setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; dataSourceEnabled: boolean; hideLocalCluster: boolean; + title: string; }) => { const capabilities = coreStart.application.capabilities; const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50); @@ -70,13 +72,11 @@ const SavedObjectsTablePage = ({ useEffect(() => { setBreadcrumbs([ { - text: i18n.translate('savedObjectsManagement.breadcrumb.index', { - defaultMessage: 'Saved objects', - }), - href: '/', + text: title, + href: undefined, }, ]); - }, [setBreadcrumbs]); + }, [setBreadcrumbs, title]); return ( ); }; diff --git a/src/plugins/saved_objects_management/public/plugin.ts b/src/plugins/saved_objects_management/public/plugin.ts index 035bf096a1af..66c199b5f5a4 100644 --- a/src/plugins/saved_objects_management/public/plugin.ts +++ b/src/plugins/saved_objects_management/public/plugin.ts @@ -29,7 +29,7 @@ */ import { i18n } from '@osd/i18n'; -import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { VisBuilderStart } from '../../vis_builder/public'; @@ -60,6 +60,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; @@ -105,9 +111,70 @@ export class SavedObjectsManagementPlugin private namespaceService = new SavedObjectsManagementNamespaceService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); + private registerLibrarySubApp( + coreSetup: CoreSetup, + dataSourceEnabled: boolean, + hideLocalCluster: boolean + ) { + const core = coreSetup; + const mountWrapper = ({ + title, + allowedObjectTypes, + }: { + title: string; + allowedObjectTypes?: string[]; + }) => async (appMountParams: AppMountParameters) => { + const { mountManagementSection } = await import('./management_section'); + return mountManagementSection({ + core, + serviceRegistry: this.serviceRegistry, + appMountParams, + title, + allowedObjectTypes, + dataSourceEnabled, + hideLocalCluster, + }); + }; + + /** + * Register saved objects overview & saved search & saved query here + */ + core.application.register({ + id: 'objects', + title: MANAGE_LIBRARY_TITLE_WORDINGS, + order: 10000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: MANAGE_LIBRARY_TITLE_WORDINGS, + }), + }); + + core.application.register({ + id: 'objects_searches', + title: SAVED_SEARCHES_WORDINGS, + order: 8000, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: SAVED_SEARCHES_WORDINGS, + allowedObjectTypes: ['search'], + }), + }); + + core.application.register({ + id: 'objects_query', + title: SAVED_QUERIES_WORDINGS, + order: 8001, + category: DEFAULT_APP_CATEGORIES.opensearchDashboards, + mount: mountWrapper({ + title: SAVED_QUERIES_WORDINGS, + allowedObjectTypes: ['query'], + }), + }); + } + public setup( core: CoreSetup, - { home, management, uiActions, dataSource }: SetupDependencies + { home, uiActions, dataSource }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); const columnSetup = this.columnService.setup(); @@ -124,37 +191,20 @@ export class SavedObjectsManagementPlugin 'Import, export, and manage your saved searches, visualizations, and dashboards.', }), icon: 'savedObjectsApp', - path: '/app/management/opensearch-dashboards/objects', + path: '/app/objects', showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); } - const opensearchDashboardsSection = management.sections.section.opensearchDashboards; - opensearchDashboardsSection.registerApp({ - id: 'objects', - title: i18n.translate('savedObjectsManagement.managementSectionLabel', { - defaultMessage: 'Saved objects', - }), - order: 1, - mount: async (mountParams) => { - const { mountManagementSection } = await import('./management_section'); - return mountManagementSection({ - core, - serviceRegistry: this.serviceRegistry, - mountParams, - dataSourceEnabled: !!dataSource, - hideLocalCluster: dataSource?.hideLocalCluster ?? false, - }); - }, - }); - // sets up the context mappings and registers any triggers/actions for the plugin bootstrap(uiActions); // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); + this.registerLibrarySubApp(core, !!dataSource, dataSource?.hideLocalCluster ?? false); + return { actions: actionSetup, columns: columnSetup, diff --git a/src/plugins/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/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index a82d4e792cdc..065541a36d77 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -73,8 +73,7 @@ export default function ({ getService }: FtrProviderContext) { score: 0, updated_at: '2017-09-21T18:51:23.794Z', meta: { - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', + editUrl: '/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/dd7caf20-9efd-11e7-acb3-3dab96693fab', @@ -237,8 +236,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -256,8 +254,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'dashboardApp', title: 'Dashboard', - editUrl: - '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -275,8 +272,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -286,8 +282,7 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -305,11 +300,9 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: - '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: - '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', diff --git a/test/api_integration/apis/saved_objects_management/relationships.ts b/test/api_integration/apis/saved_objects_management/relationships.ts index f0af2d8d9e79..77e838cfed42 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -94,11 +94,9 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'saved_objects*', icon: 'indexPatternApp', - editUrl: - '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: - '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -111,8 +109,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'VisualizationFromSavedSearch', icon: 'visualizeApp', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -137,11 +134,9 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: - '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: - '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -154,8 +149,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -199,8 +193,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -215,8 +208,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -239,8 +231,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -255,8 +246,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -300,8 +290,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -316,8 +305,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'dashboardApp', title: 'Dashboard', - editUrl: - '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -342,8 +330,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -386,8 +373,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -402,8 +388,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: - '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -428,8 +413,7 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: - '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 3b6e8a243556..6701ae0fc94c 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -112,8 +112,7 @@ export default function ({ getService, getPageObjects }) { describe('is false', () => { before(async () => { - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.common.navigateToApp('settings'); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); }); @@ -127,8 +126,7 @@ export default function ({ getService, getPageObjects }) { }); after(async () => { - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); await PageObjects.header.clickDashboard(); }); diff --git a/test/functional/apps/dashboard/time_zones.js b/test/functional/apps/dashboard/time_zones.js index 4c82cfe8006c..225e0bf1d034 100644 --- a/test/functional/apps/dashboard/time_zones.js +++ b/test/functional/apps/dashboard/time_zones.js @@ -51,7 +51,6 @@ export default function ({ getService, getPageObjects }) { await opensearchDashboardsServer.uiSettings.replace({ defaultIndex: '0bf35f60-3dc9-11e8-8660-4d65aa086b3c', }); - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); await PageObjects.savedObjects.importFile( path.join(__dirname, 'exports', 'timezonetest_6_2_4.json'), @@ -77,7 +76,6 @@ export default function ({ getService, getPageObjects }) { it('Changing timezone changes dashboard timestamp and shows the same data', async () => { await PageObjects.settings.navigateTo(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.setAdvancedSettingsSelect('dateFormat:tz', 'Etc/GMT+5'); await PageObjects.common.navigateToApp('dashboard'); await PageObjects.dashboard.loadSavedDashboard('time zone test'); diff --git a/test/functional/apps/management/_import_objects.js b/test/functional/apps/management/_import_objects.js index a4a919aedcd9..f481960b2f77 100644 --- a/test/functional/apps/management/_import_objects.js +++ b/test/functional/apps/management/_import_objects.js @@ -46,7 +46,6 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await opensearchArchiver.load('management'); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); }); @@ -215,7 +214,6 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await opensearchArchiver.load('saved_objects_imports'); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); }); diff --git a/test/functional/apps/management/_index_pattern_create_delete.js b/test/functional/apps/management/_index_pattern_create_delete.js index b7214590ebd4..1d154718c26d 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.js @@ -129,7 +129,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/_mgmt_import_saved_objects.js b/test/functional/apps/management/_mgmt_import_saved_objects.js index c5f852bae5c0..c04fa88b0dec 100644 --- a/test/functional/apps/management/_mgmt_import_saved_objects.js +++ b/test/functional/apps/management/_mgmt_import_saved_objects.js @@ -42,7 +42,6 @@ export default function ({ getService, getPageObjects }) { beforeEach(async function () { await opensearchArchiver.load('empty_opensearch_dashboards'); await opensearchArchiver.load('discover'); - await PageObjects.settings.navigateTo(); }); afterEach(async function () { diff --git a/test/functional/apps/management/_opensearch_dashboards_settings.js b/test/functional/apps/management/_opensearch_dashboards_settings.js index 0e310953e8a2..637f7073d517 100644 --- a/test/functional/apps/management/_opensearch_dashboards_settings.js +++ b/test/functional/apps/management/_opensearch_dashboards_settings.js @@ -39,12 +39,10 @@ export default function ({ getService, getPageObjects }) { before(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); - await PageObjects.settings.navigateTo(); await PageObjects.settings.createIndexPattern('logstash-*'); }); after(async function afterAll() { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); @@ -90,7 +88,6 @@ export default function ({ getService, getPageObjects }) { }); it('setting to true change is preserved', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage'); const storeInSessionStorage = await PageObjects.settings.getAdvancedSettingCheckbox( @@ -113,8 +110,7 @@ export default function ({ getService, getPageObjects }) { it("changing 'state:storeInSessionStorage' also takes effect without full page reload", async () => { await PageObjects.dashboard.preserveCrossAppState(); - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('state:storeInSessionStorage'); await PageObjects.header.clickDashboard(); const [globalState, appState] = await getStateFromUrl(); diff --git a/test/functional/apps/management/_scripted_fields.js b/test/functional/apps/management/_scripted_fields.js index 8a4659630ee1..3ef74f39cfb9 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -75,13 +75,11 @@ export default function ({ getService, getPageObjects }) { }); after(async function afterAll() { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); it('should not allow saving of invalid scripts', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); @@ -99,7 +97,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_Pain_reg'; it('should create and edit scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -133,7 +130,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_Pain1'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -255,7 +251,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painString'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -352,7 +347,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painBool'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); @@ -452,7 +446,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName2 = 'painDate'; it('should create scripted field', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); const startingCount = parseInt(await PageObjects.settings.getScriptedFieldsTabCount()); diff --git a/test/functional/apps/management/_scripted_fields_filter.js b/test/functional/apps/management/_scripted_fields_filter.js index b1714c425aac..55ec8895608c 100644 --- a/test/functional/apps/management/_scripted_fields_filter.js +++ b/test/functional/apps/management/_scripted_fields_filter.js @@ -58,7 +58,6 @@ export default function ({ getService, getPageObjects }) { const scriptedPainlessFieldName = 'ram_pain1'; it('should filter scripted fields', async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.clickScriptedFieldsTab(); diff --git a/test/functional/apps/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts index 1534c710179b..64fe2bf199b0 100644 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -88,7 +88,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }); it('allows to update the saved object when submitting', async () => { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); let objects = await PageObjects.savedObjects.getRowTitles(); @@ -154,7 +153,6 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { }, ]; - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsSavedObjects(); const objects = await PageObjects.savedObjects.getRowTitles(); diff --git a/test/functional/apps/visualize/_custom_branding.ts b/test/functional/apps/visualize/_custom_branding.ts index 37f07e932ee5..52cbc8e5fec9 100644 --- a/test/functional/apps/visualize/_custom_branding.ts +++ b/test/functional/apps/visualize/_custom_branding.ts @@ -46,7 +46,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized logo for opensearch overview header in dark mode', async () => { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('opensearch_dashboards_overview'); await testSubjects.existOrFail('osdOverviewPageHeaderLogo'); @@ -100,7 +100,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized logo in dark mode', async () => { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('home'); await testSubjects.existOrFail('welcomeCustomLogo'); @@ -179,13 +179,13 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { describe('in dark mode', async () => { before(async function () { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('home'); }); after(async function () { - await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clearAdvancedSettings('theme:darkMode'); }); @@ -206,7 +206,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) { }); it('with customized mark logo button that navigates to home page', async () => { - await PageObjects.common.navigateToApp('settings'); + await PageObjects.settings.navigateTo(); await globalNav.clickHomeButton(); await PageObjects.header.waitUntilLoadingHasFinished(); const url = await browser.getCurrentUrl(); diff --git a/test/functional/apps/visualize/_lab_mode.js b/test/functional/apps/visualize/_lab_mode.js index 82ecbcb2a655..1ba36b4b9f90 100644 --- a/test/functional/apps/visualize/_lab_mode.js +++ b/test/functional/apps/visualize/_lab_mode.js @@ -47,8 +47,7 @@ export default function ({ getService, getPageObjects }) { log.info('found saved search before toggling enableLabs mode'); // Navigate to advanced setting and disable lab mode - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); // Expect the discover still to list that saved visualization in the open list @@ -61,8 +60,7 @@ export default function ({ getService, getPageObjects }) { after(async () => { await PageObjects.discover.closeLoadSaveSearchPanel(); - await PageObjects.header.clickStackManagement(); - await PageObjects.settings.clickOpenSearchDashboardsSettings(); + await PageObjects.settings.navigateTo(); await PageObjects.settings.clearAdvancedSettings(VISUALIZE_ENABLE_LABS_SETTING); }); }); diff --git a/test/functional/apps/visualize/_tag_cloud.js b/test/functional/apps/visualize/_tag_cloud.js index a5123434115d..075e7fa22907 100644 --- a/test/functional/apps/visualize/_tag_cloud.js +++ b/test/functional/apps/visualize/_tag_cloud.js @@ -160,7 +160,6 @@ export default function ({ getService, getPageObjects }) { describe('formatted field', function () { before(async function () { - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.filterField(termsField); @@ -178,7 +177,6 @@ export default function ({ getService, getPageObjects }) { after(async function () { await filterBar.removeFilter(termsField); - await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.clickIndexPatternLogstash(); await PageObjects.settings.filterField(termsField); diff --git a/test/functional/config.js b/test/functional/config.js index 87d4302b2a15..ac9ac6085d2b 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -101,10 +101,6 @@ export default async function ({ readConfigFile }) { management: { pathname: '/app/management', }, - /** @obsolete "management" should be instead of "settings" **/ - settings: { - pathname: '/app/management', - }, console: { pathname: '/app/dev_tools', hash: '/console', diff --git a/test/functional/page_objects/settings_page.ts b/test/functional/page_objects/settings_page.ts index af2bf046e3a9..1e0106229d3d 100644 --- a/test/functional/page_objects/settings_page.ts +++ b/test/functional/page_objects/settings_page.ts @@ -51,19 +51,19 @@ export function SettingsPageProvider({ getService, getPageObjects }: FtrProvider await find.clickByDisplayedLinkText(text); } async clickOpenSearchDashboardsSettings() { - await testSubjects.click('settings'); + await PageObjects.common.navigateToApp('settings'); await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('managementSettingsTitle'); } async clickOpenSearchDashboardsSavedObjects() { - await testSubjects.click('objects'); + await PageObjects.common.navigateToApp('objects'); await PageObjects.savedObjects.waitTableIsLoaded(); } async clickOpenSearchDashboardsIndexPatterns() { log.debug('clickOpenSearchDashboardsIndexPatterns link'); - await testSubjects.click('indexPatterns'); + await PageObjects.common.navigateToApp('indexPatterns'); await PageObjects.header.waitUntilLoadingHasFinished(); } From d046d5265efb412f25fe8f277678ef03e76388fb Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 12 Oct 2023 10:35:08 +0800 Subject: [PATCH 08/34] feat: add unit test for mountWrapper (#223) Signed-off-by: SuZhou-Joe --- .../public/plugin.test.ts | 63 +++++++++++++++++-- 1 file changed, 58 insertions(+), 5 deletions(-) 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, + } + ); }); }); }); From b26aef0c9b1435073962159638cf8b54f010c102 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Thu, 12 Oct 2023 17:45:28 +0800 Subject: [PATCH 09/34] [Workspace]Add workspace id in basePath (#212) * feat: enable workspace id in basePath Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: remove useless test object id Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * feat: move formatUrlWithWorkspaceId to core/public/utils Signed-off-by: SuZhou-Joe * feat: remove useless variable Signed-off-by: SuZhou-Joe * feat: remove useless variable Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: move workspace/utils to core Signed-off-by: SuZhou-Joe * feat: move workspace/utils to core Signed-off-by: SuZhou-Joe * feat: update comment Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: update unit test Signed-off-by: SuZhou-Joe * feat: optimization Signed-off-by: SuZhou-Joe * feat: add space under license Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .../collapsible_nav.test.tsx.snap | 8 + .../header/__snapshots__/header.test.tsx.snap | 4 + src/core/public/http/base_path.test.ts | 2 +- .../dashboard_listing.test.tsx.snap | 5 + .../dashboard_top_nav.test.tsx.snap | 6 + .../dashboard_empty_screen.test.tsx.snap | 3 + .../saved_objects_table.test.tsx.snap | 1 + .../__snapshots__/flyout.test.tsx.snap | 1 + ...telemetry_management_section.test.tsx.snap | 1 + src/plugins/workspace/common/constants.ts | 2 + .../workspace/opensearch_dashboards.json | 2 +- src/plugins/workspace/public/application.tsx | 26 +++ .../workspace_fatal_error.test.tsx.snap | 180 ++++++++++++++++++ .../components/workspace_fatal_error/index.ts | 6 + .../workspace_fatal_error.test.tsx | 71 +++++++ .../workspace_fatal_error.tsx | 68 +++++++ src/plugins/workspace/public/plugin.test.ts | 89 ++++++++- src/plugins/workspace/public/plugin.ts | 65 ++++++- src/plugins/workspace/public/types.ts | 9 + .../workspace/public/workspace_client.ts | 2 +- 20 files changed, 535 insertions(+), 16 deletions(-) create mode 100644 src/plugins/workspace/public/application.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_fatal_error/workspace_fatal_error.tsx create mode 100644 src/plugins/workspace/public/types.ts diff --git a/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 62f00bee2c74..d6094f78e24b 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 @@ -60,6 +60,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2013,6 +2014,7 @@ exports[`CollapsibleNav renders the default nav 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2317,6 +2319,7 @@ exports[`CollapsibleNav renders the default nav 2`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -2622,6 +2625,7 @@ exports[`CollapsibleNav renders the default nav 3`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -3218,6 +3222,7 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -4335,6 +4340,7 @@ exports[`CollapsibleNav with custom branding renders the nav bar in default mode "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -5451,6 +5457,7 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -6560,6 +6567,7 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 790f24bc20e9..3d3e5a440c27 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -248,6 +248,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={Object {}} @@ -5953,6 +5954,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } closeNav={[Function]} @@ -7016,6 +7018,7 @@ exports[`Header renders condensed header 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } branding={ @@ -11492,6 +11495,7 @@ exports[`Header renders condensed header 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", + "workspaceBasePath": "", } } closeNav={[Function]} diff --git a/src/core/public/http/base_path.test.ts b/src/core/public/http/base_path.test.ts index 921ec13e6db2..641a04e4a60e 100644 --- a/src/core/public/http/base_path.test.ts +++ b/src/core/public/http/base_path.test.ts @@ -157,4 +157,4 @@ describe('BasePath', () => { ).toEqual('/client_base_path/remove'); }); }); -}); +}); \ No newline at end of file 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 5e6645f56d9c..b8b555bd288b 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 @@ -862,6 +862,7 @@ exports[`dashboard listing hideWriteControls 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2004,6 +2005,7 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3207,6 +3209,7 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4410,6 +4413,7 @@ exports[`dashboard listing renders table rows 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5613,6 +5617,7 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 262deaaaeaf0..ace497ea397e 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 @@ -754,6 +754,7 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1721,6 +1722,7 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2688,6 +2690,7 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3655,6 +3658,7 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4622,6 +4626,7 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5589,6 +5594,7 @@ exports[`Dashboard top nav render with all components 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 c2c83ff6f356..8c8043ae7a99 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 @@ -17,6 +17,7 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -386,6 +387,7 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -765,6 +767,7 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 a76c1bfe32ab..8d04d1182d13 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 @@ -266,6 +266,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", } } canDelete={false} 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 d4a33e4a0569..7b6604c1657e 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 @@ -174,6 +174,7 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 2761ce16fea3..7ad1fb8cd938 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 @@ -319,6 +319,7 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "prepend": [Function], "remove": [Function], "serverBasePath": "", + "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index e60bb6aea0eb..6ae89c0edad5 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; +export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index f34106ab4fed..4443b7e99834 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -7,5 +7,5 @@ "savedObjects" ], "optionalPlugins": [], - "requiredBundles": [] + "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx new file mode 100644 index 000000000000..a6f496304889 --- /dev/null +++ b/src/plugins/workspace/public/application.tsx @@ -0,0 +1,26 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import ReactDOM from 'react-dom'; +import { AppMountParameters, ScopedHistory } from '../../../core/public'; +import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; +import { WorkspaceFatalError } from './components/workspace_fatal_error'; +import { Services } from './types'; + +export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { + const { element } = params; + const history = params.history as ScopedHistory<{ error?: string }>; + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap b/src/plugins/workspace/public/components/workspace_fatal_error/__snapshots__/workspace_fatal_error.test.tsx.snap new file mode 100644 index 000000000000..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/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index e54a20552329..f1b68a1f8fb9 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,24 +3,25 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { waitFor } from '@testing-library/dom'; import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; -import { chromeServiceMock, coreMock } from '../../../core/public/mocks'; +import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; import { WorkspacePlugin } from './plugin'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { Observable, Subscriber } from 'rxjs'; describe('Workspace plugin', () => { - const getSetupMock = () => ({ - ...coreMock.createSetup(), - chrome: chromeServiceMock.createSetupContract(), - }); beforeEach(() => { WorkspaceClientMock.mockClear(); Object.values(workspaceClientMock).forEach((item) => item.mockClear()); }); it('#setup', async () => { - const setupMock = getSetupMock(); + const setupMock = coreMock.createSetup(); const workspacePlugin = new WorkspacePlugin(); await workspacePlugin.setup(setupMock); + expect(setupMock.application.register).toBeCalledTimes(1); expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); }); it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { @@ -30,6 +31,7 @@ describe('Workspace plugin', () => { coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); }); + }); it('#setup when workspace id is in url and enterWorkspace return error', async () => { const windowSpy = jest.spyOn(window, 'window', 'get'); @@ -41,11 +43,82 @@ describe('Workspace plugin', () => { }, } as any) ); - const setupMock = getSetupMock(); + 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); + expect(setupMock.application.register).toBeCalledTimes(1); + expect(WorkspaceClientMock).toBeCalledTimes(1); + expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); + expect(setupMock.getStartServices).toBeCalledTimes(1); + await waitFor( + () => { + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: 'error', + }, + }); + }, + { + container: document.body, + } + ); + windowSpy.mockRestore(); + }); + + it('#setup when workspace id is in url and enterWorkspace return success', async () => { + const windowSpy = jest.spyOn(window, 'window', 'get'); + windowSpy.mockImplementation( + () => + ({ + location: { + href: 'http://localhost/w/workspaceId/app', + }, + } as any) + ); + workspaceClientMock.enterWorkspace.mockResolvedValue({ + success: true, + error: 'error', + }); + const setupMock = 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); - expect(setupMock.workspaces.currentWorkspaceId$.getValue()).toEqual('workspaceId'); + currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 8a69d597c84b..346e243ce1bf 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,9 +4,13 @@ */ import type { Subscription } from 'rxjs'; -import { Plugin, CoreStart, CoreSetup } from '../../../core/public'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { WorkspaceClient } from './workspace_client'; +import { AppMountParameters, AppNavLinkStatus, CoreSetup, Plugin, CoreStart } from '../../../core/public'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { Services } from './types'; + +type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; @@ -20,8 +24,8 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }); } } - private getWorkspaceIdFromURL(basePath?: string): string | null { - return getWorkspaceIdFromUrl(window.location.href, basePath); + private getWorkspaceIdFromURL(): string | null { + return getWorkspaceIdFromUrl(window.location.href); } public async setup(core: CoreSetup) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); @@ -30,12 +34,63 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { /** * Retrieve workspace id from url */ - const workspaceId = this.getWorkspaceIdFromURL(core.http.basePath.getBasePath()); + const workspaceId = this.getWorkspaceIdFromURL(); if (workspaceId) { - core.workspaces.currentWorkspaceId$.next(workspaceId); + const result = await workspaceClient.enterWorkspace(workspaceId); + if (!result.success) { + /** + * Fatal error service does not support customized actions + * So we have to use a self-hosted page to show the errors and redirect. + */ + (async () => { + const [{ application, chrome }] = await core.getStartServices(); + chrome.setIsVisible(false); + application.navigateToApp(WORKSPACE_FATAL_ERROR_APP_ID, { + replace: true, + state: { + error: result.error, + }, + }); + })(); + } else { + /** + * If the workspace id is valid and user is currently on workspace_fatal_error page, + * we should redirect user to overview page of workspace. + */ + (async () => { + const [{ application }] = await core.getStartServices(); + const currentAppIdSubscription = application.currentAppId$.subscribe((currentAppId) => { + if (currentAppId === WORKSPACE_FATAL_ERROR_APP_ID) { + application.navigateToApp(WORKSPACE_OVERVIEW_APP_ID); + } + currentAppIdSubscription.unsubscribe(); + }); + })(); + } } + const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { + const [coreStart] = await core.getStartServices(); + const services = { + ...coreStart, + workspaceClient, + }; + + return renderApp(params, services); + }; + + // workspace fatal error + core.application.register({ + id: WORKSPACE_FATAL_ERROR_APP_ID, + title: '', + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderFatalErrorApp } = await import('./application'); + return mountWorkspaceApp(params, renderFatalErrorApp); + }, + }); + return {}; } diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts new file mode 100644 index 000000000000..1b3f38e50857 --- /dev/null +++ b/src/plugins/workspace/public/types.ts @@ -0,0 +1,9 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { CoreStart } from '../../../core/public'; +import { WorkspaceClient } from './workspace_client'; + +export type Services = CoreStart & { workspaceClient: WorkspaceClient }; diff --git a/src/plugins/workspace/public/workspace_client.ts b/src/plugins/workspace/public/workspace_client.ts index 3e988f38b265..dc07a83ab1bd 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -291,4 +291,4 @@ export class WorkspaceClient { this.workspaces.workspaceList$.unsubscribe(); this.workspaces.currentWorkspaceId$.unsubscribe(); } -} +} \ No newline at end of file From 722fc2898bd90849814b06bb7c789007f2f9d5a7 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Fri, 13 Oct 2023 09:55:19 +0800 Subject: [PATCH 10/34] [API] Delete saved objects by workspace (#216) * Delete saved objects by workspace Signed-off-by: Hailong Cui fix osd boostrap Signed-off-by: Hailong Cui * add unit test Signed-off-by: Hailong Cui * fix can't delete workspace due to invalid permission Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui --- src/core/server/saved_objects/service/lib/repository.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index be4a0ba6f29b..614223ba8c41 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -740,9 +740,9 @@ export class SavedObjectsRepository { } /** - * Deletes all objects from the provided workspace. + * Deletes all objects from the provided workspace. It used when deleting a workspace. * - * @param {string} workspace - workspace id + * @param {string} workspace * @param options SavedObjectsDeleteByWorkspaceOptions * @returns {promise} - { took, timed_out, total, deleted, batches, version_conflicts, noops, retries, failures } */ From 132447b1a855399a86f0dfb274330858fa55525a Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Fri, 13 Oct 2023 11:07:57 +0800 Subject: [PATCH 11/34] [Workspace][Feature] Import sample data to workspace (#210) * feat: import sample data saved objects to workspace Signed-off-by: Lin Wang * refactor: simplify sample data saved object id prefix logic (#1) * refactor: simplify sample data saved object id prefix logic Signed-off-by: Yulong Ruan * fix: align the prefix order of sample data install and uninstall rename appendPrefix to addPrefix Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan * refactor: assigned copied saved objects to new variables Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang Signed-off-by: Yulong Ruan Co-authored-by: Yulong Ruan --- .../components/sample_data_set_cards.js | 19 ++- .../opensearch_dashboards_services.ts | 2 + .../public/application/sample_data_client.js | 23 +++- src/plugins/home/public/plugin.ts | 1 + .../sample_data/data_sets/ecommerce/index.ts | 8 +- .../sample_data/data_sets/flights/index.ts | 8 +- .../sample_data/data_sets/logs/index.ts | 8 +- .../services/sample_data/data_sets/util.ts | 124 +++++++++++------- .../lib/sample_dataset_registry_types.ts | 6 +- .../sample_data/routes/install.test.ts | 63 +++++++++ .../services/sample_data/routes/install.ts | 22 +++- .../services/sample_data/routes/list.test.ts | 105 +++++++++++++++ .../services/sample_data/routes/list.ts | 8 +- .../sample_data/routes/uninstall.test.ts | 31 +++++ .../services/sample_data/routes/uninstall.ts | 16 ++- 15 files changed, 356 insertions(+), 88 deletions(-) diff --git a/src/plugins/home/public/application/components/sample_data_set_cards.js b/src/plugins/home/public/application/components/sample_data_set_cards.js index 5d1e2b86f149..a98e281083b7 100644 --- a/src/plugins/home/public/application/components/sample_data_set_cards.js +++ b/src/plugins/home/public/application/components/sample_data_set_cards.js @@ -81,7 +81,10 @@ export class SampleDataSetCards extends React.Component { loadSampleDataSets = async (dataSourceId) => { 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 727cb03c10ab..697ae3ebc092 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'; @@ -75,6 +76,7 @@ export interface HomeOpenSearchDashboardsServices { }; dataSource?: DataSourcePluginStart; sectionTypes: SectionTypeService; + 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 75b39dadd8c0..728ddcd78106 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -128,6 +128,7 @@ export class HomePublicPlugin injectedMetadata: coreStart.injectedMetadata, dataSource, sectionTypes: this.sectionTypeService, + 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) From 63e8e9d120573f4d931ca35ae6670ba1ee299a88 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 17 Oct 2023 00:04:34 +0800 Subject: [PATCH 12/34] Patch/acl (#231) * consume permissions in repository Signed-off-by: SuZhou-Joe * feat: consume permissions in serializer Signed-off-by: SuZhou-Joe * Add unit tests for consuming permissions in repository Signed-off-by: gaobinlong * feat: update Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Signed-off-by: gaobinlong Co-authored-by: gaobinlong --- .../service/lib/repository.test.js | 30 +++++++++++++++++++ .../saved_objects/service/lib/repository.ts | 4 ++- .../service/saved_objects_client.ts | 4 --- 3 files changed, 33 insertions(+), 5 deletions(-) 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 82bed74eca55..5189c754a326 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -460,6 +460,14 @@ describe('SavedObjectsRepository', () => { }, }; const workspace = 'foo-workspace'; + const permissions = { + read: { + users: ['user1'], + }, + write: { + groups: ['groups1'], + }, + }; const getMockBulkCreateResponse = (objects, namespace) => { return { @@ -774,6 +782,18 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts permissions property when providing permissions info`, async () => { + const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); + await bulkCreateSuccess(objects); + const expected = expect.objectContaining({ permissions }); + const body = [expect.any(Object), expected, expect.any(Object), expected]; + expect(client.bulk).toHaveBeenCalledWith( + expect.objectContaining({ body }), + expect.anything() + ); + client.bulk.mockClear(); + }); }); describe('errors', () => { @@ -2269,6 +2289,16 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); + + it(`accepts permissions property`, async () => { + await createSuccess(type, attributes, { id, permissions }); + expect(client.create).toHaveBeenCalledWith( + expect.objectContaining({ + body: expect.objectContaining({ permissions }), + }), + expect.anything() + ); + }); }); describe('errors', () => { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 614223ba8c41..46a3d62e6e82 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -1064,7 +1064,7 @@ export class SavedObjectsRepository { throw SavedObjectsErrorHelpers.createGenericNotFoundError(type, id); } - const { originId, updated_at: updatedAt, permissions, workspaces } = body._source; + const { originId, updated_at: updatedAt, workspaces, permissions } = body._source; let namespaces: string[] = []; if (!this._registry.isNamespaceAgnostic(type)) { @@ -1081,6 +1081,7 @@ export class SavedObjectsRepository { ...(updatedAt && { updated_at: updatedAt }), ...(permissions && { permissions }), ...(workspaces && { workspaces }), + ...(permissions && { permissions }), version: encodeHitVersion(body), attributes: body._source[type], references: body._source.references || [], @@ -1163,6 +1164,7 @@ export class SavedObjectsRepository { ...(originId && { originId }), ...(permissions && { permissions }), ...(workspaces && { workspaces }), + ...(permissions && { permissions }), references, attributes, }; 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 e1c3d16a9258..1b268e033d97 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -69,10 +69,6 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; - /** - * workspaces the new created objects belong to - */ - workspaces?: string[]; /** permission control describe by ACL object */ permissions?: Permissions; } From 7e8c2e3ab7c3495d2c83e4e6e2ef00e4ec9aa990 Mon Sep 17 00:00:00 2001 From: Yuye Zhu Date: Wed, 18 Oct 2023 09:50:18 +0800 Subject: [PATCH 13/34] [Workspace][Feature] Left navigation menu adjustment (#192) * add util function to filter workspace feature by wildcard Signed-off-by: Yulong Ruan * resolve conflict Signed-off-by: yuye-aws * update tests and snapshots Signed-off-by: yuye-aws * small adjustment to left menu Signed-off-by: yuye-aws * resolve git conflict Signed-off-by: yuye-aws * rename nav link service function Signed-off-by: yuye-aws * unit test for workspace plugin.ts Signed-off-by: yuye-aws * update snapshots Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws * optimize code Signed-off-by: yuye-aws --------- Signed-off-by: Yulong Ruan Signed-off-by: yuye-aws Co-authored-by: Yulong Ruan --- src/core/public/chrome/chrome_service.mock.ts | 2 + src/core/public/chrome/nav_links/nav_link.ts | 9 +- .../nav_links/nav_links_service.test.ts | 143 +- .../chrome/nav_links/nav_links_service.ts | 44 +- .../collapsible_nav.test.tsx.snap | 2354 +++++++++-------- .../header/__snapshots__/header.test.tsx.snap | 377 +-- .../chrome/ui/header/collapsible_nav.test.tsx | 6 +- .../chrome/ui/header/collapsible_nav.tsx | 203 +- src/core/public/chrome/ui/header/nav_link.tsx | 56 +- src/core/public/index.ts | 2 +- .../workspace/workspaces_service.mock.ts | 7 +- .../public/workspace/workspaces_service.ts | 5 +- src/core/types/workspace.ts | 4 + src/core/utils/default_app_categories.ts | 10 +- .../dashboard_listing.test.tsx.snap | 10 + .../dashboard_top_nav.test.tsx.snap | 12 + src/plugins/dev_tools/public/plugin.ts | 2 +- .../objects_table/saved_objects_table.tsx | 2 +- src/plugins/workspace/public/plugin.test.ts | 28 + src/plugins/workspace/public/plugin.ts | 58 +- src/plugins/workspace/public/utils.test.ts | 93 + src/plugins/workspace/public/utils.ts | 56 + 22 files changed, 2012 insertions(+), 1471 deletions(-) create mode 100644 src/plugins/workspace/public/utils.test.ts create mode 100644 src/plugins/workspace/public/utils.ts diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index b6ce429528a7..566a6b7095e5 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -43,7 +43,9 @@ const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { getHeaderComponent: jest.fn(), navLinks: { + setNavLinks: jest.fn(), getNavLinks$: jest.fn(), + getAllNavLinks$: jest.fn(), has: jest.fn(), get: jest.fn(), getAll: jest.fn(), diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index cddd45234514..19e2fd2eddab 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -93,8 +93,10 @@ export interface ChromeNavLink { * Disables a link from being clickable. * * @internalRemarks - * This is only used by the ML and Graph plugins currently. They use this field + * This is used by the ML and Graph plugins. They use this field * to disable the nav link when the license is expired. + * This is also used by recently visited category in left menu + * to disable "No recently visited items". */ readonly disabled?: boolean; @@ -102,6 +104,11 @@ export interface ChromeNavLink { * Hides a link from the navigation. */ readonly hidden?: boolean; + + /** + * Links can be navigated through url. + */ + readonly externalLink?: boolean; } /** @public */ diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index 3fe2b57676e0..d4cfb2630496 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -32,18 +32,12 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App } from '../../application'; import { BehaviorSubject } from 'rxjs'; +import { ChromeNavLink } from 'opensearch-dashboards/public'; const availableApps = new Map([ ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], - [ - 'app2', - { - id: 'app2', - order: -10, - title: 'App 2', - euiIconType: 'canvasApp', - }, - ], + ['app2', { id: 'app2', order: -10, title: 'App 2', euiIconType: 'canvasApp' }], + ['app3', { id: 'app3', order: 10, title: 'App 3', icon: 'app3' }], ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], ]); @@ -66,7 +60,110 @@ describe('NavLinksService', () => { start = service.start({ application: mockAppService, http: mockHttp }); }); - describe('#getNavLinks$()', () => { + describe('#getAllNavLinks$()', () => { + it('does not include `chromeless` applications', async () => { + expect( + await start + .getAllNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).not.toContain('chromelessApp'); + }); + + it('sorts navLinks by `order` property', async () => { + expect( + await start + .getAllNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).toEqual(['app2', 'app1', 'app3']); + }); + + it('emits multiple values', async () => { + const navLinkIds$ = start.getAllNavLinks$().pipe(map((links) => links.map((l) => l.id))); + const emittedLinks: string[][] = []; + navLinkIds$.subscribe((r) => emittedLinks.push(r)); + start.update('app1', { href: '/foo' }); + + service.stop(); + expect(emittedLinks).toEqual([ + ['app2', 'app1', 'app3'], + ['app2', 'app1', 'app3'], + ]); + }); + + it('completes when service is stopped', async () => { + const last$ = start.getAllNavLinks$().pipe(takeLast(1)).toPromise(); + service.stop(); + await expect(last$).resolves.toBeInstanceOf(Array); + }); + }); + + describe('#getNavLinks$() when non null', () => { + // set filtered nav links, nav link with order smaller than 0 will be filtered + beforeEach(() => { + const filteredNavLinks = new Map(); + start.getAllNavLinks$().subscribe((links) => + links.forEach((link) => { + if (link.order !== undefined && link.order >= 0) { + filteredNavLinks.set(link.id, link); + } + }) + ); + start.setNavLinks(filteredNavLinks); + }); + + it('does not include `app2` applications', async () => { + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).not.toContain('app2'); + }); + + it('sorts navLinks by `order` property', async () => { + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).toEqual(['app1', 'app3']); + }); + + it('emits multiple values', async () => { + const navLinkIds$ = start.getNavLinks$().pipe(map((links) => links.map((l) => l.id))); + const emittedLinks: string[][] = []; + navLinkIds$.subscribe((r) => emittedLinks.push(r)); + start.update('app1', { href: '/foo' }); + + service.stop(); + expect(emittedLinks).toEqual([ + ['app1', 'app3'], + ['app1', 'app3'], + ]); + }); + + it('completes when service is stopped', async () => { + const last$ = start.getNavLinks$().pipe(takeLast(1)).toPromise(); + service.stop(); + await expect(last$).resolves.toBeInstanceOf(Array); + }); + }); + + describe('#getNavLinks$() when null', () => { it('does not include `chromeless` applications', async () => { expect( await start @@ -79,7 +176,19 @@ describe('NavLinksService', () => { ).not.toContain('chromelessApp'); }); - it('sorts navlinks by `order` property', async () => { + it('include `app2` applications', async () => { + expect( + await start + .getNavLinks$() + .pipe( + take(1), + map((links) => links.map((l) => l.id)) + ) + .toPromise() + ).toContain('app2'); + }); + + it('sorts navLinks by `order` property', async () => { expect( await start .getNavLinks$() @@ -88,7 +197,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1']); + ).toEqual(['app2', 'app1', 'app3']); }); it('emits multiple values', async () => { @@ -99,8 +208,8 @@ describe('NavLinksService', () => { service.stop(); expect(emittedLinks).toEqual([ - ['app2', 'app1'], - ['app2', 'app1'], + ['app2', 'app1', 'app3'], + ['app2', 'app1', 'app3'], ]); }); @@ -123,7 +232,7 @@ describe('NavLinksService', () => { describe('#getAll()', () => { it('returns a sorted array of navlinks', () => { - expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1']); + expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1', 'app3']); }); }); @@ -148,7 +257,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1']); + ).toEqual(['app2', 'app1', 'app3']); }); it('does nothing on chromeless applications', async () => { @@ -161,7 +270,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1']); + ).toEqual(['app2', 'app1', 'app3']); }); it('removes all other links', async () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index 93c138eac62c..d4c899a57be8 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -53,6 +53,16 @@ export interface ChromeNavLinks { */ getNavLinks$(): Observable>>; + /** + * Get an observable for a sorted list of all navlinks. + */ + getAllNavLinks$(): Observable>>; + + /** + * Set navlinks. + */ + setNavLinks(navLinks: ReadonlyMap): void; + /** * Get the state of a navlink at this point in time. * @param id @@ -132,7 +142,10 @@ export class NavLinksService { // manual link modifications to be able to re-apply then after every // availableApps$ changes. const linkUpdaters$ = new BehaviorSubject([]); - const navLinks$ = new BehaviorSubject>(new Map()); + const displayedNavLinks$ = new BehaviorSubject | undefined>( + undefined + ); + const allNavLinks$ = new BehaviorSubject>(new Map()); combineLatest([appLinks$, linkUpdaters$]) .pipe( @@ -140,28 +153,41 @@ export class NavLinksService { return linkUpdaters.reduce((links, updater) => updater(links), appLinks); }) ) - .subscribe((navlinks) => { - navLinks$.next(navlinks); + .subscribe((navLinks) => { + allNavLinks$.next(navLinks); }); const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { getNavLinks$: () => { - return navLinks$.pipe(map(sortNavLinks), takeUntil(this.stop$)); + return combineLatest([allNavLinks$, displayedNavLinks$]).pipe( + map(([allNavLinks, displayedNavLinks]) => + displayedNavLinks === undefined ? sortLinks(allNavLinks) : sortLinks(displayedNavLinks) + ), + takeUntil(this.stop$) + ); + }, + + setNavLinks: (navLinks: ReadonlyMap) => { + displayedNavLinks$.next(navLinks); + }, + + getAllNavLinks$: () => { + return allNavLinks$.pipe(map(sortLinks), takeUntil(this.stop$)); }, get(id: string) { - const link = navLinks$.value.get(id); + const link = allNavLinks$.value.get(id); return link && link.properties; }, getAll() { - return sortNavLinks(navLinks$.value); + return sortLinks(allNavLinks$.value); }, has(id: string) { - return navLinks$.value.has(id); + return allNavLinks$.value.has(id); }, showOnly(id: string) { @@ -209,9 +235,9 @@ export class NavLinksService { } } -function sortNavLinks(navLinks: ReadonlyMap) { +function sortLinks(links: ReadonlyMap) { return sortBy( - [...navLinks.values()].map((link) => link.properties), + [...links.values()].map((link) => ('properties' in link ? link.properties : link)), 'order' ); } diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav.test.tsx.snap index d6094f78e24b..6f3e1077f825 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 @@ -123,7 +123,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` } } homeHref="/" - id="collapsibe-nav" + id="collapsible-nav" isLocked={false} isNavOpen={true} logos={ @@ -243,7 +243,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", @@ -423,7 +423,7 @@ exports[`CollapsibleNav renders links grouped by category 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 viewed -

-
-
- - } - className="euiCollapsibleNavGroup euiCollapsibleNavGroup--light euiCollapsibleNavGroup--withHeading" - data-test-subj="collapsibleNavGroup-recentlyViewed" - id="mockId" - initialIsOpen={true} - isLoading={false} - isLoadingMessage={false} - onToggle={[Function]} - paddingSize="none" +
-
-
- -
-
- -
-
+ + +
+
+ -
- +
- -
-

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

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

    -
    -
    -
    -
    -
    - - -
    -
    - -
    -
    -
    - +
    - - + + + 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" +
    -
    -
    - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - - + + + 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" +
    -
    -
    - -
    -
    - -
    -
    + + +
    +
    + -
    - +
    - - + + + 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" +
    -
    -
    - -
    -
    - -
    -
    + + +
    + -
    +
    - -
    -
    -
    -
    - -
    -
    - -
    +
    + +
    { ); expectShownNavLinksCount(component, 3); clickGroup(component, 'opensearchDashboards'); - clickGroup(component, 'recentlyViewed'); + clickGroup(component, 'recentlyVisited'); expectShownNavLinksCount(component, 1); component.setProps({ isNavOpen: false }); expectNavIsClosed(component); @@ -205,7 +205,7 @@ describe('CollapsibleNav', () => { }, }); - component.find('[data-test-subj="collapsibleNavGroup-recentlyViewed"] a').simulate('click'); + component.find('[data-test-subj="collapsibleNavGroup-recentlyVisited"] a').simulate('click'); expect(onClose.callCount).toEqual(1); expectNavIsClosed(component); component.setProps({ isNavOpen: true }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav.tsx b/src/core/public/chrome/ui/header/collapsible_nav.tsx index 9c9223aa501b..3ac2575c7faa 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -37,22 +37,27 @@ import { EuiListGroup, EuiListGroupItem, EuiShowFor, - EuiText, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { groupBy, sortBy } from 'lodash'; import React, { Fragment, useRef } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; +import { DEFAULT_APP_CATEGORIES } from '../../../../utils'; import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; import { AppCategory } from '../../../../types'; -import { InternalApplicationStart } from '../../../application/types'; +import { InternalApplicationStart } from '../../../application'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link'; -import type { Logos } from '../../../../common/types'; +import type { Logos } from '../../../../common'; +import { + createEuiListItem, + createRecentChromeNavLink, + emptyRecentlyVisited, + CollapsibleNavLink, +} from './nav_link'; -function getAllCategories(allCategorizedLinks: Record) { +function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; for (const [key, value] of Object.entries(allCategorizedLinks)) { @@ -62,14 +67,28 @@ function getAllCategories(allCategorizedLinks: Record) return allCategories; } -function getOrderedCategories( - mainCategories: Record, +function getSortedLinksAndCategories( + uncategorizedLinks: CollapsibleNavLink[], categoryDictionary: ReturnType -) { - return sortBy( - Object.keys(mainCategories), - (categoryName) => categoryDictionary[categoryName]?.order +): Array { + // uncategorized links and categories are ranked according the order + // if order is not defined, categories will be placed above uncategorized links + const categories = Object.values(categoryDictionary).filter( + (category) => category !== undefined + ) as AppCategory[]; + const uncategorizedLinksWithOrder = uncategorizedLinks.filter((link) => link.order !== null); + const uncategorizedLinksWithoutOrder = uncategorizedLinks.filter((link) => link.order === null); + const categoriesWithOrder = categories.filter((category) => category.order !== null); + const categoriesWithoutOrder = categories.filter((category) => category.order === null); + const sortedLinksAndCategories = sortBy( + [...uncategorizedLinksWithOrder, ...categoriesWithOrder], + 'order' ); + return [ + ...sortedLinksAndCategories, + ...categoriesWithoutOrder, + ...uncategorizedLinksWithoutOrder, + ]; } function getCategoryLocalStorageKey(id: string) { @@ -121,15 +140,30 @@ export function CollapsibleNav({ ...observables }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); + let customNavLink = useObservable(observables.customNavLink$, undefined); + if (customNavLink) { + customNavLink = { ...customNavLink, externalLink: true }; + } const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); - const customNavLink = useObservable(observables.customNavLink$, undefined); + const allNavLinks: CollapsibleNavLink[] = [...navLinks]; + if (recentlyAccessed.length) { + allNavLinks.push( + ...recentlyAccessed.map((link) => createRecentChromeNavLink(link, navLinks, basePath)) + ); + } else { + allNavLinks.push(emptyRecentlyVisited); + } const appId = useObservable(observables.appId$, ''); const lockRef = useRef(null); - const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); - const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; + const groupedNavLinks = groupBy(allNavLinks, (link) => link?.category?.id); + const { undefined: uncategorizedLinks = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); - const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); - const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { + const sortedLinksAndCategories = getSortedLinksAndCategories( + uncategorizedLinks, + categoryDictionary + ); + + const readyForEUI = (link: CollapsibleNavLink, needsIcon: boolean = false) => { return createEuiListItem({ link, appId, @@ -169,7 +203,6 @@ export function CollapsibleNav({ navigateToApp, dataTestSubj: 'collapsibleNavCustomNavLink', onClick: closeNav, - externalLink: true, }), ]} maxWidth="none" @@ -184,103 +217,53 @@ export function CollapsibleNav({ )} - {/* Recently viewed */} - setIsCategoryOpen('recentlyViewed', isCategoryOpen, storage)} - data-test-subj="collapsibleNavGroup-recentlyViewed" - > - {recentlyAccessed.length > 0 ? ( - { - // TODO #64541 - // Can remove icon from recent links completely - const { iconType, onClick, ...hydratedLink } = createRecentNavLink( - link, - navLinks, - basePath, - navigateToUrl - ); - - return { - ...hydratedLink, - 'data-test-subj': 'collapsibleNavAppLink--recent', - onClick: (event) => { - if (!isModifiedOrPrevented(event)) { - closeNav(); - onClick(event); - } - }, - }; - })} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - className="osdCollapsibleNav__recentsListGroup" - /> - ) : ( - -

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

    -
    - )} -
    - - - - {/* OpenSearchDashboards, Observability, Security, and Management sections */} - {orderedCategories.map((categoryName) => { - const category = categoryDictionary[categoryName]!; - const opensearchLinkLogo = - category.id === 'opensearchDashboards' ? logos.Mark.url : category.euiIconType; + {sortedLinksAndCategories.map((item, i) => { + if (!('href' in item)) { + // CollapsibleNavLink has href property, while AppCategory does not have + const category = item; + const opensearchLinkLogo = + category.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id + ? logos.Mark.url + : category.euiIconType; - return ( - setIsCategoryOpen(category.id, isCategoryOpen, storage)} - data-test-subj={`collapsibleNavGroup-${category.id}`} - data-test-opensearch-logo={opensearchLinkLogo} - > - readyForEUI(link))} - maxWidth="none" - color="subdued" - gutterSize="none" - size="s" - /> - - ); + return ( + + setIsCategoryOpen(category.id, isCategoryOpen, storage) + } + data-test-subj={`collapsibleNavGroup-${category.id}`} + data-test-opensearch-logo={opensearchLinkLogo} + > + readyForEUI(link))} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + /> + + ); + } else { + return ( + + + + + + ); + } })} - {/* Things with no category (largely for custom plugins) */} - {unknowns.map((link, i) => ( - - - - - - ))} - {/* Docking button only for larger screens that can support it*/} diff --git a/src/core/public/chrome/ui/header/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 38d31dbc09c9..55482708e09f 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -31,9 +31,8 @@ import { EuiIcon } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React from 'react'; -import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; +import { AppCategory, ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; import { HttpStart } from '../../../http'; -import { InternalApplicationStart } from '../../../application/types'; import { relativeToAbsolute } from '../../nav_links/to_nav_link'; export const isModifiedOrPrevented = (event: React.MouseEvent) => @@ -47,8 +46,9 @@ const aliasedApps: { [key: string]: string[] } = { export const isActiveNavLink = (appId: string | undefined, linkId: string): boolean => !!(appId === linkId || aliasedApps[linkId]?.includes(appId || '')); +export type CollapsibleNavLink = ChromeNavLink | RecentNavLink; interface Props { - link: ChromeNavLink; + link: CollapsibleNavLink; appId?: string; basePath?: HttpStart['basePath']; dataTestSubj: string; @@ -68,9 +68,8 @@ export function createEuiListItem({ onClick = () => {}, navigateToApp, dataTestSubj, - externalLink = false, }: Props) { - const { href, id, title, disabled, euiIconType, icon, tooltip } = link; + const { href, id, title, disabled, euiIconType, icon, tooltip, externalLink } = link; return { label: tooltip ?? title, @@ -101,14 +100,16 @@ export function createEuiListItem({ }; } -export interface RecentNavLink { - href: string; - label: string; - title: string; - 'aria-label': string; - iconType?: string; - onClick: React.MouseEventHandler; -} +export type RecentNavLink = Omit; + +const recentlyVisitedCategory: AppCategory = { + id: 'recentlyVisited', + label: i18n.translate('core.ui.recentlyVisited.label', { + defaultMessage: 'Recently Visited', + }), + order: 0, + euiIconType: 'clock', +}; /** * Add saved object type info to recently links @@ -120,11 +121,10 @@ export interface RecentNavLink { * @param navLinks * @param basePath */ -export function createRecentNavLink( +export function createRecentChromeNavLink( recentLink: ChromeRecentlyAccessedHistoryItem, navLinks: ChromeNavLink[], - basePath: HttpStart['basePath'], - navigateToUrl: InternalApplicationStart['navigateToUrl'] + basePath: HttpStart['basePath'] ): RecentNavLink { const { link, label } = recentLink; const href = relativeToAbsolute(basePath.prepend(link)); @@ -143,16 +143,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/index.ts b/src/core/public/index.ts index c9d416cb6f43..9b545049cad4 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -104,7 +104,7 @@ export { StringValidation, StringValidationRegex, StringValidationRegexString, - WorkspaceAttribute, + WorkspaceObject, } from '../types'; export { diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index ae56c035eb3a..ab8bda09730a 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -5,13 +5,12 @@ import { BehaviorSubject } from 'rxjs'; import type { PublicMethodsOf } from '@osd/utility-types'; - import { WorkspacesService } from './workspaces_service'; -import { WorkspaceAttribute } from '..'; +import { WorkspaceObject } from '..'; const currentWorkspaceId$ = new BehaviorSubject(''); -const workspaceList$ = new BehaviorSubject([]); -const currentWorkspace$ = new BehaviorSubject(null); +const workspaceList$ = new BehaviorSubject([]); +const currentWorkspace$ = new BehaviorSubject(null); const initialized$ = new BehaviorSubject(false); const createWorkspacesSetupContractMock = () => ({ diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index cc19b3c79229..d235f3322571 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -5,10 +5,7 @@ import { BehaviorSubject, combineLatest } from 'rxjs'; import { isEqual } from 'lodash'; - -import { CoreService, WorkspaceAttribute } from '../../types'; - -type WorkspaceObject = WorkspaceAttribute & { readonly?: boolean }; +import { CoreService, WorkspaceObject } from '../../types'; interface WorkspaceObservables { /** diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index 85bfc6711ad6..5a9e0c1c7ca5 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -14,3 +14,7 @@ export interface WorkspaceAttribute { defaultVISTheme?: string; reserved?: boolean; } + +export interface WorkspaceObject extends WorkspaceAttribute { + readonly?: boolean; +} diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 3c0920624e1b..e6e53f9101ed 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -65,12 +65,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/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 b8b555bd288b..e062c722c007 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -223,9 +223,11 @@ exports[`dashboard listing hideWriteControls 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1366,9 +1368,11 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2570,9 +2574,11 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3774,9 +3780,11 @@ exports[`dashboard listing renders table rows 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4978,9 +4986,11 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, 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 ace497ea397e..702ebcdbe498 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -211,9 +211,11 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1179,9 +1181,11 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2147,9 +2151,11 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3115,9 +3121,11 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4083,9 +4091,11 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -5051,9 +5061,11 @@ exports[`Dashboard top nav render with all components 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], + "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], + "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, 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/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 edc538716ee2..d82412be03d0 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 @@ -877,7 +877,7 @@ export class SavedObjectsTable extends Component { expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); }); + + it('#start filter nav links according to workspace feature', () => { + const workspacePlugin = new WorkspacePlugin(); + const coreStart = coreMock.createStart(); + const navLinksService = coreStart.chrome.navLinks; + const devToolsNavLink = { + id: 'dev_tools', + category: { id: 'management', label: 'Management' }, + }; + const discoverNavLink = { + id: 'discover', + category: { id: 'opensearchDashboards', label: 'Library' }, + }; + const workspace = { + id: 'test', + name: 'test', + features: ['dev_tools'], + }; + const allNavLinks = of([devToolsNavLink, discoverNavLink] as ChromeNavLink[]); + const filteredNavLinksMap = new Map(); + filteredNavLinksMap.set(devToolsNavLink.id, devToolsNavLink as ChromeNavLink); + navLinksService.getAllNavLinks$.mockReturnValue(allNavLinks); + coreStart.workspaces.currentWorkspace$.next(workspace); + workspacePlugin.start(coreStart); + expect(navLinksService.setNavLinks).toHaveBeenCalledWith(filteredNavLinksMap); + }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 346e243ce1bf..b3881e0eb956 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,10 +4,14 @@ */ import type { Subscription } from 'rxjs'; +import { combineLatest } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { i18n } from '@osd/i18n'; +import { featureMatchesConfig } from './utils'; +import { AppMountParameters, AppNavLinkStatus, ChromeNavLink, CoreSetup, CoreStart, Plugin, WorkspaceObject, DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { WorkspaceClient } from './workspace_client'; -import { AppMountParameters, AppNavLinkStatus, CoreSetup, Plugin, CoreStart } from '../../../core/public'; -import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { Services } from './types'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; @@ -27,10 +31,55 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { private getWorkspaceIdFromURL(): string | null { return getWorkspaceIdFromUrl(window.location.href); } + + private filterByWorkspace(workspace: WorkspaceObject | null, allNavLinks: ChromeNavLink[]) { + if (!workspace) return allNavLinks; + const features = workspace.features ?? ['*']; + return allNavLinks.filter(featureMatchesConfig(features)); + } + + private filterNavLinks(core: CoreStart) { + const navLinksService = core.chrome.navLinks; + const allNavLinks$ = navLinksService.getAllNavLinks$(); + const currentWorkspace$ = core.workspaces.currentWorkspace$; + combineLatest([ + allNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), + currentWorkspace$, + ]).subscribe(([allNavLinks, currentWorkspace]) => { + const filteredNavLinks = this.filterByWorkspace(currentWorkspace, allNavLinks); + const navLinks = new Map(); + filteredNavLinks.forEach((chromeNavLink) => { + navLinks.set(chromeNavLink.id, chromeNavLink); + }); + navLinksService.setNavLinks(navLinks); + }); + } + + /** + * The category "Opensearch Dashboards" needs to be renamed as "Library" + * when workspace feature flag is on, we need to do it here and generate + * a new item without polluting the original ChromeNavLink. + */ + private changeCategoryNameByWorkspaceFeatureFlag(chromeLinks: ChromeNavLink[]): ChromeNavLink[] { + return chromeLinks.map((item) => { + if (item.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { + return { + ...item, + category: { + ...item.category, + label: i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', + }), + }, + }; + } + return item; + }); + } + public async setup(core: CoreSetup) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); - /** * Retrieve workspace id from url */ @@ -98,6 +147,9 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { this.coreStart = core; this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); + if (core) { + this.filterNavLinks(core); + } return {}; } 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; +}; From 7185d73d1f7978d983d8cb3379432d2134960434 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Thu, 19 Oct 2023 10:20:11 +0800 Subject: [PATCH 14/34] Add copy saved objects API (#217) * Add copy saved objects API Signed-off-by: gaobinlong * Modify file header Signed-off-by: gaobinlong --------- Signed-off-by: gaobinlong --- src/core/server/saved_objects/routes/copy.ts | 72 +++++ src/core/server/saved_objects/routes/index.ts | 2 + .../routes/integration_tests/copy.test.ts | 264 ++++++++++++++++++ 3 files changed, 338 insertions(+) create mode 100644 src/core/server/saved_objects/routes/copy.ts create mode 100644 src/core/server/saved_objects/routes/integration_tests/copy.test.ts 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..95e79ffd40a1 --- /dev/null +++ b/src/core/server/saved_objects/routes/copy.ts @@ -0,0 +1,72 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { schema } from '@osd/config-schema'; +import { IRouter } from '../../http'; +import { SavedObjectConfig } from '../saved_objects_config'; +import { exportSavedObjectsToStream } from '../export'; +import { importSavedObjectsFromStream } from '../import'; + +export const registerCopyRoute = (router: IRouter, config: SavedObjectConfig) => { + const { maxImportExportSize } = config; + + router.post( + { + path: '/_copy', + validate: { + body: schema.object({ + objects: schema.arrayOf( + schema.object({ + type: schema.string(), + id: schema.string(), + }) + ), + 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); + + const invalidObjects = objects.filter((obj) => !supportedTypes.includes(obj.type)); + if (invalidObjects.length) { + return res.badRequest({ + body: { + message: `Trying to copy object(s) with unsupported types: ${invalidObjects + .map((obj) => `${obj.type}:${obj.id}`) + .join(', ')}`, + }, + }); + } + + 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/index.ts b/src/core/server/saved_objects/routes/index.ts index 7149474e446c..6c70276d7387 100644 --- a/src/core/server/saved_objects/routes/index.ts +++ b/src/core/server/saved_objects/routes/index.ts @@ -45,6 +45,7 @@ import { registerExportRoute } from './export'; import { registerImportRoute } from './import'; import { registerResolveImportErrorsRoute } from './resolve_import_errors'; import { registerMigrateRoute } from './migrate'; +import { registerCopyRoute } from './copy'; export function registerRoutes({ http, @@ -71,6 +72,7 @@ export function registerRoutes({ registerExportRoute(router, config); registerImportRoute(router, config); registerResolveImportErrorsRoute(router, config); + registerCopyRoute(router, config); const internalRouter = http.createRouter('/internal/saved_objects/'); diff --git a/src/core/server/saved_objects/routes/integration_tests/copy.test.ts b/src/core/server/saved_objects/routes/integration_tests/copy.test.ts new file mode 100644 index 000000000000..e8a9d83b30ea --- /dev/null +++ b/src/core/server/saved_objects/routes/integration_tests/copy.test.ts @@ -0,0 +1,264 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as exportMock from '../../export'; +import { createListStream } from '../../../utils/streams'; +import { mockUuidv4 } from '../../import/__mocks__'; +import supertest from 'supertest'; +import { UnwrapPromise } from '@osd/utility-types'; +import { registerCopyRoute } from '../copy'; +import { savedObjectsClientMock } from '../../../../../core/server/mocks'; +import { SavedObjectConfig } from '../../saved_objects_config'; +import { setupServer, createExportableType } from '../test_utils'; +import { SavedObjectsErrorHelpers } from '../..'; + +jest.mock('../../export', () => ({ + exportSavedObjectsToStream: jest.fn(), +})); + +type SetupServerReturn = UnwrapPromise>; + +const { v4: uuidv4 } = jest.requireActual('uuid'); +const allowedTypes = ['index-pattern', 'visualization', 'dashboard']; +const config = { maxImportPayloadBytes: 26214400, maxImportExportSize: 10000 } as SavedObjectConfig; +const URL = '/internal/saved_objects/_copy'; +const exportSavedObjectsToStream = exportMock.exportSavedObjectsToStream as jest.Mock; + +describe(`POST ${URL}`, () => { + let server: SetupServerReturn['server']; + let httpSetup: SetupServerReturn['httpSetup']; + let handlerContext: SetupServerReturn['handlerContext']; + let savedObjectsClient: ReturnType; + + const emptyResponse = { saved_objects: [], total: 0, per_page: 0, page: 0 }; + const mockIndexPattern = { + type: 'index-pattern', + id: 'my-pattern', + attributes: { title: 'my-pattern-*' }, + references: [], + }; + const mockVisualization = { + type: 'visualization', + id: 'my-visualization', + attributes: { title: 'Test visualization' }, + references: [ + { + name: 'ref_0', + type: 'index-pattern', + id: 'my-pattern', + }, + ], + }; + const mockDashboard = { + type: 'dashboard', + id: 'my-dashboard', + attributes: { title: 'Look at my dashboard' }, + references: [], + }; + + beforeEach(async () => { + mockUuidv4.mockReset(); + mockUuidv4.mockImplementation(() => uuidv4()); + ({ server, httpSetup, handlerContext } = await setupServer()); + handlerContext.savedObjects.typeRegistry.getImportableAndExportableTypes.mockReturnValue( + allowedTypes.map(createExportableType) + ); + handlerContext.savedObjects.typeRegistry.getType.mockImplementation( + (type: string) => + // other attributes aren't needed for the purposes of injecting metadata + ({ management: { icon: `${type}-icon` } } as any) + ); + + savedObjectsClient = handlerContext.savedObjects.client; + savedObjectsClient.find.mockResolvedValue(emptyResponse); + savedObjectsClient.checkConflicts.mockResolvedValue({ errors: [] }); + + const router = httpSetup.createRouter('/internal/saved_objects/'); + registerCopyRoute(router, config); + + await server.start(); + }); + + afterEach(async () => { + await server.stop(); + }); + + it('formats successful response', async () => { + exportSavedObjectsToStream.mockResolvedValueOnce(createListStream([])); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(200); + + expect(result.body).toEqual({ success: true, successCount: 0 }); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); // no objects were created + }); + + it('requires objects', async () => { + const result = await supertest(httpSetup.server.listener).post(URL).send({}).expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.objects]: expected value of type [array] but got [undefined]"` + ); + }); + + it('requires target workspace', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"[request body.targetWorkspace]: expected value of type [string] but got [undefined]"` + ); + }); + + it('copy unsupported objects', async () => { + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'unknown', + id: 'my-pattern', + }, + ], + includeReferencesDeep: true, + targetWorkspace: 'test_workspace', + }) + .expect(400); + + expect(result.body.message).toMatchInlineSnapshot( + `"Trying to copy object(s) with unsupported types: unknown:my-pattern"` + ); + }); + + it('copy index pattern and dashboard into a workspace successfully', async () => { + const targetWorkspace = 'target_workspace_id'; + const savedObjects = [mockIndexPattern, mockDashboard]; + exportSavedObjectsToStream.mockResolvedValueOnce(createListStream(savedObjects)); + savedObjectsClient.bulkCreate.mockResolvedValueOnce({ + saved_objects: savedObjects.map((obj) => ({ ...obj, workspaces: [targetWorkspace] })), + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'index-pattern', + id: 'my-pattern', + }, + { + type: 'dashboard', + id: 'my-dashboard', + }, + ], + includeReferencesDeep: true, + targetWorkspace, + }) + .expect(200); + expect(result.body).toEqual({ + success: true, + successCount: 2, + successResults: [ + { + type: mockIndexPattern.type, + id: mockIndexPattern.id, + meta: { title: mockIndexPattern.attributes.title, icon: 'index-pattern-icon' }, + }, + { + type: mockDashboard.type, + id: mockDashboard.id, + meta: { title: mockDashboard.attributes.title, icon: 'dashboard-icon' }, + }, + ], + }); + expect(savedObjectsClient.bulkCreate).toHaveBeenCalledTimes(1); + }); + + it('copy a visualization with missing references', async () => { + const targetWorkspace = 'target_workspace_id'; + const savedObjects = [mockVisualization]; + const exportDetail = { + exportedCount: 2, + missingRefCount: 1, + missingReferences: [{ type: 'index-pattern', id: 'my-pattern' }], + }; + exportSavedObjectsToStream.mockResolvedValueOnce( + createListStream(...savedObjects, exportDetail) + ); + + const error = SavedObjectsErrorHelpers.createGenericNotFoundError( + 'index-pattern', + 'my-pattern-*' + ).output.payload; + savedObjectsClient.bulkGet.mockResolvedValueOnce({ + saved_objects: [{ ...mockIndexPattern, error }], + }); + + const result = await supertest(httpSetup.server.listener) + .post(URL) + .send({ + objects: [ + { + type: 'visualization', + id: 'my-visualization', + }, + ], + includeReferencesDeep: true, + targetWorkspace, + }) + .expect(200); + expect(result.body).toEqual({ + success: false, + successCount: 0, + errors: [ + { + id: 'my-visualization', + type: 'visualization', + title: 'Test visualization', + meta: { title: 'Test visualization', icon: 'visualization-icon' }, + error: { + type: 'missing_references', + references: [{ type: 'index-pattern', id: 'my-pattern' }], + }, + }, + ], + }); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledTimes(1); + expect(savedObjectsClient.bulkGet).toHaveBeenCalledWith( + [{ fields: ['id'], id: 'my-pattern', type: 'index-pattern' }], + expect.any(Object) // options + ); + expect(savedObjectsClient.bulkCreate).not.toHaveBeenCalled(); + }); +}); From 0d079d7ed398a5af78317bde601c6247e36d289e Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 23 Oct 2023 17:04:13 +0800 Subject: [PATCH 15/34] Feature: create management / public workspaces when calling list api (#236) * feat: create management / public workspaces when calling list api Signed-off-by: SuZhou-Joe * feat: fix bootstrap Signed-off-by: SuZhou-Joe * fix: integration test Signed-off-by: SuZhou-Joe * fix: flaky test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- src/core/server/index.ts | 8 +- src/core/utils/constants.ts | 6 + src/core/utils/index.ts | 8 +- .../components/import_flyout.test.tsx | 9 +- src/plugins/workspace/common/constants.ts | 1 + src/plugins/workspace/server/plugin.ts | 2 +- .../workspace/server/workspace_client.ts | 127 +++++++++++++++++- 7 files changed, 150 insertions(+), 11 deletions(-) diff --git a/src/core/server/index.ts b/src/core/server/index.ts index d3df420710e5..736f12ab28fe 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -349,7 +349,13 @@ export { } from './metrics'; export { AppCategory, WorkspaceAttribute } from '../types'; -export { DEFAULT_APP_CATEGORIES, WORKSPACE_TYPE } from '../utils'; +export { + DEFAULT_APP_CATEGORIES, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, + WORKSPACE_TYPE, + PERSONAL_WORKSPACE_ID_PREFIX, +} from '../utils'; export { SavedObject, diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index ecc1b7e863c4..0993f0587e28 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -6,3 +6,9 @@ export const WORKSPACE_TYPE = 'workspace'; export const WORKSPACE_PATH_PREFIX = '/w'; + +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/index.ts b/src/core/utils/index.ts index a83f85a8fce0..a4b6cd4a922b 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -37,5 +37,11 @@ export { IContextProvider, } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; -export { WORKSPACE_PATH_PREFIX, WORKSPACE_TYPE } from './constants'; export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; +export { + WORKSPACE_PATH_PREFIX, + PUBLIC_WORKSPACE_ID, + MANAGEMENT_WORKSPACE_ID, + WORKSPACE_TYPE, + PERSONAL_WORKSPACE_ID_PREFIX, +} from './constants'; diff --git a/src/plugins/console/public/application/components/import_flyout.test.tsx b/src/plugins/console/public/application/components/import_flyout.test.tsx index 5e7093fe306c..f04678155405 100644 --- a/src/plugins/console/public/application/components/import_flyout.test.tsx +++ b/src/plugins/console/public/application/components/import_flyout.test.tsx @@ -10,6 +10,7 @@ import { ContextValue, ServicesContextProvider } from '../contexts'; import { serviceContextMock } from '../contexts/services_context.mock'; import { wrapWithIntl, nextTick } from 'test_utils/enzyme_helpers'; import { ReactWrapper, mount } from 'enzyme'; +import { waitFor } from '@testing-library/dom'; const mockFile = new File(['{"text":"Sample JSON data"}'], 'sample.json', { type: 'application/json', @@ -122,9 +123,11 @@ describe('ImportFlyout Component', () => { expect(component.find(overwriteOptionIdentifier).first().props().checked).toBe(false); // should update existing query - expect(mockUpdate).toBeCalledTimes(1); - expect(mockClose).toBeCalledTimes(1); - expect(mockRefresh).toBeCalledTimes(1); + await waitFor(() => { + expect(mockUpdate).toBeCalledTimes(1); + expect(mockClose).toBeCalledTimes(1); + expect(mockRefresh).toBeCalledTimes(1); + }); }); it('should handle errors during import', async () => { diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 6ae89c0edad5..2aa86aed2e44 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +export const WORKSPACE_UPDATE_APP_ID = 'workspace_update'; export const WORKSPACE_OVERVIEW_APP_ID = 'workspace_overview'; export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index f5b7da6430e0..20ae1cc8e172 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -45,7 +45,7 @@ export class WorkspacePlugin implements Plugin + ) { + 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, + }); + 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) { + 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, + }); + } + private async setupManagementWorkspace(savedObjectClient?: SavedObjectsClientContract) { + 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, + }); + } public async setup(core: CoreSetup): Promise> { this.setupDep.savedObjects.registerType(workspace); return { @@ -108,7 +174,7 @@ export class WorkspaceClient implements IWorkspaceClientImpl { options: WorkspaceFindOptions ): ReturnType { try { - const { + let { saved_objects: savedObjects, ...others } = await this.getSavedObjectClientsFromRequestDetail(requestDetail).find( @@ -117,6 +183,49 @@ export class WorkspaceClient implements IWorkspaceClientImpl { type: WORKSPACE_TYPE, } ); + 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)); + } + + try { + await Promise.all(tasks); + if (tasks.length) { + const { + saved_objects: retryFindSavedObjects, + ...retryFindOthers + } = await this.getSavedObjectClientsFromRequestDetail(requestDetail).find< + WorkspaceAttribute + >({ + ...options, + type: WORKSPACE_TYPE, + }); + savedObjects = retryFindSavedObjects; + others = retryFindOthers; + } + } catch (e) { + this.logger.error(`Some error happened when initializing reserved workspace: ${e}`); + } + return { success: true, result: { @@ -160,6 +269,9 @@ export class WorkspaceClient implements IWorkspaceClientImpl { 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({ @@ -172,7 +284,12 @@ export class WorkspaceClient implements IWorkspaceClientImpl { throw new Error(DUPLICATE_WORKSPACE_NAME_ERROR); } } - await client.update>(WORKSPACE_TYPE, id, attributes, {}); + + await client.create>(WORKSPACE_TYPE, attributes, { + id, + overwrite: true, + version: workspaceInDB.version, + }); return { success: true, result: true, From b78cea6affbfb799ecae6bbb87efec2483cba715 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Mon, 26 Feb 2024 16:53:59 +0800 Subject: [PATCH 16/34] fix: remove duplicated reserved field avoid type error (#250) Signed-off-by: Lin Wang --- src/core/types/workspace.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/core/types/workspace.ts b/src/core/types/workspace.ts index 5a9e0c1c7ca5..ffad76fb48a2 100644 --- a/src/core/types/workspace.ts +++ b/src/core/types/workspace.ts @@ -12,7 +12,6 @@ export interface WorkspaceAttribute { icon?: string; reserved?: boolean; defaultVISTheme?: string; - reserved?: boolean; } export interface WorkspaceObject extends WorkspaceAttribute { From f7b057b0e0c3d1495dff4718b04bf58149dd074f Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Tue, 27 Feb 2024 18:36:22 +0800 Subject: [PATCH 17/34] fix: cypress tests checkout workspace branch (#252) Signed-off-by: Lin Wang --- .github/workflows/cypress_workflow.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index 4bf8051ee82a..5b531ad82456 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -28,7 +28,7 @@ on: env: TEST_REPO: ${{ inputs.test_repo != '' && inputs.test_repo || 'opensearch-project/opensearch-dashboards-functional-test' }} - TEST_BRANCH: "${{ inputs.test_branch != '' && inputs.test_branch || github.base_ref }}" + TEST_BRANCH: "${{ inputs.test_branch != '' && inputs.test_branch || 'workspace' }}" FTR_PATH: 'ftr' START_CMD: 'node ../scripts/opensearch_dashboards --dev --no-base-path --no-watch --savedObjects.maxImportPayloadBytes=10485760 --server.maxPayloadBytes=1759977 --logging.json=false --data.search.aggs.shardDelay.enabled=true' OPENSEARCH_SNAPSHOT_CMD: 'node ../scripts/opensearch snapshot -E cluster.routing.allocation.disk.threshold_enabled=false' From b7a4a28771e0ab01bda0b0c08eaf190a4c5a2435 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Tue, 27 Feb 2024 18:38:09 +0800 Subject: [PATCH 18/34] fix: workspace routes integration failed (#251) Signed-off-by: Lin Wang --- .../server/integration_tests/routes.test.ts | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 061d0f3c4064..21d6f155a927 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -53,12 +53,14 @@ describe('workspace service', () => { const savedObjectsRepository = osd.coreStart.savedObjects.createInternalRepository([ WORKSPACE_TYPE, ]); - await Promise.all( - listResult.body.result.workspaces.map((item: WorkspaceAttribute) => - // this will delete reserved workspace - savedObjectsRepository.delete(WORKSPACE_TYPE, item.id) + await expect( + Promise.all( + listResult.body.result.workspaces.map((item: WorkspaceAttribute) => + // this will delete reserved workspace + savedObjectsRepository.delete(WORKSPACE_TYPE, item.id) + ) ) - ); + ).resolves.toBeInstanceOf(Array); }); it('create', async () => { await osdTestServer.request @@ -194,7 +196,8 @@ describe('workspace service', () => { page: 1, }) .expect(200); - expect(listResult.body.result.total).toEqual(2); + // Global and Management workspace will be created by default after workspace list API called. + expect(listResult.body.result.total).toEqual(4); }); it('unable to perform operations on workspace by calling saved objects APIs', async () => { const result = await osdTestServer.request @@ -256,7 +259,8 @@ describe('workspace service', () => { }) .expect(200); expect(findResult.body.total).toEqual(0); - expect(listResult.body.result.total).toEqual(1); + // Global and Management workspace will be created by default after workspace list API called. + expect(listResult.body.result.total).toEqual(3); }); }); }); From a6f2f3fe7e8bc0b505917a34faf7bc1d923968c7 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 1 Mar 2024 10:33:31 +0800 Subject: [PATCH 19/34] feat: consume current workspace in saved objects management and saved objects client (#261) * feat: consume current workspace in saved objects management and saved objects client Signed-off-by: SuZhou-Joe * feat: add unit test Signed-off-by: SuZhou-Joe * feat: add unit test for each change Signed-off-by: SuZhou-Joe * fix: update snapshot of unit test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- .../saved_objects/saved_objects_client.ts | 2 +- .../get_sorted_objects_for_export.test.ts | 3 + .../export/get_sorted_objects_for_export.ts | 1 + .../lib/fetch_export_by_type_and_search.ts | 4 +- .../public/lib/fetch_export_objects.test.ts | 34 ++++++++ .../public/lib/fetch_export_objects.ts | 4 +- .../public/lib/get_saved_object_counts.ts | 1 + .../public/lib/import_file.test.ts | 46 +++++++++++ .../public/lib/import_file.ts | 4 +- .../public/lib/resolve_import_errors.test.ts | 65 +++++++++++++++ .../public/lib/resolve_import_errors.ts | 10 ++- .../saved_objects_table.test.tsx.snap | 3 + .../__snapshots__/flyout.test.tsx.snap | 1 + .../objects_table/components/flyout.test.tsx | 24 ++++++ .../objects_table/components/flyout.tsx | 15 +++- .../components/import_mode_control.tsx | 1 + .../saved_objects_table.test.tsx | 81 ++++++++++++++++++- .../objects_table/saved_objects_table.tsx | 60 ++++++++++++-- .../saved_objects_table_page.tsx | 1 + .../server/routes/find.ts | 4 + .../server/routes/scroll_count.ts | 7 ++ src/plugins/workspace/public/plugin.test.ts | 11 ++- src/plugins/workspace/public/plugin.ts | 30 ++++--- src/plugins/workspace/server/plugin.test.ts | 29 +++++++ src/plugins/workspace/server/plugin.ts | 2 + 25 files changed, 410 insertions(+), 33 deletions(-) create mode 100644 src/plugins/saved_objects_management/public/lib/fetch_export_objects.test.ts create mode 100644 src/plugins/saved_objects_management/public/lib/import_file.test.ts create mode 100644 src/plugins/workspace/server/plugin.test.ts diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 44e8be470c32..243b4b7c8434 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -307,7 +307,7 @@ export class SavedObjectsClient { * @returns The result of the create operation containing created saved objects. */ public bulkCreate = ( - objects: SavedObjectsBulkCreateObject[] = [], + objects: Array> = [], options: SavedObjectsBulkCreateOptions = { overwrite: false } ) => { const path = this.getPath(['_bulk_create']); 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 da477604c029..51e987511da2 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 @@ -866,6 +866,9 @@ describe('getSortedObjectsForExport()', () => { ], Object { namespace: undefined, + workspaces: Array [ + foo, + ], }, ], ], 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 660f86846137..0336fc702973 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 @@ -111,6 +111,7 @@ async function fetchObjectsToExport({ } const bulkGetResult = await savedObjectsClient.bulkGet(objects, { namespace, + ...(workspaces ? { workspaces } : {}), }); const erroredObjects = bulkGetResult.saved_objects.filter((obj) => !!obj.error); if (erroredObjects.length) { diff --git a/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts b/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts index e5f716347a76..1af8ac210696 100644 --- a/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts +++ b/src/plugins/saved_objects_management/public/lib/fetch_export_by_type_and_search.ts @@ -34,10 +34,12 @@ export async function fetchExportByTypeAndSearch( http: HttpStart, types: string[], search: string | undefined, - includeReferencesDeep: boolean = false + includeReferencesDeep: boolean = false, + body?: Record ): 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.test.ts b/src/plugins/saved_objects_management/public/lib/fetch_export_objects.test.ts new file mode 100644 index 000000000000..d37db062540c --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/fetch_export_objects.test.ts @@ -0,0 +1,34 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { fetchExportObjects } from './fetch_export_objects'; +import { httpServiceMock } from '../../../../core/public/mocks'; + +describe('fetchExportObjects', () => { + it('make http call with body provided', async () => { + const httpClient = httpServiceMock.createStartContract(); + await fetchExportObjects(httpClient, [], false, { + workspaces: ['foo'], + }); + expect(httpClient.post).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + "/api/saved_objects/_export", + Object { + "body": "{\\"workspaces\\":[\\"foo\\"],\\"objects\\":[],\\"includeReferencesDeep\\":false}", + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + } + `); + }); +}); 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..9039dae2be53 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,6 +34,7 @@ export interface SavedObjectCountOptions { typesToInclude: string[]; namespacesToInclude?: string[]; searchString?: string; + workspaces?: string[]; } export async function getSavedObjectCounts( diff --git a/src/plugins/saved_objects_management/public/lib/import_file.test.ts b/src/plugins/saved_objects_management/public/lib/import_file.test.ts new file mode 100644 index 000000000000..e17494ba2b20 --- /dev/null +++ b/src/plugins/saved_objects_management/public/lib/import_file.test.ts @@ -0,0 +1,46 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { importFile } from './import_file'; +import { httpServiceMock } from '../../../../core/public/mocks'; + +describe('importFile', () => { + it('make http call with body provided', async () => { + const httpClient = httpServiceMock.createStartContract(); + const blob = new Blob(['']); + await importFile(httpClient, new File([blob], 'foo.ndjson'), { + overwrite: true, + createNewCopies: false, + workspaces: ['foo'], + }); + expect(httpClient.post).toMatchInlineSnapshot(` + [MockFunction] { + "calls": Array [ + Array [ + "/api/saved_objects/_import", + Object { + "body": FormData {}, + "headers": Object { + "Content-Type": undefined, + }, + "query": Object { + "overwrite": true, + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ], + "results": Array [ + Object { + "type": "return", + "value": undefined, + }, + ], + } + `); + }); +}); diff --git a/src/plugins/saved_objects_management/public/lib/import_file.ts b/src/plugins/saved_objects_management/public/lib/import_file.ts index 3753a8251e10..bcf1b6911b0f 100644 --- a/src/plugins/saved_objects_management/public/lib/import_file.ts +++ b/src/plugins/saved_objects_management/public/lib/import_file.ts @@ -40,12 +40,12 @@ interface ImportResponse { export async function importFile( http: HttpStart, file: File, - { createNewCopies, overwrite }: ImportMode, + { createNewCopies, overwrite, workspaces }: ImportMode, selectedDataSourceId?: string ) { const formData = new FormData(); formData.append('file', file); - const query = createNewCopies ? { createNewCopies } : { overwrite }; + const query = createNewCopies ? { createNewCopies, workspaces } : { overwrite, workspaces }; if (selectedDataSourceId) { query.dataSourceId = selectedDataSourceId; } diff --git a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.test.ts b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.test.ts index 428a1c56c50e..ee4a684d5702 100644 --- a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.test.ts +++ b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.test.ts @@ -303,4 +303,69 @@ describe('resolveImportErrors', () => { } `); }); + + test('make http calls with workspaces', async () => { + httpMock.post.mockResolvedValueOnce({ + success: false, + successCount: 0, + errors: [{ type: 'a', id: '1', error: { type: 'conflict' } }], + }); + httpMock.post.mockResolvedValueOnce({ + success: true, + successCount: 1, + successResults: [{ type: 'a', id: '1' }], + }); + getConflictResolutions.mockResolvedValueOnce({}); + getConflictResolutions.mockResolvedValueOnce({ + 'a:1': { retry: true, options: { overwrite: true } }, + }); + await resolveImportErrors({ + http: httpMock, + getConflictResolutions, + state: { + importCount: 0, + unmatchedReferences: [{ existingIndexPatternId: '2', newIndexPatternId: '3', list: [] }], + failedImports: [ + { + obj: { type: 'a', id: '1', meta: {} }, + error: { type: 'missing_references', references: [{ type: 'index-pattern', id: '2' }] }, + }, + ], + importMode: { createNewCopies: false, overwrite: false }, + }, + workspaces: ['foo'], + }); + expect(httpMock.post.mock.calls).toMatchInlineSnapshot(` + Array [ + Array [ + "/api/saved_objects/_resolve_import_errors", + Object { + "body": FormData {}, + "headers": Object { + "Content-Type": undefined, + }, + "query": Object { + "workspaces": Array [ + "foo", + ], + }, + }, + ], + Array [ + "/api/saved_objects/_resolve_import_errors", + Object { + "body": FormData {}, + "headers": Object { + "Content-Type": undefined, + }, + "query": Object { + "workspaces": Array [ + "foo", + ], + }, + }, + ], + ] + `); + }); }); diff --git a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts index 585102ee5b8e..2bcaf6e9f6cf 100644 --- a/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts +++ b/src/plugins/saved_objects_management/public/lib/resolve_import_errors.ts @@ -90,12 +90,13 @@ async function callResolveImportErrorsApi( file: File, retries: any, createNewCopies: boolean, - selectedDataSourceId?: string + selectedDataSourceId?: string, + 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 }; if (selectedDataSourceId) { query.dataSourceId = selectedDataSourceId; } @@ -172,6 +173,7 @@ export async function resolveImportErrors({ getConflictResolutions, state, selectedDataSourceId, + workspaces, }: { http: HttpStart; getConflictResolutions: ( @@ -186,6 +188,7 @@ export async function resolveImportErrors({ importMode: { createNewCopies: boolean; overwrite: boolean }; }; selectedDataSourceId: string; + workspaces?: string[]; }) { const retryDecisionCache = new Map(); const replaceReferencesCache = new Map(); @@ -275,7 +278,8 @@ export async function resolveImportErrors({ file!, retries, createNewCopies, - selectedDataSourceId + selectedDataSourceId, + workspaces ); importCount = response.successCount; // reset the success count since we retry all successful results each time failedImports = []; 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 8d04d1182d13..501c4e96b171 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 @@ -231,6 +231,9 @@ exports[`SavedObjectsTable should render normally 1`] = ` "edit": false, "read": true, }, + "workspaces": Object { + "enabled": false, + }, }, "currentAppId$": Observable { "_isScalar": false, 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 7b6604c1657e..fc58df41524a 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 @@ -249,6 +249,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/flyout.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx index 575741708f1e..e5c6732cb5f5 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.test.tsx @@ -184,6 +184,29 @@ describe('Flyout', () => { ); }); + it('should call importFile / resolveImportErrors with workspaces', async () => { + const component = shallowRender({ ...defaultProps, workspaces: ['foo'] }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await component.instance().import(); + expect(importFileMock.mock.calls[0][2]).toMatchInlineSnapshot(` + Object { + "createNewCopies": true, + "overwrite": true, + "workspaces": Array [ + "foo", + ], + } + `); + + await component.instance().resolveImportErrors(); + expect(resolveImportErrorsMock.mock.calls[0][0].workspaces).toEqual(['foo']); + }); + describe('conflicts', () => { beforeEach(() => { importFileMock.mockImplementation(() => ({ @@ -206,6 +229,7 @@ describe('Flyout', () => { }, ], })); + resolveImportErrorsMock.mockClear(); resolveImportErrorsMock.mockImplementation(() => ({ status: 'success', importCount: 1, 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 586a573ffb53..361648881f93 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 @@ -97,6 +97,7 @@ export interface FlyoutProps { hideLocalCluster: boolean; savedObjects: SavedObjectsClientContract; notifications: NotificationsStart; + workspaces?: string[]; } export interface FlyoutState { @@ -189,13 +190,21 @@ export class Flyout extends Component { * Does the initial import of a file, resolveImportErrors then handles errors and retries */ import = async () => { - const { http } = this.props; + const { http, workspaces } = this.props; const { file, importMode, selectedDataSourceId } = this.state; this.setState({ status: 'loading', error: undefined }); // Import the file try { - const response = await importFile(http, file!, importMode, selectedDataSourceId); + const response = await importFile( + http, + file!, + { + ...importMode, + workspaces, + }, + selectedDataSourceId + ); this.setState(processImportResponse(response), () => { // Resolve import errors right away if there's no index patterns to match // This will ask about overwriting each object, etc @@ -251,6 +260,7 @@ export class Flyout extends Component { status: 'loading', loadingMessage: undefined, }); + const { workspaces } = this.props; try { const updatedState = await resolveImportErrors({ @@ -258,6 +268,7 @@ export class Flyout extends Component { state: this.state, getConflictResolutions: this.getConflictResolutions, selectedDataSourceId: this.state.selectedDataSourceId, + workspaces, }); this.setState(updatedState); } catch (e) { diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.tsx index d0c800553996..49974c53b3d7 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/import_mode_control.tsx @@ -52,6 +52,7 @@ export interface ImportModeControlProps { export interface ImportMode { createNewCopies: boolean; overwrite: boolean; + workspaces?: string[]; } const createNewCopiesDisabled = { 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 443026e92964..612ace1587c2 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, @@ -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,13 +401,78 @@ describe('SavedObjectsTable', () => { http, allowedTypes, 'test*', - true + true, + {} ); expect(saveAsMock).toHaveBeenCalledWith(blob, 'export.ndjson'); expect(notifications.toasts.addSuccess).toHaveBeenCalledWith({ title: 'Your file is downloading in the background', }); }); + + it('should make modules call with workspace', async () => { + getSavedObjectCountsMock.mockClear(); + findObjectsMock.mockClear(); + // @ts-expect-error + defaultProps.applications.capabilities.workspaces.enabled = true; + const mockSelectedSavedObjects = [ + { id: '1', type: 'index-pattern' }, + { id: '3', type: 'dashboard' }, + ] as SavedObjectWithMetadata[]; + + const mockSavedObjects = mockSelectedSavedObjects.map((obj) => ({ + _id: obj.id, + _type: obj.type, + _source: {}, + })); + + const mockSavedObjectsClient = { + ...defaultProps.savedObjectsClient, + bulkGet: jest.fn().mockImplementation(() => ({ + savedObjects: mockSavedObjects, + })), + }; + + const workspacesStart = workspacesServiceMock.createStartContract(); + workspacesStart.currentWorkspaceId$.next('foo'); + + const component = shallowRender({ + savedObjectsClient: mockSavedObjectsClient, + workspaces: workspacesStart, + }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + // Set some as selected + component.instance().onSelectionChanged(mockSelectedSavedObjects); + + await component.instance().onExport(true); + await component.instance().onExportAll(); + + expect(fetchExportObjectsMock).toHaveBeenCalledWith(http, mockSelectedSavedObjects, true, { + workspaces: ['foo'], + }); + expect(fetchExportByTypeAndSearchMock).toHaveBeenCalledWith( + http, + ['index-pattern', 'visualization', 'dashboard', 'search'], + undefined, + true, + { + workspaces: ['foo'], + } + ); + expect( + getSavedObjectCountsMock.mock.calls.every((item) => item[1].workspaces[0] === 'foo') + ).toEqual(true); + expect(findObjectsMock.mock.calls.every((item) => item[1].workspaces[0] === 'foo')).toEqual( + true + ); + // @ts-expect-error + defaultProps.applications.capabilities.workspaces.enabled = false; + }); }); describe('import', () => { 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 d82412be03d0..dba9277ecf2d 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,6 +61,7 @@ import { FormattedMessage } from '@osd/i18n/react'; import { SavedObjectsClientContract, SavedObjectsFindOptions, + WorkspacesStart, HttpStart, OverlayStart, NotificationsStart, @@ -106,6 +107,7 @@ export interface SavedObjectsTableProps { savedObjectsClient: SavedObjectsClientContract; indexPatterns: IndexPatternsContract; http: HttpStart; + workspaces: WorkspacesStart; search: DataPublicPluginStart['search']; overlays: OverlayStart; notifications: NotificationsStart; @@ -138,6 +140,8 @@ export interface SavedObjectsTableState { exportAllOptions: ExportAllOption[]; exportAllSelectedOptions: Record; isIncludeReferencesDeepChecked: boolean; + currentWorkspaceId: string | null; + workspaceEnabled: boolean; } export class SavedObjectsTable extends Component { @@ -168,9 +172,35 @@ export class SavedObjectsTable extends Component( + obj: T + ): T | Omit { + const { workspaces, ...others } = obj; + if (workspaces) { + return obj; + } + return others; + } + componentDidMount() { this._isMounted = true; this.fetchSavedObjects(); @@ -190,10 +220,11 @@ export class SavedObjectsTable extends Component 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); @@ -222,10 +253,11 @@ export class SavedObjectsTable extends Component ns.id) || []; if (availableNamespaces.length) { @@ -406,7 +439,14 @@ export class SavedObjectsTable extends Component().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..221d39392842 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_count.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_count.ts @@ -41,6 +41,7 @@ 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())), }), }, }, @@ -55,6 +56,8 @@ export const registerScrollForCountRoute = (router: IRouter) => { perPage: 1000, }; + const requestHasWorkspaces = Array.isArray(req.body.workspaces) && req.body.workspaces.length; + const requestHasNamespaces = Array.isArray(req.body.namespacesToInclude) && req.body.namespacesToInclude.length; @@ -63,6 +66,10 @@ export const registerScrollForCountRoute = (router: IRouter) => { findOptions.namespaces = req.body.namespacesToInclude; } + if (requestHasWorkspaces) { + findOptions.workspaces = req.body.workspaces; + } + if (req.body.searchString) { findOptions.search = `${req.body.searchString}*`; findOptions.searchFields = ['title']; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 61a81fbd95f7..acbb9abe044e 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { of } from 'rxjs'; +import { BehaviorSubject, of } from 'rxjs'; import { waitFor } from '@testing-library/dom'; import { ChromeNavLink } from 'opensearch-dashboards/public'; import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; @@ -149,4 +149,13 @@ describe('Workspace plugin', () => { workspacePlugin.start(coreStart); expect(navLinksService.setNavLinks).toHaveBeenCalledWith(filteredNavLinksMap); }); + + it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { + const workspacePlugin = new WorkspacePlugin(); + const coreStart = coreMock.createStart(); + coreStart.chrome.navLinks.getAllNavLinks$.mockReturnValueOnce(new BehaviorSubject([])); + workspacePlugin.start(coreStart); + coreStart.workspaces.currentWorkspaceId$.next('foo'); + expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); + }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index b3881e0eb956..4c97070bd781 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -8,7 +8,16 @@ import { combineLatest } from 'rxjs'; import { map } from 'rxjs/operators'; import { i18n } from '@osd/i18n'; import { featureMatchesConfig } from './utils'; -import { AppMountParameters, AppNavLinkStatus, ChromeNavLink, CoreSetup, CoreStart, Plugin, WorkspaceObject, DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { + AppMountParameters, + AppNavLinkStatus, + ChromeNavLink, + CoreSetup, + CoreStart, + Plugin, + WorkspaceObject, + DEFAULT_APP_CATEGORIES, +} from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { WorkspaceClient } from './workspace_client'; @@ -19,15 +28,6 @@ type WorkspaceAppType = (params: AppMountParameters, services: Services) => () = export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; private currentWorkspaceSubscription?: Subscription; - private _changeSavedObjectCurrentWorkspace() { - if (this.coreStart) { - return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { - if (currentWorkspaceId) { - this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); - } - }); - } - } private getWorkspaceIdFromURL(): string | null { return getWorkspaceIdFromUrl(window.location.href); } @@ -77,6 +77,16 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }); } + private _changeSavedObjectCurrentWorkspace() { + if (this.coreStart) { + return this.coreStart.workspaces.currentWorkspaceId$.subscribe((currentWorkspaceId) => { + if (currentWorkspaceId) { + this.coreStart?.savedObjects.client.setCurrentWorkspace(currentWorkspaceId); + } + }); + } + } + public async setup(core: CoreSetup) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts new file mode 100644 index 000000000000..32b4b23a9f59 --- /dev/null +++ b/src/plugins/workspace/server/plugin.test.ts @@ -0,0 +1,29 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { coreMock } from '../../../core/server/mocks'; +import { WorkspacePlugin } from './plugin'; + +describe('Workspace server plugin', () => { + it('#setup', async () => { + let value; + const setupMock = coreMock.createSetup(); + const initializerContextConfigMock = coreMock.createPluginInitializerContext({ + workspace: { + enabled: true, + }, + }); + setupMock.capabilities.registerProvider.mockImplementationOnce((fn) => (value = fn())); + const workspacePlugin = new WorkspacePlugin(initializerContextConfigMock); + await workspacePlugin.setup(setupMock); + expect(value).toMatchInlineSnapshot(` + Object { + "workspaces": Object { + "enabled": true, + }, + } + `); + }); +}); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 20ae1cc8e172..b7fd9017d798 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -64,6 +64,8 @@ export class WorkspacePlugin implements Plugin ({ workspaces: { enabled: true } })); + return { client: this.client, }; From 0644dcb71d8ebbf8df122dcb5675769ba66316a4 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 4 Mar 2024 16:28:16 +0800 Subject: [PATCH 20/34] feat: backport pr 5949 to workspace-pr-inte (#275) * feat: merge 5949 Signed-off-by: SuZhou-Joe * feat: update snapshot Signed-off-by: SuZhou-Joe * fix: bootstrap error Signed-off-by: SuZhou-Joe * fix: unit test error Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe --- CHANGELOG.md | 9 ++ .../build_active_mappings.test.ts.snap | 8 -- .../migrations/core/build_active_mappings.ts | 3 - .../migrations/core/index_migrator.test.ts | 12 --- ...pensearch_dashboards_migrator.test.ts.snap | 4 - .../service/lib/repository.test.js | 93 ------------------- .../saved_objects/service/lib/repository.ts | 4 +- .../service/lib/search_dsl/query_params.ts | 11 ++- .../service/saved_objects_client.ts | 4 + src/plugins/workspace/server/plugin.ts | 9 ++ 10 files changed, 33 insertions(+), 124 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0adcbc43f515..df9fe2cf3497 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -117,6 +117,15 @@ Inspired from [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) - [Discover] Enhanced the data source selector with added sorting functionality ([#5609](https://github.com/opensearch-project/OpenSearch-Dashboards/issues/5609)) - [Multiple Datasource] Add datasource picker component and use it in devtools and tutorial page when multiple datasource is enabled ([#5756](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5756)) - [Multiple Datasource] Add datasource picker to import saved object flyout when multiple data source is enabled ([#5781](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5781)) +- [Multiple Datasource] Add interfaces to register add-on authentication method from plug-in module ([#5851](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5851)) +- [Multiple Datasource] Able to Hide "Local Cluster" option from datasource DropDown ([#5827](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5827)) +- [Multiple Datasource] Add api registry and allow it to be added into client config in data source plugin ([#5895](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5895)) +- [Multiple Datasource] Concatenate data source name with index pattern name and change delimiter to double colon ([#5907](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5907)) +- [Multiple Datasource] Refactor client and legacy client to use authentication registry ([#5881](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5881)) +- [Multiple Datasource] Improved error handling for the search API when a null value is passed for the dataSourceId ([#5882](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5882)) +- [Multiple Datasource] Hide/Show authentication method in multi data source plugin based on configuration ([#5916](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5916)) +- [[Dynamic Configurations] Add support for dynamic application configurations ([#5855](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5855)) +- [Workspace] Optional workspaces params in repository ([#5949](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/5949)) ### 🐛 Bug Fixes 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 09e8ad8b5407..6f67893104e7 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 @@ -14,7 +14,6 @@ Object { "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", - "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -112,9 +111,6 @@ Object { "updated_at": Object { "type": "date", }, - "workspaces": Object { - "type": "keyword", - }, }, } `; @@ -134,7 +130,6 @@ Object { "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", - "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -249,9 +244,6 @@ 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 fac64bf78b01..efedd9351a22 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 @@ -219,9 +219,6 @@ function defaultMapping(): IndexMapping { }, }, }, - workspaces: { - type: 'keyword', - }, permissions: { properties: { read: 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 de2e14572278..f22234fc8996 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 @@ -231,7 +231,6 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', - workspaces: '2f4316de49999235636386fe51dc06c1', }, }, properties: { @@ -242,9 +241,6 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, - workspaces: { - type: 'keyword', - }, permissions: { properties: { library_read: { @@ -387,7 +383,6 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', - workspaces: '2f4316de49999235636386fe51dc06c1', }, }, properties: { @@ -399,9 +394,6 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, - workspaces: { - type: 'keyword', - }, permissions: { properties: { library_read: { @@ -487,7 +479,6 @@ describe('IndexMigrator', () => { references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', - workspaces: '2f4316de49999235636386fe51dc06c1', }, }, properties: { @@ -499,9 +490,6 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, - workspaces: { - type: 'keyword', - }, permissions: { properties: { library_read: { 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 2748ad2eaf6a..5e39af788d79 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 @@ -14,7 +14,6 @@ Object { "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", - "workspaces": "2f4316de49999235636386fe51dc06c1", }, }, "dynamic": "strict", @@ -120,9 +119,6 @@ Object { "updated_at": Object { "type": "date", }, - "workspaces": Object { - "type": "keyword", - }, }, } `; 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 5189c754a326..3dfa293c0dc0 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -941,74 +941,6 @@ describe('SavedObjectsRepository', () => { const expectedError = expectErrorResult(obj3, { message: JSON.stringify(opensearchError) }); await bulkCreateError(obj3, opensearchError, expectedError); }); - - it(`returns error when there is a conflict with an existing saved object according to workspaces`, async () => { - const obj = { ...obj3, workspaces: ['foo'] }; - const response1 = { - status: 200, - docs: [ - { - found: true, - _id: `${obj1.type}:${obj1.id}`, - _source: { - type: obj1.type, - workspaces: ['bar'], - }, - }, - { - found: true, - _id: `${obj.type}:${obj.id}`, - _source: { - type: obj.type, - workspaces: obj.workspaces, - }, - }, - { - found: true, - _id: `${obj2.type}:${obj2.id}`, - _source: { - type: obj2.type, - }, - }, - ], - }; - client.mget.mockResolvedValueOnce( - opensearchClientMock.createSuccessTransportRequestPromise(response1) - ); - const response2 = getMockBulkCreateResponse([obj1, obj, obj2]); - client.bulk.mockResolvedValueOnce( - opensearchClientMock.createSuccessTransportRequestPromise(response2) - ); - - const options = { overwrite: true, workspaces: ['bar'] }; - const result = await savedObjectsRepository.bulkCreate([obj1, obj, obj2], options); - expect(client.bulk).toHaveBeenCalled(); - expect(client.mget).toHaveBeenCalled(); - - 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() - ); - const body2 = [...expectObjArgs(obj1)]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body: body2 }), - expect.anything() - ); - expect(result).toEqual({ - saved_objects: [ - expectSuccess(obj1), - expectErrorConflict(obj, { metadata: { isNotOverwritable: true } }), - expectErrorConflict(obj2, { metadata: { isNotOverwritable: true } }), - ], - }); - }); }); describe('migration', () => { @@ -2289,16 +2221,6 @@ describe('SavedObjectsRepository', () => { expect.anything() ); }); - - it(`accepts permissions property`, async () => { - await createSuccess(type, attributes, { id, permissions }); - expect(client.create).toHaveBeenCalledWith( - expect.objectContaining({ - body: expect.objectContaining({ permissions }), - }), - expect.anything() - ); - }); }); describe('errors', () => { @@ -2359,21 +2281,6 @@ describe('SavedObjectsRepository', () => { expect(client.get).toHaveBeenCalled(); }); - it(`throws error when there is a conflict with an existing workspaces saved object`, async () => { - const response = getMockGetResponse({ workspaces: ['foo'], id }); - client.get.mockResolvedValueOnce( - opensearchClientMock.createSuccessTransportRequestPromise(response) - ); - await expect( - savedObjectsRepository.create('dashboard', attributes, { - id, - overwrite: true, - workspaces: ['bar'], - }) - ).rejects.toThrowError(createConflictError('dashboard', id)); - expect(client.get).toHaveBeenCalled(); - }); - it.todo(`throws when automatic index creation fails`); it.todo(`throws when an unexpected failure occurs`); diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 46a3d62e6e82..42d45ea73e16 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -292,8 +292,8 @@ export class SavedObjectsRepository { migrationVersion, updated_at: time, ...(Array.isArray(references) && { references }), - ...(Array.isArray(workspaces) && { workspaces }), ...(permissions && { permissions }), + ...(Array.isArray(workspaces) && { workspaces }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -476,8 +476,8 @@ export class SavedObjectsRepository { updated_at: time, references: object.references || [], originId: object.originId, - ...(savedObjectWorkspaces && { workspaces: savedObjectWorkspaces }), ...(object.permissions && { permissions: object.permissions }), + ...(savedObjectWorkspaces && { workspaces: savedObjectWorkspaces }), }) as SavedObjectSanitizedDoc ), }; 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 186457145103..0d056f624bf1 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 @@ -127,12 +127,19 @@ function getClauseForType( }, }; } + /** * Gets the clause that will filter for the workspace. */ function getClauseForWorkspace(workspace: string) { - if (!workspace) { - return {}; + if (workspace === '*') { + return { + bool: { + must: { + match_all: {}, + }, + }, + }; } return { 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 1b268e033d97..7ebe525df0ed 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -71,6 +71,10 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { initialNamespaces?: string[]; /** permission control describe by ACL object */ permissions?: Permissions; + /** + * workspaces the new created objects belong to + */ + workspaces?: string[]; } /** diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index b7fd9017d798..145aacaf9bb8 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -16,6 +16,8 @@ import { registerRoutes } from './routes'; import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; +import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; +import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; export class WorkspacePlugin implements Plugin { private readonly logger: Logger; @@ -57,6 +59,13 @@ export class WorkspacePlugin implements Plugin Date: Wed, 6 Mar 2024 09:55:50 +0800 Subject: [PATCH 21/34] feat: remove useless code (#280) Signed-off-by: SuZhou-Joe --- .../lib/integration_tests/repository.test.ts | 310 ------------------ 1 file changed, 310 deletions(-) delete mode 100644 src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts diff --git a/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts b/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts deleted file mode 100644 index b601de985dc0..000000000000 --- a/src/core/server/saved_objects/service/lib/integration_tests/repository.test.ts +++ /dev/null @@ -1,310 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ -import { SavedObject } from 'src/core/types'; -import { isEqual } from 'lodash'; -import * as osdTestServer from '../../../../../test_helpers/osd_server'; -import { Readable } from 'stream'; - -const dashboard: Omit = { - type: 'dashboard', - attributes: {}, - references: [], -}; - -describe('repository integration test', () => { - let root: ReturnType; - let opensearchServer: osdTestServer.TestOpenSearchUtils; - beforeAll(async () => { - const { startOpenSearch, startOpenSearchDashboards } = osdTestServer.createTestServers({ - adjustTimeout: (t: number) => jest.setTimeout(t), - }); - opensearchServer = await startOpenSearch(); - const startOSDResp = await startOpenSearchDashboards(); - root = startOSDResp.root; - }, 30000); - afterAll(async () => { - await root.shutdown(); - await opensearchServer.stop(); - }); - - const deleteItem = async (object: Pick) => { - expect( - [200, 404].includes( - (await osdTestServer.request.delete(root, `/api/saved_objects/${object.type}/${object.id}`)) - .statusCode - ) - ); - }; - - const getItem = async (object: Pick) => { - return await osdTestServer.request - .get(root, `/api/saved_objects/${object.type}/${object.id}`) - .expect(200); - }; - - const clearFooAndBar = async () => { - await deleteItem({ - type: dashboard.type, - id: 'foo', - }); - await deleteItem({ - type: dashboard.type, - id: 'bar', - }); - }; - - describe('workspace related CRUD', () => { - it('create', async () => { - const createResult = await osdTestServer.request - .post(root, `/api/saved_objects/${dashboard.type}`) - .send({ - attributes: dashboard.attributes, - workspaces: ['foo'], - }) - .expect(200); - - expect(createResult.body.workspaces).toEqual(['foo']); - await deleteItem({ - type: dashboard.type, - id: createResult.body.id, - }); - }); - - it('create-with-override', async () => { - const createResult = await osdTestServer.request - .post(root, `/api/saved_objects/${dashboard.type}`) - .send({ - attributes: dashboard.attributes, - workspaces: ['foo'], - }) - .expect(200); - - await osdTestServer.request - .post(root, `/api/saved_objects/${dashboard.type}/${createResult.body.id}?overwrite=true`) - .send({ - attributes: dashboard.attributes, - workspaces: ['bar'], - }) - .expect(409); - - await deleteItem({ - type: dashboard.type, - id: createResult.body.id, - }); - }); - - it('bulk create', async () => { - await clearFooAndBar(); - const createResultFoo = await osdTestServer.request - .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) - .send([ - { - ...dashboard, - id: 'foo', - }, - ]) - .expect(200); - - const createResultBar = await osdTestServer.request - .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) - .send([ - { - ...dashboard, - id: 'bar', - }, - ]) - .expect(200); - - expect((createResultFoo.body.saved_objects as any[]).some((item) => item.error)).toEqual( - false - ); - expect( - (createResultFoo.body.saved_objects as any[]).every((item) => - isEqual(item.workspaces, ['foo']) - ) - ).toEqual(true); - expect((createResultBar.body.saved_objects as any[]).some((item) => item.error)).toEqual( - false - ); - expect( - (createResultBar.body.saved_objects as any[]).every((item) => - isEqual(item.workspaces, ['bar']) - ) - ).toEqual(true); - await Promise.all( - [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => - deleteItem({ - type: item.type, - id: item.id, - }) - ) - ); - }); - - it('bulk create with conflict', async () => { - await clearFooAndBar(); - const createResultFoo = await osdTestServer.request - .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) - .send([ - { - ...dashboard, - id: 'foo', - }, - ]) - .expect(200); - - const createResultBar = await osdTestServer.request - .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) - .send([ - { - ...dashboard, - id: 'bar', - }, - ]) - .expect(200); - - /** - * overwrite with workspaces - */ - const overwriteWithWorkspacesResult = await osdTestServer.request - .post(root, `/api/saved_objects/_bulk_create?overwrite=true&workspaces=foo`) - .send([ - { - ...dashboard, - id: 'bar', - }, - { - ...dashboard, - id: 'foo', - attributes: { - title: 'foo', - }, - }, - ]) - .expect(200); - - expect(overwriteWithWorkspacesResult.body.saved_objects[0].error.statusCode).toEqual(409); - expect(overwriteWithWorkspacesResult.body.saved_objects[1].attributes.title).toEqual('foo'); - expect(overwriteWithWorkspacesResult.body.saved_objects[1].workspaces).toEqual(['foo']); - - await Promise.all( - [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => - deleteItem({ - type: item.type, - id: item.id, - }) - ) - ); - }); - - it('checkConflicts when importing ndjson', async () => { - await clearFooAndBar(); - const createResultFoo = await osdTestServer.request - .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) - .send([ - { - ...dashboard, - id: 'foo', - }, - ]) - .expect(200); - - const createResultBar = await osdTestServer.request - .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) - .send([ - { - ...dashboard, - id: 'bar', - }, - ]) - .expect(200); - - const getResultFoo = await getItem({ - type: dashboard.type, - id: 'foo', - }); - const getResultBar = await getItem({ - type: dashboard.type, - id: 'bar', - }); - - const readableStream = new Readable(); - readableStream.push( - `Content-Disposition: form-data; name="file"; filename="tmp.ndjson"\r\n\r\n` - ); - readableStream.push( - [JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n') - ); - readableStream.push(null); - - /** - * import with workspaces when conflicts - */ - const importWithWorkspacesResult = await osdTestServer.request - .post(root, `/api/saved_objects/_import?workspaces=foo&overwrite=false`) - .attach( - 'file', - Buffer.from( - [JSON.stringify(getResultFoo.body), JSON.stringify(getResultBar.body)].join('\n'), - 'utf-8' - ), - 'tmp.ndjson' - ) - .expect(200); - - expect(importWithWorkspacesResult.body.success).toEqual(false); - expect(importWithWorkspacesResult.body.errors.length).toEqual(1); - expect(importWithWorkspacesResult.body.errors[0].id).toEqual('foo'); - expect(importWithWorkspacesResult.body.errors[0].error.type).toEqual('conflict'); - - await Promise.all( - [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => - deleteItem({ - type: item.type, - id: item.id, - }) - ) - ); - }); - - it('find by workspaces', async () => { - const createResultFoo = await osdTestServer.request - .post(root, `/api/saved_objects/_bulk_create?workspaces=foo`) - .send([ - { - ...dashboard, - id: 'foo', - }, - ]) - .expect(200); - - const createResultBar = await osdTestServer.request - .post(root, `/api/saved_objects/_bulk_create?workspaces=bar`) - .send([ - { - ...dashboard, - id: 'bar', - }, - ]) - .expect(200); - - const findResult = await osdTestServer.request - .get(root, `/api/saved_objects/_find?workspaces=bar&type=${dashboard.type}`) - .expect(200); - - expect(findResult.body.total).toEqual(1); - expect(findResult.body.saved_objects[0].workspaces).toEqual(['bar']); - - await Promise.all( - [...createResultFoo.body.saved_objects, ...createResultBar.body.saved_objects].map((item) => - deleteItem({ - type: item.type, - id: item.id, - }) - ) - ); - }); - }); -}); From e0c9e8747a9d16a617437e84aecec760dc3d14f2 Mon Sep 17 00:00:00 2001 From: tygao Date: Wed, 6 Mar 2024 14:21:44 +0800 Subject: [PATCH 22/34] add permission control service for saved objects and workspace saved objects client wrapper (#230) * feat: add basic workspace saved objects client wrapper Signed-off-by: Lin Wang * feat: add unit test (#2) Signed-off-by: SuZhou-Joe * feat: update client wrapper Signed-off-by: tygao * feat: init permission control in workspace plugin Signed-off-by: Lin Wang * Support disable permission check on workspace (#228) * support disable permission check for workspace Signed-off-by: Hailong Cui * fix typos Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui * feat: add ACLSearchParams consumer in repository (#3) Signed-off-by: SuZhou-Joe * fix: ACLSearchParams missing in search dsl Signed-off-by: Lin Wang * test: add integration test for workspace saved objects client wrapper Signed-off-by: Lin Wang * style: add empty line under license Signed-off-by: Lin Wang * test: enable workspace permission control for integration tests Signed-off-by: Lin Wang * feat: add workspace into includeHiddenTypes (#249) * feat: add workspace into includeHiddenTypes of client wrapper and permission control client Signed-off-by: SuZhou-Joe * fix: hiddenType side effect Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe * fix workspace client wrapper integration tests Signed-off-by: Lin Wang * add permissions fields to workspace CRUD APIs Signed-off-by: Lin Wang * Move WorkspacePermissionMode inside workspace plugin Signed-off-by: Lin Wang * Address pr comments Signed-off-by: Lin Wang * Remove ACLSearchParams in public SavedObjectsFindOptions Signed-off-by: Lin Wang * Remove lodash and Add default permissionModes Signed-off-by: Lin Wang * feat: address concerns on ensureRawRequest (#4) * feat: address concerns on ensureRawRequest Signed-off-by: SuZhou-Joe * feat: add check for empty array Signed-off-by: SuZhou-Joe * feat: make find api backward compatible Signed-off-by: SuZhou-Joe * feat: remove useless code Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe * Update annotations and error Signed-off-by: Lin Wang * Add unit tests for worksapce saved objects client wrapper Signed-off-by: Lin Wang * Remove getPrincipalsOfObjects in permission Signed-off-by: Lin Wang * Fix permissionEnabled flag missed in workspace plugin setup test Signed-off-by: Lin Wang * Change back to Not Authorized error Signed-off-by: Lin Wang * Fix unit tests for query_params and plugin setup Signed-off-by: Lin Wang * Fix unittests in workspace server utils Signed-off-by: Lin Wang * feat: add workspacesSearchOperators to decouple ACLSearchParams Signed-off-by: SuZhou-Joe * feat: update test cases Signed-off-by: SuZhou-Joe * feat: optimize test cases Signed-off-by: SuZhou-Joe * feat: optimize comment Signed-off-by: SuZhou-Joe * feat: omit defaultSearchOperator in public savedobjetcs client Signed-off-by: SuZhou-Joe * feat: omit workspacesSearchOperator field Signed-off-by: SuZhou-Joe --------- Signed-off-by: Lin Wang Signed-off-by: SuZhou-Joe Signed-off-by: tygao Signed-off-by: Hailong Cui Co-authored-by: Lin Wang Co-authored-by: SuZhou-Joe Co-authored-by: Hailong Cui --- src/core/public/index.ts | 1 + .../saved_objects/saved_objects_client.ts | 6 +- src/core/server/index.ts | 5 + src/core/server/saved_objects/index.ts | 8 + .../saved_objects/service/lib/repository.ts | 4 + .../lib/search_dsl/query_params.test.ts | 125 ++++ .../service/lib/search_dsl/query_params.ts | 80 ++- .../service/lib/search_dsl/search_dsl.ts | 7 + src/core/server/saved_objects/types.ts | 10 + src/plugins/workspace/common/constants.ts | 7 + src/plugins/workspace/config.ts | 5 +- .../workspace/public/workspace_client.test.ts | 69 ++ .../workspace/public/workspace_client.ts | 20 +- .../server/integration_tests/routes.test.ts | 70 ++ .../server/permission_control/client.mock.ts | 12 + .../server/permission_control/client.test.ts | 123 ++++ .../server/permission_control/client.ts | 185 ++++++ src/plugins/workspace/server/plugin.test.ts | 4 +- src/plugins/workspace/server/plugin.ts | 44 +- src/plugins/workspace/server/routes/index.ts | 59 +- .../workspace/server/saved_objects/index.ts | 1 + ...space_saved_objects_client_wrapper.test.ts | 528 ++++++++++++++++ ...space_saved_objects_client_wrapper.test.ts | 597 ++++++++++++++++++ .../workspace_saved_objects_client_wrapper.ts | 549 ++++++++++++++++ src/plugins/workspace/server/types.ts | 14 + src/plugins/workspace/server/utils.test.ts | 53 +- src/plugins/workspace/server/utils.ts | 39 ++ 27 files changed, 2600 insertions(+), 25 deletions(-) create mode 100644 src/plugins/workspace/server/permission_control/client.mock.ts create mode 100644 src/plugins/workspace/server/permission_control/client.test.ts create mode 100644 src/plugins/workspace/server/permission_control/client.ts create mode 100644 src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts create mode 100644 src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts create mode 100644 src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 9b545049cad4..993618c08fe9 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -105,6 +105,7 @@ export { StringValidationRegex, StringValidationRegexString, WorkspaceObject, + WorkspaceAttribute, } from '../types'; export { diff --git a/src/core/public/saved_objects/saved_objects_client.ts b/src/core/public/saved_objects/saved_objects_client.ts index 243b4b7c8434..f415e9c58596 100644 --- a/src/core/public/saved_objects/saved_objects_client.ts +++ b/src/core/public/saved_objects/saved_objects_client.ts @@ -45,7 +45,11 @@ import { HttpFetchOptions, HttpSetup } from '../http'; type SavedObjectsFindOptions = Omit< SavedObjectFindOptionsServer, - 'sortOrder' | 'rootSearchFields' | 'typeToNamespacesMap' + | 'sortOrder' + | 'rootSearchFields' + | 'typeToNamespacesMap' + | 'ACLSearchParams' + | 'workspacesSearchOperator' >; type PromiseType> = T extends Promise ? U : never; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 736f12ab28fe..f3edadf21895 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -322,6 +322,11 @@ export { importSavedObjectsFromStream, resolveSavedObjectsImportErrors, SavedObjectsDeleteByWorkspaceOptions, + ACL, + Principals, + TransformedPermission, + PrincipalType, + Permissions, } from './saved_objects'; export { 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/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 42d45ea73e16..5a9e0696e840 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -823,6 +823,8 @@ export class SavedObjectsRepository { filter, preference, workspaces, + workspacesSearchOperator, + ACLSearchParams, } = options; if (!type && !typeToNamespacesMap) { @@ -897,6 +899,8 @@ export class SavedObjectsRepository { hasReference, kueryNode, workspaces, + workspacesSearchOperator, + ACLSearchParams, }), }, }; 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 a47bc27fcd92..5af816a1d8f5 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 @@ -646,6 +646,131 @@ describe('#getQueryParams', () => { }); }); }); + + describe('when using ACLSearchParams search', () => { + it('no ACLSearchParams provided', () => { + const result: Result = getQueryParams({ + registry, + ACLSearchParams: {}, + }); + expect(result.query.bool.filter[1]).toEqual(undefined); + }); + + it('workspacesSearchOperator prvided as "OR"', () => { + const result: Result = getQueryParams({ + registry, + workspaces: ['foo'], + workspacesSearchOperator: 'OR', + }); + expect(result.query.bool.filter[1]).toEqual({ + bool: { + should: [ + { + bool: { + must_not: [ + { + exists: { + field: 'workspaces', + }, + }, + { + exists: { + field: 'permissions', + }, + }, + ], + }, + }, + { + bool: { + minimum_should_match: 1, + should: [ + { + bool: { + must: [ + { + term: { + workspaces: 'foo', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }); + }); + + it('principals and permissionModes provided in ACLSearchParams', () => { + const result: Result = getQueryParams({ + registry, + ACLSearchParams: { + principals: { + users: ['user-foo'], + groups: ['group-foo'], + }, + permissionModes: ['read'], + }, + }); + expect(result.query.bool.filter[1]).toEqual({ + bool: { + should: [ + { + bool: { + must_not: [ + { + exists: { + field: 'workspaces', + }, + }, + { + exists: { + field: 'permissions', + }, + }, + ], + }, + }, + { + bool: { + filter: [ + { + bool: { + should: [ + { + terms: { + 'permissions.read.users': ['user-foo'], + }, + }, + { + term: { + 'permissions.read.users': '*', + }, + }, + { + terms: { + 'permissions.read.groups': ['group-foo'], + }, + }, + { + term: { + 'permissions.read.groups': '*', + }, + }, + ], + }, + }, + ], + }, + }, + ], + }, + }); + }); + }); }); describe('namespaces property', () => { diff --git a/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts b/src/core/server/saved_objects/service/lib/search_dsl/query_params.ts index 0d056f624bf1..b8fd28fe46c2 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 @@ -34,6 +34,8 @@ 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 @@ -187,6 +189,8 @@ interface QueryParams { hasReference?: HasReferenceQueryParams; kueryNode?: KueryNode; workspaces?: string[]; + workspacesSearchOperator?: 'AND' | 'OR'; + ACLSearchParams?: SavedObjectsFindOptions['ACLSearchParams']; } export function getClauseForReference(reference: HasReferenceQueryParams) { @@ -244,6 +248,8 @@ export function getQueryParams({ hasReference, kueryNode, workspaces, + workspacesSearchOperator = 'AND', + ACLSearchParams, }: QueryParams) { const types = getTypes( registry, @@ -268,17 +274,6 @@ 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({ @@ -300,6 +295,69 @@ export function getQueryParams({ } } + const ACLSearchParamsShouldClause: any = []; + + if (ACLSearchParams) { + if (ACLSearchParams.permissionModes?.length && ACLSearchParams.principals) { + const permissionDSL = ACL.generateGetPermittedSavedObjectsQueryDSL( + ACLSearchParams.permissionModes, + ACLSearchParams.principals + ); + ACLSearchParamsShouldClause.push(permissionDSL.query); + } + } + + if (workspaces?.length) { + if (workspacesSearchOperator === 'OR') { + ACLSearchParamsShouldClause.push({ + bool: { + should: workspaces.map((workspace) => { + return getClauseForWorkspace(workspace); + }), + minimum_should_match: 1, + }, + }); + } else { + bool.filter.push({ + bool: { + should: workspaces.map((workspace) => { + return getClauseForWorkspace(workspace); + }), + minimum_should_match: 1, + }, + }); + } + } + + if (ACLSearchParamsShouldClause.length) { + bool.filter.push({ + bool: { + should: [ + /** + * Return those objects without workspaces field and permissions field to keep find API backward compatible + */ + { + bool: { + must_not: [ + { + exists: { + field: 'workspaces', + }, + }, + { + exists: { + field: 'permissions', + }, + }, + ], + }, + }, + ...ACLSearchParamsShouldClause, + ], + }, + }); + } + return { query: { bool } }; } 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 df6109eb9d0a..fa4311576638 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; @@ -53,6 +54,8 @@ interface GetSearchDslOptions { }; kueryNode?: KueryNode; workspaces?: string[]; + workspacesSearchOperator?: 'AND' | 'OR'; + ACLSearchParams?: SavedObjectsFindOptions['ACLSearchParams']; } export function getSearchDsl( @@ -73,6 +76,8 @@ export function getSearchDsl( hasReference, kueryNode, workspaces, + workspacesSearchOperator, + ACLSearchParams, } = options; if (!type) { @@ -96,6 +101,8 @@ export function getSearchDsl( hasReference, kueryNode, workspaces, + workspacesSearchOperator, + ACLSearchParams, }), ...getSortingParams(mappings, type, sortField, sortOrder), }; diff --git a/src/core/server/saved_objects/types.ts b/src/core/server/saved_objects/types.ts index 4ab6978a3dc1..d21421dbe253 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; @@ -112,6 +113,15 @@ export interface SavedObjectsFindOptions { preference?: string; /** If specified, will only retrieve objects that are in the workspaces */ workspaces?: string[]; + /** By default the operator will be 'AND' */ + workspacesSearchOperator?: 'AND' | 'OR'; + /** + * The params here will be combined with bool clause and is used for filtering with ACL structure. + */ + ACLSearchParams?: { + principals?: Principals; + permissionModes?: string[]; + }; } /** diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 2aa86aed2e44..fe702116b8ef 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -9,3 +9,10 @@ export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; export const WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace_conflict_control'; + +export enum WorkspacePermissionMode { + Read = 'read', + Write = 'write', + LibraryRead = 'library_read', + LibraryWrite = 'library_write', +} diff --git a/src/plugins/workspace/config.ts b/src/plugins/workspace/config.ts index 79412f5c02ee..70c87ac00cfc 100644 --- a/src/plugins/workspace/config.ts +++ b/src/plugins/workspace/config.ts @@ -7,6 +7,9 @@ import { schema, TypeOf } from '@osd/config-schema'; export const configSchema = schema.object({ enabled: schema.boolean({ defaultValue: false }), + permission: schema.object({ + enabled: schema.boolean({ defaultValue: true }), + }), }); -export type ConfigSchema = TypeOf; +export type WorkspacePluginConfigType = TypeOf; diff --git a/src/plugins/workspace/public/workspace_client.test.ts b/src/plugins/workspace/public/workspace_client.test.ts index c18ed3db64e7..9fe901fecc88 100644 --- a/src/plugins/workspace/public/workspace_client.test.ts +++ b/src/plugins/workspace/public/workspace_client.test.ts @@ -3,6 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { WorkspacePermissionMode } from '../common/constants'; import { httpServiceMock, workspacesServiceMock } from '../../../core/public/mocks'; import { WorkspaceClient } from './workspace_client'; @@ -104,6 +105,40 @@ describe('#WorkspaceClient', () => { }); }); + it('#create with permissions', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + name: 'foo', + workspaces: [], + }, + }); + await workspaceClient.create( + { + name: 'foo', + }, + [{ type: 'user', userId: 'foo', modes: [WorkspacePermissionMode.LibraryWrite] }] + ); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces', { + method: 'POST', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + permissions: [ + { type: 'user', userId: 'foo', modes: [WorkspacePermissionMode.LibraryWrite] }, + ], + }), + }); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/_list', { + method: 'POST', + body: JSON.stringify({ + perPage: 999, + }), + }); + }); + it('#delete', async () => { const { workspaceClient, httpSetupMock } = getWorkspaceClient(); httpSetupMock.fetch.mockResolvedValue({ @@ -209,4 +244,38 @@ describe('#WorkspaceClient', () => { }); expect(workspaceMock.workspaceList$.getValue()).toEqual([]); }); + + it('#update with permissions', async () => { + const { workspaceClient, httpSetupMock } = getWorkspaceClient(); + httpSetupMock.fetch.mockResolvedValue({ + success: true, + result: { + workspaces: [], + }, + }); + await workspaceClient.update( + 'foo', + { + name: 'foo', + }, + [{ type: 'user', userId: 'foo', modes: [WorkspacePermissionMode.LibraryWrite] }] + ); + expect(httpSetupMock.fetch).toBeCalledWith('/api/workspaces/foo', { + method: 'PUT', + body: JSON.stringify({ + attributes: { + name: 'foo', + }, + permissions: [ + { type: 'user', userId: 'foo', modes: [WorkspacePermissionMode.LibraryWrite] }, + ], + }), + }); + 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 index dc07a83ab1bd..f7a3313bdd06 100644 --- a/src/plugins/workspace/public/workspace_client.ts +++ b/src/plugins/workspace/public/workspace_client.ts @@ -11,6 +11,7 @@ import { WorkspaceAttribute, WorkspacesSetup, } from '../../../core/public'; +import { WorkspacePermissionMode } from '../common/constants'; const WORKSPACES_API_BASE_URL = '/api/workspaces'; @@ -30,6 +31,15 @@ type IResponse = error?: string; }; +type WorkspacePermissionItem = { + modes: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Read + | WorkspacePermissionMode.Write + >; +} & ({ type: 'user'; userId: string } | { type: 'group'; group: string }); + interface WorkspaceFindOptions { page?: number; perPage?: number; @@ -183,14 +193,16 @@ export class WorkspaceClient { * @returns {Promise>>} id of the new created workspace */ public async create( - attributes: Omit - ): Promise>> { + attributes: Omit, + permissions?: WorkspacePermissionItem[] + ): Promise> { const path = this.getPath(); const result = await this.safeFetch(path, { method: 'POST', body: JSON.stringify({ attributes, + permissions, }), }); @@ -268,11 +280,13 @@ export class WorkspaceClient { */ public async update( id: string, - attributes: Partial + attributes: Partial, + permissions?: WorkspacePermissionItem[] ): Promise> { const path = this.getPath(id); const body = { attributes, + permissions, }; const result = await this.safeFetch(path, { diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 21d6f155a927..caec12ad78dc 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -6,6 +6,7 @@ import { WorkspaceAttribute } from 'src/core/types'; import * as osdTestServer from '../../../../core/test_helpers/osd_server'; import { WORKSPACE_TYPE } from '../../../../core/server'; +import { WorkspacePermissionItem } from '../types'; const omitId = (object: T): Omit => { const { id, ...others } = object; @@ -29,6 +30,9 @@ describe('workspace service', () => { osd: { workspace: { enabled: true, + permission: { + enabled: false, + }, }, migrations: { skip: false }, }, @@ -80,6 +84,39 @@ describe('workspace service', () => { expect(result.body.success).toEqual(true); expect(typeof result.body.result.id).toBe('string'); }); + it('create with permissions', async () => { + await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + permissions: [{ type: 'invalid-type', userId: 'foo', modes: ['read'] }], + }) + .expect(400); + + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + permissions: [{ type: 'user', userId: 'foo', modes: ['read'] }], + }) + .expect(200); + + expect(result.body.success).toEqual(true); + expect(typeof result.body.result.id).toBe('string'); + expect( + ( + await osd.coreStart.savedObjects + .createInternalRepository([WORKSPACE_TYPE]) + .get<{ permissions: WorkspacePermissionItem[] }>(WORKSPACE_TYPE, result.body.result.id) + ).attributes.permissions + ).toEqual([ + { + modes: ['read'], + type: 'user', + userId: 'foo', + }, + ]); + }); it('get', async () => { const result = await osdTestServer.request .post(root, `/api/workspaces`) @@ -120,6 +157,39 @@ describe('workspace service', () => { expect(getResult.body.success).toEqual(true); expect(getResult.body.result.name).toEqual('updated'); }); + it('update with permissions', async () => { + const result: any = await osdTestServer.request + .post(root, `/api/workspaces`) + .send({ + attributes: omitId(testWorkspace), + permissions: [{ type: 'user', userId: 'foo', modes: ['read'] }], + }) + .expect(200); + + await osdTestServer.request + .put(root, `/api/workspaces/${result.body.result.id}`) + .send({ + attributes: { + ...omitId(testWorkspace), + }, + permissions: [{ type: 'user', userId: 'foo', modes: ['write'] }], + }) + .expect(200); + + expect( + ( + await osd.coreStart.savedObjects + .createInternalRepository([WORKSPACE_TYPE]) + .get<{ permissions: WorkspacePermissionItem[] }>(WORKSPACE_TYPE, result.body.result.id) + ).attributes.permissions + ).toEqual([ + { + modes: ['write'], + type: 'user', + userId: 'foo', + }, + ]); + }); it('delete', async () => { const result: any = await osdTestServer.request .post(root, `/api/workspaces`) 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..687e93de1d71 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.mock.ts @@ -0,0 +1,12 @@ +/* + * 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(), + setup: jest.fn(), +}; diff --git a/src/plugins/workspace/server/permission_control/client.test.ts b/src/plugins/workspace/server/permission_control/client.test.ts new file mode 100644 index 000000000000..e05e299c153b --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.test.ts @@ -0,0 +1,123 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { loggerMock } from '@osd/logging/target/mocks'; +import { SavedObjectsPermissionControl } from './client'; +import { + httpServerMock, + httpServiceMock, + savedObjectsClientMock, +} from '../../../../core/server/mocks'; +import * as utilsExports from '../utils'; + +describe('PermissionControl', () => { + jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockImplementation(() => ({ + users: ['bar'], + })); + const mockAuth = httpServiceMock.createAuth(); + + it('validate should return error when no saved objects can be found', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [], + }); + const result = await permissionControlClient.validate( + httpServerMock.createOpenSearchDashboardsRequest(), + { id: 'foo', type: 'dashboard' }, + ['read'] + ); + expect(result.success).toEqual(false); + }); + + it('validate should return error when bulkGet return error', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'foo', + type: 'dashboard', + references: [], + attributes: {}, + error: { + error: 'error_bar', + message: 'error_bar', + statusCode: 500, + }, + }, + ], + }); + const errorResult = await permissionControlClient.validate( + httpServerMock.createOpenSearchDashboardsRequest(), + { id: 'foo', type: 'dashboard' }, + ['read'] + ); + expect(errorResult.success).toEqual(false); + expect(errorResult.error).toEqual('error_bar'); + }); + + it('validate should return success normally', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + id: 'foo', + type: 'dashboard', + references: [], + attributes: {}, + }, + { + id: 'bar', + type: 'dashboard', + references: [], + attributes: {}, + permissions: { + read: { + users: ['bar'], + }, + }, + }, + ], + }); + const batchValidateResult = await permissionControlClient.batchValidate( + httpServerMock.createOpenSearchDashboardsRequest(), + [], + ['read'] + ); + expect(batchValidateResult.success).toEqual(true); + expect(batchValidateResult.result).toEqual(true); + }); + + describe('getPrincipalsFromRequest', () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + permissionControlClient.setup(getScopedClient, mockAuth); + + it('should return normally when calling getPrincipalsFromRequest', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + const result = permissionControlClient.getPrincipalsFromRequest(mockRequest); + expect(result.users).toEqual(['bar']); + }); + }); +}); 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..bad46eb156a6 --- /dev/null +++ b/src/plugins/workspace/server/permission_control/client.ts @@ -0,0 +1,185 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { + ACL, + TransformedPermission, + SavedObjectsBulkGetObject, + SavedObjectsServiceStart, + Logger, + OpenSearchDashboardsRequest, + Principals, + SavedObject, + WORKSPACE_TYPE, + Permissions, + HttpAuth, +} 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 auth?: HttpAuth; + private getScopedClient(request: OpenSearchDashboardsRequest) { + return this._getScopedClient?.(request, { + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + includedHiddenTypes: [WORKSPACE_TYPE], + }); + } + + constructor(logger: Logger) { + this.logger = logger; + } + + private async bulkGetSavedObjects( + request: OpenSearchDashboardsRequest, + savedObjects: SavedObjectsBulkGetObject[] + ) { + return (await this.getScopedClient?.(request)?.bulkGet(savedObjects))?.saved_objects || []; + } + public async setup(getScopedClient: SavedObjectsServiceStart['getScopedClient'], auth: HttpAuth) { + this._getScopedClient = getScopedClient; + this.auth = auth; + } + public async validate( + request: OpenSearchDashboardsRequest, + savedObject: SavedObjectsBulkGetObject, + permissionModes: SavedObjectsPermissionModes + ) { + return await this.batchValidate(request, [savedObject], permissionModes); + } + + 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 getPrincipalsFromRequest(request: OpenSearchDashboardsRequest) { + return getPrincipalsFromRequest(request, this.auth); + } + + public validateSavedObjectsACL( + savedObjects: Array, 'id' | 'type' | 'workspaces' | 'permissions'>>, + principals: Principals, + permissionModes: SavedObjectsPermissionModes + ) { + const notPermittedSavedObjects: Array, + 'id' | 'type' | 'workspaces' | 'permissions' + >> = []; + const hasPermissionToAllObjects = 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 (!hasPermissionToAllObjects) { + this.logNotPermitted(notPermittedSavedObjects, principals, permissionModes); + } + return hasPermissionToAllObjects; + } + + /** + * Performs batch validation to check if the current request has access to specified saved objects + * with the given permission modes. + * @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.length) { + return { + success: false, + error: i18n.translate('savedObjects.permission.notFound', { + defaultMessage: 'Can not find target saved objects.', + }), + }; + } + + if (savedObjectsGet.some((item) => item.error)) { + return { + success: false, + error: savedObjectsGet + .filter((item) => item.error) + .map((item) => item.error?.error) + .join('\n'), + }; + } + + const principals = this.getPrincipalsFromRequest(request); + const deniedObjects: Array< + Pick & { + workspaces?: string[]; + permissions?: Permissions; + } + > = []; + const hasPermissionToAllObjects = savedObjectsGet.every((item) => { + // for object that doesn't contain ACL like config, return true + if (!item.permissions) { + return true; + } + const aclInstance = new ACL(item.permissions); + const hasPermission = aclInstance.hasPermission(permissionModes, principals); + if (!hasPermission) { + deniedObjects.push({ + id: item.id, + type: item.type, + workspaces: item.workspaces, + permissions: item.permissions, + }); + } + return hasPermission; + }); + if (!hasPermissionToAllObjects) { + this.logger.debug( + `Authorization failed, principals: ${JSON.stringify( + principals + )} has no [${permissionModes}] permissions on the requested saved object: ${JSON.stringify( + deniedObjects + )}` + ); + } + return { + success: true, + result: hasPermissionToAllObjects, + }; + } +} diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index 32b4b23a9f59..c448fcf209f9 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -11,7 +11,8 @@ describe('Workspace server plugin', () => { let value; const setupMock = coreMock.createSetup(); const initializerContextConfigMock = coreMock.createPluginInitializerContext({ - workspace: { + enabled: true, + permission: { enabled: true, }, }); @@ -22,6 +23,7 @@ describe('Workspace server plugin', () => { Object { "workspaces": Object { "enabled": true, + "permissionEnabled": true, }, } `); diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index 145aacaf9bb8..3b267d7092c0 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +import { Observable } from 'rxjs'; +import { first } from 'rxjs/operators'; import { PluginInitializerContext, CoreSetup, @@ -13,16 +15,23 @@ import { import { IWorkspaceClientImpl, WorkspacePluginSetup, WorkspacePluginStart } from './types'; import { WorkspaceClient } from './workspace_client'; import { registerRoutes } from './routes'; -import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; +import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; +import { WorkspaceSavedObjectsClientWrapper } from './saved_objects'; import { cleanWorkspaceId, getWorkspaceIdFromUrl } from '../../../core/server/utils'; -import { WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../common/constants'; -import { WorkspaceConflictSavedObjectsClientWrapper } from './saved_objects/saved_objects_wrapper_for_check_workspace_conflict'; +import { + SavedObjectsPermissionControl, + SavedObjectsPermissionControlContract, +} from './permission_control/client'; +import { WorkspacePluginConfigType } from '../config'; export class WorkspacePlugin implements Plugin { private readonly logger: Logger; private client?: IWorkspaceClientImpl; private workspaceConflictControl?: WorkspaceConflictSavedObjectsClientWrapper; + private permissionControl?: SavedObjectsPermissionControlContract; + private readonly config$: Observable; + private workspaceSavedObjectsClientWrapper?: WorkspaceSavedObjectsClientWrapper; private proxyWorkspaceTrafficToRealHandler(setupDeps: CoreSetup) { /** @@ -42,10 +51,14 @@ export class WorkspacePlugin implements Plugin(); } 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 WorkspaceClient(core, this.logger); @@ -67,13 +80,34 @@ export class WorkspacePlugin implements Plugin ({ workspaces: { enabled: true } })); + core.capabilities.registerProvider(() => ({ + workspaces: { + enabled: true, + permissionEnabled: isPermissionControlEnabled, + }, + })); return { client: this.client, @@ -82,8 +116,10 @@ export class WorkspacePlugin implements Plugin { - const { attributes } = req.body; + const { attributes, permissions: permissionsInRequest } = req.body; + const authInfo = permissionControlClient?.getPrincipalsFromRequest(req); + let permissions: WorkspacePermissionItem[] = []; + if (permissionsInRequest) { + permissions = Array.isArray(permissionsInRequest) + ? permissionsInRequest + : [permissionsInRequest]; + } + + // Assign workspace owner to current user + if (!!authInfo?.users?.length) { + permissions.push({ + type: 'user', + userId: authInfo.users[0], + modes: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], + }); + } const result = await client.create( { @@ -108,6 +152,7 @@ export function registerRoutes({ }, { ...attributes, + ...(permissions.length ? { permissions } : {}), } ); return res.ok({ body: result }); @@ -122,12 +167,19 @@ export function registerRoutes({ }), body: schema.object({ attributes: workspaceAttributesSchema, + permissions: schema.maybe( + schema.oneOf([workspacePermission, schema.arrayOf(workspacePermission)]) + ), }), }, }, router.handleLegacyErrors(async (context, req, res) => { const { id } = req.params; - const { attributes } = req.body; + const { attributes, permissions } = req.body; + let finalPermissions: WorkspacePermissionItem[] = []; + if (permissions) { + finalPermissions = Array.isArray(permissions) ? permissions : [permissions]; + } const result = await client.update( { @@ -138,6 +190,7 @@ export function registerRoutes({ id, { ...attributes, + ...(finalPermissions.length ? { permissions: finalPermissions } : {}), } ); return res.ok({ body: result }); diff --git a/src/plugins/workspace/server/saved_objects/index.ts b/src/plugins/workspace/server/saved_objects/index.ts index 51653c50681e..e47be61b0cd2 100644 --- a/src/plugins/workspace/server/saved_objects/index.ts +++ b/src/plugins/workspace/server/saved_objects/index.ts @@ -4,3 +4,4 @@ */ export { workspace } from './workspace'; +export { WorkspaceSavedObjectsClientWrapper } from './workspace_saved_objects_client_wrapper'; diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/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..9f4f46b4dc99 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts @@ -0,0 +1,528 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + createTestServers, + TestOpenSearchUtils, + TestOpenSearchDashboardsUtils, + TestUtils, +} from '../../../../../core/test_helpers/osd_server'; +import { + SavedObjectsErrorHelpers, + WORKSPACE_TYPE, + ISavedObjectsRepository, + SavedObjectsClientContract, +} 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, + permission: { + enabled: true, + }, + }, + migrations: { skip: false }, + }, + }, + }); + opensearchServer = await servers.startOpenSearch(); + osd = await servers.startOpenSearchDashboards(); + + internalSavedObjectsRepository = osd.coreStart.savedObjects.createInternalRepository([ + WORKSPACE_TYPE, + ]); + + 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); + }); + }); +}); diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts new file mode 100644 index 000000000000..6b40f6e60fa0 --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts @@ -0,0 +1,597 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SavedObjectsErrorHelpers } from '../../../../core/server'; +import { WorkspaceSavedObjectsClientWrapper } from './workspace_saved_objects_client_wrapper'; + +const generateWorkspaceSavedObjectsClientWrapper = () => { + const savedObjectsStore = [ + { + type: 'dashboard', + id: 'foo', + workspaces: ['workspace-1'], + attributes: { + bar: 'baz', + }, + permissions: {}, + }, + { + type: 'dashboard', + id: 'not-permitted-dashboard', + workspaces: ['not-permitted-workspace'], + attributes: { + bar: 'baz', + }, + permissions: {}, + }, + { type: 'workspace', id: 'workspace-1', attributes: { name: 'Workspace - 1' } }, + { + type: 'workspace', + id: 'not-permitted-workspace', + attributes: { name: 'Not permitted workspace' }, + }, + ]; + const clientMock = { + get: jest.fn().mockImplementation(async (type, id) => { + if (type === 'config') { + return { + type: 'config', + }; + } + return ( + savedObjectsStore.find((item) => item.type === type && item.id === id) || + SavedObjectsErrorHelpers.createGenericNotFoundError() + ); + }), + create: jest.fn(), + bulkCreate: jest.fn(), + checkConflicts: jest.fn(), + delete: jest.fn(), + update: jest.fn(), + bulkUpdate: jest.fn(), + bulkGet: jest.fn().mockImplementation((savedObjectsToFind) => { + return { + saved_objects: savedObjectsStore.filter((item) => + savedObjectsToFind.find( + (itemToFind) => itemToFind.type === item.type && itemToFind.id === item.id + ) + ), + }; + }), + find: jest.fn(), + deleteByWorkspace: jest.fn(), + }; + const requestMock = {}; + const wrapperOptions = { + client: clientMock, + request: requestMock, + typeRegistry: {}, + }; + const permissionControlMock = { + setup: jest.fn(), + validate: jest.fn().mockImplementation((_request, { id }) => { + return { + success: true, + result: !id.startsWith('not-permitted'), + }; + }), + validateSavedObjectsACL: jest.fn(), + batchValidate: jest.fn(), + getPrincipalsFromRequest: jest.fn().mockImplementation(() => ({ users: ['user-1'] })), + }; + const wrapper = new WorkspaceSavedObjectsClientWrapper(permissionControlMock); + wrapper.setScopedClient(() => ({ + find: jest.fn().mockImplementation(async () => ({ + saved_objects: [{ id: 'workspace-1', type: 'workspace' }], + })), + })); + return { + wrapper: wrapper.wrapperFactory(wrapperOptions), + clientMock, + permissionControlMock, + requestMock, + }; +}; + +describe('WorkspaceSavedObjectsClientWrapper', () => { + describe('wrapperFactory', () => { + describe('delete', () => { + it('should throw permission error if not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.delete('dashboard', 'not-permitted-dashboard'); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call client.delete with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const deleteArgs = ['dashboard', 'foo', { force: true }] as const; + await wrapper.delete(...deleteArgs); + expect(clientMock.delete).toHaveBeenCalledWith(...deleteArgs); + }); + }); + + describe('update', () => { + it('should throw permission error if not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.update('dashboard', 'not-permitted-dashboard', { + bar: 'foo', + }); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call client.update with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const updateArgs = [ + 'workspace', + 'foo', + { + bar: 'foo', + }, + {}, + ] as const; + await wrapper.update(...updateArgs); + expect(clientMock.update).toHaveBeenCalledWith(...updateArgs); + }); + }); + + describe('bulk update', () => { + it('should throw permission error if not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.bulkUpdate([ + { type: 'dashboard', id: 'not-permitted-dashboard', attributes: { bar: 'baz' } }, + ]); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call client.bulkUpdate with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const objectsToUpdate = [{ type: 'dashboard', id: 'foo', attributes: { bar: 'baz' } }]; + await wrapper.bulkUpdate(objectsToUpdate, {}); + expect(clientMock.bulkUpdate).toHaveBeenCalledWith(objectsToUpdate, {}); + }); + }); + + describe('bulk create', () => { + it('should throw workspace permission error if passed workspaces but not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + permissionControlMock.validate.mockResolvedValueOnce({ success: true, result: false }); + let errorCatched; + try { + await wrapper.bulkCreate([{ type: 'dashboard', id: 'new-dashboard', attributes: {} }], { + workspaces: ['not-permitted-workspace'], + }); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it("should throw permission error if overwrite and not permitted on object's workspace and object", async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + permissionControlMock.validate.mockResolvedValueOnce({ success: true, result: false }); + let errorCatched; + try { + await wrapper.bulkCreate( + [{ type: 'dashboard', id: 'not-permitted-dashboard', attributes: { bar: 'baz' } }], + { + overwrite: true, + } + ); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it('should call client.bulkCreate with arguments if some objects not found', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const objectsToBulkCreate = [ + { type: 'dashboard', id: 'new-dashboard', attributes: { bar: 'baz' } }, + { type: 'dashboard', id: 'not-found', attributes: { bar: 'foo' } }, + ]; + await wrapper.bulkCreate(objectsToBulkCreate, { + overwrite: true, + workspaces: ['workspace-1'], + }); + expect(clientMock.bulkCreate).toHaveBeenCalledWith(objectsToBulkCreate, { + overwrite: true, + workspaces: ['workspace-1'], + }); + }); + it('should call client.bulkCreate with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + const objectsToBulkCreate = [ + { type: 'dashboard', id: 'new-dashboard', attributes: { bar: 'baz' } }, + ]; + await wrapper.bulkCreate(objectsToBulkCreate, { + overwrite: true, + workspaces: ['workspace-1'], + }); + expect(clientMock.bulkCreate).toHaveBeenCalledWith(objectsToBulkCreate, { + overwrite: true, + workspaces: ['workspace-1'], + }); + }); + }); + + describe('create', () => { + it('should throw workspace permission error if passed workspaces but not permitted', async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.create('dashboard', 'new-dashboard', { + workspaces: ['not-permitted-workspace'], + }); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it("should throw permission error if overwrite and not permitted on object's workspace and object", async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.create( + 'dashboard', + { foo: 'bar' }, + { + id: 'not-permitted-dashboard', + overwrite: true, + } + ); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { type: 'workspace', id: 'not-permitted-workspace' }, + ['library_write'] + ); + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [expect.objectContaining({ type: 'dashboard', id: 'not-permitted-dashboard' })], + { users: ['user-1'] }, + ['write'] + ); + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it('should call client.create with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.create( + 'dashboard', + { foo: 'bar' }, + { + id: 'foo', + overwrite: true, + } + ); + expect(clientMock.create).toHaveBeenCalledWith( + 'dashboard', + { foo: 'bar' }, + { + id: 'foo', + overwrite: true, + } + ); + }); + }); + describe('get', () => { + it('should return saved object if no need to validate permission', async () => { + const { wrapper, permissionControlMock } = generateWorkspaceSavedObjectsClientWrapper(); + const result = await wrapper.get('config', 'config-1'); + expect(result).toEqual({ type: 'config' }); + expect(permissionControlMock.validate).not.toHaveBeenCalled(); + }); + it("should call permission validate with object's workspace and throw permission error", async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.get('dashboard', 'not-permitted-dashboard'); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { + type: 'workspace', + id: 'not-permitted-workspace', + }, + ['library_read', 'library_write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call permission validateSavedObjectsACL with object', async () => { + const { wrapper, permissionControlMock } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.get('dashboard', 'not-permitted-dashboard'); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'dashboard', + id: 'not-permitted-dashboard', + }), + ], + { users: ['user-1'] }, + ['read', 'write'] + ); + }); + it('should call client.get and return result with arguments if permitted', async () => { + const { + wrapper, + clientMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + permissionControlMock.validate.mockResolvedValueOnce({ success: true, result: true }); + const getArgs = ['workspace', 'foo', {}] as const; + const result = await wrapper.get(...getArgs); + expect(clientMock.get).toHaveBeenCalledWith(...getArgs); + expect(result).toMatchInlineSnapshot(`[Error: Not Found]`); + }); + }); + describe('bulk get', () => { + it("should call permission validate with object's workspace and throw permission error", async () => { + const { + wrapper, + permissionControlMock, + requestMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.bulkGet([{ type: 'dashboard', id: 'not-permitted-dashboard' }]); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { + type: 'workspace', + id: 'not-permitted-workspace', + }, + ['library_read', 'library_write'] + ); + expect(errorCatched?.message).toEqual('Invalid saved objects permission'); + }); + it('should call permission validateSavedObjectsACL with object', async () => { + const { wrapper, permissionControlMock } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.bulkGet([{ type: 'dashboard', id: 'not-permitted-dashboard' }]); + } catch (e) { + errorCatched = e; + } + expect(permissionControlMock.validateSavedObjectsACL).toHaveBeenCalledWith( + [ + expect.objectContaining({ + type: 'dashboard', + id: 'not-permitted-dashboard', + }), + ], + { users: ['user-1'] }, + ['write', 'read'] + ); + }); + it('should call client.bulkGet and return result with arguments if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + + await wrapper.bulkGet( + [ + { + type: 'dashboard', + id: 'foo', + }, + ], + {} + ); + expect(clientMock.bulkGet).toHaveBeenCalledWith( + [ + { + type: 'dashboard', + id: 'foo', + }, + ], + {} + ); + }); + }); + describe('find', () => { + it('should call client.find with ACLSearchParams for workspace type', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.find({ + type: 'workspace', + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'workspace', + ACLSearchParams: { + principals: { + users: ['user-1'], + }, + permissionModes: ['read', 'write'], + }, + }); + }); + it('should call client.find with only read permission if find workspace and permissionModes provided', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.find({ + type: 'workspace', + ACLSearchParams: { + permissionModes: ['read'], + }, + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'workspace', + ACLSearchParams: { + principals: { + users: ['user-1'], + }, + permissionModes: ['read'], + }, + }); + }); + it('should throw workspace permission error if provided workspaces not permitted', async () => { + const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + errorCatched = await wrapper.find({ + type: 'dashboard', + workspaces: ['not-permitted-workspace'], + }); + } catch (e) { + errorCatched = e; + } + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + }); + it('should remove not permitted workspace and call client.find with arguments', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.find({ + type: 'dashboard', + workspaces: ['not-permitted-workspace', 'workspace-1'], + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'dashboard', + workspaces: ['workspace-1'], + ACLSearchParams: {}, + }); + }); + it('should call client.find with arguments if not workspace type and no options.workspace', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + await wrapper.find({ + type: 'dashboard', + }); + expect(clientMock.find).toHaveBeenCalledWith({ + type: 'dashboard', + workspaces: ['workspace-1'], + workspacesSearchOperator: 'OR', + ACLSearchParams: { + permissionModes: ['read', 'write'], + principals: { users: ['user-1'] }, + }, + }); + }); + }); + describe('deleteByWorkspace', () => { + it('should call permission validate with workspace and throw workspace permission error if not permitted', async () => { + const { + wrapper, + requestMock, + permissionControlMock, + } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.deleteByWorkspace('not-permitted-workspace'); + } catch (e) { + errorCatched = e; + } + expect(errorCatched?.message).toEqual('Invalid workspace permission'); + expect(permissionControlMock.validate).toHaveBeenCalledWith( + requestMock, + { id: 'not-permitted-workspace', type: 'workspace' }, + ['library_write'] + ); + }); + + it('should call client.deleteByWorkspace if permitted', async () => { + const { wrapper, clientMock } = generateWorkspaceSavedObjectsClientWrapper(); + + await wrapper.deleteByWorkspace('workspace-1', {}); + expect(clientMock.deleteByWorkspace).toHaveBeenCalledWith('workspace-1', {}); + }); + }); + }); +}); 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..c515f555fa4b --- /dev/null +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -0,0 +1,549 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; + +import { + OpenSearchDashboardsRequest, + SavedObject, + SavedObjectsBaseOptions, + SavedObjectsBulkCreateObject, + SavedObjectsBulkGetObject, + SavedObjectsBulkResponse, + SavedObjectsClientWrapperFactory, + SavedObjectsCreateOptions, + SavedObjectsDeleteOptions, + SavedObjectsFindOptions, + SavedObjectsUpdateOptions, + SavedObjectsUpdateResponse, + SavedObjectsBulkUpdateObject, + SavedObjectsBulkUpdateResponse, + SavedObjectsBulkUpdateOptions, + WORKSPACE_TYPE, + SavedObjectsDeleteByWorkspaceOptions, + SavedObjectsErrorHelpers, + SavedObjectsServiceStart, + SavedObjectsClientContract, +} from '../../../../core/server'; +import { SavedObjectsPermissionControlContract } from '../permission_control/client'; +import { + WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, + WorkspacePermissionMode, +} from '../../common/constants'; + +// Can't throw unauthorized for now, the page will be refreshed if unauthorized +const generateWorkspacePermissionError = () => + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + i18n.translate('workspace.permission.invalidate', { + defaultMessage: 'Invalid workspace permission', + }) + ) + ); + +const generateSavedObjectsPermissionError = () => + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + i18n.translate('saved_objects.permission.invalidate', { + defaultMessage: 'Invalid saved objects permission', + }) + ) + ); + +const intersection = (...args: T[][]) => { + const occursCountMap: { [key: string]: number } = {}; + for (let i = 0; i < args.length; i++) { + new Set(args[i]).forEach((key) => { + occursCountMap[key] = (occursCountMap[key] || 0) + 1; + }); + } + return Object.keys(occursCountMap).filter((key) => occursCountMap[key] === args.length); +}; + +const getDefaultValuesForEmpty = (values: T[] | undefined, defaultValues: T[]) => { + return !values || values.length === 0 ? defaultValues : values; +}; + +export class WorkspaceSavedObjectsClientWrapper { + private getScopedClient?: SavedObjectsServiceStart['getScopedClient']; + private formatWorkspacePermissionModeToStringArray( + permission: WorkspacePermissionMode | WorkspacePermissionMode[] + ): string[] { + if (Array.isArray(permission)) { + return permission; + } + + return [permission]; + } + + private async validateObjectsPermissions( + objects: Array>, + request: OpenSearchDashboardsRequest, + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + ) { + // PermissionMode here is an array which is merged by workspace type required permission and other saved object required permission. + // So we only need to do one permission check no matter its type. + for (const { id, type } of objects) { + const validateResult = await this.permissionControl.validate( + request, + { + type, + id, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + ); + if (!validateResult?.result) { + return false; + } + } + return true; + } + + // validate if the `request` has the specified permission(`permissionMode`) to the given `workspaceIds` + private validateMultiWorkspacesPermissions = async ( + workspacesIds: string[], + request: OpenSearchDashboardsRequest, + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + ) => { + // for attributes and options passed in this function, the num of workspaces may be 0.This case should not be passed permission check. + if (workspacesIds.length === 0) { + return false; + } + const workspaces = workspacesIds.map((id) => ({ id, type: WORKSPACE_TYPE })); + return await this.validateObjectsPermissions(workspaces, request, permissionMode); + }; + + private validateAtLeastOnePermittedWorkspaces = async ( + workspaces: string[] | undefined, + request: OpenSearchDashboardsRequest, + permissionMode: WorkspacePermissionMode | WorkspacePermissionMode[] + ) => { + // for attributes and options passed in this function, the num of workspaces attribute may be 0.This case should not be passed permission check. + if (!workspaces || workspaces.length === 0) { + return false; + } + for (const workspaceId of workspaces) { + const validateResult = await this.permissionControl.validate( + request, + { + type: WORKSPACE_TYPE, + id: workspaceId, + }, + this.formatWorkspacePermissionModeToStringArray(permissionMode) + ); + if (validateResult?.result) { + return true; + } + } + return false; + }; + + /** + * check if the type include workspace + * Workspace permission check is totally different from object permission check. + * @param type + * @returns + */ + private isRelatedToWorkspace(type: string | string[]): boolean { + return type === WORKSPACE_TYPE || (Array.isArray(type) && type.includes(WORKSPACE_TYPE)); + } + + private async validateWorkspacesAndSavedObjectsPermissions( + savedObject: Pick, + request: OpenSearchDashboardsRequest, + workspacePermissionModes: WorkspacePermissionMode[], + objectPermissionModes: WorkspacePermissionMode[], + validateAllWorkspaces = true + ) { + /** + * + * Checks if the provided saved object lacks both workspaces and permissions. + * If a saved object lacks both attributes, it implies that the object is neither associated + * with any workspaces nor has permissions defined by itself. Such objects are considered "public" + * and will be excluded from permission checks. + * + **/ + 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], + this.permissionControl.getPrincipalsFromRequest(request), + objectPermissionModes + ); + } + return hasPermission; + } + + private getWorkspaceTypeEnabledClient(request: OpenSearchDashboardsRequest) { + return this.getScopedClient?.(request, { + includedHiddenTypes: [WORKSPACE_TYPE], + excludedWrappers: [WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID], + }) as SavedObjectsClientContract; + } + + public setScopedClient(getScopedClient: SavedObjectsServiceStart['getScopedClient']) { + this.getScopedClient = getScopedClient; + } + + public wrapperFactory: SavedObjectsClientWrapperFactory = (wrapperOptions) => { + const deleteWithWorkspacePermissionControl = async ( + type: string, + id: string, + options: SavedObjectsDeleteOptions = {} + ) => { + const objectToDeleted = await wrapperOptions.client.get(type, id, options); + if ( + !(await this.validateWorkspacesAndSavedObjectsPermissions( + objectToDeleted, + wrapperOptions.request, + [WorkspacePermissionMode.LibraryWrite], + [WorkspacePermissionMode.Write] + )) + ) { + throw generateSavedObjectsPermissionError(); + } + return await wrapperOptions.client.delete(type, id, options); + }; + + /** + * validate if can update`objectToUpdate`, means a user should either + * have `Write` permission on the `objectToUpdate` itself or `LibraryWrite` permission + * to any of the workspaces the `objectToUpdate` associated with. + **/ + 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 generateWorkspacePermissionError(); + } + + /** + * + * If target workspaces parameter doesn't exists and `overwrite` is true, we need to check + * if it has permission to the object itself(defined by the object ACL) or it has permission + * to any of the workspaces that the object associates with. + * + */ + 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 doesn't exists, `options.id` was exists and `overwrite` is true, + * we need to check if it has permission to the object itself(defined by the object ACL) or + * it has permission to any of the workspaces that the object associates with. + * + */ + 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 = this.permissionControl.getPrincipalsFromRequest(wrapperOptions.request); + if (!options.ACLSearchParams) { + options.ACLSearchParams = {}; + } + + if (this.isRelatedToWorkspace(options.type)) { + /** + * + * This case is for finding workspace saved objects, will use passed permissionModes + * and override passed principals from request to get all readable workspaces. + * + */ + options.ACLSearchParams.permissionModes = getDefaultValuesForEmpty( + options.ACLSearchParams.permissionModes, + [WorkspacePermissionMode.Read, WorkspacePermissionMode.Write] + ); + options.ACLSearchParams.principals = principals; + } else { + /** + * Workspace is a hidden type so that we need to + * initialize a new saved objects client with workspace enabled to retrieve all the workspaces with permission. + */ + const permittedWorkspaceIds = ( + await this.getWorkspaceTypeEnabledClient(wrapperOptions.request).find({ + type: WORKSPACE_TYPE, + perPage: 999, + ACLSearchParams: { + principals, + /** + * The permitted workspace ids will be passed to the options.workspaces + * or options.ACLSearchParams.workspaces. These two were indicated the saved + * objects data inner specific workspaces. We use Library related permission here. + * For outside passed permission modes, it may contains other permissions. Add a intersection + * here to make sure only Library related permission modes will be used. + */ + permissionModes: getDefaultValuesForEmpty( + 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 + */ + options.workspaces = permittedWorkspaceIds; + options.workspacesSearchOperator = 'OR'; + options.ACLSearchParams.permissionModes = getDefaultValuesForEmpty( + options.ACLSearchParams.permissionModes, + [WorkspacePermissionMode.Read, WorkspacePermissionMode.Write] + ); + options.ACLSearchParams.principals = principals; + } + } + + return await wrapperOptions.client.find(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, + deleteByWorkspace: deleteByWorkspaceWithPermissionControl, + }; + }; + + constructor(private readonly permissionControl: SavedObjectsPermissionControlContract) {} +} diff --git a/src/plugins/workspace/server/types.ts b/src/plugins/workspace/server/types.ts index 29e8747c7618..3539dd76c546 100644 --- a/src/plugins/workspace/server/types.ts +++ b/src/plugins/workspace/server/types.ts @@ -12,6 +12,7 @@ import { WorkspaceAttribute, SavedObjectsServiceStart, } from '../../../core/server'; +import { WorkspacePermissionMode } from '../common/constants'; export interface WorkspaceFindOptions { page?: number; @@ -125,3 +126,16 @@ export interface WorkspacePluginSetup { export interface WorkspacePluginStart { client: IWorkspaceClientImpl; } +export interface AuthInfo { + backend_roles?: string[]; + user_name?: string; +} + +export type WorkspacePermissionItem = { + modes: Array< + | WorkspacePermissionMode.LibraryRead + | WorkspacePermissionMode.LibraryWrite + | WorkspacePermissionMode.Read + | WorkspacePermissionMode.Write + >; +} & ({ type: 'user'; userId: string } | { type: 'group'; group: string }); diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 119b8889f715..5af40eea9b06 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -3,9 +3,12 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { generateRandomId } from './utils'; +import { AuthStatus } from '../../../core/server'; +import { httpServerMock, httpServiceMock } from '../../../core/server/mocks'; +import { generateRandomId, getPrincipalsFromRequest } from './utils'; describe('workspace utils', () => { + const mockAuth = httpServiceMock.createAuth(); it('should generate id with the specified size', () => { expect(generateRandomId(6)).toHaveLength(6); }); @@ -18,4 +21,52 @@ describe('workspace utils', () => { } expect(ids.size).toBe(NUM_OF_ID); }); + + it('should return empty map when request do not have authentication', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + status: AuthStatus.unknown, + state: { + user_name: 'bar', + backend_roles: ['foo'], + }, + }); + const result = getPrincipalsFromRequest(mockRequest, mockAuth); + expect(result).toEqual({}); + }); + + it('should return normally when request has authentication', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + status: AuthStatus.authenticated, + state: { + user_name: 'bar', + backend_roles: ['foo'], + }, + }); + const result = getPrincipalsFromRequest(mockRequest, mockAuth); + expect(result.users).toEqual(['bar']); + expect(result.groups).toEqual(['foo']); + }); + + it('should throw error when request is not authenticated', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + status: AuthStatus.unauthenticated, + state: {}, + }); + expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow('NOT_AUTHORIZED'); + }); + + it('should throw error when authentication status is not expected', () => { + const mockRequest = httpServerMock.createOpenSearchDashboardsRequest(); + mockAuth.get.mockReturnValueOnce({ + // @ts-ignore + status: 'foo', + state: {}, + }); + expect(() => getPrincipalsFromRequest(mockRequest, mockAuth)).toThrow( + 'UNEXPECTED_AUTHORIZATION_STATUS' + ); + }); }); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index 89bfabd52657..e51637cd49c3 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -4,6 +4,14 @@ */ import crypto from 'crypto'; +import { + AuthStatus, + HttpAuth, + OpenSearchDashboardsRequest, + Principals, + PrincipalType, +} from '../../../core/server'; +import { AuthInfo } from './types'; /** * Generate URL friendly random ID @@ -11,3 +19,34 @@ import crypto from 'crypto'; export const generateRandomId = (size: number) => { return crypto.randomBytes(size).toString('base64url').slice(0, size); }; + +export const getPrincipalsFromRequest = ( + request: OpenSearchDashboardsRequest, + auth?: HttpAuth +): Principals => { + const payload: Principals = {}; + const authInfoResp = auth?.get(request); + if (authInfoResp?.status === AuthStatus.unknown) { + /** + * Login user have access to all the workspaces when no authentication is presented. + */ + return payload; + } + + if (authInfoResp?.status === AuthStatus.authenticated) { + const authInfo = authInfoResp?.state as AuthInfo | null; + if (authInfo?.backend_roles) { + payload[PrincipalType.Groups] = authInfo.backend_roles; + } + if (authInfo?.user_name) { + payload[PrincipalType.Users] = [authInfo.user_name]; + } + return payload; + } + + if (authInfoResp?.status === AuthStatus.unauthenticated) { + throw new Error('NOT_AUTHORIZED'); + } + + throw new Error('UNEXPECTED_AUTHORIZATION_STATUS'); +}; From fbe103915abfe1890239f0d00a142ce255c921f3 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Thu, 7 Mar 2024 15:47:54 +0800 Subject: [PATCH 23/34] revert Register Advance Settings, Data Source management,Index Pattern management and SavedObject management as standalone app, retire dashboard management (#283) * Revert "Register Advance Settings, Data Source management,Index Pattern management and SavedObject management as standalone app, retire dashboard management (#208)" This reverts commit 5f09f84ccf5435fa7a9b3acb68d5acfa38da600b. * Revert "feat: add unit test for mountWrapper (#223)" This reverts commit a70ce71593b9607461f1f55476b509d8159e5c9c. * Revert "fix: cypress tests checkout workspace branch (#252)" This reverts commit 734c8904545496d67bc733be52edd5d2f39e6f20. * run cypress with ftr main Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan --- .github/workflows/cypress_workflow.yml | 6 +- .../with-security/check_advanced_settings.js | 2 +- .../with-security/helpers/generate_data.js | 4 +- .../check_advanced_settings.js | 2 +- .../without-security/helpers/generate_data.js | 2 +- .../core_app/errors/url_overflow.test.ts | 2 +- .../public/core_app/errors/url_overflow.tsx | 2 +- .../core_app/errors/url_overflow_ui.tsx | 2 +- .../ui_settings/saved_objects/ui_settings.ts | 2 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 --- .../components/page_wrapper/index.ts | 6 -- .../page_wrapper/page_wrapper.test.tsx | 16 ---- .../components/page_wrapper/page_wrapper.tsx | 21 ----- .../mount_management_section.tsx | 55 +++++------ .../advanced_settings/public/plugin.ts | 24 ++--- .../server/saved_objects/dashboard.ts | 4 +- .../index_patterns/index_patterns.ts | 6 +- .../redirect_no_index_pattern.tsx | 8 +- .../public/search/errors/painless_error.tsx | 4 +- .../server/saved_objects/index_patterns.ts | 8 +- .../server/saved_objects/data_source.ts | 4 +- .../opensearch_dashboards.json | 2 +- .../data_source_column/data_source_column.tsx | 6 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 --- .../public/components/page_wrapper/index.ts | 6 -- .../page_wrapper/page_wrapper.test.tsx | 16 ---- .../components/page_wrapper/page_wrapper.tsx | 21 ----- .../data_source_management/public/types.ts | 5 - .../open_search_panel.test.tsx.snap | 2 +- .../components/top_nav/open_search_panel.tsx | 2 +- .../discover/server/saved_objects/search.ts | 4 +- .../components/new_theme_modal.tsx | 4 +- .../opensearch_dashboards.json | 2 +- .../mount_management_section.tsx | 73 +++++--------- .../index_pattern_management/public/plugin.ts | 29 +++--- .../getting_started.test.tsx.snap | 4 +- .../getting_started/getting_started.tsx | 6 +- .../overview_page_footer.tsx | 2 +- .../overview_page_header.test.tsx | 2 +- .../overview_page_header.tsx | 2 +- .../table_list_view/table_list_view.tsx | 2 +- .../saved_objects_management/README.md | 8 +- .../public/constants.ts | 41 -------- .../management_section/mount_section.tsx | 68 ++++++-------- .../saved_objects_table.test.tsx.snap | 10 +- .../__snapshots__/header.test.tsx.snap | 8 +- .../__snapshots__/relationships.test.tsx.snap | 20 ++-- .../__snapshots__/table.test.tsx.snap | 8 +- .../objects_table/components/header.tsx | 9 +- .../components/relationships.test.tsx | 34 +++---- .../objects_table/components/table.test.tsx | 8 +- .../saved_objects_table.test.tsx | 14 +-- .../objects_table/saved_objects_table.tsx | 6 +- .../__snapshots__/page_wrapper.test.tsx.snap | 13 --- .../management_section/page_wrapper/index.ts | 6 -- .../page_wrapper/page_wrapper.test.tsx | 16 ---- .../page_wrapper/page_wrapper.tsx | 21 ----- .../saved_objects_table_page.tsx | 13 ++- .../public/plugin.test.ts | 63 +------------ .../saved_objects_management/public/plugin.ts | 94 +++++-------------- .../server/saved_objects/augment_vis.ts | 4 +- .../server/saved_objects/vis_builder_app.ts | 3 +- .../server/saved_objects/visualization.ts | 4 +- .../apis/saved_objects_management/find.ts | 21 +++-- .../saved_objects_management/relationships.ts | 48 ++++++---- .../dashboard/create_and_add_embeddables.js | 6 +- test/functional/apps/dashboard/time_zones.js | 2 + .../apps/management/_import_objects.js | 2 + .../_index_pattern_create_delete.js | 2 +- .../management/_mgmt_import_saved_objects.js | 1 + .../_opensearch_dashboards_settings.js | 6 +- .../apps/management/_scripted_fields.js | 7 ++ .../management/_scripted_fields_filter.js | 1 + .../edit_saved_object.ts | 2 + .../apps/visualize/_custom_branding.ts | 10 +- test/functional/apps/visualize/_lab_mode.js | 6 +- test/functional/apps/visualize/_tag_cloud.js | 2 + test/functional/config.js | 4 + test/functional/page_objects/settings_page.ts | 6 +- 79 files changed, 360 insertions(+), 633 deletions(-) delete mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap delete mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts delete mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx delete mode 100644 src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx delete mode 100644 src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap delete mode 100644 src/plugins/data_source_management/public/components/page_wrapper/index.ts delete mode 100644 src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx delete mode 100644 src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx delete mode 100644 src/plugins/saved_objects_management/public/constants.ts delete mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap delete mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts delete mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx delete mode 100644 src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx diff --git a/.github/workflows/cypress_workflow.yml b/.github/workflows/cypress_workflow.yml index 5b531ad82456..291bd172b804 100644 --- a/.github/workflows/cypress_workflow.yml +++ b/.github/workflows/cypress_workflow.yml @@ -3,7 +3,7 @@ name: Run cypress tests # trigger on every PR for all branches on: pull_request: - branches: ['**'] + branches: [ '**' ] paths-ignore: - '**/*.md' workflow_dispatch: @@ -28,7 +28,7 @@ on: env: TEST_REPO: ${{ inputs.test_repo != '' && inputs.test_repo || 'opensearch-project/opensearch-dashboards-functional-test' }} - TEST_BRANCH: "${{ inputs.test_branch != '' && inputs.test_branch || 'workspace' }}" + TEST_BRANCH: "${{ inputs.test_branch != '' && inputs.test_branch || 'main' }}" FTR_PATH: 'ftr' START_CMD: 'node ../scripts/opensearch_dashboards --dev --no-base-path --no-watch --savedObjects.maxImportPayloadBytes=10485760 --server.maxPayloadBytes=1759977 --logging.json=false --data.search.aggs.shardDelay.enabled=true' OPENSEARCH_SNAPSHOT_CMD: 'node ../scripts/opensearch snapshot -E cluster.routing.allocation.disk.threshold_enabled=false' @@ -243,7 +243,7 @@ jobs: with: issue-number: ${{ inputs.pr_number }} comment-author: 'github-actions[bot]' - body-includes: '${{ env.COMMENT_TAG }}' + body-includes: "${{ env.COMMENT_TAG }}" - name: Add comment on the PR uses: peter-evans/create-or-update-comment@v3 diff --git a/cypress/integration/with-security/check_advanced_settings.js b/cypress/integration/with-security/check_advanced_settings.js index 379362063e92..9ca41207724e 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/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/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 c2c4d2dbe57d..dcd711fc7c18 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/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/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/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/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 0094d53835b0..9268d86a16e5 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/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/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 3aff136a70e0..47e9c2f5f5ed 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/settings'); + miscUtils.visitPage('app/management/opensearch-dashboards/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/core_app/errors/url_overflow.test.ts b/src/core/public/core_app/errors/url_overflow.test.ts index fe9cb8dca661..b2eee9c17d58 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 1de6fe785cf9..6dbfa96fff46 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: ( - + -
    - 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 deleted file mode 100644 index 3cf0cdd26c99..000000000000 --- a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * 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 deleted file mode 100644 index 550eb3ee1cae..000000000000 --- a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 deleted file mode 100644 index 1b1949c334e4..000000000000 --- a/src/plugins/advanced_settings/public/management_app/components/page_wrapper/page_wrapper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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 648382771ba8..7fa0b9ddd2c0 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,24 +34,18 @@ import { Router, Switch, Route } from 'react-router-dom'; import { i18n } from '@osd/i18n'; import { I18nProvider } from '@osd/i18n/react'; -import { - AppMountParameters, - ChromeBreadcrumb, - ScopedHistory, - StartServicesAccessor, -} from 'src/core/public'; +import { 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: ChromeBreadcrumb[] = [{ text: title }]; +const crumb = [{ text: title }]; const readOnlyBadge = { text: i18n.translate('advancedSettings.badge.readOnly.text', { @@ -63,18 +57,13 @@ const readOnlyBadge = { iconType: 'glasses', }; -export async function mountAdvancedSettingsManagementSection( +export async function mountManagementSection( getStartServices: StartServicesAccessor, - params: AppMountParameters, + params: ManagementAppMountParams, 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; @@ -83,23 +72,21 @@ export async function mountAdvancedSettingsManagementSection( } ReactDOM.render( - - - - - - - - - - - , + + + + + + + + + , params.element ); return () => { diff --git a/src/plugins/advanced_settings/public/plugin.ts b/src/plugins/advanced_settings/public/plugin.ts index 91fe18612749..608bfc6a25e7 100644 --- a/src/plugins/advanced_settings/public/plugin.ts +++ b/src/plugins/advanced_settings/public/plugin.ts @@ -29,11 +29,10 @@ */ import { i18n } from '@osd/i18n'; -import { AppMountParameters, CoreSetup, Plugin } from 'opensearch-dashboards/public'; +import { 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(); @@ -43,21 +42,18 @@ const title = i18n.translate('advancedSettings.advancedSettingsLabel', { export class AdvancedSettingsPlugin implements Plugin { - public setup(core: CoreSetup, { home }: AdvancedSettingsPluginSetup) { - core.application.register({ + public setup(core: CoreSetup, { management, home }: AdvancedSettingsPluginSetup) { + const opensearchDashboardsSection = management.sections.section.opensearchDashboards; + + opensearchDashboardsSection.registerApp({ id: 'settings', title, - order: 99, - category: DEFAULT_APP_CATEGORIES.management, - async mount(params: AppMountParameters) { - const { mountAdvancedSettingsManagementSection } = await import( + order: 3, + async mount(params) { + const { mountManagementSection } = await import( './management_app/mount_management_section' ); - return mountAdvancedSettingsManagementSection( - core.getStartServices, - params, - component.start - ); + return mountManagementSection(core.getStartServices, params, component.start); }, }); @@ -70,7 +66,7 @@ export class AdvancedSettingsPlugin 'Customize your OpenSearch Dashboards experience — change the date format, turn on dark mode, and more.', }), icon: 'gear', - path: '/app/settings', + path: '/app/management/opensearch-dashboards/settings', showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); diff --git a/src/plugins/dashboard/server/saved_objects/dashboard.ts b/src/plugins/dashboard/server/saved_objects/dashboard.ts index 6d6a08954fbe..ee2c162733bc 100644 --- a/src/plugins/dashboard/server/saved_objects/dashboard.ts +++ b/src/plugins/dashboard/server/saved_objects/dashboard.ts @@ -43,7 +43,9 @@ export const dashboardSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/objects/savedDashboards/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/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 489ad154afa0..688605821097 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,7 +418,11 @@ export class IndexPatternsService { ); if (!savedObject.version) { - throw new SavedObjectNotFound(savedObjectType, id, 'indexPatterns'); + throw new SavedObjectNotFound( + savedObjectType, + id, + 'management/opensearch-dashboards/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 1a43ab22aaae..b09bc8adde6f 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,7 +42,9 @@ export const onRedirectNoIndexPattern = ( overlays: CoreStart['overlays'] ) => () => { const canManageIndexPatterns = capabilities.management.opensearchDashboards.indexPatterns; - const redirectTarget = canManageIndexPatterns ? '/indexPatterns' : '/home'; + const redirectTarget = canManageIndexPatterns + ? '/management/opensearch-dashboards/indexPatterns' + : '/home'; let timeoutId: NodeJS.Timeout | undefined; if (timeoutId) { @@ -70,8 +72,8 @@ export const onRedirectNoIndexPattern = ( if (redirectTarget === '/home') { navigateToApp('home'); } else { - navigateToApp('indexPatterns', { - path: `?bannerMessage=${bannerMessage}`, + navigateToApp('management', { + path: `/opensearch-dashboards/indexPatterns?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 ee11d77b98f5..1522dcf97cb0 100644 --- a/src/plugins/data/public/search/errors/painless_error.tsx +++ b/src/plugins/data/public/search/errors/painless_error.tsx @@ -53,7 +53,9 @@ export class PainlessError extends OsdError { public getErrorMessage(application: ApplicationStart) { function onClick() { - application.navigateToApp('indexPatterns'); + application.navigateToApp('management', { + path: `/opensearch-dashboards/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 391adf6a973f..5f0864bac926 100644 --- a/src/plugins/data/server/saved_objects/index_patterns.ts +++ b/src/plugins/data/server/saved_objects/index_patterns.ts @@ -43,11 +43,15 @@ export const indexPatternSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/indexPatterns/patterns/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/indexPatterns/patterns/${encodeURIComponent( + obj.id + )}`; }, getInAppUrl(obj) { return { - path: `/app/indexPatterns/patterns/${encodeURIComponent(obj.id)}`, + path: `/app/management/opensearch-dashboards/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 0cbd9daf302f..3f31e7bd14b7 100644 --- a/src/plugins/data_source/server/saved_objects/data_source.ts +++ b/src/plugins/data_source/server/saved_objects/data_source.ts @@ -23,11 +23,11 @@ export const dataSource: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/dataSources/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/dataSources/${encodeURIComponent(obj.id)}`; }, getInAppUrl(obj) { return { - path: `/app/dataSources/${encodeURIComponent(obj.id)}`, + path: `/app/management/opensearch-dashboards/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 565ccff401dd..cfcfdd2ce430 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": ["dataSource", "indexPatternManagement"], + "requiredPlugins": ["management", "dataSource", "indexPatternManagement"], "optionalPlugins": [], "requiredBundles": ["opensearchDashboardsReact", "dataSource"], "extraPublicDirs": ["public/components/utils"] diff --git a/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx b/src/plugins/data_source_management/public/components/data_source_column/data_source_column.tsx index cd6fc7c17ae2..640eb1b369fd 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,7 +56,11 @@ export class DataSourceColumn implements IndexPatternTableColumn ?.map((dataSource) => { return { ...dataSource, - relativeUrl: basePath.prepend(`/app/dataSources/${encodeURIComponent(dataSource.id)}`), + relativeUrl: basePath.prepend( + `/app/management/opensearch-dashboards/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 deleted file mode 100644 index 3c5257e2e8d1..000000000000 --- a/src/plugins/data_source_management/public/components/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap +++ /dev/null @@ -1,13 +0,0 @@ -// 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 deleted file mode 100644 index 3cf0cdd26c99..000000000000 --- a/src/plugins/data_source_management/public/components/page_wrapper/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * 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 deleted file mode 100644 index 550eb3ee1cae..000000000000 --- a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 deleted file mode 100644 index 1b1949c334e4..000000000000 --- a/src/plugins/data_source_management/public/components/page_wrapper/page_wrapper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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/types.ts b/src/plugins/data_source_management/public/types.ts index 719d7702fcb9..bf0743468fd5 100644 --- a/src/plugins/data_source_management/public/types.ts +++ b/src/plugins/data_source_management/public/types.ts @@ -14,7 +14,6 @@ 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'; @@ -162,7 +161,3 @@ export interface SigV4Content extends SavedObjectAttributes { region: string; service?: SigV4ServiceName; } - -export interface DataSourceManagementStartDependencies { - data: DataPublicPluginStart; -} 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 45e15f809f63..1fa9680fa708 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: ( - + , - params: AppMountParameters, + params: ManagementAppMountParams, getMlCardState: () => MlCardState, dataSource?: DataSourcePluginSetup ) { @@ -85,17 +77,6 @@ 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, @@ -107,37 +88,33 @@ export async function mountManagementSection( docLinks, data, indexPatternManagementStart: indexPatternManagementStart as IndexPatternManagementStart, - setBreadcrumbs: setBreadcrumbsScope, + setBreadcrumbs: params.setBreadcrumbs, getMlCardState, dataSourceEnabled, hideLocalCluster, }; ReactDOM.render( - - - - - - - - - - - - - - - - - - - - - - - - , + + + + + + + + + + + + + + + + + + + + , params.element ); diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 0b9443a735a1..98eaab6160ee 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -29,13 +29,7 @@ */ import { i18n } from '@osd/i18n'; -import { - PluginInitializerContext, - CoreSetup, - CoreStart, - Plugin, - AppMountParameters, -} from 'src/core/public'; +import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; import { DataSourcePluginSetup, DataSourcePluginStart } from 'src/plugins/data_source/public'; import { UrlForwardingSetup } from '../../url_forwarding/public'; @@ -45,9 +39,10 @@ import { IndexPatternManagementServiceStart, } from './service'; -import { DEFAULT_APP_CATEGORIES } from '../../../core/public'; +import { ManagementSetup } from '../../management/public'; export interface IndexPatternManagementSetupDependencies { + management: ManagementSetup; urlForwarding: UrlForwardingSetup; dataSource?: DataSourcePluginSetup; } @@ -83,9 +78,15 @@ export class IndexPatternManagementPlugin core: CoreSetup, dependencies: IndexPatternManagementSetupDependencies ) { - const { urlForwarding, dataSource } = dependencies; + const { urlForwarding, management, dataSource } = dependencies; + + const opensearchDashboardsSection = management.sections.section.opensearchDashboards; + + if (!opensearchDashboardsSection) { + throw new Error('`opensearchDashboards` management section not found.'); + } - const newAppPath = IPM_APP_ID; + const newAppPath = `management/opensearch-dashboards/${IPM_APP_ID}`; const legacyPatternsPath = 'management/opensearch-dashboards/index_patterns'; urlForwarding.forwardApp( @@ -98,13 +99,11 @@ export class IndexPatternManagementPlugin return pathInApp && `/patterns${pathInApp}`; }); - // register it under Library - core.application.register({ + opensearchDashboardsSection.registerApp({ id: IPM_APP_ID, title: sectionsHeader, - order: 8100, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - mount: async (params: AppMountParameters) => { + order: 0, + mount: async (params) => { const { mountManagementSection } = await import('./management_app'); return mountManagementSection( 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 db7484e21379..9df3bb12caec 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 fcd417a42826..2e27ebd0cb6b 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/settings', + href: '/app/management', }); }; 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 e27a99fc4d44..a636f7ecdb7d 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/settings')} + href={addBasePath('/app/management')} > {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 0df7289caf75..438971862c79 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: ( - + ; serviceRegistry: ISavedObjectsManagementServiceRegistry; + mountParams: ManagementAppMountParams; dataSourceEnabled: boolean; hideLocalCluster: boolean; - 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, - appMountParams, + mountParams, serviceRegistry, dataSourceEnabled, hideLocalCluster, - title, - allowedObjectTypes, }: MountParams) => { const [coreStart, { data, uiActions }, pluginStart] = await core.getStartServices(); - const usedMountParams = appMountParams || ({} as ManagementAppMountParams); - const { element, history } = usedMountParams; - const { chrome } = coreStart; - const setBreadcrumbs = chrome.setBreadcrumbs; + const { element, history, setBreadcrumbs } = mountParams; if (allowedObjectTypes === undefined) { allowedObjectTypes = await getAllowedTypes(coreStart.http); } @@ -91,36 +90,31 @@ 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 501c4e96b171..d44cecb5b412 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 @@ -320,10 +320,10 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "1", "meta": Object { - "editUrl": "#/indexPatterns/patterns/1", + "editUrl": "#/management/opensearch-dashboards/indexPatterns/patterns/1", "icon": "indexPatternApp", "inAppUrl": Object { - "path": "/indexPatterns/patterns/1", + "path": "/management/opensearch-dashboards/indexPatterns/patterns/1", "uiCapabilitiesPath": "management.opensearchDashboards.indexPatterns", }, "title": "MyIndexPattern*", @@ -333,7 +333,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "2", "meta": Object { - "editUrl": "/objects/savedSearches/2", + "editUrl": "/management/opensearch-dashboards/objects/savedSearches/2", "icon": "search", "inAppUrl": Object { "path": "/discover/2", @@ -346,7 +346,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "3", "meta": Object { - "editUrl": "/objects/savedDashboards/3", + "editUrl": "/management/opensearch-dashboards/objects/savedDashboards/3", "icon": "dashboardApp", "inAppUrl": Object { "path": "/dashboard/3", @@ -359,7 +359,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` Object { "id": "4", "meta": Object { - "editUrl": "/objects/savedVisualizations/4", + "editUrl": "/management/opensearch-dashboards/objects/savedVisualizations/4", "icon": "visualizeApp", "inAppUrl": Object { "path": "/edit/4", 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 dace178024f2..038e1aaf2d8f 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 @@ -10,7 +10,13 @@ exports[`Header should render normally 1`] = ` grow={false} > -

    +

    + +

    void; onImport: () => void; onRefresh: () => void; filteredCount: number; - title: string; }) => ( -

    {title}

    +

    + +

    diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx index 5afdbacf6dff..1f21e5990c74 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.test.tsx @@ -53,7 +53,7 @@ describe('Relationships', () => { id: '1', relationship: 'parent', meta: { - editUrl: '/objects/savedSearches/1', + editUrl: '/management/opensearch-dashboards/objects/savedSearches/1', icon: 'search', inAppUrl: { path: '/app/discover#//1', @@ -67,7 +67,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/objects/savedVisualizations/2', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -85,9 +85,9 @@ describe('Relationships', () => { meta: { title: 'MyIndexPattern*', icon: 'indexPatternApp', - editUrl: '#/indexPatterns/patterns/1', + editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', inAppUrl: { - path: '/indexPatterns/patterns/1', + path: '/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -120,10 +120,10 @@ describe('Relationships', () => { id: '1', relationship: 'child', meta: { - editUrl: '/indexPatterns/patterns/1', + editUrl: '/management/opensearch-dashboards/indexPatterns/patterns/1', icon: 'indexPatternApp', inAppUrl: { - path: '/app/indexPatterns/patterns/1', + path: '/app/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, title: 'My Index Pattern', @@ -134,7 +134,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/objects/savedVisualizations/2', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -152,7 +152,7 @@ describe('Relationships', () => { meta: { title: 'MySearch', icon: 'search', - editUrl: '/objects/savedSearches/1', + editUrl: '/management/opensearch-dashboards/objects/savedSearches/1', inAppUrl: { path: '/discover/1', uiCapabilitiesPath: 'discover.show', @@ -187,7 +187,7 @@ describe('Relationships', () => { id: '1', relationship: 'parent', meta: { - editUrl: '/objects/savedDashboards/1', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', icon: 'dashboardApp', inAppUrl: { path: '/app/opensearch-dashboards#/dashboard/1', @@ -201,7 +201,7 @@ describe('Relationships', () => { id: '2', relationship: 'parent', meta: { - editUrl: '/objects/savedDashboards/2', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/2', icon: 'dashboardApp', inAppUrl: { path: '/app/opensearch-dashboards#/dashboard/2', @@ -219,7 +219,7 @@ describe('Relationships', () => { meta: { title: 'MyViz', icon: 'visualizeApp', - editUrl: '/objects/savedVisualizations/1', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', inAppUrl: { path: '/edit/1', uiCapabilitiesPath: 'visualize.show', @@ -256,7 +256,7 @@ describe('Relationships', () => { meta: { title: 'MyViz', icon: 'visualizeApp', - editUrl: '/objects/savedVisualizations/1', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', inAppUrl: { path: '/edit/1', uiCapabilitiesPath: 'visualize.show', @@ -272,7 +272,7 @@ describe('Relationships', () => { meta: { title: 'MyAugmentVisObject', icon: 'savedObject', - editUrl: '/objects/savedAugmentVis/1', + editUrl: '/management/opensearch-dashboards/objects/savedAugmentVis/1', }, }, close: jest.fn(), @@ -303,7 +303,7 @@ describe('Relationships', () => { id: '1', relationship: 'child', meta: { - editUrl: '/objects/savedVisualizations/1', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/1', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/1', @@ -317,7 +317,7 @@ describe('Relationships', () => { id: '2', relationship: 'child', meta: { - editUrl: '/objects/savedVisualizations/2', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/2', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/2', @@ -335,7 +335,7 @@ describe('Relationships', () => { meta: { title: 'MyDashboard', icon: 'dashboardApp', - editUrl: '/objects/savedDashboards/1', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', inAppUrl: { path: '/dashboard/1', uiCapabilitiesPath: 'dashboard.show', @@ -375,7 +375,7 @@ describe('Relationships', () => { meta: { title: 'MyDashboard', icon: 'dashboardApp', - editUrl: '/objects/savedDashboards/1', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/1', inAppUrl: { path: '/dashboard/1', uiCapabilitiesPath: 'dashboard.show', 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 c8e378b93b92..7e5bb318f4d0 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 @@ -51,9 +51,9 @@ const defaultProps: TableProps = { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/indexPatterns/patterns/1', + editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', inAppUrl: { - path: '/indexPatterns/patterns/1', + path: '/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -91,9 +91,9 @@ const defaultProps: TableProps = { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/indexPatterns/patterns/1', + editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', inAppUrl: { - path: '/indexPatterns/patterns/1', + path: '/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, 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 612ace1587c2..a0a6329ac5e0 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 @@ -179,9 +179,9 @@ describe('SavedObjectsTable', () => { meta: { title: `MyIndexPattern*`, icon: 'indexPatternApp', - editUrl: '#/indexPatterns/patterns/1', + editUrl: '#/management/opensearch-dashboards/indexPatterns/patterns/1', inAppUrl: { - path: '/indexPatterns/patterns/1', + path: '/management/opensearch-dashboards/indexPatterns/patterns/1', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, }, @@ -192,7 +192,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MySearch`, icon: 'search', - editUrl: '/objects/savedSearches/2', + editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', inAppUrl: { path: '/discover/2', uiCapabilitiesPath: 'discover.show', @@ -205,7 +205,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MyDashboard`, icon: 'dashboardApp', - editUrl: '/objects/savedDashboards/3', + editUrl: '/management/opensearch-dashboards/objects/savedDashboards/3', inAppUrl: { path: '/dashboard/3', uiCapabilitiesPath: 'dashboard.show', @@ -218,7 +218,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MyViz`, icon: 'visualizeApp', - editUrl: '/objects/savedVisualizations/4', + editUrl: '/management/opensearch-dashboards/objects/savedVisualizations/4', inAppUrl: { path: '/edit/4', uiCapabilitiesPath: 'visualize.show', @@ -533,7 +533,7 @@ describe('SavedObjectsTable', () => { meta: { title: `MySearch`, icon: 'search', - editUrl: '/objects/savedSearches/2', + editUrl: '/management/opensearch-dashboards/objects/savedSearches/2', inAppUrl: { path: '/discover/2', uiCapabilitiesPath: 'discover.show', @@ -548,7 +548,7 @@ describe('SavedObjectsTable', () => { type: 'search', meta: { title: 'MySearch', - editUrl: '/objects/savedSearches/2', + editUrl: '/management/opensearch-dashboards/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 dba9277ecf2d..28d041563a71 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 @@ -118,7 +118,6 @@ export interface SavedObjectsTableProps { dateFormat: string; dataSourceEnabled: boolean; hideLocalCluster: boolean; - title: string; } export interface SavedObjectsTableState { @@ -589,7 +588,9 @@ 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 deleted file mode 100644 index 3c5257e2e8d1..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/page_wrapper/__snapshots__/page_wrapper.test.tsx.snap +++ /dev/null @@ -1,13 +0,0 @@ -// 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 deleted file mode 100644 index 3cf0cdd26c99..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/page_wrapper/index.ts +++ /dev/null @@ -1,6 +0,0 @@ -/* - * 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 deleted file mode 100644 index 550eb3ee1cae..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.test.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/* - * 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 deleted file mode 100644 index 1b1949c334e4..000000000000 --- a/src/plugins/saved_objects_management/public/management_section/page_wrapper/page_wrapper.tsx +++ /dev/null @@ -1,21 +0,0 @@ -/* - * 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 52177ed40df2..ad5f83fc8238 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'; @@ -51,7 +51,6 @@ const SavedObjectsTablePage = ({ setBreadcrumbs, dataSourceEnabled, hideLocalCluster, - title, }: { coreStart: CoreStart; dataStart: DataPublicPluginStart; @@ -63,7 +62,6 @@ const SavedObjectsTablePage = ({ setBreadcrumbs: (crumbs: ChromeBreadcrumb[]) => void; dataSourceEnabled: boolean; hideLocalCluster: boolean; - title: string; }) => { const capabilities = coreStart.application.capabilities; const itemsPerPage = coreStart.uiSettings.get('savedObjects:perPage', 50); @@ -72,11 +70,13 @@ const SavedObjectsTablePage = ({ useEffect(() => { setBreadcrumbs([ { - text: title, - href: undefined, + text: i18n.translate('savedObjectsManagement.breadcrumb.index', { + defaultMessage: 'Saved objects', + }), + href: '/', }, ]); - }, [setBreadcrumbs, title]); + }, [setBreadcrumbs]); return ( ); }; diff --git a/src/plugins/saved_objects_management/public/plugin.test.ts b/src/plugins/saved_objects_management/public/plugin.test.ts index 149cee7c5c86..c8e762f73dcc 100644 --- a/src/plugins/saved_objects_management/public/plugin.test.ts +++ b/src/plugins/saved_objects_management/public/plugin.test.ts @@ -28,23 +28,12 @@ * 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; @@ -61,22 +50,12 @@ 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, - application: { - ...coreSetup.application, - register: registerMock, - }, - }, - { - home: homeSetup, - management: managementSetup, - uiActions: uiActionsSetup, - } - ); + await plugin.setup(coreSetup, { + home: homeSetup, + management: managementSetup, + uiActions: uiActionsSetup, + }); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledTimes(1); expect(homeSetup.featureCatalogue.register).toHaveBeenCalledWith( @@ -84,38 +63,6 @@ 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 66c199b5f5a4..035bf096a1af 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 { AppMountParameters, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin } from 'src/core/public'; import { DataSourcePluginSetup } from 'src/plugins/data_source/public'; import { VisBuilderStart } from '../../vis_builder/public'; @@ -60,12 +60,6 @@ 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; @@ -111,70 +105,9 @@ export class SavedObjectsManagementPlugin private namespaceService = new SavedObjectsManagementNamespaceService(); private serviceRegistry = new SavedObjectsManagementServiceRegistry(); - private registerLibrarySubApp( - coreSetup: CoreSetup, - dataSourceEnabled: boolean, - hideLocalCluster: boolean - ) { - const core = coreSetup; - const mountWrapper = ({ - title, - allowedObjectTypes, - }: { - title: string; - allowedObjectTypes?: string[]; - }) => async (appMountParams: AppMountParameters) => { - const { mountManagementSection } = await import('./management_section'); - return mountManagementSection({ - core, - serviceRegistry: this.serviceRegistry, - appMountParams, - title, - allowedObjectTypes, - dataSourceEnabled, - hideLocalCluster, - }); - }; - - /** - * Register saved objects overview & saved search & saved query here - */ - core.application.register({ - id: 'objects', - title: MANAGE_LIBRARY_TITLE_WORDINGS, - order: 10000, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - mount: mountWrapper({ - title: MANAGE_LIBRARY_TITLE_WORDINGS, - }), - }); - - core.application.register({ - id: 'objects_searches', - title: SAVED_SEARCHES_WORDINGS, - order: 8000, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - mount: mountWrapper({ - title: SAVED_SEARCHES_WORDINGS, - allowedObjectTypes: ['search'], - }), - }); - - core.application.register({ - id: 'objects_query', - title: SAVED_QUERIES_WORDINGS, - order: 8001, - category: DEFAULT_APP_CATEGORIES.opensearchDashboards, - mount: mountWrapper({ - title: SAVED_QUERIES_WORDINGS, - allowedObjectTypes: ['query'], - }), - }); - } - public setup( core: CoreSetup, - { home, uiActions, dataSource }: SetupDependencies + { home, management, uiActions, dataSource }: SetupDependencies ): SavedObjectsManagementPluginSetup { const actionSetup = this.actionService.setup(); const columnSetup = this.columnService.setup(); @@ -191,20 +124,37 @@ export class SavedObjectsManagementPlugin 'Import, export, and manage your saved searches, visualizations, and dashboards.', }), icon: 'savedObjectsApp', - path: '/app/objects', + path: '/app/management/opensearch-dashboards/objects', showOnHomePage: false, category: FeatureCatalogueCategory.ADMIN, }); } + const opensearchDashboardsSection = management.sections.section.opensearchDashboards; + opensearchDashboardsSection.registerApp({ + id: 'objects', + title: i18n.translate('savedObjectsManagement.managementSectionLabel', { + defaultMessage: 'Saved objects', + }), + order: 1, + mount: async (mountParams) => { + const { mountManagementSection } = await import('./management_section'); + return mountManagementSection({ + core, + serviceRegistry: this.serviceRegistry, + mountParams, + dataSourceEnabled: !!dataSource, + hideLocalCluster: dataSource?.hideLocalCluster ?? false, + }); + }, + }); + // sets up the context mappings and registers any triggers/actions for the plugin bootstrap(uiActions); // depends on `getStartServices`, should not be awaited registerServices(this.serviceRegistry, core.getStartServices); - this.registerLibrarySubApp(core, !!dataSource, dataSource?.hideLocalCluster ?? false); - return { actions: actionSetup, columns: columnSetup, diff --git a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts index 558649f900bd..52188d52998a 100644 --- a/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts +++ b/src/plugins/vis_augmenter/server/saved_objects/augment_vis.ts @@ -15,7 +15,9 @@ export const augmentVisSavedObjectType: SavedObjectsType = { return `augment-vis-${obj?.attributes?.originPlugin}`; }, getEditUrl(obj) { - return `/objects/savedAugmentVis/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/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 2d329227491c..029557010bee 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,7 +20,8 @@ export const visBuilderSavedObjectType: SavedObjectsType = { defaultSearchField: 'title', importableAndExportable: true, getTitle: ({ attributes: { title } }: SavedObject) => title, - getEditUrl: ({ id }: SavedObject) => `/objects/savedVisBuilder/${encodeURIComponent(id)}`, + getEditUrl: ({ id }: SavedObject) => + `/management/opensearch-dashboards/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 4e46c83db157..15a926b3f81d 100644 --- a/src/plugins/visualizations/server/saved_objects/visualization.ts +++ b/src/plugins/visualizations/server/saved_objects/visualization.ts @@ -43,7 +43,9 @@ export const visualizationSavedObjectType: SavedObjectsType = { return obj.attributes.title; }, getEditUrl(obj) { - return `/objects/savedVisualizations/${encodeURIComponent(obj.id)}`; + return `/management/opensearch-dashboards/objects/savedVisualizations/${encodeURIComponent( + obj.id + )}`; }, getInAppUrl(obj) { return { diff --git a/test/api_integration/apis/saved_objects_management/find.ts b/test/api_integration/apis/saved_objects_management/find.ts index 065541a36d77..a82d4e792cdc 100644 --- a/test/api_integration/apis/saved_objects_management/find.ts +++ b/test/api_integration/apis/saved_objects_management/find.ts @@ -73,7 +73,8 @@ export default function ({ getService }: FtrProviderContext) { score: 0, updated_at: '2017-09-21T18:51:23.794Z', meta: { - editUrl: '/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/dd7caf20-9efd-11e7-acb3-3dab96693fab', icon: 'visualizeApp', inAppUrl: { path: '/app/visualize#/edit/dd7caf20-9efd-11e7-acb3-3dab96693fab', @@ -236,7 +237,8 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -254,7 +256,8 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'dashboardApp', title: 'Dashboard', - editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -272,7 +275,8 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -282,7 +286,8 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[1].meta).to.eql({ icon: 'visualizeApp', title: 'Visualization', - editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -300,9 +305,11 @@ export default function ({ getService }: FtrProviderContext) { expect(resp.body.saved_objects[0].meta).to.eql({ icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: + '/app/management/opensearch-dashboards/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 77e838cfed42..f0af2d8d9e79 100644 --- a/test/api_integration/apis/saved_objects_management/relationships.ts +++ b/test/api_integration/apis/saved_objects_management/relationships.ts @@ -94,9 +94,11 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'saved_objects*', icon: 'indexPatternApp', - editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: + '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -109,7 +111,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { title: 'VisualizationFromSavedSearch', icon: 'visualizeApp', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -134,9 +137,11 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'indexPatternApp', title: 'saved_objects*', - editUrl: '/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', inAppUrl: { - path: '/app/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', + path: + '/app/management/opensearch-dashboards/indexPatterns/patterns/8963ca30-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'management.opensearchDashboards.indexPatterns', }, namespaceType: 'single', @@ -149,7 +154,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -193,7 +199,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -208,7 +215,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -231,7 +239,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -246,7 +255,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'VisualizationFromSavedSearch', - editUrl: '/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/a42c0580-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/a42c0580-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -290,7 +300,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -305,7 +316,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'dashboardApp', title: 'Dashboard', - editUrl: '/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedDashboards/b70c7ae0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/dashboards#/view/b70c7ae0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'dashboard.show', @@ -330,7 +342,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -373,7 +386,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', @@ -388,7 +402,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'visualizeApp', title: 'Visualization', - editUrl: '/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedVisualizations/add810b0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/visualize#/edit/add810b0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'visualize.show', @@ -413,7 +428,8 @@ export default function ({ getService }: FtrProviderContext) { meta: { icon: 'discoverApp', title: 'OneRecord', - editUrl: '/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', + editUrl: + '/management/opensearch-dashboards/objects/savedSearches/960372e0-3224-11e8-a572-ffca06da1357', inAppUrl: { path: '/app/discover#/view/960372e0-3224-11e8-a572-ffca06da1357', uiCapabilitiesPath: 'discover.show', diff --git a/test/functional/apps/dashboard/create_and_add_embeddables.js b/test/functional/apps/dashboard/create_and_add_embeddables.js index 6701ae0fc94c..3b6e8a243556 100644 --- a/test/functional/apps/dashboard/create_and_add_embeddables.js +++ b/test/functional/apps/dashboard/create_and_add_embeddables.js @@ -112,7 +112,8 @@ export default function ({ getService, getPageObjects }) { describe('is false', () => { before(async () => { - await PageObjects.common.navigateToApp('settings'); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); }); @@ -126,7 +127,8 @@ export default function ({ getService, getPageObjects }) { }); after(async () => { - await PageObjects.settings.navigateTo(); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); 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 225e0bf1d034..4c82cfe8006c 100644 --- a/test/functional/apps/dashboard/time_zones.js +++ b/test/functional/apps/dashboard/time_zones.js @@ -51,6 +51,7 @@ 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'), @@ -76,6 +77,7 @@ 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/_import_objects.js b/test/functional/apps/management/_import_objects.js index f481960b2f77..a4a919aedcd9 100644 --- a/test/functional/apps/management/_import_objects.js +++ b/test/functional/apps/management/_import_objects.js @@ -46,6 +46,7 @@ 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(); }); @@ -214,6 +215,7 @@ 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 1d154718c26d..b7214590ebd4 100644 --- a/test/functional/apps/management/_index_pattern_create_delete.js +++ b/test/functional/apps/management/_index_pattern_create_delete.js @@ -129,7 +129,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('indexPatterns'); + expect(currentUrl).to.contain('management/opensearch-dashboards/indexPatterns'); }); }); }); diff --git a/test/functional/apps/management/_mgmt_import_saved_objects.js b/test/functional/apps/management/_mgmt_import_saved_objects.js index c04fa88b0dec..c5f852bae5c0 100644 --- a/test/functional/apps/management/_mgmt_import_saved_objects.js +++ b/test/functional/apps/management/_mgmt_import_saved_objects.js @@ -42,6 +42,7 @@ 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 637f7073d517..0e310953e8a2 100644 --- a/test/functional/apps/management/_opensearch_dashboards_settings.js +++ b/test/functional/apps/management/_opensearch_dashboards_settings.js @@ -39,10 +39,12 @@ export default function ({ getService, getPageObjects }) { before(async function () { // delete .kibana index and then wait for OpenSearch Dashboards to re-create it await opensearchDashboardsServer.uiSettings.replace({}); + await PageObjects.settings.navigateTo(); await PageObjects.settings.createIndexPattern('logstash-*'); }); after(async function afterAll() { + await PageObjects.settings.navigateTo(); await PageObjects.settings.clickOpenSearchDashboardsIndexPatterns(); await PageObjects.settings.removeLogstashIndexPatternIfExist(); }); @@ -88,6 +90,7 @@ 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( @@ -110,7 +113,8 @@ export default function ({ getService, getPageObjects }) { it("changing 'state:storeInSessionStorage' also takes effect without full page reload", async () => { await PageObjects.dashboard.preserveCrossAppState(); - await PageObjects.settings.navigateTo(); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); 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 3ef74f39cfb9..8a4659630ee1 100644 --- a/test/functional/apps/management/_scripted_fields.js +++ b/test/functional/apps/management/_scripted_fields.js @@ -75,11 +75,13 @@ 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(); @@ -97,6 +99,7 @@ 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()); @@ -130,6 +133,7 @@ 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()); @@ -251,6 +255,7 @@ 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()); @@ -347,6 +352,7 @@ 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()); @@ -446,6 +452,7 @@ 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 55ec8895608c..b1714c425aac 100644 --- a/test/functional/apps/management/_scripted_fields_filter.js +++ b/test/functional/apps/management/_scripted_fields_filter.js @@ -58,6 +58,7 @@ 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/saved_objects_management/edit_saved_object.ts b/test/functional/apps/saved_objects_management/edit_saved_object.ts index 64fe2bf199b0..1534c710179b 100644 --- a/test/functional/apps/saved_objects_management/edit_saved_object.ts +++ b/test/functional/apps/saved_objects_management/edit_saved_object.ts @@ -88,6 +88,7 @@ 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(); @@ -153,6 +154,7 @@ 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 52cbc8e5fec9..37f07e932ee5 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.settings.navigateTo(); + await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); 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.settings.navigateTo(); + await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); 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.settings.navigateTo(); + await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); await PageObjects.settings.toggleAdvancedSettingCheckbox('theme:darkMode'); await PageObjects.common.navigateToApp('home'); }); after(async function () { - await PageObjects.settings.navigateTo(); + await PageObjects.common.navigateToApp('management/opensearch-dashboards/settings'); 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.settings.navigateTo(); + await PageObjects.common.navigateToApp('settings'); 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 1ba36b4b9f90..82ecbcb2a655 100644 --- a/test/functional/apps/visualize/_lab_mode.js +++ b/test/functional/apps/visualize/_lab_mode.js @@ -47,7 +47,8 @@ export default function ({ getService, getPageObjects }) { log.info('found saved search before toggling enableLabs mode'); // Navigate to advanced setting and disable lab mode - await PageObjects.settings.navigateTo(); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); await PageObjects.settings.toggleAdvancedSettingCheckbox(VISUALIZE_ENABLE_LABS_SETTING); // Expect the discover still to list that saved visualization in the open list @@ -60,7 +61,8 @@ export default function ({ getService, getPageObjects }) { after(async () => { await PageObjects.discover.closeLoadSaveSearchPanel(); - await PageObjects.settings.navigateTo(); + await PageObjects.header.clickStackManagement(); + await PageObjects.settings.clickOpenSearchDashboardsSettings(); 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 075e7fa22907..a5123434115d 100644 --- a/test/functional/apps/visualize/_tag_cloud.js +++ b/test/functional/apps/visualize/_tag_cloud.js @@ -160,6 +160,7 @@ 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); @@ -177,6 +178,7 @@ 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 ac9ac6085d2b..87d4302b2a15 100644 --- a/test/functional/config.js +++ b/test/functional/config.js @@ -101,6 +101,10 @@ 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 1e0106229d3d..af2bf046e3a9 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 PageObjects.common.navigateToApp('settings'); + await testSubjects.click('settings'); await PageObjects.header.waitUntilLoadingHasFinished(); await testSubjects.existOrFail('managementSettingsTitle'); } async clickOpenSearchDashboardsSavedObjects() { - await PageObjects.common.navigateToApp('objects'); + await testSubjects.click('objects'); await PageObjects.savedObjects.waitTableIsLoaded(); } async clickOpenSearchDashboardsIndexPatterns() { log.debug('clickOpenSearchDashboardsIndexPatterns link'); - await PageObjects.common.navigateToApp('indexPatterns'); + await testSubjects.click('indexPatterns'); await PageObjects.header.waitUntilLoadingHasFinished(); } From 035dc4c05fb35cb8aa1e0536ecfe94aedbe98e31 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Thu, 7 Mar 2024 17:40:45 +0800 Subject: [PATCH 24/34] Add a workspace dropdown menu in left navigation bar (#282) In this commit, the workspace plugin registered a workspace dropdown menu on the top of left navigation bar. This allows user to quickly switch the current workspace and navigate to workspace create page and workspace list page from the dropdown sticky on the top of the left nav bar. Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan --- src/core/public/index.ts | 2 +- src/core/public/mocks.ts | 1 + src/core/public/utils/index.ts | 1 + .../workspace/workspaces_service.mock.ts | 41 ++-- src/plugins/workspace/common/constants.ts | 2 + .../workspace_menu/workspace_menu.test.tsx | 120 +++++++++++ .../workspace_menu/workspace_menu.tsx | 188 ++++++++++++++++++ src/plugins/workspace/public/plugin.test.ts | 7 + src/plugins/workspace/public/plugin.ts | 13 +- .../public/render_workspace_menu.tsx | 12 ++ 10 files changed, 368 insertions(+), 19 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx create mode 100644 src/plugins/workspace/public/render_workspace_menu.tsx diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 993618c08fe9..7003732433e5 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -360,4 +360,4 @@ export { __osdBootstrap__ } from './osd_bootstrap'; export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; -export { WORKSPACE_TYPE } from '../utils'; +export { WORKSPACE_TYPE, cleanWorkspaceId } from '../utils'; diff --git a/src/core/public/mocks.ts b/src/core/public/mocks.ts index 3acc71424b91..05c3b7d18d1b 100644 --- a/src/core/public/mocks.ts +++ b/src/core/public/mocks.ts @@ -74,6 +74,7 @@ function createCoreSetupMock({ } = {}) { const mock = { application: applicationServiceMock.createSetupContract(), + chrome: chromeServiceMock.createSetupContract(), context: contextServiceMock.createSetupContract(), docLinks: docLinksServiceMock.createSetupContract(), fatalErrors: fatalErrorsServiceMock.createSetupContract(), diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index c0c6f2582e9c..30055b0ff81c 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -36,4 +36,5 @@ export { WORKSPACE_TYPE, formatUrlWithWorkspaceId, getWorkspaceIdFromUrl, + cleanWorkspaceId, } from '../../utils'; diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index ab8bda09730a..2c81cd888916 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -8,24 +8,31 @@ import type { PublicMethodsOf } from '@osd/utility-types'; import { WorkspacesService } from './workspaces_service'; import { WorkspaceObject } 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 createWorkspacesSetupContractMock = () => { + const currentWorkspaceId$ = new BehaviorSubject(''); + const workspaceList$ = new BehaviorSubject([]); + const currentWorkspace$ = new BehaviorSubject(null); + const initialized$ = new BehaviorSubject(false); + return { + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + }; +}; -const createWorkspacesStartContractMock = () => ({ - currentWorkspaceId$, - workspaceList$, - currentWorkspace$, - initialized$, -}); +const createWorkspacesStartContractMock = () => { + const currentWorkspaceId$ = new BehaviorSubject(''); + const workspaceList$ = new BehaviorSubject([]); + const currentWorkspace$ = new BehaviorSubject(null); + const initialized$ = new BehaviorSubject(false); + return { + currentWorkspaceId$, + workspaceList$, + currentWorkspace$, + initialized$, + }; +}; export type WorkspacesServiceContract = PublicMethodsOf; const createMock = (): jest.Mocked => ({ diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index fe702116b8ef..5bd8ab34c313 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -3,6 +3,8 @@ * SPDX-License-Identifier: Apache-2.0 */ +export const WORKSPACE_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'; export const WORKSPACE_FATAL_ERROR_APP_ID = 'workspace_fatal_error'; diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx new file mode 100644 index 000000000000..c63b232bb232 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.test.tsx @@ -0,0 +1,120 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; + +import { WorkspaceMenu } from './workspace_menu'; +import { coreMock } from '../../../../../core/public/mocks'; +import { CoreStart } from '../../../../../core/public'; + +describe('', () => { + let coreStartMock: CoreStart; + + beforeEach(() => { + coreStartMock = coreMock.createStart(); + coreStartMock.workspaces.initialized$.next(true); + jest.spyOn(coreStartMock.application, 'getUrlForApp').mockImplementation((appId: string) => { + return `https://test.com/app/${appId}`; + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + it('should display a list of workspaces in the dropdown', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + + expect(screen.getByText(/workspace 1/i)).toBeInTheDocument(); + expect(screen.getByText(/workspace 2/i)).toBeInTheDocument(); + }); + + it('should display current workspace name', () => { + coreStartMock.workspaces.currentWorkspace$.next({ id: 'workspace-1', name: 'workspace 1' }); + render(); + expect(screen.getByText(/workspace 1/i)).toBeInTheDocument(); + }); + + it('should close the workspace dropdown list', async () => { + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + + expect(screen.getByLabelText(/close workspace dropdown/i)).toBeInTheDocument(); + fireEvent.click(screen.getByLabelText(/close workspace dropdown/i)); + await waitFor(() => { + expect(screen.queryByLabelText(/close workspace dropdown/i)).not.toBeInTheDocument(); + }); + }); + + it('should navigate to the workspace', () => { + coreStartMock.workspaces.workspaceList$.next([ + { id: 'workspace-1', name: 'workspace 1' }, + { id: 'workspace-2', name: 'workspace 2' }, + ]); + + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + fireEvent.click(screen.getByText(/workspace 1/i)); + + expect(window.location.assign).toHaveBeenCalledWith( + 'https://test.com/w/workspace-1/app/workspace_overview' + ); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); + + it('should navigate to create workspace page', () => { + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + fireEvent.click(screen.getByText(/create workspace/i)); + expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_create'); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); + + it('should navigate to workspace list page', () => { + const originalLocation = window.location; + Object.defineProperty(window, 'location', { + value: { + assign: jest.fn(), + }, + }); + + render(); + fireEvent.click(screen.getByText(/select a workspace/i)); + fireEvent.click(screen.getByText(/all workspace/i)); + expect(window.location.assign).toHaveBeenCalledWith('https://test.com/app/workspace_list'); + + Object.defineProperty(window, 'location', { + value: originalLocation, + }); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx new file mode 100644 index 000000000000..a5b250e6b89c --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx @@ -0,0 +1,188 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { useObservable } from 'react-use'; +import { + EuiButtonIcon, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiListGroup, + EuiListGroupItem, + EuiPopover, + EuiText, +} from '@elastic/eui'; +import type { EuiContextMenuPanelItemDescriptor } from '@elastic/eui'; + +import { + WORKSPACE_CREATE_APP_ID, + WORKSPACE_LIST_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, +} from '../../../common/constants'; +import { cleanWorkspaceId, formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { CoreStart, WorkspaceObject } from '../../../../../core/public'; + +interface Props { + coreStart: CoreStart; +} + +function getFilteredWorkspaceList( + workspaceList: WorkspaceObject[], + currentWorkspace: WorkspaceObject | null +): WorkspaceObject[] { + // list top5 workspaces and place the current workspace at the top + return [ + ...(currentWorkspace ? [currentWorkspace] : []), + ...workspaceList.filter((workspace) => workspace.id !== currentWorkspace?.id), + ].slice(0, 5); +} + +export const WorkspaceMenu = ({ coreStart }: Props) => { + const [isPopoverOpen, setPopover] = useState(false); + const currentWorkspace = useObservable(coreStart.workspaces.currentWorkspace$, null); + const workspaceList = useObservable(coreStart.workspaces.workspaceList$, []); + + const defaultHeaderName = i18n.translate( + 'core.ui.primaryNav.workspacePickerMenu.defaultHeaderName', + { + defaultMessage: 'Select a workspace', + } + ); + const filteredWorkspaceList = getFilteredWorkspaceList(workspaceList, currentWorkspace); + const currentWorkspaceName = currentWorkspace?.name ?? defaultHeaderName; + + const openPopover = () => { + setPopover(!isPopoverOpen); + }; + + const closePopover = () => { + setPopover(false); + }; + + const workspaceToItem = (workspace: WorkspaceObject) => { + const workspaceURL = formatUrlWithWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: false, + }), + workspace.id, + coreStart.http.basePath + ); + const name = + currentWorkspace?.name === workspace.name ? ( + + {workspace.name} + + ) : ( + workspace.name + ); + return { + name, + key: workspace.id, + icon: , + onClick: () => { + window.location.assign(workspaceURL); + }, + }; + }; + + const getWorkspaceListItems = () => { + const workspaceListItems: EuiContextMenuPanelItemDescriptor[] = filteredWorkspaceList.map( + workspaceToItem + ); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.createWorkspace', { + defaultMessage: 'Create workspace', + }), + key: WORKSPACE_CREATE_APP_ID, + onClick: () => { + window.location.assign( + cleanWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }) + ) + ); + }, + }); + workspaceListItems.push({ + icon: , + name: i18n.translate('core.ui.primaryNav.workspaceContextMenu.allWorkspace', { + defaultMessage: 'All workspaces', + }), + key: WORKSPACE_LIST_APP_ID, + onClick: () => { + window.location.assign( + cleanWorkspaceId( + coreStart.application.getUrlForApp(WORKSPACE_LIST_APP_ID, { + absolute: false, + }) + ) + ); + }, + }); + return workspaceListItems; + }; + + const currentWorkspaceButton = ( + <> + + + + + ); + + const currentWorkspaceTitle = ( + + + {currentWorkspaceName} + + + + + + ); + + const panels = [ + { + id: 0, + title: currentWorkspaceTitle, + items: getWorkspaceListItems(), + }, + ]; + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index acbb9abe044e..35e349f1d07e 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -158,4 +158,11 @@ describe('Workspace plugin', () => { coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); }); + + it('#setup register workspace dropdown menu when setup', async () => { + const setupMock = coreMock.createSetup(); + const workspacePlugin = new WorkspacePlugin(); + await workspacePlugin.setup(setupMock); + expect(setupMock.chrome.registerCollapsibleNavHeader).toBeCalledTimes(1); + }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 4c97070bd781..d86d8555bfcf 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -20,8 +20,9 @@ import { } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; -import { WorkspaceClient } from './workspace_client'; +import { renderWorkspaceMenu } from './render_workspace_menu'; import { Services } from './types'; +import { WorkspaceClient } from './workspace_client'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; @@ -150,6 +151,16 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }, }); + /** + * Register workspace dropdown selector on the top of left navigation menu + */ + core.chrome.registerCollapsibleNavHeader(() => { + if (!this.coreStart) { + return null; + } + return renderWorkspaceMenu(this.coreStart); + }); + return {}; } 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..2ce3ee21ee00 --- /dev/null +++ b/src/plugins/workspace/public/render_workspace_menu.tsx @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { CoreStart } from '../../../core/public'; +import { WorkspaceMenu } from './components/workspace_menu/workspace_menu'; + +export function renderWorkspaceMenu(coreStart: CoreStart) { + return ; +} From c726157a4754b37323aa06798218ec82046c30a6 Mon Sep 17 00:00:00 2001 From: yuboluo <15242088755@163.com> Date: Thu, 7 Mar 2024 19:52:45 +0800 Subject: [PATCH 25/34] remove management workspace (#278) (#281) * remove management workspace (#278) * remove management workspace * delete unnecessary code comments * delete comments # Conflicts: # src/core/public/index.ts # src/core/public/utils/index.ts # src/core/utils/constants.ts # src/plugins/workspace/public/components/workspace_creator/workspace_form.tsx # src/plugins/workspace/public/components/workspace_menu/workspace_menu.tsx # src/plugins/workspace/server/workspace_client.ts * update typescript version to 4.6.4 * delete useless code and add a lost yarn.lock * delete yarn.lock --- src/core/server/index.ts | 8 +---- src/core/utils/constants.ts | 4 --- src/core/utils/index.ts | 8 +---- .../server/integration_tests/routes.test.ts | 8 ++--- .../workspace/server/workspace_client.ts | 29 ------------------- 5 files changed, 6 insertions(+), 51 deletions(-) diff --git a/src/core/server/index.ts b/src/core/server/index.ts index f3edadf21895..1af8818ab0f0 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -354,13 +354,7 @@ export { } from './metrics'; export { AppCategory, WorkspaceAttribute } from '../types'; -export { - DEFAULT_APP_CATEGORIES, - PUBLIC_WORKSPACE_ID, - MANAGEMENT_WORKSPACE_ID, - WORKSPACE_TYPE, - PERSONAL_WORKSPACE_ID_PREFIX, -} from '../utils'; +export { DEFAULT_APP_CATEGORIES, PUBLIC_WORKSPACE_ID, WORKSPACE_TYPE } from '../utils'; export { SavedObject, diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 0993f0587e28..2ea3b423a7cb 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -8,7 +8,3 @@ export const WORKSPACE_TYPE = 'workspace'; export const WORKSPACE_PATH_PREFIX = '/w'; 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/index.ts b/src/core/utils/index.ts index a4b6cd4a922b..ec7971865566 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -38,10 +38,4 @@ export { } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; -export { - WORKSPACE_PATH_PREFIX, - PUBLIC_WORKSPACE_ID, - MANAGEMENT_WORKSPACE_ID, - WORKSPACE_TYPE, - PERSONAL_WORKSPACE_ID_PREFIX, -} from './constants'; +export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE_ID, WORKSPACE_TYPE } from './constants'; diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index caec12ad78dc..8eeb010b4a21 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -266,8 +266,8 @@ describe('workspace service', () => { page: 1, }) .expect(200); - // Global and Management workspace will be created by default after workspace list API called. - expect(listResult.body.result.total).toEqual(4); + // Global workspace will be created by default after workspace list API called. + expect(listResult.body.result.total).toEqual(3); }); it('unable to perform operations on workspace by calling saved objects APIs', async () => { const result = await osdTestServer.request @@ -329,8 +329,8 @@ describe('workspace service', () => { }) .expect(200); expect(findResult.body.total).toEqual(0); - // Global and Management workspace will be created by default after workspace list API called. - expect(listResult.body.result.total).toEqual(3); + // Global workspace will be created by default after workspace list API called. + expect(listResult.body.result.total).toEqual(2); }); }); }); diff --git a/src/plugins/workspace/server/workspace_client.ts b/src/plugins/workspace/server/workspace_client.ts index a52b74cc6111..e7bdf97b54ec 100644 --- a/src/plugins/workspace/server/workspace_client.ts +++ b/src/plugins/workspace/server/workspace_client.ts @@ -13,7 +13,6 @@ import type { } from '../../../core/server'; import { DEFAULT_APP_CATEGORIES, - MANAGEMENT_WORKSPACE_ID, PUBLIC_WORKSPACE_ID, WORKSPACE_TYPE, Logger, @@ -106,24 +105,6 @@ export class WorkspaceClient implements IWorkspaceClientImpl { reserved: true, }); } - private async setupManagementWorkspace(savedObjectClient?: SavedObjectsClientContract) { - 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, - }); - } public async setup(core: CoreSetup): Promise> { this.setupDep.savedObjects.registerType(workspace); return { @@ -197,16 +178,6 @@ export class WorkspaceClient implements IWorkspaceClientImpl { 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)); - } - try { await Promise.all(tasks); if (tasks.length) { From 3cf8b8eeffe5f325b473fe631e56bdfd998217b3 Mon Sep 17 00:00:00 2001 From: gaobinlong Date: Fri, 8 Mar 2024 15:43:47 +0800 Subject: [PATCH 26/34] [Workspace][Feature] Add ACL related functions (#5084) (#287) * [Workspace] Add ACL related functions for workspace (#146) * [Workspace] Add acl related functions for workspace * Minor change --------- * Modify changelog * Add more unit test cases * Modify test case * Some minor change * Add more test cases * Optimize some code and the comments of the functions * Add more comments for some basic functions * Export more interfaces * consume permissions in repository * feat: consume permissions in serializer * Add unit tests for consuming permissions in repository * Remove double exclamation * Rename some variables * Remove duplicated semicolon * Add permissions field to the mapping only if the permission control is enabled * Fix test failure * Add feature flag config to the yml file * Make the comment of feature flag more clear * Make comment more clear * Remove management permission type * Fix test failure --------- Signed-off-by: gaobinlong Signed-off-by: SuZhou-Joe Co-authored-by: Josh Romero Co-authored-by: SuZhou-Joe --- .../build_active_mappings.test.ts.snap | 110 ------------------ .../migrations/core/build_active_mappings.ts | 19 --- .../migrations/core/index_migrator.test.ts | 105 ----------------- ...pensearch_dashboards_migrator.test.ts.snap | 55 --------- .../saved_objects/service/lib/repository.ts | 4 +- .../service/saved_objects_client.ts | 4 +- .../server/integration_tests/routes.test.ts | 5 + ...apper_for_check_workspace_conflict.test.ts | 5 + ...space_saved_objects_client_wrapper.test.ts | 2 + 9 files changed, 16 insertions(+), 293 deletions(-) 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 6f67893104e7..f8ef47cae894 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,7 +10,6 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", - "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -37,60 +36,6 @@ 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 { @@ -124,7 +69,6 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", - "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "secondType": "72d57924f415fbadb3ee293b67d233ab", "thirdType": "510f1f0adb69830cf8a1c5ce2923ed82", @@ -155,60 +99,6 @@ 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 { 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 efedd9351a22..55b73daabc3e 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 @@ -171,16 +171,6 @@ 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: { @@ -219,15 +209,6 @@ function defaultMapping(): IndexMapping { }, }, }, - 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 f22234fc8996..8b1f5df9640a 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 @@ -227,7 +227,6 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', - permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -241,40 +240,6 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, - permissions: { - properties: { - library_read: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - library_write: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - management: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - read: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - write: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - }, - }, references: { type: 'nested', properties: { @@ -379,7 +344,6 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', - permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -394,40 +358,6 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, - permissions: { - properties: { - library_read: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - library_write: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - management: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - read: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - write: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - }, - }, references: { type: 'nested', properties: { @@ -475,7 +405,6 @@ describe('IndexMigrator', () => { namespace: '2f4316de49999235636386fe51dc06c1', namespaces: '2f4316de49999235636386fe51dc06c1', originId: '2f4316de49999235636386fe51dc06c1', - permissions: '07c04cdd060494956fdddaa7ef86e8ac', references: '7997cf5a56cc02bdc9c93361bde732b0', type: '2f4316de49999235636386fe51dc06c1', updated_at: '00da57df13e94e9d98437d13ace4bfe0', @@ -490,40 +419,6 @@ describe('IndexMigrator', () => { originId: { type: 'keyword' }, type: { type: 'keyword' }, updated_at: { type: 'date' }, - permissions: { - properties: { - library_read: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - library_write: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - management: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - read: { - properties: { - users: { type: 'keyword' }, - groups: { type: 'keyword' }, - }, - }, - write: { - properties: { - users: { type: 'keyword' }, - groups: { 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 5e39af788d79..baebb7848798 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,7 +10,6 @@ Object { "namespace": "2f4316de49999235636386fe51dc06c1", "namespaces": "2f4316de49999235636386fe51dc06c1", "originId": "2f4316de49999235636386fe51dc06c1", - "permissions": "07c04cdd060494956fdddaa7ef86e8ac", "references": "7997cf5a56cc02bdc9c93361bde732b0", "type": "2f4316de49999235636386fe51dc06c1", "updated_at": "00da57df13e94e9d98437d13ace4bfe0", @@ -45,60 +44,6 @@ 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 { diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 5a9e0696e840..61da059f7839 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -292,8 +292,8 @@ export class SavedObjectsRepository { migrationVersion, updated_at: time, ...(Array.isArray(references) && { references }), - ...(permissions && { permissions }), ...(Array.isArray(workspaces) && { workspaces }), + ...(permissions && { permissions }), }); const raw = this._serializer.savedObjectToRaw(migrated as SavedObjectSanitizedDoc); @@ -476,8 +476,8 @@ export class SavedObjectsRepository { updated_at: time, references: object.references || [], originId: object.originId, - ...(object.permissions && { permissions: object.permissions }), ...(savedObjectWorkspaces && { workspaces: savedObjectWorkspaces }), + ...(object.permissions && { permissions: object.permissions }), }) as SavedObjectSanitizedDoc ), }; 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 7ebe525df0ed..e1c3d16a9258 100644 --- a/src/core/server/saved_objects/service/saved_objects_client.ts +++ b/src/core/server/saved_objects/service/saved_objects_client.ts @@ -69,12 +69,12 @@ export interface SavedObjectsCreateOptions extends SavedObjectsBaseOptions { * Note: this can only be used for multi-namespace object types. */ initialNamespaces?: string[]; - /** permission control describe by ACL object */ - permissions?: Permissions; /** * workspaces the new created objects belong to */ workspaces?: string[]; + /** permission control describe by ACL object */ + permissions?: Permissions; } /** diff --git a/src/plugins/workspace/server/integration_tests/routes.test.ts b/src/plugins/workspace/server/integration_tests/routes.test.ts index 8eeb010b4a21..0c6e55101b7f 100644 --- a/src/plugins/workspace/server/integration_tests/routes.test.ts +++ b/src/plugins/workspace/server/integration_tests/routes.test.ts @@ -34,6 +34,11 @@ describe('workspace service', () => { enabled: false, }, }, + savedObjects: { + permission: { + enabled: true, + }, + }, migrations: { skip: false }, }, }, diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts index 75b19bb225b0..ec5259608c72 100644 --- a/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts +++ b/src/plugins/workspace/server/saved_objects/integration_tests/saved_objects_wrapper_for_check_workspace_conflict.test.ts @@ -35,6 +35,11 @@ describe('saved_objects_wrapper_for_check_workspace_conflict integration test', workspace: { enabled: true, }, + savedObjects: { + permission: { + enabled: true, + }, + }, migrations: { skip: false, }, diff --git a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts index 9f4f46b4dc99..2a7fb0e440b5 100644 --- a/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/integration_tests/workspace_saved_objects_client_wrapper.test.ts @@ -69,6 +69,8 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { osd: { workspace: { enabled: true, + }, + savedObjects: { permission: { enabled: true, }, From 822ef5456d45e9d433ad7bfbe0a2e877bd280001 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Fri, 8 Mar 2024 17:49:43 +0800 Subject: [PATCH 27/34] Workspace left nav bar (#286) * revert unnecessary changes to recently viewed component refactor nav link updater so that the displayed links can be customized, this is majority required by workspace as with workspace, user would be able to config what features(plugins) then want to see for a workspace, this requires to filter out those links that are not configured by the user. Signed-off-by: Yulong Ruan * fix test snapshot Signed-off-by: Yulong Ruan * tweak comments Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan --- src/core/public/chrome/chrome_service.mock.ts | 3 +- src/core/public/chrome/index.ts | 7 +- src/core/public/chrome/nav_links/index.ts | 2 +- src/core/public/chrome/nav_links/nav_link.ts | 9 +- .../nav_links/nav_links_service.test.ts | 143 +- .../chrome/nav_links/nav_links_service.ts | 45 +- .../collapsible_nav.test.tsx.snap | 2356 ++++++++--------- .../header/__snapshots__/header.test.tsx.snap | 377 ++- .../chrome/ui/header/collapsible_nav.test.tsx | 6 +- .../chrome/ui/header/collapsible_nav.tsx | 203 +- src/core/public/chrome/ui/header/nav_link.tsx | 56 +- src/core/public/index.ts | 2 + .../workspace/workspaces_service.mock.ts | 1 + .../public/workspace/workspaces_service.ts | 1 + src/core/utils/default_app_categories.ts | 10 +- .../dashboard_listing.test.tsx.snap | 15 +- .../dashboard_top_nav.test.tsx.snap | 18 +- src/plugins/dev_tools/public/plugin.ts | 2 +- src/plugins/workspace/public/plugin.test.ts | 29 - src/plugins/workspace/public/plugin.ts | 93 +- src/plugins/workspace/public/utils.ts | 15 +- 21 files changed, 1543 insertions(+), 1850 deletions(-) diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 566a6b7095e5..7fd9ee35ba04 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -43,9 +43,8 @@ const createStartContractMock = () => { const startContract: DeeplyMockedKeys = { getHeaderComponent: jest.fn(), navLinks: { - setNavLinks: jest.fn(), getNavLinks$: jest.fn(), - getAllNavLinks$: jest.fn(), + getLinkUpdaters$: jest.fn(), has: jest.fn(), get: jest.fn(), getAll: jest.fn(), diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index 4004c2c323f9..6790e1678f9c 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -45,7 +45,12 @@ export { ChromeHelpExtensionMenuGitHubLink, } from './ui/header/header_help_menu'; export { NavType } from './ui'; -export { ChromeNavLink, ChromeNavLinks, ChromeNavLinkUpdateableFields } from './nav_links'; +export { + ChromeNavLink, + ChromeNavLinks, + ChromeNavLinkUpdateableFields, + LinksUpdater, +} from './nav_links'; export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem } from './recently_accessed'; export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; diff --git a/src/core/public/chrome/nav_links/index.ts b/src/core/public/chrome/nav_links/index.ts index 4be7e0be49b8..f0fad9fa28c2 100644 --- a/src/core/public/chrome/nav_links/index.ts +++ b/src/core/public/chrome/nav_links/index.ts @@ -29,4 +29,4 @@ */ export { ChromeNavLink, ChromeNavLinkUpdateableFields } from './nav_link'; -export { ChromeNavLinks, NavLinksService } from './nav_links_service'; +export { ChromeNavLinks, NavLinksService, LinksUpdater } from './nav_links_service'; diff --git a/src/core/public/chrome/nav_links/nav_link.ts b/src/core/public/chrome/nav_links/nav_link.ts index 19e2fd2eddab..cddd45234514 100644 --- a/src/core/public/chrome/nav_links/nav_link.ts +++ b/src/core/public/chrome/nav_links/nav_link.ts @@ -93,10 +93,8 @@ export interface ChromeNavLink { * Disables a link from being clickable. * * @internalRemarks - * This is used by the ML and Graph plugins. They use this field + * This is only used by the ML and Graph plugins currently. 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; @@ -104,11 +102,6 @@ export interface ChromeNavLink { * Hides a link from the navigation. */ readonly hidden?: boolean; - - /** - * Links can be navigated through url. - */ - readonly externalLink?: boolean; } /** @public */ diff --git a/src/core/public/chrome/nav_links/nav_links_service.test.ts b/src/core/public/chrome/nav_links/nav_links_service.test.ts index d4cfb2630496..3fe2b57676e0 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.test.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.test.ts @@ -32,12 +32,18 @@ import { NavLinksService } from './nav_links_service'; import { take, map, takeLast } from 'rxjs/operators'; import { App } from '../../application'; import { BehaviorSubject } from 'rxjs'; -import { ChromeNavLink } from 'opensearch-dashboards/public'; const availableApps = new Map([ ['app1', { id: 'app1', order: 0, title: 'App 1', icon: 'app1' }], - ['app2', { id: 'app2', order: -10, title: 'App 2', euiIconType: 'canvasApp' }], - ['app3', { id: 'app3', order: 10, title: 'App 3', icon: 'app3' }], + [ + 'app2', + { + id: 'app2', + order: -10, + title: 'App 2', + euiIconType: 'canvasApp', + }, + ], ['chromelessApp', { id: 'chromelessApp', order: 20, title: 'Chromless App', chromeless: true }], ]); @@ -60,110 +66,7 @@ describe('NavLinksService', () => { start = service.start({ application: mockAppService, http: mockHttp }); }); - describe('#getAllNavLinks$()', () => { - it('does not include `chromeless` applications', async () => { - expect( - await start - .getAllNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).not.toContain('chromelessApp'); - }); - - it('sorts navLinks by `order` property', async () => { - expect( - await start - .getAllNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).toEqual(['app2', 'app1', 'app3']); - }); - - it('emits multiple values', async () => { - const navLinkIds$ = start.getAllNavLinks$().pipe(map((links) => links.map((l) => l.id))); - const emittedLinks: string[][] = []; - navLinkIds$.subscribe((r) => emittedLinks.push(r)); - start.update('app1', { href: '/foo' }); - - service.stop(); - expect(emittedLinks).toEqual([ - ['app2', 'app1', 'app3'], - ['app2', 'app1', 'app3'], - ]); - }); - - it('completes when service is stopped', async () => { - const last$ = start.getAllNavLinks$().pipe(takeLast(1)).toPromise(); - service.stop(); - await expect(last$).resolves.toBeInstanceOf(Array); - }); - }); - - describe('#getNavLinks$() when non null', () => { - // set filtered nav links, nav link with order smaller than 0 will be filtered - beforeEach(() => { - const filteredNavLinks = new Map(); - start.getAllNavLinks$().subscribe((links) => - links.forEach((link) => { - if (link.order !== undefined && link.order >= 0) { - filteredNavLinks.set(link.id, link); - } - }) - ); - start.setNavLinks(filteredNavLinks); - }); - - it('does not include `app2` applications', async () => { - expect( - await start - .getNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).not.toContain('app2'); - }); - - it('sorts navLinks by `order` property', async () => { - expect( - await start - .getNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).toEqual(['app1', 'app3']); - }); - - it('emits multiple values', async () => { - const navLinkIds$ = start.getNavLinks$().pipe(map((links) => links.map((l) => l.id))); - const emittedLinks: string[][] = []; - navLinkIds$.subscribe((r) => emittedLinks.push(r)); - start.update('app1', { href: '/foo' }); - - service.stop(); - expect(emittedLinks).toEqual([ - ['app1', 'app3'], - ['app1', 'app3'], - ]); - }); - - it('completes when service is stopped', async () => { - const last$ = start.getNavLinks$().pipe(takeLast(1)).toPromise(); - service.stop(); - await expect(last$).resolves.toBeInstanceOf(Array); - }); - }); - - describe('#getNavLinks$() when null', () => { + describe('#getNavLinks$()', () => { it('does not include `chromeless` applications', async () => { expect( await start @@ -176,19 +79,7 @@ describe('NavLinksService', () => { ).not.toContain('chromelessApp'); }); - it('include `app2` applications', async () => { - expect( - await start - .getNavLinks$() - .pipe( - take(1), - map((links) => links.map((l) => l.id)) - ) - .toPromise() - ).toContain('app2'); - }); - - it('sorts navLinks by `order` property', async () => { + it('sorts navlinks by `order` property', async () => { expect( await start .getNavLinks$() @@ -197,7 +88,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1', 'app3']); + ).toEqual(['app2', 'app1']); }); it('emits multiple values', async () => { @@ -208,8 +99,8 @@ describe('NavLinksService', () => { service.stop(); expect(emittedLinks).toEqual([ - ['app2', 'app1', 'app3'], - ['app2', 'app1', 'app3'], + ['app2', 'app1'], + ['app2', 'app1'], ]); }); @@ -232,7 +123,7 @@ describe('NavLinksService', () => { describe('#getAll()', () => { it('returns a sorted array of navlinks', () => { - expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1', 'app3']); + expect(start.getAll().map((l) => l.id)).toEqual(['app2', 'app1']); }); }); @@ -257,7 +148,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1', 'app3']); + ).toEqual(['app2', 'app1']); }); it('does nothing on chromeless applications', async () => { @@ -270,7 +161,7 @@ describe('NavLinksService', () => { map((links) => links.map((l) => l.id)) ) .toPromise() - ).toEqual(['app2', 'app1', 'app3']); + ).toEqual(['app2', 'app1']); }); it('removes all other links', async () => { diff --git a/src/core/public/chrome/nav_links/nav_links_service.ts b/src/core/public/chrome/nav_links/nav_links_service.ts index d4c899a57be8..58adb653527b 100644 --- a/src/core/public/chrome/nav_links/nav_links_service.ts +++ b/src/core/public/chrome/nav_links/nav_links_service.ts @@ -54,14 +54,11 @@ export interface ChromeNavLinks { getNavLinks$(): Observable>>; /** - * Get an observable for a sorted list of all navlinks. + * Get an observable for the current link updaters. Link updater is used to modify the + * nav links, for example, filter the nav links or update a specific nav link's properties. + * {@link LinksUpdater} */ - getAllNavLinks$(): Observable>>; - - /** - * Set navlinks. - */ - setNavLinks(navLinks: ReadonlyMap): void; + getLinkUpdaters$(): BehaviorSubject; /** * Get the state of a navlink at this point in time. @@ -122,7 +119,7 @@ export interface ChromeNavLinks { getForceAppSwitcherNavigation$(): Observable; } -type LinksUpdater = (navLinks: Map) => Map; +export type LinksUpdater = (navLinks: Map) => Map; export class NavLinksService { private readonly stop$ = new ReplaySubject(1); @@ -142,10 +139,7 @@ export class NavLinksService { // manual link modifications to be able to re-apply then after every // availableApps$ changes. const linkUpdaters$ = new BehaviorSubject([]); - const displayedNavLinks$ = new BehaviorSubject | undefined>( - undefined - ); - const allNavLinks$ = new BehaviorSubject>(new Map()); + const navLinks$ = new BehaviorSubject>(new Map()); combineLatest([appLinks$, linkUpdaters$]) .pipe( @@ -154,40 +148,31 @@ export class NavLinksService { }) ) .subscribe((navLinks) => { - allNavLinks$.next(navLinks); + navLinks$.next(navLinks); }); const forceAppSwitcherNavigation$ = new BehaviorSubject(false); return { getNavLinks$: () => { - return combineLatest([allNavLinks$, displayedNavLinks$]).pipe( - map(([allNavLinks, displayedNavLinks]) => - displayedNavLinks === undefined ? sortLinks(allNavLinks) : sortLinks(displayedNavLinks) - ), - takeUntil(this.stop$) - ); - }, - - setNavLinks: (navLinks: ReadonlyMap) => { - displayedNavLinks$.next(navLinks); + return navLinks$.pipe(map(sortNavLinks), takeUntil(this.stop$)); }, - getAllNavLinks$: () => { - return allNavLinks$.pipe(map(sortLinks), takeUntil(this.stop$)); + getLinkUpdaters$: () => { + return linkUpdaters$; }, get(id: string) { - const link = allNavLinks$.value.get(id); + const link = navLinks$.value.get(id); return link && link.properties; }, getAll() { - return sortLinks(allNavLinks$.value); + return sortNavLinks(navLinks$.value); }, has(id: string) { - return allNavLinks$.value.has(id); + return navLinks$.value.has(id); }, showOnly(id: string) { @@ -235,9 +220,9 @@ export class NavLinksService { } } -function sortLinks(links: ReadonlyMap) { +function sortNavLinks(navLinks: ReadonlyMap) { return sortBy( - [...links.values()].map((link) => ('properties' in link ? link.properties : link)), + [...navLinks.values()].map((link) => link.properties), '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 6f3e1077f825..d6094f78e24b 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 @@ -123,7 +123,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` } } homeHref="/" - id="collapsible-nav" + id="collapsibe-nav" isLocked={false} isNavOpen={true} logos={ @@ -243,7 +243,7 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "euiIconType": "managementApp", "id": "management", "label": "Management", - "order": 6000, + "order": 5000, }, "data-test-subj": "monitoring", "href": "monitoring", @@ -423,7 +423,7 @@ exports[`CollapsibleNav renders links grouped by category 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 +

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

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

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

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

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

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

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

    - 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" +
    -
    -
    - -
    -
    - +
    + + + +
    +
    + +
    +
    -
    - + dashboard + + + + + +
    - +
    -
    - - + +
    +
    + +
    + +
    +
    + +
    { ); expectShownNavLinksCount(component, 3); clickGroup(component, 'opensearchDashboards'); - clickGroup(component, 'recentlyVisited'); + clickGroup(component, 'recentlyViewed'); expectShownNavLinksCount(component, 1); component.setProps({ isNavOpen: false }); expectNavIsClosed(component); @@ -205,7 +205,7 @@ describe('CollapsibleNav', () => { }, }); - component.find('[data-test-subj="collapsibleNavGroup-recentlyVisited"] a').simulate('click'); + component.find('[data-test-subj="collapsibleNavGroup-recentlyViewed"] 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 3ac2575c7faa..9c9223aa501b 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav.tsx @@ -37,27 +37,22 @@ import { EuiListGroup, EuiListGroupItem, EuiShowFor, + EuiText, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { groupBy, sortBy } from 'lodash'; import React, { Fragment, useRef } from 'react'; import useObservable from 'react-use/lib/useObservable'; import * as Rx from 'rxjs'; -import { DEFAULT_APP_CATEGORIES } from '../../../../utils'; import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem } from '../..'; import { AppCategory } from '../../../../types'; -import { InternalApplicationStart } from '../../../application'; +import { InternalApplicationStart } from '../../../application/types'; import { HttpStart } from '../../../http'; import { OnIsLockedUpdate } from './'; -import type { Logos } from '../../../../common'; -import { - createEuiListItem, - createRecentChromeNavLink, - emptyRecentlyVisited, - CollapsibleNavLink, -} from './nav_link'; +import { createEuiListItem, createRecentNavLink, isModifiedOrPrevented } from './nav_link'; +import type { Logos } from '../../../../common/types'; -function getAllCategories(allCategorizedLinks: Record) { +function getAllCategories(allCategorizedLinks: Record) { const allCategories = {} as Record; for (const [key, value] of Object.entries(allCategorizedLinks)) { @@ -67,28 +62,14 @@ function getAllCategories(allCategorizedLinks: Record, categoryDictionary: ReturnType -): Array { - // uncategorized links and categories are ranked according the order - // if order is not defined, categories will be placed above uncategorized links - const categories = Object.values(categoryDictionary).filter( - (category) => category !== undefined - ) as AppCategory[]; - const uncategorizedLinksWithOrder = uncategorizedLinks.filter((link) => link.order !== null); - const uncategorizedLinksWithoutOrder = uncategorizedLinks.filter((link) => link.order === null); - const categoriesWithOrder = categories.filter((category) => category.order !== null); - const categoriesWithoutOrder = categories.filter((category) => category.order === null); - const sortedLinksAndCategories = sortBy( - [...uncategorizedLinksWithOrder, ...categoriesWithOrder], - 'order' +) { + return sortBy( + Object.keys(mainCategories), + (categoryName) => categoryDictionary[categoryName]?.order ); - return [ - ...sortedLinksAndCategories, - ...categoriesWithoutOrder, - ...uncategorizedLinksWithoutOrder, - ]; } function getCategoryLocalStorageKey(id: string) { @@ -140,30 +121,15 @@ export function CollapsibleNav({ ...observables }: Props) { const navLinks = useObservable(observables.navLinks$, []).filter((link) => !link.hidden); - let customNavLink = useObservable(observables.customNavLink$, undefined); - if (customNavLink) { - customNavLink = { ...customNavLink, externalLink: true }; - } const recentlyAccessed = useObservable(observables.recentlyAccessed$, []); - const allNavLinks: CollapsibleNavLink[] = [...navLinks]; - if (recentlyAccessed.length) { - allNavLinks.push( - ...recentlyAccessed.map((link) => createRecentChromeNavLink(link, navLinks, basePath)) - ); - } else { - allNavLinks.push(emptyRecentlyVisited); - } + const customNavLink = useObservable(observables.customNavLink$, undefined); const appId = useObservable(observables.appId$, ''); const lockRef = useRef(null); - const groupedNavLinks = groupBy(allNavLinks, (link) => link?.category?.id); - const { undefined: uncategorizedLinks = [], ...allCategorizedLinks } = groupedNavLinks; + const groupedNavLinks = groupBy(navLinks, (link) => link?.category?.id); + const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; const categoryDictionary = getAllCategories(allCategorizedLinks); - const sortedLinksAndCategories = getSortedLinksAndCategories( - uncategorizedLinks, - categoryDictionary - ); - - const readyForEUI = (link: CollapsibleNavLink, needsIcon: boolean = false) => { + const orderedCategories = getOrderedCategories(allCategorizedLinks, categoryDictionary); + const readyForEUI = (link: ChromeNavLink, needsIcon: boolean = false) => { return createEuiListItem({ link, appId, @@ -203,6 +169,7 @@ export function CollapsibleNav({ navigateToApp, dataTestSubj: 'collapsibleNavCustomNavLink', onClick: closeNav, + externalLink: true, }), ]} maxWidth="none" @@ -217,53 +184,103 @@ export function CollapsibleNav({ )} + {/* Recently viewed */} + setIsCategoryOpen('recentlyViewed', isCategoryOpen, storage)} + data-test-subj="collapsibleNavGroup-recentlyViewed" + > + {recentlyAccessed.length > 0 ? ( + { + // TODO #64541 + // Can remove icon from recent links completely + const { iconType, onClick, ...hydratedLink } = createRecentNavLink( + link, + navLinks, + basePath, + navigateToUrl + ); + + return { + ...hydratedLink, + 'data-test-subj': 'collapsibleNavAppLink--recent', + onClick: (event) => { + if (!isModifiedOrPrevented(event)) { + closeNav(); + onClick(event); + } + }, + }; + })} + maxWidth="none" + color="subdued" + gutterSize="none" + size="s" + className="osdCollapsibleNav__recentsListGroup" + /> + ) : ( + +

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

    +
    + )} +
    + + + - {sortedLinksAndCategories.map((item, i) => { - if (!('href' in item)) { - // CollapsibleNavLink has href property, while AppCategory does not have - const category = item; - const opensearchLinkLogo = - category.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id - ? logos.Mark.url - : category.euiIconType; + {/* OpenSearchDashboards, Observability, Security, and Management sections */} + {orderedCategories.map((categoryName) => { + const category = categoryDictionary[categoryName]!; + 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" - /> - - ); - } else { - return ( - - - - - - ); - } + 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" + /> + + ); })} + {/* 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/nav_link.tsx b/src/core/public/chrome/ui/header/nav_link.tsx index 55482708e09f..38d31dbc09c9 100644 --- a/src/core/public/chrome/ui/header/nav_link.tsx +++ b/src/core/public/chrome/ui/header/nav_link.tsx @@ -31,8 +31,9 @@ import { EuiIcon } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import React from 'react'; -import { AppCategory, ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; +import { ChromeNavLink, ChromeRecentlyAccessedHistoryItem, CoreStart } from '../../..'; import { HttpStart } from '../../../http'; +import { InternalApplicationStart } from '../../../application/types'; import { relativeToAbsolute } from '../../nav_links/to_nav_link'; export const isModifiedOrPrevented = (event: React.MouseEvent) => @@ -46,9 +47,8 @@ const aliasedApps: { [key: string]: string[] } = { export const isActiveNavLink = (appId: string | undefined, linkId: string): boolean => !!(appId === linkId || aliasedApps[linkId]?.includes(appId || '')); -export type CollapsibleNavLink = ChromeNavLink | RecentNavLink; interface Props { - link: CollapsibleNavLink; + link: ChromeNavLink; appId?: string; basePath?: HttpStart['basePath']; dataTestSubj: string; @@ -68,8 +68,9 @@ export function createEuiListItem({ onClick = () => {}, navigateToApp, dataTestSubj, + externalLink = false, }: Props) { - const { href, id, title, disabled, euiIconType, icon, tooltip, externalLink } = link; + const { href, id, title, disabled, euiIconType, icon, tooltip } = link; return { label: tooltip ?? title, @@ -100,16 +101,14 @@ export function createEuiListItem({ }; } -export type RecentNavLink = Omit; - -const recentlyVisitedCategory: AppCategory = { - id: 'recentlyVisited', - label: i18n.translate('core.ui.recentlyVisited.label', { - defaultMessage: 'Recently Visited', - }), - order: 0, - euiIconType: 'clock', -}; +export interface RecentNavLink { + href: string; + label: string; + title: string; + 'aria-label': string; + iconType?: string; + onClick: React.MouseEventHandler; +} /** * Add saved object type info to recently links @@ -121,10 +120,11 @@ const recentlyVisitedCategory: AppCategory = { * @param navLinks * @param basePath */ -export function createRecentChromeNavLink( +export function createRecentNavLink( recentLink: ChromeRecentlyAccessedHistoryItem, navLinks: ChromeNavLink[], - basePath: HttpStart['basePath'] + basePath: HttpStart['basePath'], + navigateToUrl: InternalApplicationStart['navigateToUrl'] ): RecentNavLink { const { link, label } = recentLink; const href = relativeToAbsolute(basePath.prepend(link)); @@ -143,20 +143,16 @@ export function createRecentChromeNavLink( return { href, - id: recentLink.id, - externalLink: true, - category: recentlyVisitedCategory, + label, 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/index.ts b/src/core/public/index.ts index 7003732433e5..22b85f30605b 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -67,6 +67,7 @@ import { ChromeStart, ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, + LinksUpdater, NavType, } from './chrome'; import { FatalErrorsSetup, FatalErrorsStart, FatalErrorInfo } from './fatal_errors'; @@ -330,6 +331,7 @@ export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem, ChromeStart, + LinksUpdater, IContextContainer, HandlerFunction, HandlerContextType, diff --git a/src/core/public/workspace/workspaces_service.mock.ts b/src/core/public/workspace/workspaces_service.mock.ts index 2c81cd888916..7f38a898fea1 100644 --- a/src/core/public/workspace/workspaces_service.mock.ts +++ b/src/core/public/workspace/workspaces_service.mock.ts @@ -5,6 +5,7 @@ import { BehaviorSubject } from 'rxjs'; import type { PublicMethodsOf } from '@osd/utility-types'; + import { WorkspacesService } from './workspaces_service'; import { WorkspaceObject } from '..'; diff --git a/src/core/public/workspace/workspaces_service.ts b/src/core/public/workspace/workspaces_service.ts index d235f3322571..674ec6a8d19a 100644 --- a/src/core/public/workspace/workspaces_service.ts +++ b/src/core/public/workspace/workspaces_service.ts @@ -5,6 +5,7 @@ import { BehaviorSubject, combineLatest } from 'rxjs'; import { isEqual } from 'lodash'; + import { CoreService, WorkspaceObject } from '../../types'; interface WorkspaceObservables { diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index e6e53f9101ed..3c0920624e1b 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -65,20 +65,12 @@ 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: 6000, + order: 5000, euiIconType: 'managementApp', }, }); 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 e062c722c007..1bff186d2634 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -223,11 +223,10 @@ exports[`dashboard listing hideWriteControls 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1368,11 +1367,10 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2574,11 +2572,10 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3780,11 +3777,10 @@ exports[`dashboard listing renders table rows 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4986,11 +4982,10 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 702ebcdbe498..f8b202372235 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -211,11 +211,10 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -1181,11 +1180,10 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -2151,11 +2149,10 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -3121,11 +3118,10 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -4091,11 +4087,10 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, @@ -5061,11 +5056,10 @@ exports[`Dashboard top nav render with all components 1`] = ` "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], "getAll": [MockFunction], - "getAllNavLinks$": [MockFunction], "getForceAppSwitcherNavigation$": [MockFunction], + "getLinkUpdaters$": [MockFunction], "getNavLinks$": [MockFunction], "has": [MockFunction], - "setNavLinks": [MockFunction], "showOnly": [MockFunction], "update": [MockFunction], }, diff --git a/src/plugins/dev_tools/public/plugin.ts b/src/plugins/dev_tools/public/plugin.ts index e22f12b9234a..bb0b6ee1d981 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.openSearchFeatures, + category: DEFAULT_APP_CATEGORIES.management, mount: async (params: AppMountParameters) => { const { element, history } = params; element.classList.add('devAppWrapper'); diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 35e349f1d07e..f1f70b8e1d36 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -3,9 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { BehaviorSubject, of } from 'rxjs'; import { waitFor } from '@testing-library/dom'; -import { ChromeNavLink } from 'opensearch-dashboards/public'; import { workspaceClientMock, WorkspaceClientMock } from './workspace_client.mock'; import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../core/public/mocks'; import { WorkspacePlugin } from './plugin'; @@ -124,36 +122,9 @@ describe('Workspace plugin', () => { windowSpy.mockRestore(); }); - it('#start filter nav links according to workspace feature', () => { - const workspacePlugin = new WorkspacePlugin(); - const coreStart = coreMock.createStart(); - const navLinksService = coreStart.chrome.navLinks; - const devToolsNavLink = { - id: 'dev_tools', - category: { id: 'management', label: 'Management' }, - }; - const discoverNavLink = { - id: 'discover', - category: { id: 'opensearchDashboards', label: 'Library' }, - }; - const workspace = { - id: 'test', - name: 'test', - features: ['dev_tools'], - }; - const allNavLinks = of([devToolsNavLink, discoverNavLink] as ChromeNavLink[]); - const filteredNavLinksMap = new Map(); - filteredNavLinksMap.set(devToolsNavLink.id, devToolsNavLink as ChromeNavLink); - navLinksService.getAllNavLinks$.mockReturnValue(allNavLinks); - coreStart.workspaces.currentWorkspace$.next(workspace); - workspacePlugin.start(coreStart); - expect(navLinksService.setNavLinks).toHaveBeenCalledWith(filteredNavLinksMap); - }); - it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { const workspacePlugin = new WorkspacePlugin(); const coreStart = coreMock.createStart(); - coreStart.chrome.navLinks.getAllNavLinks$.mockReturnValueOnce(new BehaviorSubject([])); workspacePlugin.start(coreStart); coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index d86d8555bfcf..d7f1ba6192ad 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,77 +4,77 @@ */ import type { Subscription } from 'rxjs'; -import { combineLatest } from 'rxjs'; -import { map } from 'rxjs/operators'; -import { i18n } from '@osd/i18n'; import { featureMatchesConfig } from './utils'; import { AppMountParameters, AppNavLinkStatus, - ChromeNavLink, CoreSetup, CoreStart, + LinksUpdater, Plugin, WorkspaceObject, - DEFAULT_APP_CATEGORIES, } from '../../../core/public'; import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { renderWorkspaceMenu } from './render_workspace_menu'; import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; +import { NavLinkWrapper } from '../../../core/public/chrome/nav_links/nav_link'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; + private currentWorkspaceIdSubscription?: Subscription; private currentWorkspaceSubscription?: Subscription; private getWorkspaceIdFromURL(): string | null { return getWorkspaceIdFromUrl(window.location.href); } - private filterByWorkspace(workspace: WorkspaceObject | null, allNavLinks: ChromeNavLink[]) { - if (!workspace) return allNavLinks; - const features = workspace.features ?? ['*']; - return allNavLinks.filter(featureMatchesConfig(features)); - } + /** + * Filter the nav links based on the feature configuration of workspace + */ + private filterByWorkspace(allNavLinks: NavLinkWrapper[], workspace: WorkspaceObject | null) { + if (!workspace || !workspace.features) return allNavLinks; - private filterNavLinks(core: CoreStart) { - const navLinksService = core.chrome.navLinks; - const allNavLinks$ = navLinksService.getAllNavLinks$(); - const currentWorkspace$ = core.workspaces.currentWorkspace$; - combineLatest([ - allNavLinks$.pipe(map(this.changeCategoryNameByWorkspaceFeatureFlag)), - currentWorkspace$, - ]).subscribe(([allNavLinks, currentWorkspace]) => { - const filteredNavLinks = this.filterByWorkspace(currentWorkspace, allNavLinks); - const navLinks = new Map(); - filteredNavLinks.forEach((chromeNavLink) => { - navLinks.set(chromeNavLink.id, chromeNavLink); - }); - navLinksService.setNavLinks(navLinks); - }); + const featureFilter = featureMatchesConfig(workspace.features); + return allNavLinks.filter((linkWrapper) => featureFilter(linkWrapper.properties)); } /** - * 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. + * Filter nav links by the current workspace, once the current workspace change, the nav links(left nav bar) + * should also be updated according to the configured features of the current workspace */ - 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; + private filterNavLinks(core: CoreStart) { + const currentWorkspace$ = core.workspaces.currentWorkspace$; + let filterLinksByWorkspace: LinksUpdater; + + this.currentWorkspaceSubscription?.unsubscribe(); + this.currentWorkspaceSubscription = currentWorkspace$.subscribe((currentWorkspace) => { + const linkUpdaters$ = core.chrome.navLinks.getLinkUpdaters$(); + let linkUpdaters = linkUpdaters$.value; + + /** + * It should only have one link filter exist based on the current workspace at a given time + * So we need to filter out previous workspace link filter before adding new one after changing workspace + */ + linkUpdaters = linkUpdaters.filter((updater) => updater !== filterLinksByWorkspace); + + /** + * Whenever workspace changed, this function will filter out those links that should not + * be displayed. For example, some workspace may not have Observability features configured, in such case, + * the nav links of Observability features should not be displayed in left nav bar + */ + filterLinksByWorkspace = (navLinks) => { + const filteredNavLinks = this.filterByWorkspace([...navLinks.values()], currentWorkspace); + const newNavLinks = new Map(); + filteredNavLinks.forEach((chromeNavLink) => { + newNavLinks.set(chromeNavLink.id, chromeNavLink); + }); + return newNavLinks; + }; + + linkUpdaters$.next([...linkUpdaters, filterLinksByWorkspace]); }); } @@ -167,14 +167,15 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { public start(core: CoreStart) { this.coreStart = core; - this.currentWorkspaceSubscription = this._changeSavedObjectCurrentWorkspace(); - if (core) { - this.filterNavLinks(core); - } + this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); + + // When starts, filter the nav links based on the current workspace + this.filterNavLinks(core); return {}; } public stop() { + this.currentWorkspaceIdSubscription?.unsubscribe(); this.currentWorkspaceSubscription?.unsubscribe(); } } diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index f7c59dbfc53c..444b3aadadf3 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -6,14 +6,15 @@ import { AppCategory } from '../../../core/public'; /** - * Given a list of feature config, check if a feature matches config + * Checks if a given feature matches the provided feature configuration. + * * Rules: - * 1. `*` matches any feature - * 2. config starts with `@` matches category, for example, @management matches any feature of `management` category - * 3. to match a specific feature, 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' + * 1. `*` matches any feature. + * 2. Config starts with `@` matches category, for example, @management matches any feature of `management` category, + * 3. To match a specific feature, use the feature id, such as `discover`, + * 4. To exclude a feature or category, prepend with `!`, e.g., `!discover` or `!@management`. + * 5. The order of featureConfig array matters. From left to right, later configs override the previous ones. + * For example, ['!@management', '*'] matches any feature because '*' overrides the previous setting: '!@management'. */ export const featureMatchesConfig = (featureConfigs: string[]) => ({ id, From 6f12320e807cacf1ec877041f8a310b8b3148fe1 Mon Sep 17 00:00:00 2001 From: Hailong Cui Date: Mon, 11 Mar 2024 10:23:09 +0800 Subject: [PATCH 28/34] Add workspace filter/column into saved objects page (#211) * Add workspace column/filter into saved objects page Signed-off-by: Hailong Cui fix failed test case Signed-off-by: Hailong Cui move workspace column to its own folder Signed-off-by: Hailong Cui * default workspace Signed-off-by: Hailong Cui fix test case Signed-off-by: Hailong Cui add test case Signed-off-by: Hailong Cui remove hide import Signed-off-by: Hailong Cui * address review comments Signed-off-by: Hailong Cui --------- Signed-off-by: Hailong Cui --- src/core/public/index.ts | 2 +- src/core/server/index.ts | 7 +- src/core/utils/constants.ts | 6 + src/core/utils/index.ts | 7 +- .../public/lib/get_saved_object_counts.ts | 4 +- .../public/lib/parse_query.test.ts | 3 + .../public/lib/parse_query.ts | 7 + .../saved_objects_table.test.tsx.snap | 2 + .../objects_table/components/table.test.tsx | 45 ++++ .../objects_table/components/table.tsx | 25 ++- .../saved_objects_table.test.tsx | 194 ++++++++++++++++++ .../objects_table/saved_objects_table.tsx | 118 ++++++++++- .../server/routes/find.ts | 1 + .../server/routes/scroll_count.ts | 26 ++- .../workspace/opensearch_dashboards.json | 2 +- .../components/workspace_column/index.ts | 6 + .../workspace_column/workspace_colum.test.tsx | 59 ++++++ .../workspace_column/workspace_column.tsx | 49 +++++ src/plugins/workspace/public/plugin.test.ts | 19 +- src/plugins/workspace/public/plugin.ts | 13 +- 20 files changed, 567 insertions(+), 28 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_column/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_column/workspace_colum.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_column/workspace_column.tsx diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 22b85f30605b..01e018d07df3 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -362,4 +362,4 @@ export { __osdBootstrap__ } from './osd_bootstrap'; export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; -export { WORKSPACE_TYPE, cleanWorkspaceId } from '../utils'; +export { WORKSPACE_TYPE, cleanWorkspaceId, DEFAULT_WORKSPACE_ID } from '../utils'; diff --git a/src/core/server/index.ts b/src/core/server/index.ts index 1af8818ab0f0..84ee65dcb199 100644 --- a/src/core/server/index.ts +++ b/src/core/server/index.ts @@ -354,7 +354,12 @@ export { } from './metrics'; export { AppCategory, WorkspaceAttribute } from '../types'; -export { DEFAULT_APP_CATEGORIES, PUBLIC_WORKSPACE_ID, WORKSPACE_TYPE } from '../utils'; +export { + DEFAULT_APP_CATEGORIES, + PUBLIC_WORKSPACE_ID, + WORKSPACE_TYPE, + DEFAULT_WORKSPACE_ID, +} from '../utils'; export { SavedObject, diff --git a/src/core/utils/constants.ts b/src/core/utils/constants.ts index 2ea3b423a7cb..5c2a24f59b0d 100644 --- a/src/core/utils/constants.ts +++ b/src/core/utils/constants.ts @@ -8,3 +8,9 @@ export const WORKSPACE_TYPE = 'workspace'; export const WORKSPACE_PATH_PREFIX = '/w'; export const PUBLIC_WORKSPACE_ID = 'public'; + +/** + * deafult workspace is a virtual workspace, + * saved objects without any workspaces are consider belongs to default workspace + */ +export const DEFAULT_WORKSPACE_ID = 'default'; diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index ec7971865566..e2f5fd90460a 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -38,4 +38,9 @@ export { } from './context'; export { DEFAULT_APP_CATEGORIES } from './default_app_categories'; export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; -export { WORKSPACE_PATH_PREFIX, PUBLIC_WORKSPACE_ID, WORKSPACE_TYPE } from './constants'; +export { + WORKSPACE_PATH_PREFIX, + PUBLIC_WORKSPACE_ID, + WORKSPACE_TYPE, + DEFAULT_WORKSPACE_ID, +} from './constants'; 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 9039dae2be53..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 @@ -40,8 +40,8 @@ export interface SavedObjectCountOptions { 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/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/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 d44cecb5b412..ab8a16be5cbe 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 @@ -260,6 +260,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` "has": [MockFunction], } } + availableWorkspaces={Array []} basePath={ BasePath { "basePath": "", @@ -280,6 +281,7 @@ exports[`SavedObjectsTable should render normally 1`] = ` "has": [MockFunction], } } + currentWorkspaceId="" filters={ Array [ Object { 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..0ce820f5a02b 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, @@ -115,6 +116,50 @@ 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: '/management/opensearch-dashboards/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], + }; + // not in a workspace + let component = shallowWithI18nProvider(); + + let table = component.find('EuiBasicTable'); + let columns = table.prop('columns') as any[]; + let content = columns[1].render('My-Dashboard-test', item); + expect(content.props.href).toEqual('http://localhost/w/ws-1/app/dashboards#/view/dashboard-1'); + + // in a workspace + const currentWorkspaceId = 'foo-ws'; + component = shallowWithI18nProvider( +
    + ); + + table = component.find('EuiBasicTable'); + columns = table.prop('columns') as any[]; + content = columns[1].render('My-Dashboard-test', item); + expect(content.props.href).toEqual( + `http://localhost/w/${currentWorkspaceId}/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..1a1df64e3752 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 { @@ -56,6 +56,7 @@ import { SavedObjectsManagementAction, SavedObjectsManagementColumnServiceStart, } from '../../../services'; +import { formatUrlWithWorkspaceId } from '../../../../../../core/public/utils'; export interface TableProps { basePath: IBasePath; @@ -83,6 +84,8 @@ export interface TableProps { onShowRelationships: (object: SavedObjectWithMetadata) => void; canGoInApp: (obj: SavedObjectWithMetadata) => boolean; dateFormat: string; + availableWorkspaces?: WorkspaceAttribute[]; + currentWorkspaceId?: string; } interface TableState { @@ -177,8 +180,12 @@ export class Table extends PureComponent { columnRegistry, namespaceRegistry, dateFormat, + availableWorkspaces, + currentWorkspaceId, } = this.props; + const visibleWsIds = availableWorkspaces?.map((ws) => ws.id) || []; + const pagination = { pageIndex, pageSize, @@ -231,9 +238,19 @@ export class Table extends PureComponent { if (!canGoInApp) { return {title || getDefaultTitle(object)}; } - return ( - {title || getDefaultTitle(object)} - ); + let inAppUrl = basePath.prepend(path); + if (object.workspaces?.length) { + if (currentWorkspaceId) { + inAppUrl = formatUrlWithWorkspaceId(path, currentWorkspaceId, basePath); + } else { + // first workspace user have permission + const [workspaceId] = object.workspaces.filter((wsId) => visibleWsIds.includes(wsId)); + if (workspaceId) { + inAppUrl = formatUrlWithWorkspaceId(path, workspaceId, basePath); + } + } + } + return {title || getDefaultTitle(object)}; }, } as EuiTableFieldDataColumnType>, { 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 a0a6329ac5e0..8f895419400d 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 @@ -40,6 +40,7 @@ import { import React from 'react'; import { Query } from '@elastic/eui'; +import { waitFor } from '@testing-library/dom'; import { ShallowWrapper } from 'enzyme'; import { shallowWithI18nProvider } from 'test_utils/enzyme_helpers'; import { @@ -62,6 +63,9 @@ import { } from './saved_objects_table'; import { Flyout, Relationships } from './components'; import { SavedObjectWithMetadata } from '../../types'; +import { WorkspaceObject } from 'opensearch-dashboards/public'; +import { DEFAULT_WORKSPACE_ID } from '../../../../../core/public'; +import { TableProps } from './components/table'; const allowedTypes = ['index-pattern', 'visualization', 'dashboard', 'search']; @@ -645,4 +649,194 @@ describe('SavedObjectsTable', () => { expect(component.state('selectedSavedObjects').length).toBe(0); }); }); + + describe('workspace filter', () => { + it('show workspace filter when workspace turn on and not in any workspace', async () => { + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next(''); + workspaces.currentWorkspace$.next(null); + + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const props = component.find('Table').props() as TableProps; + const filters = props.filters; + expect(filters.length).toBe(2); + expect(filters[0].field).toBe('type'); + expect(filters[1].field).toBe('workspaces'); + expect(filters[1].options.length).toBe(3); + expect(filters[1].options[0].value).toBe('foo'); + expect(filters[1].options[1].value).toBe('bar'); + expect(filters[1].options[2].value).toBe(DEFAULT_WORKSPACE_ID); + }); + + it('show workspace filter when workspace turn on and enter a workspace', async () => { + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next(workspaceList[0]); + + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + const props = component.find('Table').props() as TableProps; + const filters = props.filters; + const wsFilter = filters.filter((f) => f.field === 'workspaces'); + expect(wsFilter.length).toBe(1); + expect(wsFilter[0].options.length).toBe(1); + expect(wsFilter[0].options[0].value).toBe('foo'); + }); + + it('workspace exists in find options when workspace on', async () => { + findObjectsMock.mockClear(); + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + workspaces.workspaceList$.next(workspaceList); + workspaces.currentWorkspaceId$.next('workspace1'); + workspaces.currentWorkspace$.next(workspaceList[0]); + + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await waitFor(() => { + expect(findObjectsMock).toBeCalledWith( + http, + expect.objectContaining({ + workspaces: expect.arrayContaining(['workspace1']), + }) + ); + }); + }); + + it('workspace exists in find options when workspace on and not in any workspace', async () => { + findObjectsMock.mockClear(); + const applications = applicationServiceMock.createStartContract(); + applications.capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + savedObjectsManagement: { + read: true, + edit: false, + delete: false, + }, + workspaces: { + enabled: true, + }, + }; + + const workspaceList: WorkspaceObject[] = [ + { + id: 'workspace1', + name: 'foo', + }, + { + id: 'workspace2', + name: 'bar', + }, + ]; + workspaces.workspaceList$.next(workspaceList); + + const component = shallowRender({ applications, workspaces }); + + // Ensure all promises resolve + await new Promise((resolve) => process.nextTick(resolve)); + // Ensure the state changes are reflected + component.update(); + + await waitFor(() => { + expect(findObjectsMock).toBeCalledWith( + http, + expect.objectContaining({ + workspaces: expect.arrayContaining(['workspace1', 'default']), + workspacesSearchOperator: expect.stringMatching('OR'), + }) + ); + }); + }); + }); }); 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 28d041563a71..1b1de928be03 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 @@ -66,7 +66,10 @@ import { OverlayStart, NotificationsStart, ApplicationStart, + WorkspaceAttribute, } from 'src/core/public'; +import { Subscription } from 'rxjs'; +import { DEFAULT_WORKSPACE_ID } from '../../../../../core/public'; import { RedirectAppLinks } from '../../../../opensearch_dashboards_react/public'; import { IndexPatternsContract } from '../../../../data/public'; import { @@ -125,7 +128,7 @@ export interface SavedObjectsTableState { page: number; perPage: number; savedObjects: SavedObjectWithMetadata[]; - savedObjectCounts: Record; + savedObjectCounts: Record>; activeQuery: Query; selectedSavedObjects: SavedObjectWithMetadata[]; isShowingImportFlyout: boolean; @@ -139,25 +142,30 @@ export interface SavedObjectsTableState { exportAllOptions: ExportAllOption[]; exportAllSelectedOptions: Record; isIncludeReferencesDeepChecked: boolean; - currentWorkspaceId: string | null; + currentWorkspaceId?: string; + availableWorkspaces?: WorkspaceAttribute[]; workspaceEnabled: boolean; } export class SavedObjectsTable extends Component { private _isMounted = false; + private currentWorkspaceIdSubscription?: Subscription; + private workspacesSubscription?: 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: [], isShowingImportFlyout: false, @@ -172,24 +180,37 @@ export class SavedObjectsTable extends Component ws.id).concat(DEFAULT_WORKSPACE_ID); } else { return [currentWorkspaceId]; } } } + private get wsNameIdLookup() { + const { availableWorkspaces } = this.state; + const workspaceNameIdMap = new Map(); + workspaceNameIdMap.set(DEFAULT_WORKSPACE_ID, DEFAULT_WORKSPACE_ID); + // Assumption: workspace name is unique across the system + availableWorkspaces?.reduce((map, ws) => { + return map.set(ws.name, ws.id); + }, workspaceNameIdMap); + return workspaceNameIdMap; + } + private formatWorkspaceIdParams( obj: T ): T | Omit { @@ -202,6 +223,8 @@ 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); @@ -229,6 +256,11 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || '') + .filter((wsId) => !!wsId); + } // These are the saved objects visible in the table. const filteredSavedObjectCounts = await getSavedObjectCounts( @@ -278,6 +310,19 @@ 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)); }; @@ -285,7 +330,7 @@ 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 @@ -304,6 +349,20 @@ export class SavedObjectsTable extends Component this.wsNameIdLookup?.get(wsName) || '' + ); + findOptions.workspaces = workspaceIds; + } + + if (findOptions.workspaces) { + if (findOptions.workspaces.indexOf(DEFAULT_WORKSPACE_ID) !== -1) { + // search both saved objects with workspace and without workspace + findOptions.workspacesSearchOperator = 'OR'; + } + } + if (findOptions.type.length > 1) { findOptions.sortField = 'type'; } @@ -846,6 +905,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})`, + }; + }); + + if (!currentWorkspaceId) { + wsFilterOptions.push({ + name: DEFAULT_WORKSPACE_ID, + value: DEFAULT_WORKSPACE_ID, + view: `Default (${wsCounts[DEFAULT_WORKSPACE_ID] || 0})`, + }); + } + + filters.push({ + type: 'field_value_selection', + field: 'workspaces', + name: i18n.translate('savedObjectsManagement.objectsTable.table.workspaceFilterName', { + defaultMessage: 'Workspaces', + }), + multiSelect: 'or', + options: wsFilterOptions, + }); + } + return ( {this.renderFlyout()} @@ -933,6 +1029,8 @@ export class SavedObjectsTable extends Component diff --git a/src/plugins/saved_objects_management/server/routes/find.ts b/src/plugins/saved_objects_management/server/routes/find.ts index 61211532e96c..8d94c0d935c9 100644 --- a/src/plugins/saved_objects_management/server/routes/find.ts +++ b/src/plugins/saved_objects_management/server/routes/find.ts @@ -67,6 +67,7 @@ export const registerFindRoute = ( workspaces: schema.maybe( schema.oneOf([schema.string(), schema.arrayOf(schema.string())]) ), + workspacesSearchOperator: schema.maybe(schema.string()), }), }, }, 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 221d39392842..ec8af5482855 100644 --- a/src/plugins/saved_objects_management/server/routes/scroll_count.ts +++ b/src/plugins/saved_objects_management/server/routes/scroll_count.ts @@ -30,6 +30,7 @@ import { schema } from '@osd/config-schema'; import { IRouter, SavedObjectsFindOptions } from 'src/core/server'; +import { DEFAULT_WORKSPACE_ID } from '../../../../core/server'; import { findAll } from '../lib'; export const registerScrollForCountRoute = (router: IRouter) => { @@ -47,7 +48,7 @@ export const registerScrollForCountRoute = (router: IRouter) => { }, router.handleLegacyErrors(async (context, req, res) => { const { client } = context.core.savedObjects; - const counts = { + const counts: Record> = { type: {}, }; @@ -56,18 +57,23 @@ export const registerScrollForCountRoute = (router: IRouter) => { perPage: 1000, }; - const requestHasWorkspaces = Array.isArray(req.body.workspaces) && req.body.workspaces.length; - 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 (findOptions.workspaces.indexOf(DEFAULT_WORKSPACE_ID) !== -1) { + // search both saved objects with workspace and without workspace + findOptions.workspacesSearchOperator = 'OR'; + } } if (req.body.searchString) { @@ -89,6 +95,13 @@ export const registerScrollForCountRoute = (router: IRouter) => { counts.namespaces[ns]++; }); } + if (requestHasWorkspaces) { + const resultWorkspaces = result.workspaces || [DEFAULT_WORKSPACE_ID]; + resultWorkspaces.forEach((ws) => { + counts.workspaces[ws] = counts.workspaces[ws] || 0; + counts.workspaces[ws]++; + }); + } counts.type[type] = counts.type[type] || 0; counts.type[type]++; }); @@ -106,6 +119,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/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 4443b7e99834..7d94a7491a00 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -6,6 +6,6 @@ "requiredPlugins": [ "savedObjects" ], - "optionalPlugins": [], + "optionalPlugins": ["savedObjectsManagement"], "requiredBundles": ["opensearchDashboardsReact"] } diff --git a/src/plugins/workspace/public/components/workspace_column/index.ts b/src/plugins/workspace/public/components/workspace_column/index.ts new file mode 100644 index 000000000000..a9325eb49279 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_column/index.ts @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { getWorkspaceColumn, WorkspaceColumn } from './workspace_column'; diff --git a/src/plugins/workspace/public/components/workspace_column/workspace_colum.test.tsx b/src/plugins/workspace/public/components/workspace_column/workspace_colum.test.tsx new file mode 100644 index 000000000000..655bd9e57f2c --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_column/workspace_colum.test.tsx @@ -0,0 +1,59 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import React from 'react'; +import { coreMock } from '../../../../../core/public/mocks'; +import { render } from '@testing-library/react'; +import { WorkspaceColumn } from './workspace_column'; + +describe('workspace column in saved objects page', () => { + const coreSetup = coreMock.createSetup(); + const workspaceList = [ + { + id: 'ws-1', + name: 'foo', + }, + { + id: 'ws-2', + name: 'bar', + }, + ]; + coreSetup.workspaces.workspaceList$.next(workspaceList); + + it('should show workspace name correctly', () => { + const workspaces = ['ws-1', 'ws-2']; + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
    +
    + foo | bar +
    +
    + `); + }); + + it('show empty when no workspace', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
    +
    +
    + `); + }); + + it('show empty when workspace can not found', () => { + const { container } = render(); + expect(container).toMatchInlineSnapshot(` +
    +
    +
    + `); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx new file mode 100644 index 000000000000..3d964009ee86 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_column/workspace_column.tsx @@ -0,0 +1,49 @@ +/* + * 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 } from '../../../../saved_objects_management/public'; + +interface WorkspaceColumnProps { + coreSetup: CoreSetup; + workspaces?: string[]; +} + +export function WorkspaceColumn({ coreSetup, workspaces }: 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[]) => { + return ; + }, + }, + loadData: () => { + return Promise.resolve(undefined); + }, + }; +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index f1f70b8e1d36..ef601da5f943 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -9,6 +9,7 @@ import { applicationServiceMock, chromeServiceMock, coreMock } from '../../../co import { WorkspacePlugin } from './plugin'; import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; import { Observable, Subscriber } from 'rxjs'; +import { savedObjectsManagementPluginMock } from '../../saved_objects_management/public/mocks'; describe('Workspace plugin', () => { beforeEach(() => { @@ -17,11 +18,15 @@ describe('Workspace plugin', () => { }); it('#setup', async () => { const setupMock = coreMock.createSetup(); + const savedObjectManagementSetupMock = savedObjectsManagementPluginMock.createSetupContract(); const workspacePlugin = new WorkspacePlugin(); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, { + savedObjectsManagement: savedObjectManagementSetupMock, + }); expect(setupMock.application.register).toBeCalledTimes(1); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); + expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1); }); it('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { @@ -62,7 +67,9 @@ describe('Workspace plugin', () => { }); const workspacePlugin = new WorkspacePlugin(); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, { + savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), + }); expect(setupMock.application.register).toBeCalledTimes(1); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); @@ -116,7 +123,9 @@ describe('Workspace plugin', () => { }); const workspacePlugin = new WorkspacePlugin(); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, { + savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), + }); currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); @@ -133,7 +142,9 @@ describe('Workspace plugin', () => { it('#setup register workspace dropdown menu when setup', async () => { const setupMock = coreMock.createSetup(); const workspacePlugin = new WorkspacePlugin(); - await workspacePlugin.setup(setupMock); + await workspacePlugin.setup(setupMock, { + savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), + }); expect(setupMock.chrome.registerCollapsibleNavHeader).toBeCalledTimes(1); }); }); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index d7f1ba6192ad..3363af857c37 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,6 +4,7 @@ */ import type { Subscription } from 'rxjs'; +import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; import { featureMatchesConfig } from './utils'; import { AppMountParameters, @@ -19,10 +20,15 @@ import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { renderWorkspaceMenu } from './render_workspace_menu'; import { Services } from './types'; import { WorkspaceClient } from './workspace_client'; +import { getWorkspaceColumn } from './components/workspace_column'; import { NavLinkWrapper } from '../../../core/public/chrome/nav_links/nav_link'; type WorkspaceAppType = (params: AppMountParameters, services: Services) => () => void; +interface WorkspacePluginSetupDeps { + savedObjectsManagement?: SavedObjectsManagementPluginSetup; +} + export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; private currentWorkspaceIdSubscription?: Subscription; @@ -88,7 +94,7 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { } } - public async setup(core: CoreSetup) { + public async setup(core: CoreSetup, { savedObjectsManagement }: WorkspacePluginSetupDeps) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); await workspaceClient.init(); /** @@ -161,6 +167,11 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { return renderWorkspaceMenu(this.coreStart); }); + /** + * register workspace column into saved objects table + */ + savedObjectsManagement?.columns.register(getWorkspaceColumn(core)); + return {}; } From d287778f44bd5a8fc47ececa64771b3cc54885b9 Mon Sep 17 00:00:00 2001 From: tygao Date: Mon, 11 Mar 2024 18:00:19 +0800 Subject: [PATCH 29/34] Workspace list pr (#285) * feat: add workspace list Signed-off-by: tygao * update index.ts Signed-off-by: tygao * test: update workspace plugin test Signed-off-by: tygao * remove extra file and add test for modal Signed-off-by: tygao * test: add tests for workspace util Signed-off-by: tygao * test: update test for delete_workspace_modal Signed-off-by: tygao * test: update test for delete_workspace_modal Signed-off-by: tygao * test: update test for workspace list Signed-off-by: tygao * rename and update test Signed-off-by: tygao * add tests for workspace list Signed-off-by: tygao --------- Signed-off-by: tygao --- src/core/public/index.ts | 2 + src/core/public/utils/debounce.test.ts | 55 ++++ src/core/public/utils/debounce.ts | 23 ++ src/core/public/utils/index.ts | 1 + src/plugins/workspace/public/application.tsx | 14 + .../delete_workspace_modal.test.tsx.snap | 134 ++++++++ .../delete_workspace_modal.test.tsx | 262 +++++++++++++++ .../delete_workspace_modal.tsx | 121 +++++++ .../delete_workspace_modal/index.ts | 6 + .../public/components/utils/workspace.test.ts | 78 +++++ .../public/components/utils/workspace.ts | 36 +++ .../__snapshots__/index.test.tsx.snap | 301 ++++++++++++++++++ .../components/workspace_list/index.test.tsx | 119 +++++++ .../components/workspace_list/index.tsx | 221 +++++++++++++ .../public/components/workspace_list_app.tsx | 35 ++ src/plugins/workspace/public/plugin.test.ts | 4 +- src/plugins/workspace/public/plugin.ts | 17 +- 17 files changed, 1426 insertions(+), 3 deletions(-) create mode 100644 src/core/public/utils/debounce.test.ts create mode 100644 src/core/public/utils/debounce.ts create mode 100644 src/plugins/workspace/public/components/delete_workspace_modal/__snapshots__/delete_workspace_modal.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx create mode 100644 src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx create mode 100644 src/plugins/workspace/public/components/delete_workspace_modal/index.ts create mode 100644 src/plugins/workspace/public/components/utils/workspace.test.ts create mode 100644 src/plugins/workspace/public/components/utils/workspace.ts create mode 100644 src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap create mode 100644 src/plugins/workspace/public/components/workspace_list/index.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_list/index.tsx create mode 100644 src/plugins/workspace/public/components/workspace_list_app.tsx diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 01e018d07df3..d90142c37090 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -363,3 +363,5 @@ export { __osdBootstrap__ } from './osd_bootstrap'; export { WorkspacesStart, WorkspacesSetup, WorkspacesService } from './workspace'; export { WORKSPACE_TYPE, cleanWorkspaceId, DEFAULT_WORKSPACE_ID } from '../utils'; + +export { debounce } from './utils'; diff --git a/src/core/public/utils/debounce.test.ts b/src/core/public/utils/debounce.test.ts new file mode 100644 index 000000000000..7722a26bd0e5 --- /dev/null +++ b/src/core/public/utils/debounce.test.ts @@ -0,0 +1,55 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ +import { debounce } from './debounce'; + +describe('debounce', () => { + let fn: Function; + beforeEach(() => { + fn = jest.fn(); + jest.useFakeTimers(); + }); + afterEach(() => { + jest.clearAllTimers(); + }); + + test('it should call the debounced fn once at the end of the quiet time', () => { + const debounced = debounce(fn, 1000); + + for (let i = 0; i < 100; i++) { + debounced(i); + } + + jest.advanceTimersByTime(1001); + expect(fn).toBeCalledTimes(1); + expect(fn).toBeCalledWith(99); + }); + + test("with a leading invocation, it should call the debounced fn once, if the time doens't pass", () => { + const debounced = debounce(fn, 1000, true); + + for (let i = 0; i < 100; i++) { + debounced(i); + } + + jest.advanceTimersByTime(999); + + expect(fn).toBeCalledTimes(1); + expect(fn).toBeCalledWith(0); + }); + + test('with a leading invocation, it should call the debounced fn twice (at the beginning and at the end)', () => { + const debounced = debounce(fn, 1000, true); + + for (let i = 0; i < 100; i++) { + debounced(i); + } + + jest.advanceTimersByTime(1500); + + expect(fn).toBeCalledTimes(2); + expect(fn).toBeCalledWith(0); + expect(fn).toBeCalledWith(99); + }); +}); diff --git a/src/core/public/utils/debounce.ts b/src/core/public/utils/debounce.ts new file mode 100644 index 000000000000..95e1a81dcab8 --- /dev/null +++ b/src/core/public/utils/debounce.ts @@ -0,0 +1,23 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * @param func The function to be debounced. + * @param delay The time in milliseconds to wait before invoking the function again after the last invocation. + * @param leading An optional parameter that, when true, allows the function to be invoked immediately upon the first call. + + */ +export const debounce = (func: Function, delay: number, leading?: boolean) => { + let timerId: NodeJS.Timeout; + + return (...args: any) => { + if (!timerId && leading) { + func(...args); + } + clearTimeout(timerId); + + timerId = setTimeout(() => func(...args), delay); + }; +}; diff --git a/src/core/public/utils/index.ts b/src/core/public/utils/index.ts index 30055b0ff81c..4c64728feb16 100644 --- a/src/core/public/utils/index.ts +++ b/src/core/public/utils/index.ts @@ -38,3 +38,4 @@ export { getWorkspaceIdFromUrl, cleanWorkspaceId, } from '../../utils'; +export { debounce } from './debounce'; diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index a6f496304889..ad943786d0b6 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -7,6 +7,7 @@ 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 { WorkspaceFatalError } from './components/workspace_fatal_error'; import { Services } from './types'; @@ -24,3 +25,16 @@ export const renderFatalErrorApp = (params: AppMountParameters, services: Servic ReactDOM.unmountComponentAtNode(element); }; }; + +export const renderListApp = ({ element }: AppMountParameters, services: Services) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/__snapshots__/delete_workspace_modal.test.tsx.snap b/src/plugins/workspace/public/components/delete_workspace_modal/__snapshots__/delete_workspace_modal.test.tsx.snap new file mode 100644 index 000000000000..efa63c2f1d08 --- /dev/null +++ b/src/plugins/workspace/public/components/delete_workspace_modal/__snapshots__/delete_workspace_modal.test.tsx.snap @@ -0,0 +1,134 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DeleteWorkspaceModal should render normally 1`] = ` + +
    +
    +
    +
    + +
    +
    +
    + Delete workspace +
    +
    +
    +
    +
    +

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

    +
      +
      +
      +
      + To confirm your action, type + + delete + + . +
      +
      +
      +
      + +
      +
      +
      +
    +
    +
    + + +
    +
    +
    +
    +
    + +`; diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx new file mode 100644 index 000000000000..15078b87bade --- /dev/null +++ b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx @@ -0,0 +1,262 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { DeleteWorkspaceModal, DeleteWorkspaceModalProps } from './delete_workspace_modal'; +import { coreMock } from '../../../../../core/public/mocks'; +import { render, fireEvent, waitFor } from '@testing-library/react'; +import { workspaceClientMock } from '../../../public/workspace_client.mock'; +import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; + +const defaultProps: DeleteWorkspaceModalProps = { + onClose: jest.fn(), + selectedWorkspace: null, + returnToHome: true, +}; + +const coreStartMock = coreMock.createStart(); + +function getWrapWorkspaceDeleteModalInContext( + props: DeleteWorkspaceModalProps, + services = { ...coreStartMock } +) { + return ( + + + + ); +} + +describe('DeleteWorkspaceModal', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render normally', () => { + const { getByText, baseElement, getByTestId } = render( + getWrapWorkspaceDeleteModalInContext(defaultProps) + ); + + expect(getByText('Delete workspace')).toBeInTheDocument(); + expect(getByTestId('delete-workspace-modal-header')).toBeInTheDocument(); + expect(getByTestId('delete-workspace-modal-body')).toBeInTheDocument(); + expect(baseElement).toMatchSnapshot(); + }); + + it('should emit onClose when clicking cancel button', () => { + const onClose = jest.fn(); + const newProps = { + ...defaultProps, + onClose, + }; + const { getByTestId } = render(getWrapWorkspaceDeleteModalInContext(newProps)); + expect(onClose).not.toHaveBeenCalled(); + const cancelButton = getByTestId('delete-workspace-modal-cancel-button'); + fireEvent.click(cancelButton); + expect(onClose).toHaveBeenCalledTimes(1); + }); + + it('should be able to delete workspace and navigate successfully', async () => { + const onCloseFn = jest.fn(); + const newProps = { + ...defaultProps, + selectedWorkspace: { + id: 'test', + name: 'test', + }, + onClose: onCloseFn, + }; + const deleteFn = jest.fn().mockReturnValue({ + success: true, + }); + const newServices = { + ...coreStartMock, + workspaceClient: { + ...workspaceClientMock, + delete: deleteFn, + }, + }; + const { getByTestId, findByTestId } = render( + getWrapWorkspaceDeleteModalInContext(newProps, newServices) + ); + await findByTestId('delete-workspace-modal-input'); + const input = getByTestId('delete-workspace-modal-input'); + fireEvent.change(input, { + target: { value: 'delete' }, + }); + const confirmButton = getByTestId('delete-workspace-modal-confirm'); + expect(deleteFn).not.toHaveBeenCalled(); + fireEvent.click(confirmButton); + expect(deleteFn).toHaveBeenCalledWith('test'); + await waitFor(() => { + expect(coreStartMock.notifications.toasts.addSuccess).toHaveBeenCalled(); + expect(onCloseFn).toHaveBeenCalled(); + expect(coreStartMock.application.navigateToUrl).toHaveBeenCalled(); + }); + }); + + it('should not navigate when successfully if returnToHome is false', async () => { + const newProps = { + ...defaultProps, + selectedWorkspace: { + id: 'test', + name: 'test', + }, + returnToHome: false, + }; + const deleteFn = jest.fn().mockReturnValue({ + success: true, + }); + const newServices = { + ...coreStartMock, + workspaceClient: { + ...workspaceClientMock, + delete: deleteFn, + }, + }; + const { getByTestId, findByTestId } = render( + getWrapWorkspaceDeleteModalInContext(newProps, newServices) + ); + await findByTestId('delete-workspace-modal-input'); + const input = getByTestId('delete-workspace-modal-input'); + fireEvent.change(input, { + target: { value: 'delete' }, + }); + const confirmButton = getByTestId('delete-workspace-modal-confirm'); + fireEvent.click(confirmButton); + expect(deleteFn).toHaveBeenCalledWith('test'); + await waitFor(() => { + expect(coreStartMock.notifications.toasts.addSuccess).toHaveBeenCalled(); + expect(coreStartMock.application.navigateToUrl).not.toHaveBeenCalled(); + }); + }); + + it('should not call deleteWorkspace if passed selectedWorkspace is null', async () => { + const newProps = { + ...defaultProps, + selectedWorkspace: null, + }; + const deleteFn = jest.fn().mockReturnValue({ + success: true, + }); + const newServices = { + ...coreStartMock, + workspaceClient: { + ...workspaceClientMock, + delete: deleteFn, + }, + }; + const { getByTestId, findByTestId } = render( + getWrapWorkspaceDeleteModalInContext(newProps, newServices) + ); + await findByTestId('delete-workspace-modal-input'); + const input = getByTestId('delete-workspace-modal-input'); + fireEvent.change(input, { + target: { value: 'delete' }, + }); + const confirmButton = getByTestId('delete-workspace-modal-confirm'); + fireEvent.click(confirmButton); + expect(deleteFn).not.toHaveBeenCalled(); + }); + + it('should add danger is returned data is unsuccess', async () => { + const newProps = { + ...defaultProps, + selectedWorkspace: { + id: 'test', + name: 'test', + }, + }; + const deleteFn = jest.fn().mockReturnValue({ + success: false, + }); + const newServices = { + ...coreStartMock, + workspaceClient: { + ...workspaceClientMock, + delete: deleteFn, + }, + }; + const { getByTestId, findByTestId } = render( + getWrapWorkspaceDeleteModalInContext(newProps, newServices) + ); + await findByTestId('delete-workspace-modal-input'); + const input = getByTestId('delete-workspace-modal-input'); + fireEvent.change(input, { + target: { value: 'delete' }, + }); + const confirmButton = getByTestId('delete-workspace-modal-confirm'); + fireEvent.click(confirmButton); + expect(deleteFn).toHaveBeenCalledWith('test'); + await waitFor(() => { + expect(coreStartMock.notifications.toasts.addSuccess).not.toHaveBeenCalled(); + expect(coreStartMock.notifications.toasts.addDanger).toHaveBeenCalled(); + }); + }); + + it('confirm button should be disabled if not input delete', async () => { + const newProps = { + ...defaultProps, + selectedWorkspace: { + id: 'test', + name: 'test', + }, + }; + const deleteFn = jest.fn().mockReturnValue({ + success: false, + }); + const newServices = { + ...coreStartMock, + workspaceClient: { + ...workspaceClientMock, + delete: deleteFn, + }, + }; + const { getByTestId, findByTestId } = render( + getWrapWorkspaceDeleteModalInContext(newProps, newServices) + ); + await findByTestId('delete-workspace-modal-input'); + const input = getByTestId('delete-workspace-modal-input'); + fireEvent.change(input, { + target: { value: 'delet' }, + }); + const confirmButton = getByTestId('delete-workspace-modal-confirm'); + expect(confirmButton.hasAttribute('disabled')); + }); + + it('should catch error and add danger', async () => { + const onCloseFn = jest.fn(); + const newProps = { + ...defaultProps, + selectedWorkspace: { + id: 'test', + name: 'test', + }, + onclose: onCloseFn, + }; + const deleteFn = jest.fn().mockImplementation(() => { + throw new Error('error'); + }); + const newServices = { + ...coreStartMock, + workspaceClient: { + ...workspaceClientMock, + delete: deleteFn, + }, + }; + const { getByTestId, findByTestId } = render( + getWrapWorkspaceDeleteModalInContext(newProps, newServices) + ); + await findByTestId('delete-workspace-modal-input'); + const input = getByTestId('delete-workspace-modal-input'); + fireEvent.change(input, { + target: { value: 'delete' }, + }); + const confirmButton = getByTestId('delete-workspace-modal-confirm'); + fireEvent.click(confirmButton); + expect(deleteFn).toHaveBeenCalledWith('test'); + expect(coreStartMock.notifications.toasts.addDanger).toHaveBeenCalled(); + }); +}); 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..4273134805a8 --- /dev/null +++ b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.tsx @@ -0,0 +1,121 @@ +/* + * 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'; +import { WorkspaceAttribute } from 'opensearch-dashboards/public'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceClient } from '../../workspace_client'; + +export interface DeleteWorkspaceModalProps { + onClose: () => void; + selectedWorkspace?: WorkspaceAttribute | null; + returnToHome: boolean; +} + +export function DeleteWorkspaceModal(props: DeleteWorkspaceModalProps) { + const [value, setValue] = useState(''); + const { onClose, selectedWorkspace, returnToHome } = props; + const { + services: { application, notifications, http, workspaceClient }, + } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); + + const deleteWorkspace = async () => { + if (selectedWorkspace?.id) { + let result; + try { + result = await workspaceClient.delete(selectedWorkspace?.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 onClose(); + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.delete.success', { + defaultMessage: 'Delete workspace successfully', + }), + }); + onClose(); + if (http && application && returnToHome) { + 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 ( + + + Delete workspace + + + +
    +

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

    +
      + {selectedWorkspace?.name ?
    • {selectedWorkspace.name}
    • : null} +
    + + + 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/utils/workspace.test.ts b/src/plugins/workspace/public/components/utils/workspace.test.ts new file mode 100644 index 000000000000..7b0e93c739c7 --- /dev/null +++ b/src/plugins/workspace/public/components/utils/workspace.test.ts @@ -0,0 +1,78 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { switchWorkspace, updateWorkspace } from './workspace'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +jest.mock('../../../../../core/public/utils'); + +import { coreMock } from '../../../../../core/public/mocks'; + +const coreStartMock = coreMock.createStart(); +let mockNavigateToUrl = jest.fn(); + +const defaultUrl = 'localhost://'; + +describe('workspace utils', () => { + beforeEach(() => { + mockNavigateToUrl = jest.fn(); + coreStartMock.application.navigateToUrl = mockNavigateToUrl; + }); + + describe('switchWorkspace', () => { + it('should redirect if newUrl is returned', () => { + Object.defineProperty(window, 'location', { + value: { + href: defaultUrl, + }, + writable: true, + }); + // @ts-ignore + formatUrlWithWorkspaceId.mockImplementation(() => 'new_url'); + switchWorkspace({ application: coreStartMock.application, http: coreStartMock.http }, ''); + expect(mockNavigateToUrl).toHaveBeenCalledWith('new_url'); + }); + + it('should not redirect if newUrl is not returned', () => { + Object.defineProperty(window, 'location', { + value: { + href: defaultUrl, + }, + writable: true, + }); + // @ts-ignore + formatUrlWithWorkspaceId.mockImplementation(() => ''); + switchWorkspace({ application: coreStartMock.application, http: coreStartMock.http }, ''); + expect(mockNavigateToUrl).not.toBeCalled(); + }); + }); + + describe('updateWorkspace', () => { + it('should redirect if newUrl is returned', () => { + Object.defineProperty(window, 'location', { + value: { + href: defaultUrl, + }, + writable: true, + }); + // @ts-ignore + formatUrlWithWorkspaceId.mockImplementation(() => 'new_url'); + updateWorkspace({ application: coreStartMock.application, http: coreStartMock.http }, ''); + expect(mockNavigateToUrl).toHaveBeenCalledWith('new_url'); + }); + + it('should not redirect if newUrl is not returned', () => { + Object.defineProperty(window, 'location', { + value: { + href: defaultUrl, + }, + writable: true, + }); + // @ts-ignore + formatUrlWithWorkspaceId.mockImplementation(() => ''); + updateWorkspace({ application: coreStartMock.application, http: coreStartMock.http }, ''); + expect(mockNavigateToUrl).not.toBeCalled(); + }); + }); +}); 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..fb47ca316cbe --- /dev/null +++ b/src/plugins/workspace/public/components/utils/workspace.ts @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_UPDATE_APP_ID } from '../../../common/constants'; +import { CoreStart } from '../../../../../core/public'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; + +type Core = Pick; + +export const switchWorkspace = ({ application, http }: Core, id: string) => { + const newUrl = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + id, + http.basePath + ); + if (newUrl) { + application.navigateToUrl(newUrl); + } +}; + +export const updateWorkspace = ({ application, http }: Core, id: string) => { + const newUrl = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_UPDATE_APP_ID, { + absolute: true, + }), + id, + http.basePath + ); + if (newUrl) { + application.navigateToUrl(newUrl); + } +}; diff --git a/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap new file mode 100644 index 000000000000..f90101772950 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list/__snapshots__/index.test.tsx.snap @@ -0,0 +1,301 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`WorkspaceList should render title and table normally 1`] = ` +
    +
    +
    +
    +
    +
    +
    +

    + Workspaces +

    +
    +
    +

    + Workspace allow you to save and organize library items, such as index patterns, visualizations, dashboards, saved searches, and share them with other OpenSearch Dashboards users. You can control which features are visible in each workspace, and which users and groups have read and write access to the library items in the workspace. +

    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    + + +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    +
    + +
    +
    +
    +
    +
    +
    +
    + + + + + + + + + + + + + + +
    +
    + + + + + + + Description + + + + + + Features + + + + + + Actions + + +
    +
    + + No items found + +
    +
    +
    +
    +
    +
    +
    +
    +
    +`; diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx new file mode 100644 index 000000000000..f9e5a388368e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -0,0 +1,119 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { WorkspaceList } from './index'; +import { coreMock } from '../../../../../core/public/mocks'; +import { render, fireEvent, screen } from '@testing-library/react'; +import { I18nProvider } from '@osd/i18n/react'; +import { switchWorkspace, updateWorkspace } from '../utils/workspace'; + +import { of } from 'rxjs'; + +import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; + +jest.mock('../utils/workspace'); + +function getWrapWorkspaceListInContext( + workspaceList = [ + { id: 'id1', name: 'name1' }, + { id: 'id2', name: 'name2' }, + ] +) { + const coreStartMock = coreMock.createStart(); + + const services = { + ...coreStartMock, + workspaces: { + workspaceList$: of(workspaceList), + }, + }; + + return ( + + + + + + ); +} + +describe('WorkspaceList', () => { + it('should render title and table normally', () => { + const { getByText, getByRole, container } = render(); + expect(getByText('Workspaces')).toBeInTheDocument(); + expect(getByRole('table')).toBeInTheDocument(); + expect(container).toMatchSnapshot(); + }); + it('should render data in table based on workspace list data', async () => { + const { getByText } = render(getWrapWorkspaceListInContext()); + expect(getByText('name1')).toBeInTheDocument(); + expect(getByText('name2')).toBeInTheDocument(); + }); + it('should be able to apply debounce search after input', async () => { + const list = [ + { id: 'id1', name: 'name1' }, + { id: 'id2', name: 'name2' }, + { id: 'id3', name: 'name3' }, + { id: 'id4', name: 'name4' }, + { id: 'id5', name: 'name5' }, + { id: 'id6', name: 'name6' }, + ]; + const { getByText, getByRole, queryByText } = render(getWrapWorkspaceListInContext(list)); + expect(getByText('name1')).toBeInTheDocument(); + expect(queryByText('name6')).not.toBeInTheDocument(); + const input = getByRole('searchbox'); + fireEvent.change(input, { + target: { value: 'nam' }, + }); + fireEvent.change(input, { + target: { value: 'name6' }, + }); + expect(queryByText('name6')).not.toBeInTheDocument(); + }); + + it('should be able to switch workspace after clicking name', async () => { + const { getByText } = render(getWrapWorkspaceListInContext()); + const nameLink = getByText('name1'); + fireEvent.click(nameLink); + expect(switchWorkspace).toBeCalled(); + }); + + it('should be able to update workspace after clicking name', async () => { + const { getAllByTestId } = render(getWrapWorkspaceListInContext()); + const editIcon = getAllByTestId('workspace-list-edit-icon')[0]; + fireEvent.click(editIcon); + expect(updateWorkspace).toBeCalled(); + }); + + it('should be able to call delete modal after clicking delete button', async () => { + const { getAllByTestId } = render(getWrapWorkspaceListInContext()); + const deleteIcon = getAllByTestId('workspace-list-delete-icon')[0]; + fireEvent.click(deleteIcon); + await screen.findByTestId('delete-workspace-modal-header'); + expect(screen.getByTestId('delete-workspace-modal-header')).toBeInTheDocument(); + const cancelButton = screen.getByTestId('delete-workspace-modal-cancel-button'); + fireEvent.click(cancelButton); + expect(screen.queryByTestId('delete-workspace-modal-header')).not.toBeInTheDocument(); + }); + + it('should be able to pagination when clicking pagination button', async () => { + const list = [ + { id: 'id1', name: 'name1' }, + { id: 'id2', name: 'name2' }, + { id: 'id3', name: 'name3' }, + { id: 'id4', name: 'name4' }, + { id: 'id5', name: 'name5' }, + { id: 'id6', name: 'name6' }, + ]; + const { getByTestId, getByText, queryByText } = render(getWrapWorkspaceListInContext(list)); + expect(getByText('name1')).toBeInTheDocument(); + expect(queryByText('name6')).not.toBeInTheDocument(); + const paginationButton = getByTestId('pagination-button-next'); + fireEvent.click(paginationButton); + expect(queryByText('name1')).not.toBeInTheDocument(); + expect(getByText('name6')).toBeInTheDocument(); + }); +}); 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..bc92a01f8f58 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -0,0 +1,221 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useState, useMemo, useCallback } from 'react'; +import { + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiPageContent, + EuiLink, + EuiButton, + EuiInMemoryTable, + EuiSearchBarProps, +} from '@elastic/eui'; +import useObservable from 'react-use/lib/useObservable'; +import { of } from 'rxjs'; +import { i18n } from '@osd/i18n'; +import { debounce } from '../../../../../core/public'; +import { WorkspaceAttribute } from '../../../../../core/public'; +import { useOpenSearchDashboards } from '../../../../../plugins/opensearch_dashboards_react/public'; +import { switchWorkspace, updateWorkspace } from '../utils/workspace'; + +import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; + +import { cleanWorkspaceId } from '../../../../../core/public'; +import { DeleteWorkspaceModal } from '../delete_workspace_modal'; + +const WORKSPACE_LIST_PAGE_DESCRIPTIOIN = i18n.translate('workspace.list.description', { + defaultMessage: + 'Workspace allow you to save and organize library items, such as index patterns, visualizations, dashboards, saved searches, and share them with other OpenSearch Dashboards users. You can control which features are visible in each workspace, and which users and groups have read and write access to the library items in the workspace.', +}); + +export const WorkspaceList = () => { + const { + services: { workspaces, application, http }, + } = useOpenSearchDashboards(); + + const initialSortField = 'name'; + const initialSortDirection = 'asc'; + const workspaceList = useObservable(workspaces?.workspaceList$ ?? of([]), []); + const [queryInput, setQueryInput] = useState(''); + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 5, + pageSizeOptions: [5, 10, 20], + }); + const [deletedWorkspace, setDeletedWorkspace] = useState(null); + + const handleSwitchWorkspace = useCallback( + (id: string) => { + if (application && http) { + switchWorkspace({ application, http }, id); + } + }, + [application, http] + ); + + const handleUpdateWorkspace = useCallback( + (id: string) => { + if (application && http) { + updateWorkspace({ application, http }, id); + } + }, + [application, http] + ); + + const searchResult = useMemo(() => { + if (queryInput) { + const normalizedQuery = queryInput.toLowerCase(); + const result = workspaceList.filter((item) => { + return ( + item.id.toLowerCase().indexOf(normalizedQuery) > -1 || + item.name.toLowerCase().indexOf(normalizedQuery) > -1 + ); + }); + return result; + } + return workspaceList; + }, [workspaceList, queryInput]); + + const columns = [ + { + field: 'name', + name: 'Name', + sortable: true, + render: (name: string, item: WorkspaceAttribute) => ( + + handleSwitchWorkspace(item.id)}>{name} + + ), + }, + { + field: 'id', + name: 'ID', + sortable: true, + }, + { + field: 'description', + name: 'Description', + truncateText: true, + }, + { + field: 'features', + name: 'Features', + isExpander: true, + hasActions: true, + }, + { + name: 'Actions', + field: '', + actions: [ + { + name: 'Edit', + icon: 'pencil', + type: 'icon', + description: 'Edit workspace', + onClick: ({ id }: WorkspaceAttribute) => handleUpdateWorkspace(id), + 'data-test-subj': 'workspace-list-edit-icon', + }, + { + name: 'Delete', + icon: 'trash', + type: 'icon', + description: 'Delete workspace', + onClick: (item: WorkspaceAttribute) => setDeletedWorkspace(item), + 'data-test-subj': 'workspace-list-delete-icon', + }, + ], + }, + ]; + + const workspaceCreateUrl = useMemo(() => { + if (!application || !http) { + return ''; + } + + const appUrl = application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }); + if (!appUrl) return ''; + + return cleanWorkspaceId(appUrl); + }, [application, http]); + + const debouncedSetQueryInput = useMemo(() => { + return debounce(setQueryInput, 300); + }, [setQueryInput]); + + const handleSearchInput: EuiSearchBarProps['onChange'] = useCallback( + ({ query }) => { + debouncedSetQueryInput(query?.text ?? ''); + }, + [debouncedSetQueryInput] + ); + + const search: EuiSearchBarProps = { + onChange: handleSearchInput, + box: { + incremental: true, + }, + toolsRight: [ + + Create workspace + , + ], + }; + + return ( + + + + + + setPagination((prev) => { + return { ...prev, pageIndex: index, pageSize: size }; + }) + } + pagination={pagination} + sorting={{ + sort: { + field: initialSortField, + direction: initialSortDirection, + }, + }} + isSelectable={true} + search={search} + /> + + + {deletedWorkspace && ( + setDeletedWorkspace(null)} + returnToHome={false} + /> + )} + + ); +}; 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..8970cfc46fc7 --- /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: 'Workspaces', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index ef601da5f943..f0050879074a 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -23,7 +23,7 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { savedObjectsManagement: savedObjectManagementSetupMock, }); - expect(setupMock.application.register).toBeCalledTimes(1); + expect(setupMock.application.register).toBeCalledTimes(2); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1); @@ -70,7 +70,7 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), }); - expect(setupMock.application.register).toBeCalledTimes(1); + expect(setupMock.application.register).toBeCalledTimes(2); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); expect(setupMock.getStartServices).toBeCalledTimes(1); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 3363af857c37..d430caabdd5c 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -15,7 +15,11 @@ import { Plugin, WorkspaceObject, } from '../../../core/public'; -import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID } from '../common/constants'; +import { + WORKSPACE_FATAL_ERROR_APP_ID, + WORKSPACE_OVERVIEW_APP_ID, + WORKSPACE_LIST_APP_ID, +} from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { renderWorkspaceMenu } from './render_workspace_menu'; import { Services } from './types'; @@ -146,6 +150,17 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { return renderApp(params, services); }; + // 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, From 93a65858fde7c3b1d37da52cd66f6b786d536127 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 15 Mar 2024 10:05:47 +0800 Subject: [PATCH 30/34] fix: authInfo destructure (#7) (#296) * fix: authInfo destructure * fix: unit test error --------- Signed-off-by: SuZhou-Joe --- src/plugins/workspace/server/utils.test.ts | 12 ++++++++---- src/plugins/workspace/server/utils.ts | 10 +++++----- 2 files changed, 13 insertions(+), 9 deletions(-) diff --git a/src/plugins/workspace/server/utils.test.ts b/src/plugins/workspace/server/utils.test.ts index 5af40eea9b06..1f6c3e58f122 100644 --- a/src/plugins/workspace/server/utils.test.ts +++ b/src/plugins/workspace/server/utils.test.ts @@ -27,8 +27,10 @@ describe('workspace utils', () => { mockAuth.get.mockReturnValueOnce({ status: AuthStatus.unknown, state: { - user_name: 'bar', - backend_roles: ['foo'], + authInfo: { + user_name: 'bar', + backend_roles: ['foo'], + }, }, }); const result = getPrincipalsFromRequest(mockRequest, mockAuth); @@ -40,8 +42,10 @@ describe('workspace utils', () => { mockAuth.get.mockReturnValueOnce({ status: AuthStatus.authenticated, state: { - user_name: 'bar', - backend_roles: ['foo'], + authInfo: { + user_name: 'bar', + backend_roles: ['foo'], + }, }, }); const result = getPrincipalsFromRequest(mockRequest, mockAuth); diff --git a/src/plugins/workspace/server/utils.ts b/src/plugins/workspace/server/utils.ts index e51637cd49c3..1c8d73953afa 100644 --- a/src/plugins/workspace/server/utils.ts +++ b/src/plugins/workspace/server/utils.ts @@ -34,12 +34,12 @@ export const getPrincipalsFromRequest = ( } if (authInfoResp?.status === AuthStatus.authenticated) { - const authInfo = authInfoResp?.state as AuthInfo | null; - if (authInfo?.backend_roles) { - payload[PrincipalType.Groups] = authInfo.backend_roles; + const authInfo = authInfoResp?.state as { authInfo: AuthInfo } | null; + if (authInfo?.authInfo?.backend_roles) { + payload[PrincipalType.Groups] = authInfo.authInfo.backend_roles; } - if (authInfo?.user_name) { - payload[PrincipalType.Users] = [authInfo.user_name]; + if (authInfo?.authInfo?.user_name) { + payload[PrincipalType.Users] = [authInfo.authInfo.user_name]; } return payload; } From 529fc0137cc014217952ab7f924c6043b7f4d2fa Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Fri, 15 Mar 2024 11:37:45 +0800 Subject: [PATCH 31/34] Add workspace create page (#284) * Add workspace create page Signed-off-by: Lin Wang * Address PR comments Signed-off-by: Lin Wang * Add more comments Signed-off-by: Lin Wang * Add example for dependencies field in App Signed-off-by: Lin Wang * Separate workspace feature selector Signed-off-by: Lin Wang * Correct example for dependencies Signed-off-by: Lin Wang * Remove unclear icon and defaultVISTheme input Signed-off-by: Lin Wang * Remove unclear dependencies feature Signed-off-by: Lin Wang * Remove states and fix onChange fired after mount Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang --- src/plugins/workspace/common/constants.ts | 2 + src/plugins/workspace/public/application.tsx | 14 + .../components/workspace_creator/index.tsx | 6 + .../workspace_creator.test.tsx | 226 ++++++++++++++++ .../workspace_creator/workspace_creator.tsx | 97 +++++++ .../components/workspace_creator_app.tsx | 35 +++ .../components/workspace_form/constants.ts | 64 +++++ .../public/components/workspace_form/index.ts | 8 + .../public/components/workspace_form/types.ts | 53 ++++ .../workspace_form/use_workspace_form.ts | 216 +++++++++++++++ .../public/components/workspace_form/utils.ts | 97 +++++++ .../workspace_form/workspace_bottom_bar.tsx | 112 ++++++++ .../workspace_form/workspace_cancel_modal.tsx | 49 ++++ .../workspace_feature_selector.tsx | 212 +++++++++++++++ .../workspace_form/workspace_form.tsx | 188 +++++++++++++ .../workspace_icon_selector.tsx | 46 ++++ .../workspace_permission_setting_input.tsx | 129 +++++++++ .../workspace_permission_setting_panel.tsx | 246 ++++++++++++++++++ src/plugins/workspace/public/hooks.ts | 19 ++ src/plugins/workspace/public/plugin.test.ts | 4 +- src/plugins/workspace/public/plugin.ts | 15 ++ 21 files changed, 1836 insertions(+), 2 deletions(-) create mode 100644 src/plugins/workspace/public/components/workspace_creator/index.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx create mode 100644 src/plugins/workspace/public/components/workspace_creator_app.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/constants.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/index.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/types.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/utils.ts create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_form.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_icon_selector.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx create mode 100644 src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx create mode 100644 src/plugins/workspace/public/hooks.ts diff --git a/src/plugins/workspace/common/constants.ts b/src/plugins/workspace/common/constants.ts index 5bd8ab34c313..2be4a9d121db 100644 --- a/src/plugins/workspace/common/constants.ts +++ b/src/plugins/workspace/common/constants.ts @@ -7,6 +7,8 @@ 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 WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID = 'workspace'; export const WORKSPACE_CONFLICT_CONTROL_SAVED_OBJECTS_CLIENT_WRAPPER_ID = diff --git a/src/plugins/workspace/public/application.tsx b/src/plugins/workspace/public/application.tsx index ad943786d0b6..f01e788f3cc9 100644 --- a/src/plugins/workspace/public/application.tsx +++ b/src/plugins/workspace/public/application.tsx @@ -9,8 +9,22 @@ import { AppMountParameters, ScopedHistory } from '../../../core/public'; import { OpenSearchDashboardsContextProvider } from '../../opensearch_dashboards_react/public'; import { WorkspaceListApp } from './components/workspace_list_app'; import { WorkspaceFatalError } from './components/workspace_fatal_error'; +import { WorkspaceCreatorApp } from './components/workspace_creator_app'; import { Services } from './types'; +export const renderCreatorApp = ({ element }: AppMountParameters, services: Services) => { + ReactDOM.render( + + + , + element + ); + + return () => { + ReactDOM.unmountComponentAtNode(element); + }; +}; + export const renderFatalErrorApp = (params: AppMountParameters, services: Services) => { const { element } = params; const history = params.history as ScopedHistory<{ error?: string }>; diff --git a/src/plugins/workspace/public/components/workspace_creator/index.tsx b/src/plugins/workspace/public/components/workspace_creator/index.tsx new file mode 100644 index 000000000000..c8cdbfab65be --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/index.tsx @@ -0,0 +1,6 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceCreator } from './workspace_creator'; diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx new file mode 100644 index 000000000000..f10fd39cfe9d --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.test.tsx @@ -0,0 +1,226 @@ +/* + * 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), + }, + 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' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + color: '#000000', + description: 'test workspace description', + }), + expect.any(Array) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('create workspace with customized features', async () => { + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-app1')); + fireEvent.click(getByTestId('workspaceForm-workspaceFeatureVisibility-category1')); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + features: expect.arrayContaining(['app1', 'app2', 'app3']), + }), + expect.any(Array) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('create workspace with customized permissions', async () => { + const { getByTestId, getByText } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByText('Users & Permissions')); + fireEvent.click(getByTestId('workspaceForm-permissionSettingPanel-user-addNew')); + const userIdInput = getByTestId('workspaceForm-permissionSettingPanel-0-userId'); + fireEvent.click(userIdInput); + fireEvent.input(getByTestId('comboBoxSearchInput'), { + target: { value: 'test user id' }, + }); + fireEvent.blur(getByTestId('comboBoxSearchInput')); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalledWith( + expect.objectContaining({ + name: 'test workspace name', + }), + expect.arrayContaining([expect.objectContaining({ type: 'user', userId: 'test user id' })]) + ); + await waitFor(() => { + expect(notificationToastsAddSuccess).toHaveBeenCalled(); + }); + expect(notificationToastsAddDanger).not.toHaveBeenCalled(); + }); + + it('should show danger toasts after create workspace failed', async () => { + workspaceClientCreate.mockReturnValue({ result: { id: 'failResult' }, success: false }); + const { getByTestId } = render(); + const nameInput = getByTestId('workspaceForm-workspaceDetails-nameInputText'); + fireEvent.input(nameInput, { + target: { value: 'test workspace name' }, + }); + fireEvent.click(getByTestId('workspaceForm-bottomBar-createButton')); + expect(workspaceClientCreate).toHaveBeenCalled(); + await waitFor(() => { + expect(notificationToastsAddDanger).toHaveBeenCalled(); + }); + expect(notificationToastsAddSuccess).not.toHaveBeenCalled(); + }); +}); diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx new file mode 100644 index 000000000000..2b3511f18b8b --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback } from 'react'; +import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../../opensearch_dashboards_react/public'; +import { WorkspaceForm, WorkspaceFormSubmitData, WorkspaceOperationType } from '../workspace_form'; +import { WORKSPACE_OVERVIEW_APP_ID } from '../../../common/constants'; +import { formatUrlWithWorkspaceId } from '../../../../../core/public/utils'; +import { WorkspaceClient } from '../../workspace_client'; + +export const WorkspaceCreator = () => { + const { + services: { application, notifications, http, workspaceClient }, + } = useOpenSearchDashboards<{ workspaceClient: WorkspaceClient }>(); + + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; + const handleWorkspaceFormSubmit = useCallback( + async (data: WorkspaceFormSubmitData) => { + let result; + try { + const { permissions, ...attributes } = data; + result = await workspaceClient.create(attributes, permissions); + } catch (error) { + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: error instanceof Error ? error.message : JSON.stringify(error), + }); + return; + } + if (result?.success) { + notifications?.toasts.addSuccess({ + title: i18n.translate('workspace.create.success', { + defaultMessage: 'Create workspace successfully', + }), + }); + if (application && http) { + const newWorkspaceId = result.result.id; + // Redirect page after one second, leave one second time to show create successful toast. + window.setTimeout(() => { + window.location.href = formatUrlWithWorkspaceId( + application.getUrlForApp(WORKSPACE_OVERVIEW_APP_ID, { + absolute: true, + }), + newWorkspaceId, + http.basePath + ); + }, 1000); + } + return; + } + notifications?.toasts.addDanger({ + title: i18n.translate('workspace.create.failed', { + defaultMessage: 'Failed to create workspace', + }), + text: result?.error, + }); + }, + [notifications?.toasts, http, application, workspaceClient] + ); + + return ( + + + + + + {application && ( + + )} + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_creator_app.tsx b/src/plugins/workspace/public/components/workspace_creator_app.tsx new file mode 100644 index 000000000000..b74359929352 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_creator_app.tsx @@ -0,0 +1,35 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useEffect } from 'react'; +import { I18nProvider } from '@osd/i18n/react'; +import { i18n } from '@osd/i18n'; +import { useOpenSearchDashboards } from '../../../opensearch_dashboards_react/public'; +import { WorkspaceCreator } from './workspace_creator'; + +export const WorkspaceCreatorApp = () => { + const { + services: { chrome }, + } = useOpenSearchDashboards(); + + /** + * set breadcrumbs to chrome + */ + useEffect(() => { + chrome?.setBreadcrumbs([ + { + text: i18n.translate('workspace.workspaceCreateTitle', { + defaultMessage: 'Create workspace', + }), + }, + ]); + }, [chrome]); + + return ( + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/constants.ts b/src/plugins/workspace/public/components/workspace_form/constants.ts new file mode 100644 index 000000000000..3af7f5c743e9 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/constants.ts @@ -0,0 +1,64 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { WorkspacePermissionMode } from '../../../common/constants'; + +export enum WorkspacePermissionItemType { + User = 'user', + Group = 'group', +} + +export enum PermissionModeId { + Read = 'read', + ReadAndWrite = 'read+write', + Admin = 'admin', +} + +export 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', + }), + }, +]; + +export const optionIdToWorkspacePermissionModesMap: { + [key: string]: WorkspacePermissionMode[]; +} = { + [PermissionModeId.Read]: [WorkspacePermissionMode.LibraryRead, WorkspacePermissionMode.Read], + [PermissionModeId.ReadAndWrite]: [ + WorkspacePermissionMode.LibraryWrite, + WorkspacePermissionMode.Read, + ], + [PermissionModeId.Admin]: [WorkspacePermissionMode.LibraryWrite, WorkspacePermissionMode.Write], +}; + +export enum WorkspaceOperationType { + Create = 'create', + Update = 'update', +} + +export enum WorkspaceFormTabs { + NotSelected, + FeatureVisibility, + UsersAndPermissions, +} diff --git a/src/plugins/workspace/public/components/workspace_form/index.ts b/src/plugins/workspace/public/components/workspace_form/index.ts new file mode 100644 index 000000000000..6531d4a1c6f7 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { WorkspaceForm } from './workspace_form'; +export { WorkspaceFormSubmitData } from './types'; +export { WorkspaceOperationType } from './constants'; diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts new file mode 100644 index 000000000000..15af85965943 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -0,0 +1,53 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import type { WorkspacePermissionItemType, WorkspaceOperationType } from './constants'; +import type { WorkspacePermissionMode } from '../../../common/constants'; +import type { App, ApplicationStart } from '../../../../../core/public'; + +export type WorkspacePermissionSetting = + | { type: WorkspacePermissionItemType.User; userId: string; modes: WorkspacePermissionMode[] } + | { type: WorkspacePermissionItemType.Group; group: string; modes: WorkspacePermissionMode[] }; + +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; +} + +export interface WorkspaceFeature { + id: string; + name: string; +} + +export interface WorkspaceFeatureGroup { + name: string; + features: WorkspaceFeature[]; +} + +export type WorkspaceFormErrors = Omit< + { [key in keyof WorkspaceFormData]?: string }, + 'permissions' +> & { + permissions?: string[]; +}; + +export interface WorkspaceFormProps { + application: ApplicationStart; + onSubmit?: (formData: WorkspaceFormSubmitData) => void; + defaultValues?: WorkspaceFormData; + operationType?: WorkspaceOperationType; + permissionEnabled?: boolean; + permissionLastAdminItemDeletable?: boolean; +} diff --git a/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts new file mode 100644 index 000000000000..7158693aedff --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts @@ -0,0 +1,216 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { useCallback, useState, FormEventHandler, useRef, useMemo, useEffect } from 'react'; +import { htmlIdGenerator, EuiFieldTextProps, EuiColorPickerProps } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { useApplications } from '../../hooks'; +import { featureMatchesConfig } from '../../utils'; + +import { WorkspacePermissionItemType, WorkspaceFormTabs } from './constants'; +import { WorkspacePermissionSetting, WorkspaceFormProps, WorkspaceFormErrors } from './types'; +import { + appendDefaultFeatureIds, + getNumberOfErrors, + isUserOrGroupPermissionSettingDuplicated, + isValidNameOrDescription, + isValidWorkspacePermissionSetting, +} from './utils'; + +const workspaceHtmlIdGenerator = htmlIdGenerator(); + +export const useWorkspaceForm = ({ application, defaultValues, onSubmit }: WorkspaceFormProps) => { + const applications = useApplications(application); + const [name, setName] = useState(defaultValues?.name); + const [description, setDescription] = useState(defaultValues?.description); + const [color, setColor] = useState(defaultValues?.color); + + const [selectedTab, setSelectedTab] = useState(WorkspaceFormTabs.FeatureVisibility); + 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; + + 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, + permissions: permissionSettings, + }); + const getFormDataRef = useRef(getFormData); + getFormDataRef.current = getFormData; + + if (!formIdRef.current) { + formIdRef.current = workspaceHtmlIdGenerator(); + } + + const handleFormSubmit = useCallback( + (e) => { + e.preventDefault(); + let currentFormErrors: WorkspaceFormErrors = {}; + const formData = getFormDataRef.current(); + if (!formData.name) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.empty', { + defaultMessage: "Name can't be empty.", + }), + }; + } + if (!isValidNameOrDescription(formData.name)) { + currentFormErrors = { + ...currentFormErrors, + name: i18n.translate('workspace.form.detail.name.invalid', { + defaultMessage: 'Invalid workspace name', + }), + }; + } + if (!isValidNameOrDescription(formData.description)) { + currentFormErrors = { + ...currentFormErrors, + description: i18n.translate('workspace.form.detail.description.invalid', { + defaultMessage: 'Invalid workspace description', + }), + }; + } + const permissionErrors: string[] = new Array(formData.permissions.length); + for (let i = 0; i < formData.permissions.length; i++) { + const permission = formData.permissions[i]; + if (isValidWorkspacePermissionSetting(permission)) { + if ( + isUserOrGroupPermissionSettingDuplicated(formData.permissions.slice(0, i), permission) + ) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', { + defaultMessage: 'Duplicate permission setting', + }); + continue; + } + continue; + } + if (!permission.type) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.type', { + defaultMessage: 'Invalid type', + }); + continue; + } + if (!permission.modes || permission.modes.length === 0) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.modes', { + defaultMessage: 'Invalid permission modes', + }); + continue; + } + if (permission.type === WorkspacePermissionItemType.User && !permission.userId) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.userId', { + defaultMessage: 'Invalid userId', + }); + continue; + } + if (permission.type === WorkspacePermissionItemType.Group && !permission.group) { + permissionErrors[i] = i18n.translate('workspace.form.permission.invalidate.group', { + defaultMessage: 'Invalid user group', + }); + continue; // this line is need for more conditions + } + } + if (permissionErrors.some((error) => !!error)) { + currentFormErrors = { + ...currentFormErrors, + permissions: permissionErrors, + }; + } + const currentNumberOfErrors = getNumberOfErrors(currentFormErrors); + setFormErrors(currentFormErrors); + setNumberOfErrors(currentNumberOfErrors); + if (currentNumberOfErrors > 0) { + return; + } + + const featureConfigChanged = + formData.features.length !== defaultFeatures.length || + formData.features.some((feat) => !defaultFeatures.includes(feat)); + + if (!featureConfigChanged) { + // If feature config not changed, set workspace feature config to the original value. + // The reason why we do this is when a workspace feature is configured by wildcard, + // such as `['@management']` or `['*']`. The form value `formData.features` will be + // expanded to array of individual feature id, if the feature hasn't changed, we will + // set the feature config back to the original value so that category wildcard won't + // expanded to feature ids + formData.features = defaultValues?.features ?? []; + } + + const permissions = formData.permissions.filter(isValidWorkspacePermissionSetting); + onSubmit?.({ ...formData, name: formData.name!, permissions }); + }, + [defaultFeatures, onSubmit, defaultValues?.features] + ); + + const handleNameInputChange = useCallback['onChange']>((e) => { + setName(e.target.value); + }, []); + + const handleDescriptionInputChange = useCallback['onChange']>((e) => { + setDescription(e.target.value); + }, []); + + const handleColorChange = useCallback['onChange']>((text) => { + setColor(text); + }, []); + + const handleTabFeatureClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.FeatureVisibility); + }, []); + + const handleTabPermissionClick = useCallback(() => { + setSelectedTab(WorkspaceFormTabs.UsersAndPermissions); + }, []); + + const handleFeaturesChange = useCallback((featureIds: string[]) => { + setSelectedFeatureIds(featureIds); + }, []); + + useEffect(() => { + // When applications changed, reset form feature selection to original value + setSelectedFeatureIds(appendDefaultFeatureIds(defaultFeaturesRef.current)); + }, [applications]); + + return { + formId: formIdRef.current, + formData: getFormData(), + formErrors, + selectedTab, + applications, + numberOfErrors, + handleFormSubmit, + handleColorChange, + handleFeaturesChange, + handleNameInputChange, + handleTabFeatureClick, + setPermissionSettings, + handleTabPermissionClick, + handleDescriptionInputChange, + }; +}; diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts new file mode 100644 index 000000000000..133a3bc563de --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -0,0 +1,97 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { WorkspacePermissionMode, DEFAULT_CHECKED_FEATURES_IDS } from '../../../common/constants'; + +import { + WorkspaceFeature, + WorkspaceFeatureGroup, + WorkspacePermissionSetting, + WorkspaceFormErrors, +} from './types'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + PermissionModeId, +} from './constants'; + +export const isWorkspaceFeatureGroup = ( + featureOrGroup: WorkspaceFeature | WorkspaceFeatureGroup +): featureOrGroup is WorkspaceFeatureGroup => 'features' in featureOrGroup; + +export 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)); + +export const isDefaultCheckedFeatureId = (id: string) => { + return DEFAULT_CHECKED_FEATURES_IDS.indexOf(id) > -1; +}; + +export const appendDefaultFeatureIds = (ids: string[]) => { + // concat default checked ids and unique the result + return Array.from(new Set(ids.concat(DEFAULT_CHECKED_FEATURES_IDS))); +}; + +export const isValidNameOrDescription = (input?: string) => { + if (!input) { + return true; + } + const regex = /^[0-9a-zA-Z()_\[\]\-\s]+$/; + return regex.test(input); +}; + +export 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; +}; + +export const isUserOrGroupPermissionSettingDuplicated = ( + permissionSettings: Array>, + permissionSettingToCheck: WorkspacePermissionSetting +) => + permissionSettings.some( + (permissionSetting) => + (permissionSettingToCheck.type === WorkspacePermissionItemType.User && + permissionSetting.type === WorkspacePermissionItemType.User && + permissionSettingToCheck.userId === permissionSetting.userId) || + (permissionSettingToCheck.type === WorkspacePermissionItemType.Group && + permissionSetting.type === WorkspacePermissionItemType.Group && + permissionSettingToCheck.group === permissionSetting.group) + ); + +export 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 +export const getPermissionModeId = (modes: WorkspacePermissionMode[]) => { + for (const key in optionIdToWorkspacePermissionModesMap) { + if (optionIdToWorkspacePermissionModesMap[key].every((mode) => modes?.includes(mode))) { + return key; + } + } + return PermissionModeId.Read; +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx new file mode 100644 index 000000000000..c55501725a52 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_bottom_bar.tsx @@ -0,0 +1,112 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + EuiBottomBar, + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiSpacer, + EuiText, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import React, { useState } from 'react'; +import { ApplicationStart } from 'opensearch-dashboards/public'; +import { WorkspaceOperationType } from '../workspace_form'; +import { WorkspaceCancelModal } from './workspace_cancel_modal'; + +interface WorkspaceBottomBarProps { + formId: string; + operationType?: WorkspaceOperationType; + numberOfErrors: number; + application: ApplicationStart; + numberOfUnSavedChanges?: number; +} + +export const WorkspaceBottomBar = ({ + formId, + operationType, + numberOfErrors, + numberOfUnSavedChanges, + application, +}: WorkspaceBottomBarProps) => { + const [isCancelModalVisible, setIsCancelModalVisible] = useState(false); + const closeCancelModal = () => setIsCancelModalVisible(false); + const showCancelModal = () => setIsCancelModalVisible(true); + + return ( +
    + + + + + + + {operationType === WorkspaceOperationType.Update ? ( + + {i18n.translate('workspace.form.bottomBar.unsavedChanges', { + defaultMessage: '{numberOfUnSavedChanges} Unsaved change(s)', + values: { + numberOfUnSavedChanges, + }, + })} + + ) : ( + + {i18n.translate('workspace.form.bottomBar.errors', { + defaultMessage: '{numberOfErrors} Error(s)', + values: { + numberOfErrors, + }, + })} + + )} + + + + + + {i18n.translate('workspace.form.bottomBar.cancel', { + defaultMessage: 'Cancel', + })} + + + {operationType === WorkspaceOperationType.Create && ( + + {i18n.translate('workspace.form.bottomBar.createWorkspace', { + defaultMessage: 'Create workspace', + })} + + )} + {operationType === WorkspaceOperationType.Update && ( + + {i18n.translate('workspace.form.bottomBar.saveChanges', { + defaultMessage: 'Save changes', + })} + + )} + + + + + +
    + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_cancel_modal.tsx new file mode 100644 index 000000000000..040e46f9ddfc --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/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_form/workspace_feature_selector.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx new file mode 100644 index 000000000000..61181a7a749e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_feature_selector.tsx @@ -0,0 +1,212 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiText, + EuiFlexItem, + EuiCheckbox, + EuiCheckboxGroup, + EuiFlexGroup, + EuiCheckboxGroupProps, + EuiCheckboxProps, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { groupBy } from 'lodash'; + +import { + AppNavLinkStatus, + DEFAULT_APP_CATEGORIES, + PublicAppInfo, +} from '../../../../../core/public'; + +import { WorkspaceFeature, WorkspaceFeatureGroup } from './types'; +import { isDefaultCheckedFeatureId, isWorkspaceFeatureGroup } from './utils'; + +const libraryCategoryLabel = i18n.translate('core.ui.libraryNavList.label', { + defaultMessage: 'Library', +}); + +interface WorkspaceFeatureSelectorProps { + applications: PublicAppInfo[]; + selectedFeatures: string[]; + onChange: (newFeatures: string[]) => void; +} + +export const WorkspaceFeatureSelector = ({ + applications, + selectedFeatures, + onChange, +}: WorkspaceFeatureSelectorProps) => { + const featureOrGroups = useMemo(() => { + const transformedApplications = applications.map((app) => { + if (app.category?.id === DEFAULT_APP_CATEGORIES.opensearchDashboards.id) { + return { + ...app, + category: { + ...app.category, + label: libraryCategoryLabel, + }, + }; + } + 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 }) => ({ + id, + name: title, + })); + if (features.length === 0) { + return previousValue; + } + if (currentKey === 'undefined') { + return [...previousValue, ...features]; + } + return [ + ...previousValue, + { + name: apps[0].category?.label || '', + features, + }, + ]; + }, []); + }, [applications]); + + const handleFeatureChange = useCallback( + (featureId) => { + if (!selectedFeatures.includes(featureId)) { + onChange([...selectedFeatures, featureId]); + return; + } + onChange(selectedFeatures.filter((selectedId) => selectedId !== featureId)); + }, + [selectedFeatures, onChange] + ); + + const handleFeatureCheckboxChange = useCallback( + (e) => { + handleFeatureChange(e.target.id); + }, + [handleFeatureChange] + ); + + const handleFeatureGroupChange = useCallback( + (e) => { + const featureOrGroup = featureOrGroups.find( + (item) => isWorkspaceFeatureGroup(item) && item.name === e.target.id + ); + if (!featureOrGroup || !isWorkspaceFeatureGroup(featureOrGroup)) { + return; + } + const groupFeatureIds = featureOrGroup.features.map((feature) => feature.id); + // setSelectedFeatureIds((previousData) => { + const notExistsIds = groupFeatureIds.filter((id) => !selectedFeatures.includes(id)); + // Check all not selected features if not been selected in current group. + if (notExistsIds.length > 0) { + onChange([...selectedFeatures, ...notExistsIds]); + return; + } + // Need to un-check these features, if all features in group has been selected + onChange(selectedFeatures.filter((featureId) => !groupFeatureIds.includes(featureId))); + }, + [featureOrGroups, selectedFeatures, onChange] + ); + + return ( + <> + {featureOrGroups.map((featureOrGroup) => { + const features = isWorkspaceFeatureGroup(featureOrGroup) ? featureOrGroup.features : []; + const selectedIds = selectedFeatures.filter((id) => + (isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.features + : [featureOrGroup] + ).find((item) => item.id === id) + ); + const featureOrGroupId = isWorkspaceFeatureGroup(featureOrGroup) + ? featureOrGroup.name + : featureOrGroup.id; + + const categoryToDescription: { [key: string]: string } = { + [libraryCategoryLabel]: i18n.translate( + 'workspace.form.featureVisibility.libraryCategory.Description', + { + defaultMessage: 'Workspace-owned library items', + } + ), + }; + + return ( + + +
    + + {featureOrGroup.name} + + {isWorkspaceFeatureGroup(featureOrGroup) && + categoryToDescription[featureOrGroup.name] && ( + {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}`} + /> + )} + +
    + ); + })} + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx new file mode 100644 index 000000000000..ec4f2bfed3e0 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -0,0 +1,188 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React from 'react'; +import { + EuiPanel, + EuiSpacer, + EuiTitle, + EuiForm, + EuiFormRow, + EuiFieldText, + EuiText, + EuiColorPicker, + EuiHorizontalRule, + EuiTab, + EuiTabs, +} from '@elastic/eui'; +import { i18n } from '@osd/i18n'; + +import { WorkspaceBottomBar } from './workspace_bottom_bar'; +import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; +import { WorkspaceFormProps } from './types'; +import { WorkspaceFormTabs } from './constants'; +import { useWorkspaceForm } from './use_workspace_form'; +import { WorkspaceFeatureSelector } from './workspace_feature_selector'; + +export const WorkspaceForm = (props: WorkspaceFormProps) => { + const { + application, + defaultValues, + operationType, + permissionEnabled, + permissionLastAdminItemDeletable, + } = props; + const { + formId, + formData, + formErrors, + selectedTab, + applications, + numberOfErrors, + handleFormSubmit, + handleColorChange, + handleFeaturesChange, + handleNameInputChange, + handleTabFeatureClick, + setPermissionSettings, + handleTabPermissionClick, + handleDescriptionInputChange, + } = useWorkspaceForm(props); + const workspaceDetailsTitle = i18n.translate('workspace.form.workspaceDetails.title', { + defaultMessage: 'Workspace Details', + }); + const featureVisibilityTitle = i18n.translate('workspace.form.featureVisibility.title', { + defaultMessage: 'Feature Visibility', + }); + const usersAndPermissionsTitle = i18n.translate('workspace.form.usersAndPermissions.title', { + defaultMessage: 'Users & Permissions', + }); + + return ( + + + +

    {workspaceDetailsTitle}

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

    {featureVisibilityTitle}

    +
    + + + +
    + )} + + {selectedTab === WorkspaceFormTabs.UsersAndPermissions && ( + + +

    {usersAndPermissionsTitle}

    +
    + + +
    + )} + + +
    + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_icon_selector.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_icon_selector.tsx new file mode 100644 index 000000000000..06b0a224a258 --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/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_form/workspace_permission_setting_input.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx new file mode 100644 index 000000000000..e17f99b0d15b --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_input.tsx @@ -0,0 +1,129 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo } from 'react'; +import { + EuiFlexGroup, + EuiComboBox, + EuiFlexItem, + EuiButtonIcon, + EuiButtonGroup, +} from '@elastic/eui'; +import { WorkspacePermissionMode } from '../../../common/constants'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + permissionModeOptions, +} from './constants'; +import { getPermissionModeId } from './utils'; + +export 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; +} + +export 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 ( + + + + + + + + + + + + ); +}; diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx new file mode 100644 index 000000000000..8d2dacc4165e --- /dev/null +++ b/src/plugins/workspace/public/components/workspace_form/workspace_permission_setting_panel.tsx @@ -0,0 +1,246 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import React, { useCallback, useMemo, useState, useEffect } from 'react'; +import { EuiButton, EuiFormRow, EuiText, EuiSpacer } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; +import { WorkspacePermissionSetting } from './types'; +import { + WorkspacePermissionItemType, + optionIdToWorkspacePermissionModesMap, + PermissionModeId, +} from './constants'; +import { + WorkspacePermissionSettingInput, + WorkspacePermissionSettingInputProps, +} from './workspace_permission_setting_input'; +import { generateWorkspacePermissionItemKey, getPermissionModeId } from './utils'; + +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 = useMemo( + () => + permissionSettings?.filter( + (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.User + ) ?? [], + [permissionSettings] + ); + const groupPermissionSettings = useMemo( + () => + permissionSettings?.filter( + (permissionSettingItem) => permissionSettingItem.type === WorkspacePermissionItemType.Group + ) ?? [], + [permissionSettings] + ); + + const handleUserPermissionSettingsChange = useCallback( + (newSettings) => { + onChange?.([...newSettings, ...groupPermissionSettings]); + }, + [groupPermissionSettings, onChange] + ); + + const handleGroupPermissionSettingsChange = useCallback( + (newSettings) => { + onChange?.([...userPermissionSettings, ...newSettings]); + }, + [userPermissionSettings, onChange] + ); + + 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/hooks.ts b/src/plugins/workspace/public/hooks.ts new file mode 100644 index 000000000000..e84ee46507ef --- /dev/null +++ b/src/plugins/workspace/public/hooks.ts @@ -0,0 +1,19 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ApplicationStart, PublicAppInfo } from 'opensearch-dashboards/public'; +import { useObservable } from 'react-use'; +import { useMemo } from 'react'; + +export function useApplications(application: ApplicationStart) { + const applications = useObservable(application.applications$); + return useMemo(() => { + const apps: PublicAppInfo[] = []; + applications?.forEach((app) => { + apps.push(app); + }); + return apps; + }, [applications]); +} diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index f0050879074a..5ecdc219fe96 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -23,7 +23,7 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { savedObjectsManagement: savedObjectManagementSetupMock, }); - expect(setupMock.application.register).toBeCalledTimes(2); + expect(setupMock.application.register).toBeCalledTimes(3); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledTimes(0); expect(savedObjectManagementSetupMock.columns.register).toBeCalledTimes(1); @@ -70,7 +70,7 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), }); - expect(setupMock.application.register).toBeCalledTimes(2); + expect(setupMock.application.register).toBeCalledTimes(3); expect(WorkspaceClientMock).toBeCalledTimes(1); expect(workspaceClientMock.enterWorkspace).toBeCalledWith('workspaceId'); expect(setupMock.getStartServices).toBeCalledTimes(1); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index d430caabdd5c..24fb61741cc7 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -4,6 +4,7 @@ */ import type { Subscription } from 'rxjs'; +import { i18n } from '@osd/i18n'; import { SavedObjectsManagementPluginSetup } from 'src/plugins/saved_objects_management/public'; import { featureMatchesConfig } from './utils'; import { @@ -19,6 +20,7 @@ import { WORKSPACE_FATAL_ERROR_APP_ID, WORKSPACE_OVERVIEW_APP_ID, WORKSPACE_LIST_APP_ID, + WORKSPACE_CREATE_APP_ID, } from '../common/constants'; import { getWorkspaceIdFromUrl } from '../../../core/public/utils'; import { renderWorkspaceMenu } from './render_workspace_menu'; @@ -161,6 +163,19 @@ export class WorkspacePlugin implements Plugin<{}, {}, {}> { }, }); + // create + core.application.register({ + id: WORKSPACE_CREATE_APP_ID, + title: i18n.translate('workspace.settings.workspaceCreate', { + defaultMessage: 'Create Workspace', + }), + navLinkStatus: AppNavLinkStatus.hidden, + async mount(params: AppMountParameters) { + const { renderCreatorApp } = await import('./application'); + return mountWorkspaceApp(params, renderCreatorApp); + }, + }); + // workspace fatal error core.application.register({ id: WORKSPACE_FATAL_ERROR_APP_ID, From f95cf18ee929d665f09b220007c526951bafa70a Mon Sep 17 00:00:00 2001 From: tygao Date: Fri, 15 Mar 2024 14:49:58 +0800 Subject: [PATCH 32/34] fix test for delete workspace modal (#299) Signed-off-by: tygao --- .../delete_workspace_modal.test.tsx | 6 +++--- .../components/workspace_list/index.test.tsx | 17 ++++++++++++----- 2 files changed, 15 insertions(+), 8 deletions(-) diff --git a/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx index 15078b87bade..d2ba2e7876ef 100644 --- a/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx +++ b/src/plugins/workspace/public/components/delete_workspace_modal/delete_workspace_modal.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { DeleteWorkspaceModal, DeleteWorkspaceModalProps } from './delete_workspace_modal'; import { coreMock } from '../../../../../core/public/mocks'; -import { render, fireEvent, waitFor } from '@testing-library/react'; +import { render, fireEvent, waitFor, screen } from '@testing-library/react'; import { workspaceClientMock } from '../../../public/workspace_client.mock'; import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/opensearch_dashboards_react/public'; @@ -34,11 +34,11 @@ describe('DeleteWorkspaceModal', () => { jest.clearAllMocks(); }); - it('should render normally', () => { + it('should render normally', async () => { const { getByText, baseElement, getByTestId } = render( getWrapWorkspaceDeleteModalInContext(defaultProps) ); - + await screen.findByTestId('delete-workspace-modal-header'); expect(getByText('Delete workspace')).toBeInTheDocument(); expect(getByTestId('delete-workspace-modal-header')).toBeInTheDocument(); expect(getByTestId('delete-workspace-modal-body')).toBeInTheDocument(); diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx index f9e5a388368e..1719bfe7a109 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -16,6 +16,14 @@ import { OpenSearchDashboardsContextProvider } from '../../../../../plugins/open jest.mock('../utils/workspace'); +jest.mock('../delete_workspace_modal', () => ({ + DeleteWorkspaceModal: ({ onClose }: { onClose: () => void }) => ( +
    +
    + ), +})); + function getWrapWorkspaceListInContext( workspaceList = [ { id: 'id1', name: 'name1' }, @@ -92,11 +100,10 @@ describe('WorkspaceList', () => { const { getAllByTestId } = render(getWrapWorkspaceListInContext()); const deleteIcon = getAllByTestId('workspace-list-delete-icon')[0]; fireEvent.click(deleteIcon); - await screen.findByTestId('delete-workspace-modal-header'); - expect(screen.getByTestId('delete-workspace-modal-header')).toBeInTheDocument(); - const cancelButton = screen.getByTestId('delete-workspace-modal-cancel-button'); - fireEvent.click(cancelButton); - expect(screen.queryByTestId('delete-workspace-modal-header')).not.toBeInTheDocument(); + expect(screen.queryByLabelText('mock delete workspace modal')).toBeInTheDocument(); + const modalCancelButton = screen.getByLabelText('mock delete workspace modal button'); + fireEvent.click(modalCancelButton); + expect(screen.queryByLabelText('mock delete workspace modal')).not.toBeInTheDocument(); }); it('should be able to pagination when clicking pagination button', async () => { From 1b542b6e5ead46681e698524b8e205e2fc407007 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 18 Mar 2024 22:22:34 +0800 Subject: [PATCH 33/34] feat: rebase 20240518 Signed-off-by: SuZhou-Joe --- .../collapsible_nav.test.tsx.snap | 8 - .../header/__snapshots__/header.test.tsx.snap | 4 - .../routes/resolve_import_errors.ts | 1 - .../service/lib/repository.test.js | 24 -- .../saved_objects/service/lib/repository.ts | 13 +- .../service/lib/search_dsl/query_params.ts | 21 -- .../dashboard_listing.test.tsx.snap | 5 - .../dashboard_top_nav.test.tsx.snap | 6 - .../dashboard_empty_screen.test.tsx.snap | 3 - .../data_source_aggregated_view.test.tsx.snap | 222 +++++++++--------- .../saved_objects_table.test.tsx.snap | 1 - .../__snapshots__/flyout.test.tsx.snap | 1 - ...telemetry_management_section.test.tsx.snap | 1 - src/plugins/workspace/public/plugin.test.ts | 75 +++--- 14 files changed, 145 insertions(+), 240 deletions(-) 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 d6094f78e24b..62f00bee2c74 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 @@ -60,7 +60,6 @@ exports[`CollapsibleNav renders links grouped by category 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={Object {}} @@ -2014,7 +2013,6 @@ exports[`CollapsibleNav renders the default nav 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={Object {}} @@ -2319,7 +2317,6 @@ exports[`CollapsibleNav renders the default nav 2`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={Object {}} @@ -2625,7 +2622,6 @@ exports[`CollapsibleNav renders the default nav 3`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={Object {}} @@ -3222,7 +3218,6 @@ exports[`CollapsibleNav with custom branding renders the nav bar in dark mode 1` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={ @@ -4340,7 +4335,6 @@ exports[`CollapsibleNav with custom branding renders the nav bar in default mode "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={ @@ -5457,7 +5451,6 @@ exports[`CollapsibleNav without custom branding renders the nav bar in dark mode "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={ @@ -6567,7 +6560,6 @@ exports[`CollapsibleNav without custom branding renders the nav bar in default m "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={Object {}} diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 3d3e5a440c27..790f24bc20e9 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -248,7 +248,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={Object {}} @@ -5954,7 +5953,6 @@ exports[`Header handles visibility and lock changes 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } closeNav={[Function]} @@ -7018,7 +7016,6 @@ exports[`Header renders condensed header 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } branding={ @@ -11495,7 +11492,6 @@ exports[`Header renders condensed header 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "/test", - "workspaceBasePath": "", } } closeNav={[Function]} 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 7d22e35a658d..5d2be7fdfddf 100644 --- a/src/core/server/saved_objects/routes/resolve_import_errors.ts +++ b/src/core/server/saved_objects/routes/resolve_import_errors.ts @@ -132,7 +132,6 @@ export const registerResolveImportErrorsRoute = (router: IRouter, config: SavedO retries: req.body.retries, objectLimit: maxImportExportSize, createNewCopies: req.query.createNewCopies, - workspaces, dataSourceId, dataSourceTitle, workspaces, 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 3dfa293c0dc0..68942c0e435f 100644 --- a/src/core/server/saved_objects/service/lib/repository.test.js +++ b/src/core/server/saved_objects/service/lib/repository.test.js @@ -187,7 +187,6 @@ describe('SavedObjectsRepository', () => { _source: { ...(registry.isSingleNamespace(type) && { namespace: namespaceId }), ...(registry.isMultiNamespace(type) && { namespaces: [namespaceId ?? 'default'] }), - workspaces, ...(originId && { originId }), ...(permissions && { permissions }), type, @@ -460,14 +459,6 @@ describe('SavedObjectsRepository', () => { }, }; const workspace = 'foo-workspace'; - const permissions = { - read: { - users: ['user1'], - }, - write: { - groups: ['groups1'], - }, - }; const getMockBulkCreateResponse = (objects, namespace) => { return { @@ -761,18 +752,6 @@ describe('SavedObjectsRepository', () => { expectClientCallArgsAction(objects, { method: 'create', getId }); }); - it(`accepts permissions property when providing permissions info`, async () => { - const objects = [obj1, obj2].map((obj) => ({ ...obj, permissions: permissions })); - await bulkCreateSuccess(objects); - const expected = expect.objectContaining({ permissions }); - const body = [expect.any(Object), expected, expect.any(Object), expected]; - expect(client.bulk).toHaveBeenCalledWith( - expect.objectContaining({ body }), - expect.anything() - ); - client.bulk.mockClear(); - }); - it(`adds workspaces to request body for any types`, async () => { await bulkCreateSuccess([obj1, obj2], { workspaces: [workspace] }); const expected = expect.objectContaining({ workspaces: [workspace] }); @@ -1965,9 +1944,6 @@ describe('SavedObjectsRepository', () => { ...omitWorkspace(obj9), error: { ...createConflictError(obj9.type, obj9.id), - metadata: { - isNotOverwritable: true, - }, }, }, ], diff --git a/src/core/server/saved_objects/service/lib/repository.ts b/src/core/server/saved_objects/service/lib/repository.ts index 61da059f7839..1b07e751220b 100644 --- a/src/core/server/saved_objects/service/lib/repository.ts +++ b/src/core/server/saved_objects/service/lib/repository.ts @@ -598,24 +598,13 @@ 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) || workspaceConflict) && { + ...(!this.rawDocExistsInNamespace(doc!, namespace) && { metadata: { isNotOverwritable: true }, }), }, 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 b8fd28fe46c2..abbef0850dba 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 @@ -151,27 +151,6 @@ function getClauseForWorkspace(workspace: string) { }; } -/** - * 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; 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 1bff186d2634..f1635f41c318 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 @@ -863,7 +863,6 @@ exports[`dashboard listing hideWriteControls 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2007,7 +2006,6 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3212,7 +3210,6 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4417,7 +4414,6 @@ exports[`dashboard listing renders table rows 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5622,7 +5618,6 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index f8b202372235..314d9e449c40 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 @@ -755,7 +755,6 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -1724,7 +1723,6 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -2693,7 +2691,6 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -3662,7 +3659,6 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -4631,7 +4627,6 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -5600,7 +5595,6 @@ exports[`Dashboard top nav render with all components 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 8c8043ae7a99..c2c83ff6f356 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 @@ -17,7 +17,6 @@ exports[`DashboardEmptyScreen renders correctly with readonly mode 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -387,7 +386,6 @@ exports[`DashboardEmptyScreen renders correctly with visualize paragraph 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], @@ -767,7 +765,6 @@ exports[`DashboardEmptyScreen renders correctly without visualize paragraph 1`] "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap index 2be5b7c11b8a..2c789f04da12 100644 --- a/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_aggregated_view/__snapshots__/data_source_aggregated_view.test.tsx.snap @@ -1,5 +1,59 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP +exports[`DataSourceAggregatedView should render normally with data source filter 1`] = ` + + + Data sources + + + All + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + + + +`; + exports[`DataSourceAggregatedView should render normally with local cluster and actice selections 1`] = ` `; -exports[`DataSourceAggregatedView should render normally with local cluster and actice selections 2`] = ` +exports[`DataSourceAggregatedView should render normally with local cluster hidden and all options 1`] = ` + + +`; + +exports[`DataSourceAggregatedView should render normally with local cluster not hidden and all options 1`] = ` + + + Data sources + + + All + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + id="dataSourceSViewContextMenuPopover" + isOpen={false} + ownFocus={true} + panelPaddingSize="none" + > + `; -exports[`DataSourceAggregatedView should render normally with local cluster and actice selections 3`] = ` +exports[`DataSourceAggregatedView should render popup when clicking on info icon 1`] = ` Object { "asFragment": [Function], "baseElement": @@ -339,111 +447,3 @@ Object { "unmount": [Function], } `; - -exports[`DataSourceAggregatedView should render normally with local cluster hidden and all options 1`] = ` - - - Data sources - - - All - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="dataSourceSViewContextMenuPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > - - - -`; - -exports[`DataSourceAggregatedView should render normally with local cluster not hidden and all options 1`] = ` - - - Data sources - - - All - - - } - closePopover={[Function]} - display="inlineBlock" - hasArrow={true} - id="dataSourceSViewContextMenuPopover" - isOpen={false} - ownFocus={true} - panelPaddingSize="none" - > - - - -`; 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 ab8a16be5cbe..f527bb7984c9 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 @@ -270,7 +270,6 @@ exports[`SavedObjectsTable should render normally 1`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", } } canDelete={false} 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 fc58df41524a..45036362e649 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 @@ -174,7 +174,6 @@ exports[`Flyout conflicts should allow conflict resolution 2`] = ` "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], 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 7ad1fb8cd938..2761ce16fea3 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 @@ -319,7 +319,6 @@ exports[`TelemetryManagementSectionComponent renders null because allowChangingO "prepend": [Function], "remove": [Function], "serverBasePath": "", - "workspaceBasePath": "", }, "delete": [MockFunction], "fetch": [MockFunction], diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index 5ecdc219fe96..41f79c5b194e 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -36,9 +36,8 @@ describe('Workspace plugin', () => { coreStart.workspaces.currentWorkspaceId$.next('foo'); expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); }); - }); - it('#setup when workspace id is in url and enterWorkspace return error', async () => { + it('#setup when workspace id is in url and enterWorkspace return success', async () => { const windowSpy = jest.spyOn(window, 'window', 'get'); windowSpy.mockImplementation( () => @@ -49,17 +48,21 @@ describe('Workspace plugin', () => { } as any) ); workspaceClientMock.enterWorkspace.mockResolvedValue({ - success: false, + success: true, error: 'error', }); const setupMock = coreMock.createSetup(); const applicationStartMock = applicationServiceMock.createStartContract(); - const chromeStartMock = chromeServiceMock.createStartContract(); + let currentAppIdSubscriber: Subscriber | undefined; setupMock.getStartServices.mockImplementation(() => { return Promise.resolve([ { - application: applicationStartMock, - chrome: chromeStartMock, + application: { + ...applicationStartMock, + currentAppId$: new Observable((subscriber) => { + currentAppIdSubscriber = subscriber; + }), + }, }, {}, {}, @@ -70,27 +73,12 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), }); - expect(setupMock.application.register).toBeCalledTimes(3); - 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, - } - ); + currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); + expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); windowSpy.mockRestore(); }); - it('#setup when workspace id is in url and enterWorkspace return success', async () => { + it('#setup when workspace id is in url and enterWorkspace return error', async () => { const windowSpy = jest.spyOn(window, 'window', 'get'); windowSpy.mockImplementation( () => @@ -101,21 +89,17 @@ describe('Workspace plugin', () => { } as any) ); workspaceClientMock.enterWorkspace.mockResolvedValue({ - success: true, + success: false, error: 'error', }); const setupMock = coreMock.createSetup(); const applicationStartMock = applicationServiceMock.createStartContract(); - let currentAppIdSubscriber: Subscriber | undefined; + const chromeStartMock = chromeServiceMock.createStartContract(); setupMock.getStartServices.mockImplementation(() => { return Promise.resolve([ { - application: { - ...applicationStartMock, - currentAppId$: new Observable((subscriber) => { - currentAppIdSubscriber = subscriber; - }), - }, + application: applicationStartMock, + chrome: chromeStartMock, }, {}, {}, @@ -126,19 +110,26 @@ describe('Workspace plugin', () => { await workspacePlugin.setup(setupMock, { savedObjectsManagement: savedObjectsManagementPluginMock.createSetupContract(), }); - currentAppIdSubscriber?.next(WORKSPACE_FATAL_ERROR_APP_ID); - expect(applicationStartMock.navigateToApp).toBeCalledWith(WORKSPACE_OVERVIEW_APP_ID); + expect(setupMock.application.register).toBeCalledTimes(3); + 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('#call savedObjectsClient.setCurrentWorkspace when current workspace id changed', () => { - const workspacePlugin = new WorkspacePlugin(); - const coreStart = coreMock.createStart(); - workspacePlugin.start(coreStart); - coreStart.workspaces.currentWorkspaceId$.next('foo'); - expect(coreStart.savedObjects.client.setCurrentWorkspace).toHaveBeenCalledWith('foo'); - }); - it('#setup register workspace dropdown menu when setup', async () => { const setupMock = coreMock.createSetup(); const workspacePlugin = new WorkspacePlugin(); From 22638daff39cf12c6b40471d6029448de181388e Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Mon, 18 Mar 2024 21:28:48 +0800 Subject: [PATCH 34/34] feat: update to run ci Signed-off-by: SuZhou-Joe --- src/plugins/workspace/public/plugin.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 24fb61741cc7..1874f1fe2d8b 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -35,6 +35,8 @@ interface WorkspacePluginSetupDeps { savedObjectsManagement?: SavedObjectsManagementPluginSetup; } +console.log('run'); + export class WorkspacePlugin implements Plugin<{}, {}, {}> { private coreStart?: CoreStart; private currentWorkspaceIdSubscription?: Subscription;