mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 17:28:26 -04:00
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:
parent
cd5ff16dfd
commit
fb9700caa0
76 changed files with 5195 additions and 464 deletions
|
@ -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';
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -5,4 +5,9 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export type { RolePutPayload, RolesAPIClient } from './roles_api_client';
|
||||
export type {
|
||||
BulkUpdatePayload,
|
||||
BulkUpdateRoleResponse,
|
||||
RolePutPayload,
|
||||
RolesAPIClient,
|
||||
} from './roles_api_client';
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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}>
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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(),
|
||||
|
|
|
@ -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),
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -11,5 +11,6 @@ export const rolesAPIClientMock = {
|
|||
getRole: jest.fn(),
|
||||
deleteRole: jest.fn(),
|
||||
saveRole: jest.fn(),
|
||||
bulkUpdateRoles: jest.fn(),
|
||||
}),
|
||||
};
|
||||
|
|
|
@ -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: [],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 = (
|
||||
|
|
|
@ -137,6 +137,7 @@ describe('Security Plugin', () => {
|
|||
"getAll": [Function],
|
||||
},
|
||||
"roles": Object {
|
||||
"bulkUpdateRoles": [Function],
|
||||
"deleteRole": [Function],
|
||||
"getRole": [Function],
|
||||
"getRoles": [Function],
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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);
|
||||
};
|
||||
}
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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}
|
|
@ -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,
|
||||
});
|
|
@ -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';
|
|
@ -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';
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
}
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
||||
};
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
|
@ -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,
|
||||
});
|
||||
};
|
|
@ -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];
|
||||
};
|
|
@ -5,4 +5,4 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
export { ManageSpacePage } from './manage_space_page';
|
||||
export { EditSpacePage } from './edit_space_page';
|
||||
|
|
|
@ -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.')
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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;
|
||||
};
|
|
@ -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';
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
};
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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 can’t edit the access of reserved roles to this space.` }
|
||||
)
|
||||
: i18n.translate(
|
||||
'xpack.spaces.management.spaceDetails.rolesTable.column.actions.notEditableDescription.isAssignedToAll',
|
||||
{
|
||||
defaultMessage: `Can't perform actions on a role that is assigned to all spaces`,
|
||||
}
|
||||
),
|
||||
isPrimary: true,
|
||||
enabled: () => false,
|
||||
available: (rowRecord) => !isEditableRole(rowRecord),
|
||||
},
|
||||
{
|
||||
type: 'icon',
|
||||
icon: 'pencil',
|
||||
'data-test-subj': 'spaceRoleCellEditAction',
|
||||
name: i18n.translate(
|
||||
'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.title',
|
||||
{ defaultMessage: 'Remove from space' }
|
||||
),
|
||||
isPrimary: true,
|
||||
description: i18n.translate(
|
||||
'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.description',
|
||||
{
|
||||
defaultMessage:
|
||||
'Click this action to edit the role privileges of this user for this space.',
|
||||
}
|
||||
),
|
||||
showOnHover: true,
|
||||
available: (rowRecord) => isEditableRole(rowRecord),
|
||||
onClick: onClickRowEditAction,
|
||||
},
|
||||
{
|
||||
isPrimary: true,
|
||||
type: 'icon',
|
||||
icon: 'trash',
|
||||
color: 'danger',
|
||||
'data-test-subj': 'spaceRoleCellDeleteAction',
|
||||
name: i18n.translate(
|
||||
'xpack.spaces.management.spaceDetails.rolesTable.column.actions.remove.title',
|
||||
{ defaultMessage: 'Remove from space' }
|
||||
),
|
||||
description: i18n.translate(
|
||||
'xpack.spaces.management.spaceDetails.rolesTable.column.actions.edit.description',
|
||||
{ defaultMessage: 'Click this action to remove the user from this space.' }
|
||||
),
|
||||
showOnHover: true,
|
||||
available: (rowRecord) => isEditableRole(rowRecord),
|
||||
onClick: onClickRowRemoveAction,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return columns;
|
||||
};
|
||||
|
||||
const getRowProps = (item: Role) => {
|
||||
const { name } = item;
|
||||
return {
|
||||
'data-test-subj': `space-role-row-${name}`,
|
||||
onClick: () => {},
|
||||
};
|
||||
};
|
||||
|
||||
const getCellProps = (item: Role, column: EuiTableFieldDataColumnType<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>
|
||||
);
|
||||
};
|
|
@ -8,3 +8,5 @@
|
|||
export { toSpaceIdentifier, isValidSpaceIdentifier } from './space_identifier_utils';
|
||||
|
||||
export { SpaceValidator } from './validate_space';
|
||||
|
||||
export { sortRolesForListing } from './sort_roles';
|
||||
|
|
171
x-pack/plugins/spaces/public/management/lib/sort_roles.test.ts
Normal file
171
x-pack/plugins/spaces/public/management/lib/sort_roles.test.ts
Normal 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);
|
||||
});
|
||||
});
|
28
x-pack/plugins/spaces/public/management/lib/sort_roles.ts
Normal file
28
x-pack/plugins/spaces/public/management/lib/sort_roles.ts
Normal 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);
|
||||
}
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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());
|
|
@ -13,6 +13,7 @@ export const createRolesAPIClientMock = (): RolesAPIClient => {
|
|||
getRole: jest.fn(),
|
||||
saveRole: jest.fn(),
|
||||
deleteRole: jest.fn(),
|
||||
bulkUpdateRoles: jest.fn(),
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
@ -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 },
|
||||
}
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
`);
|
||||
|
|
|
@ -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>
|
||||
|
|
18
x-pack/plugins/spaces/public/management/types.ts
Normal file
18
x-pack/plugins/spaces/public/management/types.ts
Normal 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;
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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>;
|
||||
}
|
||||
|
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
|
|
|
@ -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/**/*",
|
||||
|
|
|
@ -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 |
|
@ -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
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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'));
|
||||
});
|
||||
}
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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)
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
|
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue