From 40f4da101e127d1e278af6afb40b8d29feda87ea Mon Sep 17 00:00:00 2001 From: Tim Sullivan Date: Mon, 23 Sep 2024 12:13:56 -0700 Subject: [PATCH] Assign Roles to Space from Spaces Management (#191795) ## Summary Epic link: https://github.com/elastic/kibana-team/issues/785 This changes bring a new design to the management of Spaces in Stack Management / Security. We have a new page to view the details of the Space, and new UX to assign Roles to a Space. ### Release Note Added several UX improvements to the management of Spaces in **Stack Management > Spaces**, including the ability to assign Roles to an existing Space. ### Checklist Delete any items that are not applicable to this PR. - [x] Use flaky test runner on changed functional tests: https://buildkite.com/elastic/kibana-flaky-test-suite-runner/builds/6953 - [x] Create test for the ability to change space avatar from `initials` to `image` and vice versa - [x] Any text added follows [EUI's writing guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses sentence case text and includes [i18n support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md) - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios - [x] Any UI touched in this PR is usable by keyboard only (learn more about [keyboard accessibility](https://webaim.org/techniques/keyboard/)) - [x] Any UI touched in this PR does not create any new axe failures (run axe in browser: [FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/), [Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US)) - [x] This renders correctly on smaller devices using a responsive layout. (You can test this [in your browser](https://www.browserstack.com/guide/responsive-testing-on-local-server)) - [x] This was checked for [cross-browser compatibility](https://www.elastic.co/support/matrix#matrix_browsers) --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com> Co-authored-by: Eyo Okon Eyo Co-authored-by: Elastic Machine Co-authored-by: Aleh Zasypkin (cherry picked from commit fb9700caa097dbdac310f8fd3a387b8d218a7428) --- .../security/plugin_types_public/index.ts | 7 +- .../src/privileges/privileges_api_client.ts | 2 +- .../plugin_types_public/src/roles/index.ts | 7 +- .../src/roles/roles_api_client.ts | 11 + .../feature_table.test.tsx | 2 +- .../kibana_privilege_table/feature_table.tsx | 27 +- .../feature_table_expanded_row.test.tsx | 2 +- .../privilege_form_calculator.test.ts | 2 +- .../public/authentication/index.mock.ts | 2 + .../authorization/authorization_service.ts | 1 + .../roles/edit_role/edit_role_page.tsx | 190 +++--- .../privilege_space_form.tsx | 10 +- .../space_aware_privilege_section.tsx | 2 +- .../management/roles/roles_api_client.mock.ts | 1 + .../management/roles/roles_api_client.test.ts | 532 ++++++++------- .../management/roles/roles_api_client.ts | 13 + .../plugins/security/public/plugin.test.tsx | 1 + x-pack/plugins/spaces/common/constants.ts | 12 + .../plugins/spaces/common/types/space/v1.ts | 4 +- x-pack/plugins/spaces/public/constants.ts | 2 +- .../customize_space.test.tsx.snap | 2 +- .../customize_space/customize_space.tsx | 10 +- .../customize_space_avatar.tsx | 8 +- .../solution_view/solution_view.tsx | 2 +- .../create_space_page.test.tsx} | 26 +- .../create_space_page.tsx} | 16 +- .../public/management/create_space/index.ts | 8 + .../public/management/edit_space/constants.ts | 10 + .../management/edit_space/edit_space.tsx | 283 ++++++++ .../edit_space_content_tab.test.tsx | 125 ++++ .../edit_space/edit_space_content_tab.tsx | 147 +++++ .../edit_space/edit_space_features_tab.tsx | 77 +++ .../edit_space_general_tab.test.tsx | 489 ++++++++++++++ .../edit_space/edit_space_general_tab.tsx | 297 +++++++++ .../management/edit_space/edit_space_page.tsx | 40 ++ .../edit_space/edit_space_roles_tab.test.tsx | 119 ++++ .../edit_space/edit_space_roles_tab.tsx | 207 ++++++ .../management/edit_space/edit_space_tabs.tsx | 125 ++++ .../public/management/edit_space/footer.tsx | 87 +++ .../management/edit_space/handle_api_error.ts | 27 + .../management/edit_space/hooks/use_tabs.ts | 41 ++ .../public/management/edit_space/index.ts | 2 +- .../provider/edit_space_provider.test.tsx | 113 ++++ .../provider/edit_space_provider.tsx | 149 +++++ .../management/edit_space/provider/index.ts | 13 + .../edit_space/provider/reducers/index.ts | 66 ++ .../space_assign_role_privilege_form.test.tsx | 308 +++++++++ .../space_assign_role_privilege_form.tsx | 610 ++++++++++++++++++ .../space_assigned_roles_table.test.tsx | 146 +++++ .../component/space_assigned_roles_table.tsx | 509 +++++++++++++++ .../spaces/public/management/lib/index.ts | 2 + .../public/management/lib/sort_roles.test.ts | 171 +++++ .../public/management/lib/sort_roles.ts | 28 + .../public/management/lib/validate_space.ts | 24 +- .../management/management_service.test.ts | 5 + .../public/management/management_service.tsx | 4 + .../management/privilege_api_client.mock.ts | 18 + .../management/roles_api_client.mock.ts | 1 + .../spaces_grid/spaces_grid_page.tsx | 60 +- .../management/spaces_management_app.test.tsx | 27 +- .../management/spaces_management_app.tsx | 58 +- .../plugins/spaces/public/management/types.ts | 18 + x-pack/plugins/spaces/public/plugin.tsx | 1 + .../spaces_manager/spaces_manager.mock.ts | 2 + .../spaces_manager/spaces_manager.test.ts | 28 + .../public/spaces_manager/spaces_manager.ts | 12 + x-pack/plugins/spaces/public/types.ts | 11 + x-pack/plugins/spaces/tsconfig.json | 16 +- .../apps/spaces/create_edit_space.ts | 36 -- .../spaces/create_edit_space/acme_logo.png | Bin 0 -> 3243 bytes .../create_edit_space/create_edit_space.ts | 117 ++++ .../apps/spaces/create_edit_space/index.ts | 14 + .../feature_controls/spaces_security.ts | 4 +- .../create_edit_space.ts | 10 +- .../functional/apps/spaces/spaces_grid.ts | 119 +++- .../page_objects/space_selector_page.ts | 5 + 76 files changed, 5207 insertions(+), 476 deletions(-) rename x-pack/plugins/spaces/public/management/{edit_space/manage_space_page.test.tsx => create_space/create_space_page.test.tsx} (98%) rename x-pack/plugins/spaces/public/management/{edit_space/manage_space_page.tsx => create_space/create_space_page.tsx} (97%) create mode 100644 x-pack/plugins/spaces/public/management/create_space/index.ts create mode 100644 x-pack/plugins/spaces/public/management/edit_space/constants.ts create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/edit_space_tabs.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/footer.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/handle_api_error.ts create mode 100644 x-pack/plugins/spaces/public/management/edit_space/hooks/use_tabs.ts create mode 100644 x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/provider/index.ts create mode 100644 x-pack/plugins/spaces/public/management/edit_space/provider/reducers/index.ts create mode 100644 x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx create mode 100644 x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx create mode 100644 x-pack/plugins/spaces/public/management/lib/sort_roles.test.ts create mode 100644 x-pack/plugins/spaces/public/management/lib/sort_roles.ts create mode 100644 x-pack/plugins/spaces/public/management/privilege_api_client.mock.ts create mode 100644 x-pack/plugins/spaces/public/management/types.ts delete mode 100644 x-pack/test/functional/apps/spaces/create_edit_space.ts create mode 100644 x-pack/test/functional/apps/spaces/create_edit_space/acme_logo.png create mode 100644 x-pack/test/functional/apps/spaces/create_edit_space/create_edit_space.ts create mode 100644 x-pack/test/functional/apps/spaces/create_edit_space/index.ts diff --git a/x-pack/packages/security/plugin_types_public/index.ts b/x-pack/packages/security/plugin_types_public/index.ts index a2a6f4ea6a3ee..a48511441382a 100644 --- a/x-pack/packages/security/plugin_types_public/index.ts +++ b/x-pack/packages/security/plugin_types_public/index.ts @@ -16,6 +16,11 @@ export type { UserProfileSuggestParams, UserProfileAPIClient, } from './src/user_profile'; -export type { RolePutPayload, RolesAPIClient } from './src/roles'; +export type { + BulkUpdatePayload, + BulkUpdateRoleResponse, + RolePutPayload, + RolesAPIClient, +} from './src/roles'; export { PrivilegesAPIClientPublicContract } from './src/privileges'; export type { PrivilegesAPIClientGetAllArgs } from './src/privileges'; diff --git a/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts b/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts index e3a97398db7a3..25d768cb7b1ac 100644 --- a/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts +++ b/x-pack/packages/security/plugin_types_public/src/privileges/privileges_api_client.ts @@ -15,7 +15,7 @@ export interface PrivilegesAPIClientGetAllArgs { */ respectLicenseLevel: boolean; } -// TODO: Eyo include the proper return types for contract + export abstract class PrivilegesAPIClientPublicContract { abstract getAll(args: PrivilegesAPIClientGetAllArgs): Promise; } diff --git a/x-pack/packages/security/plugin_types_public/src/roles/index.ts b/x-pack/packages/security/plugin_types_public/src/roles/index.ts index 36a3e85fa8767..9364f7cee0bc2 100644 --- a/x-pack/packages/security/plugin_types_public/src/roles/index.ts +++ b/x-pack/packages/security/plugin_types_public/src/roles/index.ts @@ -5,4 +5,9 @@ * 2.0. */ -export type { RolePutPayload, RolesAPIClient } from './roles_api_client'; +export type { + BulkUpdatePayload, + BulkUpdateRoleResponse, + RolePutPayload, + RolesAPIClient, +} from './roles_api_client'; diff --git a/x-pack/packages/security/plugin_types_public/src/roles/roles_api_client.ts b/x-pack/packages/security/plugin_types_public/src/roles/roles_api_client.ts index b5c45c5160fde..12f79d3c3449e 100644 --- a/x-pack/packages/security/plugin_types_public/src/roles/roles_api_client.ts +++ b/x-pack/packages/security/plugin_types_public/src/roles/roles_api_client.ts @@ -11,9 +11,20 @@ export interface RolePutPayload { createOnly?: boolean; } +export interface BulkUpdatePayload { + rolesUpdate: Role[]; +} + +export interface BulkUpdateRoleResponse { + created?: string[]; + updated?: string[]; + errors?: Record; +} + export interface RolesAPIClient { getRoles: () => Promise; getRole: (roleName: string) => Promise; deleteRole: (roleName: string) => Promise; saveRole: (payload: RolePutPayload) => Promise; + bulkUpdateRoles: (payload: BulkUpdatePayload) => Promise; } diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx index 83a0da2e26815..2380088dd713f 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.test.tsx @@ -15,10 +15,10 @@ import { kibanaFeatures, } from '@kbn/security-role-management-model/src/__fixtures__'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; +import type { Role } from '@kbn/security-plugin-types-common'; import { getDisplayedFeaturePrivileges } from './__fixtures__'; import { FeatureTable } from './feature_table'; -import type { Role } from '@kbn/security-plugin-types-common'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; const createRole = (kibana: Role['kibana'] = []): Role => { diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx index daa1ddd704f74..45b263b66f2fb 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table.tsx @@ -48,6 +48,10 @@ interface Props { canCustomizeSubFeaturePrivileges: boolean; allSpacesSelected: boolean; disabled?: boolean; + /** + * default is true, to remain backwards compatible + */ + showTitle?: boolean; } interface State { @@ -58,6 +62,7 @@ export class FeatureTable extends Component { public static defaultProps = { privilegeIndex: -1, showLocks: true, + showTitle: true, }; private featureCategories: Map = new Map(); @@ -187,16 +192,18 @@ export class FeatureTable extends Component {
- - - {i18n.translate( - 'xpack.security.management.editRole.featureTable.featureVisibilityTitle', - { - defaultMessage: 'Customize feature privileges', - } - )} - - + {this.props.showTitle && ( + + + {i18n.translate( + 'xpack.security.management.editRole.featureTable.featureVisibilityTitle', + { + defaultMessage: 'Customize feature privileges', + } + )} + + + )} {!this.props.disabled && ( diff --git a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table_expanded_row.test.tsx b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table_expanded_row.test.tsx index 3b787f01cdf92..5e4f4ce021d44 100644 --- a/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table_expanded_row.test.tsx +++ b/x-pack/packages/security/ui_components/src/kibana_privilege_table/feature_table_expanded_row.test.tsx @@ -12,10 +12,10 @@ import { createKibanaPrivileges, kibanaFeatures, } from '@kbn/security-role-management-model/src/__fixtures__'; +import type { Role } from '@kbn/security-plugin-types-common'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; import { FeatureTableExpandedRow } from './feature_table_expanded_row'; -import type { Role } from '@kbn/security-plugin-types-common'; import { PrivilegeFormCalculator } from '../privilege_form_calculator'; const createRole = (kibana: Role['kibana'] = []): Role => { diff --git a/x-pack/packages/security/ui_components/src/privilege_form_calculator/privilege_form_calculator.test.ts b/x-pack/packages/security/ui_components/src/privilege_form_calculator/privilege_form_calculator.test.ts index 0281605f00f34..e61134b816ffa 100644 --- a/x-pack/packages/security/ui_components/src/privilege_form_calculator/privilege_form_calculator.test.ts +++ b/x-pack/packages/security/ui_components/src/privilege_form_calculator/privilege_form_calculator.test.ts @@ -9,9 +9,9 @@ import { createKibanaPrivileges, kibanaFeatures, } from '@kbn/security-role-management-model/src/__fixtures__'; +import type { Role } from '@kbn/security-plugin-types-common'; import { PrivilegeFormCalculator } from './privilege_form_calculator'; -import type { Role } from '@kbn/security-plugin-types-common'; const createRole = (kibana: Role['kibana'] = []): Role => { return { diff --git a/x-pack/plugins/security/public/authentication/index.mock.ts b/x-pack/plugins/security/public/authentication/index.mock.ts index 166583b1274cb..f30d47af3f701 100644 --- a/x-pack/plugins/security/public/authentication/index.mock.ts +++ b/x-pack/plugins/security/public/authentication/index.mock.ts @@ -31,6 +31,7 @@ export const authorizationMock = { getRole: jest.fn(), deleteRole: jest.fn(), saveRole: jest.fn(), + bulkUpdateRoles: jest.fn(), }, privileges: { getAll: jest.fn(), @@ -43,6 +44,7 @@ export const authorizationMock = { getRole: jest.fn(), deleteRole: jest.fn(), saveRole: jest.fn(), + bulkUpdateRoles: jest.fn(), }, privileges: { getAll: jest.fn(), diff --git a/x-pack/plugins/security/public/authorization/authorization_service.ts b/x-pack/plugins/security/public/authorization/authorization_service.ts index c650d381be1af..4fbae4fb54e6a 100644 --- a/x-pack/plugins/security/public/authorization/authorization_service.ts +++ b/x-pack/plugins/security/public/authorization/authorization_service.ts @@ -29,6 +29,7 @@ export class AuthorizationService { getRole: rolesAPIClient.getRole, deleteRole: rolesAPIClient.deleteRole, saveRole: rolesAPIClient.saveRole, + bulkUpdateRoles: rolesAPIClient.bulkUpdateRoles, }, privileges: { getAll: privilegesAPIClient.getAll.bind(privilegesAPIClient), diff --git a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx index 63229be3c8683..02812eda34c7b 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/edit_role_page.tsx @@ -14,6 +14,7 @@ import { EuiFlexItem, EuiForm, EuiFormRow, + EuiIconTip, EuiPanel, EuiSpacer, EuiText, @@ -556,30 +557,27 @@ export const EditRolePage: FunctionComponent = ({ const getElasticsearchPrivileges = () => { return ( -
- - -
+ ); }; @@ -587,21 +585,18 @@ export const EditRolePage: FunctionComponent = ({ const getKibanaPrivileges = () => { return ( -
- - -
+ ); }; @@ -800,44 +795,89 @@ export const EditRolePage: FunctionComponent = ({ return (
- - {getFormTitle()} - - - - - {isRoleReserved && ( - - - -

+ + + + {getFormTitle()} + + + + + + + {isRoleReserved && ( + + +

+ +

+
+
+ )} + + + {isDeprecatedRole && ( + + + + + )} + + {getRoleNameAndDescription()} + + -

- - - )} - {isDeprecatedRole && ( - - - - - )} - - {getRoleNameAndDescription()} - {getElasticsearchPrivileges()} - {getKibanaPrivileges()} - - {getFormButtons()} + } + > + {getElasticsearchPrivileges()} +
+
+ + + + + + + + } + /> + + + } + > + {getKibanaPrivileges()} + + + + {getFormButtons()} + +
); diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx index a37fd799a035e..8275a7b1203ab 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/privilege_space_form.tsx @@ -107,10 +107,18 @@ export class PrivilegeSpaceForm extends Component {

+ +

+ +

+
{this.getForm()} diff --git a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx index e5a4cb1494d77..2bb3292932870 100644 --- a/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx +++ b/x-pack/plugins/security/public/management/roles/edit_role/privileges/kibana/space_aware_privilege_section/space_aware_privilege_section.tsx @@ -206,7 +206,7 @@ export class SpaceAwarePrivilegeSection extends Component { > ); diff --git a/x-pack/plugins/security/public/management/roles/roles_api_client.mock.ts b/x-pack/plugins/security/public/management/roles/roles_api_client.mock.ts index 0e756e87c081c..5f868fda093a4 100644 --- a/x-pack/plugins/security/public/management/roles/roles_api_client.mock.ts +++ b/x-pack/plugins/security/public/management/roles/roles_api_client.mock.ts @@ -11,5 +11,6 @@ export const rolesAPIClientMock = { getRole: jest.fn(), deleteRole: jest.fn(), saveRole: jest.fn(), + bulkUpdateRoles: jest.fn(), }), }; diff --git a/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts b/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts index e7f4839e56c5d..688aa78699769 100644 --- a/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts +++ b/x-pack/plugins/security/public/management/roles/roles_api_client.test.ts @@ -11,260 +11,330 @@ import { RolesAPIClient } from './roles_api_client'; import type { Role } from '../../../common'; describe('RolesAPIClient', () => { - async function saveRole(role: Role) { - const httpMock = httpServiceMock.createStartContract(); - const rolesAPIClient = new RolesAPIClient(httpMock); - - await rolesAPIClient.saveRole({ role }); - expect(httpMock.put).toHaveBeenCalledTimes(1); - - return JSON.parse((httpMock.put.mock.calls[0] as any)[1]?.body as any); - } - - it('removes placeholder index privileges', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [{ names: [], privileges: [] }], - remote_indices: [{ clusters: [], names: [], privileges: [] }], - run_as: [], - }, - kibana: [], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [], - remote_indices: [], - run_as: [], - }, - kibana: [], - }); - }); + describe('#saveRole', () => { + async function saveRole(role: Role) { + const httpMock = httpServiceMock.createStartContract(); + const rolesAPIClient = new RolesAPIClient(httpMock); + + await rolesAPIClient.saveRole({ role }); + expect(httpMock.put).toHaveBeenCalledTimes(1); + + return JSON.parse((httpMock.put.mock.calls[0] as any)[1]?.body as any); + } - it('removes placeholder query entries', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [{ names: ['.kibana*'], privileges: ['all'], query: '' }], - run_as: [], - }, - kibana: [], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [{ names: ['.kibana*'], privileges: ['all'] }], - run_as: [], - }, - kibana: [], + it('removes placeholder index privileges', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: [], privileges: [] }], + remote_indices: [{ clusters: [], names: [], privileges: [] }], + run_as: [], + }, + kibana: [], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + remote_indices: [], + run_as: [], + }, + kibana: [], + }); }); - }); - it('removes transient fields not required for save', async () => { - const role: Role = { - name: 'my role', - transient_metadata: { - foo: 'bar', - }, - _transform_error: ['kibana'], - metadata: { - someOtherMetadata: true, - }, - _unrecognized_applications: ['foo'], - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - metadata: { - someOtherMetadata: true, - }, - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [], + it('removes placeholder query entries', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: '' }], + run_as: [], + }, + kibana: [], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'] }], + run_as: [], + }, + kibana: [], + }); }); - }); - it('does not remove actual query entries', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], - remote_indices: [ - { clusters: ['cluster'], names: ['.kibana*'], privileges: ['all'], query: 'something' }, - ], - run_as: [], - }, - kibana: [], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], - remote_indices: [ - { clusters: ['cluster'], names: ['.kibana*'], privileges: ['all'], query: 'something' }, - ], - run_as: [], - }, - kibana: [], + it('removes transient fields not required for save', async () => { + const role: Role = { + name: 'my role', + transient_metadata: { + foo: 'bar', + }, + _transform_error: ['kibana'], + metadata: { + someOtherMetadata: true, + }, + _unrecognized_applications: ['foo'], + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + metadata: { + someOtherMetadata: true, + }, + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }); }); - }); - it('should remove feature privileges if a corresponding base privilege is defined', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['foo'], - base: ['all'], - feature: { - feature1: ['read'], - feature2: ['write'], - }, + it('does not remove actual query entries', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], + remote_indices: [ + { + clusters: ['cluster'], + names: ['.kibana*'], + privileges: ['all'], + query: 'something', + }, + ], + run_as: [], }, - ], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['foo'], - base: ['all'], - feature: {}, + kibana: [], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [{ names: ['.kibana*'], privileges: ['all'], query: 'something' }], + remote_indices: [ + { + clusters: ['cluster'], + names: ['.kibana*'], + privileges: ['all'], + query: 'something', + }, + ], + run_as: [], }, - ], + kibana: [], + }); }); - }); - it('should not remove feature privileges if a corresponding base privilege is not defined', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], - }, + it('should remove feature privileges if a corresponding base privilege is defined', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - ], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['foo'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], + kibana: [ + { + spaces: ['foo'], + base: ['all'], + feature: { + feature1: ['read'], + feature2: ['write'], + }, }, + ], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - ], + kibana: [ + { + spaces: ['foo'], + base: ['all'], + feature: {}, + }, + ], + }); }); - }); - it('should not remove space privileges', async () => { - const role: Role = { - name: 'my role', - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], - }, + it('should not remove feature privileges if a corresponding base privilege is not defined', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], + kibana: [ + { + spaces: ['foo'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, }, + ], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - ], - }; - - const result = await saveRole(role); - - expect(result).toEqual({ - elasticsearch: { - cluster: [], - indices: [], - run_as: [], - }, - kibana: [ - { - spaces: ['*'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], + kibana: [ + { + spaces: ['foo'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, }, + ], + }); + }); + + it('should not remove space privileges', async () => { + const role: Role = { + name: 'my role', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - { - spaces: ['marketing'], - base: [], - feature: { - feature1: ['read'], - feature2: ['write'], + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }; + + const result = await saveRole(role); + + expect(result).toEqual({ + elasticsearch: { + cluster: [], + indices: [], + run_as: [], }, - ], + kibana: [ + { + spaces: ['*'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + { + spaces: ['marketing'], + base: [], + feature: { + feature1: ['read'], + feature2: ['write'], + }, + }, + ], + }); + }); + + describe('#bulkUpdateRoles', () => { + async function bulkUpdateRoles(roles: Role[]) { + const httpMock = httpServiceMock.createStartContract(); + const rolesAPIClient = new RolesAPIClient(httpMock); + + await rolesAPIClient.bulkUpdateRoles({ rolesUpdate: roles }); + expect(httpMock.post).toHaveBeenCalledTimes(1); + + return JSON.parse((httpMock.post.mock.calls[0] as any)[1]?.body as any); + } + + it('send payload in the accepted format', async () => { + const roles: Role[] = [ + { + name: 'role1', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }, + { + name: 'role2', + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }, + ]; + + const result = await bulkUpdateRoles(roles); + + expect(result).toEqual({ + roles: { + role1: { + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }, + role2: { + elasticsearch: { + cluster: [], + indices: [], + run_as: [], + }, + kibana: [], + }, + }, + }); + }); }); }); }); diff --git a/x-pack/plugins/security/public/management/roles/roles_api_client.ts b/x-pack/plugins/security/public/management/roles/roles_api_client.ts index c870f99e24dd3..d6dcab658d21c 100644 --- a/x-pack/plugins/security/public/management/roles/roles_api_client.ts +++ b/x-pack/plugins/security/public/management/roles/roles_api_client.ts @@ -6,6 +6,7 @@ */ import type { HttpStart } from '@kbn/core/public'; +import type { BulkUpdatePayload, BulkUpdateRoleResponse } from '@kbn/security-plugin-types-public'; import type { Role, RoleIndexPrivilege, RoleRemoteIndexPrivilege } from '../../../common'; import { copyRole } from '../../../common/model'; @@ -32,6 +33,18 @@ export class RolesAPIClient { }); }; + public bulkUpdateRoles = async ({ + rolesUpdate, + }: BulkUpdatePayload): Promise => { + return await this.http.post('/api/security/roles', { + body: JSON.stringify({ + roles: Object.fromEntries( + rolesUpdate.map((role) => [role.name, this.transformRoleForSave(copyRole(role))]) + ), + }), + }); + }; + private transformRoleForSave = (role: Role) => { // Remove any placeholder index privileges const isPlaceholderPrivilege = ( diff --git a/x-pack/plugins/security/public/plugin.test.tsx b/x-pack/plugins/security/public/plugin.test.tsx index 336a42a1fd324..e58539bf2bc8f 100644 --- a/x-pack/plugins/security/public/plugin.test.tsx +++ b/x-pack/plugins/security/public/plugin.test.tsx @@ -137,6 +137,7 @@ describe('Security Plugin', () => { "getAll": [Function], }, "roles": Object { + "bulkUpdateRoles": [Function], "deleteRole": [Function], "getRole": [Function], "getRoles": [Function], diff --git a/x-pack/plugins/spaces/common/constants.ts b/x-pack/plugins/spaces/common/constants.ts index d70c332fb62ab..bbbe38451fedf 100644 --- a/x-pack/plugins/spaces/common/constants.ts +++ b/x-pack/plugins/spaces/common/constants.ts @@ -31,3 +31,15 @@ export const MAX_SPACE_INITIALS = 2; * The path to enter a space. */ export const ENTER_SPACE_PATH = '/spaces/enter'; + +/** + * The 'classic' solution view is the default, non-project type of solution view + */ +export const SOLUTION_VIEW_CLASSIC = 'classic' as const; + +/** + * The feature privileges constants are used to identify the granularity of the configured feature visibility + */ +export const FEATURE_PRIVILEGES_ALL = 'all' as const; +export const FEATURE_PRIVILEGES_READ = 'read' as const; +export const FEATURE_PRIVILEGES_CUSTOM = 'custom' as const; diff --git a/x-pack/plugins/spaces/common/types/space/v1.ts b/x-pack/plugins/spaces/common/types/space/v1.ts index 9ba2deb09aaa2..ebd841e914e69 100644 --- a/x-pack/plugins/spaces/common/types/space/v1.ts +++ b/x-pack/plugins/spaces/common/types/space/v1.ts @@ -7,7 +7,9 @@ import type { OnBoardingDefaultSolution } from '@kbn/cloud-plugin/common'; -export type SolutionView = OnBoardingDefaultSolution | 'classic'; +import type { SOLUTION_VIEW_CLASSIC } from '../../constants'; + +export type SolutionView = OnBoardingDefaultSolution | typeof SOLUTION_VIEW_CLASSIC; /** * A Space. diff --git a/x-pack/plugins/spaces/public/constants.ts b/x-pack/plugins/spaces/public/constants.ts index 64781228d4f43..09bab1124f27d 100644 --- a/x-pack/plugins/spaces/public/constants.ts +++ b/x-pack/plugins/spaces/public/constants.ts @@ -13,7 +13,7 @@ export const getSpacesFeatureDescription = () => { if (!spacesFeatureDescription) { spacesFeatureDescription = i18n.translate('xpack.spaces.featureDescription', { defaultMessage: - 'Organize your dashboards and other saved objects into meaningful categories.', + 'Organize Kibana into spaces with dedicated navigation, privileges and objects.', }); } return spacesFeatureDescription; diff --git a/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap b/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap index 7eac1c7499919..d7527e300eece 100644 --- a/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap +++ b/x-pack/plugins/spaces/public/management/components/customize_space/__snapshots__/customize_space.test.tsx.snap @@ -5,7 +5,7 @@ exports[`renders correctly 1`] = ` dataTestSubj="generalPanel" > interface Props { validator: SpaceValidator; - space: FormValues; + space: CustomizeSpaceFormValues; editingExistingSpace: boolean; - onChange: (space: FormValues) => void; + onChange: (space: CustomizeSpaceFormValues) => void; title?: string; } @@ -71,7 +71,7 @@ export class CustomizeSpace extends Component { description={i18n.translate( 'xpack.spaces.management.manageSpacePage.describeSpaceDescription', { - defaultMessage: "Give your space a name that's memorable.", + defaultMessage: 'Give your space a meaningful name and description.', } )} fullWidth @@ -258,7 +258,7 @@ export class CustomizeSpace extends Component { }); }; - public onAvatarChange = (space: FormValues) => { + public onAvatarChange = (space: CustomizeSpaceFormValues) => { this.props.onChange(space); }; } diff --git a/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx b/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx index 4faa29d77e037..1a317a4fe9e9a 100644 --- a/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx +++ b/x-pack/plugins/spaces/public/management/components/customize_space/customize_space_avatar.tsx @@ -19,12 +19,12 @@ import { i18n } from '@kbn/i18n'; import { MAX_SPACE_INITIALS } from '../../../../common'; import { encode, imageTypes } from '../../../../common/lib/dataurl'; -import type { FormValues } from '../../edit_space/manage_space_page'; import type { SpaceValidator } from '../../lib'; +import type { CustomizeSpaceFormValues } from '../../types'; interface Props { - space: FormValues; - onChange: (space: FormValues) => void; + space: CustomizeSpaceFormValues; + onChange: (space: CustomizeSpaceFormValues) => void; validator: SpaceValidator; } @@ -127,7 +127,7 @@ export class CustomizeSpaceAvatar extends Component { onChange={(avatarType: string) => this.props.onChange({ ...space, - avatarType: avatarType as FormValues['avatarType'], + avatarType: avatarType as CustomizeSpaceFormValues['avatarType'], }) } buttonSize="m" diff --git a/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx b/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx index 9d7ca7140956c..c336791991df4 100644 --- a/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx +++ b/x-pack/plugins/spaces/public/management/components/solution_view/solution_view.tsx @@ -153,7 +153,7 @@ export const SolutionView: FunctionComponent = ({ placeholder={i18n.translate( 'xpack.spaces.management.navigation.solutionViewDefaultValue', { - defaultMessage: 'Classic (Default)', + defaultMessage: 'Select view', } )} isInvalid={validator.validateSolutionView(space, isEditing).isInvalid} diff --git a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx b/x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx similarity index 98% rename from x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx rename to x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx index 9fd90d739df41..4c8617ff007b8 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/manage_space_page.test.tsx +++ b/x-pack/plugins/spaces/public/management/create_space/create_space_page.test.tsx @@ -18,7 +18,7 @@ import { KibanaFeature } from '@kbn/features-plugin/public'; import { featuresPluginMock } from '@kbn/features-plugin/public/mocks'; import { findTestSubject, mountWithIntl } from '@kbn/test-jest-helpers'; -import { ManageSpacePage } from './manage_space_page'; +import { CreateSpacePage } from './create_space_page'; import type { SolutionView, Space } from '../../../common/types/latest'; import { EventTracker } from '../../analytics'; import type { SpacesManager } from '../../spaces_manager'; @@ -70,7 +70,7 @@ describe('ManageSpacePage', () => { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { const spacesManager = spacesManagerMock.create(); const wrapper = mountWithIntl( - { const onLoadSpace = jest.fn(); const wrapper = mountWithIntl( - { const onLoadSpace = jest.fn(); const wrapper = mountWithIntl( - { const notifications = notificationServiceMock.createStartContract(); const wrapper = mountWithIntl( - Promise.reject(error)} notifications={notifications} @@ -542,7 +542,7 @@ describe('ManageSpacePage', () => { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space); const wrapper = mountWithIntl( - { - customIdentifier?: boolean; - avatarType?: 'initials' | 'image'; - customAvatarInitials?: boolean; - customAvatarColor?: boolean; -} +import type { CustomizeSpaceFormValues } from '../types'; interface Props { getFeatures: FeaturesPluginStart['getFeatures']; @@ -62,7 +56,7 @@ interface Props { } interface State { - space: FormValues; + space: CustomizeSpaceFormValues; features: KibanaFeature[]; originalSpace?: Partial; showAlteringActiveSpaceDialog: boolean; @@ -77,7 +71,7 @@ interface State { }; } -export class ManageSpacePage extends Component { +export class CreateSpacePage extends Component { private readonly validator: SpaceValidator; constructor(props: Props) { @@ -189,7 +183,7 @@ export class ManageSpacePage extends Component { const { showAlteringActiveSpaceDialog } = this.state; return ( -
+
{ this.onSpaceChange(space); }; - public onSpaceChange = (updatedSpace: FormValues) => { + public onSpaceChange = (updatedSpace: CustomizeSpaceFormValues) => { this.setState({ space: updatedSpace, }); diff --git a/x-pack/plugins/spaces/public/management/create_space/index.ts b/x-pack/plugins/spaces/public/management/create_space/index.ts new file mode 100644 index 0000000000000..df9774f722dd3 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/create_space/index.ts @@ -0,0 +1,8 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { CreateSpacePage } from './create_space_page'; diff --git a/x-pack/plugins/spaces/public/management/edit_space/constants.ts b/x-pack/plugins/spaces/public/management/edit_space/constants.ts new file mode 100644 index 0000000000000..21e10c547800f --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/constants.ts @@ -0,0 +1,10 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export const TAB_ID_CONTENT = 'content'; +export const TAB_ID_ROLES = 'roles'; +export const TAB_ID_GENERAL = 'general'; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx new file mode 100644 index 0000000000000..cd2bd76a57928 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space.tsx @@ -0,0 +1,283 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBadge, + EuiButton, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, + EuiSpacer, + EuiTab, + EuiTabs, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import React, { lazy, Suspense, useEffect, useState } from 'react'; +import type { FC } from 'react'; + +import type { ScopedHistory } from '@kbn/core/public'; +import type { FeaturesPluginStart, KibanaFeature } from '@kbn/features-plugin/public'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; +import type { Role } from '@kbn/security-plugin-types-common'; + +import { TAB_ID_CONTENT, TAB_ID_GENERAL, TAB_ID_ROLES } from './constants'; +import { handleApiError } from './handle_api_error'; +import { useTabs } from './hooks/use_tabs'; +import { useEditSpaceServices, useEditSpaceStore } from './provider'; +import { addSpaceIdToPath, ENTER_SPACE_PATH, type Space } from '../../../common'; +import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; +import { getSpaceAvatarComponent } from '../../space_avatar'; +import { SpaceSolutionBadge } from '../../space_solution_badge'; + +// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana. +const LazySpaceAvatar = lazy(() => + getSpaceAvatarComponent().then((component) => ({ default: component })) +); + +const getSelectedTabId = (canUserViewRoles: boolean, selectedTabId?: string) => { + // Validation of the selectedTabId routing parameter, default to the Content tab + return selectedTabId && + [TAB_ID_CONTENT, canUserViewRoles ? TAB_ID_ROLES : null].filter(Boolean).includes(selectedTabId) + ? selectedTabId + : TAB_ID_GENERAL; +}; + +interface PageProps { + spaceId?: string; + history: ScopedHistory; + selectedTabId?: string; + getFeatures: FeaturesPluginStart['getFeatures']; + onLoadSpace: (space: Space) => void; + allowFeatureVisibility: boolean; + allowSolutionVisibility: boolean; +} + +export const EditSpace: FC = ({ + spaceId, + getFeatures, + history, + onLoadSpace, + selectedTabId: _selectedTabId, + ...props +}) => { + const { state, dispatch } = useEditSpaceStore(); + const { invokeClient } = useEditSpaceServices(); + const { spacesManager, capabilities, serverBasePath, logger, notifications } = + useEditSpaceServices(); + const [space, setSpace] = useState(null); + const [userActiveSpace, setUserActiveSpace] = useState(null); + const [features, setFeatures] = useState(null); + const [isLoadingSpace, setIsLoadingSpace] = useState(true); + const [isLoadingFeatures, setIsLoadingFeatures] = useState(true); + const [isLoadingRoles, setIsLoadingRoles] = useState(true); + const selectedTabId = getSelectedTabId(Boolean(capabilities?.roles?.view), _selectedTabId); + const [tabs, selectedTabContent] = useTabs({ + space, + features, + rolesCount: state.roles.size, + capabilities, + history, + currentSelectedTabId: selectedTabId, + ...props, + }); + + useEffect(() => { + if (!spaceId) { + return; + } + + const getSpaceInfo = async () => { + // active space: the space that is active in the user's session + // current space: the space being edited by the user + const [activeSpace, currentSpace] = await Promise.all([ + spacesManager.getActiveSpace(), + spacesManager.getSpace(spaceId), + ]); + + setSpace(currentSpace); + setUserActiveSpace(activeSpace); + setIsLoadingSpace(false); + }; + + getSpaceInfo().catch((error) => + handleApiError(error, { logger, toasts: notifications.toasts }) + ); + }, [spaceId, spacesManager, logger, notifications.toasts]); + + // Load roles to show the count of assigned roles as a badge in the "Assigned roles" tab title + useEffect(() => { + if (!spaceId) { + return; + } + + const getRoles = async () => { + await invokeClient(async (clients) => { + let result: Role[] = []; + try { + result = await clients.spacesManager.getRolesForSpace(spaceId); + } catch (error) { + const message = error?.body?.message ?? error.toString(); + const statusCode = error?.body?.statusCode ?? null; + if (statusCode === 403) { + logger.error('Insufficient permissions to get list of roles for the space'); + logger.error(message); + } else { + logger.error('Encountered error while getting list of roles for space!'); + logger.error(error); + } + dispatch({ type: 'fetch_roles_error', payload: true }); + } + dispatch({ type: 'update_roles', payload: result }); + }); + + setIsLoadingRoles(false); + }; + + if (!state.roles.size && !state.fetchRolesError) { + getRoles(); + } + }, [dispatch, invokeClient, spaceId, state.roles, state.fetchRolesError, logger]); + + useEffect(() => { + const _getFeatures = async () => { + const result = await getFeatures(); + setFeatures(result); + setIsLoadingFeatures(false); + }; + _getFeatures().catch((error) => + handleApiError(error, { logger, toasts: notifications.toasts }) + ); + }, [getFeatures, logger, notifications.toasts]); + + useEffect(() => { + if (space) { + onLoadSpace?.(space); + } + }, [onLoadSpace, space]); + + if (!space) { + return null; + } + + if (isLoadingSpace || isLoadingFeatures || isLoadingRoles) { + return ( + + + + + + ); + } + + const HeaderAvatar = () => { + return ( + }> + + + ); + }; + + const { id, solution: spaceSolution } = space; + const solution = spaceSolution ?? SOLUTION_VIEW_CLASSIC; + const shouldShowSolutionBadge = + props.allowSolutionVisibility || solution !== SOLUTION_VIEW_CLASSIC; + + return ( +
+ + + + + + + + + + + + +

{space.name}

+
+
+ + + {userActiveSpace?.id !== id ? ( + + + + ) : null} + + +
+
+ +
+ {shouldShowSolutionBadge ? ( + + ) : null} + {userActiveSpace?.id === id ? ( + + + + ) : null} +
+
+
+
+
+
+ + +

{space.description}

+
+
+
+ + + + + {tabs.map((tab, index) => ( + + {tab.name} + + ))} + + + {selectedTabContent ?? null} + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx new file mode 100644 index 0000000000000..209f65b9a6783 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.test.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { + httpServiceMock, + i18nServiceMock, + loggingSystemMock, + notificationServiceMock, + overlayServiceMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +import { EditSpaceContentTab } from './edit_space_content_tab'; +import { EditSpaceProvider } from './provider'; +import type { Space } from '../../../common'; +import { spacesManagerMock } from '../../spaces_manager/spaces_manager.mock'; +import type { SpaceContentTypeSummaryItem } from '../../types'; +import { getPrivilegeAPIClientMock } from '../privilege_api_client.mock'; +import { getRolesAPIClientMock } from '../roles_api_client.mock'; + +const getUrlForApp = (appId: string) => appId; +const navigateToUrl = jest.fn(); +const spacesManager = spacesManagerMock.create(); +const getRolesAPIClient = getRolesAPIClientMock; +const getPrivilegeAPIClient = getPrivilegeAPIClientMock; + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); +const logger = loggingSystemMock.createLogger(); + +const TestComponent: React.FC = ({ children }) => { + return ( + + + {children} + + + ); +}; + +describe('EditSpaceContentTab', () => { + const space: Space = { + id: '1', + name: 'space1', + disabledFeatures: [], + }; + + const getSpaceContentSpy = jest.spyOn(spacesManager, 'getContentForSpace'); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render with a loading indicator initially', () => { + render( + + + + ); + + expect(screen.getByTestId('editSpaceContentTabLoadingIndicator')).toBeInTheDocument(); + }); + + it('should render the space content on resolving the saved objects within the space', async () => { + const spaceContentSummary: SpaceContentTypeSummaryItem[] = [ + { + type: 'dashboard', + count: 1, + displayName: 'Dashboard', + }, + ]; + + getSpaceContentSpy.mockResolvedValue({ + summary: spaceContentSummary, + total: spaceContentSummary.length, + }); + + render( + + + + ); + + await waitFor(() => null); + + expect(getSpaceContentSpy).toHaveBeenCalledTimes(1); + expect(getSpaceContentSpy).toHaveBeenCalledWith(space.id); + + expect(screen.queryByTestId('editSpaceContentTabLoadingIndicator')).not.toBeInTheDocument(); + + spaceContentSummary.forEach((item) => { + expect(screen.getByTestId(`space-content-row-${item.type}`)).toBeInTheDocument(); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.tsx new file mode 100644 index 0000000000000..2c20e61800174 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_content_tab.tsx @@ -0,0 +1,147 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { EuiBasicTableColumn, EuiTableFieldDataColumnType } from '@elastic/eui'; +import { + EuiBasicTable, + EuiFlexGroup, + EuiFlexItem, + EuiIcon, + EuiLink, + EuiLoadingSpinner, + EuiText, +} from '@elastic/eui'; +import { capitalize } from 'lodash'; +import type { FC } from 'react'; +import React, { useEffect, useState } from 'react'; + +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { handleApiError } from './handle_api_error'; +import { useEditSpaceServices } from './provider'; +import { addSpaceIdToPath, ENTER_SPACE_PATH, type Space } from '../../../common'; +import type { SpaceContentTypeSummaryItem } from '../../types'; + +export const EditSpaceContentTab: FC<{ space: Space }> = ({ space }) => { + const { id: spaceId } = space; + const { spacesManager, serverBasePath, logger, notifications } = useEditSpaceServices(); + const [isLoading, setIsLoading] = useState(true); + const [items, setItems] = useState(null); + + const columns: Array> = [ + { + field: 'type', + name: 'Type', + render: (_value: string, item: SpaceContentTypeSummaryItem) => { + const { icon, displayName } = item; + return ( + + + + + {capitalize(displayName)} + + ); + }, + }, + { + field: 'count', + name: 'Count', + render: (value: string, item: SpaceContentTypeSummaryItem) => { + const uriComponent = encodeURIComponent( + `/app/management/kibana/objects?initialQuery=type:(${item.type})` + ); + const href = addSpaceIdToPath( + serverBasePath, + space.id, + `${ENTER_SPACE_PATH}?next=${uriComponent}` + ); + return {value}; + }, + }, + ]; + + const getRowProps = (item: SpaceContentTypeSummaryItem) => { + const { type } = item; + return { + 'data-test-subj': `space-content-row-${type}`, + onClick: () => {}, + }; + }; + + const getCellProps = ( + item: SpaceContentTypeSummaryItem, + column: EuiTableFieldDataColumnType + ) => { + const { type } = item; + const { field } = column; + return { + 'data-test-subj': `space-content-cell-${type}-${String(field)}`, + textOnly: true, + }; + }; + + useEffect(() => { + const getItems = async () => { + const result = await spacesManager.getContentForSpace(spaceId); + const { summary } = result; + setItems(summary); + setIsLoading(false); + }; + + getItems().catch((error) => { + handleApiError(error, { logger, toasts: notifications.toasts }); + }); + }, [spaceId, spacesManager, logger, notifications.toasts]); + + if (isLoading) { + return ( + + + + + + ); + } + + if (!items) { + return null; + } + + return ( + + + + + + + + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx new file mode 100644 index 0000000000000..f5bfbe79ec2d4 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_features_tab.tsx @@ -0,0 +1,77 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiLink, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import type { FC } from 'react'; +import React from 'react'; + +import type { KibanaFeature } from '@kbn/features-plugin/common'; +import { FormattedMessage } from '@kbn/i18n-react'; + +import { useEditSpaceServices } from './provider'; +import type { Space } from '../../../common'; +import { FeatureTable } from '../components/enabled_features/feature_table'; +import { SectionPanel } from '../components/section_panel'; + +interface Props { + space: Partial; + features: KibanaFeature[]; + onChange: (updatedSpace: Partial) => void; +} + +export const EditSpaceEnabledFeatures: FC = ({ features, space, onChange }) => { + const { capabilities, getUrlForApp } = useEditSpaceServices(); + const canManageRoles = capabilities.roles?.save === true; + + if (!features) { + return null; + } + + return ( + + + + +

+ +

+
+ + +

+ + + + ) : ( + + ), + }} + /> +

+
+
+ + + +
+
+ ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx new file mode 100644 index 0000000000000..433362777f4d2 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.test.tsx @@ -0,0 +1,489 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, fireEvent, render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import React from 'react'; + +import { + httpServiceMock, + i18nServiceMock, + loggingSystemMock, + notificationServiceMock, + overlayServiceMock, + scopedHistoryMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; +import { DEFAULT_APP_CATEGORIES } from '@kbn/core-application-common'; +import { KibanaFeature } from '@kbn/features-plugin/common'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +import { EditSpaceSettingsTab } from './edit_space_general_tab'; +import { EditSpaceProvider } from './provider/edit_space_provider'; +import type { SolutionView } from '../../../common'; +import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; +import { spacesManagerMock } from '../../spaces_manager/spaces_manager.mock'; +import { getPrivilegeAPIClientMock } from '../privilege_api_client.mock'; +import { getRolesAPIClientMock } from '../roles_api_client.mock'; + +const space = { id: 'default', name: 'Default', disabledFeatures: [], _reserved: true }; +const history = scopedHistoryMock.create(); +const getUrlForApp = (appId: string) => appId; +const navigateToUrl = jest.fn(); +const spacesManager = spacesManagerMock.create(); +const getRolesAPIClient = getRolesAPIClientMock; +const getPrivilegeAPIClient = getPrivilegeAPIClientMock; +const reloadWindow = jest.fn(); + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); +const logger = loggingSystemMock.createLogger(); + +const navigateSpy = jest.spyOn(history, 'push').mockImplementation(() => {}); +const updateSpaceSpy = jest + .spyOn(spacesManager, 'updateSpace') + .mockImplementation(() => Promise.resolve()); +const deleteSpaceSpy = jest + .spyOn(spacesManager, 'deleteSpace') + .mockImplementation(() => Promise.resolve()); + +describe('EditSpaceSettings', () => { + beforeEach(() => { + navigateSpy.mockReset(); + updateSpaceSpy.mockReset(); + deleteSpaceSpy.mockReset(); + }); + + const TestComponent: React.FC = ({ children }) => { + return ( + + + {children} + + + ); + }; + + it('should render controls for initial state of editing a space', () => { + render( + + + + ); + + expect(screen.getByTestId('addSpaceName')).toBeInTheDocument(); + expect(screen.getByTestId('descriptionSpaceText')).toBeInTheDocument(); + expect(screen.getByTestId('spaceLetterInitial')).toBeInTheDocument(); + expect(screen.getByTestId('euiColorPickerAnchor')).toBeInTheDocument(); + + expect(screen.queryByTestId('solutionViewSelect')).not.toBeInTheDocument(); // hides solution view when not not set to visible + expect(screen.queryByTestId('enabled-features-panel')).not.toBeInTheDocument(); // hides navigation features table when not set to visible + }); + + it('shows solution view select when visible', async () => { + render( + + + + ); + + expect(screen.getByTestId('solutionViewSelect')).toBeInTheDocument(); + expect(screen.queryByTestId('enabled-features-panel')).not.toBeInTheDocument(); // hides navigation features table when not set to visible + }); + + it('shows feature visibility controls when allowed', async () => { + const features = [ + new KibanaFeature({ + id: 'feature-1', + name: 'feature 1', + app: [], + category: DEFAULT_APP_CATEGORIES.kibana, + privileges: null, + }), + ]; + + render( + + + + ); + + expect(screen.getByTestId('enabled-features-panel')).toBeInTheDocument(); + expect(screen.queryByTestId('solutionViewSelect')).not.toBeInTheDocument(); // hides solution view when not not set to visible + }); + + it('allows a space to be updated', async () => { + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: '#aabbcc', + initials: 'AB', + disabledFeatures: [], + solution: 'es' as SolutionView, + }; + + render( + + + + ); + + await act(async () => { + // update the space name + const nameInput = screen.getByTestId('addSpaceName'); + fireEvent.change(nameInput, { target: { value: 'Updated Name Of Space' } }); + + expect(screen.queryByTestId('space-edit-page-user-impact-warning')).not.toBeInTheDocument(); + expect(screen.queryByTestId('confirmModalTitleText')).not.toBeInTheDocument(); + + const updateButton = await screen.findByTestId('save-space-button'); // appears via re-render + await userEvent.click(updateButton); + + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + name: 'Updated Name Of Space', + initials: 'UN', + imageUrl: '', + color: '#D6BF57', + }); + }); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); + + it('allows space to be deleted', async () => { + const spaceToDelete = { + id: 'delete-me-space', + name: 'Delete Me Space', + description: 'This is a very nice space... for me to DELETE!', + color: '#aabbcc', + initials: 'XX', + disabledFeatures: [], + }; + + render( + + + + ); + + await act(async () => { + const deleteButton = screen.getByTestId('delete-space-button'); + await userEvent.click(deleteButton); + + const confirmButton = await screen.findByTestId('confirmModalConfirmButton'); // click delete confirm + await userEvent.click(confirmButton); + + expect(deleteSpaceSpy).toHaveBeenCalledWith(spaceToDelete); + }); + }); + + it('sets calculated fields for existing spaces', async () => { + // The Spaces plugin provides functions to calculate the initials and color of a space if they have not been customized. The new space + // management page explicitly sets these fields when a new space is created, but it should also handle existing "legacy" spaces that do + // not already have these fields set. + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: undefined, + initials: undefined, + imageUrl: undefined, + disabledFeatures: [], + }; + + render( + + + + ); + + await act(async () => { + // update the space name + const nameInput = screen.getByTestId('addSpaceName'); + fireEvent.change(nameInput, { target: { value: 'Updated Existing Space' } }); + + const updateButton = await screen.findByTestId('save-space-button'); // appears via re-render + await userEvent.click(updateButton); + + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + name: 'Updated Existing Space', + color: '#D6BF57', + initials: 'UE', + imageUrl: '', + }); + }); + }); + + it('warns when updating solution view', async () => { + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: '#aabbcc', + initials: 'AB', + disabledFeatures: [], + solution: undefined, + }; + + render( + + + + ); + + // update the space solution view + await act(async () => { + const solutionViewPicker = screen.getByTestId('solutionViewSelect'); + await userEvent.click(solutionViewPicker); + + const esSolutionOption = await screen.findByTestId('solutionViewEsOption'); // appears via re-render + await userEvent.click(esSolutionOption); + + expect(screen.getByTestId('space-edit-page-user-impact-warning')).toBeInTheDocument(); + expect(screen.queryByTestId('confirmModalTitleText')).not.toBeInTheDocument(); + + const updateButton = screen.getByTestId('save-space-button'); + await userEvent.click(updateButton); + + expect(screen.getByTestId('confirmModalTitleText')).toBeInTheDocument(); + + const confirmButton = screen.getByTestId('confirmModalConfirmButton'); + await userEvent.click(confirmButton); + + await waitFor(() => { + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + imageUrl: '', + solution: 'es', + }); + }); + }); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); + + it('warns when updating features in the active space', async () => { + const features = [ + new KibanaFeature({ + id: 'feature-1', + name: 'feature 1', + app: [], + category: DEFAULT_APP_CATEGORIES.kibana, + privileges: null, + }), + ]; + + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: '#aabbcc', + initials: 'AB', + disabledFeatures: [], + solution: SOLUTION_VIEW_CLASSIC, + }; + + render( + + + + ); + + // update the space visible features + await act(async () => { + const feature1Checkbox = screen.getByTestId('featureCheckbox_feature-1'); + expect(feature1Checkbox).toBeChecked(); + + await userEvent.click(feature1Checkbox); + await waitFor(() => { + expect(feature1Checkbox).not.toBeChecked(); + }); + + expect(screen.getByTestId('space-edit-page-user-impact-warning')).toBeInTheDocument(); + expect(screen.queryByTestId('confirmModalTitleText')).not.toBeInTheDocument(); + + const updateButton = screen.getByTestId('save-space-button'); + await userEvent.click(updateButton); + + expect(screen.getByTestId('confirmModalTitleText')).toBeInTheDocument(); + + const confirmButton = screen.getByTestId('confirmModalConfirmButton'); + await userEvent.click(confirmButton); + + await waitFor(() => { + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + imageUrl: '', + disabledFeatures: ['feature-1'], + }); + }); + }); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); + + it('empties the disabled features list when the solution view non-classic', async () => { + const features = [ + new KibanaFeature({ + id: 'feature-1', + name: 'feature 1', + app: [], + category: DEFAULT_APP_CATEGORIES.kibana, + privileges: null, + }), + ]; + + const spaceToUpdate = { + id: 'existing-space', + name: 'Existing Space', + description: 'hey an existing space', + color: '#aabbcc', + initials: 'AB', + disabledFeatures: [], + solution: SOLUTION_VIEW_CLASSIC, + }; + + render( + + + + ); + + // customize the space visible features to disable feature-1 + await act(async () => { + const feature1Checkbox = screen.getByTestId('featureCheckbox_feature-1'); + expect(feature1Checkbox).toBeChecked(); + + await userEvent.click(feature1Checkbox); + await waitFor(() => { + expect(feature1Checkbox).not.toBeChecked(); + }); + + expect(screen.getByTestId('space-edit-page-user-impact-warning')).toBeInTheDocument(); + expect(screen.queryByTestId('confirmModalTitleText')).not.toBeInTheDocument(); + }); + + // change the selected solution view to es + await act(async () => { + const solutionViewPicker = screen.getByTestId('solutionViewSelect'); + await userEvent.click(solutionViewPicker); + + const esSolutionOption = await screen.findByTestId('solutionViewEsOption'); // appears via re-render + await userEvent.click(esSolutionOption); + }); + + // perform the save + await act(async () => { + const updateButton = screen.getByTestId('save-space-button'); + await userEvent.click(updateButton); + + expect(screen.getByTestId('confirmModalTitleText')).toBeInTheDocument(); + + const confirmButton = screen.getByTestId('confirmModalConfirmButton'); + await userEvent.click(confirmButton); + + await waitFor(() => { + expect(updateSpaceSpy).toHaveBeenCalledWith({ + ...spaceToUpdate, + imageUrl: '', + solution: 'es', + disabledFeatures: [], // "feature-1" became deselected + }); + }); + }); + + expect(navigateSpy).toHaveBeenCalledTimes(1); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx new file mode 100644 index 0000000000000..2b7f04e4d9417 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_general_tab.tsx @@ -0,0 +1,297 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiCallOut, EuiSpacer } from '@elastic/eui'; +import React, { useCallback, useState } from 'react'; + +import type { ScopedHistory } from '@kbn/core-application-browser'; +import type { KibanaFeature } from '@kbn/features-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { useUnsavedChangesPrompt } from '@kbn/unsaved-changes-prompt'; + +import { EditSpaceEnabledFeatures } from './edit_space_features_tab'; +import { EditSpaceTabFooter } from './footer'; +import { useEditSpaceServices } from './provider'; +import type { Space } from '../../../common'; +import { SOLUTION_VIEW_CLASSIC } from '../../../common/constants'; +import { ConfirmDeleteModal } from '../components'; +import { ConfirmAlterActiveSpaceModal } from '../components/confirm_alter_active_space_modal'; +import { CustomizeSpace } from '../components/customize_space'; +import { SolutionView } from '../components/solution_view'; +import { SpaceValidator } from '../lib'; +import type { CustomizeSpaceFormValues } from '../types'; + +interface Props { + space: Space; + history: ScopedHistory; + features: KibanaFeature[]; + allowFeatureVisibility: boolean; + allowSolutionVisibility: boolean; + reloadWindow: () => void; +} + +export const EditSpaceSettingsTab: React.FC = ({ space, features, history, ...props }) => { + const imageAvatarSelected = Boolean(space.imageUrl); + const [formValues, setFormValues] = useState({ + ...space, + avatarType: imageAvatarSelected ? 'image' : 'initials', + imageUrl: imageAvatarSelected ? space.imageUrl : '', + }); + + const [isDirty, setIsDirty] = useState(false); // track if unsaved changes have been made + const [isLoading, setIsLoading] = useState(false); // track if user has just clicked the Update button + const [showUserImpactWarning, setShowUserImpactWarning] = useState(false); + const [showAlteringActiveSpaceDialog, setShowAlteringActiveSpaceDialog] = useState(false); + const [showConfirmDeleteModal, setShowConfirmDeleteModal] = useState(false); + const { http, overlays, logger, notifications, navigateToUrl, spacesManager } = + useEditSpaceServices(); + + const [solution, setSolution] = useState(space.solution); + + useUnsavedChangesPrompt({ + hasUnsavedChanges: isDirty, + http, + openConfirm: overlays.openConfirm, + navigateToUrl, + history, + titleText: i18n.translate('xpack.spaces.management.spaceDetails.unsavedChangesPromptTitle', { + defaultMessage: 'Leave without saving?', + }), + messageText: i18n.translate( + 'xpack.spaces.management.spaceDetails.unsavedChangesPromptMessage', + { + defaultMessage: "Unsaved changes won't be applied to the space and will be lost.", + } + ), + cancelButtonText: i18n.translate('xpack.spaces.management.spaceDetails.keepEditingButton', { + defaultMessage: 'Save before leaving', + }), + confirmButtonText: i18n.translate('xpack.spaces.management.spaceDetails.leavePageButton', { + defaultMessage: 'Leave', + }), + }); + + const onChangeSpaceSettings = useCallback( + (newFormValues: CustomizeSpaceFormValues) => { + setFormValues({ ...formValues, ...newFormValues }); + setIsDirty(true); + }, + [formValues] + ); + + const onChangeFeatures = useCallback( + (updatedSpace: Partial) => { + setFormValues({ ...formValues, ...updatedSpace }); + setIsDirty(true); + setShowUserImpactWarning(true); + }, + [formValues] + ); + + const onSolutionViewChange = useCallback( + (updatedSpace: Partial) => { + setSolution(updatedSpace.solution); + onChangeFeatures(updatedSpace); + }, + [onChangeFeatures] + ); + + const backToSpacesList = useCallback(() => { + history.push('/'); + }, [history]); + + const onClickCancel = useCallback(() => { + setShowAlteringActiveSpaceDialog(false); + setShowUserImpactWarning(false); + backToSpacesList(); + }, [backToSpacesList]); + + const onClickDeleteSpace = useCallback(() => { + setShowConfirmDeleteModal(true); + }, []); + + const performSave = useCallback( + async ({ requiresReload = false }) => { + const { + avatarType, + customIdentifier, + customAvatarColor, + customAvatarInitials, + ...partialSpace + } = formValues; + + const spaceClone = structuredClone(partialSpace as Partial); + const { id, name } = spaceClone; + + if (!id) { + throw new Error(`Can not update space without id field!`); + } + if (!name) { + throw new Error(`Can not update space without name field!`); + } + + setIsLoading(true); + + let disabledFeatures: string[] | undefined; + if (spaceClone.solution === SOLUTION_VIEW_CLASSIC) { + disabledFeatures = spaceClone.disabledFeatures; + } + + try { + await spacesManager.updateSpace({ + ...spaceClone, + id, + name, + disabledFeatures: disabledFeatures ?? [], + imageUrl: avatarType === 'image' ? spaceClone.imageUrl : '', + }); + + notifications.toasts.addSuccess( + i18n.translate( + 'xpack.spaces.management.spaceDetails.spaceSuccessfullySavedNotificationMessage', + { + defaultMessage: 'Space "{name}" was saved.', + values: { name }, + } + ) + ); + + setIsDirty(false); + backToSpacesList(); + if (requiresReload) { + props.reloadWindow(); + } + } catch (error) { + logger.error('Could not save changes to space!', error); + const message = error?.body?.message ?? error.toString(); + notifications.toasts.addError(error, { + title: i18n.translate('xpack.spaces.management.spaceDetails.errorSavingSpaceTitle', { + defaultMessage: 'Error saving space: {message}', + values: { message }, + }), + }); + } finally { + setIsLoading(false); + } + }, + [backToSpacesList, notifications.toasts, formValues, spacesManager, logger, props] + ); + + const onClickSubmit = useCallback(() => { + if (showUserImpactWarning) { + setShowAlteringActiveSpaceDialog(true); + } else { + performSave({ requiresReload: false }); + } + }, [performSave, showUserImpactWarning]); + + const doShowAlteringActiveSpaceDialog = () => { + return ( + showAlteringActiveSpaceDialog && ( + performSave({ requiresReload: true })} + onCancel={() => { + setShowAlteringActiveSpaceDialog(false); + }} + /> + ) + ); + }; + + const doShowConfirmDeleteSpaceDialog = () => { + return ( + showConfirmDeleteModal && ( + { + setShowConfirmDeleteModal(false); + }} + onSuccess={() => { + setShowConfirmDeleteModal(false); + backToSpacesList(); + }} + /> + ) + ); + }; + + // Show if user has changed disabled features + // Show if user has changed solution view + const doShowUserImpactWarning = () => { + return ( + showUserImpactWarning && ( + <> + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.spaceChangesWarning.impactAllUsersInSpace', + { + defaultMessage: 'The changes made will impact all users in the space.', + } + )} + + + ) + ); + }; + + const validator = new SpaceValidator(); + + return ( + <> + {doShowAlteringActiveSpaceDialog()} + {doShowConfirmDeleteSpaceDialog()} + + + + {props.allowSolutionVisibility && ( + <> + + + + )} + + {props.allowFeatureVisibility && (solution == null || solution === SOLUTION_VIEW_CLASSIC) && ( + <> + + + + )} + + {doShowUserImpactWarning()} + + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx new file mode 100644 index 0000000000000..882301d36459a --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_page.tsx @@ -0,0 +1,40 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import React from 'react'; +import type { ComponentProps, PropsWithChildren } from 'react'; + +import { EditSpace } from './edit_space'; +import { EditSpaceProvider, type EditSpaceProviderProps } from './provider'; + +type EditSpacePageProps = ComponentProps & EditSpaceProviderProps; + +export function EditSpacePage({ + spaceId, + getFeatures, + history, + onLoadSpace, + selectedTabId, + allowFeatureVisibility, + allowSolutionVisibility, + children, + ...editSpaceServicesProps +}: PropsWithChildren) { + return ( + + + + ); +} diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx new file mode 100644 index 0000000000000..ca6d8e1c0767d --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.test.tsx @@ -0,0 +1,119 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { act, render, waitFor } from '@testing-library/react'; +import React from 'react'; + +import { + httpServiceMock, + i18nServiceMock, + loggingSystemMock, + notificationServiceMock, + overlayServiceMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +import { EditSpaceAssignedRolesTab } from './edit_space_roles_tab'; +import { EditSpaceProvider } from './provider'; +import { spacesManagerMock } from '../../spaces_manager/spaces_manager.mock'; +import { getPrivilegeAPIClientMock } from '../privilege_api_client.mock'; +import { getRolesAPIClientMock } from '../roles_api_client.mock'; + +const getUrlForApp = (appId: string) => appId; +const navigateToUrl = jest.fn(); +const spacesManager = spacesManagerMock.create(); +const getRolesAPIClient = getRolesAPIClientMock; +const getPrivilegeAPIClient = getPrivilegeAPIClientMock; + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); +const logger = loggingSystemMock.createLogger(); + +const space = { + id: 'space-a', + name: 'Space A', + disabledFeatures: [], + _reserved: false, +}; + +describe('EditSpaceAssignedRolesTab', () => { + const loadRolesSpy = jest.spyOn(spacesManager, 'getRolesForSpace'); + const toastErrorSpy = jest.spyOn(notifications.toasts, 'addError'); + + const TestComponent: React.FC = ({ children }) => { + return ( + + + {children} + + + ); + }; + + beforeEach(() => { + loadRolesSpy.mockReset(); + toastErrorSpy.mockReset(); + }); + + it('loads the assigned roles', async () => { + act(() => { + render( + + + + ); + }); + + await waitFor(() => { + expect(loadRolesSpy).toHaveBeenCalledTimes(1); + }); + }); + + it('shows an error toast if there is an error loading the assigned roles', async () => { + loadRolesSpy.mockImplementation(() => { + throw new Error('test error'); + }); + + act(() => { + render( + + + + ); + }); + + await waitFor(() => { + expect(loadRolesSpy).toHaveBeenCalledTimes(1); + expect(toastErrorSpy).toHaveBeenCalledWith(new Error('test error'), { + title: 'Error: test error', + }); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx new file mode 100644 index 0000000000000..2733790d8de8b --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_roles_tab.tsx @@ -0,0 +1,207 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiFlexGroup, EuiFlexItem, EuiText } from '@elastic/eui'; +import type { FC } from 'react'; +import React, { useCallback, useEffect } from 'react'; + +import type { KibanaFeature } from '@kbn/features-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { toMountPoint } from '@kbn/react-kibana-mount'; +import type { Role } from '@kbn/security-plugin-types-common'; + +import { handleApiError } from './handle_api_error'; +import { EditSpaceProvider, useEditSpaceServices, useEditSpaceStore } from './provider'; +import { PrivilegesRolesForm } from './roles/component/space_assign_role_privilege_form'; +import { SpaceAssignedRolesTable } from './roles/component/space_assigned_roles_table'; +import type { Space } from '../../../common'; + +interface Props { + space: Space; + features: KibanaFeature[]; + isReadOnly: boolean; +} + +export const EditSpaceAssignedRolesTab: FC = ({ space, features, isReadOnly }) => { + const { dispatch, state } = useEditSpaceStore(); // no loading state because roles have already been loaded + const services = useEditSpaceServices(); + const { + getUrlForApp, + overlays, + theme, + i18n: i18nStart, + logger, + notifications, + invokeClient, + } = services; + + // Roles are already loaded in app state, refresh them when user navigates to this tab + useEffect(() => { + const getRoles = async () => { + await invokeClient(async (clients) => { + let result: Role[] = []; + try { + result = await clients.spacesManager.getRolesForSpace(space.id); + + dispatch({ type: 'update_roles', payload: result }); + } catch (error) { + handleApiError(error, { logger, toasts: notifications.toasts }); + } + }); + }; + + getRoles(); + }, [dispatch, invokeClient, space.id, logger, notifications.toasts]); + + const showRolesPrivilegeEditor = useCallback( + (defaultSelected?: Role[]) => { + const overlayRef = overlays.openFlyout( + toMountPoint( + + { + const { updated, errors } = response; + + if (updated) { + notifications.toasts.addSuccess( + i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assignmentSuccessMsg', + { + defaultMessage: `Selected roles have been assigned to the "{spaceName}" space`, + values: { spaceName: space.name }, + } + ) + ); + } + + for (const [roleName, error] of Object.entries(errors ?? {})) { + notifications.toasts.addError(new Error(JSON.stringify(error)), { + title: `Error updating ${roleName}`, + }); + } + overlayRef.close(); + }, + closeFlyout: () => overlayRef.close(), + defaultSelected, + storeDispatch: dispatch, + spacesClientsInvocator: invokeClient, + getUrlForApp, + }} + /> + , + { theme, i18n: i18nStart } + ), + { + size: 'm', + maxWidth: true, + maskProps: { headerZindexLocation: 'below' }, + } + ); + }, + [ + overlays, + services, + space, + features, + dispatch, + invokeClient, + getUrlForApp, + theme, + i18nStart, + notifications.toasts, + ] + ); + + const removeRole = useCallback( + async (payload: Role[]) => { + // To remove the role from the space in bulk-edit, we take the payload of roles to edit, loop over + // each role, and modify the kibana.spaces field of each role by stripping them of the space to + // disassociate + const updateDoc = structuredClone(payload).map((roleDef) => { + roleDef.kibana = roleDef.kibana.filter(({ spaces }) => { + let spaceIdIndex: number; + + if (spaces.length && (spaceIdIndex = spaces.indexOf(space.id)) > -1) { + if (spaces.length > 1) { + spaces.splice(spaceIdIndex, 1); + return true; + } else { + return false; + } + } + return true; + }); + + return roleDef; + }); + + await invokeClient((clients) => { + return clients.rolesClient.bulkUpdateRoles({ rolesUpdate: updateDoc }).then((response) => { + const { updated, errors } = response; + + if (updated) { + notifications.toasts.addSuccess( + i18n.translate('xpack.spaces.management.spaceDetails.roles.removalSuccessMsg', { + defaultMessage: + 'Removed {count, plural, one {role} other {{count} roles}} from "{spaceName}" space', + values: { + spaceName: space.name, + count: updateDoc.length, + }, + }) + ); + } + + for (const [roleName, error] of Object.entries(errors ?? {})) { + notifications.toasts.addError(new Error(JSON.stringify(error)), { + title: `Error updating ${roleName}`, + }); + } + }); + }); + + dispatch({ type: 'remove_roles', payload: updateDoc }); + }, + [dispatch, invokeClient, notifications.toasts, space.id, space.name] + ); + + return ( + + + + + + + + + showRolesPrivilegeEditor([rowRecord])} + onClickBulkRemove={async (selectedRoles) => { + await removeRole(selectedRoles); + }} + onClickRowRemoveAction={async (rowRecord) => { + await removeRole([rowRecord]); + }} + onClickAssignNewRole={async () => { + showRolesPrivilegeEditor(); + }} + /> + + + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/edit_space_tabs.tsx b/x-pack/plugins/spaces/public/management/edit_space/edit_space_tabs.tsx new file mode 100644 index 0000000000000..48731de7af98c --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/edit_space_tabs.tsx @@ -0,0 +1,125 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { EuiNotificationBadge } from '@elastic/eui'; +import React from 'react'; + +import type { Capabilities, ScopedHistory } from '@kbn/core/public'; +import type { KibanaFeature } from '@kbn/features-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { withSuspense } from '@kbn/shared-ux-utility'; + +import { TAB_ID_CONTENT, TAB_ID_GENERAL, TAB_ID_ROLES } from './constants'; +import type { Space } from '../../../common'; + +export interface EditSpaceTab { + id: string; + name: string; + content: JSX.Element; + append?: JSX.Element; + href?: string; +} + +export interface GetTabsProps { + space: Space; + rolesCount: number; + features: KibanaFeature[]; + history: ScopedHistory; + capabilities: Capabilities & { + roles?: { view: boolean; save: boolean }; + }; + allowFeatureVisibility: boolean; + allowSolutionVisibility: boolean; +} + +const SuspenseEditSpaceSettingsTab = withSuspense( + React.lazy(() => + import('./edit_space_general_tab').then(({ EditSpaceSettingsTab }) => ({ + default: EditSpaceSettingsTab, + })) + ) +); + +const SuspenseEditSpaceAssignedRolesTab = withSuspense( + React.lazy(() => + import('./edit_space_roles_tab').then(({ EditSpaceAssignedRolesTab }) => ({ + default: EditSpaceAssignedRolesTab, + })) + ) +); + +const SuspenseEditSpaceContentTab = withSuspense( + React.lazy(() => + import('./edit_space_content_tab').then(({ EditSpaceContentTab }) => ({ + default: EditSpaceContentTab, + })) + ) +); + +export const getTabs = ({ + space, + features, + history, + capabilities, + rolesCount, + ...props +}: GetTabsProps): EditSpaceTab[] => { + const canUserViewRoles = Boolean(capabilities?.roles?.view); + const canUserModifyRoles = Boolean(capabilities?.roles?.save); + const reloadWindow = () => { + window.location.reload(); + }; + + const tabsDefinition: EditSpaceTab[] = [ + { + id: TAB_ID_GENERAL, + name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.general.heading', { + defaultMessage: 'General settings', + }), + content: ( + + ), + }, + ]; + + if (canUserViewRoles) { + tabsDefinition.push({ + id: TAB_ID_ROLES, + name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.roles.heading', { + defaultMessage: 'Permissions', + }), + append: ( + + {rolesCount} + + ), + content: ( + + ), + }); + } + + tabsDefinition.push({ + id: TAB_ID_CONTENT, + name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.content.heading', { + defaultMessage: 'Content', + }), + content: , + }); + + return tabsDefinition; +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/footer.tsx b/x-pack/plugins/spaces/public/management/edit_space/footer.tsx new file mode 100644 index 0000000000000..013a356f9b400 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/footer.tsx @@ -0,0 +1,87 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiFlexGroup, + EuiFlexItem, + EuiLoadingSpinner, +} from '@elastic/eui'; +import React from 'react'; + +import { FormattedMessage } from '@kbn/i18n-react'; + +interface Props { + isDirty: boolean; + isLoading: boolean; + onClickCancel: () => void; + onClickSubmit: () => void; + onClickDeleteSpace: () => void; +} + +export const EditSpaceTabFooter: React.FC = ({ + isDirty, + isLoading, + onClickCancel, + onClickSubmit, + onClickDeleteSpace, +}) => { + return ( + <> + {isLoading && ( + + + + + + )} + {!isLoading && ( + + + + + + + + + + + + + + + {isDirty && ( + + + + + + )} + + )} + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/handle_api_error.ts b/x-pack/plugins/spaces/public/management/edit_space/handle_api_error.ts new file mode 100644 index 0000000000000..2d40a1c34b990 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/handle_api_error.ts @@ -0,0 +1,27 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { NotificationsStart } from '@kbn/core-notifications-browser'; +import type { Logger } from '@kbn/logging'; + +interface HandleErrorDeps { + toasts: NotificationsStart['toasts']; + logger: Logger; +} + +export const handleApiError = (error: any, deps: HandleErrorDeps) => { + const { logger, toasts } = deps; + + const message = error?.body?.message ?? error.toString(); + + logger.error(message); + logger.error(error); + + toasts.addError(error, { + title: message, + }); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/hooks/use_tabs.ts b/x-pack/plugins/spaces/public/management/edit_space/hooks/use_tabs.ts new file mode 100644 index 0000000000000..fc583e54b0693 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/hooks/use_tabs.ts @@ -0,0 +1,41 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { useMemo } from 'react'; + +import type { ScopedHistory } from '@kbn/core-application-browser'; +import type { KibanaFeature } from '@kbn/features-plugin/public'; + +import type { Space } from '../../../../common'; +import { type EditSpaceTab, getTabs, type GetTabsProps } from '../edit_space_tabs'; + +type UseTabsProps = Pick & { + space: Space | null; + features: KibanaFeature[] | null; + currentSelectedTabId: string; + history: ScopedHistory; + allowFeatureVisibility: boolean; + allowSolutionVisibility: boolean; +}; + +export const useTabs = ({ + space, + features, + currentSelectedTabId, + ...getTabsArgs +}: UseTabsProps): [EditSpaceTab[], JSX.Element | undefined] => { + const [tabs, selectedTabContent] = useMemo(() => { + if (space === null || features === null) { + return [[]]; + } + + const _tabs = space != null ? getTabs({ space, features, ...getTabsArgs }) : []; + return [_tabs, _tabs.find((obj) => obj.id === currentSelectedTabId)?.content]; + }, [space, features, getTabsArgs, currentSelectedTabId]); + + return [tabs, selectedTabContent]; +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/index.ts b/x-pack/plugins/spaces/public/management/edit_space/index.ts index 78c3b0fc42e04..c85e8f1c2e499 100644 --- a/x-pack/plugins/spaces/public/management/edit_space/index.ts +++ b/x-pack/plugins/spaces/public/management/edit_space/index.ts @@ -5,4 +5,4 @@ * 2.0. */ -export { ManageSpacePage } from './manage_space_page'; +export { EditSpacePage } from './edit_space_page'; diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx new file mode 100644 index 0000000000000..bfd7d7b6059e8 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.test.tsx @@ -0,0 +1,113 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { renderHook } from '@testing-library/react-hooks'; +import type { PropsWithChildren } from 'react'; +import React from 'react'; + +import { + httpServiceMock, + i18nServiceMock, + loggingSystemMock, + notificationServiceMock, + overlayServiceMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; +import type { ApplicationStart } from '@kbn/core-application-browser'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; + +import { EditSpaceProvider, useEditSpaceServices, useEditSpaceStore } from './edit_space_provider'; +import { spacesManagerMock } from '../../../spaces_manager/spaces_manager.mock'; +import { getPrivilegeAPIClientMock } from '../../privilege_api_client.mock'; +import { getRolesAPIClientMock } from '../../roles_api_client.mock'; + +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); +const logger = loggingSystemMock.createLogger(); + +const spacesManager = spacesManagerMock.create(); + +const SUTProvider = ({ + children, + capabilities = { + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }, +}: PropsWithChildren>>) => { + return ( + + _, + getRolesAPIClient: getRolesAPIClientMock, + getPrivilegesAPIClient: getPrivilegeAPIClientMock, + navigateToUrl: jest.fn(), + capabilities, + }} + > + {children} + + + ); +}; + +describe('EditSpaceProvider', () => { + describe('useEditSpaceServices', () => { + it('returns an object of predefined properties', () => { + const { result } = renderHook(useEditSpaceServices, { wrapper: SUTProvider }); + + expect(result.current).toEqual( + expect.objectContaining({ + invokeClient: expect.any(Function), + }) + ); + }); + + it('throws when the hook is used within a tree that does not have the provider', () => { + const { result } = renderHook(useEditSpaceServices); + expect(result.error).toBeDefined(); + expect(result.error?.message).toEqual( + expect.stringMatching('EditSpaceService Context is missing.') + ); + }); + }); + + describe('useEditSpaceStore', () => { + it('returns an object of predefined properties', () => { + const { result } = renderHook(useEditSpaceStore, { wrapper: SUTProvider }); + + expect(result.current).toEqual( + expect.objectContaining({ + state: expect.objectContaining({ roles: expect.any(Map) }), + dispatch: expect.any(Function), + }) + ); + }); + + it('throws when the hook is used within a tree that does not have the provider', () => { + const { result } = renderHook(useEditSpaceStore); + + expect(result.error).toBeDefined(); + expect(result.error?.message).toEqual( + expect.stringMatching('EditSpaceStore Context is missing.') + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx new file mode 100644 index 0000000000000..75af2beea2108 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/edit_space_provider.tsx @@ -0,0 +1,149 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { once } from 'lodash'; +import React, { + createContext, + type Dispatch, + type PropsWithChildren, + useCallback, + useContext, + useEffect, + useReducer, + useRef, +} from 'react'; + +import type { ApplicationStart } from '@kbn/core-application-browser'; +import type { CoreStart } from '@kbn/core-lifecycle-browser'; +import type { Logger } from '@kbn/logging'; +import type { + PrivilegesAPIClientPublicContract, + RolesAPIClient, +} from '@kbn/security-plugin-types-public'; + +import { + createSpaceRolesReducer, + type IDispatchAction, + type IEditSpaceStoreState, +} from './reducers'; +import type { SpacesManager } from '../../../spaces_manager'; + +export interface EditSpaceProviderProps + extends Pick { + logger: Logger; + capabilities: ApplicationStart['capabilities']; + getUrlForApp: ApplicationStart['getUrlForApp']; + navigateToUrl: ApplicationStart['navigateToUrl']; + serverBasePath: string; + spacesManager: SpacesManager; + getRolesAPIClient: () => Promise; + getPrivilegesAPIClient: () => Promise; +} + +export interface EditSpaceServices extends EditSpaceProviderProps { + invokeClient(arg: (clients: EditSpaceClients) => Promise): Promise; +} + +interface EditSpaceClients { + spacesManager: SpacesManager; + rolesClient: RolesAPIClient; + privilegesClient: PrivilegesAPIClientPublicContract; +} + +export interface EditSpaceStore { + state: IEditSpaceStoreState; + dispatch: Dispatch; +} + +const createSpaceRolesContext = once(() => createContext(null)); + +const createEditSpaceServicesContext = once(() => createContext(null)); + +export const EditSpaceProvider = ({ + children, + ...services +}: PropsWithChildren) => { + const EditSpaceStoreContext = createSpaceRolesContext(); + const EditSpaceServicesContext = createEditSpaceServicesContext(); + + const clients = useRef( + Promise.all([services.getRolesAPIClient(), services.getPrivilegesAPIClient()]) + ); + const rolesAPIClientRef = useRef(); + const privilegesClientRef = useRef(); + + const initialStoreState = useRef({ + roles: new Map(), + fetchRolesError: false, + }); + + const { logger } = services; + const resolveAPIClients = useCallback(async () => { + try { + [rolesAPIClientRef.current, privilegesClientRef.current] = await clients.current; + } catch (err) { + logger.error('Could not resolve API Clients!', err); + } + }, [logger]); + + useEffect(() => { + resolveAPIClients(); + }, [resolveAPIClients]); + + const createInitialState = useCallback((state: IEditSpaceStoreState) => { + return state; + }, []); + + const [state, dispatch] = useReducer( + createSpaceRolesReducer, + initialStoreState.current, + createInitialState + ); + + const invokeClient: EditSpaceServices['invokeClient'] = useCallback( + async (...args) => { + await resolveAPIClients(); + + return args[0]({ + spacesManager: services.spacesManager, + rolesClient: rolesAPIClientRef.current!, + privilegesClient: privilegesClientRef.current!, + }); + }, + [resolveAPIClients, services.spacesManager] + ); + + return ( + + + {children} + + + ); +}; + +export const useEditSpaceServices = (): EditSpaceServices => { + const context = useContext(createEditSpaceServicesContext()); + if (!context) { + throw new Error( + 'EditSpaceService Context is missing. Ensure the component or React root is wrapped with EditSpaceProvider' + ); + } + + return context; +}; + +export const useEditSpaceStore = () => { + const context = useContext(createSpaceRolesContext()); + if (!context) { + throw new Error( + 'EditSpaceStore Context is missing. Ensure the component or React root is wrapped with EditSpaceProvider' + ); + } + + return context; +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts b/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts new file mode 100644 index 0000000000000..7ae7301cd2c60 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/index.ts @@ -0,0 +1,13 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +export { EditSpaceProvider, useEditSpaceServices, useEditSpaceStore } from './edit_space_provider'; +export type { + EditSpaceProviderProps, + EditSpaceServices, + EditSpaceStore, +} from './edit_space_provider'; diff --git a/x-pack/plugins/spaces/public/management/edit_space/provider/reducers/index.ts b/x-pack/plugins/spaces/public/management/edit_space/provider/reducers/index.ts new file mode 100644 index 0000000000000..66e66681fc759 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/provider/reducers/index.ts @@ -0,0 +1,66 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { type Reducer } from 'react'; + +import type { Role } from '@kbn/security-plugin-types-common'; + +export type IDispatchAction = + | { + /** @description updates the records of roles for a space */ + type: 'update_roles' | 'remove_roles'; + payload: Role[]; + } + | { + /** @description updates to true if user does not have privilege to view roles */ + type: 'fetch_roles_error'; + payload: boolean; + } + | { + type: 'string'; + payload: unknown; + }; + +export interface IEditSpaceStoreState { + /** roles assigned to current space */ + roles: Map; + /** track if there was an error on the attempt to fetch roles **/ + fetchRolesError: boolean; +} + +export const createSpaceRolesReducer: Reducer = ( + state, + action +) => { + const clonedState = structuredClone(state); + + switch (action.type) { + case 'update_roles': { + if (action.payload) { + action.payload.forEach((role) => { + clonedState.roles.set(role.name, role); + }); + } + + return clonedState; + } + case 'remove_roles': { + action.payload.forEach((role) => { + clonedState.roles.delete(role.name); + }); + + return clonedState; + } + case 'fetch_roles_error': { + clonedState.fetchRolesError = action.payload; + return clonedState; + } + default: { + return clonedState; + } + } +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx new file mode 100644 index 0000000000000..9150f0c211adb --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.test.tsx @@ -0,0 +1,308 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import crypto from 'crypto'; +import React from 'react'; + +import { + httpServiceMock, + i18nServiceMock, + loggingSystemMock, + notificationServiceMock, + overlayServiceMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import type { Role } from '@kbn/security-plugin-types-common'; +import { + createRawKibanaPrivileges, + kibanaFeatures, +} from '@kbn/security-role-management-model/src/__fixtures__'; + +import { PrivilegesRolesForm } from './space_assign_role_privilege_form'; +import type { Space } from '../../../../../common'; +import { FEATURE_PRIVILEGES_ALL, FEATURE_PRIVILEGES_READ } from '../../../../../common/constants'; +import { spacesManagerMock } from '../../../../spaces_manager/spaces_manager.mock'; +import { + createPrivilegeAPIClientMock, + getPrivilegeAPIClientMock, +} from '../../../privilege_api_client.mock'; +import { createRolesAPIClientMock, getRolesAPIClientMock } from '../../../roles_api_client.mock'; +import { EditSpaceProvider } from '../../provider'; + +const rolesAPIClient = createRolesAPIClientMock(); +const privilegeAPIClient = createPrivilegeAPIClientMock(); +const http = httpServiceMock.createStartContract(); +const notifications = notificationServiceMock.createStartContract(); +const overlays = overlayServiceMock.createStartContract(); +const theme = themeServiceMock.createStartContract(); +const i18n = i18nServiceMock.createStartContract(); +const logger = loggingSystemMock.createLogger(); +const spacesManager = spacesManagerMock.create(); + +const createRole = (roleName: string, kibana: Role['kibana'] = []): Role => { + return { + name: roleName, + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana, + }; +}; + +const space: Space = { + id: crypto.randomUUID(), + name: 'Odyssey', + description: 'Journey vs. Destination', + disabledFeatures: [], +}; + +const spacesClientsInvocatorMock = jest.fn((fn) => + fn({ + rolesClient: rolesAPIClient, + privilegesClient: privilegeAPIClient, + }) +); +const dispatchMock = jest.fn(); +const onSaveCompleted = jest.fn(); +const closeFlyout = jest.fn(); + +const renderPrivilegeRolesForm = ({ + preSelectedRoles, +}: { + preSelectedRoles?: Role[]; +} = {}) => { + return render( + + _), + getRolesAPIClient: getRolesAPIClientMock, + getPrivilegesAPIClient: getPrivilegeAPIClientMock, + navigateToUrl: jest.fn(), + capabilities: { + navLinks: {}, + management: {}, + catalogue: {}, + spaces: { manage: true }, + }, + }} + > + _), + }} + /> + + + ); +}; + +describe('PrivilegesRolesForm', () => { + let getRolesSpy: jest.SpiedFunction['getRoles']>; + let getAllKibanaPrivilegeSpy: jest.SpiedFunction< + ReturnType['getAll'] + >; + + beforeAll(() => { + getRolesSpy = jest.spyOn(rolesAPIClient, 'getRoles'); + getAllKibanaPrivilegeSpy = jest.spyOn(privilegeAPIClient, 'getAll'); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('does not display the privilege selection buttons or customization form when no role is selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + renderPrivilegeRolesForm(); + + await waitFor(() => null); + + ['all', 'read', 'custom'].forEach((privilege) => { + expect(screen.queryByTestId(`${privilege}-privilege-button`)).not.toBeInTheDocument(); + }); + + expect( + screen.queryByTestId('space-assign-role-privilege-customization-form') + ).not.toBeInTheDocument(); + }); + + it('renders with the assign roles button disabled when no role is selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + renderPrivilegeRolesForm(); + + await waitFor(() => null); + + expect(screen.getByTestId('space-assign-role-create-roles-privilege-button')).toBeDisabled(); + }); + + it('preselects the privilege of the selected role when one is provided', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + renderPrivilegeRolesForm({ + preSelectedRoles: [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_ALL], feature: {}, spaces: [space.id] }, + ]), + ], + }); + + await waitFor(() => null); + + expect(screen.getByTestId(`${FEATURE_PRIVILEGES_ALL}-privilege-button`)).toHaveAttribute( + 'aria-pressed', + String(true) + ); + }); + + it('displays the privilege customization form, when there is a selected role', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const roles: Role[] = [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + expect(screen.getByTestId(`${FEATURE_PRIVILEGES_READ}-privilege-button`)).toHaveAttribute( + 'aria-pressed', + String(true) + ); + + expect( + screen.getByTestId('space-assign-role-privilege-customization-form') + ).toBeInTheDocument(); + + expect( + screen.getByTestId('space-update-role-create-roles-privilege-button') + ).not.toBeDisabled(); + }); + + describe('selecting multiple roles', () => { + it('displays a warning message when roles with different privilege levels are selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const roles: Role[] = [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_ALL], feature: {}, spaces: [space.id] }, + ]), + createRole('test_role_2', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + expect(screen.getByTestId('privilege-conflict-callout')).toBeInTheDocument(); + }); + + it('does not display the permission conflict message when roles with the same privilege levels are selected', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const roles: Role[] = [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + createRole('test_role_2', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + expect(screen.queryByTestId('privilege-conflict-callout')).not.toBeInTheDocument(); + }); + }); + + describe('applying custom privileges', () => { + it('for a selection of roles pre-assigned to a space, the first encountered privilege with a custom privilege is used as the starting point', async () => { + getRolesSpy.mockResolvedValue([]); + getAllKibanaPrivilegeSpy.mockResolvedValue(createRawKibanaPrivileges(kibanaFeatures)); + + const featureIds: string[] = kibanaFeatures.map((kibanaFeature) => kibanaFeature.id); + + const roles: Role[] = [ + createRole('test_role_1', [ + { base: [FEATURE_PRIVILEGES_ALL], feature: {}, spaces: [space.id] }, + ]), + createRole('test_role_2', [ + { base: [], feature: { [featureIds[0]]: [FEATURE_PRIVILEGES_ALL] }, spaces: [space.id] }, + ]), + createRole('test_role_3', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + createRole('test_role_4', [ + { base: [FEATURE_PRIVILEGES_READ], feature: {}, spaces: [space.id] }, + ]), + // empty base denotes role with custom privilege + createRole('test_role_5', [ + { base: [], feature: { [featureIds[0]]: [FEATURE_PRIVILEGES_READ] }, spaces: [space.id] }, + ]), + ]; + + renderPrivilegeRolesForm({ + preSelectedRoles: roles, + }); + + await waitFor(() => null); + + await userEvent.click(screen.getByTestId('custom-privilege-button')); + + expect( + screen.getByTestId('space-assign-role-privilege-customization-form') + ).toBeInTheDocument(); + + expect(screen.queryByTestId(`${featureIds[0]}_read`)).not.toHaveAttribute( + 'aria-pressed', + String(true) + ); + + expect(screen.getByTestId(`${featureIds[0]}_all`)).toHaveAttribute( + 'aria-pressed', + String(true) + ); + }); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx new file mode 100644 index 0000000000000..658730a848a33 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assign_role_privilege_form.tsx @@ -0,0 +1,610 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiButton, + EuiButtonEmpty, + EuiButtonGroup, + EuiCallOut, + EuiComboBox, + EuiFlexGroup, + EuiFlexItem, + EuiFlyoutBody, + EuiFlyoutFooter, + EuiFlyoutHeader, + EuiForm, + EuiFormRow, + EuiLink, + EuiLoadingSpinner, + EuiSpacer, + EuiText, + EuiTitle, +} from '@elastic/eui'; +import type { EuiComboBoxOptionOption } from '@elastic/eui'; +import type { FC } from 'react'; +import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import type { KibanaFeature, KibanaFeatureConfig } from '@kbn/features-plugin/common'; +import { i18n } from '@kbn/i18n'; +import { FormattedMessage } from '@kbn/i18n-react'; +import { type RawKibanaPrivileges } from '@kbn/security-authorization-core'; +import type { Role } from '@kbn/security-plugin-types-common'; +import type { BulkUpdateRoleResponse } from '@kbn/security-plugin-types-public/src/roles/roles_api_client'; +import { KibanaPrivileges } from '@kbn/security-role-management-model'; +import { KibanaPrivilegeTable, PrivilegeFormCalculator } from '@kbn/security-ui-components'; + +import type { Space } from '../../../../../common'; +import { + FEATURE_PRIVILEGES_ALL, + FEATURE_PRIVILEGES_CUSTOM, + FEATURE_PRIVILEGES_READ, +} from '../../../../../common/constants'; +import { type EditSpaceServices, type EditSpaceStore, useEditSpaceServices } from '../../provider'; + +type KibanaRolePrivilege = + | keyof NonNullable + | typeof FEATURE_PRIVILEGES_CUSTOM; + +interface PrivilegesRolesFormProps { + space: Space; + features: KibanaFeature[]; + closeFlyout: () => void; + onSaveCompleted: (response: BulkUpdateRoleResponse) => void; + /** + * @description default roles that should be selected when the form is opened, + * this is useful when the form is opened in edit mode + */ + defaultSelected?: Role[]; + storeDispatch: EditSpaceStore['dispatch']; + spacesClientsInvocator: EditSpaceServices['invokeClient']; + getUrlForApp: EditSpaceServices['getUrlForApp']; +} + +const createRolesComboBoxOptions = (roles: Role[]): Array> => + roles.map((role) => ({ + label: role.name, + value: role, + })); + +export const PrivilegesRolesForm: FC = (props) => { + const { + space, + onSaveCompleted, + closeFlyout, + features, + defaultSelected = [], + spacesClientsInvocator, + storeDispatch, + getUrlForApp, + } = props; + const { logger, notifications } = useEditSpaceServices(); + const [assigningToRole, setAssigningToRole] = useState(false); + const [fetchingDataDeps, setFetchingDataDeps] = useState(false); + const [kibanaPrivileges, setKibanaPrivileges] = useState(null); + const [spaceUnallocatedRoles, setSpaceUnallocatedRole] = useState([]); + const [selectedRoles, setSelectedRoles] = useState>( + createRolesComboBoxOptions(defaultSelected) + ); + const isEditOperation = useRef(Boolean(defaultSelected.length)); + + useEffect(() => { + async function fetchRequiredData(spaceId: string) { + setFetchingDataDeps(true); + + const [systemRoles, _kibanaPrivileges] = await spacesClientsInvocator((clients) => + Promise.all([ + clients.rolesClient.getRoles(), + clients.privilegesClient.getAll({ includeActions: true, respectLicenseLevel: false }), + ]) + ); + + // exclude roles that are already assigned to this space + setSpaceUnallocatedRole( + systemRoles.filter( + (role) => + !role.metadata?._reserved && + (!role.kibana.length || + role.kibana.every((rolePrivileges) => { + return !( + rolePrivileges.spaces.includes(spaceId) || rolePrivileges.spaces.includes('*') + ); + })) + ) + ); + + setKibanaPrivileges(_kibanaPrivileges); + } + + fetchRequiredData(space.id!).finally(() => setFetchingDataDeps(false)); + }, [space.id, spacesClientsInvocator]); + + const selectedRolesCombinedPrivileges = useMemo(() => { + const combinedPrivilege = new Set( + selectedRoles.reduce((result, selectedRole) => { + let match: KibanaRolePrivilege[] = []; + for (let i = 0; i < selectedRole.value!.kibana.length; i++) { + const { spaces, base } = selectedRole.value!.kibana[i]; + if (spaces.includes(space.id!)) { + match = (base.length ? base : [FEATURE_PRIVILEGES_CUSTOM]) as [KibanaRolePrivilege]; + break; + } + } + + return result.concat(match); + }, [] as KibanaRolePrivilege[]) + ); + + return Array.from(combinedPrivilege); + }, [selectedRoles, space.id]); + + const [roleSpacePrivilege, setRoleSpacePrivilege] = useState( + !selectedRoles.length || !selectedRolesCombinedPrivileges.length + ? FEATURE_PRIVILEGES_ALL + : selectedRolesCombinedPrivileges[0] + ); + + const [roleCustomizationAnchor, setRoleCustomizationAnchor] = useState<{ + value: Role; + privilegeIndex: number; + }>(() => { + if (!selectedRoles.length) { + // return a skeleton anchor on init when no roles are selected + return { + value: { + name: 'placeholder', + elasticsearch: { + cluster: [], + run_as: [], + indices: [], + }, + kibana: [ + { + spaces: [space.id], + base: [roleSpacePrivilege === FEATURE_PRIVILEGES_CUSTOM ? '' : roleSpacePrivilege], + feature: {}, + }, + ], + }, + privilegeIndex: 0, + }; + } + + // support instance where the form is opened with roles already preselected + const defaultAnchor = selectedRoles[0]?.value!; + const privilegeIndex = defaultAnchor.kibana.findIndex(({ spaces }) => + spaces.includes(space.id!) + ); + + return { + value: defaultAnchor, + privilegeIndex: (privilegeIndex || -1) >= 0 ? privilegeIndex : 0, + }; + }); + + /** + * @description computes the value anchor role that will be used as the starting point for granular customizations + * on the selected roles. + */ + const computeRoleCustomizationAnchor = useCallback( + (spaceId: string, _selectedRoles: ReturnType) => { + let anchor: typeof roleCustomizationAnchor | null = null; + + for (let i = 0; i < _selectedRoles.length; i++) { + let role; + + if ((role = _selectedRoles[i].value)) { + for (let j = 0; j < _selectedRoles[i].value!.kibana.length; j++) { + let privilegeIterationIndexValue; + + if ((privilegeIterationIndexValue = role.kibana[j])) { + const { spaces, base } = privilegeIterationIndexValue; + /* + * check to see if current role already has a custom privilege, if it does we use that as the starting point for all customizations + * that will happen to all the other selected roles and exit + */ + if (spaces.includes(spaceId) && !base.length) { + anchor = { + value: structuredClone(role), + privilegeIndex: j, + }; + + break; + } + } + } + } + + if (anchor) break; + + // provide a fallback anchor if no suitable anchor was discovered, and we have reached the end of selected roles iteration + if (!anchor && role && i === _selectedRoles.length - 1) { + const fallbackRole = structuredClone(role); + + const spacePrivilegeIndex = fallbackRole.kibana.findIndex(({ spaces }) => + spaces.includes(spaceId) + ); + + anchor = { + value: fallbackRole, + privilegeIndex: + (spacePrivilegeIndex || -1) >= 0 + ? spacePrivilegeIndex + : (fallbackRole?.kibana?.push?.({ + spaces: [spaceId], + base: [], + feature: {}, + }) || 0) - 1, + }; + } + } + + return anchor; + }, + [] + ); + + const onRoleSpacePrivilegeChange = useCallback( + (spacePrivilege: KibanaRolePrivilege) => { + if (spacePrivilege === FEATURE_PRIVILEGES_CUSTOM) { + const _roleCustomizationAnchor = computeRoleCustomizationAnchor(space.id, selectedRoles); + if (_roleCustomizationAnchor) setRoleCustomizationAnchor(_roleCustomizationAnchor); + } else { + // opt for simple updates for customization anchor when privilege is not a custom one, especially that it's used only for visual treatment + setRoleCustomizationAnchor(({ value, privilegeIndex }) => { + value.kibana[privilegeIndex!] = { + spaces: [space.id], + base: [spacePrivilege], + feature: {}, + }; + + return { value, privilegeIndex }; + }); + } + + // persist selected privilege for UI + setRoleSpacePrivilege(spacePrivilege); + }, + [computeRoleCustomizationAnchor, selectedRoles, space.id] + ); + + const assignRolesToSpace = useCallback(async () => { + try { + setAssigningToRole(true); + + const newPrivileges = { + base: roleSpacePrivilege === FEATURE_PRIVILEGES_CUSTOM ? [] : [roleSpacePrivilege], + feature: + roleSpacePrivilege === FEATURE_PRIVILEGES_CUSTOM + ? roleCustomizationAnchor.value?.kibana[roleCustomizationAnchor.privilegeIndex!] + .feature! + : {}, + }; + + const updatedRoles = structuredClone(selectedRoles).map((selectedRole) => { + let found = false; + + for (let i = 0; i < selectedRole.value!.kibana.length; i++) { + const { spaces } = selectedRole.value!.kibana[i]; + + if (spaces.includes(space.id!)) { + if (spaces.length > 1) { + // account for instance where current space belongs to a collection of other spaces that share the same privileges that are grouped together, + // since we intend to apply the new privilege exclusively to the current space + // we remove the space from the shared privilege. + spaces.splice(i, 1); + } else { + Object.assign(selectedRole.value!.kibana[i], newPrivileges); + found = true; + } + + break; + } + } + + if (!found) { + selectedRole.value?.kibana.push(Object.assign({ spaces: [space.id] }, newPrivileges)); + } + + return selectedRole.value!; + }); + + await spacesClientsInvocator((clients) => + clients.rolesClient.bulkUpdateRoles({ rolesUpdate: updatedRoles }).then((response) => { + setAssigningToRole(false); + onSaveCompleted(response); + }) + ); + + storeDispatch({ + type: 'update_roles', + payload: updatedRoles, + }); + } catch (error) { + logger.error('Could not assign role to space!', error); + const message = error?.body?.message ?? error.toString(); + + notifications.toasts.addError(error, { + title: i18n.translate('xpack.spaces.management.spaceDetails.errorAssigningRoleTitle', { + defaultMessage: 'Error assigning role to space: {message}', + values: { message }, + }), + }); + } + }, [ + selectedRoles, + spacesClientsInvocator, + storeDispatch, + onSaveCompleted, + space.id, + roleSpacePrivilege, + roleCustomizationAnchor, + logger, + notifications.toasts, + ]); + + const getForm = () => { + return ( + + + {!isEditOperation.current && ( + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.selectRolesFormRowLabelAnchor', + { defaultMessage: 'Manage roles' } + )} + + } + helpText={i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.selectRolesHelp', + { + defaultMessage: 'Select Kibana spaces to which you wish to assign privileges.', + } + )} + > + setSelectedRoles(value)} + fullWidth + /> + + )} + + + {Boolean(selectedRoles.length) && ( + + + {selectedRolesCombinedPrivileges.length > 1 ? ( + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.description', + { + defaultMessage: + 'Updating the settings here in a bulk will override current individual settings.', + } + )} + + ) : ( + + )} + + + ({ + ...privilege, + 'data-test-subj': `${privilege.id}-privilege-button`, + }))} + color="primary" + idSelected={roleSpacePrivilege} + onChange={(id) => onRoleSpacePrivilegeChange(id as KibanaRolePrivilege)} + isFullWidth + /> + + {Boolean(selectedRoles.length) && ( + + + {!kibanaPrivileges ? ( + + ) : ( + { + // apply selected changes only to the designated customization anchor, this way we delay reconciling the intending privileges + // to all of the selected roles till we decide to commit the changes chosen + setRoleCustomizationAnchor(({ value, privilegeIndex }) => { + let privilege; + + if ((privilege = value!.kibana?.[privilegeIndex!])) { + privilege.feature[featureId] = selectedPrivileges; + } + + return { value, privilegeIndex }; + }); + }} + onChangeAll={(_privilege) => { + // apply selected changes only to the designated customization anchor, this way we delay reconciling the intending privileges + // to all of the selected roles till we decide to commit the changes chosen + setRoleCustomizationAnchor(({ value, privilegeIndex }) => { + let privilege; + + if ((privilege = value!.kibana?.[privilegeIndex!])) { + privilege.base = _privilege; + } + + return { value, privilegeIndex }; + }); + }} + kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges, features)} + privilegeCalculator={ + new PrivilegeFormCalculator( + new KibanaPrivileges(kibanaPrivileges, features), + roleCustomizationAnchor.value! + ) + } + allSpacesSelected={false} + canCustomizeSubFeaturePrivileges={false} + /> + )} + + + )} + + )} + + + ); + }; + + const getSaveButton = useCallback(() => { + return ( + assignRolesToSpace()} + data-test-subj={`space-${ + isEditOperation.current ? 'update' : 'assign' + }-role-create-roles-privilege-button`} + > + {isEditOperation.current + ? i18n.translate('xpack.spaces.management.spaceDetails.roles.updateRoleButton', { + defaultMessage: 'Update', + }) + : i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', { + defaultMessage: 'Assign', + })} + + ); + }, [assignRolesToSpace, assigningToRole, selectedRoles.length]); + + return ( + + + +

+ {isEditOperation.current + ? i18n.translate('xpack.spaces.management.spaceDetails.roles.assignRoleButton', { + defaultMessage: 'Edit role privileges', + }) + : i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.assign.privileges.custom', + { + defaultMessage: 'Assign roles to space', + } + )} +

+
+ + +

+ +

+
+
+ {getForm()} + + + + + {i18n.translate('xpack.spaces.management.spaceDetails.roles.cancelRoleButton', { + defaultMessage: 'Cancel', + })} + + + {getSaveButton()} + + +
+ ); +}; diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx new file mode 100644 index 0000000000000..f909dba415c41 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.test.tsx @@ -0,0 +1,146 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { render, screen, within } from '@testing-library/react'; +import React, { type ComponentProps } from 'react'; + +import { __IntlProvider as IntlProvider } from '@kbn/i18n-react'; +import type { Role } from '@kbn/security-plugin-types-common'; + +import { SpaceAssignedRolesTable } from './space_assigned_roles_table'; + +const defaultProps: Pick< + ComponentProps, + | 'onClickAssignNewRole' + | 'onClickBulkRemove' + | 'onClickRowEditAction' + | 'onClickRowRemoveAction' + | 'currentSpace' +> = { + currentSpace: { + id: 'odyssey', + name: 'Odyssey', + disabledFeatures: [], + }, + onClickBulkRemove: jest.fn(), + onClickRowEditAction: jest.fn(), + onClickAssignNewRole: jest.fn(), + onClickRowRemoveAction: jest.fn(), +}; + +const renderTestComponent = ( + props: Pick< + ComponentProps, + 'assignedRoles' | 'isReadOnly' | 'supportsBulkAction' + > +) => { + render( + + + + ); +}; + +describe('SpaceAssignedRolesTable', () => { + const spaceAssignedRoles = new Map( + [ + { + name: 'Odyssey', + description: 'Journey vs. Destination', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana: [ + { + spaces: [defaultProps.currentSpace.id], + base: ['all'], + feature: {}, + }, + ], + }, + { + name: 'Iliad', + description: '???', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana: [ + { + spaces: [defaultProps.currentSpace.id], + base: ['read'], + feature: {}, + }, + ], + }, + { + name: 'Trisolaris', + description: 'Dark Forest???', + elasticsearch: { cluster: [], run_as: [], indices: [] }, + kibana: [ + { + spaces: ['*'], + base: ['read'], + feature: {}, + }, + ], + metadata: { + _reserved: true, + }, + }, + ].map((role) => [role.name.toLocaleLowerCase(), role]) + ); + + it('renders the table', () => { + renderTestComponent({ + assignedRoles: spaceAssignedRoles, + }); + + expect(screen.getByTestId('spaceAssignedRolesTable')).toBeInTheDocument(); + }); + + it('does not render row selection and bulk actions context menu by default', () => { + renderTestComponent({ + assignedRoles: spaceAssignedRoles, + supportsBulkAction: false, + }); + + expect(screen.getByTestId('spaceAssignedRolesTable')).toBeInTheDocument(); + expect(screen.queryByTestId('bulkActionsContextMenuOpener')).toBeNull(); + expect(screen.queryByTestId('checkboxSelectAll')).toBeNull(); + }); + + it('renders with row selection and bulk actions context menu when bulk action are supported and table is not in readOnly mode', () => { + renderTestComponent({ + assignedRoles: spaceAssignedRoles, + supportsBulkAction: true, + }); + + expect(screen.getByTestId('spaceAssignedRolesTable')).toBeInTheDocument(); + expect(screen.getByTestId('bulkActionsContextMenuOpener')).toBeInTheDocument(); + expect(screen.getByTestId('checkboxSelectAll')).toBeInTheDocument(); + }); + + // it('will not render the bulk actions context menu when the table is in readOnly mode', () => {}) + + it('prevents modification of reserved roles', () => { + renderTestComponent({ + assignedRoles: spaceAssignedRoles, + supportsBulkAction: true, + }); + + expect(screen.getByTestId('spaceAssignedRolesTable')).toBeInTheDocument(); + + const trisolarisRow = screen.getByTestId('space-role-row-Trisolaris'); + + expect(trisolarisRow).toBeInTheDocument(); + + // We expect a length of 2 because EUI also adds a second node for screen readers + expect(within(trisolarisRow).getAllByText('Reserved')).toHaveLength(2); + expect(within(trisolarisRow).getByTestId('spaceRoleCellActionLocked')).toBeInTheDocument(); + expect(within(trisolarisRow).getByTestId('spaceRoleCellActionLocked')).toBeDisabled(); + expect( + within(trisolarisRow).queryByTestId('spaceRoleCellDeleteAction') + ).not.toBeInTheDocument(); + expect(within(trisolarisRow).queryByTestId('spaceRoleCellEditAction')).not.toBeInTheDocument(); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx new file mode 100644 index 0000000000000..6a1d9f24bc042 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/edit_space/roles/component/space_assigned_roles_table.tsx @@ -0,0 +1,509 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { + EuiBadge, + EuiButton, + EuiButtonEmpty, + EuiContextMenu, + EuiFlexGroup, + EuiFlexItem, + EuiHorizontalRule, + EuiIcon, + EuiInMemoryTable, + EuiPopover, + EuiText, + EuiTextColor, +} from '@elastic/eui'; +import type { + CriteriaWithPagination, + EuiBasicTableColumn, + EuiInMemoryTableProps, + EuiSearchBarProps, + EuiTableFieldDataColumnType, + EuiTableSelectionType, +} from '@elastic/eui'; +import React, { useCallback, useEffect, useMemo, useState } from 'react'; + +import { i18n } from '@kbn/i18n'; +import type { Role } from '@kbn/security-plugin-types-common'; + +import type { Space } from '../../../../../common'; +import { sortRolesForListing } from '../../../lib'; + +interface ISpaceAssignedRolesTableProps { + isReadOnly?: boolean; + currentSpace: Space; + assignedRoles: Map; + onClickAssignNewRole: () => Promise; + onClickRowEditAction: (role: Role) => void; + onClickRowRemoveAction: (role: Role) => void; + supportsBulkAction?: boolean; + onClickBulkRemove?: (selectedRoles: Role[]) => void; +} + +const isRoleReserved = (role: Role) => { + return role.metadata?._reserved; +}; +const isRoleAssignedToAll = (role: Role) => { + return role.kibana.reduce((acc, cur) => { + return cur.spaces.includes('*') || acc; + }, false); +}; + +/** + * @description checks if the passed role qualifies as one that can + * be edited by a user with sufficient permissions + */ +export const isEditableRole = (role: Role) => { + return !(isRoleReserved(role) || isRoleAssignedToAll(role)); +}; + +const getTableColumns = ({ + isReadOnly, + currentSpace, + onClickRowEditAction, + onClickRowRemoveAction, +}: Pick< + ISpaceAssignedRolesTableProps, + 'isReadOnly' | 'onClickRowEditAction' | 'onClickRowRemoveAction' | 'currentSpace' +>) => { + const columns: Array> = [ + { + field: 'name', + name: i18n.translate('xpack.spaces.management.spaceDetails.rolesTable.column.name.title', { + defaultMessage: 'Role', + }), + }, + { + field: 'privileges', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.privileges.title', + { defaultMessage: 'Privileges' } + ), + render: (_, record) => { + const uniquePrivilege = new Set( + record.kibana.reduce((privilegeBaseTuple, kibanaPrivilege) => { + if ( + kibanaPrivilege.spaces.includes(currentSpace.id) || + kibanaPrivilege.spaces.includes('*') + ) { + if (!kibanaPrivilege.base.length) { + privilegeBaseTuple.push( + i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.privileges.customPrivilege', + { defaultMessage: 'custom' } + ) + ); + } else { + return privilegeBaseTuple.concat(kibanaPrivilege.base); + } + } + + return privilegeBaseTuple; + }, [] as string[]) + ); + + return Array.from(uniquePrivilege).join(','); + }, + }, + { + field: 'metadata', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.title', + { defaultMessage: 'Role type' } + ), + render: (_value: Role['metadata']) => { + return React.createElement(EuiBadge, { + children: _value?._reserved + ? i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.reserved', + { defaultMessage: 'Reserved' } + ) + : i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.roleType.custom', + { defaultMessage: 'Custom' } + ), + color: _value?._reserved ? undefined : 'success', + }); + }, + }, + ]; + + if (!isReadOnly) { + columns.push({ + name: 'Actions', + actions: [ + { + type: 'icon', + icon: 'lock', + href: '#', + target: '_self', + 'data-test-subj': 'spaceRoleCellActionLocked', + name: (role) => + isRoleReserved(role) + ? i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableTitle.isReserved', + { defaultMessage: 'Reserved' } + ) + : i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableTitle.isAssignedToAll', + { defaultMessage: 'Assigned to all spaces' } + ), + description: (role) => + isRoleReserved(role) + ? i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableDescription.isReserved', + { defaultMessage: `You can’t edit the access of reserved roles to this space.` } + ) + : i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableDescription.isAssignedToAll', + { + defaultMessage: `Can't perform actions on a role that is assigned to all spaces`, + } + ), + isPrimary: true, + enabled: () => false, + available: (rowRecord) => !isEditableRole(rowRecord), + }, + { + type: 'icon', + icon: 'pencil', + 'data-test-subj': 'spaceRoleCellEditAction', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.title', + { defaultMessage: 'Remove from space' } + ), + isPrimary: true, + description: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.description', + { + defaultMessage: + 'Click this action to edit the role privileges of this user for this space.', + } + ), + showOnHover: true, + available: (rowRecord) => isEditableRole(rowRecord), + onClick: onClickRowEditAction, + }, + { + isPrimary: true, + type: 'icon', + icon: 'trash', + color: 'danger', + 'data-test-subj': 'spaceRoleCellDeleteAction', + name: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.remove.title', + { defaultMessage: 'Remove from space' } + ), + description: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.description', + { defaultMessage: 'Click this action to remove the user from this space.' } + ), + showOnHover: true, + available: (rowRecord) => isEditableRole(rowRecord), + onClick: onClickRowRemoveAction, + }, + ], + }); + } + + return columns; +}; + +const getRowProps = (item: Role) => { + const { name } = item; + return { + 'data-test-subj': `space-role-row-${name}`, + onClick: () => {}, + }; +}; + +const getCellProps = (item: Role, column: EuiTableFieldDataColumnType) => { + const { name } = item; + const { field } = column; + return { + 'data-test-subj': `space-role-cell-${name}-${String(field)}`, + textOnly: true, + }; +}; + +export const SpaceAssignedRolesTable = ({ + assignedRoles, + currentSpace, + onClickAssignNewRole, + onClickBulkRemove, + onClickRowEditAction, + onClickRowRemoveAction, + isReadOnly = false, + supportsBulkAction = false, +}: ISpaceAssignedRolesTableProps) => { + const tableColumns = useMemo( + () => + getTableColumns({ isReadOnly, onClickRowEditAction, onClickRowRemoveAction, currentSpace }), + [currentSpace, isReadOnly, onClickRowEditAction, onClickRowRemoveAction] + ); + const [rolesInView, setRolesInView] = useState([]); + const [selectedRoles, setSelectedRoles] = useState([]); + const [isBulkActionContextOpen, setBulkActionContextOpen] = useState(false); + const [pagination, setPagination] = useState['page']>({ + index: 0, + size: 10, + }); + + useEffect(() => { + const valuesFromMap = Array.from(assignedRoles.values()); + const sortedRoles = valuesFromMap.sort(sortRolesForListing); + setRolesInView(sortedRoles); + }, [assignedRoles]); + + const onSearchQueryChange = useCallback>>( + ({ query }) => { + const _assignedRolesTransformed = Array.from(assignedRoles.values()); + + if (query?.text) { + setRolesInView( + _assignedRolesTransformed.filter((role) => role.name.includes(query.text.toLowerCase())) + ); + } else { + setRolesInView(_assignedRolesTransformed); + } + }, + [assignedRoles] + ); + + const searchElementDefinition = useMemo(() => { + return { + box: { + fullWidth: false, + incremental: true, + 'data-test-subj': 'spaceAssignedRolesSearchBox', + placeholder: i18n.translate( + 'xpack.spaces.management.spaceDetails.roles.searchField.placeholder', + { defaultMessage: 'Filter assigned roles...' } + ), + }, + onChange: onSearchQueryChange, + toolsRight: ( + <> + {!isReadOnly && ( + + + {i18n.translate('xpack.spaces.management.spaceDetails.roles.assign', { + defaultMessage: 'Assign new roles', + })} + + + )} + + ), + }; + }, [isReadOnly, onClickAssignNewRole, onSearchQueryChange]); + + const tableHeader = useMemo['childrenBetween']>(() => { + if (!supportsBulkAction) { + return null; + } + + const pageSize = pagination.size; + const pageIndex = pagination.index; + + const selectableRoles = rolesInView.filter((role) => isEditableRole(role) && !isReadOnly); + + return ( + + + + + + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectedStatusInfo', + { + defaultMessage: + 'Showing: {pageItemLength} of {rolesInViewCount} | Selected: {selectedCount, plural, one {one role} other {{selectedCount} roles}}', + values: { + pageItemLength: Math.floor( + rolesInView.length / (pageSize * (pageIndex + 1)) + ) + ? pageSize * (pageIndex + 1) + : rolesInView.length % pageSize, + rolesInViewCount: rolesInView.length, + selectedCount: selectedRoles.length, + }, + } + )} + + + + + + {!isReadOnly && ( + + + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.contextMenuOpener', + { defaultMessage: 'Bulk actions' } + )} + + } + > + , + name: ( + + {i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.remove', + { defaultMessage: 'Remove from space' } + )} + + ), + onClick: async () => { + onClickBulkRemove?.(selectedRoles); + setBulkActionContextOpen(false); + }, + }, + ], + }, + ]} + /> + + + + {Boolean(selectableRoles.length) && + React.createElement(EuiButtonEmpty, { + size: 's', + ...(Boolean(selectedRoles.length) + ? { + iconType: 'crossInCircle', + onClick: setSelectedRoles.bind(null, []), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.clearRolesSelection', + { defaultMessage: 'Clear selection' } + ), + } + : { + iconType: 'pagesSelect', + onClick: setSelectedRoles.bind(null, selectableRoles), + children: i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectAllRoles', + { + defaultMessage: + 'Select {count, plural, one {role} other {all {count} roles}}', + values: { count: selectableRoles.length }, + } + ), + }), + })} + + + )} + + + + + + + + ); + }, [ + pagination.size, + pagination.index, + rolesInView, + selectedRoles, + isReadOnly, + supportsBulkAction, + isBulkActionContextOpen, + onClickBulkRemove, + ]); + + const onTableChange = ({ page }: CriteriaWithPagination) => { + setPagination(page); + }; + + const onSelectionChange = (selection: Role[]) => { + setSelectedRoles(selection); + }; + + const selection: EuiTableSelectionType | undefined = useMemo(() => { + if (!supportsBulkAction) { + return void 0; + } + + return { + selected: selectedRoles, + selectable: (role) => isEditableRole(role), + selectableMessage: (_selectable, role) => { + if (isRoleReserved(role)) { + return i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectableMessage.isReserved', + { defaultMessage: `You can't select a role that is reserved` } + ); + } + if (isRoleAssignedToAll(role)) { + return i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectableMessage.isRoleAssignedToAll', + { defaultMessage: `You can't select a role that is assigned to all spaces` } + ); + } + + return i18n.translate( + 'xpack.spaces.management.spaceDetails.rolesTable.selectableMessage.selectRole', + { defaultMessage: `Select {roleName}`, values: { roleName: role.name } } + ); + }, + onSelectionChange, + }; + }, [selectedRoles, supportsBulkAction]); + + return ( + + + + data-test-subj="spaceAssignedRolesTable" + search={searchElementDefinition} + childrenBetween={tableHeader} + itemId="name" + columns={tableColumns} + items={rolesInView} + rowProps={getRowProps} + cellProps={getCellProps} + selection={selection} + pagination={{ + pageSize: pagination.size, + pageIndex: pagination.index, + pageSizeOptions: [50, 25, 10], + }} + onChange={onTableChange} + /> + + + ); +}; diff --git a/x-pack/plugins/spaces/public/management/lib/index.ts b/x-pack/plugins/spaces/public/management/lib/index.ts index cbeb9036ec4ed..0507a79cb76a5 100644 --- a/x-pack/plugins/spaces/public/management/lib/index.ts +++ b/x-pack/plugins/spaces/public/management/lib/index.ts @@ -8,3 +8,5 @@ export { toSpaceIdentifier, isValidSpaceIdentifier } from './space_identifier_utils'; export { SpaceValidator } from './validate_space'; + +export { sortRolesForListing } from './sort_roles'; diff --git a/x-pack/plugins/spaces/public/management/lib/sort_roles.test.ts b/x-pack/plugins/spaces/public/management/lib/sort_roles.test.ts new file mode 100644 index 0000000000000..bba4e1e7430c9 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/lib/sort_roles.test.ts @@ -0,0 +1,171 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Role } from '@kbn/security-plugin-types-common'; + +import { sortRolesForListing } from './sort_roles'; + +const createCustom = (name: string): Role => { + return { + name, + metadata: { _reserved: false }, + } as unknown as Role; +}; + +const createReserved = (name: string): Role => { + return { + name, + metadata: { _reserved: true }, + } as unknown as Role; +}; + +const expected = [ + 'Apple', + 'Banana', + 'Cherry', + 'Date', + 'Elderberry', + 'Fig', + 'Grape', + 'Honeydew melon', + 'Indian fig', + 'Jackfruit', + 'Kiwi', + 'Lemon', + 'Mango', + 'Nectarine', + 'Orange', + 'Papaya', + 'Quince', + 'Raspberry', + 'Strawberry', + 'Tangerine', + 'Artichoke', + 'Broccoli', + 'Carrot', + 'Daikon', + 'Eggplant', + 'Fennel', + 'Garlic', + 'Horseradish', + 'Iceberg lettuce', + 'Jalapeño', + 'Kale', + 'Leek', + 'Mushroom', + 'Napa cabbage', + 'Okra', + 'Parsnip', + 'Quinoa greens', + 'Radish', + 'Spinach', + 'Turnip', +]; + +describe('sortRolesForListing: sorts the roles correctly', () => { + it('when they are originally sorted alphabetically', () => { + const roles = [ + createCustom('Apple'), + createReserved('Artichoke'), + createCustom('Banana'), + createReserved('Broccoli'), + createReserved('Carrot'), + createCustom('Cherry'), + createReserved('Daikon'), + createCustom('Date'), + createReserved('Eggplant'), + createCustom('Elderberry'), + createReserved('Fennel'), + createCustom('Fig'), + createReserved('Garlic'), + createCustom('Grape'), + createCustom('Honeydew melon'), + createReserved('Horseradish'), + createReserved('Iceberg lettuce'), + createCustom('Indian fig'), + createCustom('Jackfruit'), + createReserved('Jalapeño'), + createReserved('Kale'), + createCustom('Kiwi'), + createReserved('Leek'), + createCustom('Lemon'), + createCustom('Mango'), + createReserved('Mushroom'), + createReserved('Napa cabbage'), + createCustom('Nectarine'), + createReserved('Okra'), + createCustom('Orange'), + createCustom('Papaya'), + createReserved('Parsnip'), + createCustom('Quince'), + createReserved('Quinoa greens'), + createReserved('Radish'), + createCustom('Raspberry'), + createReserved('Spinach'), + createCustom('Strawberry'), + createCustom('Tangerine'), + createReserved('Turnip'), + ]; + + const sortResult = roles.sort(sortRolesForListing); + const names = sortResult.map(({ name }) => name); + + // expect fruits to be at the top, otherwise sorted alphabetically + expect(names).toEqual(expected); + }); + + it('when they are originally sorted randomly', () => { + const roles = [ + createReserved('Iceberg lettuce'), + createCustom('Nectarine'), + createCustom('Strawberry'), + createReserved('Jalapeño'), + createCustom('Papaya'), + createReserved('Fennel'), + createCustom('Lemon'), + createCustom('Grape'), + createReserved('Artichoke'), + createCustom('Apple'), + createReserved('Quinoa greens'), + createCustom('Quince'), + createCustom('Raspberry'), + createReserved('Leek'), + createReserved('Radish'), + createReserved('Daikon'), + createReserved('Turnip'), + createCustom('Elderberry'), + createCustom('Tangerine'), + createReserved('Broccoli'), + createReserved('Mushroom'), + createCustom('Honeydew melon'), + createCustom('Kiwi'), + createCustom('Fig'), + createCustom('Mango'), + createCustom('Banana'), + createCustom('Jackfruit'), + createReserved('Napa cabbage'), + createReserved('Spinach'), + createCustom('Orange'), + createReserved('Okra'), + createReserved('Eggplant'), + createReserved('Kale'), + createCustom('Cherry'), + createReserved('Horseradish'), + createReserved('Garlic'), + createReserved('Carrot'), + createCustom('Date'), + createReserved('Parsnip'), + createCustom('Indian fig'), + ]; + + const sortResult = roles.sort(sortRolesForListing); + const names = sortResult.map(({ name }) => name); + + // expect fruits to be at the top, otherwise sorted alphabetically + expect(names).toEqual(expected); + }); +}); diff --git a/x-pack/plugins/spaces/public/management/lib/sort_roles.ts b/x-pack/plugins/spaces/public/management/lib/sort_roles.ts new file mode 100644 index 0000000000000..952502fcc777b --- /dev/null +++ b/x-pack/plugins/spaces/public/management/lib/sort_roles.ts @@ -0,0 +1,28 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Role } from '@kbn/security-plugin-types-common'; + +/** + * Roles in the listing must be sorted so that custom roles appear in the beginning + * and reserved roles appear at the end + */ +export function sortRolesForListing(aRole: Role, bRole: Role) { + const { name: aName, metadata: aMeta } = aRole; + const { name: bName, metadata: bMeta } = bRole; + const aReserved = aMeta?._reserved ?? false; + const bReserved = bMeta?._reserved ?? false; + + if (aReserved && !bReserved) { + return 1; + } + if (!aReserved && bReserved) { + return -1; + } + + return aName.localeCompare(bName); +} diff --git a/x-pack/plugins/spaces/public/management/lib/validate_space.ts b/x-pack/plugins/spaces/public/management/lib/validate_space.ts index 9a9ae0cbe98fd..7a7980028dad0 100644 --- a/x-pack/plugins/spaces/public/management/lib/validate_space.ts +++ b/x-pack/plugins/spaces/public/management/lib/validate_space.ts @@ -11,7 +11,7 @@ import { i18n } from '@kbn/i18n'; import { isValidSpaceIdentifier } from './space_identifier_utils'; import { isReservedSpace } from '../../../common/is_reserved_space'; -import type { FormValues } from '../edit_space/manage_space_page'; +import type { CustomizeSpaceFormValues } from '../types'; interface SpaceValidatorOptions { shouldValidate?: boolean; @@ -32,7 +32,7 @@ export class SpaceValidator { this.shouldValidate = false; } - public validateSpaceName(space: FormValues) { + public validateSpaceName(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -56,7 +56,7 @@ export class SpaceValidator { return valid(); } - public validateSpaceDescription(space: FormValues) { + public validateSpaceDescription(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -72,7 +72,7 @@ export class SpaceValidator { return valid(); } - public validateURLIdentifier(space: FormValues) { + public validateURLIdentifier(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -104,7 +104,7 @@ export class SpaceValidator { return valid(); } - public validateAvatarInitials(space: FormValues) { + public validateAvatarInitials(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -129,7 +129,7 @@ export class SpaceValidator { return valid(); } - public validateAvatarColor(space: FormValues) { + public validateAvatarColor(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -153,7 +153,7 @@ export class SpaceValidator { return valid(); } - public validateAvatarImage(space: FormValues) { + public validateAvatarImage(space: CustomizeSpaceFormValues) { if (!this.shouldValidate) { return valid(); } @@ -170,7 +170,7 @@ export class SpaceValidator { } public validateSolutionView( - space: FormValues, + space: CustomizeSpaceFormValues, isEditing: boolean, allowSolutionVisibility = true ) { @@ -189,11 +189,15 @@ export class SpaceValidator { return valid(); } - public validateEnabledFeatures(space: FormValues) { + public validateEnabledFeatures(space: CustomizeSpaceFormValues) { return valid(); } - public validateForSave(space: FormValues, isEditing: boolean, allowSolutionVisibility: boolean) { + public validateForSave( + space: CustomizeSpaceFormValues, + isEditing: boolean, + allowSolutionVisibility: boolean + ) { const { isInvalid: isNameInvalid } = this.validateSpaceName(space); const { isInvalid: isDescriptionInvalid } = this.validateSpaceDescription(space); const { isInvalid: isIdentifierInvalid } = this.validateURLIdentifier(space); diff --git a/x-pack/plugins/spaces/public/management/management_service.test.ts b/x-pack/plugins/spaces/public/management/management_service.test.ts index e5438c0cf5e9c..40a61397e286f 100644 --- a/x-pack/plugins/spaces/public/management/management_service.test.ts +++ b/x-pack/plugins/spaces/public/management/management_service.test.ts @@ -7,6 +7,7 @@ import type { CoreSetup } from '@kbn/core/public'; import { coreMock } from '@kbn/core/public/mocks'; +import { loggingSystemMock } from '@kbn/core-logging-browser-mocks'; import type { ManagementSection } from '@kbn/management-plugin/public'; import { managementPluginMock } from '@kbn/management-plugin/public/mocks'; @@ -18,6 +19,7 @@ import type { PluginsStart } from '../plugin'; import { spacesManagerMock } from '../spaces_manager/mocks'; const eventTracker = new EventTracker({ reportEvent: jest.fn() }); +const logger = loggingSystemMock.createLogger(); describe('ManagementService', () => { const config: ConfigType = { @@ -44,6 +46,7 @@ describe('ManagementService', () => { .getStartServices as CoreSetup['getStartServices'], spacesManager: spacesManagerMock.create(), config, + logger, getRolesAPIClient: getRolesAPIClientMock, getPrivilegesAPIClient: jest.fn(), eventTracker, @@ -66,6 +69,7 @@ describe('ManagementService', () => { .getStartServices as CoreSetup['getStartServices'], spacesManager: spacesManagerMock.create(), config, + logger, getRolesAPIClient: getRolesAPIClientMock, getPrivilegesAPIClient: jest.fn(), eventTracker, @@ -89,6 +93,7 @@ describe('ManagementService', () => { .getStartServices as CoreSetup['getStartServices'], spacesManager: spacesManagerMock.create(), config, + logger, getRolesAPIClient: jest.fn(), getPrivilegesAPIClient: jest.fn(), eventTracker, diff --git a/x-pack/plugins/spaces/public/management/management_service.tsx b/x-pack/plugins/spaces/public/management/management_service.tsx index b186135d88e05..0379189e192c3 100644 --- a/x-pack/plugins/spaces/public/management/management_service.tsx +++ b/x-pack/plugins/spaces/public/management/management_service.tsx @@ -6,6 +6,7 @@ */ import type { StartServicesAccessor } from '@kbn/core/public'; +import type { Logger } from '@kbn/logging'; import type { ManagementApp, ManagementSetup } from '@kbn/management-plugin/public'; import type { PrivilegesAPIClientPublicContract, @@ -26,6 +27,7 @@ interface SetupDeps { getRolesAPIClient: () => Promise; eventTracker: EventTracker; getPrivilegesAPIClient: () => Promise; + logger: Logger; } export class ManagementService { @@ -36,6 +38,7 @@ export class ManagementService { management, spacesManager, config, + logger, getRolesAPIClient, eventTracker, getPrivilegesAPIClient, @@ -45,6 +48,7 @@ export class ManagementService { getStartServices, spacesManager, config, + logger, getRolesAPIClient, eventTracker, getPrivilegesAPIClient, diff --git a/x-pack/plugins/spaces/public/management/privilege_api_client.mock.ts b/x-pack/plugins/spaces/public/management/privilege_api_client.mock.ts new file mode 100644 index 0000000000000..a8351e2d88ad5 --- /dev/null +++ b/x-pack/plugins/spaces/public/management/privilege_api_client.mock.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { PrivilegesAPIClientPublicContract } from '@kbn/security-plugin-types-public'; + +export const createPrivilegeAPIClientMock = (): PrivilegesAPIClientPublicContract => { + return { + getAll: jest.fn(), + }; +}; + +export const getPrivilegeAPIClientMock = jest + .fn() + .mockResolvedValue(createPrivilegeAPIClientMock()); diff --git a/x-pack/plugins/spaces/public/management/roles_api_client.mock.ts b/x-pack/plugins/spaces/public/management/roles_api_client.mock.ts index dd996814f9e51..66a356b3fdb75 100644 --- a/x-pack/plugins/spaces/public/management/roles_api_client.mock.ts +++ b/x-pack/plugins/spaces/public/management/roles_api_client.mock.ts @@ -13,6 +13,7 @@ export const createRolesAPIClientMock = (): RolesAPIClient => { getRole: jest.fn(), saveRole: jest.fn(), deleteRole: jest.fn(), + bulkUpdateRoles: jest.fn(), }; }; diff --git a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx index 2bbcc290a8a83..5ac3ecf0ca687 100644 --- a/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_grid/spaces_grid_page.tsx @@ -35,7 +35,11 @@ import { reactRouterNavigate } from '@kbn/kibana-react-plugin/public'; import { addSpaceIdToPath, type Space } from '../../../common'; import { isReservedSpace } from '../../../common'; -import { DEFAULT_SPACE_ID, ENTER_SPACE_PATH } from '../../../common/constants'; +import { + DEFAULT_SPACE_ID, + ENTER_SPACE_PATH, + SOLUTION_VIEW_CLASSIC, +} from '../../../common/constants'; import { getSpacesFeatureDescription } from '../../constants'; import { getSpaceAvatarComponent } from '../../space_avatar'; import { SpaceSolutionBadge } from '../../space_solution_badge'; @@ -251,6 +255,9 @@ export class SpacesGridPage extends Component { }; public getColumnConfig() { + const { activeSpace, features } = this.state; + const { solution: activeSolution } = activeSpace ?? {}; + const config: Array> = [ { field: 'initials', @@ -306,17 +313,21 @@ export class SpacesGridPage extends Component { truncateText: true, width: '30%', }, - { + ]; + + const shouldShowFeaturesColumn = !activeSolution || activeSolution === SOLUTION_VIEW_CLASSIC; + if (shouldShowFeaturesColumn) { + config.push({ field: 'disabledFeatures', name: i18n.translate('xpack.spaces.management.spacesGridPage.featuresColumnName', { defaultMessage: 'Features visible', }), sortable: (space: Space) => { - return getEnabledFeatures(this.state.features, space).length; + return getEnabledFeatures(features, space).length; }, render: (_disabledFeatures: string[], rowRecord: Space) => { - const enabledFeatureCount = getEnabledFeatures(this.state.features, rowRecord).length; - if (enabledFeatureCount === this.state.features.length) { + const enabledFeatureCount = getEnabledFeatures(features, rowRecord).length; + if (enabledFeatureCount === features.length) { return ( { } if (enabledFeatureCount === 0) { return ( - + { defaultMessage="{enabledFeatureCount} / {totalFeatureCount}" values={{ enabledFeatureCount, - totalFeatureCount: this.state.features.length, + totalFeatureCount: features.length, }} /> ); }, + }); + } + + config.push({ + field: 'id', + name: i18n.translate('xpack.spaces.management.spacesGridPage.identifierColumnName', { + defaultMessage: 'Identifier', + }), + sortable: true, + render(id: string) { + if (id === DEFAULT_SPACE_ID) { + return ''; + } + return id; }, - { - field: 'id', - name: i18n.translate('xpack.spaces.management.spacesGridPage.identifierColumnName', { - defaultMessage: 'Identifier', - }), - sortable: true, - render(id: string) { - if (id === DEFAULT_SPACE_ID) { - return ''; - } - return id; - }, - }, - ]; + }); if (this.props.allowSolutionVisibility) { config.push({ @@ -404,7 +416,7 @@ export class SpacesGridPage extends Component { defaultMessage: 'Switch', }), description: (rowRecord) => - this.state.activeSpace?.name !== rowRecord.name + activeSpace?.name !== rowRecord.name ? i18n.translate( 'xpack.spaces.management.spacesGridPage.switchSpaceActionDescription', { @@ -428,7 +440,7 @@ export class SpacesGridPage extends Component { rowRecord.id, `${ENTER_SPACE_PATH}?next=/app/management/kibana/spaces/` ), - enabled: (rowRecord) => this.state.activeSpace?.name !== rowRecord.name, + enabled: (rowRecord) => activeSpace?.name !== rowRecord.name, 'data-test-subj': (rowRecord) => `${rowRecord.name}-switchSpace`, }, { @@ -440,7 +452,7 @@ export class SpacesGridPage extends Component { ? i18n.translate( 'xpack.spaces.management.spacesGridPage.deleteActionDisabledDescription', { - defaultMessage: `{spaceName} is reserved`, + defaultMessage: `You can't delete the {spaceName} space`, values: { spaceName: rowRecord.name }, } ) diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx index d9852a82f8259..a04335613e59b 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.test.tsx @@ -9,8 +9,17 @@ jest.mock('./spaces_grid', () => ({ SpacesGridPage: (props: any) => `Spaces Page: ${JSON.stringify(props)}`, })); +jest.mock('./create_space', () => ({ + CreateSpacePage: (props: any) => { + if (props.spacesManager && props.onLoadSpace) { + props.spacesManager.getSpace().then((space: any) => props.onLoadSpace(space)); + } + return `Spaces Create Page: ${JSON.stringify(props)}`; + }, +})); + jest.mock('./edit_space', () => ({ - ManageSpacePage: (props: any) => { + EditSpacePage: (props: any) => { if (props.spacesManager && props.onLoadSpace) { props.spacesManager.getSpace().then((space: any) => props.onLoadSpace(space)); } @@ -18,7 +27,12 @@ jest.mock('./edit_space', () => ({ }, })); -import { coreMock, scopedHistoryMock, themeServiceMock } from '@kbn/core/public/mocks'; +import { + coreMock, + loggingSystemMock, + scopedHistoryMock, + themeServiceMock, +} from '@kbn/core/public/mocks'; import { featuresPluginMock } from '@kbn/features-plugin/public/mocks'; import { spacesManagementApp } from './spaces_management_app'; @@ -37,6 +51,7 @@ const config: ConfigType = { }; const eventTracker = new EventTracker({ reportEvent: jest.fn() }); +const logger = loggingSystemMock.createLogger(); async function mountApp(basePath: string, pathname: string, spaceId?: string) { const container = document.createElement('div'); @@ -59,6 +74,7 @@ async function mountApp(basePath: string, pathname: string, spaceId?: string) { spacesManager, getStartServices: async () => [coreStart, pluginsStart as PluginsStart, {}], config, + logger, getRolesAPIClient: jest.fn(), getPrivilegesAPIClient: jest.fn(), eventTracker, @@ -82,6 +98,7 @@ describe('spacesManagementApp', () => { spacesManager: spacesManagerMock.create(), getStartServices: coreMock.createSetup().getStartServices as any, config, + logger, getRolesAPIClient: jest.fn(), getPrivilegesAPIClient: jest.fn(), eventTracker, @@ -136,7 +153,7 @@ describe('spacesManagementApp', () => { css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)." data-test-subj="kbnRedirectAppLink" > - Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/create","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true,"eventTracker":{"analytics":{}}} + Spaces Create Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"history":{"action":"PUSH","length":1,"location":{"pathname":"/create","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true,"eventTracker":{"analytics":{}}}
`); @@ -159,7 +176,7 @@ describe('spacesManagementApp', () => { expect(setBreadcrumbs).toHaveBeenCalledTimes(1); expect(setBreadcrumbs).toHaveBeenCalledWith([ { href: `/`, text: 'Spaces' }, - { text: `space with id some-space` }, + { text: `Edit "space with id some-space"` }, ]); expect(docTitle.change).toHaveBeenCalledWith('Spaces'); expect(docTitle.reset).not.toHaveBeenCalled(); @@ -169,7 +186,7 @@ describe('spacesManagementApp', () => { css="You have tried to stringify object returned from \`css\` function. It isn't supposed to be used directly (e.g. as value of the \`className\` prop), but rather handed to emotion so it can handle it (e.g. as value of \`css\` prop)." data-test-subj="kbnRedirectAppLink" > - Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"notifications":{"toasts":{}},"spacesManager":{"onActiveSpaceChange$":{}},"spaceId":"some-space","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/some-space","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true,"eventTracker":{"analytics":{}}} + Spaces Edit Page: {"capabilities":{"catalogue":{},"management":{},"navLinks":{}},"serverBasePath":"","http":{"basePath":{"basePath":"","serverBasePath":"","assetsHrefBase":""},"anonymousPaths":{},"externalUrl":{},"staticAssets":{}},"overlays":{"banners":{}},"notifications":{"toasts":{}},"theme":{"theme$":{}},"i18n":{},"logger":{"context":[]},"spacesManager":{"onActiveSpaceChange$":{}},"spaceId":"some-space","history":{"action":"PUSH","length":1,"location":{"pathname":"/edit/some-space","search":"","hash":""}},"allowFeatureVisibility":true,"allowSolutionVisibility":true}
`); diff --git a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx index 40532a7259521..fa74316779a7e 100644 --- a/x-pack/plugins/spaces/public/management/spaces_management_app.tsx +++ b/x-pack/plugins/spaces/public/management/spaces_management_app.tsx @@ -12,6 +12,7 @@ import { useParams } from 'react-router-dom'; import type { StartServicesAccessor } from '@kbn/core/public'; import { i18n } from '@kbn/i18n'; import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public'; +import type { Logger } from '@kbn/logging'; import type { RegisterManagementAppArgs } from '@kbn/management-plugin/public'; import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render'; import type { @@ -31,6 +32,7 @@ interface CreateParams { getStartServices: StartServicesAccessor; spacesManager: SpacesManager; config: ConfigType; + logger: Logger; getRolesAPIClient: () => Promise; eventTracker: EventTracker; getPrivilegesAPIClient: () => Promise; @@ -38,7 +40,15 @@ interface CreateParams { export const spacesManagementApp = Object.freeze({ id: 'spaces', - create({ getStartServices, spacesManager, config, eventTracker }: CreateParams) { + create({ + getStartServices, + spacesManager, + config, + logger, + eventTracker, + getRolesAPIClient, + getPrivilegesAPIClient, + }: CreateParams) { const title = i18n.translate('xpack.spaces.displayName', { defaultMessage: 'Spaces', }); @@ -49,14 +59,23 @@ export const spacesManagementApp = Object.freeze({ title, async mount({ element, setBreadcrumbs, history }) { - const [[coreStart, { features }], { SpacesGridPage }, { ManageSpacePage }] = - await Promise.all([getStartServices(), import('./spaces_grid'), import('./edit_space')]); + const [ + [coreStart, { features }], + { SpacesGridPage }, + { CreateSpacePage }, + { EditSpacePage }, + ] = await Promise.all([ + getStartServices(), + import('./spaces_grid'), + import('./create_space'), + import('./edit_space'), + ]); const spacesFirstBreadcrumb = { text: title, href: `/`, }; - const { notifications, application, chrome, http } = coreStart; + const { notifications, application, chrome, http, overlays, theme } = coreStart; chrome.docTitle.change(title); @@ -88,7 +107,7 @@ export const spacesManagementApp = Object.freeze({ ]); return ( - { - const { spaceId } = useParams<{ spaceId: string }>(); + const { spaceId, selectedTabId } = useParams<{ + spaceId: string; + selectedTabId?: string; + }>(); + + const breadcrumbText = (space: Space) => + i18n.translate('xpack.spaces.management.editSpaceBreadcrumb', { + defaultMessage: 'Edit "{space}"', + values: { space: space.name }, + }); const onLoadSpace = (space: Space) => { setBreadcrumbs([ spacesFirstBreadcrumb, { - text: space.name, + text: breadcrumbText(space), }, ]); }; return ( - ); }; @@ -141,7 +179,7 @@ export const spacesManagementApp = Object.freeze({ - + diff --git a/x-pack/plugins/spaces/public/management/types.ts b/x-pack/plugins/spaces/public/management/types.ts new file mode 100644 index 0000000000000..dbf839256e43e --- /dev/null +++ b/x-pack/plugins/spaces/public/management/types.ts @@ -0,0 +1,18 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import type { Space } from '../../common'; + +/** + * Values used in the "Customize Space" form + */ +export interface CustomizeSpaceFormValues extends Partial { + customIdentifier?: boolean; + avatarType?: 'initials' | 'image'; + customAvatarInitials?: boolean; + customAvatarColor?: boolean; +} diff --git a/x-pack/plugins/spaces/public/plugin.tsx b/x-pack/plugins/spaces/public/plugin.tsx index 6d545cdb70e61..86196333c0883 100644 --- a/x-pack/plugins/spaces/public/plugin.tsx +++ b/x-pack/plugins/spaces/public/plugin.tsx @@ -125,6 +125,7 @@ export class SpacesPlugin implements Plugin; } diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts index bed49326efea5..021620b41dc55 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.test.ts @@ -183,4 +183,32 @@ describe('SpacesManager', () => { }); }); }); + + describe('#getRolesForSpace', () => { + it('retrieves roles for the specified space', async () => { + const coreStart = coreMock.createStart(); + const rolesForSpace = [Symbol()]; + coreStart.http.get.mockResolvedValue(rolesForSpace); + const spacesManager = new SpacesManager(coreStart.http); + + const result = await spacesManager.getRolesForSpace('foo'); + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.http.get).toHaveBeenLastCalledWith('/internal/security/roles/foo'); + expect(result).toEqual(rolesForSpace); + }); + }); + + describe('#getContentForSpace', () => { + it('retrieves content for the specified space', async () => { + const coreStart = coreMock.createStart(); + const spaceContent = [Symbol()]; + coreStart.http.get.mockResolvedValue({ summary: spaceContent, total: spaceContent.length }); + const spacesManager = new SpacesManager(coreStart.http); + + const result = await spacesManager.getContentForSpace('foo'); + expect(coreStart.http.get).toHaveBeenCalledTimes(1); + expect(coreStart.http.get).toHaveBeenLastCalledWith('/internal/spaces/foo/content_summary'); + expect(result).toEqual({ summary: spaceContent, total: spaceContent.length }); + }); + }); }); diff --git a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts index 7762158a4379b..962f02ca2bd79 100644 --- a/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts +++ b/x-pack/plugins/spaces/public/spaces_manager/spaces_manager.ts @@ -11,9 +11,11 @@ import { BehaviorSubject, skipWhile } from 'rxjs'; import type { HttpSetup } from '@kbn/core/public'; import type { SavedObjectsCollectMultiNamespaceReferencesResponse } from '@kbn/core-saved-objects-api-server'; import type { LegacyUrlAliasTarget } from '@kbn/core-saved-objects-common'; +import type { Role } from '@kbn/security-plugin-types-common'; import type { GetAllSpacesOptions, GetSpaceResult, Space } from '../../common'; import type { CopySavedObjectsToSpaceResponse } from '../copy_saved_objects_to_space/types'; +import type { SpaceContentTypeSummaryItem } from '../types'; interface SavedObjectTarget { type: string; @@ -192,4 +194,14 @@ export class SpacesManager { private isAnonymousPath() { return this.http.anonymousPaths.isAnonymous(window.location.pathname); } + + public getContentForSpace( + id: string + ): Promise<{ summary: SpaceContentTypeSummaryItem[]; total: number }> { + return this.http.get(`/internal/spaces/${id}/content_summary`); + } + + public getRolesForSpace(id: string): Promise { + return this.http.get(`/internal/security/roles/${id}`); + } } diff --git a/x-pack/plugins/spaces/public/types.ts b/x-pack/plugins/spaces/public/types.ts index b5540df1fdd5a..874ed9e8fd7d3 100644 --- a/x-pack/plugins/spaces/public/types.ts +++ b/x-pack/plugins/spaces/public/types.ts @@ -66,3 +66,14 @@ export interface SpacesApi { */ isSolutionViewEnabled: boolean; } + +/** + * The API for retrieving content associated with a space returns an array of summary data for each type of + * saved object content. SpaceContentTypeSummaryItem is the format of the items included in this summary data. + */ +export interface SpaceContentTypeSummaryItem { + displayName: string; + icon?: string; + count: number; + type: string; // the type of saved object content (dashboard, search, config, etc) +} diff --git a/x-pack/plugins/spaces/tsconfig.json b/x-pack/plugins/spaces/tsconfig.json index ba13f984ef66d..20d3f7d3175d8 100644 --- a/x-pack/plugins/spaces/tsconfig.json +++ b/x-pack/plugins/spaces/tsconfig.json @@ -37,7 +37,21 @@ "@kbn/utility-types-jest", "@kbn/security-plugin-types-public", "@kbn/cloud-plugin", - "@kbn/core-analytics-browser" + "@kbn/core-analytics-browser", + "@kbn/core-analytics-browser", + "@kbn/security-plugin-types-common", + "@kbn/core-application-browser", + "@kbn/unsaved-changes-prompt", + "@kbn/core-lifecycle-browser", + "@kbn/security-role-management-model", + "@kbn/security-ui-components", + "@kbn/react-kibana-mount", + "@kbn/shared-ux-utility", + "@kbn/core-application-common", + "@kbn/security-authorization-core", + "@kbn/core-notifications-browser", + "@kbn/logging", + "@kbn/core-logging-browser-mocks", ], "exclude": [ "target/**/*", diff --git a/x-pack/test/functional/apps/spaces/create_edit_space.ts b/x-pack/test/functional/apps/spaces/create_edit_space.ts deleted file mode 100644 index cfffc752cca0c..0000000000000 --- a/x-pack/test/functional/apps/spaces/create_edit_space.ts +++ /dev/null @@ -1,36 +0,0 @@ -/* - * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one - * or more contributor license agreements. Licensed under the Elastic License - * 2.0; you may not use this file except in compliance with the Elastic License - * 2.0. - */ - -import { FtrProviderContext } from '../../ftr_provider_context'; - -export default function ({ getPageObjects, getService }: FtrProviderContext) { - const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['common', 'settings', 'security', 'spaceSelector']); - const testSubjects = getService('testSubjects'); - - describe('edit space', () => { - before(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - }); - - after(async () => { - await kibanaServer.savedObjects.cleanStandardList(); - }); - - describe('solution view', () => { - it('does not show solution view panel', async () => { - await PageObjects.common.navigateToUrl('management', 'kibana/spaces/edit/default', { - shouldUseHashForSubUrl: false, - }); - - await testSubjects.existOrFail('spaces-edit-page'); - await testSubjects.existOrFail('spaces-edit-page > generalPanel'); - await testSubjects.missingOrFail('spaces-edit-page > navigationPanel'); - }); - }); - }); -} diff --git a/x-pack/test/functional/apps/spaces/create_edit_space/acme_logo.png b/x-pack/test/functional/apps/spaces/create_edit_space/acme_logo.png new file mode 100644 index 0000000000000000000000000000000000000000..8e8ed078b8b614775ab0aebcb4d7731a9b5b242a GIT binary patch literal 3243 zcmeH}_ct2~8^C4rQY>97+4j;~|w8~|)U5IYB$lZ*Ql4=*3T zfZ!iOr-emC#l$5f&zzN#J|`o4UQS*?5u&85q6$@0*MMolwX}6~_4NO|U|?uuY+`D5 z(Hw!aKv|-#tZi)VE@AAk4miA{le5cZS2u#Yho_gfk1z3xpMOALP;kiA(6DRa5!WOC zii*Awb2BzBJ|Qvb@8nyzQ&Q8?GcvR8Was4G&AXRhKq@LO`KR=L*@N2s%vV= zl)A@Ho<6I8-q6_eqWR^kme#iR*Bw+^XBYiVcMqetuYcg};LyAG!^{sOqhsR}lOLz1 zXJ+T-KP@aSeg3k%vby&5+xPX2%^zFaJG(zwd%yM%4v&rrOAGH$NU(WZqY%eTK$imm zz=cI4%y8lo-#Zgx-*EsLKUh)J;BSXYHjv5BQK$8)V9L~R$XCWL{axQ(b>?9j-l=A_ z(4mmDsHC#rLYMqCShcb+VUezCLg{Ac3^^8`BfF7A;FG)fmG+K|#|ZKDt5Nk>w6>yVDF>(}>1DDo>mca3?6G${o?O}zJ%$=Sfjx$I6o6Bf2irIS~bzXF9m7)otqnLm6@c}olc_eq|KWrP$1WcVA?Uy*g4W5D+4T`Y^;ZF}g zM#`bS8qRyZQvdSNyWF4UV#634l*XZ&zgE9KDBv+))k}pLk~3Y&HN^l#Agqg|yiqm3jo;@t({41f^fPunu+>DAZixZ+U$0f;Ns#Q03^GmOPs?8^( z>QI*V06IMP7{Tw0e?Um~QQtfmBBXQe^So>0njwxu9unhX87S@s6Zzm{ zb`WgQtFa?9x@#{XwuFw6RPu*9_-X9Lpi8*3?mjt^3=9h{GAGHLi-jmJ1D!&151aqDx*NR;DxFJmJ!8?M9NKosW{Mp$Mj0lbNp0QZ85?RcmQT918T%zGrm z+on55U;apR;=F?{>>?B6*=947$d|W}S^w5S;nEDs5fi+Z#87|K_aqM%JQy< zu3}WFJJ>?SG?&Lt)mwn1-p~zH)t0m*c>t>s0e@`X0SP z9SB!Y16{?JSDF@SbJLxF>9E0dz|j`*Q9f3=?ws*8rjT-6>y}aqek&8)tSnumO8s6m z6g8qsgK>lj(Wk%I=f>;Lh7Gn=39Uwh?4xV zisbk|_CEH(fJG4V--|_Vbr$Xl)U*!emZJ7XbA?EdZhsR<974y=^GOILk>NVWka*M> z)!6Un;mBj5;MCB9)l)LoDf;EL9lSJG{CG7wj^Bv3Q%l-DcD0RP2XA@t}tB2HK0cSh63&8 zgKD4_PSO8NSaayO8H@i8wPp88xxNLl1b84E$)%6=i1%ts%$qs}K(Q^NCTzdP`vmh& zBM=)|iw)J`u6^&OOQaEZNbD@WrIh9zI3YRxzdYFHdVuEo$Z~ zMjGGxbT14aEk+f_57%%_-KY4r3M6yVe}XB3B{?A-=J;L1Y~mx8GoQOiq7_yto<9b| zl`xGV&iqCRLAQd{QWEChGzZ1J6r8+Z*gl-RS~G=D&Rz9;euLi+dWmp^G{y^W%;lD} zsy7N!Mbz+(S3>QRH?(4LQRNTRp~--<5LW8SzTS`^ZPZb0xDTYiUH^)uE;WTCy=QwC zY?R!614uth7%`+7G?`63)$7(4aKPVjjcOzNY}fIF7;9f2cCi3YWn0T@gK0Akg@SRC zN>VjE{&8G%c>;6rwm1nvQKa+0s%k}J?stbRxu*hOpy}GE7vXeG8-vtTa)Wv%LEP{X zg(Uf`Cp>VqUbhpjvwqNJ3^kd3csatbG{@t|r?)elmll=@9}$Ie@YS=UAsM5e19R*9 zP{bOso;AOKf_jma*(b+ubx#h|JD%qz>bR~y3?9K zpN(=w8o!o{xGEot6`%$c;ExS)-^Ighv&l=4-1zR1Pf$ULoc4+^J2 z(K6=P^GE}pV%e!=>2?^ZL@vUC?R0*ZT*IG6d9AtX%Eihh=CfWOJd0L$*TcCBkd4RFM9s{FPIIBVE_OC literal 0 HcmV?d00001 diff --git a/x-pack/test/functional/apps/spaces/create_edit_space/create_edit_space.ts b/x-pack/test/functional/apps/spaces/create_edit_space/create_edit_space.ts new file mode 100644 index 0000000000000..4b100595a38c6 --- /dev/null +++ b/x-pack/test/functional/apps/spaces/create_edit_space/create_edit_space.ts @@ -0,0 +1,117 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import expect from '@kbn/expect'; +import { faker } from '@faker-js/faker'; +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function ({ getPageObjects, getService }: FtrProviderContext) { + const kibanaServer = getService('kibanaServer'); + const PageObjects = getPageObjects(['common', 'settings', 'security', 'spaceSelector']); + const testSubjects = getService('testSubjects'); + const spacesServices = getService('spaces'); + const log = getService('log'); + + describe('Spaces Management: Create and Edit', () => { + before(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + after(async () => { + await kibanaServer.savedObjects.cleanStandardList(); + }); + + describe('create space', () => { + const spaceName = `${faker.word.adjective()} space`; + const spaceId = spaceName.replace(' ', '-'); + + before(async () => { + await PageObjects.common.navigateToApp('spacesManagement'); + await testSubjects.existOrFail('spaces-grid-page'); + + await PageObjects.spaceSelector.clickCreateSpace(); + await testSubjects.existOrFail('spaces-create-page'); + }); + + after(async () => { + await spacesServices.delete(spaceId); + }); + + it('create a space with a given name', async () => { + await PageObjects.spaceSelector.addSpaceName(spaceName); + await PageObjects.spaceSelector.clickSaveSpaceCreation(); + await testSubjects.existOrFail(`spacesListTableRow-${spaceId}`); + }); + }); + + describe('edit space', () => { + const spaceName = `${faker.word.adjective()} space`; + const spaceId = spaceName.replace(' ', '-'); + + before(async () => { + log.debug(`Creating space named "${spaceName}" with ID "${spaceId}"`); + + await spacesServices.create({ + id: spaceId, + name: spaceName, + disabledFeatures: [], + color: '#AABBCC', + }); + + await PageObjects.common.navigateToApp('spacesManagement'); + await testSubjects.existOrFail('spaces-grid-page'); + }); + + after(async () => { + await spacesServices.delete(spaceId); + }); + + it('allows changing space initials', async () => { + const spaceInitials = faker.string.alpha(2); + + await testSubjects.click(`${spaceId}-hyperlink`); + await testSubjects.existOrFail('spaces-view-page > generalPanel'); + + await testSubjects.setValue('spaceLetterInitial', spaceInitials); + await testSubjects.click('save-space-button'); + + await testSubjects.existOrFail('spaces-grid-page'); // wait for grid page to reload + await testSubjects.existOrFail(`space-avatar-${spaceId}`); + expect(await testSubjects.getVisibleText(`space-avatar-${spaceId}`)).to.be(spaceInitials); + }); + + it('allows changing space avatar', async () => { + await testSubjects.click(`${spaceId}-hyperlink`); + await testSubjects.existOrFail('spaces-view-page > generalPanel'); + + await testSubjects.click('image'); + + const avatarPath = require.resolve('./acme_logo.png'); + log.debug(`Importing file '${avatarPath}' ...`); + await PageObjects.common.setFileInputPath(avatarPath); + + await testSubjects.click('save-space-button'); + await testSubjects.existOrFail('spaces-grid-page'); // wait for grid page to reload + await testSubjects.existOrFail(`space-avatar-${spaceId}`); + const avatarEl = await testSubjects.find(`space-avatar-${spaceId}`); + expect(await avatarEl.getAttribute('role')).to.be('img'); // expect that the space uses image avatar + }); + }); + + describe('solution view', () => { + it('does not show solution view panel', async () => { + await PageObjects.common.navigateToUrl('management', 'kibana/spaces/edit/default', { + shouldUseHashForSubUrl: false, + }); + + await testSubjects.existOrFail('spaces-view-page'); + await testSubjects.existOrFail('spaces-view-page > generalPanel'); + await testSubjects.missingOrFail('spaces-view-page > navigationPanel'); // xpack.spaces.allowSolutionVisibility is not enabled, so the solution view picker should not appear + }); + }); + }); +} diff --git a/x-pack/test/functional/apps/spaces/create_edit_space/index.ts b/x-pack/test/functional/apps/spaces/create_edit_space/index.ts new file mode 100644 index 0000000000000..dc96179e1cb7c --- /dev/null +++ b/x-pack/test/functional/apps/spaces/create_edit_space/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one + * or more contributor license agreements. Licensed under the Elastic License + * 2.0; you may not use this file except in compliance with the Elastic License + * 2.0. + */ + +import { FtrProviderContext } from '../../../ftr_provider_context'; + +export default function spacesApp({ loadTestFile }: FtrProviderContext) { + describe('Spaces app', function spacesAppTestSuite() { + loadTestFile(require.resolve('./create_edit_space')); + }); +} diff --git a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts index be03af7c896a0..66d5eb280d613 100644 --- a/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts +++ b/x-pack/test/functional/apps/spaces/feature_controls/spaces_security.ts @@ -92,7 +92,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await testSubjects.existOrFail('spaces-edit-page'); + await testSubjects.existOrFail('spaces-create-page'); }); it(`can navigate to edit space page`, async () => { @@ -102,7 +102,7 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await testSubjects.existOrFail('spaces-edit-page'); + await testSubjects.existOrFail('spaces-view-page'); }); }); diff --git a/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/create_edit_space.ts b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/create_edit_space.ts index 3f00dda32c878..f6f69ada3c0c1 100644 --- a/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/create_edit_space.ts +++ b/x-pack/test/functional/apps/spaces/solution_view_flag_enabled/create_edit_space.ts @@ -28,9 +28,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await testSubjects.existOrFail('spaces-edit-page'); - await testSubjects.existOrFail('spaces-edit-page > generalPanel'); - await testSubjects.existOrFail('spaces-edit-page > navigationPanel'); + await testSubjects.existOrFail('spaces-view-page'); + await testSubjects.existOrFail('spaces-view-page > generalPanel'); + await testSubjects.existOrFail('spaces-view-page > navigationPanel'); }); it('changes the space solution and updates the side navigation', async () => { @@ -58,9 +58,9 @@ export default function ({ getPageObjects, getService }: FtrProviderContext) { shouldUseHashForSubUrl: false, }); - await testSubjects.missingOrFail('userImpactWarning'); + await testSubjects.missingOrFail('space-edit-page-user-impact-warning'); await PageObjects.spaceSelector.changeSolutionView('classic'); - await testSubjects.existOrFail('userImpactWarning'); // Warn that the change will impact other users + await testSubjects.existOrFail('space-edit-page-user-impact-warning'); // Warn that the change will impact other users await PageObjects.spaceSelector.clickSaveSpaceCreation(); await PageObjects.spaceSelector.confirmModal(); diff --git a/x-pack/test/functional/apps/spaces/spaces_grid.ts b/x-pack/test/functional/apps/spaces/spaces_grid.ts index 62363802db98a..bcb04f45b87cb 100644 --- a/x-pack/test/functional/apps/spaces/spaces_grid.ts +++ b/x-pack/test/functional/apps/spaces/spaces_grid.ts @@ -5,43 +5,120 @@ * 2.0. */ -import { FtrProviderContext } from '../../ftr_provider_context'; +import crypto from 'crypto'; +import expect from '@kbn/expect'; +import { type FtrProviderContext } from '../../ftr_provider_context'; -export default function enterSpaceFunctionalTests({ +export default function spaceDetailsViewFunctionalTests({ getService, getPageObjects, }: FtrProviderContext) { - const kibanaServer = getService('kibanaServer'); - const PageObjects = getPageObjects(['security', 'spaceSelector', 'common']); + const PageObjects = getPageObjects(['common', 'settings', 'spaceSelector']); const spacesService = getService('spaces'); const testSubjects = getService('testSubjects'); + const retry = getService('retry'); - const anotherSpace = { - id: 'space2', - name: 'space2', - disabledFeatures: [], - }; + const testSpacesIds = [ + 'odyssey', + // this number is chosen intentionally to not exceed the default 10 items displayed by spaces table + ...Array.from(new Array(5)).map((_) => `space-${crypto.randomUUID()}`), + ]; - describe('Spaces grid', function () { + describe('Spaces Management: List of Spaces', function () { before(async () => { - await spacesService.create(anotherSpace); + for (const testSpaceId of testSpacesIds) { + await spacesService.create({ id: testSpaceId, name: `${testSpaceId}-name` }); + } + + await PageObjects.settings.navigateTo(); + await testSubjects.existOrFail('spaces'); + }); + + beforeEach(async () => { + await PageObjects.common.navigateToUrl('management', 'kibana/spaces', { + ensureCurrentUrl: false, + shouldLoginIfPrompted: false, + shouldUseHashForSubUrl: false, + }); - await PageObjects.common.navigateToApp('spacesManagement'); await testSubjects.existOrFail('spaces-grid-page'); }); after(async () => { - await spacesService.delete('another-space'); - await kibanaServer.savedObjects.cleanStandardList(); + for (const testSpaceId of testSpacesIds) { + await spacesService.delete(testSpaceId); + } + }); + + it('should list all the spaces populated', async () => { + const renderedSpaceRow = await testSubjects.findAll('*spacesListTableRow-'); + + expect(renderedSpaceRow.length).to.equal(testSpacesIds.length + 1); }); - it('can switch to a space from the row in the grid', async () => { - // use the "current" badge confirm that Default is the current space - await testSubjects.existOrFail('spacesListCurrentBadge-default'); - // click the switch button of "another space" - await PageObjects.spaceSelector.clickSwitchSpaceButton('space2'); - // use the "current" badge confirm that "Another Space" is now the current space - await testSubjects.existOrFail('spacesListCurrentBadge-space2'); + it('does not display the space switcher button when viewing the details page for the current selected space', async () => { + const currentSpaceTitle = ( + await PageObjects.spaceSelector.currentSelectedSpaceTitle() + )?.toLowerCase(); + + expect(currentSpaceTitle).to.equal('default'); + + await testSubjects.click('default-hyperlink'); + await testSubjects.existOrFail('space-view-page-details-header'); + expect( + (await testSubjects.getVisibleText('space-view-page-details-header')) + .toLowerCase() + .includes('default') + ).to.be(true); + await testSubjects.missingOrFail('spaces-view-page-switcher-button'); + }); + + it("displays the space switcher button when viewing the details page of the space that's not the current selected one", async () => { + const testSpaceId = testSpacesIds[Math.floor(Math.random() * testSpacesIds.length)]; + + const currentSpaceTitle = ( + await PageObjects.spaceSelector.currentSelectedSpaceTitle() + )?.toLowerCase(); + + expect(currentSpaceTitle).to.equal('default'); + + await testSubjects.click(`${testSpaceId}-hyperlink`); + await testSubjects.existOrFail('space-view-page-details-header'); + expect( + (await testSubjects.getVisibleText('space-view-page-details-header')) + .toLowerCase() + .includes(`${testSpaceId}-name`) + ).to.be(true); + await testSubjects.existOrFail('spaces-view-page-switcher-button'); + }); + + it('switches to a new space using the space switcher button', async () => { + const currentSpaceTitle = ( + await PageObjects.spaceSelector.currentSelectedSpaceTitle() + )?.toLowerCase(); + + expect(currentSpaceTitle).to.equal('default'); + + const testSpaceId = testSpacesIds[Math.floor(Math.random() * testSpacesIds.length)]; + + await testSubjects.click(`${testSpaceId}-hyperlink`); + await testSubjects.click('spaces-view-page-switcher-button'); + + await retry.try(async () => { + const detailsTitle = ( + await testSubjects.getVisibleText('space-view-page-details-header') + ).toLowerCase(); + + const currentSwitchSpaceTitle = ( + await PageObjects.spaceSelector.currentSelectedSpaceTitle() + )?.toLocaleLowerCase(); + + return ( + currentSwitchSpaceTitle && + currentSwitchSpaceTitle === `${testSpaceId}-name` && + detailsTitle.includes(currentSwitchSpaceTitle) + ); + }); }); }); } diff --git a/x-pack/test/functional/page_objects/space_selector_page.ts b/x-pack/test/functional/page_objects/space_selector_page.ts index 5dce6ed2d7c94..e5afbd78fe767 100644 --- a/x-pack/test/functional/page_objects/space_selector_page.ts +++ b/x-pack/test/functional/page_objects/space_selector_page.ts @@ -288,4 +288,9 @@ export class SpaceSelectorPageObject extends FtrService { ); expect(await msgElem.getVisibleText()).to.be('no spaces found'); } + + async currentSelectedSpaceTitle() { + const spacesNavSelector = await this.testSubjects.find('spacesNavSelector'); + return spacesNavSelector.getAttribute('title'); + } }