Role Management: improve editing experience for DLS queries (#99977)

Co-authored-by: Kibana Machine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Larry Gregory 2021-06-28 12:55:55 -04:00 committed by GitHub
parent d2690d8ac8
commit af38aca3fd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
9 changed files with 190 additions and 52 deletions

View file

@ -95,6 +95,11 @@ export interface Props {
* Should the editor use a transparent background
*/
transparentBackground?: boolean;
/**
* Should the editor be rendered using the fullWidth EUI attribute
*/
fullWidth?: boolean;
}
export class CodeEditor extends React.Component<Props, {}> {

View file

@ -51,7 +51,7 @@ export const CodeEditor: React.FunctionComponent<Props> = (props) => {
* Renders a Monaco code editor in the same style as other EUI form fields.
*/
export const CodeEditorField: React.FunctionComponent<Props> = (props) => {
const { width, height, options } = props;
const { width, height, options, fullWidth } = props;
const darkMode = useUiSetting<boolean>('theme:darkMode');
const theme = darkMode ? darkTheme : lightTheme;
const style = {
@ -75,7 +75,12 @@ export const CodeEditorField: React.FunctionComponent<Props> = (props) => {
</EuiFormControlLayout>
}
>
<EuiFormControlLayout append={<div hidden />} style={style} readOnly={options?.readOnly}>
<EuiFormControlLayout
append={<div hidden />}
style={style}
readOnly={options?.readOnly}
fullWidth={fullWidth}
>
<LazyBaseEditor {...props} useDarkTheme={darkMode} transparentBackground />
</EuiFormControlLayout>
</React.Suspense>

View file

@ -26,4 +26,18 @@ export class MonacoEditorService extends FtrService {
return values[nthIndex] as string;
}
public async setCodeEditorValue(nthIndex: number, value: string) {
await this.retry.try(async () => {
await this.browser.execute(
(editorIndex, codeEditorValue) => {
const editor = (window as any).MonacoEnvironment.monaco.editor;
const instance = editor.getModels()[editorIndex];
instance.setValue(JSON.parse(codeEditorValue));
},
nthIndex,
value
);
});
}
}

View file

@ -1,3 +1,50 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`it renders without crashing 1`] = `<Fragment />`;
exports[`it renders without crashing 1`] = `
<IndexPrivileges
availableIndexPrivileges={
Array [
"all",
"read",
"write",
"index",
]
}
editable={true}
indexPatterns={Array []}
indicesAPIClient={
Object {
"getFields": [MockFunction],
}
}
license={
Object {
"features$": Observable {
"_isScalar": false,
"_subscribe": [Function],
},
"getFeatures": [MockFunction],
"getType": [MockFunction],
"isEnabled": [MockFunction],
"isLicenseAvailable": [MockFunction],
}
}
onChange={[MockFunction]}
role={
Object {
"elasticsearch": Object {
"cluster": Array [],
"indices": Array [],
"run_as": Array [],
},
"kibana": Array [],
"name": "",
}
}
validator={
RoleValidator {
"shouldValidate": undefined,
}
}
/>
`;

View file

@ -9,7 +9,12 @@ import { EuiButtonIcon, EuiComboBox, EuiTextArea } from '@elastic/eui';
import React from 'react';
import { findTestSubject, mountWithIntl, nextTick, shallowWithIntl } from '@kbn/test/jest';
import { coreMock } from 'src/core/public/mocks';
import {
CodeEditorField,
KibanaContextProvider,
} from '../../../../../../../../../src/plugins/kibana_react/public';
import { indicesAPIClientMock } from '../../../index.mock';
import { RoleValidator } from '../../validate_role';
import { IndexPrivilegeForm } from './index_privilege_form';
@ -180,9 +185,13 @@ describe(`document level security`, () => {
...props,
};
const wrapper = mountWithIntl(<IndexPrivilegeForm {...testProps} />);
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreMock.createStart()}>
<IndexPrivilegeForm {...testProps} />
</KibanaContextProvider>
);
expect(wrapper.find('EuiSwitch[data-test-subj="restrictDocumentsQuery0"]')).toHaveLength(1);
expect(wrapper.find(EuiTextArea)).toHaveLength(1);
expect(wrapper.find(CodeEditorField)).toHaveLength(1);
});
});

View file

@ -15,16 +15,16 @@ import {
EuiHorizontalRule,
EuiSpacer,
EuiSwitch,
EuiTextArea,
} from '@elastic/eui';
import _ from 'lodash';
import type { ChangeEvent } from 'react';
import React, { Component, Fragment } from 'react';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n/react';
import type { monaco } from '@kbn/monaco';
import type { PublicMethodsOf } from '@kbn/utility-types';
import { CodeEditorField } from '../../../../../../../../../src/plugins/kibana_react/public';
import type { RoleIndexPrivilege } from '../../../../../../common/model';
import type { IndicesAPIClient } from '../../../indices_api_client';
import type { RoleValidator } from '../../validate_role';
@ -52,6 +52,7 @@ interface State {
grantedFields: string[];
exceptedFields: string[];
documentQuery?: string;
documentQueryEditorHeight: string;
isFieldListLoading: boolean;
flsOptions: string[];
}
@ -73,6 +74,7 @@ export class IndexPrivilegeForm extends Component<Props, State> {
grantedFields: grant,
exceptedFields: except,
documentQuery: props.indexPrivilege.query,
documentQueryEditorHeight: '100px',
isFieldListLoading: false,
flsOptions: [],
};
@ -302,21 +304,19 @@ export class IndexPrivilegeForm extends Component<Props, State> {
<EuiFlexGroup direction="column">
{!this.props.isRoleReadOnly && (
<EuiFlexItem>
{
<EuiSwitch
data-test-subj={`restrictDocumentsQuery${this.props.formIndex}`}
label={
<FormattedMessage
id="xpack.security.management.editRole.indexPrivilegeForm.grantReadPrivilegesLabel"
defaultMessage="Grant read privileges to specific documents"
/>
}
compressed={true}
checked={this.state.queryExpanded}
onChange={this.toggleDocumentQuery}
disabled={isRoleReadOnly}
/>
}
<EuiSwitch
data-test-subj={`restrictDocumentsQuery${this.props.formIndex}`}
label={
<FormattedMessage
id="xpack.security.management.editRole.indexPrivilegeForm.grantReadPrivilegesLabel"
defaultMessage="Grant read privileges to specific documents"
/>
}
compressed={true}
checked={this.state.queryExpanded}
onChange={this.toggleDocumentQuery}
disabled={isRoleReadOnly}
/>
</EuiFlexItem>
)}
{this.state.queryExpanded && (
@ -329,14 +329,30 @@ export class IndexPrivilegeForm extends Component<Props, State> {
/>
}
fullWidth={true}
data-test-subj={`queryInput${this.props.formIndex}`}
>
<EuiTextArea
data-test-subj={`queryInput${this.props.formIndex}`}
style={{ resize: 'none' }}
<CodeEditorField
languageId="xjson"
width="100%"
fullWidth={true}
value={indexPrivilege.query}
height={this.state.documentQueryEditorHeight}
aria-label={i18n.translate(
'xpack.security.management.editRole.indexPrivilegeForm.grantedDocumentsQueryEditorAriaLabel',
{
defaultMessage: 'Granted documents query editor',
}
)}
value={indexPrivilege.query ?? ''}
onChange={this.onQueryChange}
readOnly={this.props.isRoleReadOnly}
options={{
readOnly: this.props.isRoleReadOnly,
minimap: {
enabled: false,
},
// Prevent an empty form from showing an error
renderValidationDecorations: indexPrivilege.query ? 'editable' : 'off',
}}
editorDidMount={this.editorDidMount}
/>
</EuiFormRow>
</EuiFlexItem>
@ -345,6 +361,24 @@ export class IndexPrivilegeForm extends Component<Props, State> {
);
};
private editorDidMount = (editor: monaco.editor.IStandaloneCodeEditor) => {
/**
* Resize the editor based on the contents of the editor itself.
* Adapted from https://github.com/microsoft/monaco-editor/issues/794#issuecomment-688959283
*/
const minHeight = 100;
const maxHeight = 1000;
const updateHeight = () => {
const contentHeight = Math.min(maxHeight, Math.max(minHeight, editor.getContentHeight()));
this.setState({ documentQueryEditorHeight: `${contentHeight}px` });
};
editor.onDidContentSizeChange(updateHeight);
updateHeight();
};
private toggleDocumentQuery = () => {
const willToggleOff = this.state.queryExpanded;
const willToggleOn = !willToggleOff;
@ -457,10 +491,10 @@ export class IndexPrivilegeForm extends Component<Props, State> {
});
};
private onQueryChange = (e: ChangeEvent<HTMLTextAreaElement>) => {
private onQueryChange = (query: string) => {
this.props.onChange({
...this.props.indexPrivilege,
query: e.target.value,
query,
});
};

View file

@ -8,7 +8,9 @@
import React from 'react';
import { mountWithIntl, shallowWithIntl } from '@kbn/test/jest';
import { coreMock } from 'src/core/public/mocks';
import { KibanaContextProvider } from '../../../../../../../../../src/plugins/kibana_react/public';
import { licenseMock } from '../../../../../../common/licensing/index.mock';
import { indicesAPIClientMock } from '../../../index.mock';
import { RoleValidator } from '../../validate_role';
@ -44,9 +46,13 @@ test('it renders without crashing', async () => {
indicesAPIClient: indicesAPIClientMock.create(),
license,
};
const wrapper = shallowWithIntl(<IndexPrivileges {...props} />);
const wrapper = shallowWithIntl(
<KibanaContextProvider services={coreMock.createStart()}>
<IndexPrivileges {...props} />
</KibanaContextProvider>
);
await flushPromises();
expect(wrapper).toMatchSnapshot();
expect(wrapper.children()).toMatchSnapshot();
});
test('it renders a IndexPrivilegeForm for each privilege on the role', async () => {
@ -86,7 +92,11 @@ test('it renders a IndexPrivilegeForm for each privilege on the role', async ()
indicesAPIClient,
license,
};
const wrapper = mountWithIntl(<IndexPrivileges {...props} />);
const wrapper = mountWithIntl(
<KibanaContextProvider services={coreMock.createStart()}>
<IndexPrivileges {...props} />
</KibanaContextProvider>
);
await flushPromises();
expect(wrapper.find(IndexPrivilegeForm)).toHaveLength(1);
});

View file

@ -13,6 +13,7 @@ import { i18n } from '@kbn/i18n';
import type { FatalErrorsSetup, StartServicesAccessor } from 'src/core/public';
import type { RegisterManagementAppArgs } from 'src/plugins/management/public';
import { KibanaContextProvider } from '../../../../../../src/plugins/kibana_react/public';
import type { SecurityLicense } from '../../../common/licensing';
import type { PluginStartDependencies } from '../../plugin';
import { tryDecodeURIComponent } from '../url_utils';
@ -42,10 +43,7 @@ export const rolesManagementApp = Object.freeze({
];
const [
[
{ application, docLinks, http, i18n: i18nStart, notifications, chrome },
{ data, features, spaces },
],
[startServices, { data, features, spaces }],
{ RolesGridPage },
{ EditRolePage },
{ RolesAPIClient },
@ -62,6 +60,15 @@ export const rolesManagementApp = Object.freeze({
import('../users'),
]);
const {
application,
docLinks,
http,
i18n: i18nStart,
notifications,
chrome,
} = startServices;
chrome.docTitle.change(title);
const rolesAPIClient = new RolesAPIClient(http);
@ -119,21 +126,24 @@ export const rolesManagementApp = Object.freeze({
};
render(
<i18nStart.Context>
<Router history={history}>
<Switch>
<Route path={['/', '']} exact={true}>
<RolesGridPageWithBreadcrumbs />
</Route>
<Route path="/edit/:roleName?">
<EditRolePageWithBreadcrumbs action="edit" />
</Route>
<Route path="/clone/:roleName">
<EditRolePageWithBreadcrumbs action="clone" />
</Route>
</Switch>
</Router>
</i18nStart.Context>,
<KibanaContextProvider services={startServices}>
<i18nStart.Context>
<Router history={history}>
<Switch>
<Route path={['/', '']} exact={true}>
<RolesGridPageWithBreadcrumbs />
</Route>
<Route path="/edit/:roleName?">
<EditRolePageWithBreadcrumbs action="edit" />
</Route>
<Route path="/clone/:roleName">
<EditRolePageWithBreadcrumbs action="clone" />
</Route>
</Switch>
</Router>
</i18nStart.Context>
</KibanaContextProvider>,
element
);

View file

@ -32,6 +32,7 @@ export class SecurityPageObject extends FtrService {
private readonly deployment = this.ctx.getService('deployment');
private readonly common = this.ctx.getPageObject('common');
private readonly header = this.ctx.getPageObject('header');
private readonly monacoEditor = this.ctx.getService('monacoEditor');
public loginPage = Object.freeze({
login: async (username?: string, password?: string, options: LoginOptions = {}) => {
@ -467,7 +468,10 @@ export class SecurityPageObject extends FtrService {
if (roleObj.elasticsearch.indices[0].query) {
await this.testSubjects.click('restrictDocumentsQuery0');
await this.testSubjects.setValue('queryInput0', roleObj.elasticsearch.indices[0].query);
await this.monacoEditor.setCodeEditorValue(
0,
JSON.stringify(roleObj.elasticsearch.indices[0].query)
);
}
const globalPrivileges = (roleObj.kibana as any).global;