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 <eyo.eyo@elastic.co>
Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
Co-authored-by: Aleh Zasypkin <aleh.zasypkin@elastic.co>
This commit is contained in:
Tim Sullivan 2024-09-23 12:13:56 -07:00 committed by GitHub
parent cd5ff16dfd
commit fb9700caa0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
76 changed files with 5195 additions and 464 deletions

View file

@ -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';

View file

@ -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<RawKibanaPrivileges>;
}

View file

@ -5,4 +5,9 @@
* 2.0.
*/
export type { RolePutPayload, RolesAPIClient } from './roles_api_client';
export type {
BulkUpdatePayload,
BulkUpdateRoleResponse,
RolePutPayload,
RolesAPIClient,
} from './roles_api_client';

View file

@ -11,9 +11,20 @@ export interface RolePutPayload {
createOnly?: boolean;
}
export interface BulkUpdatePayload {
rolesUpdate: Role[];
}
export interface BulkUpdateRoleResponse {
created?: string[];
updated?: string[];
errors?: Record<string, { type: string; reason: string }>;
}
export interface RolesAPIClient {
getRoles: () => Promise<Role[]>;
getRole: (roleName: string) => Promise<Role>;
deleteRole: (roleName: string) => Promise<void>;
saveRole: (payload: RolePutPayload) => Promise<void>;
bulkUpdateRoles: (payload: BulkUpdatePayload) => Promise<BulkUpdateRoleResponse>;
}

View file

@ -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 => {

View file

@ -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<Props, State> {
public static defaultProps = {
privilegeIndex: -1,
showLocks: true,
showTitle: true,
};
private featureCategories: Map<string, SecuredFeature[]> = new Map();
@ -187,16 +192,18 @@ export class FeatureTable extends Component<Props, State> {
<div>
<EuiFlexGroup alignItems={'flexEnd'}>
<EuiFlexItem>
<EuiText size="xs">
<b>
{i18n.translate(
'xpack.security.management.editRole.featureTable.featureVisibilityTitle',
{
defaultMessage: 'Customize feature privileges',
}
)}
</b>
</EuiText>
{this.props.showTitle && (
<EuiText size="xs">
<b>
{i18n.translate(
'xpack.security.management.editRole.featureTable.featureVisibilityTitle',
{
defaultMessage: 'Customize feature privileges',
}
)}
</b>
</EuiText>
)}
</EuiFlexItem>
{!this.props.disabled && (
<EuiFlexItem grow={false}>

View file

@ -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 => {

View file

@ -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 {

View file

@ -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(),

View file

@ -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),

View file

@ -14,6 +14,7 @@ import {
EuiFlexItem,
EuiForm,
EuiFormRow,
EuiIconTip,
EuiPanel,
EuiSpacer,
EuiText,
@ -556,30 +557,27 @@ export const EditRolePage: FunctionComponent<Props> = ({
const getElasticsearchPrivileges = () => {
return (
<div>
<EuiSpacer />
<ElasticsearchPrivileges
role={role}
editable={!isRoleReadOnly}
indicesAPIClient={indicesAPIClient}
onChange={onRoleChange}
runAsUsers={runAsUsers}
validator={validator}
indexPatterns={indexPatternsTitles}
remoteClusters={remoteClustersState.value}
builtinESPrivileges={builtInESPrivileges}
license={license}
docLinks={docLinks}
canUseRemoteIndices={
buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteIndices
}
canUseRemoteClusters={
buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteClusters
}
isDarkMode={isDarkMode}
buildFlavor={buildFlavor}
/>
</div>
<ElasticsearchPrivileges
role={role}
editable={!isRoleReadOnly}
indicesAPIClient={indicesAPIClient}
onChange={onRoleChange}
runAsUsers={runAsUsers}
validator={validator}
indexPatterns={indexPatternsTitles}
remoteClusters={remoteClustersState.value}
builtinESPrivileges={builtInESPrivileges}
license={license}
docLinks={docLinks}
canUseRemoteIndices={
buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteIndices
}
canUseRemoteClusters={
buildFlavor === 'traditional' && featureCheckState.value?.canUseRemoteClusters
}
isDarkMode={isDarkMode}
buildFlavor={buildFlavor}
/>
);
};
@ -587,21 +585,18 @@ export const EditRolePage: FunctionComponent<Props> = ({
const getKibanaPrivileges = () => {
return (
<div>
<EuiSpacer />
<KibanaPrivilegesRegion
kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges, features)}
spaces={spaces.list}
spacesEnabled={spaces.enabled}
uiCapabilities={uiCapabilities}
canCustomizeSubFeaturePrivileges={license.getFeatures().allowSubFeaturePrivileges}
editable={!isRoleReadOnly}
role={role}
onChange={onRoleChange}
validator={validator}
spacesApiUi={spacesApiUi}
/>
</div>
<KibanaPrivilegesRegion
kibanaPrivileges={new KibanaPrivileges(kibanaPrivileges, features)}
spaces={spaces.list}
spacesEnabled={spaces.enabled}
uiCapabilities={uiCapabilities}
canCustomizeSubFeaturePrivileges={license.getFeatures().allowSubFeaturePrivileges}
editable={!isRoleReadOnly}
role={role}
onChange={onRoleChange}
validator={validator}
spacesApiUi={spacesApiUi}
/>
);
};
@ -800,44 +795,89 @@ export const EditRolePage: FunctionComponent<Props> = ({
return (
<div className="editRolePage">
<EuiForm {...formError}>
{getFormTitle()}
<EuiSpacer />
<EuiText size="s">
<FormattedMessage
id="xpack.security.management.editRole.setPrivilegesToKibanaSpacesDescription"
defaultMessage="Set privileges on your Elasticsearch data and control access to your Project spaces."
/>
</EuiText>
{isRoleReserved && (
<Fragment>
<EuiSpacer size="s" />
<EuiText size="s" color="subdued">
<p id="reservedRoleDescription" tabIndex={0}>
<FormattedMessage
id="xpack.security.management.editRole.modifyingReversedRolesDescription"
defaultMessage="Reserved roles are built-in and cannot be removed or modified."
/>
</p>
<EuiForm {...formError} fullWidth>
<EuiFlexGroup direction="column">
<EuiFlexItem>
{getFormTitle()}
<EuiSpacer />
<EuiText size="s">
<FormattedMessage
id="xpack.security.management.editRole.setPrivilegesToKibanaSpacesDescription"
defaultMessage="Set privileges on your Elasticsearch data and control access to your Project spaces."
/>
</EuiText>
</Fragment>
)}
{isDeprecatedRole && (
<Fragment>
<EuiSpacer size="s" />
<EuiCallOut
title={getExtendedRoleDeprecationNotice(role)}
color="warning"
iconType="warning"
/>
</Fragment>
)}
<EuiSpacer />
{getRoleNameAndDescription()}
{getElasticsearchPrivileges()}
{getKibanaPrivileges()}
<EuiSpacer />
{getFormButtons()}
</EuiFlexItem>
<EuiFlexItem>
{isRoleReserved && (
<Fragment>
<EuiText size="s" color="subdued">
<p id="reservedRoleDescription" tabIndex={0}>
<FormattedMessage
id="xpack.security.management.editRole.modifyingReversedRolesDescription"
defaultMessage="Reserved roles are built-in and cannot be removed or modified."
/>
</p>
</EuiText>
</Fragment>
)}
</EuiFlexItem>
<EuiFlexItem>
{isDeprecatedRole && (
<Fragment>
<EuiSpacer size="s" />
<EuiCallOut
title={getExtendedRoleDeprecationNotice(role)}
color="warning"
iconType="warning"
/>
</Fragment>
)}
</EuiFlexItem>
<EuiFlexItem>{getRoleNameAndDescription()}</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<FormattedMessage
id="xpack.security.management.editRole.dataLayerLabel"
defaultMessage="Data Layer"
/>
}
>
{getElasticsearchPrivileges()}
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow
label={
<EuiFlexGroup gutterSize="s" alignItems="center" responsive={false}>
<EuiFlexItem grow={false}>
<FormattedMessage
id="xpack.security.management.editRole.appLayerLabel"
defaultMessage="Application layer"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiIconTip
type="iInCircle"
color="subdued"
content={
<FormattedMessage
id="xpack.security.management.editRole.appLayerTooltipText"
defaultMessage="Feature access is granted on a per space basis for all features. Feature visibility is set on the space. Both must be enabled for this role to use a feature"
/>
}
/>
</EuiFlexItem>
</EuiFlexGroup>
}
>
{getKibanaPrivileges()}
</EuiFormRow>
</EuiFlexItem>
<EuiFlexItem>
<EuiFormRow fullWidth={false}>{getFormButtons()}</EuiFormRow>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
</div>
);

View file

@ -107,10 +107,18 @@ export class PrivilegeSpaceForm extends Component<Props, State> {
<h2>
<FormattedMessage
id="xpack.security.management.editRole.spacePrivilegeForm.modalTitle"
defaultMessage="Kibana privileges"
defaultMessage="Assign role to space"
/>
</h2>
</EuiTitle>
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.security.management.editRole.spacePrivilegeForm.modalHeadline"
defaultMessage="This role will be granted access to the following spaces"
/>
</p>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>
<EuiErrorBoundary>{this.getForm()}</EuiErrorBoundary>

View file

@ -206,7 +206,7 @@ export class SpaceAwarePrivilegeSection extends Component<Props, State> {
>
<FormattedMessage
id="xpack.security.management.editRole.spacePrivilegeSection.addSpacePrivilegeButton"
defaultMessage="Add Kibana privilege"
defaultMessage="Assign to space"
/>
</EuiButton>
);

View file

@ -11,5 +11,6 @@ export const rolesAPIClientMock = {
getRole: jest.fn(),
deleteRole: jest.fn(),
saveRole: jest.fn(),
bulkUpdateRoles: jest.fn(),
}),
};

View file

@ -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);
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);
await rolesAPIClient.saveRole({ role });
expect(httpMock.put).toHaveBeenCalledTimes(1);
return JSON.parse((httpMock.put.mock.calls[0] as any)[1]?.body as any);
}
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: [],
};
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);
const result = await saveRole(role);
expect(result).toEqual({
elasticsearch: {
cluster: [],
indices: [],
remote_indices: [],
run_as: [],
},
kibana: [],
expect(result).toEqual({
elasticsearch: {
cluster: [],
indices: [],
remote_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: [],
};
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);
const result = await saveRole(role);
expect(result).toEqual({
elasticsearch: {
cluster: [],
indices: [{ names: ['.kibana*'], privileges: ['all'] }],
run_as: [],
},
kibana: [],
expect(result).toEqual({
elasticsearch: {
cluster: [],
indices: [{ names: ['.kibana*'], privileges: ['all'] }],
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: [],
};
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);
const result = await saveRole(role);
expect(result).toEqual({
metadata: {
someOtherMetadata: true,
},
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [],
expect(result).toEqual({
metadata: {
someOtherMetadata: true,
},
elasticsearch: {
cluster: [],
indices: [],
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' },
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('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'],
},
},
],
run_as: [],
},
kibana: [],
};
};
const result = await saveRole(role);
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' },
expect(result).toEqual({
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
spaces: ['foo'],
base: ['all'],
feature: {},
},
],
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('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'],
},
},
},
],
};
],
};
const result = await saveRole(role);
const result = await saveRole(role);
expect(result).toEqual({
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
spaces: ['foo'],
base: ['all'],
feature: {},
expect(result).toEqual({
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
],
kibana: [
{
spaces: ['foo'],
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: [],
},
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: [],
},
],
};
const result = await saveRole(role);
expect(result).toEqual({
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
spaces: ['foo'],
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'],
},
},
],
});
});
});
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'],
},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
feature2: ['write'],
},
},
],
};
describe('#bulkUpdateRoles', () => {
async function bulkUpdateRoles(roles: Role[]) {
const httpMock = httpServiceMock.createStartContract();
const rolesAPIClient = new RolesAPIClient(httpMock);
const result = await saveRole(role);
await rolesAPIClient.bulkUpdateRoles({ rolesUpdate: roles });
expect(httpMock.post).toHaveBeenCalledTimes(1);
expect(result).toEqual({
elasticsearch: {
cluster: [],
indices: [],
run_as: [],
},
kibana: [
{
spaces: ['*'],
base: [],
feature: {
feature1: ['read'],
feature2: ['write'],
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: [],
},
},
{
spaces: ['marketing'],
base: [],
feature: {
feature1: ['read'],
feature2: ['write'],
{
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: [],
},
},
});
});
});
});
});

View file

@ -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<BulkUpdateRoleResponse> => {
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 = (

View file

@ -137,6 +137,7 @@ describe('Security Plugin', () => {
"getAll": [Function],
},
"roles": Object {
"bulkUpdateRoles": [Function],
"deleteRole": [Function],
"getRole": [Function],
"getRoles": [Function],

View file

@ -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;

View file

@ -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.

View file

@ -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;

View file

@ -5,7 +5,7 @@ exports[`renders correctly 1`] = `
dataTestSubj="generalPanel"
>
<EuiDescribedFormGroup
description="Give your space a name that's memorable."
description="Give your space a meaningful name and description."
fullWidth={true}
title={
<EuiTitle

View file

@ -22,9 +22,9 @@ import { FormattedMessage } from '@kbn/i18n-react';
import { CustomizeSpaceAvatar } from './customize_space_avatar';
import { getSpaceAvatarComponent, getSpaceColor, getSpaceInitials } from '../../../space_avatar';
import type { FormValues } from '../../edit_space/manage_space_page';
import type { SpaceValidator } from '../../lib';
import { toSpaceIdentifier } from '../../lib';
import type { CustomizeSpaceFormValues } from '../../types';
import { SectionPanel } from '../section_panel';
// No need to wrap LazySpaceAvatar in an error boundary, because it is one of the first chunks loaded when opening Kibana.
@ -34,9 +34,9 @@ const LazySpaceAvatar = lazy(() =>
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<Props, State> {
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<Props, State> {
});
};
public onAvatarChange = (space: FormValues) => {
public onAvatarChange = (space: CustomizeSpaceFormValues) => {
this.props.onChange(space);
};
}

View file

@ -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<Props> {
onChange={(avatarType: string) =>
this.props.onChange({
...space,
avatarType: avatarType as FormValues['avatarType'],
avatarType: avatarType as CustomizeSpaceFormValues['avatarType'],
})
}
buttonSize="m"

View file

@ -153,7 +153,7 @@ export const SolutionView: FunctionComponent<Props> = ({
placeholder={i18n.translate(
'xpack.spaces.management.navigation.solutionViewDefaultValue',
{
defaultMessage: 'Classic (Default)',
defaultMessage: 'Select view',
}
)}
isInvalid={validator.validateSolutionView(space, isEditing).isInvalid}

View file

@ -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(
<ManageSpacePage
<CreateSpacePage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
@ -122,7 +122,7 @@ describe('ManageSpacePage', () => {
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
@ -190,7 +190,7 @@ describe('ManageSpacePage', () => {
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
@ -221,7 +221,7 @@ describe('ManageSpacePage', () => {
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
@ -252,7 +252,7 @@ describe('ManageSpacePage', () => {
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
@ -283,7 +283,7 @@ describe('ManageSpacePage', () => {
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
@ -312,7 +312,7 @@ describe('ManageSpacePage', () => {
const spacesManager = spacesManagerMock.create();
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spacesManager={spacesManager}
getFeatures={featuresStart.getFeatures}
notifications={notificationServiceMock.createStartContract()}
@ -373,7 +373,7 @@ describe('ManageSpacePage', () => {
const onLoadSpace = jest.fn();
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spaceId={'existing-space'}
spacesManager={spacesManager as unknown as SpacesManager}
onLoadSpace={onLoadSpace}
@ -451,7 +451,7 @@ describe('ManageSpacePage', () => {
const onLoadSpace = jest.fn();
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spaceId={'existing-space'}
spacesManager={spacesManager as unknown as SpacesManager}
onLoadSpace={onLoadSpace}
@ -504,7 +504,7 @@ describe('ManageSpacePage', () => {
const notifications = notificationServiceMock.createStartContract();
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={() => Promise.reject(error)}
notifications={notifications}
@ -542,7 +542,7 @@ describe('ManageSpacePage', () => {
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spaceId={'my-space'}
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}
@ -605,7 +605,7 @@ describe('ManageSpacePage', () => {
spacesManager.getActiveSpace = jest.fn().mockResolvedValue(space);
const wrapper = mountWithIntl(
<ManageSpacePage
<CreateSpacePage
spaceId={'my-space'}
spacesManager={spacesManager as unknown as SpacesManager}
getFeatures={featuresStart.getFeatures}

View file

@ -40,13 +40,7 @@ import { EnabledFeatures } from '../components/enabled_features';
import { SolutionView } from '../components/solution_view';
import { toSpaceIdentifier } from '../lib';
import { SpaceValidator } from '../lib/validate_space';
export interface FormValues extends Partial<Space> {
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<Space>;
showAlteringActiveSpaceDialog: boolean;
@ -77,7 +71,7 @@ interface State {
};
}
export class ManageSpacePage extends Component<Props, State> {
export class CreateSpacePage extends Component<Props, State> {
private readonly validator: SpaceValidator;
constructor(props: Props) {
@ -189,7 +183,7 @@ export class ManageSpacePage extends Component<Props, State> {
const { showAlteringActiveSpaceDialog } = this.state;
return (
<div data-test-subj="spaces-edit-page">
<div data-test-subj="spaces-create-page">
<CustomizeSpace
title={i18n.translate('xpack.spaces.management.manageSpacePage.generalTitle', {
defaultMessage: 'General',
@ -363,7 +357,7 @@ export class ManageSpacePage extends Component<Props, State> {
this.onSpaceChange(space);
};
public onSpaceChange = (updatedSpace: FormValues) => {
public onSpaceChange = (updatedSpace: CustomizeSpaceFormValues) => {
this.setState({
space: updatedSpace,
});

View file

@ -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';

View file

@ -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';

View file

@ -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<PageProps> = ({
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<Space | null>(null);
const [userActiveSpace, setUserActiveSpace] = useState<Space | null>(null);
const [features, setFeatures] = useState<KibanaFeature[] | null>(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 (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xxl" />
</EuiFlexItem>
</EuiFlexGroup>
);
}
const HeaderAvatar = () => {
return (
<Suspense fallback={<EuiLoadingSpinner />}>
<LazySpaceAvatar space={space} size="xl" />
</Suspense>
);
};
const { id, solution: spaceSolution } = space;
const solution = spaceSolution ?? SOLUTION_VIEW_CLASSIC;
const shouldShowSolutionBadge =
props.allowSolutionVisibility || solution !== SOLUTION_VIEW_CLASSIC;
return (
<div data-test-subj="spaces-view-page">
<EuiFlexGroup
data-test-subj="space-view-page-details-header"
alignItems="flexStart"
direction="column"
>
<EuiFlexItem grow={true} css={{ flexBasis: '100%', width: '100%' }}>
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<HeaderAvatar />
</EuiFlexItem>
<EuiFlexItem grow={true}>
<EuiFlexGroup direction="column">
<EuiFlexItem grow={true} al>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={true}>
<EuiTitle size="l">
<h1 data-test-subj="spaces-view-page-title">{space.name}</h1>
</EuiTitle>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<React.Fragment>
{userActiveSpace?.id !== id ? (
<EuiButton
iconType="push"
href={addSpaceIdToPath(
serverBasePath,
id,
`${ENTER_SPACE_PATH}?next=/app/management/kibana/spaces/edit/${id}`
)}
data-test-subj="spaces-view-page-switcher-button"
>
<FormattedMessage
id="xpack.spaces.management.spaceDetails.space.switchToSpaceButton.label"
defaultMessage="Switch to this space"
/>
</EuiButton>
) : null}
</React.Fragment>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<div>
{shouldShowSolutionBadge ? (
<SpaceSolutionBadge
solution={solution}
data-test-subj={`space-solution-badge-${solution}`}
/>
) : null}
{userActiveSpace?.id === id ? (
<EuiBadge color="primary">
<FormattedMessage
id="xpack.spaces.management.spaceDetails.space.badge.isCurrent"
description="Text for a badge shown in the Space details page when the particular Space currently active."
defaultMessage="Current"
/>
</EuiBadge>
) : null}
</div>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiText size="s">
<p>{space.description}</p>
</EuiText>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer />
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiTabs>
{tabs.map((tab, index) => (
<EuiTab
key={index}
isSelected={tab.id === selectedTabId}
append={tab.append}
{...reactRouterNavigate(history, `/edit/${encodeURIComponent(id)}/${tab.id}`)}
>
{tab.name}
</EuiTab>
))}
</EuiTabs>
</EuiFlexItem>
<EuiFlexItem>{selectedTabContent ?? null}</EuiFlexItem>
</EuiFlexGroup>
</div>
);
};

View file

@ -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 (
<IntlProvider locale="en">
<EditSpaceProvider
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
getUrlForApp={getUrlForApp}
navigateToUrl={navigateToUrl}
serverBasePath=""
spacesManager={spacesManager}
getRolesAPIClient={getRolesAPIClient}
http={http}
notifications={notifications}
overlays={overlays}
getPrivilegesAPIClient={getPrivilegeAPIClient}
theme={theme}
i18n={i18n}
logger={logger}
>
{children}
</EditSpaceProvider>
</IntlProvider>
);
};
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(
<TestComponent>
<EditSpaceContentTab space={space} />
</TestComponent>
);
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(
<TestComponent>
<EditSpaceContentTab space={space} />
</TestComponent>
);
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();
});
});
});

View file

@ -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<SpaceContentTypeSummaryItem[] | null>(null);
const columns: Array<EuiBasicTableColumn<SpaceContentTypeSummaryItem>> = [
{
field: 'type',
name: 'Type',
render: (_value: string, item: SpaceContentTypeSummaryItem) => {
const { icon, displayName } = item;
return (
<EuiFlexGroup gutterSize="m" alignItems="center">
<EuiFlexItem grow={false}>
<EuiIcon type={icon ?? 'gear'} size="m" />
</EuiFlexItem>
<EuiFlexItem grow={true}>{capitalize(displayName)}</EuiFlexItem>
</EuiFlexGroup>
);
},
},
{
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 <EuiLink href={href}>{value}</EuiLink>;
},
},
];
const getRowProps = (item: SpaceContentTypeSummaryItem) => {
const { type } = item;
return {
'data-test-subj': `space-content-row-${type}`,
onClick: () => {},
};
};
const getCellProps = (
item: SpaceContentTypeSummaryItem,
column: EuiTableFieldDataColumnType<SpaceContentTypeSummaryItem>
) => {
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 (
<EuiFlexGroup
justifyContent="spaceAround"
data-test-subj="editSpaceContentTabLoadingIndicator"
>
<EuiFlexItem grow={false}>
<EuiLoadingSpinner size="xxl" />
</EuiFlexItem>
</EuiFlexGroup>
);
}
if (!items) {
return null;
}
return (
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiText>
<FormattedMessage
id="xpack.spaces.management.editSpaceContent.heading"
defaultMessage="All Kibana objects that are associated with this space."
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<EuiBasicTable
data-test-subj="editSpaceContentTab"
tableCaption={i18n.translate(
'xpack.spaces.management.editSpaceContent.listTableCaption',
{
defaultMessage: 'List of saved object content within the space',
}
)}
items={items}
rowHeader="type"
columns={columns}
rowProps={getRowProps}
cellProps={getCellProps}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -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<Space>;
features: KibanaFeature[];
onChange: (updatedSpace: Partial<Space>) => void;
}
export const EditSpaceEnabledFeatures: FC<Props> = ({ features, space, onChange }) => {
const { capabilities, getUrlForApp } = useEditSpaceServices();
const canManageRoles = capabilities.roles?.save === true;
if (!features) {
return null;
}
return (
<SectionPanel dataTestSubj="enabled-features-panel">
<EuiFlexGroup>
<EuiFlexItem>
<EuiTitle size="xs">
<h3>
<FormattedMessage
id="xpack.spaces.management.editSpaceFeatures.featuresVisibility"
defaultMessage="Set features visibility"
/>
</h3>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText size="s" color="subdued">
<p>
<FormattedMessage
id="xpack.spaces.management.editSpaceFeatures.notASecurityMechanismMessage"
defaultMessage="Hidden features are removed from the user interface, but not disabled. To secure access to features, {manageRolesLink}."
values={{
manageRolesLink: canManageRoles ? (
<EuiLink href={getUrlForApp('management', { path: '/security/roles' })}>
<FormattedMessage
id="xpack.spaces.management.editSpaceFeatures.manageRolesLinkText"
defaultMessage="manage security roles"
/>
</EuiLink>
) : (
<FormattedMessage
id="xpack.spaces.management.editSpaceFeatures.askAnAdministratorText"
defaultMessage="ask an administrator to manage roles"
/>
),
}}
/>
</p>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<FeatureTable features={features} space={space} onChange={onChange} />
</EuiFlexItem>
</EuiFlexGroup>
</SectionPanel>
);
};

View file

@ -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 (
<IntlProvider locale="en">
<EditSpaceProvider
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
getUrlForApp={getUrlForApp}
navigateToUrl={navigateToUrl}
serverBasePath=""
spacesManager={spacesManager}
getRolesAPIClient={getRolesAPIClient}
http={http}
notifications={notifications}
overlays={overlays}
getPrivilegesAPIClient={getPrivilegeAPIClient}
theme={theme}
i18n={i18n}
logger={logger}
>
{children}
</EditSpaceProvider>
</IntlProvider>
);
};
it('should render controls for initial state of editing a space', () => {
render(
<TestComponent>
<EditSpaceSettingsTab
space={space}
history={history}
features={[]}
allowFeatureVisibility={false}
allowSolutionVisibility={false}
reloadWindow={reloadWindow}
/>
</TestComponent>
);
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(
<TestComponent>
<EditSpaceSettingsTab
space={space}
history={history}
features={[]}
allowFeatureVisibility={false}
allowSolutionVisibility={true}
reloadWindow={reloadWindow}
/>
</TestComponent>
);
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(
<TestComponent>
<EditSpaceSettingsTab
space={space}
history={history}
features={features}
allowFeatureVisibility={true}
allowSolutionVisibility={false}
reloadWindow={reloadWindow}
/>
</TestComponent>
);
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(
<TestComponent>
<EditSpaceSettingsTab
space={spaceToUpdate}
history={history}
features={[]}
allowFeatureVisibility={false}
allowSolutionVisibility={false}
reloadWindow={reloadWindow}
/>
</TestComponent>
);
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(
<TestComponent>
<EditSpaceSettingsTab
space={spaceToDelete}
history={history}
features={[]}
allowFeatureVisibility={false}
allowSolutionVisibility={false}
reloadWindow={reloadWindow}
/>
</TestComponent>
);
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(
<TestComponent>
<EditSpaceSettingsTab
space={spaceToUpdate}
history={history}
features={[]}
allowFeatureVisibility={false}
allowSolutionVisibility={false}
reloadWindow={reloadWindow}
/>
</TestComponent>
);
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(
<TestComponent>
<EditSpaceSettingsTab
space={spaceToUpdate}
history={history}
features={[]}
allowFeatureVisibility={false}
allowSolutionVisibility={true}
reloadWindow={reloadWindow}
/>
</TestComponent>
);
// 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(
<TestComponent>
<EditSpaceSettingsTab
space={spaceToUpdate}
history={history}
features={features}
allowFeatureVisibility={true}
allowSolutionVisibility={true}
reloadWindow={reloadWindow}
/>
</TestComponent>
);
// 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(
<TestComponent>
<EditSpaceSettingsTab
space={spaceToUpdate}
history={history}
features={features}
allowFeatureVisibility={true}
allowSolutionVisibility={true}
reloadWindow={reloadWindow}
/>
</TestComponent>
);
// 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);
});
});

View file

@ -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<Props> = ({ space, features, history, ...props }) => {
const imageAvatarSelected = Boolean(space.imageUrl);
const [formValues, setFormValues] = useState<CustomizeSpaceFormValues>({
...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<typeof space.solution | undefined>(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<Space>) => {
setFormValues({ ...formValues, ...updatedSpace });
setIsDirty(true);
setShowUserImpactWarning(true);
},
[formValues]
);
const onSolutionViewChange = useCallback(
(updatedSpace: Partial<Space>) => {
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<Space>);
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 && (
<ConfirmAlterActiveSpaceModal
onConfirm={() => performSave({ requiresReload: true })}
onCancel={() => {
setShowAlteringActiveSpaceDialog(false);
}}
/>
)
);
};
const doShowConfirmDeleteSpaceDialog = () => {
return (
showConfirmDeleteModal && (
<ConfirmDeleteModal
space={space}
spacesManager={spacesManager}
onCancel={() => {
setShowConfirmDeleteModal(false);
}}
onSuccess={() => {
setShowConfirmDeleteModal(false);
backToSpacesList();
}}
/>
)
);
};
// Show if user has changed disabled features
// Show if user has changed solution view
const doShowUserImpactWarning = () => {
return (
showUserImpactWarning && (
<>
<EuiSpacer />
<EuiCallOut
color="warning"
iconType="help"
title="Warning"
data-test-subj="space-edit-page-user-impact-warning"
>
{i18n.translate(
'xpack.spaces.management.spaceDetails.spaceChangesWarning.impactAllUsersInSpace',
{
defaultMessage: 'The changes made will impact all users in the space.',
}
)}
</EuiCallOut>
</>
)
);
};
const validator = new SpaceValidator();
return (
<>
{doShowAlteringActiveSpaceDialog()}
{doShowConfirmDeleteSpaceDialog()}
<CustomizeSpace
space={formValues}
onChange={onChangeSpaceSettings}
editingExistingSpace={true}
validator={validator}
/>
{props.allowSolutionVisibility && (
<>
<EuiSpacer />
<SolutionView
space={formValues}
onChange={onSolutionViewChange}
validator={validator}
isEditing={true}
/>
</>
)}
{props.allowFeatureVisibility && (solution == null || solution === SOLUTION_VIEW_CLASSIC) && (
<>
<EuiSpacer />
<EditSpaceEnabledFeatures
features={features}
space={formValues}
onChange={onChangeFeatures}
/>
</>
)}
{doShowUserImpactWarning()}
<EuiSpacer />
<EditSpaceTabFooter
isDirty={isDirty}
isLoading={isLoading}
onClickCancel={onClickCancel}
onClickSubmit={onClickSubmit}
onClickDeleteSpace={onClickDeleteSpace}
/>
</>
);
};

View file

@ -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<typeof EditSpace> & EditSpaceProviderProps;
export function EditSpacePage({
spaceId,
getFeatures,
history,
onLoadSpace,
selectedTabId,
allowFeatureVisibility,
allowSolutionVisibility,
children,
...editSpaceServicesProps
}: PropsWithChildren<EditSpacePageProps>) {
return (
<EditSpaceProvider {...editSpaceServicesProps}>
<EditSpace
spaceId={spaceId}
getFeatures={getFeatures}
history={history}
onLoadSpace={onLoadSpace}
selectedTabId={selectedTabId}
allowFeatureVisibility={allowFeatureVisibility}
allowSolutionVisibility={allowSolutionVisibility}
/>
</EditSpaceProvider>
);
}

View file

@ -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 (
<IntlProvider locale="en">
<EditSpaceProvider
capabilities={{
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
}}
getUrlForApp={getUrlForApp}
navigateToUrl={navigateToUrl}
serverBasePath=""
spacesManager={spacesManager}
getRolesAPIClient={getRolesAPIClient}
http={http}
notifications={notifications}
overlays={overlays}
getPrivilegesAPIClient={getPrivilegeAPIClient}
theme={theme}
i18n={i18n}
logger={logger}
>
{children}
</EditSpaceProvider>
</IntlProvider>
);
};
beforeEach(() => {
loadRolesSpy.mockReset();
toastErrorSpy.mockReset();
});
it('loads the assigned roles', async () => {
act(() => {
render(
<TestComponent>
<EditSpaceAssignedRolesTab space={space} isReadOnly={false} features={[]} />
</TestComponent>
);
});
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(
<TestComponent>
<EditSpaceAssignedRolesTab space={space} isReadOnly={false} features={[]} />
</TestComponent>
);
});
await waitFor(() => {
expect(loadRolesSpy).toHaveBeenCalledTimes(1);
expect(toastErrorSpy).toHaveBeenCalledWith(new Error('test error'), {
title: 'Error: test error',
});
});
});
});

View file

@ -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<Props> = ({ 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(
<EditSpaceProvider {...services}>
<PrivilegesRolesForm
{...{
space,
features,
onSaveCompleted: (response) => {
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,
}}
/>
</EditSpaceProvider>,
{ 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 (
<React.Fragment>
<EuiFlexGroup direction="column">
<EuiFlexItem>
<EuiText>
<FormattedMessage
id="xpack.spaces.management.spaceDetails.roles.heading"
defaultMessage="Assign roles to this space. Users with these roles can access and use the space according to the privileges that you set."
/>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<SpaceAssignedRolesTable
isReadOnly={isReadOnly}
currentSpace={space}
assignedRoles={state.roles}
onClickRowEditAction={(rowRecord) => showRolesPrivilegeEditor([rowRecord])}
onClickBulkRemove={async (selectedRoles) => {
await removeRole(selectedRoles);
}}
onClickRowRemoveAction={async (rowRecord) => {
await removeRole([rowRecord]);
}}
onClickAssignNewRole={async () => {
showRolesPrivilegeEditor();
}}
/>
</EuiFlexItem>
</EuiFlexGroup>
</React.Fragment>
);
};

View file

@ -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: (
<SuspenseEditSpaceSettingsTab
space={space}
features={features}
history={history}
reloadWindow={reloadWindow}
{...props}
/>
),
},
];
if (canUserViewRoles) {
tabsDefinition.push({
id: TAB_ID_ROLES,
name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.roles.heading', {
defaultMessage: 'Permissions',
}),
append: (
<EuiNotificationBadge className="eui-alignCenter" color="subdued" size="m">
{rolesCount}
</EuiNotificationBadge>
),
content: (
<SuspenseEditSpaceAssignedRolesTab
space={space}
features={features}
isReadOnly={!canUserModifyRoles}
/>
),
});
}
tabsDefinition.push({
id: TAB_ID_CONTENT,
name: i18n.translate('xpack.spaces.management.spaceDetails.contentTabs.content.heading', {
defaultMessage: 'Content',
}),
content: <SuspenseEditSpaceContentTab space={space} />,
});
return tabsDefinition;
};

View file

@ -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<Props> = ({
isDirty,
isLoading,
onClickCancel,
onClickSubmit,
onClickDeleteSpace,
}) => {
return (
<>
{isLoading && (
<EuiFlexGroup justifyContent="spaceAround">
<EuiFlexItem grow={false}>
<EuiLoadingSpinner />
</EuiFlexItem>
</EuiFlexGroup>
)}
{!isLoading && (
<EuiFlexGroup>
<EuiFlexItem grow={false}>
<EuiButtonEmpty
onClick={onClickDeleteSpace}
color="danger"
data-test-subj="delete-space-button"
>
<FormattedMessage
id="xpack.spaces.management.spaceDetails.footerActions.deleteSpace"
defaultMessage="Delete space"
/>
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={true} />
<EuiFlexItem grow={false}>
<EuiButtonEmpty onClick={onClickCancel} data-test-subj="cancel-space-button">
<FormattedMessage
id="xpack.spaces.management.spaceDetails.footerActions.cancel"
defaultMessage="Cancel"
/>
</EuiButtonEmpty>
</EuiFlexItem>
{isDirty && (
<EuiFlexItem grow={false}>
<EuiButton
color="primary"
fill
onClick={onClickSubmit}
data-test-subj="save-space-button"
>
<FormattedMessage
id="xpack.spaces.management.spaceDetails.footerActions.updateSpace"
defaultMessage="Update space"
/>
</EuiButton>
</EuiFlexItem>
)}
</EuiFlexGroup>
)}
</>
);
};

View file

@ -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,
});
};

View file

@ -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<GetTabsProps, 'capabilities' | 'rolesCount'> & {
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];
};

View file

@ -5,4 +5,4 @@
* 2.0.
*/
export { ManageSpacePage } from './manage_space_page';
export { EditSpacePage } from './edit_space_page';

View file

@ -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<Partial<Pick<ApplicationStart, 'capabilities'>>>) => {
return (
<IntlProvider locale="en">
<EditSpaceProvider
{...{
logger,
i18n,
http,
theme,
overlays,
notifications,
spacesManager,
serverBasePath: '',
getUrlForApp: (_) => _,
getRolesAPIClient: getRolesAPIClientMock,
getPrivilegesAPIClient: getPrivilegeAPIClientMock,
navigateToUrl: jest.fn(),
capabilities,
}}
>
{children}
</EditSpaceProvider>
</IntlProvider>
);
};
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.')
);
});
});
});

View file

@ -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<CoreStart, 'theme' | 'i18n' | 'overlays' | 'http' | 'notifications'> {
logger: Logger;
capabilities: ApplicationStart['capabilities'];
getUrlForApp: ApplicationStart['getUrlForApp'];
navigateToUrl: ApplicationStart['navigateToUrl'];
serverBasePath: string;
spacesManager: SpacesManager;
getRolesAPIClient: () => Promise<RolesAPIClient>;
getPrivilegesAPIClient: () => Promise<PrivilegesAPIClientPublicContract>;
}
export interface EditSpaceServices extends EditSpaceProviderProps {
invokeClient<R extends unknown>(arg: (clients: EditSpaceClients) => Promise<R>): Promise<R>;
}
interface EditSpaceClients {
spacesManager: SpacesManager;
rolesClient: RolesAPIClient;
privilegesClient: PrivilegesAPIClientPublicContract;
}
export interface EditSpaceStore {
state: IEditSpaceStoreState;
dispatch: Dispatch<IDispatchAction>;
}
const createSpaceRolesContext = once(() => createContext<EditSpaceStore | null>(null));
const createEditSpaceServicesContext = once(() => createContext<EditSpaceServices | null>(null));
export const EditSpaceProvider = ({
children,
...services
}: PropsWithChildren<EditSpaceProviderProps>) => {
const EditSpaceStoreContext = createSpaceRolesContext();
const EditSpaceServicesContext = createEditSpaceServicesContext();
const clients = useRef(
Promise.all([services.getRolesAPIClient(), services.getPrivilegesAPIClient()])
);
const rolesAPIClientRef = useRef<RolesAPIClient>();
const privilegesClientRef = useRef<PrivilegesAPIClientPublicContract>();
const initialStoreState = useRef<IEditSpaceStoreState>({
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 (
<EditSpaceServicesContext.Provider value={{ ...services, invokeClient }}>
<EditSpaceStoreContext.Provider value={{ state, dispatch }}>
{children}
</EditSpaceStoreContext.Provider>
</EditSpaceServicesContext.Provider>
);
};
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;
};

View file

@ -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';

View file

@ -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<string, Role>;
/** track if there was an error on the attempt to fetch roles **/
fetchRolesError: boolean;
}
export const createSpaceRolesReducer: Reducer<IEditSpaceStoreState, IDispatchAction> = (
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;
}
}
};

View file

@ -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(
<IntlProvider locale="en">
<EditSpaceProvider
{...{
logger,
i18n,
http,
theme,
overlays,
notifications,
spacesManager,
serverBasePath: '',
getUrlForApp: jest.fn((_) => _),
getRolesAPIClient: getRolesAPIClientMock,
getPrivilegesAPIClient: getPrivilegeAPIClientMock,
navigateToUrl: jest.fn(),
capabilities: {
navLinks: {},
management: {},
catalogue: {},
spaces: { manage: true },
},
}}
>
<PrivilegesRolesForm
{...{
space,
features: kibanaFeatures,
closeFlyout,
defaultSelected: preSelectedRoles,
onSaveCompleted,
storeDispatch: dispatchMock,
spacesClientsInvocator: spacesClientsInvocatorMock,
getUrlForApp: jest.fn((_) => _),
}}
/>
</EditSpaceProvider>
</IntlProvider>
);
};
describe('PrivilegesRolesForm', () => {
let getRolesSpy: jest.SpiedFunction<ReturnType<typeof createRolesAPIClientMock>['getRoles']>;
let getAllKibanaPrivilegeSpy: jest.SpiedFunction<
ReturnType<typeof createPrivilegeAPIClientMock>['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)
);
});
});
});

View file

@ -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<KibanaFeatureConfig['privileges']>
| 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<EuiComboBoxOptionOption<Role>> =>
roles.map((role) => ({
label: role.name,
value: role,
}));
export const PrivilegesRolesForm: FC<PrivilegesRolesFormProps> = (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<RawKibanaPrivileges | null>(null);
const [spaceUnallocatedRoles, setSpaceUnallocatedRole] = useState<Role[]>([]);
const [selectedRoles, setSelectedRoles] = useState<ReturnType<typeof createRolesComboBoxOptions>>(
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<KibanaRolePrivilege>(
!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<typeof createRolesComboBoxOptions>) => {
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 (
<EuiForm component="form" fullWidth>
<React.Fragment>
{!isEditOperation.current && (
<EuiFormRow
label={i18n.translate(
'xpack.spaces.management.spaceDetails.roles.selectRolesFormRowLabel',
{ defaultMessage: 'Select roles(s)' }
)}
labelAppend={
<EuiLink href={getUrlForApp('management', { deepLinkId: 'roles' })}>
{i18n.translate(
'xpack.spaces.management.spaceDetails.roles.selectRolesFormRowLabelAnchor',
{ defaultMessage: 'Manage roles' }
)}
</EuiLink>
}
helpText={i18n.translate(
'xpack.spaces.management.spaceDetails.roles.selectRolesHelp',
{
defaultMessage: 'Select Kibana spaces to which you wish to assign privileges.',
}
)}
>
<EuiComboBox
data-test-subj="space-assign-role-selection-combo-box"
aria-label={i18n.translate(
'xpack.spaces.management.spaceDetails.roles.selectRoles',
{
defaultMessage: 'Select role to assign to the "{spaceName}" space',
values: { spaceName: space.name },
}
)}
isLoading={fetchingDataDeps}
options={createRolesComboBoxOptions(spaceUnallocatedRoles)}
selectedOptions={selectedRoles}
onChange={(value) => setSelectedRoles(value)}
fullWidth
/>
</EuiFormRow>
)}
</React.Fragment>
<React.Fragment>
{Boolean(selectedRoles.length) && (
<React.Fragment>
<EuiFormRow>
{selectedRolesCombinedPrivileges.length > 1 ? (
<EuiCallOut
size="s"
color="warning"
iconType="iInCircle"
data-test-subj="privilege-conflict-callout"
title={i18n.translate(
'xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.title',
{
defaultMessage: 'Selected roles have different privileges granted',
}
)}
>
{i18n.translate(
'xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.description',
{
defaultMessage:
'Updating the settings here in a bulk will override current individual settings.',
}
)}
</EuiCallOut>
) : (
<EuiCallOut
size="s"
color="primary"
iconType="iInCircle"
data-test-subj="privilege-info-callout"
title={i18n.translate(
'xpack.spaces.management.spaceDetails.roles.assign.privilegeConflictMsg.title',
{
defaultMessage: 'Privileges will apply only to this space.',
}
)}
/>
)}
</EuiFormRow>
<EuiFormRow
label={i18n.translate(
'xpack.spaces.management.spaceDetails.roles.assign.privilegesLabelText',
{
defaultMessage: 'Define role privileges',
}
)}
>
<EuiButtonGroup
data-test-subj="space-assign-role-privilege-selection-switch"
legend={i18n.translate(
'xpack.spaces.management.spaceDetails.roles.assign.privilegeSelectionLegendText',
{
defaultMessage: 'select the privilege for the features enabled in this space',
}
)}
isDisabled={!Boolean(selectedRoles.length)}
options={[
{
id: FEATURE_PRIVILEGES_ALL,
label: i18n.translate(
'xpack.spaces.management.spaceDetails.roles.assign.privileges.all',
{
defaultMessage: 'All',
}
),
},
{
id: FEATURE_PRIVILEGES_READ,
label: i18n.translate(
'xpack.spaces.management.spaceDetails.roles.assign.privileges.read',
{ defaultMessage: 'Read' }
),
},
{
id: FEATURE_PRIVILEGES_CUSTOM,
label: i18n.translate(
'xpack.spaces.management.spaceDetails.roles.assign.privileges.custom',
{ defaultMessage: 'Customize' }
),
},
].map((privilege) => ({
...privilege,
'data-test-subj': `${privilege.id}-privilege-button`,
}))}
color="primary"
idSelected={roleSpacePrivilege}
onChange={(id) => onRoleSpacePrivilegeChange(id as KibanaRolePrivilege)}
isFullWidth
/>
</EuiFormRow>
{Boolean(selectedRoles.length) && (
<EuiFormRow data-test-subj="space-assign-role-privilege-customization-form">
<React.Fragment>
{!kibanaPrivileges ? (
<EuiLoadingSpinner size="l" />
) : (
<KibanaPrivilegeTable
showTitle={false}
disabled={roleSpacePrivilege !== FEATURE_PRIVILEGES_CUSTOM}
role={roleCustomizationAnchor.value!}
privilegeIndex={roleCustomizationAnchor.privilegeIndex}
onChange={(featureId, selectedPrivileges) => {
// 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}
/>
)}
</React.Fragment>
</EuiFormRow>
)}
</React.Fragment>
)}
</React.Fragment>
</EuiForm>
);
};
const getSaveButton = useCallback(() => {
return (
<EuiButton
fill
disabled={!selectedRoles.length}
isLoading={assigningToRole}
onClick={() => 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',
})}
</EuiButton>
);
}, [assignRolesToSpace, assigningToRole, selectedRoles.length]);
return (
<React.Fragment>
<EuiFlyoutHeader hasBorder>
<EuiTitle size="m">
<h2>
{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',
}
)}
</h2>
</EuiTitle>
<EuiSpacer size="s" />
<EuiText size="s">
<p>
<FormattedMessage
id="xpack.spaces.management.spaceDetails.privilegeForm.heading"
defaultMessage="Define the privileges a given role should have in this space."
/>
</p>
</EuiText>
</EuiFlyoutHeader>
<EuiFlyoutBody>{getForm()}</EuiFlyoutBody>
<EuiFlyoutFooter>
<EuiFlexGroup justifyContent="spaceBetween">
<EuiFlexItem grow={false}>
<EuiButtonEmpty
iconType="cross"
onClick={closeFlyout}
flush="left"
data-test-subj="space-assign-role-cancel-roles-privilege-button"
>
{i18n.translate('xpack.spaces.management.spaceDetails.roles.cancelRoleButton', {
defaultMessage: 'Cancel',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>{getSaveButton()}</EuiFlexItem>
</EuiFlexGroup>
</EuiFlyoutFooter>
</React.Fragment>
);
};

View file

@ -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<typeof SpaceAssignedRolesTable>,
| '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<typeof SpaceAssignedRolesTable>,
'assignedRoles' | 'isReadOnly' | 'supportsBulkAction'
>
) => {
render(
<IntlProvider locale="en">
<SpaceAssignedRolesTable {...defaultProps} {...props} />
</IntlProvider>
);
};
describe('SpaceAssignedRolesTable', () => {
const spaceAssignedRoles = new Map<string, Role>(
[
{
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();
});
});

View file

@ -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<Role['name'], Role>;
onClickAssignNewRole: () => Promise<void>;
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<EuiBasicTableColumn<Role>> = [
{
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 cant 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<Role>) => {
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<Role[]>([]);
const [selectedRoles, setSelectedRoles] = useState<Role[]>([]);
const [isBulkActionContextOpen, setBulkActionContextOpen] = useState(false);
const [pagination, setPagination] = useState<CriteriaWithPagination<Role>['page']>({
index: 0,
size: 10,
});
useEffect(() => {
const valuesFromMap = Array.from(assignedRoles.values());
const sortedRoles = valuesFromMap.sort(sortRolesForListing);
setRolesInView(sortedRoles);
}, [assignedRoles]);
const onSearchQueryChange = useCallback<NonNullable<NonNullable<EuiSearchBarProps['onChange']>>>(
({ 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<EuiSearchBarProps>(() => {
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 && (
<EuiFlexItem grow={false} color="primary">
<EuiButton iconType="plusInCircle" onClick={onClickAssignNewRole}>
{i18n.translate('xpack.spaces.management.spaceDetails.roles.assign', {
defaultMessage: 'Assign new roles',
})}
</EuiButton>
</EuiFlexItem>
)}
</>
),
};
}, [isReadOnly, onClickAssignNewRole, onSearchQueryChange]);
const tableHeader = useMemo<EuiInMemoryTableProps<Role>['childrenBetween']>(() => {
if (!supportsBulkAction) {
return null;
}
const pageSize = pagination.size;
const pageIndex = pagination.index;
const selectableRoles = rolesInView.filter((role) => isEditableRole(role) && !isReadOnly);
return (
<EuiFlexGroup direction="column" gutterSize="xs">
<EuiFlexItem>
<EuiFlexGroup justifyContent="flexStart" alignItems="center">
<EuiFlexItem grow={false}>
<EuiText size="s">
<span>
<EuiTextColor color="subdued">
{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,
},
}
)}
</EuiTextColor>
</span>
</EuiText>
</EuiFlexItem>
<React.Fragment>
{!isReadOnly && (
<React.Fragment>
<EuiFlexItem grow={false}>
<EuiPopover
isOpen={isBulkActionContextOpen}
closePopover={setBulkActionContextOpen.bind(null, false)}
anchorPosition="downCenter"
button={
<EuiButtonEmpty
size="s"
iconSide="right"
iconType="arrowDown"
disabled={!selectedRoles.length}
data-test-subj="bulkActionsContextMenuOpener"
onClick={setBulkActionContextOpen.bind(null, true)}
>
{i18n.translate(
'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.contextMenuOpener',
{ defaultMessage: 'Bulk actions' }
)}
</EuiButtonEmpty>
}
>
<EuiContextMenu
size="s"
initialPanelId={0}
panels={[
{
id: 0,
size: 's',
width: 180,
items: [
{
icon: <EuiIcon type="trash" color="danger" />,
name: (
<EuiTextColor color="danger">
{i18n.translate(
'xpack.spaces.management.spaceDetails.rolesTable.bulkActions.remove',
{ defaultMessage: 'Remove from space' }
)}
</EuiTextColor>
),
onClick: async () => {
onClickBulkRemove?.(selectedRoles);
setBulkActionContextOpen(false);
},
},
],
},
]}
/>
</EuiPopover>
</EuiFlexItem>
<EuiFlexItem grow={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 },
}
),
}),
})}
</EuiFlexItem>
</React.Fragment>
)}
</React.Fragment>
</EuiFlexGroup>
</EuiFlexItem>
<EuiFlexItem>
<EuiHorizontalRule margin="none" style={{ height: 1 }} />
</EuiFlexItem>
</EuiFlexGroup>
);
}, [
pagination.size,
pagination.index,
rolesInView,
selectedRoles,
isReadOnly,
supportsBulkAction,
isBulkActionContextOpen,
onClickBulkRemove,
]);
const onTableChange = ({ page }: CriteriaWithPagination<Role>) => {
setPagination(page);
};
const onSelectionChange = (selection: Role[]) => {
setSelectedRoles(selection);
};
const selection: EuiTableSelectionType<Role> | 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 (
<EuiFlexGroup direction="column" gutterSize="none">
<EuiFlexItem>
<EuiInMemoryTable<Role>
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}
/>
</EuiFlexItem>
</EuiFlexGroup>
);
};

View file

@ -8,3 +8,5 @@
export { toSpaceIdentifier, isValidSpaceIdentifier } from './space_identifier_utils';
export { SpaceValidator } from './validate_space';
export { sortRolesForListing } from './sort_roles';

View file

@ -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);
});
});

View file

@ -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);
}

View file

@ -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);

View file

@ -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<PluginsStart>['getStartServices'],
spacesManager: spacesManagerMock.create(),
config,
logger,
getRolesAPIClient: getRolesAPIClientMock,
getPrivilegesAPIClient: jest.fn(),
eventTracker,
@ -66,6 +69,7 @@ describe('ManagementService', () => {
.getStartServices as CoreSetup<PluginsStart>['getStartServices'],
spacesManager: spacesManagerMock.create(),
config,
logger,
getRolesAPIClient: getRolesAPIClientMock,
getPrivilegesAPIClient: jest.fn(),
eventTracker,
@ -89,6 +93,7 @@ describe('ManagementService', () => {
.getStartServices as CoreSetup<PluginsStart>['getStartServices'],
spacesManager: spacesManagerMock.create(),
config,
logger,
getRolesAPIClient: jest.fn(),
getPrivilegesAPIClient: jest.fn(),
eventTracker,

View file

@ -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<RolesAPIClient>;
eventTracker: EventTracker;
getPrivilegesAPIClient: () => Promise<PrivilegesAPIClientPublicContract>;
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,

View file

@ -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());

View file

@ -13,6 +13,7 @@ export const createRolesAPIClientMock = (): RolesAPIClient => {
getRole: jest.fn(),
saveRole: jest.fn(),
deleteRole: jest.fn(),
bulkUpdateRoles: jest.fn(),
};
};

View file

@ -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<Props, State> {
};
public getColumnConfig() {
const { activeSpace, features } = this.state;
const { solution: activeSolution } = activeSpace ?? {};
const config: Array<EuiBasicTableColumn<Space>> = [
{
field: 'initials',
@ -306,17 +313,21 @@ export class SpacesGridPage extends Component<Props, State> {
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 (
<FormattedMessage
id="xpack.spaces.management.spacesGridPage.allFeaturesEnabled"
@ -326,7 +337,7 @@ export class SpacesGridPage extends Component<Props, State> {
}
if (enabledFeatureCount === 0) {
return (
<EuiText color={'danger'}>
<EuiText color={'danger'} size="s">
<FormattedMessage
id="xpack.spaces.management.spacesGridPage.noFeaturesEnabled"
defaultMessage="No features visible"
@ -340,26 +351,27 @@ export class SpacesGridPage extends Component<Props, State> {
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<Props, State> {
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<Props, State> {
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<Props, State> {
? i18n.translate(
'xpack.spaces.management.spacesGridPage.deleteActionDisabledDescription',
{
defaultMessage: `{spaceName} is reserved`,
defaultMessage: `You can't delete the {spaceName} space`,
values: { spaceName: rowRecord.name },
}
)

View file

@ -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":{}}}
</div>
</div>
`);
@ -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}
</div>
</div>
`);

View file

@ -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<PluginsStart>;
spacesManager: SpacesManager;
config: ConfigType;
logger: Logger;
getRolesAPIClient: () => Promise<RolesAPIClient>;
eventTracker: EventTracker;
getPrivilegesAPIClient: () => Promise<PrivilegesAPIClientPublicContract>;
@ -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 (
<ManageSpacePage
<CreateSpacePage
capabilities={application.capabilities}
getFeatures={features.getFeatures}
notifications={notifications}
@ -102,29 +121,48 @@ export const spacesManagementApp = Object.freeze({
};
const EditSpacePageWithBreadcrumbs = () => {
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 (
<ManageSpacePage
<EditSpacePage
capabilities={application.capabilities}
getUrlForApp={application.getUrlForApp}
navigateToUrl={application.navigateToUrl}
serverBasePath={http.basePath.serverBasePath}
getFeatures={features.getFeatures}
http={http}
overlays={overlays}
notifications={notifications}
theme={theme}
i18n={coreStart.i18n}
logger={logger}
spacesManager={spacesManager}
spaceId={spaceId}
onLoadSpace={onLoadSpace}
history={history}
selectedTabId={selectedTabId}
getRolesAPIClient={getRolesAPIClient}
allowFeatureVisibility={config.allowFeatureVisibility}
allowSolutionVisibility={config.allowSolutionVisibility}
eventTracker={eventTracker}
getPrivilegesAPIClient={getPrivilegesAPIClient}
/>
);
};
@ -141,7 +179,7 @@ export const spacesManagementApp = Object.freeze({
<Route path="/create">
<CreateSpacePageWithBreadcrumbs />
</Route>
<Route path="/edit/:spaceId">
<Route path={['/edit/:spaceId', '/edit/:spaceId/:selectedTabId']} exact>
<EditSpacePageWithBreadcrumbs />
</Route>
</Routes>

View file

@ -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<Space> {
customIdentifier?: boolean;
avatarType?: 'initials' | 'image';
customAvatarInitials?: boolean;
customAvatarColor?: boolean;
}

View file

@ -125,6 +125,7 @@ export class SpacesPlugin implements Plugin<SpacesPluginSetup, SpacesPluginStart
getStartServices: core.getStartServices,
spacesManager: this.spacesManager,
config: this.config,
logger: this.initializerContext.logger.get(),
getRolesAPIClient,
eventTracker: this.eventTracker,
getPrivilegesAPIClient,

View file

@ -26,6 +26,8 @@ function createSpacesManagerMock() {
updateSavedObjectsSpaces: jest.fn().mockResolvedValue(undefined),
resolveCopySavedObjectsErrors: jest.fn().mockResolvedValue(undefined),
getShareSavedObjectPermissions: jest.fn().mockResolvedValue(undefined),
getContentForSpace: jest.fn().mockResolvedValue({ summary: [], total: 0 }),
getRolesForSpace: jest.fn().mockResolvedValue([]),
redirectToSpaceSelector: jest.fn().mockResolvedValue(undefined),
} as unknown as jest.Mocked<SpacesManager>;
}

View file

@ -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 });
});
});
});

View file

@ -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<Role[]> {
return this.http.get(`/internal/security/roles/${id}`);
}
}

View file

@ -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)
}

View file

@ -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/**/*",

View file

@ -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');
});
});
});
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

View file

@ -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
});
});
});
}

View file

@ -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'));
});
}

View file

@ -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');
});
});

View file

@ -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();

View file

@ -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('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('should list all the spaces populated', async () => {
const renderedSpaceRow = await testSubjects.findAll('*spacesListTableRow-');
expect(renderedSpaceRow.length).to.equal(testSpacesIds.length + 1);
});
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)
);
});
});
});
}

View file

@ -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');
}
}