mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[Security][Features] Adds subtext option to Kibana feature and sub-feature controls (#147709)
## Summary - [x] Allow plugins to configure a description subtext underneath kibana features - [x] Allow plugins to configure a description subtext underneath kibana subfeatures - [x] Adjusts subfeature form UI so privilege buttons have fullwidth, adjusts padding/margins - [x] Adds unit tests # Screen Shots <img width="752" alt="image" src="https://user-images.githubusercontent.com/56409205/211621510-83769516-4a04-4442-8d96-92f5b6708a45.png"> Privilege button group before  Privilege button group after <img width="666" alt="image" src="https://user-images.githubusercontent.com/56409205/211621622-36b7a388-f1f5-4cb4-810d-48adbf7f0155.png"> Example to test: 1. In `x-pack/plugins/security_solution/server/features.ts` before `privilegeGroups` on line 254, add `description: 'some subfeature description here'` and before `management` on line 551, add `description: 'some feature description here'`. 3. Stack Management > Roles > edit Kibana Privileges > Security > Security see descriptions show up underneath Security and underneath Endpoint List sub feature
This commit is contained in:
parent
83a1904491
commit
28ba652c3d
13 changed files with 257 additions and 31 deletions
|
@ -32,6 +32,11 @@ export interface KibanaFeatureConfig {
|
|||
*/
|
||||
name: string;
|
||||
|
||||
/**
|
||||
* An optional description that will appear as subtext underneath the feature name
|
||||
*/
|
||||
description?: string;
|
||||
|
||||
/**
|
||||
* The category for this feature.
|
||||
* This will be used to organize the list of features for display within the
|
||||
|
@ -156,6 +161,10 @@ export class KibanaFeature {
|
|||
return this.config.name;
|
||||
}
|
||||
|
||||
public get description() {
|
||||
return this.config.description;
|
||||
}
|
||||
|
||||
public get order() {
|
||||
return this.config.order;
|
||||
}
|
||||
|
|
|
@ -29,6 +29,11 @@ export interface SubFeatureConfig {
|
|||
|
||||
/** Collection of privilege groups */
|
||||
privilegeGroups: readonly SubFeaturePrivilegeGroupConfig[];
|
||||
|
||||
/**
|
||||
* An optional description that will appear as subtext underneath the sub-feature name
|
||||
*/
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
|
@ -105,6 +110,10 @@ export class SubFeature {
|
|||
return this.config.requireAllSpaces ?? false;
|
||||
}
|
||||
|
||||
public get description() {
|
||||
return this.config.description || '';
|
||||
}
|
||||
|
||||
public toRaw() {
|
||||
return { ...this.config };
|
||||
}
|
||||
|
|
|
@ -165,6 +165,7 @@ const kibanaSubFeatureSchema = schema.object({
|
|||
name: schema.string(),
|
||||
requireAllSpaces: schema.maybe(schema.boolean()),
|
||||
privilegesTooltip: schema.maybe(schema.string()),
|
||||
description: schema.maybe(schema.string()),
|
||||
privilegeGroups: schema.maybe(
|
||||
schema.arrayOf(
|
||||
schema.oneOf([
|
||||
|
@ -198,6 +199,7 @@ const kibanaFeatureSchema = schema.object({
|
|||
}),
|
||||
name: schema.string(),
|
||||
category: appCategorySchema,
|
||||
description: schema.maybe(schema.string()),
|
||||
order: schema.maybe(schema.number()),
|
||||
excludeFromBasePrivileges: schema.maybe(schema.boolean()),
|
||||
minimumLicense: schema.maybe(validLicenseSchema),
|
||||
|
|
|
@ -11,7 +11,7 @@ import { KibanaFeature } from '@kbn/features-plugin/public';
|
|||
export const createFeature = (
|
||||
config: Pick<
|
||||
KibanaFeatureConfig,
|
||||
'id' | 'name' | 'subFeatures' | 'reserved' | 'privilegesTooltip'
|
||||
'id' | 'name' | 'subFeatures' | 'reserved' | 'privilegesTooltip' | 'description'
|
||||
> & {
|
||||
excludeFromBaseAll?: boolean;
|
||||
excludeFromBaseRead?: boolean;
|
||||
|
|
|
@ -1,5 +1,11 @@
|
|||
.subFeaturePrivilegeExpandedRegion {
|
||||
background-color: $euiColorLightestShade;
|
||||
padding-left: $euiSizeXXL;
|
||||
padding-top: $euiSizeS;
|
||||
}
|
||||
.euiAccordionWithDescription:hover, .euiAccordionWithDescription:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.subFeaturePanel {
|
||||
margin-left: $euiSizeL + $euiSizeXS;
|
||||
}
|
||||
|
||||
.noSubFeaturePrivileges {
|
||||
margin-left: $euiSizeL + $euiSizeXS;
|
||||
}
|
||||
|
|
|
@ -837,6 +837,63 @@ describe('FeatureTable', () => {
|
|||
expect(findTestSubject(wrapper, 'primaryFeaturePrivilegeControl')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('renders subtext for features that define an optional description', () => {
|
||||
const role = createRole([
|
||||
{
|
||||
spaces: ['foo'],
|
||||
base: [],
|
||||
feature: {
|
||||
my_feature: ['all'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const featureWithDescription = createFeature({
|
||||
id: 'my_feature',
|
||||
name: 'Some Feature',
|
||||
description: 'a description of my feature',
|
||||
});
|
||||
|
||||
const { wrapper } = setup({
|
||||
role,
|
||||
features: [featureWithDescription],
|
||||
privilegeIndex: 0,
|
||||
calculateDisplayedPrivileges: false,
|
||||
canCustomizeSubFeaturePrivileges: false,
|
||||
});
|
||||
|
||||
expect(findTestSubject(wrapper, 'featurePrivilegeDescriptionText').exists()).toEqual(true);
|
||||
|
||||
expect(
|
||||
findTestSubject(wrapper, 'featurePrivilegeDescriptionText').text()
|
||||
).toMatchInlineSnapshot(`"a description of my feature"`);
|
||||
});
|
||||
|
||||
it('does not render subtext for features without a description', () => {
|
||||
const role = createRole([
|
||||
{
|
||||
spaces: ['foo'],
|
||||
base: [],
|
||||
feature: {
|
||||
my_feature: ['all'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
const featureWithDescription = createFeature({
|
||||
id: 'my_feature',
|
||||
name: 'Some Feature',
|
||||
});
|
||||
|
||||
const { wrapper } = setup({
|
||||
role,
|
||||
features: [featureWithDescription],
|
||||
privilegeIndex: 0,
|
||||
calculateDisplayedPrivileges: false,
|
||||
canCustomizeSubFeaturePrivileges: false,
|
||||
});
|
||||
|
||||
expect(findTestSubject(wrapper, 'featurePrivilegeDescriptionText').exists()).toEqual(false);
|
||||
});
|
||||
|
||||
it('renders renders the primary feature controls when both primary and reserved privileges are specified', () => {
|
||||
const role = createRole([
|
||||
{
|
||||
|
@ -1315,4 +1372,100 @@ describe('FeatureTable', () => {
|
|||
expect(type).toBe('empty');
|
||||
});
|
||||
});
|
||||
describe('Optional description for sub-features', () => {
|
||||
const role = createRole([
|
||||
{
|
||||
spaces: ['foo'],
|
||||
base: [],
|
||||
feature: {
|
||||
unit_test: ['minimal_read', 'sub-toggle-1', 'sub-toggle-2'],
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
it('renders description subtext if defined', () => {
|
||||
const feature = createFeature({
|
||||
id: 'unit_test',
|
||||
name: 'Unit Test Feature',
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'Some Sub Feature',
|
||||
description: 'some sub feature description',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-toggle-1',
|
||||
name: 'Sub Toggle 1',
|
||||
includeIn: 'all',
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: ['sub-toggle-1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as SubFeatureConfig[],
|
||||
});
|
||||
const { wrapper } = setup({
|
||||
role,
|
||||
features: [feature],
|
||||
privilegeIndex: 0,
|
||||
calculateDisplayedPrivileges: false,
|
||||
canCustomizeSubFeaturePrivileges: true,
|
||||
});
|
||||
|
||||
const categoryExpander = findTestSubject(wrapper, 'featureCategoryButton_foo');
|
||||
categoryExpander.simulate('click');
|
||||
|
||||
const featureExpander = findTestSubject(wrapper, 'featureTableCell');
|
||||
featureExpander.simulate('click');
|
||||
|
||||
expect(findTestSubject(wrapper, 'subFeatureDescription').exists()).toEqual(true);
|
||||
expect(findTestSubject(wrapper, 'subFeatureDescription').text()).toMatchInlineSnapshot(
|
||||
`"some sub feature description"`
|
||||
);
|
||||
});
|
||||
it('should not render description subtext if undefined', () => {
|
||||
const feature = createFeature({
|
||||
id: 'unit_test',
|
||||
name: 'Unit Test Feature',
|
||||
subFeatures: [
|
||||
{
|
||||
name: 'Some Sub Feature',
|
||||
privilegeGroups: [
|
||||
{
|
||||
groupType: 'independent',
|
||||
privileges: [
|
||||
{
|
||||
id: 'sub-toggle-1',
|
||||
name: 'Sub Toggle 1',
|
||||
includeIn: 'all',
|
||||
savedObject: { all: [], read: [] },
|
||||
ui: ['sub-toggle-1'],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
] as SubFeatureConfig[],
|
||||
});
|
||||
const { wrapper } = setup({
|
||||
role,
|
||||
features: [feature],
|
||||
privilegeIndex: 0,
|
||||
calculateDisplayedPrivileges: false,
|
||||
canCustomizeSubFeaturePrivileges: true,
|
||||
});
|
||||
|
||||
const categoryExpander = findTestSubject(wrapper, 'featureCategoryButton_foo');
|
||||
categoryExpander.simulate('click');
|
||||
|
||||
const featureExpander = findTestSubject(wrapper, 'featureTableCell');
|
||||
featureExpander.simulate('click');
|
||||
|
||||
expect(findTestSubject(wrapper, 'subFeatureDescription').exists()).toEqual(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -17,10 +17,12 @@ import {
|
|||
EuiHorizontalRule,
|
||||
EuiIcon,
|
||||
EuiIconTip,
|
||||
EuiPanel,
|
||||
EuiSpacer,
|
||||
EuiText,
|
||||
EuiTitle,
|
||||
} from '@elastic/eui';
|
||||
import classNames from 'classnames';
|
||||
import type { ReactElement } from 'react';
|
||||
import React, { Component } from 'react';
|
||||
|
||||
|
@ -221,11 +223,12 @@ export class FeatureTable extends Component<Props, State> {
|
|||
return (
|
||||
<EuiFlexGroup gutterSize="s" alignItems="center">
|
||||
<EuiFlexItem grow={false}>{infoIcon}</EuiFlexItem>
|
||||
<EuiFlexItem>
|
||||
<EuiFlexItem className="eui-fullWidth">
|
||||
<EuiAccordion
|
||||
id={`featurePrivilegeControls_${feature.id}`}
|
||||
data-test-subj="featurePrivilegeControls"
|
||||
buttonContent={buttonContent}
|
||||
buttonClassName="euiAccordionWithDescription"
|
||||
extraAction={extraAction}
|
||||
forceState={hasSubFeaturePrivileges ? undefined : 'closed'}
|
||||
arrowDisplay={hasSubFeaturePrivileges ? 'left' : 'none'}
|
||||
|
@ -241,7 +244,8 @@ export class FeatureTable extends Component<Props, State> {
|
|||
});
|
||||
}}
|
||||
>
|
||||
<div className="subFeaturePrivilegeExpandedRegion">
|
||||
<EuiSpacer size="s" />
|
||||
<EuiPanel color="subdued" paddingSize="s" className="subFeaturePanel">
|
||||
<FeatureTableExpandedRow
|
||||
feature={feature}
|
||||
privilegeIndex={this.props.privilegeIndex}
|
||||
|
@ -256,7 +260,7 @@ export class FeatureTable extends Component<Props, State> {
|
|||
this.props.canCustomizeSubFeaturePrivileges
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</EuiPanel>
|
||||
</EuiAccordion>
|
||||
</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
|
@ -267,9 +271,7 @@ export class FeatureTable extends Component<Props, State> {
|
|||
|
||||
if (feature.reserved && primaryFeaturePrivileges.length === 0) {
|
||||
const buttonContent = (
|
||||
<>
|
||||
{<EuiIcon type="empty" size="l" />} <FeatureTableCell feature={feature} />
|
||||
</>
|
||||
<FeatureTableCell className="noSubFeaturePrivileges" feature={feature} />
|
||||
);
|
||||
|
||||
const extraAction = (
|
||||
|
@ -336,10 +338,10 @@ export class FeatureTable extends Component<Props, State> {
|
|||
|
||||
const hasSubFeaturePrivileges = feature.getSubFeaturePrivileges().length > 0;
|
||||
const buttonContent = (
|
||||
<>
|
||||
{!hasSubFeaturePrivileges && <EuiIcon type="empty" size="l" />}{' '}
|
||||
<FeatureTableCell feature={feature} />
|
||||
</>
|
||||
<FeatureTableCell
|
||||
className={classNames({ noSubFeaturePrivileges: !hasSubFeaturePrivileges })}
|
||||
feature={feature}
|
||||
/>
|
||||
);
|
||||
|
||||
const extraAction = (
|
||||
|
|
|
@ -70,7 +70,7 @@ export const FeatureTableExpandedRow = ({
|
|||
};
|
||||
|
||||
return (
|
||||
<EuiFlexGroup direction="column">
|
||||
<EuiFlexGroup direction="column" gutterSize="s">
|
||||
<EuiFlexItem>
|
||||
<div>
|
||||
<EuiSwitch
|
||||
|
|
|
@ -64,15 +64,30 @@ export const SubFeatureForm = (props: Props) => {
|
|||
if (groupsWithPrivileges.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<EuiFlexGroup>
|
||||
<EuiFlexItem>
|
||||
<EuiText size="s">
|
||||
{props.subFeature.name} {getTooltip()}
|
||||
</EuiText>
|
||||
<EuiFlexGroup alignItems="center">
|
||||
<EuiFlexItem grow={3}>
|
||||
<EuiFlexGroup gutterSize="none" direction="column">
|
||||
<EuiFlexItem>
|
||||
<EuiText size="s">
|
||||
{props.subFeature.name} {getTooltip()}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
{props.subFeature.description && (
|
||||
<EuiFlexItem>
|
||||
<EuiText
|
||||
color={'subdued'}
|
||||
size={'xs'}
|
||||
data-test-subj="subFeatureDescription"
|
||||
aria-describedby={`${props.subFeature.name} description text`}
|
||||
>
|
||||
{props.subFeature.description}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem>{groupsWithPrivileges.map(renderPrivilegeGroup)}</EuiFlexItem>
|
||||
<EuiFlexItem grow={2}>{groupsWithPrivileges.map(renderPrivilegeGroup)}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
|
||||
|
@ -157,6 +172,7 @@ export const SubFeatureForm = (props: Props) => {
|
|||
key={index}
|
||||
buttonSize="compressed"
|
||||
data-test-subj="mutexSubFeaturePrivilegeControl"
|
||||
isFullWidth
|
||||
options={options}
|
||||
idSelected={firstSelectedPrivilege?.id ?? NO_PRIVILEGE_VALUE}
|
||||
isDisabled={props.disabled}
|
||||
|
|
|
@ -0,0 +1,3 @@
|
|||
.featurePrivilegeName:hover, .featurePrivilegeName:focus {
|
||||
text-decoration: underline;
|
||||
}
|
|
@ -25,7 +25,7 @@ describe('FeatureTableCell', () => {
|
|||
<FeatureTableCell feature={new SecuredFeature(feature.toRaw())} />
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature "`);
|
||||
expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature"`);
|
||||
expect(wrapper.find(EuiIconTip)).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
@ -40,7 +40,7 @@ describe('FeatureTableCell', () => {
|
|||
<FeatureTableCell feature={new SecuredFeature(feature.toRaw())} />
|
||||
);
|
||||
|
||||
expect(wrapper.text()).toMatchInlineSnapshot(`"Test Feature Info"`);
|
||||
expect(wrapper.text()).toMatchInlineSnapshot(`"Test FeatureInfo"`);
|
||||
|
||||
expect(wrapper.find(EuiIconTip).props().content).toMatchInlineSnapshot(`
|
||||
<EuiText>
|
||||
|
|
|
@ -5,16 +5,19 @@
|
|||
* 2.0.
|
||||
*/
|
||||
|
||||
import { EuiIconTip, EuiText } from '@elastic/eui';
|
||||
import './feature_table_cell.scss';
|
||||
|
||||
import { EuiFlexGroup, EuiFlexItem, EuiIconTip, EuiText } from '@elastic/eui';
|
||||
import React from 'react';
|
||||
|
||||
import type { SecuredFeature } from '../../../../model';
|
||||
|
||||
interface Props {
|
||||
feature: SecuredFeature;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FeatureTableCell = ({ feature }: Props) => {
|
||||
export const FeatureTableCell = ({ feature, className }: Props) => {
|
||||
let tooltipElement = null;
|
||||
if (feature.getPrivilegesTooltip()) {
|
||||
const tooltipContent = (
|
||||
|
@ -35,8 +38,27 @@ export const FeatureTableCell = ({ feature }: Props) => {
|
|||
}
|
||||
|
||||
return (
|
||||
<span data-test-subj={`featureTableCell`}>
|
||||
{feature.name} {tooltipElement}
|
||||
</span>
|
||||
<EuiFlexGroup className={className} direction="column" gutterSize="none" component="span">
|
||||
<EuiFlexItem data-test-subj={`featureTableCell`} component="span">
|
||||
<EuiFlexGroup gutterSize="xs">
|
||||
<EuiFlexItem className="featurePrivilegeName" grow={false}>
|
||||
{feature.name}
|
||||
</EuiFlexItem>
|
||||
<EuiFlexItem grow={false}>{tooltipElement}</EuiFlexItem>
|
||||
</EuiFlexGroup>
|
||||
</EuiFlexItem>
|
||||
{feature.description && (
|
||||
<EuiFlexItem>
|
||||
<EuiText
|
||||
color="subdued"
|
||||
size="xs"
|
||||
data-test-subj="featurePrivilegeDescriptionText"
|
||||
aria-describedby={`${feature.name} description text`}
|
||||
>
|
||||
{feature.description}
|
||||
</EuiText>
|
||||
</EuiFlexItem>
|
||||
)}
|
||||
</EuiFlexGroup>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -44,4 +44,8 @@ export class SecuredSubFeature extends SubFeature {
|
|||
.filter((privilege) => predicate(privilege, this));
|
||||
}
|
||||
}
|
||||
|
||||
public getDescription() {
|
||||
return this.description;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue