[Advanced Settings] Integrate new Settings application into stateful Kibana (#175255)

Closes https://github.com/elastic/kibana/issues/172922 

## Summary

This PR:
- Integrates the new Settings application
(`packages/kbn-management/settings/application`) into stateful Kibana
and removes the old `management_app` from the
`src/plugins/advanced_settings` plugin.
- Adds support for section registry in the new Settings application, so
that other plugins can add their own sections to the Advanced settings
app.
- Adds functionality for disabling saving of settings based on the
provided capabilities of the current user.

<img width="1352" alt="Screenshot 2024-01-23 at 16 46 03"
src="1f3b7088-58e2-46e8-a7dd-ae0fc346b4ba">

<br><br>

"Usage collection" section in Global settings:

<img width="1099" alt="Screenshot 2024-01-23 at 16 48 24"
src="ebc54ad5-348b-46dd-a047-b418ddc7ba4f">

### How to test

**Testing Advanced settings in stateful Kibana:**
1. Start Es with `yarn es snapshot` and Kibana with `yarn start`
2. Go to Stack Management -> Advanced Settings
3. Verify that the app functions correctly. Both tabs (for space and
global settings) should be displayed, setting fields should be editable
and saveable, etc.

**Testing the section registry**
Currently, `telemetry_management_section` is the only plugin that
registers a section - the "Usage collection" section under the "Global
settings" tab. This should work correctly in stateful Kibana.
1. Start Es with `yarn es snapshot --license=trial` and Kibana with
`yarn start`
2. Go to Stack Management -> Advanced Settings and select the "Global
settings" tab
3. Scroll down and verify that the "Usage collection" section is
displayed and works as expected.

**Testing with different capabilities:**
1. Start Es with `yarn es snapshot` and Kibana with `yarn start`
2. Go to Stack Management -> Roles
3. Create a role that has "Read" access to Advanced settings and one
that doesn't have any access.
4. Create users with each of these two roles.
5. Log in with these users and verify that the user with "Read" access
can see the app but cannot edit it, and the user with no privileges
cannot access the app.

**Testing Advanced settings in serverless Kibana:**
The Advanced settings app in serverless shouldn't be affected by these
changes.
1. Start Es with `yarn es serverless` and Kibana with `yarn
serverless-{es/oblt/security}`
2. Go to Management -> Advanced Settings
3. Verify that the app functions correctly. There shouldn't be any tabs
as there are no spaces.


<!--
### Checklist

Delete any items that are not applicable to this PR.

- [ ] 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)
- [ ]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [ ] [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
- [ ] [Flaky Test
Runner](https://ci-stats.kibana.dev/trigger_flaky_test_runner/1) was
used on any tests changed
- [ ] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [ ] 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))
- [ ] If a plugin configuration key changed, check if it needs to be
allowlisted in the cloud and added to the [docker
list](https://github.com/elastic/kibana/blob/main/src/dev/build/tasks/os_packages/docker_generator/resources/base/bin/kibana-docker)
- [ ] 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))
- [ ] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)


### Risk Matrix

Delete this section if it is not applicable to this PR.

Before closing this PR, invite QA, stakeholders, and other developers to
identify risks that should be tested prior to the change/feature
release.

When forming the risk matrix, consider some of the following examples
and how they may potentially impact the change:

| Risk | Probability | Severity | Mitigation/Notes |

|---------------------------|-------------|----------|-------------------------|
| Multiple Spaces&mdash;unexpected behavior in non-default Kibana Space.
| Low | High | Integration tests will verify that all features are still
supported in non-default Kibana Space and when user switches between
spaces. |
| Multiple nodes&mdash;Elasticsearch polling might have race conditions
when multiple Kibana nodes are polling for the same tasks. | High | Low
| Tasks are idempotent, so executing them multiple times will not result
in logical error, but will degrade performance. To test for this case we
add plenty of unit tests around this logic and document manual testing
procedure. |
| Code should gracefully handle cases when feature X or plugin Y are
disabled. | Medium | High | Unit tests will verify that any feature flag
or plugin combination still results in our service operational. |
| [See more potential risk
examples](https://github.com/elastic/kibana/blob/main/RISK_MATRIX.mdx) |


### For maintainers

- [ ] This was checked for breaking API changes and was [labeled
appropriately](https://www.elastic.co/guide/en/kibana/master/contributing.html#kibana-release-notes-process)
-->

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Elena Stoeva 2024-01-30 20:47:35 +00:00 committed by GitHub
parent 9e720d0076
commit 123e62517a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
77 changed files with 242 additions and 10389 deletions

View file

@ -58,7 +58,7 @@ guided_onboarding.enabled: false
# Other disabled plugins
xpack.canvas.enabled: false
data.search.sessions.enabled: false
advanced_settings.enabled: false
advanced_settings.globalSettingsEnabled: false
# Disable the browser-side functionality that depends on SecurityCheckupGetStateRoutes
xpack.security.showInsecureClusterWarning: false

View file

@ -23,7 +23,7 @@ NOTE:
|{kib-repo}blob/{branch}/src/plugins/advanced_settings/README.md[advancedSettings]
|This plugin contains the advanced settings management section
|This plugin registers the management settings application
allowing users to configure their advanced settings, also known
as uiSettings within the code.

View file

@ -16,6 +16,7 @@ import {
getSettingsMock,
} from '@kbn/management-settings-utilities/mocks/settings.mock';
import { UiSettingsScope } from '@kbn/core-ui-settings-common';
import { getSettingsCapabilitiesMock } from '@kbn/management-settings-utilities/mocks/capabilities.mock';
import { SettingsApplication as Component } from '../application';
import { SettingsApplicationProvider } from '../services';
@ -43,6 +44,11 @@ const getSettingsApplicationStory = ({ hasGlobalSettings }: StoryProps) => (
getAllowlistedSettings={(scope: UiSettingsScope) =>
scope === 'namespace' ? getSettingsMock() : hasGlobalSettings ? getGlobalSettingsMock() : {}
}
getSections={() => []}
// @ts-ignore
getToastsService={() => null}
getCapabilities={getSettingsCapabilitiesMock}
setBadge={() => {}}
isCustomSetting={() => false}
isOverriddenSetting={() => false}
saveChanges={action('saveChanges')}

View file

@ -22,6 +22,7 @@ import { SettingsTabs } from '@kbn/management-settings-types/tab';
import { EmptyState } from './empty_state';
import { i18nTexts } from './i18n_texts';
import { Tab } from './tab';
import { readOnlyBadge } from './read_only_badge';
import { useScopeFields } from './hooks/use_scope_fields';
import { QueryInput, QueryInputProps } from './query_input';
import { useServices } from './services';
@ -53,7 +54,8 @@ function getQueryParam(url: string) {
* Component for displaying the {@link SettingsApplication} component.
*/
export const SettingsApplication = () => {
const { addUrlToHistory } = useServices();
const { addUrlToHistory, getSections, getToastsService, getCapabilities, setBadge } =
useServices();
const queryParam = getQueryParam(window.location.href);
const [query, setQuery] = useState<Query>(Query.parse(queryParam));
@ -68,7 +70,17 @@ export const SettingsApplication = () => {
const [spaceAllFields, globalAllFields] = useScopeFields();
const [spaceFilteredFields, globalFilteredFields] = useScopeFields(query);
const globalSettingsEnabled = globalAllFields.length > 0;
const {
spaceSettings: { save: canSaveSpaceSettings },
globalSettings: { save: canSaveGlobalSettings, show: canShowGlobalSettings },
} = getCapabilities();
if (!canSaveSpaceSettings || (!canSaveGlobalSettings && canShowGlobalSettings)) {
setBadge(readOnlyBadge);
}
// Only enabled the Global settings tab if there are any global settings
// and if global settings can be shown
const globalTabEnabled = globalAllFields.length > 0 && canShowGlobalSettings;
const tabs: SettingsTabs = {
[SPACE_SETTINGS_TAB_ID]: {
@ -77,16 +89,19 @@ export const SettingsApplication = () => {
categoryCounts: getCategoryCounts(spaceAllFields),
callOutTitle: i18nTexts.spaceCalloutTitle,
callOutText: i18nTexts.spaceCalloutText,
sections: getSections('namespace'),
isSavingEnabled: canSaveSpaceSettings,
},
};
// Only add a Global settings tab if there are any global settings
if (globalSettingsEnabled) {
if (globalTabEnabled) {
tabs[GLOBAL_SETTINGS_TAB_ID] = {
name: i18nTexts.globalTabTitle,
fields: globalFilteredFields,
categoryCounts: getCategoryCounts(globalAllFields),
callOutTitle: i18nTexts.globalCalloutTitle,
callOutText: i18nTexts.globalCalloutText,
sections: getSections('global'),
isSavingEnabled: canSaveGlobalSettings,
};
}
@ -110,7 +125,7 @@ export const SettingsApplication = () => {
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
{globalSettingsEnabled && (
{globalTabEnabled && (
<>
<EuiTabs>
{Object.keys(tabs).map((id) => (
@ -130,13 +145,31 @@ export const SettingsApplication = () => {
)}
<EuiSpacer size="xl" />
{selectedTab.fields.length ? (
<Form
fields={selectedTab.fields}
categoryCounts={selectedTab.categoryCounts}
isSavingEnabled={true}
onClearQuery={() => onQueryChange()}
scope={selectedTabId === SPACE_SETTINGS_TAB_ID ? 'namespace' : 'global'}
/>
<>
<Form
fields={selectedTab.fields}
categoryCounts={selectedTab.categoryCounts}
isSavingEnabled={selectedTab.isSavingEnabled}
onClearQuery={() => onQueryChange()}
scope={selectedTabId === SPACE_SETTINGS_TAB_ID ? 'namespace' : 'global'}
/>
<EuiSpacer size="l" />
{selectedTab.sections.length > 0 &&
selectedTab.sections.map(({ Component, queryMatch }, index) => {
if (queryMatch(query.text)) {
return (
<Component
key={`component-${index}`}
toasts={getToastsService()}
enableSaving={{
global: canSaveGlobalSettings,
namespace: canSaveSpaceSettings,
}}
/>
);
}
})}
</>
) : (
<EmptyState {...{ queryText: query?.text, onClearQuery: () => onQueryChange() }} />
)}

View file

@ -28,9 +28,22 @@ export const KibanaSettingsApplication = ({
settings,
theme,
history,
sectionRegistry,
application,
chrome,
}: SettingsApplicationKibanaDependencies) => (
<SettingsApplicationKibanaProvider
{...{ settings, theme, i18n, notifications, docLinks, history }}
{...{
settings,
theme,
i18n,
notifications,
docLinks,
history,
sectionRegistry,
application,
chrome,
}}
>
<SettingsApplication />
</SettingsApplicationKibanaProvider>

View file

@ -21,6 +21,7 @@ import {
getSettingsMock,
} from '@kbn/management-settings-utilities/mocks/settings.mock';
import { UiSettingsScope } from '@kbn/core-ui-settings-common';
import { getSettingsCapabilitiesMock } from '@kbn/management-settings-utilities/mocks/capabilities.mock';
import { SettingsApplicationProvider, SettingsApplicationServices } from '../services';
const createRootMock = () => {
@ -42,10 +43,14 @@ export const createSettingsApplicationServicesMock = (
...createFormServicesMock(),
getAllowlistedSettings: (scope: UiSettingsScope) =>
scope === 'namespace' ? getSettingsMock() : hasGlobalSettings ? getGlobalSettingsMock() : {},
getSections: () => [],
getCapabilities: getSettingsCapabilitiesMock,
setBadge: jest.fn(),
isCustomSetting: () => false,
isOverriddenSetting: () => false,
subscribeToUpdates: () => new Subscription(),
addUrlToHistory: jest.fn(),
getToastsService: jest.fn(),
});
export const TestWrapper = ({

View file

@ -6,15 +6,14 @@
* Side Public License, v 1.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { i18n } from '@kbn/i18n';
import { CallOuts } from './call_outs';
describe('CallOuts', () => {
it('should render normally', async () => {
const component = shallow(<CallOuts />);
expect(component).toMatchSnapshot();
});
});
export const readOnlyBadge = {
text: i18n.translate('management.settings.badge.readOnly.text', {
defaultMessage: 'Read only',
}),
tooltip: i18n.translate('management.settings.badge.readOnly.tooltip', {
defaultMessage: 'Unable to save advanced settings',
}),
iconType: 'glasses',
};

View file

@ -14,15 +14,22 @@ import {
type FormKibanaDependencies,
type FormServices,
} from '@kbn/management-settings-components-form';
import { UiSettingMetadata } from '@kbn/management-settings-types';
import { SettingsCapabilities, UiSettingMetadata } from '@kbn/management-settings-types';
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { normalizeSettings } from '@kbn/management-settings-utilities';
import { Subscription } from 'rxjs';
import { ScopedHistory } from '@kbn/core-application-browser';
import { ApplicationStart, ScopedHistory } from '@kbn/core-application-browser';
import { UiSettingsScope } from '@kbn/core-ui-settings-common';
import { RegistryEntry, SectionRegistryStart } from '@kbn/management-settings-section-registry';
import { ToastsStart } from '@kbn/core-notifications-browser';
import { ChromeBadge, ChromeStart } from '@kbn/core-chrome-browser';
export interface Services {
getAllowlistedSettings: (scope: UiSettingsScope) => Record<string, UiSettingMetadata>;
getSections: (scope: UiSettingsScope) => RegistryEntry[];
getToastsService: () => ToastsStart;
getCapabilities: () => SettingsCapabilities;
setBadge: (badge: ChromeBadge) => void;
subscribeToUpdates: (fn: () => void, scope: UiSettingsScope) => Subscription;
isCustomSetting: (key: string, scope: UiSettingsScope) => boolean;
isOverriddenSetting: (key: string, scope: UiSettingsScope) => boolean;
@ -43,6 +50,12 @@ export interface KibanaDependencies {
>;
};
history: ScopedHistory;
sectionRegistry: SectionRegistryStart;
notifications: {
toasts: ToastsStart;
};
application: Pick<ApplicationStart, 'capabilities'>;
chrome: Pick<ChromeStart, 'setBadge'>;
}
export type SettingsApplicationKibanaDependencies = KibanaDependencies & FormKibanaDependencies;
@ -65,6 +78,10 @@ export const SettingsApplicationProvider: FC<SettingsApplicationServices> = ({
links,
showDanger,
getAllowlistedSettings,
getSections,
getCapabilities,
setBadge,
getToastsService,
subscribeToUpdates,
isCustomSetting,
isOverriddenSetting,
@ -75,6 +92,10 @@ export const SettingsApplicationProvider: FC<SettingsApplicationServices> = ({
<SettingsApplicationContext.Provider
value={{
getAllowlistedSettings,
getSections,
getToastsService,
getCapabilities,
setBadge,
subscribeToUpdates,
isCustomSetting,
isOverriddenSetting,
@ -97,7 +118,17 @@ export const SettingsApplicationKibanaProvider: FC<SettingsApplicationKibanaDepe
children,
...dependencies
}) => {
const { docLinks, notifications, theme, i18n, settings, history } = dependencies;
const {
docLinks,
notifications,
theme,
i18n,
settings,
history,
sectionRegistry,
application,
chrome,
} = dependencies;
const { client, globalClient } = settings;
const getScopeClient = (scope: UiSettingsScope) => {
@ -114,6 +145,26 @@ export const SettingsApplicationKibanaProvider: FC<SettingsApplicationKibanaDepe
return normalizeSettings(rawSettings);
};
const getSections = (scope: UiSettingsScope) => {
return scope === 'namespace'
? sectionRegistry.getSpacesSections()
: sectionRegistry.getGlobalSections();
};
const getCapabilities = () => {
const { advancedSettings, globalSettings } = application.capabilities;
return {
spaceSettings: {
show: advancedSettings.show as boolean,
save: advancedSettings.save as boolean,
},
globalSettings: {
show: globalSettings.show as boolean,
save: globalSettings.save as boolean,
},
};
};
const isCustomSetting = (key: string, scope: UiSettingsScope) => {
const scopeClient = getScopeClient(scope);
return scopeClient.isCustom(key);
@ -131,6 +182,10 @@ export const SettingsApplicationKibanaProvider: FC<SettingsApplicationKibanaDepe
const services: Services = {
getAllowlistedSettings,
getSections,
getToastsService: () => notifications.toasts,
getCapabilities,
setBadge: (badge: ChromeBadge) => chrome.setBadge(badge),
isCustomSetting,
isOverriddenSetting,
subscribeToUpdates,

View file

@ -32,5 +32,8 @@
"@kbn/core-i18n-browser",
"@kbn/core-analytics-browser-mocks",
"@kbn/core-ui-settings-common",
"@kbn/management-settings-section-registry",
"@kbn/core-notifications-browser",
"@kbn/core-chrome-browser",
]
}

View file

@ -6,7 +6,12 @@
* Side Public License, v 1.
*/
export { Field, getEditableValue } from './field';
export interface SettingsCapabilities {
spaceSettings: SettingCapability;
globalSettings: SettingCapability;
}
// eslint-disable-next-line import/no-default-export
export { Field as default } from './field';
interface SettingCapability {
show: boolean;
save: boolean;
}

View file

@ -66,6 +66,8 @@ export type {
} from './setting_type';
export type { CategorizedFields, CategoryCounts } from './category';
export type { SettingsTabs } from './tab';
export type { SettingsCapabilities } from './capabilities';
/**
* A React `ref` that indicates an input can be reset using an

View file

@ -6,6 +6,7 @@
* Side Public License, v 1.
*/
import { RegistryEntry } from '@kbn/management-settings-section-registry';
import { CategoryCounts } from './category';
import { FieldDefinition } from '.';
@ -16,5 +17,7 @@ export interface SettingsTabs {
categoryCounts: CategoryCounts;
callOutTitle: string;
callOutText: string;
sections: RegistryEntry[];
isSavingEnabled: boolean;
};
}

View file

@ -14,5 +14,6 @@
"@kbn/analytics",
"@kbn/core",
"@kbn/core-ui-settings-common",
"@kbn/management-settings-section-registry",
]
}

View file

@ -6,12 +6,15 @@
* Side Public License, v 1.
*/
import { FieldSetting } from '../types';
import { SettingsCapabilities } from '@kbn/management-settings-types';
export function isDefaultValue(setting: FieldSetting) {
return (
setting.isCustom ||
setting.value === undefined ||
String(setting.value) === String(setting.defVal)
);
}
export const getSettingsCapabilitiesMock = (): SettingsCapabilities => ({
spaceSettings: {
show: true,
save: true,
},
globalSettings: {
show: true,
save: true,
},
});

View file

@ -1,5 +1,5 @@
# Advanced Settings
This plugin contains the advanced settings management section
This plugin registers the [management settings application](packages/kbn-management/settings/application/application.tsx)
allowing users to configure their advanced settings, also known
as uiSettings within the code.

View file

@ -1,16 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
module.exports = {
preset: '@kbn/test',
rootDir: '../../..',
roots: ['<rootDir>/src/plugins/advanced_settings'],
coverageDirectory: '<rootDir>/target/kibana-coverage/jest/src/plugins/advanced_settings',
coverageReporters: ['text', 'html'],
collectCoverageFrom: ['<rootDir>/src/plugins/advanced_settings/{public,server}/**/*.{ts,tsx}'],
};

View file

@ -14,8 +14,6 @@
"usageCollection"
],
"requiredBundles": [
"kibanaReact",
"kibanaUtils"
]
}
}
}

View file

@ -6,23 +6,10 @@
* Side Public License, v 1.
*/
import React from 'react';
import { PluginInitializerContext } from '@kbn/core/public';
import { AdvancedSettingsPlugin } from './plugin';
export type { AdvancedSettingsSetup, AdvancedSettingsStart } from './types';
/**
* Exports the field component as a React.lazy component. We're explicitly naming it lazy here
* so any plugin that would import that can clearly see it's lazy loaded and can only be used
* inside a suspense context.
*/
const LazyField = React.lazy(() => import('./management_app/components/field'));
export { LazyField };
export { toEditableConfig } from './management_app/lib/to_editable_config';
export function plugin(initializerContext: PluginInitializerContext) {
return new AdvancedSettingsPlugin();
}
export type { FieldState } from './management_app/types';

View file

@ -1,43 +0,0 @@
.mgtAdvancedSettings__field {
+ * {
margin-top: $euiSize;
}
.mgtAdvancedSettings__fieldTitle {
padding-left: $euiSizeS;
margin-left: -$euiSizeS;
}
&--unsaved .mgtAdvancedSettings__fieldTitle {
// Simulates a left side border without shifting content
box-shadow: -$euiSizeXS 0 $euiColorWarning;
}
&--invalid .mgtAdvancedSettings__fieldTitle {
// Simulates a left side border without shifting content
box-shadow: -$euiSizeXS 0 $euiColorDanger;
}
}
.mgtAdvancedSettings__fieldTitleUnsavedIcon {
margin-left: $euiSizeS;
}
.mgtAdvancedSettingsForm__unsavedCount {
@include euiBreakpoint('xs') {
display: none;
}
}
.mgtAdvancedSettingsForm__unsavedCountMessage {
// Simulates a left side border without shifting content
box-shadow: -$euiSizeXS 0 $euiColorWarning;
padding-left: $euiSizeS;
}
.mgtAdvancedSettingsForm__button {
width: 100%;
}
.kbnBody--mgtAdvancedSettingsHasBottomBar .mgtPage__body {
padding-bottom: $euiSizeXL * 2;
}

View file

@ -1,99 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { Component } from 'react';
import { UiCounterMetricType } from '@kbn/analytics';
import { DocLinksStart, ToastsStart, ThemeServiceStart } from '@kbn/core/public';
import { KibanaContextProvider } from '@kbn/kibana-react-plugin/public';
import { EuiCallOut, EuiSpacer } from '@elastic/eui';
import { IUiSettingsClient, SettingsStart } from '@kbn/core-ui-settings-browser';
import { AdvancedSettingsVoiceAnnouncement } from './components/advanced_settings_voice_announcement';
import { Form } from './components/form';
import { FieldSetting, SettingsChanges } from './types';
export const QUERY = 'query';
interface AdvancedSettingsProps {
enableSaving: boolean;
settingsService: SettingsStart;
/** TODO: remove once use_ui_setting is changed to use the settings service
* https://github.com/elastic/kibana/issues/149347 */
uiSettingsClient: IUiSettingsClient;
docLinks: DocLinksStart['links'];
toasts: ToastsStart;
theme: ThemeServiceStart['theme$'];
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
groupedSettings: GroupedSettings;
categoryCounts: Record<string, number>;
categories: string[];
visibleSettings: Record<string, FieldSetting[]>;
noResults: boolean;
clearQuery: () => void;
queryText: string;
callOutTitle: string;
callOutSubtitle: string;
}
type GroupedSettings = Record<string, FieldSetting[]>;
export class AdvancedSettings extends Component<AdvancedSettingsProps> {
constructor(props: AdvancedSettingsProps) {
super(props);
}
saveConfig = async (changes: SettingsChanges) => {
const arr = Object.entries(changes).map(([key, value]) =>
this.props.uiSettingsClient.set(key, value)
);
return Promise.all(arr);
};
render() {
return (
<div>
<EuiSpacer size="xl" />
<EuiCallOut title={this.props.callOutTitle} iconType="warning">
<p>{this.props.callOutSubtitle}</p>
</EuiCallOut>
<EuiSpacer size="xl" />
<AdvancedSettingsVoiceAnnouncement
queryText={this.props.queryText}
settings={this.props.visibleSettings}
/>
<KibanaContextProvider
services={{
uiSettings: this.props.settingsService.client,
settings: this.props.settingsService,
theme: { theme$: this.props.theme },
}}
>
<Form
settings={this.props.groupedSettings}
visibleSettings={this.props.visibleSettings}
categories={this.props.categories}
categoryCounts={this.props.categoryCounts}
clearQuery={this.props.clearQuery}
save={this.saveConfig}
showNoResultsMessage={this.props.noResults}
enableSaving={this.props.enableSaving}
docLinks={this.props.docLinks}
toasts={this.props.toasts}
trackUiMetric={this.props.trackUiMetric}
queryText={this.props.queryText}
theme={this.props.theme}
/>
</KibanaContextProvider>
</div>
);
}
}

View file

@ -1,52 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Advanced Settings: Voice Announcement should render announcement 1`] = `
<EuiScreenReaderOnly>
<div
aria-label="Advanced Settings results info"
aria-live="polite"
role="region"
>
<EuiDelayRender
delay={500}
>
<FormattedMessage
defaultMessage="You searched for {query}. There {optionLenght, plural, one {is # option} other {are # options}} in {sectionLenght, plural, one {# section} other {# sections}}"
id="advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage"
values={
Object {
"optionLenght": 1,
"query": "dark theme",
"sectionLenght": 1,
}
}
/>
</EuiDelayRender>
</div>
</EuiScreenReaderOnly>
`;
exports[`Advanced Settings: Voice Announcement should render nothing 1`] = `
<EuiScreenReaderOnly>
<div
aria-label="Advanced Settings results info"
aria-live="polite"
role="region"
>
<EuiDelayRender
delay={500}
>
<FormattedMessage
defaultMessage="There {optionLenght, plural, one {is # option} other {are # options}} in {sectionLenght, plural, one {# section} other {# sections}}"
id="advancedSettings.voiceAnnouncement.noSearchResultScreenReaderMessage"
values={
Object {
"optionLenght": 1,
"sectionLenght": 1,
}
}
/>
</EuiDelayRender>
</div>
</EuiScreenReaderOnly>
`;

View file

@ -1,73 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { shallow } from 'enzyme';
import { UiSettingsType } from '@kbn/core/public';
import { AdvancedSettingsVoiceAnnouncement } from './advanced_settings_voice_announcement';
const settingPartial = {
name: 'name',
isOverridden: false,
type: 'string' as UiSettingsType,
value: 'value',
defVal: 'defVal',
optionLabels: { label: 'label' },
description: 'description',
displayName: 'displayName',
isCustom: false,
requiresPageReload: false,
options: [],
validation: { regex: /a/, message: 'message' },
category: ['category'],
readOnly: false,
};
const testProps = {
nothing: {
query: '',
filteredSettings: [
{
ariaName: 'General',
...settingPartial,
},
],
},
searchResult: {
query: 'dark theme',
filteredSettings: [
{
ariaName: 'General',
...settingPartial,
},
],
},
};
describe('Advanced Settings: Voice Announcement', () => {
it('should render nothing', async () => {
const { query, filteredSettings } = testProps.nothing;
const component = shallow(
<AdvancedSettingsVoiceAnnouncement queryText={query} settings={{ filteredSettings }} />
);
expect(component).toMatchSnapshot();
});
it('should render announcement', async () => {
const { query, filteredSettings } = testProps.searchResult;
const component = shallow(
<AdvancedSettingsVoiceAnnouncement queryText={query} settings={{ filteredSettings }} />
);
expect(component).toMatchSnapshot();
});
});

View file

@ -1,92 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/*
This component aims to insert assertive live region on the page,
to make sure that a screen reader announces layout changes.
Due to the fact that it has a specific way of detecting what-and-when announce
as well as delay of announcement (which depends on what a user is doing at the moment)
I place a 500ms delay of re-render the text of anouncement.
That time period is best fits the time of screen reader reaction.
That anouncement depends on what user is typying into search box as well as
the speed of ordinary screen reader pronouns what user is typing before start reading this anouncement.
The order of triggering functions:
1: React trigs the component to be updated
2: It places a timer and block render
3: The time is over
4: Component renders
5: If there is another component call, the timer is dropped (cleared).
*/
import React, { Component } from 'react';
import { FormattedMessage } from '@kbn/i18n-react';
import { EuiScreenReaderOnly, EuiDelayRender } from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FieldSetting } from '../../types';
interface Props {
queryText: string;
settings: Record<string, FieldSetting[]>;
}
export class AdvancedSettingsVoiceAnnouncement extends Component<Props> {
shouldComponentUpdate = (nextProps: Props) => {
/*
If a user typed smth new, we should clear the previous timer
and start another one + block component rendering.
When it is reset and delaying is over as well as no new string came,
it's ready to be rendered.
*/
return nextProps.queryText !== this.props.queryText;
};
render() {
const filteredSections = Object.values(this.props.settings).map((setting) =>
setting.map((option) => option.ariaName)
);
const filteredOptions = [...filteredSections];
return (
<EuiScreenReaderOnly>
<div
role="region"
aria-live="polite"
aria-label={i18n.translate('advancedSettings.voiceAnnouncement.ariaLabel', {
defaultMessage: 'Advanced Settings results info',
})}
>
<EuiDelayRender>
{this.props.queryText ? (
<FormattedMessage
id="advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage"
defaultMessage="You searched for {query}. There {optionLenght, plural, one {is # option} other {are # options}} in {sectionLenght, plural, one {# section} other {# sections}}"
values={{
query: this.props.queryText,
sectionLenght: filteredSections.length,
optionLenght: filteredOptions.length,
}}
/>
) : (
<FormattedMessage
id="advancedSettings.voiceAnnouncement.noSearchResultScreenReaderMessage"
defaultMessage="There {optionLenght, plural, one {is # option} other {are # options}} in {sectionLenght, plural, one {# section} other {# sections}}"
values={{
sectionLenght: filteredSections.length,
optionLenght: filteredOptions.length,
}}
/>
)}
</EuiDelayRender>
</div>
</EuiScreenReaderOnly>
);
}
}

View file

@ -1,9 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { AdvancedSettingsVoiceAnnouncement } from './advanced_settings_voice_announcement';

View file

@ -1,25 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`CallOuts should render normally 1`] = `
<div>
<EuiCallOut
color="warning"
iconType="bolt"
title={
<FormattedMessage
defaultMessage="Caution: You can break stuff here"
id="advancedSettings.callOutCautionTitle"
values={Object {}}
/>
}
>
<p>
<FormattedMessage
defaultMessage="Be careful in here, these settings are for very advanced users only. Tweaks you make here can break large portions of Kibana. Some of these settings may be undocumented, unsupported or in technical preview. If a field has a default value, blanking the field will reset it to its default which may be unacceptable given other configuration directives. Deleting a custom setting will permanently remove it from Kibana's config."
id="advancedSettings.callOutCautionDescription"
values={Object {}}
/>
</p>
</EuiCallOut>
</div>
`;

View file

@ -1,41 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { EuiCallOut } from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
export const CallOuts = () => {
return (
<div>
<EuiCallOut
title={
<FormattedMessage
id="advancedSettings.callOutCautionTitle"
defaultMessage="Caution: You can break stuff here"
/>
}
color="warning"
iconType="bolt"
>
<p>
<FormattedMessage
id="advancedSettings.callOutCautionDescription"
defaultMessage="Be careful in here, these settings are for very advanced users only.
Tweaks you make here can break large portions of Kibana.
Some of these settings may be undocumented, unsupported or in technical preview.
If a field has a default value, blanking the field will reset it to its default which may be
unacceptable given other configuration directives.
Deleting a custom setting will permanently remove it from Kibana's config."
/>
</p>
</EuiCallOut>
</div>
);
};

View file

@ -1,9 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { CallOuts } from './call_outs';

View file

@ -1,494 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { I18nProvider } from '@kbn/i18n-react';
import { shallowWithI18nProvider, mountWithI18nProvider } from '@kbn/test-jest-helpers';
import '@kbn/code-editor-mock/jest_helper';
import { mount, ReactWrapper } from 'enzyme';
import { FieldSetting } from '../../types';
import { UiSettingsType } from '@kbn/core/public';
import { notificationServiceMock, docLinksServiceMock } from '@kbn/core/public/mocks';
import { findTestSubject } from '@elastic/eui/lib/test';
import { Field, getEditableValue } from './field';
jest.mock('@kbn/kibana-react-plugin/public/ui_settings/use_ui_setting', () => ({
useUiSetting: jest.fn(),
}));
const defaults = {
requiresPageReload: false,
readOnly: false,
category: ['category'],
};
const exampleValues = {
array: ['example_value'],
boolean: false,
color: '#FF00CC',
image: 'data:image/gif;base64,R0lGODlhAQABAIAAAAUEBAAAACwAAAAAAQABAAACAkQBADs=',
json: { foo: 'bar2' },
markdown: 'Hello World',
number: 1,
select: 'banana',
string: 'hello world',
stringWithValidation: 'foo',
};
const settings: Record<string, FieldSetting> = {
array: {
name: 'array:test:setting',
ariaName: 'array test setting',
displayName: 'Array test setting',
description: 'Description for Array test setting',
type: 'array',
value: undefined,
defVal: ['default_value'],
isCustom: false,
isOverridden: false,
...defaults,
},
boolean: {
name: 'boolean:test:setting',
ariaName: 'boolean test setting',
displayName: 'Boolean test setting',
description: 'Description for Boolean test setting',
type: 'boolean',
value: undefined,
defVal: true,
isCustom: false,
isOverridden: false,
...defaults,
},
image: {
name: 'image:test:setting',
ariaName: 'image test setting',
displayName: 'Image test setting',
description: 'Description for Image test setting',
type: 'image',
value: undefined,
defVal: null,
isCustom: false,
isOverridden: false,
...defaults,
},
json: {
name: 'json:test:setting',
ariaName: 'json test setting',
displayName: 'Json test setting',
description: 'Description for Json test setting',
type: 'json',
value: '{"foo": "bar"}',
defVal: '{}',
isCustom: false,
isOverridden: false,
...defaults,
},
markdown: {
name: 'markdown:test:setting',
ariaName: 'markdown test setting',
displayName: 'Markdown test setting',
description: 'Description for Markdown test setting',
type: 'markdown',
value: undefined,
defVal: '',
isCustom: false,
isOverridden: false,
...defaults,
},
number: {
name: 'number:test:setting',
ariaName: 'number test setting',
displayName: 'Number test setting',
description: 'Description for Number test setting',
type: 'number',
value: undefined,
defVal: 5,
isCustom: false,
isOverridden: false,
...defaults,
},
select: {
name: 'select:test:setting',
ariaName: 'select test setting',
displayName: 'Select test setting',
description: 'Description for Select test setting',
type: 'select',
value: undefined,
defVal: 'orange',
isCustom: false,
isOverridden: false,
options: ['apple', 'orange', 'banana'],
optionLabels: {
apple: 'Apple',
orange: 'Orange',
// Deliberately left out `banana` to test if it also works with missing labels
},
...defaults,
},
string: {
name: 'string:test:setting',
ariaName: 'string test setting',
displayName: 'String test setting',
description: 'Description for String test setting',
type: 'string',
value: undefined,
defVal: null,
isCustom: false,
isOverridden: false,
...defaults,
},
stringWithValidation: {
name: 'string:test-validation:setting',
ariaName: 'string test validation setting',
displayName: 'String test validation setting',
description: 'Description for String test validation setting',
type: 'string',
value: undefined,
defVal: 'foo-default',
isCustom: false,
isOverridden: false,
...defaults,
},
color: {
name: 'color:test:setting',
ariaName: 'color test setting',
displayName: 'Color test setting',
description: 'Description for Color test setting',
type: 'color',
value: undefined,
defVal: null,
isCustom: false,
isOverridden: false,
...defaults,
},
};
const userValues = {
array: ['user', 'value'],
boolean: false,
image: 'data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw==',
json: '{"hello": "world"}',
markdown: '**bold**',
number: 10,
select: 'banana',
string: 'foo',
stringWithValidation: 'fooUserValue',
color: '#FACF0C',
};
const handleChange = jest.fn();
const clearChange = jest.fn();
const getFieldSettingValue = (wrapper: ReactWrapper, name: string, type: string) => {
const field = findTestSubject(wrapper, `advancedSetting-editField-${name}`);
if (type === 'boolean') {
return field.props()['aria-checked'];
} else if (type === 'color') {
return field.props().color;
} else {
return field.props().value;
}
};
describe('Field', () => {
Object.keys(settings).forEach((type) => {
const setting = settings[type];
describe(`for ${type} setting`, () => {
it('should render default value if there is no user value set', async () => {
const component = shallowWithI18nProvider(
<Field
setting={setting}
handleChange={handleChange}
enableSaving={true}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
/>
);
expect(component).toMatchSnapshot();
});
it('should render as read only with help text if overridden', async () => {
const component = shallowWithI18nProvider(
<Field
setting={{
...setting,
// @ts-ignore
value: userValues[type],
isOverridden: true,
}}
handleChange={handleChange}
enableSaving={true}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
/>
);
expect(component).toMatchSnapshot();
});
it('should render as read only if saving is disabled', async () => {
const component = shallowWithI18nProvider(
<Field
setting={setting}
handleChange={handleChange}
enableSaving={false}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
/>
);
expect(component).toMatchSnapshot();
});
it('should render user value if there is user value is set', async () => {
const component = shallowWithI18nProvider(
<Field
setting={{
...setting,
// @ts-ignore
value: userValues[type],
}}
handleChange={handleChange}
enableSaving={true}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
/>
);
expect(component).toMatchSnapshot();
});
it('should render custom setting icon if it is custom', async () => {
const component = shallowWithI18nProvider(
<Field
setting={{
...setting,
isCustom: true,
}}
handleChange={handleChange}
enableSaving={true}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
/>
);
expect(component).toMatchSnapshot();
});
it('should render unsaved value if there are unsaved changes', async () => {
const component = shallowWithI18nProvider(
<Field
setting={{
...setting,
isCustom: true,
}}
handleChange={handleChange}
enableSaving={true}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
unsavedChanges={{
// @ts-ignore
value: exampleValues[setting.type],
}}
/>
);
expect(component).toMatchSnapshot();
});
});
if (type === 'select') {
it('should use options for rendering values and optionsLabels for rendering labels', () => {
const component = mountWithI18nProvider(
<Field
setting={{
...setting,
isCustom: true,
}}
handleChange={handleChange}
enableSaving={true}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
/>
);
const select = findTestSubject(component, `advancedSetting-editField-${setting.name}`);
// @ts-ignore
const values = select.find('option').map((option) => option.prop('value'));
expect(values).toEqual(['apple', 'orange', 'banana']);
// @ts-ignore
const labels = select.find('option').map((option) => option.text());
expect(labels).toEqual(['Apple', 'Orange', 'banana']);
});
}
const setup = () => {
const Wrapper = (props: Record<string, any>) => (
<I18nProvider>
<Field
setting={setting}
clearChange={clearChange}
handleChange={handleChange}
enableSaving={true}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
{...props}
/>
</I18nProvider>
);
const wrapper = mount(<Wrapper />);
const component = wrapper.find(I18nProvider).find(Field);
return {
wrapper,
component,
};
};
if (type === 'image') {
describe(`for changing ${type} setting`, () => {
const { wrapper, component } = setup();
const userValue = userValues[type];
(component.instance() as Field).getImageAsBase64 = ({}: Blob) => Promise.resolve('');
it('should be able to change value and cancel', async () => {
(component.instance() as Field).onImageChange([userValue] as unknown as FileList);
expect(handleChange).toBeCalled();
await wrapper.setProps({
unsavedChanges: {
value: userValue,
changeImage: true,
},
setting: {
...(component.instance() as Field).props.setting,
value: userValue,
},
});
await (component.instance() as Field).cancelChangeImage();
expect(clearChange).toBeCalledWith(setting.name);
wrapper.update();
});
it('should be able to change value from existing value', async () => {
await wrapper.setProps({
unsavedChanges: {},
});
const updated = wrapper.update();
findTestSubject(updated, `advancedSetting-changeImage-${setting.name}`).simulate('click');
const newUserValue = `${userValue}=`;
await (component.instance() as Field).onImageChange([
newUserValue,
] as unknown as FileList);
expect(handleChange).toBeCalled();
});
it('should be able to reset to default value', async () => {
const updated = wrapper.update();
findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click');
expect(handleChange).toBeCalledWith(setting.name, {
value: getEditableValue(setting.type, setting.defVal, setting.defVal),
changeImage: true,
});
});
});
} else if (type === 'markdown' || type === 'json') {
describe(`for changing ${type} setting`, () => {
const { wrapper, component } = setup();
const userValue = userValues[type];
it('should be able to change value', async () => {
(component.instance() as Field).onCodeEditorChange(userValue as UiSettingsType);
expect(handleChange).toBeCalledWith(setting.name, { value: userValue });
await wrapper.setProps({
setting: {
...(component.instance() as Field).props.setting,
value: userValue,
},
});
wrapper.update();
});
it('should be able to reset to default value', async () => {
const updated = wrapper.update();
findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click');
expect(handleChange).toBeCalledWith(setting.name, {
value: getEditableValue(setting.type, setting.defVal),
});
});
if (type === 'json') {
it('should be able to clear value and have empty object populate', async () => {
await (component.instance() as Field).onCodeEditorChange('' as UiSettingsType);
wrapper.update();
expect(handleChange).toBeCalledWith(setting.name, { value: setting.defVal });
});
}
});
} else if (type === 'color') {
describe(`for changing ${type} setting`, () => {
const { wrapper, component } = setup();
const userValue = userValues[type];
it('should be able to change value', async () => {
await (component.instance() as Field).onFieldChange(userValue);
const updated = wrapper.update();
expect(handleChange).toBeCalledWith(setting.name, { value: userValue });
updated.setProps({ unsavedChanges: { value: userValue } });
const currentValue = wrapper.find('EuiColorPicker').prop('color');
expect(currentValue).toEqual(userValue);
});
it('should be able to reset to default value', async () => {
await wrapper.setProps({
unsavedChanges: {},
setting: { ...setting, value: userValue },
});
const updated = wrapper.update();
findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click');
const expectedEditableValue = getEditableValue(setting.type, setting.defVal);
expect(handleChange).toBeCalledWith(setting.name, {
value: expectedEditableValue,
});
updated.setProps({ unsavedChanges: { value: expectedEditableValue } });
const currentValue = wrapper.find('EuiColorPicker').prop('color');
expect(currentValue).toEqual(expectedEditableValue);
});
});
} else {
describe(`for changing ${type} setting`, () => {
const { wrapper, component } = setup();
// @ts-ignore
const userValue = userValues[type];
const fieldUserValue = type === 'array' ? userValue.join(', ') : userValue;
it('should be able to change value', async () => {
await (component.instance() as Field).onFieldChange(fieldUserValue);
const updated = wrapper.update();
expect(handleChange).toBeCalledWith(setting.name, { value: fieldUserValue });
updated.setProps({ unsavedChanges: { value: fieldUserValue } });
const currentValue = getFieldSettingValue(updated, setting.name, type);
expect(currentValue).toEqual(fieldUserValue);
});
it('should be able to reset to default value', async () => {
await wrapper.setProps({
unsavedChanges: {},
setting: { ...setting, value: fieldUserValue },
});
const updated = wrapper.update();
findTestSubject(updated, `advancedSetting-resetField-${setting.name}`).simulate('click');
const expectedEditableValue = getEditableValue(setting.type, setting.defVal);
expect(handleChange).toBeCalledWith(setting.name, {
value: expectedEditableValue,
});
updated.setProps({ unsavedChanges: { value: expectedEditableValue } });
const currentValue = getFieldSettingValue(updated, setting.name, type);
expect(currentValue).toEqual(expectedEditableValue);
});
});
}
});
});

View file

@ -1,667 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { PureComponent, Fragment } from 'react';
import classNames from 'classnames';
import {
EuiBadge,
EuiCode,
EuiCodeBlock,
EuiColorPicker,
EuiScreenReaderOnly,
EuiDescribedFormGroup,
EuiFieldNumber,
EuiFieldText,
EuiFilePicker,
EuiFormRow,
EuiIconTip,
EuiImage,
EuiLink,
EuiSpacer,
EuiText,
EuiSelect,
EuiSwitch,
EuiSwitchEvent,
EuiToolTip,
} from '@elastic/eui';
import { i18n } from '@kbn/i18n';
import { FormattedMessage } from '@kbn/i18n-react';
import { UiSettingsType, DocLinksStart, ToastsStart } from '@kbn/core/public';
import { FieldCodeEditor } from './field_code_editor';
import { FieldSetting, FieldState } from '../../types';
import { isDefaultValue } from '../../lib';
interface FieldProps {
setting: FieldSetting;
handleChange: (name: string, value: FieldState) => void;
enableSaving: boolean;
docLinks: DocLinksStart['links'];
toasts: ToastsStart;
clearChange?: (name: string) => void;
unsavedChanges?: FieldState;
loading?: boolean;
}
export const getEditableValue = (
type: UiSettingsType,
value: FieldSetting['value'],
defVal?: FieldSetting['defVal']
) => {
const val = value === null || value === undefined ? defVal : value;
switch (type) {
case 'array':
return (val as string[]).join(', ');
case 'boolean':
return !!val;
case 'number':
return Number(val);
case 'image':
return val;
default:
return val || '';
}
};
export class Field extends PureComponent<FieldProps> {
private changeImageForm = React.createRef<EuiFilePicker>();
getDisplayedDefaultValue(
type: UiSettingsType,
defVal: FieldSetting['defVal'],
optionLabels: Record<string, any> = {}
) {
if (defVal === undefined || defVal === null || defVal === '') {
return 'null';
}
switch (type) {
case 'array':
return (defVal as string[]).join(', ');
case 'select':
return optionLabels.hasOwnProperty(String(defVal))
? optionLabels[String(defVal)]
: String(defVal);
default:
return String(defVal);
}
}
handleChange = (unsavedChanges: FieldState) => {
this.props.handleChange(this.props.setting.name, unsavedChanges);
};
resetField = () => {
const { type, defVal } = this.props.setting;
if (type === 'image') {
this.cancelChangeImage();
return this.handleChange({
value: getEditableValue(type, defVal, defVal),
changeImage: true,
});
}
return this.handleChange({ value: getEditableValue(type, defVal) });
};
componentDidUpdate(prevProps: FieldProps) {
if (
prevProps.setting.type === 'image' &&
prevProps.unsavedChanges?.value &&
!this.props.unsavedChanges?.value
) {
this.cancelChangeImage();
}
}
onCodeEditorChange = (value: string) => {
const { defVal, type } = this.props.setting;
let newUnsavedValue;
let errorParams = {};
switch (type) {
case 'json':
const isJsonArray = Array.isArray(JSON.parse((defVal as string) || '{}'));
newUnsavedValue = value || (isJsonArray ? '[]' : '{}');
try {
JSON.parse(newUnsavedValue);
} catch (e) {
errorParams = {
error: i18n.translate('advancedSettings.field.codeEditorSyntaxErrorMessage', {
defaultMessage: 'Invalid JSON syntax',
}),
isInvalid: true,
};
}
break;
default:
newUnsavedValue = value;
}
this.handleChange({
value: newUnsavedValue,
...errorParams,
});
};
onFieldChangeSwitch = (e: EuiSwitchEvent) => {
return this.onFieldChange(e.target.checked);
};
onFieldChangeEvent = (e: React.ChangeEvent<HTMLInputElement | HTMLSelectElement>) =>
this.onFieldChange(e.target.value);
onFieldChange = (targetValue: any) => {
const { type, value, defVal, options } = this.props.setting;
let newUnsavedValue;
switch (type) {
case 'boolean':
const { unsavedChanges } = this.props;
const currentValue = unsavedChanges
? unsavedChanges.value
: getEditableValue(type, value, defVal);
newUnsavedValue = !currentValue;
break;
case 'number':
newUnsavedValue = Number(targetValue);
break;
case 'select':
if (typeof options?.[0] === 'number') {
newUnsavedValue = Number(targetValue);
} else {
newUnsavedValue = targetValue;
}
break;
default:
newUnsavedValue = targetValue;
}
this.handleChange({
value: newUnsavedValue,
});
};
onImageChange = async (files: FileList | null) => {
if (files == null) return;
if (!files.length) {
this.setState({
unsavedValue: null,
});
return;
}
const file = files[0];
try {
let base64Image = '';
if (file instanceof File) {
base64Image = (await this.getImageAsBase64(file)) as string;
}
this.handleChange({
changeImage: true,
value: base64Image,
});
} catch (err) {
this.props.toasts.addDanger(
i18n.translate('advancedSettings.field.imageChangeErrorMessage', {
defaultMessage: 'Image could not be saved',
})
);
this.cancelChangeImage();
}
};
async getImageAsBase64(file: Blob): Promise<string | ArrayBuffer> {
const reader = new FileReader();
reader.readAsDataURL(file);
return new Promise((resolve, reject) => {
reader.onload = () => {
resolve(reader.result!);
};
reader.onerror = (err) => {
reject(err);
};
});
}
changeImage = () => {
this.handleChange({
value: null,
changeImage: true,
});
};
cancelChangeImage = () => {
if (this.changeImageForm.current?.fileInput) {
this.changeImageForm.current.fileInput.value = '';
this.changeImageForm.current.handleChange();
}
if (this.props.clearChange) {
this.props.clearChange(this.props.setting.name);
}
};
renderField(setting: FieldSetting, ariaDescribedBy?: string) {
const { enableSaving, unsavedChanges, loading } = this.props;
const {
name,
value,
type,
options,
optionLabels = {},
isOverridden,
defVal,
ariaName,
} = setting;
const a11yProps: { [key: string]: string } = ariaDescribedBy
? {
'aria-label': ariaName,
'aria-describedby': ariaDescribedBy,
}
: {
'aria-label': ariaName,
};
const currentValue = unsavedChanges
? unsavedChanges.value
: getEditableValue(type, value, defVal);
switch (type) {
case 'boolean':
return (
<EuiSwitch
label={
!!currentValue ? (
<FormattedMessage id="advancedSettings.field.onLabel" defaultMessage="On" />
) : (
<FormattedMessage id="advancedSettings.field.offLabel" defaultMessage="Off" />
)
}
checked={!!currentValue}
onChange={this.onFieldChangeSwitch}
disabled={loading || isOverridden || !enableSaving}
data-test-subj={`advancedSetting-editField-${name}`}
{...a11yProps}
/>
);
case 'markdown':
case 'json':
return (
<div data-test-subj={`advancedSetting-editField-${name}`}>
<FieldCodeEditor
value={currentValue}
onChange={this.onCodeEditorChange}
type={type}
isReadOnly={isOverridden || !enableSaving}
a11yProps={a11yProps}
name={`advancedSetting-editField-${name}-editor`}
/>
</div>
);
case 'image':
const changeImage = unsavedChanges?.changeImage;
if (!isDefaultValue(setting) && !changeImage) {
return <EuiImage {...a11yProps} allowFullScreen url={value as string} alt={name} />;
} else {
return (
<EuiFilePicker
disabled={loading || isOverridden || !enableSaving}
onChange={this.onImageChange}
accept=".jpg,.jpeg,.png"
ref={this.changeImageForm}
fullWidth
data-test-subj={`advancedSetting-editField-${name}`}
aria-label={name}
/>
);
}
case 'select':
return (
<EuiSelect
{...a11yProps}
value={currentValue}
options={(options as string[]).map((option) => {
return {
text: optionLabels.hasOwnProperty(option) ? optionLabels[option] : option,
value: option,
};
})}
onChange={this.onFieldChangeEvent}
isLoading={loading}
disabled={loading || isOverridden || !enableSaving}
fullWidth
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
case 'number':
return (
<EuiFieldNumber
{...a11yProps}
value={currentValue}
onChange={this.onFieldChangeEvent}
isLoading={loading}
disabled={loading || isOverridden || !enableSaving}
fullWidth
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
case 'color':
return (
<EuiColorPicker
{...a11yProps}
color={currentValue}
onChange={this.onFieldChange}
disabled={loading || isOverridden || !enableSaving}
format="hex"
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
default:
return (
<EuiFieldText
{...a11yProps}
value={currentValue}
onChange={this.onFieldChangeEvent}
isLoading={loading}
disabled={loading || isOverridden || !enableSaving}
fullWidth
data-test-subj={`advancedSetting-editField-${name}`}
/>
);
}
}
renderLabel(setting: FieldSetting) {
return setting.name;
}
renderHelpText(setting: FieldSetting) {
if (setting.isOverridden) {
return (
<EuiText size="xs">
<FormattedMessage
id="advancedSettings.field.helpText"
defaultMessage="This setting is overridden by the Kibana server and can not be changed."
/>
</EuiText>
);
}
const canUpdateSetting = this.props.enableSaving;
const defaultLink = this.renderResetToDefaultLink(setting);
const imageLink = this.renderChangeImageLink(setting);
if (canUpdateSetting && (defaultLink || imageLink)) {
return (
<span>
{defaultLink}
{imageLink}
</span>
);
}
return null;
}
renderTitle(setting: FieldSetting) {
const { unsavedChanges } = this.props;
const isInvalid = unsavedChanges?.isInvalid;
const unsavedIconLabel = unsavedChanges
? isInvalid
? i18n.translate('advancedSettings.field.invalidIconLabel', {
defaultMessage: 'Invalid',
})
: i18n.translate('advancedSettings.field.unsavedIconLabel', {
defaultMessage: 'Unsaved',
})
: undefined;
return (
<h3>
<span className="mgtAdvancedSettings__fieldTitle">
{setting.displayName || setting.name}
</span>
{setting.isCustom ? (
<EuiIconTip
type="asterisk"
color="primary"
aria-label={i18n.translate('advancedSettings.field.customSettingAriaLabel', {
defaultMessage: 'Custom setting',
})}
content={
<FormattedMessage
id="advancedSettings.field.customSettingTooltip"
defaultMessage="Custom setting"
/>
}
/>
) : (
''
)}
{unsavedChanges ? (
<EuiIconTip
anchorClassName="mgtAdvancedSettings__fieldTitleUnsavedIcon"
type={isInvalid ? 'warning' : 'dot'}
color={isInvalid ? 'danger' : 'warning'}
aria-label={unsavedIconLabel}
content={unsavedIconLabel}
/>
) : (
''
)}
</h3>
);
}
renderDescription(setting: FieldSetting) {
let description;
let deprecation;
if (setting.deprecation) {
const links = this.props.docLinks;
deprecation = (
<>
<EuiToolTip content={setting.deprecation.message}>
<EuiBadge
color="warning"
onClick={() => {
window.open(links.management[setting.deprecation!.docLinksKey], '_blank');
}}
onClickAriaLabel={i18n.translate('advancedSettings.field.deprecationClickAreaLabel', {
defaultMessage: 'Click to view deprecation documentation for {settingName}.',
values: {
settingName: setting.name,
},
})}
>
Deprecated
</EuiBadge>
</EuiToolTip>
<EuiSpacer size="s" />
</>
);
}
if (React.isValidElement(setting.description)) {
description = setting.description;
} else {
description = (
<div
/*
* Justification for dangerouslySetInnerHTML:
* Setting description may contain formatting and links to documentation.
*/
dangerouslySetInnerHTML={{ __html: setting.description || '' }} // eslint-disable-line react/no-danger
/>
);
}
return (
<Fragment>
{deprecation}
{description}
{this.renderDefaultValue(setting)}
</Fragment>
);
}
renderDefaultValue(setting: FieldSetting) {
const { type, defVal, optionLabels } = setting;
if (isDefaultValue(setting)) {
return;
}
return (
<Fragment>
<EuiSpacer size="s" />
<EuiText size="xs">
{type === 'json' ? (
<Fragment>
<FormattedMessage
id="advancedSettings.field.defaultValueTypeJsonText"
defaultMessage="Default: {value}"
values={{
value: (
<EuiCodeBlock
language="json"
paddingSize="s"
overflowHeight={(defVal as string).length >= 500 ? 300 : undefined}
>
{this.getDisplayedDefaultValue(type, defVal)}
</EuiCodeBlock>
),
}}
/>
</Fragment>
) : (
<Fragment>
<FormattedMessage
id="advancedSettings.field.defaultValueText"
defaultMessage="Default: {value}"
values={{
value: (
<EuiCode>{this.getDisplayedDefaultValue(type, defVal, optionLabels)}</EuiCode>
),
}}
/>
</Fragment>
)}
</EuiText>
</Fragment>
);
}
renderResetToDefaultLink(setting: FieldSetting) {
const { defVal, ariaName, name } = setting;
if (
defVal === this.props.unsavedChanges?.value ||
isDefaultValue(setting) ||
this.props.loading
) {
return;
}
return (
<span>
<EuiLink
aria-label={i18n.translate('advancedSettings.field.resetToDefaultLinkAriaLabel', {
defaultMessage: 'Reset {ariaName} to default',
values: {
ariaName,
},
})}
onClick={this.resetField}
data-test-subj={`advancedSetting-resetField-${name}`}
>
<FormattedMessage
id="advancedSettings.field.resetToDefaultLinkText"
defaultMessage="Reset to default"
/>
</EuiLink>
&nbsp;&nbsp;&nbsp;
</span>
);
}
renderChangeImageLink(setting: FieldSetting) {
const changeImage = this.props.unsavedChanges?.changeImage;
const { type, value, ariaName, name } = setting;
if (type !== 'image' || !value || changeImage) {
return;
}
return (
<span>
<EuiLink
aria-label={i18n.translate('advancedSettings.field.changeImageLinkAriaLabel', {
defaultMessage: 'Change {ariaName}',
values: {
ariaName,
},
})}
onClick={this.changeImage}
data-test-subj={`advancedSetting-changeImage-${name}`}
>
<FormattedMessage
id="advancedSettings.field.changeImageLinkText"
defaultMessage="Change image"
/>
</EuiLink>
</span>
);
}
render() {
const { setting, unsavedChanges } = this.props;
const error = unsavedChanges?.error;
const isInvalid = unsavedChanges?.isInvalid;
const className = classNames('mgtAdvancedSettings__field', {
'mgtAdvancedSettings__field--unsaved': unsavedChanges,
'mgtAdvancedSettings__field--invalid': isInvalid,
});
const groupId = `${setting.name}-group`;
const unsavedId = `${setting.name}-unsaved`;
return (
<EuiDescribedFormGroup
id={groupId}
className={className}
title={this.renderTitle(setting)}
description={this.renderDescription(setting)}
fullWidth
>
<EuiFormRow
isInvalid={isInvalid}
error={error}
label={this.renderLabel(setting)}
helpText={this.renderHelpText(setting)}
className="mgtAdvancedSettings__fieldRow"
hasChildLabel={setting.type !== 'boolean'}
fullWidth
>
<>
{this.renderField(setting, unsavedChanges ? `${groupId} ${unsavedId}` : undefined)}
{unsavedChanges && (
<EuiScreenReaderOnly>
<p id={`${unsavedId}`}>
{unsavedChanges.error
? unsavedChanges.error
: i18n.translate('advancedSettings.field.settingIsUnsaved', {
defaultMessage: 'Setting is currently not saved.',
})}
</p>
</EuiScreenReaderOnly>
)}
</>
</EuiFormRow>
</EuiDescribedFormGroup>
);
}
}

View file

@ -1,107 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useCallback } from 'react';
import { monaco, XJsonLang } from '@kbn/monaco';
import { CodeEditor, MarkdownLang } from '@kbn/code-editor';
interface FieldCodeEditorProps {
value: string;
onChange: (value: string) => void;
type: 'markdown' | 'json';
isReadOnly: boolean;
a11yProps: Record<string, string>;
name: string;
}
const MIN_DEFAULT_LINES_COUNT = 6;
const MAX_DEFAULT_LINES_COUNT = 30;
export const FieldCodeEditor = ({
value,
onChange,
type,
isReadOnly,
a11yProps,
name,
}: FieldCodeEditorProps) => {
// setting editor height based on lines height and count to stretch and fit its content
const setEditorCalculatedHeight = useCallback(
(editor: monaco.editor.IStandaloneCodeEditor) => {
const editorElement = editor.getDomNode();
if (!editorElement) {
return;
}
const lineHeight = editor.getOption(monaco.editor.EditorOption.lineHeight);
let lineCount = editor.getModel()?.getLineCount() || MIN_DEFAULT_LINES_COUNT;
if (lineCount < MIN_DEFAULT_LINES_COUNT) {
lineCount = MIN_DEFAULT_LINES_COUNT;
} else if (lineCount > MAX_DEFAULT_LINES_COUNT) {
lineCount = MAX_DEFAULT_LINES_COUNT;
}
const height = lineHeight * lineCount;
editorElement.id = name;
editorElement.style.height = `${height}px`;
editor.layout();
},
[name]
);
const trimEditorBlankLines = useCallback((editor: monaco.editor.IStandaloneCodeEditor) => {
const editorModel = editor.getModel();
if (!editorModel) {
return;
}
const trimmedValue = editorModel.getValue().trim();
editorModel.setValue(trimmedValue);
}, []);
const editorDidMount = useCallback(
(editor) => {
setEditorCalculatedHeight(editor);
editor.onDidChangeModelContent(() => {
setEditorCalculatedHeight(editor);
});
editor.onDidBlurEditorWidget(() => {
trimEditorBlankLines(editor);
});
},
[setEditorCalculatedHeight, trimEditorBlankLines]
);
return (
<CodeEditor
{...a11yProps}
languageId={type === 'json' ? XJsonLang.ID : MarkdownLang}
value={value}
onChange={onChange}
editorDidMount={editorDidMount}
width="100%"
options={{
readOnly: isReadOnly,
lineNumbers: 'off',
scrollBeyondLastLine: false,
automaticLayout: true,
folding: false,
tabSize: 2,
scrollbar: {
alwaysConsumeMouseWheel: false,
},
wordWrap: 'on',
wrappingIndent: 'indent',
}}
/>
);
};

View file

@ -1,321 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { shallowWithI18nProvider, mountWithI18nProvider } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { UiSettingsType } from '@kbn/core/public';
import { themeServiceMock, notificationServiceMock } from '@kbn/core/public/mocks';
import { SettingsChanges } from '../../types';
import { Form } from './form';
jest.mock('../field', () => ({
Field: () => {
return 'field';
},
}));
beforeAll(() => {
const localStorage: Record<string, any> = {
'core.chrome.isLocked': true,
};
Object.defineProperty(window, 'localStorage', {
value: {
getItem: (key: string) => {
return localStorage[key] || null;
},
},
writable: true,
});
});
afterAll(() => {
delete (window as any).localStorage;
});
const defaults = {
requiresPageReload: false,
readOnly: false,
value: 'value',
description: 'description',
isOverridden: false,
type: 'string' as UiSettingsType,
isCustom: false,
defVal: 'defVal',
};
const settings = {
dashboard: [
{
...defaults,
name: 'dashboard:test:setting',
ariaName: 'dashboard test setting',
displayName: 'Dashboard test setting',
category: ['dashboard'],
requiresPageReload: true,
},
],
general: [
{
...defaults,
name: 'general:test:date',
ariaName: 'general test date',
displayName: 'Test date',
description: 'bar',
category: ['general'],
},
{
...defaults,
name: 'setting:test',
ariaName: 'setting test',
displayName: 'Test setting',
description: 'foo',
category: ['general'],
},
{
...defaults,
name: 'general:test:array',
ariaName: 'array test',
displayName: 'Test array setting',
description: 'array foo',
type: 'array' as UiSettingsType,
category: ['general'],
defVal: ['test'],
},
],
'x-pack': [
{
...defaults,
name: 'xpack:test:setting',
ariaName: 'xpack test setting',
displayName: 'X-Pack test setting',
category: ['x-pack'],
description: 'bar',
},
],
};
const categories = ['general', 'dashboard', 'hiddenCategory', 'x-pack'];
const categoryCounts = {
general: 2,
dashboard: 1,
'x-pack': 10,
};
const save = jest.fn((changes: SettingsChanges) => Promise.resolve([true]));
const clearQuery = () => {};
describe('Form', () => {
it('should render normally', async () => {
const component = shallowWithI18nProvider(
<Form
settings={settings}
visibleSettings={settings}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clearQuery={clearQuery}
showNoResultsMessage={true}
enableSaving={true}
toasts={{} as any}
docLinks={{} as any}
theme={themeServiceMock.createStartContract().theme$}
/>
);
expect(component).toMatchSnapshot();
});
it('should render read-only when saving is disabled', async () => {
const component = shallowWithI18nProvider(
<Form
settings={settings}
visibleSettings={settings}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clearQuery={clearQuery}
showNoResultsMessage={true}
enableSaving={false}
toasts={{} as any}
docLinks={{} as any}
theme={themeServiceMock.createStartContract().theme$}
/>
);
expect(component).toMatchSnapshot();
});
it('should render no settings message when there are no settings', async () => {
const component = shallowWithI18nProvider(
<Form
settings={{}}
visibleSettings={settings}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clearQuery={clearQuery}
showNoResultsMessage={true}
enableSaving={true}
toasts={{} as any}
docLinks={{} as any}
theme={themeServiceMock.createStartContract().theme$}
/>
);
expect(component).toMatchSnapshot();
});
it('should not render no settings message when instructed not to', async () => {
const component = shallowWithI18nProvider(
<Form
settings={{}}
visibleSettings={settings}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clearQuery={clearQuery}
showNoResultsMessage={false}
enableSaving={true}
toasts={{} as any}
docLinks={{} as any}
theme={themeServiceMock.createStartContract().theme$}
/>
);
expect(component).toMatchSnapshot();
});
it('should hide bottom bar when clicking on the cancel changes button', async () => {
const wrapper = mountWithI18nProvider(
<Form
settings={settings}
visibleSettings={settings}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clearQuery={clearQuery}
showNoResultsMessage={true}
enableSaving={false}
toasts={{} as any}
docLinks={{} as any}
theme={themeServiceMock.createStartContract().theme$}
/>
);
(wrapper.instance() as Form).setState({
unsavedChanges: {
'dashboard:test:setting': {
value: 'changedValue',
},
},
});
const updated = wrapper.update();
expect(updated.exists('[data-test-subj="advancedSetting-bottomBar"]')).toEqual(true);
await findTestSubject(updated, `advancedSetting-cancelButton`).simulate('click');
updated.update();
expect(updated.exists('[data-test-subj="advancedSetting-bottomBar"]')).toEqual(false);
});
it('should show a reload toast when saving setting requiring a page reload', async () => {
const toasts = notificationServiceMock.createStartContract().toasts;
const wrapper = mountWithI18nProvider(
<Form
settings={settings}
visibleSettings={settings}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clearQuery={clearQuery}
showNoResultsMessage={true}
enableSaving={false}
toasts={toasts}
docLinks={{} as any}
theme={themeServiceMock.createStartContract().theme$}
/>
);
(wrapper.instance() as Form).setState({
unsavedChanges: {
'dashboard:test:setting': {
value: 'changedValue',
},
},
});
const updated = wrapper.update();
findTestSubject(updated, `advancedSetting-saveButton`).simulate('click');
expect(save).toHaveBeenCalled();
await save({ 'dashboard:test:setting': 'changedValue' });
expect(toasts.add).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.stringContaining(
'One or more settings require you to reload the page to take effect.'
),
})
);
});
it('should save an array typed field when user provides an empty string correctly', async () => {
const wrapper = mountWithI18nProvider(
<Form
settings={settings}
visibleSettings={settings}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clearQuery={clearQuery}
showNoResultsMessage={true}
enableSaving={false}
toasts={{} as any}
docLinks={{} as any}
theme={themeServiceMock.createStartContract().theme$}
/>
);
(wrapper.instance() as Form).setState({
unsavedChanges: {
'general:test:array': {
value: '',
},
},
});
findTestSubject(wrapper.update(), `advancedSetting-saveButton`).simulate('click');
expect(save).toHaveBeenCalledWith({ 'general:test:array': [] });
});
it('should save an array typed field when user provides a comma separated string correctly', async () => {
const wrapper = mountWithI18nProvider(
<Form
settings={settings}
visibleSettings={settings}
categories={categories}
categoryCounts={categoryCounts}
save={save}
clearQuery={clearQuery}
showNoResultsMessage={true}
enableSaving={false}
toasts={{} as any}
docLinks={{} as any}
theme={themeServiceMock.createStartContract().theme$}
/>
);
(wrapper.instance() as Form).setState({
unsavedChanges: {
'general:test:array': {
value: 'test1, test2',
},
},
});
findTestSubject(wrapper.update(), `advancedSetting-saveButton`).simulate('click');
expect(save).toHaveBeenCalledWith({ 'general:test:array': ['test1', 'test2'] });
});
});

View file

@ -1,432 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { PureComponent, Fragment } from 'react';
import {
EuiFlexGroup,
EuiFlexItem,
EuiSplitPanel,
EuiLink,
EuiCallOut,
EuiSpacer,
EuiText,
EuiBottomBar,
EuiButton,
EuiToolTip,
EuiButtonEmpty,
EuiTitle,
} from '@elastic/eui';
import { FormattedMessage } from '@kbn/i18n-react';
import { isEmpty } from 'lodash';
import { i18n } from '@kbn/i18n';
import { UiCounterMetricType } from '@kbn/analytics';
import { KibanaThemeProvider, toMountPoint } from '@kbn/kibana-react-plugin/public';
import { DocLinksStart, ThemeServiceStart, ToastsStart } from '@kbn/core/public';
import { getCategoryName } from '../../lib';
import { Field, getEditableValue } from '../field';
import { FieldSetting, SettingsChanges, FieldState } from '../../types';
type Category = string;
interface FormProps {
settings: Record<string, FieldSetting[]>;
visibleSettings: Record<string, FieldSetting[]>;
categories: Category[];
categoryCounts: Record<string, number>;
clearQuery: () => void;
save: (changes: SettingsChanges) => Promise<boolean[]>;
showNoResultsMessage: boolean;
enableSaving: boolean;
docLinks: DocLinksStart['links'];
toasts: ToastsStart;
theme: ThemeServiceStart['theme$'];
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
queryText?: string;
}
interface FormState {
unsavedChanges: {
[key: string]: FieldState;
};
loading: boolean;
}
export class Form extends PureComponent<FormProps> {
state: FormState = {
unsavedChanges: {},
loading: false,
};
setLoading(loading: boolean) {
this.setState({
loading,
});
}
getSettingByKey = (key: string): FieldSetting | undefined => {
return Object.values(this.props.settings)
.flat()
.find((el) => el.name === key);
};
getCountOfUnsavedChanges = (): number => {
return Object.keys(this.state.unsavedChanges).length;
};
getCountOfHiddenUnsavedChanges = (): number => {
const shownSettings = Object.values(this.props.visibleSettings)
.flat()
.map((setting) => setting.name);
return Object.keys(this.state.unsavedChanges).filter((key) => !shownSettings.includes(key))
.length;
};
areChangesInvalid = (): boolean => {
const { unsavedChanges } = this.state;
return Object.values(unsavedChanges).some(({ isInvalid }) => isInvalid);
};
handleChange = (key: string, change: FieldState) => {
const setting = this.getSettingByKey(key);
if (!setting) {
return;
}
const { type, defVal, value } = setting;
const savedValue = getEditableValue(type, value, defVal);
if (change.value === savedValue) {
return this.clearChange(key);
}
this.setState({
unsavedChanges: {
...this.state.unsavedChanges,
[key]: change,
},
});
};
clearChange = (key: string) => {
if (!this.state.unsavedChanges[key]) {
return;
}
const unsavedChanges = { ...this.state.unsavedChanges };
delete unsavedChanges[key];
this.setState({
unsavedChanges,
});
};
clearAllUnsaved = () => {
this.setState({ unsavedChanges: {} });
};
saveAll = async () => {
this.setLoading(true);
const { unsavedChanges } = this.state;
if (isEmpty(unsavedChanges)) {
return;
}
const configToSave: SettingsChanges = {};
let requiresReload = false;
Object.entries(unsavedChanges).forEach(([name, { value }]) => {
const setting = this.getSettingByKey(name);
if (!setting) {
return;
}
const { defVal, type, requiresPageReload, metric } = setting;
let valueToSave = value;
let equalsToDefault = false;
switch (type) {
case 'array':
valueToSave = valueToSave.trim();
valueToSave =
valueToSave === '' ? [] : valueToSave.split(',').map((val: string) => val.trim());
equalsToDefault = valueToSave.join(',') === (defVal as string[]).join(',');
break;
case 'json':
const isArray = Array.isArray(JSON.parse((defVal as string) || '{}'));
valueToSave = valueToSave.trim();
valueToSave = valueToSave || (isArray ? '[]' : '{}');
case 'boolean':
if (metric && this.props.trackUiMetric) {
const metricName = valueToSave ? `${metric.name}_on` : `${metric.name}_off`;
this.props.trackUiMetric(metric.type, metricName);
}
default:
equalsToDefault = valueToSave === defVal;
}
if (requiresPageReload) {
requiresReload = true;
}
configToSave[name] = equalsToDefault ? null : valueToSave;
});
try {
await this.props.save(configToSave);
this.clearAllUnsaved();
if (requiresReload) {
this.renderPageReloadToast();
}
} catch (e) {
this.props.toasts.addDanger(
i18n.translate('advancedSettings.form.saveErrorMessage', {
defaultMessage: 'Unable to save',
})
);
}
this.setLoading(false);
};
renderPageReloadToast = () => {
this.props.toasts.add({
title: i18n.translate('advancedSettings.form.requiresPageReloadToastDescription', {
defaultMessage: 'One or more settings require you to reload the page to take effect.',
}),
toastLifeTimeMs: 15000,
text: toMountPoint(
<KibanaThemeProvider theme$={this.props.theme}>
<EuiFlexGroup justifyContent="flexEnd" gutterSize="s">
<EuiFlexItem grow={false}>
<EuiButton
size="s"
onClick={() => window.location.reload()}
data-test-subj="windowReloadButton"
>
{i18n.translate('advancedSettings.form.requiresPageReloadToastButtonLabel', {
defaultMessage: 'Reload page',
})}
</EuiButton>
</EuiFlexItem>
</EuiFlexGroup>
</KibanaThemeProvider>
),
color: 'success',
});
};
renderClearQueryLink(totalSettings: number, currentSettings: number) {
const { clearQuery } = this.props;
if (totalSettings !== currentSettings) {
return (
<EuiFlexItem grow={false}>
<em>
<FormattedMessage
id="advancedSettings.form.searchResultText"
defaultMessage="Search terms are hiding {settingsCount} settings {clearSearch}"
values={{
settingsCount: totalSettings - currentSettings,
clearSearch: (
<EuiLink onClick={clearQuery}>
<em>
<FormattedMessage
id="advancedSettings.form.clearSearchResultText"
defaultMessage="(clear search)"
/>
</em>
</EuiLink>
),
}}
/>
</em>
</EuiFlexItem>
);
}
return null;
}
renderCategory(category: Category, settings: FieldSetting[], totalSettings: number) {
return (
<Fragment key={category}>
<EuiSplitPanel.Outer hasBorder>
<EuiSplitPanel.Inner color="subdued">
<EuiFlexGroup alignItems="baseline">
<EuiFlexItem grow={false}>
<EuiTitle>
<h2>{getCategoryName(category)}</h2>
</EuiTitle>
</EuiFlexItem>
{this.renderClearQueryLink(totalSettings, settings.length)}
</EuiFlexGroup>
</EuiSplitPanel.Inner>
<EuiSplitPanel.Inner>
{settings.map((setting) => {
return (
<Field
key={setting.name}
setting={setting}
handleChange={this.handleChange}
unsavedChanges={this.state.unsavedChanges[setting.name]}
clearChange={this.clearChange}
enableSaving={this.props.enableSaving}
docLinks={this.props.docLinks}
toasts={this.props.toasts}
/>
);
})}
</EuiSplitPanel.Inner>
</EuiSplitPanel.Outer>
<EuiSpacer size="l" />
</Fragment>
);
}
maybeRenderNoSettings(clearQuery: FormProps['clearQuery']) {
if (this.props.showNoResultsMessage) {
return (
<EuiCallOut
color="danger"
title={
<>
<FormattedMessage
id="advancedSettings.form.noSearchResultText"
defaultMessage="No settings found for {queryText} {clearSearch}"
values={{
clearSearch: (
<EuiLink onClick={clearQuery}>
<FormattedMessage
id="advancedSettings.form.clearNoSearchResultText"
defaultMessage="(clear search)"
/>
</EuiLink>
),
queryText: <strong>{this.props.queryText}</strong>,
}}
/>
</>
}
/>
);
}
return null;
}
renderCountOfUnsaved = () => {
const unsavedCount = this.getCountOfUnsavedChanges();
const hiddenUnsavedCount = this.getCountOfHiddenUnsavedChanges();
return (
<EuiText className="mgtAdvancedSettingsForm__unsavedCountMessage">
<FormattedMessage
id="advancedSettings.form.countOfSettingsChanged"
defaultMessage="{unsavedCount} unsaved {unsavedCount, plural,
one {setting}
other {settings}
}{hiddenCount, plural,
=0 {}
other {, # hidden}
}"
values={{
unsavedCount,
hiddenCount: hiddenUnsavedCount,
}}
/>
</EuiText>
);
};
renderBottomBar = () => {
const areChangesInvalid = this.areChangesInvalid();
return (
<EuiBottomBar data-test-subj="advancedSetting-bottomBar">
<EuiFlexGroup
justifyContent="spaceBetween"
alignItems="center"
responsive={false}
gutterSize="s"
>
<EuiFlexItem grow={false} className="mgtAdvancedSettingsForm__unsavedCount">
<p id="aria-describedby.countOfUnsavedSettings">{this.renderCountOfUnsaved()}</p>
</EuiFlexItem>
<EuiFlexItem />
<EuiFlexItem grow={false}>
<EuiButtonEmpty
color="text"
size="s"
iconType="cross"
onClick={this.clearAllUnsaved}
aria-describedby="aria-describedby.countOfUnsavedSettings"
data-test-subj="advancedSetting-cancelButton"
>
{i18n.translate('advancedSettings.form.cancelButtonLabel', {
defaultMessage: 'Cancel changes',
})}
</EuiButtonEmpty>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiToolTip
content={
areChangesInvalid &&
i18n.translate('advancedSettings.form.saveButtonTooltipWithInvalidChanges', {
defaultMessage: 'Fix invalid settings before saving.',
})
}
>
<EuiButton
className="mgtAdvancedSettingsForm__button"
disabled={areChangesInvalid}
color="success"
fill
size="s"
iconType="check"
onClick={this.saveAll}
aria-describedby="aria-describedby.countOfUnsavedSettings"
isLoading={this.state.loading}
data-test-subj="advancedSetting-saveButton"
>
{i18n.translate('advancedSettings.form.saveButtonLabel', {
defaultMessage: 'Save changes',
})}
</EuiButton>
</EuiToolTip>
</EuiFlexItem>
</EuiFlexGroup>
</EuiBottomBar>
);
};
render() {
const { unsavedChanges } = this.state;
const { visibleSettings, categories, categoryCounts, clearQuery } = this.props;
const currentCategories: Category[] = [];
const hasUnsavedChanges = !isEmpty(unsavedChanges);
if (hasUnsavedChanges) {
document.body.classList.add('kbnBody--mgtAdvancedSettingsHasBottomBar');
} else {
document.body.classList.remove('kbnBody--mgtAdvancedSettingsHasBottomBar');
}
categories.forEach((category) => {
if (visibleSettings[category] && visibleSettings[category].length) {
currentCategories.push(category);
}
});
return (
<Fragment>
<div>
{currentCategories.length
? currentCategories.map((category) => {
return this.renderCategory(
category,
visibleSettings[category],
categoryCounts[category]
);
})
: this.maybeRenderNoSettings(clearQuery)}
</div>
{hasUnsavedChanges && this.renderBottomBar()}
</Fragment>
);
}
}

View file

@ -1,9 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { Form } from './form';

View file

@ -1,63 +0,0 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Search should render normally 1`] = `
<Fragment>
<EuiSearchBar
box={
Object {
"aria-label": "Search advanced settings",
"data-test-subj": "settingsSearchBar",
"incremental": true,
}
}
filters={
Array [
Object {
"field": "category",
"multiSelect": "or",
"name": "Category",
"options": Array [
Object {
"name": "General",
"value": "general",
},
Object {
"name": "Dashboard",
"value": "dashboard",
},
Object {
"name": "HiddenCategory",
"value": "hiddenCategory",
},
Object {
"name": "X-pack",
"value": "x-pack",
},
],
"type": "field_value_selection",
},
]
}
onChange={[Function]}
query={
Query {
"ast": _AST {
"_clauses": Array [],
"_indexedClauses": Object {
"field": Object {},
"group": Array [],
"is": Object {},
"term": Array [],
},
},
"syntax": Object {
"parse": [Function],
"print": [Function],
"printClause": [Function],
},
"text": "",
}
}
/>
</Fragment>
`;

View file

@ -1,9 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { Search } from './search';

View file

@ -1,63 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { shallowWithI18nProvider, mountWithI18nProvider } from '@kbn/test-jest-helpers';
import { findTestSubject } from '@elastic/eui/lib/test';
import { Query } from '@elastic/eui';
import { Search } from './search';
const query = Query.parse('');
const categories = ['general', 'dashboard', 'hiddenCategory', 'x-pack'];
describe('Search', () => {
it('should render normally', async () => {
const onQueryChange = () => {};
const component = shallowWithI18nProvider(
<Search query={query} categories={categories} onQueryChange={onQueryChange} />
);
expect(component).toMatchSnapshot();
});
it('should call parent function when query is changed', async () => {
// This test is brittle as it knows about implementation details
// (EuiFieldSearch uses onKeyup instead of onChange to handle input)
const onQueryChange = jest.fn();
const component = mountWithI18nProvider(
<Search query={query} categories={categories} onQueryChange={onQueryChange} />
);
findTestSubject(component, 'settingsSearchBar').simulate('keyup', {
target: { value: 'new filter' },
});
expect(onQueryChange).toHaveBeenCalledTimes(1);
});
it('should handle query parse error', async () => {
const onQueryChangeMock = jest.fn();
const component = mountWithI18nProvider(
<Search query={query} categories={categories} onQueryChange={onQueryChangeMock} />
);
const searchBar = findTestSubject(component, 'settingsSearchBar');
// Send invalid query
searchBar.simulate('keyup', { target: { value: '?' } });
expect(onQueryChangeMock).toHaveBeenCalledTimes(0);
expect(component.state().isSearchTextValid).toBe(false);
onQueryChangeMock.mockReset();
// Send valid query to ensure component can recover from invalid query
searchBar.simulate('keyup', { target: { value: 'dateFormat' } });
expect(onQueryChangeMock).toHaveBeenCalledTimes(1);
expect(component.state().isSearchTextValid).toBe(true);
});
});

View file

@ -1,100 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { Fragment, PureComponent } from 'react';
import { i18n } from '@kbn/i18n';
import { EuiSearchBar, EuiFormErrorText, Query } from '@elastic/eui';
import { getCategoryName } from '../../lib';
export const CATEGORY_FIELD = 'category';
interface SearchProps {
categories: string[];
query: Query;
onQueryChange: ({ query }: { query: Query }) => void;
}
export const parseErrorMsg = i18n.translate(
'advancedSettings.searchBar.unableToParseQueryErrorMessage',
{ defaultMessage: 'Unable to parse query' }
);
export class Search extends PureComponent<SearchProps> {
private categories: Array<{ value: string; name: string }> = [];
constructor(props: SearchProps) {
super(props);
const { categories } = props;
this.categories = categories.map((category) => {
return {
value: category,
name: getCategoryName(category),
};
});
}
state = {
isSearchTextValid: true,
parseErrorMessage: null,
};
onChange = ({ query, error }: { query: Query | null; error: { message: string } | null }) => {
if (error) {
this.setState({
isSearchTextValid: false,
parseErrorMessage: error.message,
});
return;
}
this.setState({
isSearchTextValid: true,
parseErrorMessage: null,
});
this.props.onQueryChange({ query: query! });
};
render() {
const { query } = this.props;
const box = {
incremental: true,
'data-test-subj': 'settingsSearchBar',
'aria-label': i18n.translate('advancedSettings.searchBarAriaLabel', {
defaultMessage: 'Search advanced settings',
}), // hack until EuiSearchBar is fixed
};
const filters = [
{
type: 'field_value_selection' as const,
field: CATEGORY_FIELD,
name: i18n.translate('advancedSettings.categorySearchLabel', {
defaultMessage: 'Category',
}),
multiSelect: 'or' as const,
options: this.categories,
},
];
let queryParseError;
if (!this.state.isSearchTextValid) {
queryParseError = (
<EuiFormErrorText>{`${parseErrorMsg}. ${this.state.parseErrorMessage}`}</EuiFormErrorText>
);
}
return (
<Fragment>
<EuiSearchBar box={box} filters={filters} onChange={this.onChange} query={query} />
{queryParseError}
</Fragment>
);
}
}

View file

@ -1,35 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
export const i18nTexts = {
defaultSpaceTabTitle: i18n.translate('advancedSettings.spaceSettingsTabTitle', {
defaultMessage: 'Space Settings',
}),
defaultSpaceCalloutTitle: i18n.translate('advancedSettings.defaultSpaceCalloutTitle', {
defaultMessage: 'Changes will affect the current space.',
}),
defaultSpaceCalloutSubtitle: i18n.translate('advancedSettings.defaultSpaceCalloutSubtitle', {
defaultMessage:
'Changes will only be applied to the current space. These settings are intended for advanced users, as improper configurations may adversely affect aspects of Kibana.',
}),
globalTabTitle: i18n.translate('advancedSettings.globalSettingsTabTitle', {
defaultMessage: 'Global Settings',
}),
globalCalloutTitle: i18n.translate('advancedSettings.globalCalloutTitle', {
defaultMessage: 'Changes will affect all user settings across all spaces',
}),
globalCalloutSubtitle: i18n.translate('advancedSettings.globalCalloutSubtitle', {
defaultMessage:
'Changes will be applied to all users across all spaces. This includes both native Kibana users and single-sign on users.',
}),
advancedSettingsTitle: i18n.translate('advancedSettings.advancedSettingsLabel', {
defaultMessage: 'Advanced Settings',
}),
};

View file

@ -1 +0,0 @@
@import './advanced_settings';

View file

@ -1,9 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export const DEFAULT_CATEGORY = 'general';

View file

@ -1,36 +0,0 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the Elastic License
* 2.0 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { getAriaName } from './get_aria_name';
describe('Settings', function () {
describe('Advanced', function () {
describe('getAriaName(name)', function () {
it('should return a space delimited lower-case string with no special characters', function () {
expect(getAriaName('xPack:defaultAdminEmail')).to.be('x pack default admin email');
expect(getAriaName('doc_table:highlight')).to.be('doc table highlight');
expect(getAriaName('foo')).to.be('foo');
});
it('should return an empty string if passed undefined or null', function () {
expect(getAriaName()).to.be('');
expect(getAriaName(undefined)).to.be('');
});
it('should preserve category string', function () {
expect(getAriaName('xPack:fooBar:foo_bar_baz category:(general)')).to.be(
'x pack foo bar foo bar baz category:(general)'
);
expect(getAriaName('xPack:fooBar:foo_bar_baz category:(general or discover)')).to.be(
'x pack foo bar foo bar baz category:(general or discover)'
);
});
});
});
});

View file

@ -1,53 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { words } from 'lodash';
import { Query } from '@elastic/eui';
import { CATEGORY_FIELD } from '../components/search/search';
const mapWords = (name?: string): string =>
words(name ?? '')
.map((word) => word.toLowerCase())
.join(' ');
/**
* @name {string} the name of the configuration object
* @returns {string} a space delimited, lowercase string with
* special characters removed.
*
* Examples:
* - `xPack:fooBar:foo_bar_baz` -> `x pack foo bar foo bar baz`
* - `xPack:fooBar:foo_bar_baz category:(general)` -> `x pack foo bar foo bar baz category:(general)`
*/
export function getAriaName(name?: string) {
if (!name) {
return '';
}
const query = Query.parse(name);
if (query.hasOrFieldClause(CATEGORY_FIELD)) {
const categories = query.getOrFieldClause(CATEGORY_FIELD);
const termValue = mapWords(query.removeOrFieldClauses(CATEGORY_FIELD).text);
if (!categories || !Array.isArray(categories.value)) {
return termValue;
}
let categoriesQuery = Query.parse('');
categories.value.forEach((v) => {
categoriesQuery = categoriesQuery.addOrFieldValue(CATEGORY_FIELD, v);
});
return `${termValue} ${categoriesQuery.text}`;
}
return mapWords(name);
}

View file

@ -1,25 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { getCategoryName } from './get_category_name';
describe('Settings', function () {
describe('Advanced', function () {
describe('getCategoryName(category)', function () {
it('should capitalize unknown category', function () {
expect(getCategoryName('elasticsearch')).to.be('Elasticsearch');
});
it('should return empty string for no category', function () {
expect(getCategoryName()).to.be('');
expect(getCategoryName('')).to.be('');
});
});
});
});

View file

@ -1,54 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { i18n } from '@kbn/i18n';
const upperFirst = (str = '') => str.replace(/^./, (strng) => strng.toUpperCase());
const names: Record<string, string> = {
general: i18n.translate('advancedSettings.categoryNames.generalLabel', {
defaultMessage: 'General',
}),
machineLearning: i18n.translate('advancedSettings.categoryNames.machineLearningLabel', {
defaultMessage: 'Machine Learning',
}),
observability: i18n.translate('advancedSettings.categoryNames.observabilityLabel', {
defaultMessage: 'Observability',
}),
timelion: i18n.translate('advancedSettings.categoryNames.timelionLabel', {
defaultMessage: 'Timelion',
}),
notifications: i18n.translate('advancedSettings.categoryNames.notificationsLabel', {
defaultMessage: 'Notifications',
}),
visualizations: i18n.translate('advancedSettings.categoryNames.visualizationsLabel', {
defaultMessage: 'Visualizations',
}),
discover: i18n.translate('advancedSettings.categoryNames.discoverLabel', {
defaultMessage: 'Discover',
}),
dashboard: i18n.translate('advancedSettings.categoryNames.dashboardLabel', {
defaultMessage: 'Dashboard',
}),
reporting: i18n.translate('advancedSettings.categoryNames.reportingLabel', {
defaultMessage: 'Reporting',
}),
search: i18n.translate('advancedSettings.categoryNames.searchLabel', {
defaultMessage: 'Search',
}),
securitySolution: i18n.translate('advancedSettings.categoryNames.securitySolutionLabel', {
defaultMessage: 'Security Solution',
}),
enterpriseSearch: i18n.translate('advancedSettings.categoryNames.enterpriseSearchLabel', {
defaultMessage: 'Enterprise Search',
}),
};
export function getCategoryName(category?: string) {
return category ? names[category] || upperFirst(category) : '';
}

View file

@ -1,39 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { getValType } from './get_val_type';
describe('Settings', function () {
describe('Advanced', function () {
describe('getValType(def, val)', function () {
it('should return the explicitly defined type of a setting', function () {
expect(getValType({ type: 'string' })).to.be('string');
expect(getValType({ type: 'json' })).to.be('json');
expect(getValType({ type: 'string', value: 5 })).to.be('string');
});
it('should return array if the value is an Array and there is no defined type', function () {
expect(getValType({ type: 'string' }, [1, 2, 3])).to.be('string');
expect(getValType({ type: 'json', value: [1, 2, 3] })).to.be('json');
expect(getValType({ value: 'someString' }, [1, 2, 3])).to.be('array');
expect(getValType({ value: [1, 2, 3] }, 'someString')).to.be('array');
});
it('should return the type of the default value if there is no type and it is not an array', function () {
expect(getValType({ value: 'someString' })).to.be('string');
expect(getValType({ value: 'someString' }, 42)).to.be('string');
});
it('should return the type of the value if the default value is null', function () {
expect(getValType({ value: null }, 'someString')).to.be('string');
});
});
});
});

View file

@ -1,40 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
/**
* @param {object} advanced setting definition object
* @param {?} current value of the setting
* @returns {string} the type to use for determining the display and editor
*/
import { UiSettingsType } from '@kbn/core/public';
import { FieldSetting } from '../types';
export function getValType(def: Partial<FieldSetting>, value?: any): UiSettingsType {
if (def.type) {
return def.type;
}
if (Array.isArray(value) || Array.isArray(def.value)) {
return 'array';
}
const typeofVal = def.value != null ? typeof def.value : typeof value;
if (typeofVal === 'bigint') {
return 'number';
}
if (typeofVal === 'symbol' || typeofVal === 'object' || typeofVal === 'function') {
throw new Error(
`incompatible UiSettingsType: '${def.name}' type ${typeofVal} | ${JSON.stringify(def)}`
);
}
return typeofVal;
}

View file

@ -1,14 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
export { isDefaultValue } from './is_default_value';
export { toEditableConfig } from './to_editable_config';
export { getCategoryName } from './get_category_name';
export { DEFAULT_CATEGORY } from './default_category';
export { getAriaName } from './get_aria_name';
export { fieldSorter } from './sort_fields';

View file

@ -1,76 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import expect from '@kbn/expect';
import { isDefaultValue } from './is_default_value';
import { UiSettingsType } from '@kbn/core/public';
describe('Settings', function () {
describe('Advanced', function () {
describe('getCategoryName(category)', function () {
describe('when given a setting definition object', function () {
const setting = {
isCustom: false,
value: 'value',
defVal: 'defaultValue',
displayName: 'displayName',
name: 'name',
ariaName: 'ariaName',
description: 'description',
requiresPageReload: false,
type: 'string' as UiSettingsType,
isOverridden: false,
readOnly: false,
options: [],
optionLabels: { option: 'label' },
category: ['category'],
validation: { regex: /regexString/, message: 'validation description' },
};
describe('that is custom', function () {
it('should return true', function () {
expect(isDefaultValue({ ...setting, isCustom: true })).to.be(true);
});
});
describe('without a value', function () {
it('should return false for empty string but true for undefined', function () {
expect(isDefaultValue({ ...setting, value: undefined })).to.be(true);
expect(isDefaultValue({ ...setting, value: '' })).to.be(false);
});
});
describe('with a value that is the same as the default value', function () {
it('should return true', function () {
expect(isDefaultValue({ ...setting, value: 'defaultValue' })).to.be(true);
expect(isDefaultValue({ ...setting, value: [], defVal: [] })).to.be(true);
expect(
isDefaultValue({ ...setting, value: '{"foo":"bar"}', defVal: '{"foo":"bar"}' })
).to.be(true);
expect(isDefaultValue({ ...setting, value: 123, defVal: 123 })).to.be(true);
expect(isDefaultValue({ ...setting, value: 456, defVal: '456' })).to.be(true);
expect(isDefaultValue({ ...setting, value: false, defVal: false })).to.be(true);
});
});
describe('with a value that is different than the default value', function () {
it('should return false', function () {
expect(isDefaultValue({ ...setting })).to.be(false);
expect(isDefaultValue({ ...setting, value: [1], defVal: [2] })).to.be(false);
expect(
isDefaultValue({ ...setting, value: '{"foo":"bar"}', defVal: '{"foo2":"bar2"}' })
).to.be(false);
expect(isDefaultValue({ ...setting, value: 123, defVal: 1234 })).to.be(false);
expect(isDefaultValue({ ...setting, value: 456, defVal: '4567' })).to.be(false);
expect(isDefaultValue({ ...setting, value: true, defVal: false })).to.be(false);
});
});
});
});
});
});

View file

@ -1,56 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { FieldSetting } from '../types';
import { fieldSorter } from './sort_fields';
const createField = (parts: Partial<FieldSetting>): FieldSetting => ({
displayName: 'displayName',
name: 'field',
value: 'value',
requiresPageReload: false,
type: 'string',
category: [],
ariaName: 'ariaName',
isOverridden: false,
defVal: 'defVal',
isCustom: false,
...parts,
});
describe('fieldSorter', () => {
it('sort fields based on their `order` field if present on both', () => {
const fieldA = createField({ order: 3 });
const fieldB = createField({ order: 1 });
const fieldC = createField({ order: 2 });
expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]);
});
it('fields with order defined are ordered first', () => {
const fieldA = createField({ order: 2 });
const fieldB = createField({ order: undefined });
const fieldC = createField({ order: 1 });
expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldA, fieldB]);
});
it('sorts by `name` when fields have the same `order`', () => {
const fieldA = createField({ order: 2, name: 'B' });
const fieldB = createField({ order: 1 });
const fieldC = createField({ order: 2, name: 'A' });
expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldB, fieldC, fieldA]);
});
it('sorts by `name` when fields have no `order`', () => {
const fieldA = createField({ order: undefined, name: 'B' });
const fieldB = createField({ order: undefined, name: 'A' });
const fieldC = createField({ order: 1 });
expect([fieldA, fieldB, fieldC].sort(fieldSorter)).toEqual([fieldC, fieldB, fieldA]);
});
});

View file

@ -1,31 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { Comparators } from '@elastic/eui';
import { FieldSetting } from '../types';
const cmp = Comparators.default('asc');
export const fieldSorter = (a: FieldSetting, b: FieldSetting): number => {
const aOrder = a.order !== undefined;
const bOrder = b.order !== undefined;
if (aOrder && bOrder) {
if (a.order === b.order) {
return cmp(a.name, b.name);
}
return cmp(a.order, b.order);
}
if (aOrder) {
return -1;
}
if (bOrder) {
return 1;
}
return cmp(a.name, b.name);
};

View file

@ -1,106 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PublicUiSettingsParams } from '@kbn/core/public';
import expect from '@kbn/expect';
import { toEditableConfig } from './to_editable_config';
const defDefault = {
isOverridden: true,
};
function invoke({
def = defDefault,
name = 'woah',
value = 'forreal',
}: {
def?: PublicUiSettingsParams & { isOverridden?: boolean };
name?: string;
value?: any;
}) {
return toEditableConfig({ def, name, value, isCustom: def === defDefault, isOverridden: true });
}
describe('Settings', function () {
describe('Advanced', function () {
describe('toEditableConfig(def, name, value)', function () {
it('sets name', function () {
expect(invoke({ name: 'who' }).name).to.equal('who');
});
it('sets value', function () {
expect(invoke({ value: 'what' }).value).to.equal('what');
});
it('sets type', function () {
expect(invoke({ value: 'what' }).type).to.be('string');
expect(invoke({ value: 0 }).type).to.be('number');
expect(invoke({ value: [] }).type).to.be('array');
});
describe('when given a setting definition object', function () {
let def: PublicUiSettingsParams & { isOverridden?: boolean };
beforeEach(function () {
def = {
value: 'the original',
description: 'the one and only',
options: ['all the options'],
};
});
it('is not marked as custom', function () {
expect(invoke({ def }).isCustom).to.be(false);
});
it('sets a default value', function () {
expect(invoke({ def }).defVal).to.equal(def.value);
});
it('sets a description', function () {
expect(invoke({ def }).description).to.equal(def.description);
});
it('sets options', function () {
expect(invoke({ def }).options).to.equal(def.options);
});
describe('that contains a type', function () {
it('sets that type', function () {
def.type = 'string';
expect(invoke({ def }).type).to.equal(def.type);
});
});
describe('that contains a value of type array', function () {
it('sets type to array', function () {
def.value = [];
expect(invoke({ def }).type).to.equal('array');
});
});
});
describe('when not given a setting definition object', function () {
it('is marked as custom', function () {
expect(invoke({}).isCustom).to.be(true);
});
it('sets defVal to undefined', function () {
expect(invoke({}).defVal).to.be(undefined);
});
it('sets description to undefined', function () {
expect(invoke({}).description).to.be(undefined);
});
it('sets options to undefined', function () {
expect(invoke({}).options).to.be(undefined);
});
});
});
});
});

View file

@ -1,59 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { PublicUiSettingsParams, UserProvidedValues, SavedObjectAttribute } from '@kbn/core/public';
import { FieldSetting } from '../types';
import { getValType } from './get_val_type';
import { getAriaName } from './get_aria_name';
import { DEFAULT_CATEGORY } from './default_category';
/**
* @param {object} advanced setting definition object
* @param {object} name of setting
* @param {object} current value of setting
* @returns {object} the editable config object
*/
export function toEditableConfig({
def,
name,
value,
isCustom,
isOverridden,
}: {
def: PublicUiSettingsParams & UserProvidedValues<any>;
name: string;
value: SavedObjectAttribute;
isCustom: boolean;
isOverridden: boolean;
}) {
if (!def) {
def = {};
}
const conf: FieldSetting = {
name,
displayName: def.name || name,
ariaName: def.name || getAriaName(name),
value,
category: def.category && def.category.length ? def.category : [DEFAULT_CATEGORY],
isCustom,
isOverridden,
readOnly: !!def.readonly,
defVal: def.value,
type: getValType(def, value),
description: def.description,
deprecation: def.deprecation,
options: def.options,
optionLabels: def.optionLabels,
order: def.order,
requiresPageReload: !!def.requiresPageReload,
metric: def.metric,
};
return conf;
}

View file

@ -1,109 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { Redirect, RouteChildrenProps } from 'react-router-dom';
import { Router, Routes, Route } from '@kbn/shared-ux-router';
import { i18n } from '@kbn/i18n';
import { LocationDescriptor } from 'history';
import { url } from '@kbn/kibana-utils-plugin/public';
import { ManagementAppMountParams } from '@kbn/management-plugin/public';
import { UsageCollectionSetup } from '@kbn/usage-collection-plugin/public';
import { StartServicesAccessor } from '@kbn/core/public';
import type { SectionRegistryStart } from '@kbn/management-settings-section-registry';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { QUERY } from './advanced_settings';
import { Settings } from './settings';
import './index.scss';
const title = i18n.translate('advancedSettings.advancedSettingsLabel', {
defaultMessage: 'Advanced Settings',
});
const crumb = [{ text: title }];
const readOnlyBadge = {
text: i18n.translate('advancedSettings.badge.readOnly.text', {
defaultMessage: 'Read only',
}),
tooltip: i18n.translate('advancedSettings.badge.readOnly.tooltip', {
defaultMessage: 'Unable to save advanced settings',
}),
iconType: 'glasses',
};
type RedirectUrlProps = RouteChildrenProps<{ [QUERY]: string }>;
const redirectUrl = ({ match, location }: RedirectUrlProps): LocationDescriptor => {
const search = url.addQueryParam(location.search, QUERY, match?.params[QUERY]);
return {
pathname: '/',
search,
};
};
export async function mountManagementSection(
getStartServices: StartServicesAccessor,
params: ManagementAppMountParams,
sectionRegistry: SectionRegistryStart,
usageCollection?: UsageCollectionSetup
) {
params.setBreadcrumbs(crumb);
const [{ settings, notifications, docLinks, application, chrome, i18n: i18nStart, theme }] =
await getStartServices();
const { advancedSettings, globalSettings } = application.capabilities;
const canSaveAdvancedSettings = advancedSettings.save as boolean;
const canSaveGlobalSettings = globalSettings.save as boolean;
const canShowGlobalSettings = globalSettings.show as boolean;
const trackUiMetric = usageCollection?.reportUiCounter.bind(usageCollection, 'advanced_settings');
if (!canSaveAdvancedSettings || (!canSaveGlobalSettings && canShowGlobalSettings)) {
chrome.setBadge(readOnlyBadge);
}
chrome.docTitle.change(title);
ReactDOM.render(
<KibanaRenderContextProvider {...{ i18n: i18nStart, theme }}>
<Router history={params.history}>
<Routes>
{/* TODO: remove route param (`query`) in 7.13 */}
<Route path={`/:${QUERY}`}>
{(props: RedirectUrlProps) => <Redirect to={redirectUrl(props)} />}
</Route>
<Route path="/">
<Settings
history={params.history}
enableSaving={{
namespace: canSaveAdvancedSettings,
global: canSaveGlobalSettings,
}}
enableShowing={{ namespace: true, global: canShowGlobalSettings }}
toasts={notifications.toasts}
docLinks={docLinks.links}
settingsService={settings}
theme={params.theme$}
sectionRegistry={sectionRegistry}
trackUiMetric={trackUiMetric}
/>
</Route>
</Routes>
</Router>
</KibanaRenderContextProvider>,
params.element
);
return () => {
chrome.docTitle.reset();
ReactDOM.unmountComponentAtNode(params.element);
};
}

View file

@ -1,376 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React from 'react';
import { Observable } from 'rxjs';
import { ReactWrapper } from 'enzyme';
import { mountWithI18nProvider } from '@kbn/test-jest-helpers';
import dedent from 'dedent';
import { PublicUiSettingsParams, UserProvidedValues, UiSettingsType } from '@kbn/core/public';
import { settingsServiceMock } from '@kbn/core-ui-settings-browser-mocks';
import { FieldSetting } from './types';
import { Settings } from './settings';
import {
notificationServiceMock,
docLinksServiceMock,
themeServiceMock,
} from '@kbn/core/public/mocks';
import { SectionRegistry } from '@kbn/management-settings-section-registry';
import { Search } from './components/search';
import { EuiTab } from '@elastic/eui';
jest.mock('./components/field', () => ({
Field: () => {
return 'field';
},
}));
jest.mock('./components/call_outs', () => ({
CallOuts: () => {
return 'callOuts';
},
}));
jest.mock('./components/search', () => ({
Search: () => {
return 'search';
},
}));
function mockConfig() {
const defaultConfig: Partial<FieldSetting> = {
displayName: 'defaultName',
requiresPageReload: false,
isOverridden: false,
ariaName: 'ariaName',
readOnly: false,
isCustom: false,
defVal: 'defVal',
type: 'string' as UiSettingsType,
category: ['category'],
};
const config = {
set: (key: string, value: any) => Promise.resolve(true),
remove: (key: string) => Promise.resolve(true),
isCustom: (key: string) => false,
isOverridden: (key: string) => Boolean(config.getAll()[key].isOverridden),
getRegistered: () => ({} as Readonly<Record<string, PublicUiSettingsParams>>),
getUpdate$: () =>
new Observable<{
key: string;
newValue: any;
oldValue: any;
}>(),
isDeclared: (key: string) => true,
isDefault: (key: string) => true,
getSaved$: () =>
new Observable<{
key: string;
newValue: any;
oldValue: any;
}>(),
getUpdateErrors$: () => new Observable<Error>(),
get: (key: string, defaultOverride?: any): any => config.getAll()[key] || defaultOverride,
get$: (key: string) => new Observable<any>(config.get(key)),
getAll: (): Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>> => {
return {
'test:array:setting': {
...defaultConfig,
value: ['default_value'],
name: 'Test array setting',
description: 'Description for Test array setting',
category: ['elasticsearch'],
},
'test:boolean:setting': {
...defaultConfig,
value: true,
name: 'Test boolean setting',
description: 'Description for Test boolean setting',
category: ['elasticsearch'],
},
'test:image:setting': {
...defaultConfig,
value: null,
name: 'Test image setting',
description: 'Description for Test image setting',
type: 'image',
},
'test:json:setting': {
...defaultConfig,
value: '{"foo": "bar"}',
name: 'Test json setting',
description: 'Description for Test json setting',
type: 'json',
},
'test:markdown:setting': {
...defaultConfig,
value: '',
name: 'Test markdown setting',
description: 'Description for Test markdown setting',
type: 'markdown',
},
'test:number:setting': {
...defaultConfig,
value: 5,
name: 'Test number setting',
description: 'Description for Test number setting',
},
'test:select:setting': {
...defaultConfig,
value: 'orange',
name: 'Test select setting',
description: 'Description for Test select setting',
type: 'select',
options: ['apple', 'orange', 'banana'],
},
'test:string:setting': {
...defaultConfig,
...{
value: null,
name: 'Test string setting',
description: 'Description for Test string setting',
type: 'string',
isCustom: true,
},
},
'test:readonlystring:setting': {
...defaultConfig,
...{
value: null,
name: 'Test readonly string setting',
description: 'Description for Test readonly string setting',
type: 'string',
readOnly: true,
},
},
'test:customstring:setting': {
...defaultConfig,
...{
value: null,
name: 'Test custom string setting',
description: 'Description for Test custom string setting',
type: 'string',
isCustom: true,
},
},
'test:isOverridden:string': {
...defaultConfig,
isOverridden: true,
value: 'foo',
name: 'An overridden string',
description: 'Description for overridden string',
type: 'string',
},
'test:isOverridden:number': {
...defaultConfig,
isOverridden: true,
value: 1234,
name: 'An overridden number',
description: 'Description for overridden number',
type: 'number',
},
'test:isOverridden:json': {
...defaultConfig,
isOverridden: true,
value: dedent`
{
"foo": "bar"
}
`,
name: 'An overridden json',
description: 'Description for overridden json',
type: 'json',
},
'test:isOverridden:select': {
...defaultConfig,
isOverridden: true,
value: 'orange',
name: 'Test overridden select setting',
description: 'Description for overridden select setting',
type: 'select',
options: ['apple', 'orange', 'banana'],
},
};
},
validateValue: (key: string, value: any) =>
Promise.resolve({ successfulValidation: true, valid: true }),
};
return {
core: {
settings: {
client: config,
globalClient: settingsServiceMock.createStartContract().globalClient,
},
},
plugins: {
advancedSettings: {
componentRegistry: {
get: () => {
const foo: React.ComponentType = () => <div>Hello</div>;
foo.displayName = 'foo_component';
return foo;
},
componentType: {
PAGE_TITLE_COMPONENT: 'page_title_component',
PAGE_SUBTITLE_COMPONENT: 'page_subtitle_component',
},
},
},
},
};
}
describe('Settings', () => {
const defaultQuery = 'test:string:setting';
const mockHistory = {
listen: jest.fn(),
} as any;
const locationSpy = jest.spyOn(window, 'location', 'get');
afterAll(() => {
locationSpy.mockRestore();
});
const mockQuery = (query = defaultQuery) => {
locationSpy.mockImplementation(
() =>
({
search: `?query=${query}`,
} as any)
);
};
it('should render specific setting if given setting key', async () => {
mockQuery();
const component = mountWithI18nProvider(
<Settings
history={mockHistory}
enableSaving={{ global: true, namespace: true }}
enableShowing={{ global: true, namespace: true }}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
settingsService={mockConfig().core.settings}
sectionRegistry={new SectionRegistry().start}
theme={themeServiceMock.createStartContract().theme$}
/>
);
expect(
component
.find('Field')
.filterWhere(
(n: ReactWrapper) => (n.prop('setting') as Record<string, string>).name === defaultQuery
)
).toHaveLength(1);
});
it('should not render a custom setting', async () => {
// The manual mock for the uiSettings client returns false for isConfig, override that
const uiSettings = mockConfig().core.settings.client;
uiSettings.isCustom = (key) => true;
const customSettingQuery = 'test:customstring:setting';
mockQuery(customSettingQuery);
const component = mountWithI18nProvider(
<Settings
history={mockHistory}
enableSaving={{ global: true, namespace: true }}
enableShowing={{ global: true, namespace: true }}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
settingsService={mockConfig().core.settings}
sectionRegistry={new SectionRegistry().start}
theme={themeServiceMock.createStartContract().theme$}
/>
);
expect(component.find('Field')).not.toBeNull();
expect(
component
.find('Field')
.filterWhere(
(n: ReactWrapper) =>
(n.prop('setting') as Record<string, any>).name === customSettingQuery
)
).toEqual({});
});
it('should render read-only when saving is disabled', async () => {
mockQuery();
const component = mountWithI18nProvider(
<Settings
history={mockHistory}
enableSaving={{ global: true, namespace: false }}
enableShowing={{ global: true, namespace: true }}
toasts={notificationServiceMock.createStartContract().toasts}
docLinks={docLinksServiceMock.createStartContract().links}
settingsService={mockConfig().core.settings}
sectionRegistry={new SectionRegistry().start}
theme={themeServiceMock.createStartContract().theme$}
/>
);
expect(component.find('Field')).not.toBeNull();
expect(
component
.find('Field')
.filterWhere(
(n: ReactWrapper) => (n.prop('setting') as Record<string, string>).name === defaultQuery
)
.prop('enableSaving')
).toBe(false);
});
it('should render unfiltered with query parsing error', async () => {
const badQuery = 'category:(accessibility))';
mockQuery(badQuery);
const { toasts } = notificationServiceMock.createStartContract();
const component = mountWithI18nProvider(
<Settings
history={mockHistory}
enableSaving={{ global: false, namespace: false }}
enableShowing={{ global: true, namespace: true }}
toasts={toasts}
docLinks={docLinksServiceMock.createStartContract().links}
settingsService={mockConfig().core.settings}
sectionRegistry={new SectionRegistry().start}
theme={themeServiceMock.createStartContract().theme$}
/>
);
expect(toasts.addWarning).toHaveBeenCalledTimes(1);
expect(component.find(Search).prop('query').text).toEqual('');
});
it('does not render global settings if show is set to false', async () => {
const badQuery = 'category:(accessibility))';
mockQuery(badQuery);
const { toasts } = notificationServiceMock.createStartContract();
const component = mountWithI18nProvider(
<Settings
history={mockHistory}
enableSaving={{ global: false, namespace: false }}
enableShowing={{ global: false, namespace: true }}
toasts={toasts}
docLinks={docLinksServiceMock.createStartContract().links}
settingsService={mockConfig().core.settings}
sectionRegistry={new SectionRegistry().start}
theme={themeServiceMock.createStartContract().theme$}
/>
);
expect(component.find(EuiTab).length).toEqual(1);
expect(component.find(EuiTab).at(0).text()).toEqual('Space Settings');
});
});

View file

@ -1,369 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import React, { useState, useCallback, useMemo } from 'react';
import useEffectOnce from 'react-use/lib/useEffectOnce';
import {
EuiSpacer,
Query,
EuiNotificationBadge,
EuiTab,
EuiTabs,
EuiFlexGroup,
EuiFlexItem,
EuiText,
} from '@elastic/eui';
import { ScopedHistory } from '@kbn/core-application-browser';
import { SettingsStart } from '@kbn/core-ui-settings-browser';
import { DocLinksStart } from '@kbn/core-doc-links-browser';
import { ToastsStart } from '@kbn/core-notifications-browser';
import { ThemeServiceStart } from '@kbn/core-theme-browser';
import { UiCounterMetricType } from '@kbn/analytics';
import { url } from '@kbn/kibana-utils-plugin/common';
import { parse } from 'query-string';
import { UiSettingsScope } from '@kbn/core-ui-settings-common';
import type { SectionRegistryStart } from '@kbn/management-settings-section-registry';
import type { RegistryEntry } from '@kbn/management-settings-section-registry';
import { mapConfig, mapSettings, initCategoryCounts, initCategories } from './settings_helper';
import { parseErrorMsg } from './components/search/search';
import { AdvancedSettings, QUERY } from './advanced_settings';
import { Search } from './components/search';
import { FieldSetting } from './types';
import { i18nTexts } from './i18n_texts';
import { getAriaName } from './lib';
interface AdvancedSettingsState {
query: Query;
filteredSettings: Record<UiSettingsScope, Record<string, FieldSetting[]>>;
filteredSections: {
global: RegistryEntry[];
space: RegistryEntry[];
};
}
export type GroupedSettings = Record<string, FieldSetting[]>;
interface Props {
history: ScopedHistory;
enableSaving: Record<UiSettingsScope, boolean>;
enableShowing: Record<UiSettingsScope, boolean>;
settingsService: SettingsStart;
docLinks: DocLinksStart['links'];
toasts: ToastsStart;
theme: ThemeServiceStart['theme$'];
sectionRegistry: SectionRegistryStart;
trackUiMetric?: (metricType: UiCounterMetricType, eventName: string | string[]) => void;
}
const SPACE_SETTINGS_ID = 'space-settings';
const GLOBAL_SETTINGS_ID = 'global-settings';
export const Settings = (props: Props) => {
const { sectionRegistry, history, settingsService, enableSaving, enableShowing, ...rest } = props;
const uiSettings = settingsService.client;
const globalUiSettings = settingsService.globalClient;
const [settings, setSettings] = useState<FieldSetting[]>(mapConfig(uiSettings));
const [globalSettings, setGlobalSettings] = useState<FieldSetting[]>(mapConfig(globalUiSettings));
const [groupedSettings, setGroupedSettings] = useState<Record<UiSettingsScope, GroupedSettings>>({
namespace: mapSettings(settings),
global: mapSettings(globalSettings),
});
const [categoryCounts, setCategoryCounts] = useState<
Record<UiSettingsScope, Record<string, number>>
>({
namespace: initCategoryCounts(groupedSettings.namespace),
global: initCategoryCounts(groupedSettings.global),
});
const [categories, setCategories] = useState<Record<UiSettingsScope, string[]>>({
namespace: initCategories(groupedSettings.namespace),
global: initCategories(groupedSettings.global),
});
const [queryState, setQueryState] = useState<AdvancedSettingsState>({
filteredSettings: {
global: {},
namespace: {},
},
filteredSections: {
global: sectionRegistry.getGlobalSections(),
space: sectionRegistry.getSpacesSections(),
},
query: Query.parse(''),
});
const setTimeoutCallback = () => {
const { hash } = window.location;
const id = hash.replace('#', '');
const element = document.getElementById(id);
let globalNavOffset = 0;
const globalNavBars = document
.getElementById('globalHeaderBars')
?.getElementsByClassName('euiHeader');
if (globalNavBars) {
Array.from(globalNavBars).forEach((navBar) => {
globalNavOffset += (navBar as HTMLDivElement).offsetHeight;
});
}
if (element) {
element.scrollIntoView();
window.scrollBy(0, -globalNavOffset); // offsets scroll by height of the global nav
}
};
useEffectOnce(() => {
setQueryState(getQueryState(undefined, true));
const subscription = (mappedSettings: FieldSetting[], scope: UiSettingsScope) => {
const grouped = { ...groupedSettings };
grouped[scope] = mapSettings(mappedSettings);
setGroupedSettings(grouped);
const updatedCategories = { ...categories };
updatedCategories[scope] = initCategories(groupedSettings[scope]);
setCategories(updatedCategories);
const updatedCategoryCounts = { ...categoryCounts };
updatedCategoryCounts[scope] = initCategoryCounts(groupedSettings[scope]);
setCategoryCounts(updatedCategoryCounts);
const updatedQueryState = { ...getQueryState(undefined, true) };
updatedQueryState.filteredSettings[scope] = mapSettings(
Query.execute(updatedQueryState.query, mappedSettings)
);
setQueryState(updatedQueryState);
};
const uiSettingsSubscription = uiSettings.getUpdate$().subscribe(() => {
const updatedSettings = mapConfig(uiSettings);
setSettings(updatedSettings);
subscription(updatedSettings, 'namespace');
});
const globalUiSettingsSubscription = globalUiSettings.getUpdate$().subscribe(() => {
const mappedSettings = mapConfig(globalUiSettings);
setGlobalSettings(mappedSettings);
subscription(mappedSettings, 'global');
});
if (window.location.hash !== '') {
setTimeout(() => setTimeoutCallback(), 0);
}
const unregister = history.listen(({ search }) => {
setQueryState(getQueryState(search));
});
return () => {
unregister();
uiSettingsSubscription.unsubscribe();
globalUiSettingsSubscription.unsubscribe();
};
});
const setUrlQuery = useCallback(
(query: string = '') => {
const search = url.addQueryParam(window.location.search, QUERY, query);
history.push({
pathname: '', // remove any route query param
search,
});
},
[history]
);
const searchCategories = useMemo(() => {
return categories.global.concat(categories.namespace);
}, [categories.global, categories.namespace]);
const callOutTitle = (scope: UiSettingsScope) => {
if (scope === 'namespace') {
return i18nTexts.defaultSpaceCalloutTitle;
}
return i18nTexts.globalCalloutTitle;
};
const callOutSubtitle = (scope: UiSettingsScope) => {
if (scope === 'namespace') {
return i18nTexts.defaultSpaceCalloutSubtitle;
}
return i18nTexts.globalCalloutSubtitle;
};
const getClientForScope = (scope: UiSettingsScope) => {
if (scope === 'namespace') {
return uiSettings;
}
return globalUiSettings;
};
const renderAdvancedSettings = (scope: UiSettingsScope) => {
return (
<AdvancedSettings
groupedSettings={groupedSettings[scope]}
categoryCounts={categoryCounts[scope]}
categories={categories[scope]}
visibleSettings={queryState.filteredSettings[scope]}
clearQuery={() => setUrlQuery('')}
noResults={
queryState.filteredSections.global.length + queryState.filteredSections.space.length === 0
}
queryText={queryState.query.text}
callOutTitle={callOutTitle(scope)}
callOutSubtitle={callOutSubtitle(scope)}
settingsService={settingsService}
uiSettingsClient={getClientForScope(scope)}
enableSaving={enableSaving[scope]}
{...rest}
/>
);
};
const tabs = [
{
id: SPACE_SETTINGS_ID,
name: i18nTexts.defaultSpaceTabTitle,
append:
queryState.query.text !== '' ? (
<EuiNotificationBadge className="eui-alignCenter" size="m" key="spaceSettings-badge">
{Object.keys(queryState.filteredSettings.namespace).length +
queryState.filteredSections.space.length}
</EuiNotificationBadge>
) : null,
content: renderAdvancedSettings('namespace'),
},
];
if (enableShowing.global) {
tabs.push({
id: GLOBAL_SETTINGS_ID,
name: i18nTexts.globalTabTitle,
append:
queryState.query.text !== '' ? (
<EuiNotificationBadge className="eui-alignCenter" size="m" key="spaceSettings-badge">
{Object.keys(queryState.filteredSettings.global).length +
queryState.filteredSections.global.length}
</EuiNotificationBadge>
) : null,
content: renderAdvancedSettings('global'),
});
}
const [selectedTabId, setSelectedTabId] = useState(SPACE_SETTINGS_ID);
const selectedTabContent = tabs.find((obj) => obj.id === selectedTabId)?.content;
const onSelectedTabChanged = (id: string) => {
setSelectedTabId(id);
};
const renderTabs = () => {
return tabs.map((tab, index) => (
<EuiTab
key={index}
data-test-subj={`advancedSettingsTab-${tab.id}`}
onClick={() => onSelectedTabChanged(tab.id)}
isSelected={tab.id === selectedTabId}
append={tab.append}
>
{tab.name}
</EuiTab>
));
};
const getQuery = (queryString: string, initialQuery = false): Query => {
try {
const query = initialQuery ? getAriaName(queryString) : queryString ?? '';
return Query.parse(query);
} catch ({ message }) {
props.toasts.addWarning({
title: parseErrorMsg,
text: message,
});
return Query.parse('');
}
};
const getQueryText = (search?: string): string => {
const queryParams = parse(search ?? window.location.search) ?? {};
return (queryParams[QUERY] as string) ?? '';
};
const getQueryState = (search?: string, initialQuery = false): AdvancedSettingsState => {
const queryString = getQueryText(search);
const query = getQuery(queryString, initialQuery);
const filteredSettings = {
namespace: mapSettings(Query.execute(query, settings)),
global: mapSettings(Query.execute(query, globalSettings)),
};
return {
query,
filteredSettings,
filteredSections: {
global: sectionRegistry
.getGlobalSections()
.filter(({ queryMatch }) => queryMatch(query.text)),
space: sectionRegistry
.getSpacesSections()
.filter(({ queryMatch }) => queryMatch(query.text)),
},
};
};
const onQueryChange = useCallback(
({ query }: { query: Query }) => {
setUrlQuery(query.text);
},
[setUrlQuery]
);
const PageTitle = (
<EuiText>
<h1 data-test-subj="managementSettingsTitle">{i18nTexts.advancedSettingsTitle}</h1>
</EuiText>
);
const mapSections = (entries: RegistryEntry[]) =>
entries.map(({ Component, queryMatch }, index) => {
if (queryMatch(queryState.query.text)) {
return (
<Component
key={`component-${index}`}
toasts={props.toasts}
enableSaving={props.enableSaving}
/>
);
}
return null;
});
return (
<div>
<EuiFlexGroup>
<EuiFlexItem>{PageTitle}</EuiFlexItem>
<EuiFlexItem>
<Search
query={queryState.query}
categories={searchCategories}
onQueryChange={onQueryChange}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="m" />
<EuiTabs>{renderTabs()}</EuiTabs>
{selectedTabContent}
{selectedTabId === SPACE_SETTINGS_ID ? (
<>{mapSections(queryState.filteredSections.space)}</>
) : (
<>{mapSections(queryState.filteredSections.global)}</>
)}
</div>
);
};

View file

@ -1,155 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { UiSettingsType, UserProvidedValues } from '@kbn/core-ui-settings-common';
import { PublicUiSettingsParams } from '@kbn/core/public';
import { Observable } from 'rxjs';
import { FieldSetting } from './types';
import { mapConfig, mapSettings, initCategoryCounts, initCategories } from './settings_helper';
describe('Settings Helper', () => {
const defaultConfig: Partial<FieldSetting> = {
displayName: 'defaultName',
requiresPageReload: false,
isOverridden: false,
ariaName: 'ariaName',
readOnly: false,
isCustom: false,
defVal: 'defVal',
type: 'string' as UiSettingsType,
category: ['category'],
};
const arraySetting = {
'test:array:setting': {
...defaultConfig,
value: ['default_value'],
name: 'Test array setting',
description: 'Description for Test array setting',
category: ['elasticsearch'],
},
};
const booleanSetting = {
'test:boolean:setting': {
...defaultConfig,
value: true,
name: 'Test boolean setting',
description: 'Description for Test boolean setting',
category: ['elasticsearch'],
},
};
const imageSetting = {
'test:image:setting': {
...defaultConfig,
value: null,
name: 'Test image setting',
description: 'Description for Test image setting',
type: 'image' as UiSettingsType,
},
};
const arrayFieldSetting = {
ariaName: 'Test array setting',
category: ['elasticsearch'],
defVal: ['default_value'],
description: 'Description for Test array setting',
displayName: 'Test array setting',
isCustom: false,
isOverridden: false,
name: 'test:array:setting',
readOnly: false,
requiresPageReload: false,
type: 'string' as UiSettingsType,
};
const booleanFieldSetting = {
ariaName: 'Test boolean setting',
category: ['elasticsearch'],
defVal: true,
description: 'Description for Test boolean setting',
displayName: 'Test boolean setting',
isCustom: false,
isOverridden: false,
name: 'test:boolean:setting',
readOnly: false,
requiresPageReload: false,
type: 'string' as UiSettingsType,
};
const imageFieldSetting = {
ariaName: 'Test image setting',
category: ['category'],
defVal: null,
description: 'Description for Test image setting',
displayName: 'Test image setting',
isCustom: false,
isOverridden: false,
name: 'test:image:setting',
readOnly: false,
requiresPageReload: false,
type: 'image' as UiSettingsType,
};
const config = {
set: (key: string, value: any) => Promise.resolve(true),
remove: (key: string) => Promise.resolve(true),
isCustom: (key: string) => false,
isOverridden: (key: string) => Boolean(config.getAll()[key].isOverridden),
getRegistered: () => ({} as Readonly<Record<string, PublicUiSettingsParams>>),
getUpdate$: () =>
new Observable<{
key: string;
newValue: any;
oldValue: any;
}>(),
isDeclared: (key: string) => true,
isDefault: (key: string) => true,
getSaved$: () =>
new Observable<{
key: string;
newValue: any;
oldValue: any;
}>(),
getUpdateErrors$: () => new Observable<Error>(),
get: (key: string, defaultOverride?: any): any => config.getAll()[key] || defaultOverride,
get$: (key: string) => new Observable<any>(config.get(key)),
getAll: (): Readonly<Record<string, PublicUiSettingsParams & UserProvidedValues>> => {
return {
...arraySetting,
...booleanSetting,
...imageSetting,
};
},
validateValue: (key: string, value: any) =>
Promise.resolve({ successfulValidation: true, valid: true }),
};
it('mapConfig', () => {
expect(mapConfig(config)).toEqual([arrayFieldSetting, booleanFieldSetting, imageFieldSetting]);
});
it('mapSettings, initCategoryCounts and initCategories', () => {
const fieldSetting1: FieldSetting = { ...arrayFieldSetting, value: ['a', 'b', 'c'] };
const fieldSetting2: FieldSetting = { ...booleanFieldSetting, value: false };
const fieldSetting3: FieldSetting = { ...imageFieldSetting, value: 'imageSrc' };
const mapped = mapSettings([fieldSetting1, fieldSetting2, fieldSetting3]);
expect(Object.keys(mapped).sort()).toEqual(['category', 'elasticsearch'].sort());
expect(mapped.category.length).toEqual(1);
expect(mapped.elasticsearch.length).toEqual(2);
const categoryCounts = initCategoryCounts(mapped);
expect(categoryCounts).toEqual({ category: 1, elasticsearch: 2 });
const categories = initCategories(mapped);
expect(categories).toEqual(['category', 'elasticsearch']);
});
});

View file

@ -1,56 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import { IUiSettingsClient } from '@kbn/core-ui-settings-browser';
import { DEFAULT_CATEGORY, fieldSorter, toEditableConfig } from './lib';
import { FieldSetting } from './types';
import { GroupedSettings } from './settings';
export const mapConfig = (config: IUiSettingsClient) => {
const all = config.getAll();
return Object.entries(all)
.map(([settingId, settingDef]) => {
return toEditableConfig({
def: settingDef,
name: settingId,
value: settingDef.userValue,
isCustom: config.isCustom(settingId),
isOverridden: config.isOverridden(settingId),
});
})
.filter((c) => !c.readOnly)
.filter((c) => !c.isCustom) // hide any settings that aren't explicitly registered by enabled plugins.
.sort(fieldSorter);
};
export const mapSettings = (fieldSettings: FieldSetting[]) => {
// Group settings by category
return fieldSettings.reduce((grouped: GroupedSettings, setting) => {
// We will want to change this logic when we put each category on its
// own page aka allowing a setting to be included in multiple categories.
const category = setting.category[0];
(grouped[category] = grouped[category] || []).push(setting);
return grouped;
}, {});
};
export const initCategoryCounts = (grouped: GroupedSettings) => {
return Object.keys(grouped).reduce((counts: Record<string, number>, category: string) => {
counts[category] = grouped[category].length;
return counts;
}, {});
};
export const initCategories = (grouped: GroupedSettings) => {
return Object.keys(grouped).sort((a, b) => {
if (a === DEFAULT_CATEGORY) return -1;
if (b === DEFAULT_CATEGORY) return 1;
if (a > b) return 1;
return a === b ? 0 : -1;
});
};

View file

@ -1,51 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type { ReactElement } from 'react';
import { UiCounterMetricType } from '@kbn/analytics';
import { UiSettingsType } from '@kbn/core/public';
export interface FieldSetting {
displayName: string;
name: string;
value: unknown;
description?: string | ReactElement;
options?: string[] | number[];
optionLabels?: Record<string, string>;
requiresPageReload: boolean;
type: UiSettingsType;
category: string[];
ariaName: string;
isOverridden: boolean;
defVal: unknown;
isCustom: boolean;
readOnly?: boolean;
order?: number;
deprecation?: {
message: string;
docLinksKey: string;
};
metric?: {
type: UiCounterMetricType;
name: string;
};
}
// until eui searchbar and query are typed
export interface SettingsChanges {
[key: string]: any;
}
export interface FieldState {
value?: any;
changeImage?: boolean;
loading?: boolean;
isInvalid?: boolean;
error?: string | null;
}

View file

@ -1,26 +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 and the Server Side Public License, v 1; you may not use this file except
* in compliance with, at your election, the Elastic License 2.0 or the Server
* Side Public License, v 1.
*/
import type {
SectionRegistrySetup,
SectionRegistryStart,
} from '@kbn/management-settings-section-registry';
const addGlobalSection = jest.fn();
const addSpaceSection = jest.fn();
const getGlobalSections = jest.fn();
const getSpacesSections = jest.fn();
export const advancedSettingsMock = {
createSetupContract(): SectionRegistrySetup {
return { addGlobalSection, addSpaceSection };
},
createStartContract(): SectionRegistryStart {
return { getGlobalSections, getSpacesSections };
},
};

View file

@ -9,10 +9,20 @@
import { i18n } from '@kbn/i18n';
import { CoreSetup, Plugin } from '@kbn/core/public';
import { SectionRegistry } from '@kbn/management-settings-section-registry';
import ReactDOM from 'react-dom';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import React from 'react';
import { withSuspense } from '@kbn/shared-ux-utility';
import { AdvancedSettingsSetup, AdvancedSettingsStart, AdvancedSettingsPluginSetup } from './types';
const { setup: sectionRegistrySetup, start: sectionRegistryStart } = new SectionRegistry();
const LazyKibanaSettingsApplication = React.lazy(async () => ({
default: (await import('@kbn/management-settings-application')).KibanaSettingsApplication,
}));
const KibanaSettingsApplication = withSuspense(LazyKibanaSettingsApplication);
const title = i18n.translate('advancedSettings.advancedSettingsLabel', {
defaultMessage: 'Advanced Settings',
});
@ -30,16 +40,21 @@ export class AdvancedSettingsPlugin
id: 'settings',
title,
order: 3,
async mount(params) {
const { mountManagementSection } = await import(
'./management_app/mount_management_section'
);
return mountManagementSection(
core.getStartServices,
params,
sectionRegistryStart,
usageCollection
async mount({ element, setBreadcrumbs, history }) {
const [coreStart] = await core.getStartServices();
setBreadcrumbs([{ text: title }]);
ReactDOM.render(
<KibanaRenderContextProvider {...coreStart}>
<KibanaSettingsApplication
{...{ ...coreStart, history, sectionRegistry: sectionRegistryStart }}
/>
</KibanaRenderContextProvider>,
element
);
return () => {
ReactDOM.unmountComponentAtNode(element);
};
},
});

View file

@ -6,13 +6,15 @@
* Side Public License, v 1.
*/
export const capabilitiesProvider = () => ({
import { AdvancedSettingsConfig } from './config';
export const capabilitiesProvider = (config: AdvancedSettingsConfig) => ({
globalSettings: {
show: true,
show: config.globalSettingsEnabled,
save: true,
},
advancedSettings: {
show: true,
show: config.advancedSettingsEnabled,
save: true,
},
});

View file

@ -10,7 +10,8 @@ import { schema, TypeOf } from '@kbn/config-schema';
import { PluginConfigDescriptor } from '@kbn/core-plugins-server';
const configSchema = schema.object({
enabled: schema.boolean({ defaultValue: true }),
globalSettingsEnabled: schema.boolean({ defaultValue: true }),
advancedSettingsEnabled: schema.boolean({ defaultValue: true }),
});
export type AdvancedSettingsConfig = TypeOf<typeof configSchema>;

View file

@ -7,19 +7,23 @@
*/
import { PluginInitializerContext, CoreSetup, CoreStart, Plugin, Logger } from '@kbn/core/server';
import { capabilitiesProvider } from './capabilities_provider';
import { AdvancedSettingsConfig } from './config';
export class AdvancedSettingsServerPlugin implements Plugin<object, object> {
private readonly logger: Logger;
private readonly config: AdvancedSettingsConfig;
constructor(initializerContext: PluginInitializerContext) {
this.logger = initializerContext.logger.get();
this.config = initializerContext.config.get();
}
public setup(core: CoreSetup) {
this.logger.debug('advancedSettings: Setup');
core.capabilities.registerProvider(capabilitiesProvider);
core.capabilities.registerProvider(() => capabilitiesProvider(this.config));
return {};
}

View file

@ -12,28 +12,13 @@
"@kbn/management-plugin",
"@kbn/home-plugin",
"@kbn/usage-collection-plugin",
"@kbn/kibana-react-plugin",
"@kbn/i18n",
"@kbn/test-jest-helpers",
"@kbn/analytics",
"@kbn/kibana-utils-plugin",
"@kbn/i18n-react",
"@kbn/expect",
"@kbn/monaco",
"@kbn/shared-ux-router",
"@kbn/core-ui-settings-browser-mocks",
"@kbn/core-application-browser",
"@kbn/core-ui-settings-browser",
"@kbn/core-doc-links-browser",
"@kbn/core-notifications-browser",
"@kbn/core-theme-browser",
"@kbn/core-ui-settings-common",
"@kbn/config-schema",
"@kbn/core-plugins-server",
"@kbn/management-settings-section-registry",
"@kbn/react-kibana-context-render",
"@kbn/code-editor",
"@kbn/code-editor-mock",
"@kbn/shared-ux-utility",
"@kbn/management-settings-application",
],
"exclude": [
"target/**/*",

View file

@ -6,8 +6,6 @@
* Side Public License, v 1.
*/
import React from 'react';
import ReactDOM from 'react-dom';
import { i18n as kbnI18n } from '@kbn/i18n';
import { BehaviorSubject } from 'rxjs';
import type { SharePluginSetup, SharePluginStart } from '@kbn/share-plugin/public';
@ -25,8 +23,6 @@ import {
AppNavLinkStatus,
AppDeepLink,
} from '@kbn/core/public';
import { KibanaRenderContextProvider } from '@kbn/react-kibana-context-render';
import { withSuspense } from '@kbn/shared-ux-utility';
import { ConfigSchema, ManagementSetup, ManagementStart, NavigationCardsSubject } from './types';
import { MANAGEMENT_APP_ID } from '../common/contants';
@ -47,12 +43,6 @@ interface ManagementStartDependencies {
serverless?: ServerlessPluginStart;
}
const LazyKibanaSettingsApplication = React.lazy(async () => ({
default: (await import('@kbn/management-settings-application')).KibanaSettingsApplication,
}));
const KibanaSettingsApplication = withSuspense(LazyKibanaSettingsApplication);
export class ManagementPlugin
implements
Plugin<
@ -176,33 +166,6 @@ export class ManagementPlugin
});
}
// Register the Settings app only if in serverless, until we integrate the SettingsApplication into the Advanced settings plugin
// Otherwise, it will be double registered from the Advanced settings plugin
if (plugins.serverless) {
const title = kbnI18n.translate('management.settings.settingsLabel', {
defaultMessage: 'Advanced Settings',
});
this.managementSections.definedSections.kibana.registerApp({
id: 'settings',
title,
order: 3,
async mount({ element, setBreadcrumbs, history }) {
setBreadcrumbs([{ text: title }]);
ReactDOM.render(
<KibanaRenderContextProvider {...core}>
<KibanaSettingsApplication {...{ ...core, history }} />
</KibanaRenderContextProvider>,
element
);
return () => {
ReactDOM.unmountComponentAtNode(element);
};
},
});
}
return {
setIsSidebarEnabled: (isSidebarEnabled: boolean) =>
this.isSidebarEnabled$.next(isSidebarEnabled),

View file

@ -25,10 +25,7 @@
"@kbn/test-jest-helpers",
"@kbn/config-schema",
"@kbn/serverless",
"@kbn/management-settings-application",
"@kbn/react-kibana-context-render",
"@kbn/shared-ux-utility",
"@kbn/shared-ux-error-boundary"
"@kbn/shared-ux-error-boundary",
],
"exclude": [
"target/**/*"

View file

@ -11,7 +11,7 @@ import { FtrProviderContext } from '../../../ftr_provider_context';
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const PageObjects = getPageObjects(['settings', 'common', 'header']);
const find = getService('find');
const testSubjects = getService('testSubjects');
describe('Data view field caps cache advanced setting', async function () {
before(async () => {
@ -19,8 +19,9 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
await PageObjects.settings.clickKibanaSettings();
});
it('should have cache setting', async () => {
const cacheSetting = await find.byCssSelector('#data_views\\:cache_max_age-group');
expect(cacheSetting).to.not.be(undefined);
expect(
await testSubjects.exists('management-settings-editField-data_views:cache_max_age')
).to.be(true);
});
});
}

View file

@ -39,7 +39,7 @@ export class SettingsPageObject extends FtrService {
async clickKibanaGlobalSettings() {
await this.testSubjects.click('settings');
await this.header.waitUntilLoadingHasFinished();
await this.testSubjects.click('advancedSettingsTab-global-settings');
await this.testSubjects.click('settings-tab-global-settings');
}
async clickKibanaSavedObjects() {
@ -73,21 +73,24 @@ export class SettingsPageObject extends FtrService {
async getAdvancedSettings(propertyName: string) {
this.log.debug('in getAdvancedSettings');
return await this.testSubjects.getAttribute(
`advancedSetting-editField-${propertyName}`,
`management-settings-editField-${propertyName}`,
'value'
);
}
async expectDisabledAdvancedSetting(propertyName: string) {
expect(
await this.testSubjects.getAttribute(`advancedSetting-editField-${propertyName}`, 'disabled')
await this.testSubjects.getAttribute(
`management-settings-editField-${propertyName}`,
'disabled'
)
).to.eql('true');
}
async getAdvancedSettingCheckbox(propertyName: string) {
this.log.debug('in getAdvancedSettingCheckbox');
return await this.testSubjects.getAttribute(
`advancedSetting-editField-${propertyName}`,
`management-settings-editField-${propertyName}`,
'checked'
);
}
@ -95,37 +98,37 @@ export class SettingsPageObject extends FtrService {
async getAdvancedSettingAriaCheckbox(propertyName: string) {
this.log.debug('in getAdvancedSettingAriaCheckbox');
return await this.testSubjects.getAttribute(
`advancedSetting-editField-${propertyName}`,
`management-settings-editField-${propertyName}`,
'aria-checked'
);
}
async clearAdvancedSettings(propertyName: string) {
await this.testSubjects.click(`advancedSetting-resetField-${propertyName}`);
await this.testSubjects.click(`management-settings-resetField-${propertyName}`);
await this.header.waitUntilLoadingHasFinished();
await this.testSubjects.click(`advancedSetting-saveButton`);
await this.testSubjects.click(`settings-save-button`);
await this.header.waitUntilLoadingHasFinished();
}
async setAdvancedSettingsSelect(propertyName: string, propertyValue: string) {
await this.find.clickByCssSelector(
`[data-test-subj="advancedSetting-editField-${propertyName}"] option[value="${propertyValue}"]`
`[data-test-subj="management-settings-editField-${propertyName}"] option[value="${propertyValue}"]`
);
await this.header.waitUntilLoadingHasFinished();
await this.testSubjects.click(`advancedSetting-saveButton`);
await this.testSubjects.click(`settings-save-button`);
await this.header.waitUntilLoadingHasFinished();
}
async setAdvancedSettingsInput(propertyName: string, propertyValue: string) {
const input = await this.testSubjects.find(`advancedSetting-editField-${propertyName}`);
const input = await this.testSubjects.find(`management-settings-editField-${propertyName}`);
await input.clearValue();
await input.type(propertyValue);
await this.testSubjects.click(`advancedSetting-saveButton`);
await this.testSubjects.click(`settings-save-button`);
await this.header.waitUntilLoadingHasFinished();
}
async setAdvancedSettingsTextArea(propertyName: string, propertyValue: string) {
const wrapper = await this.testSubjects.find(`advancedSetting-editField-${propertyName}`);
const wrapper = await this.testSubjects.find(`management-settings-editField-${propertyName}`);
const textarea = await wrapper.findByTagName('textarea');
await textarea.focus();
// only way to properly replace the value of the ace editor is via the JS api
@ -133,17 +136,17 @@ export class SettingsPageObject extends FtrService {
(editor: string, value: string) => {
return (window as any).ace.edit(editor).setValue(value);
},
`advancedSetting-editField-${propertyName}-editor`,
`management-settings-editField-${propertyName}-editor`,
propertyValue
);
await this.testSubjects.click(`advancedSetting-saveButton`);
await this.testSubjects.click(`settings-save-button`);
await this.header.waitUntilLoadingHasFinished();
}
async setAdvancedSettingsImage(propertyName: string, path: string) {
const input = await this.testSubjects.find(`advancedSetting-editField-${propertyName}`);
const input = await this.testSubjects.find(`management-settings-editField-${propertyName}`);
await input.type(path);
await this.testSubjects.click(`advancedSetting-saveButton`);
await this.testSubjects.click(`settings-save-button`);
await this.header.waitUntilLoadingHasFinished();
}
@ -155,9 +158,9 @@ export class SettingsPageObject extends FtrService {
if (curValue === (value ? 'true' : 'false')) return;
}
await this.testSubjects.click(`advancedSetting-editField-${propertyName}`);
await this.testSubjects.click(`management-settings-editField-${propertyName}`);
await this.header.waitUntilLoadingHasFinished();
await this.testSubjects.click(`advancedSetting-saveButton`);
await this.testSubjects.click(`settings-save-button`);
await this.header.waitUntilLoadingHasFinished();
}

View file

@ -76,64 +76,6 @@
}
},
"messages": {
"advancedSettings.field.changeImageLinkAriaLabel": "Modifier {ariaName}",
"advancedSettings.field.defaultValueText": "Par défaut : {value}",
"advancedSettings.field.defaultValueTypeJsonText": "Par défaut : {value}",
"advancedSettings.field.deprecationClickAreaLabel": "Cliquez ici pour afficher la documentation de déclassement pour {settingName}.",
"advancedSettings.field.resetToDefaultLinkAriaLabel": "Réinitialiser {ariaName} à la valeur par défaut",
"advancedSettings.form.countOfSettingsChanged": "{unsavedCount} {unsavedCount, plural, one {paramètre} many {les paramètres d''index suivants déclassés ?} other {paramètres}} non enregistré(s){hiddenCount, plural, =0 {} one {, # masqué} many {, # masqués} other {, # masqués}}",
"advancedSettings.form.noSearchResultText": "Aucun paramètre trouvé pour {queryText} {clearSearch}",
"advancedSettings.form.searchResultText": "Les termes de la recherche masquent {settingsCount} paramètres {clearSearch}",
"advancedSettings.voiceAnnouncement.noSearchResultScreenReaderMessage": "Il {optionLenght, plural, one {y a # option} many {y a # options} other {y a # options}} dans {sectionLenght, plural, one {# section} many {# sections} other {# sections}}",
"advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage": "Vous avez recherché {query}. Il {optionLenght, plural, one {y a # option} many {y a # options} other {y a # options}} dans {sectionLenght, plural, one {# section} many {# sections} other {# sections}}",
"advancedSettings.advancedSettingsLabel": "Paramètres avancés",
"advancedSettings.badge.readOnly.text": "Lecture seule",
"advancedSettings.badge.readOnly.tooltip": "Impossible denregistrer les paramètres avancés",
"advancedSettings.callOutCautionDescription": "Soyez prudent, ces paramètres sont destinés aux utilisateurs très avancés uniquement. Toute modification est susceptible dentraîner des dommages importants à Kibana. Certains de ces paramètres peuvent être non documentés, non compatibles ou en version d'évaluation technique. Lorsquun champ dispose dune valeur par défaut, le laisser vide entraîne lapplication de cette valeur par défaut, ce qui peut ne pas être acceptable compte tenu dautres directives de configuration. Toute suppression d'un paramètre personnalisé de la configuration de Kibana est définitive.",
"advancedSettings.callOutCautionTitle": "Attention : toute action est susceptible de provoquer des dommages.",
"advancedSettings.categoryNames.dashboardLabel": "Tableau de bord",
"advancedSettings.categoryNames.discoverLabel": "Découverte",
"advancedSettings.categoryNames.enterpriseSearchLabel": "Enterprise Search",
"advancedSettings.categoryNames.generalLabel": "Général",
"advancedSettings.categoryNames.machineLearningLabel": "Machine Learning",
"advancedSettings.categoryNames.notificationsLabel": "Notifications",
"advancedSettings.categoryNames.observabilityLabel": "Observabilité",
"advancedSettings.categoryNames.reportingLabel": "Reporting",
"advancedSettings.categoryNames.searchLabel": "Recherche",
"advancedSettings.categoryNames.securitySolutionLabel": "Solution de sécurité",
"advancedSettings.categoryNames.timelionLabel": "Timelion",
"advancedSettings.categoryNames.visualizationsLabel": "Visualisations",
"advancedSettings.categorySearchLabel": "Catégorie",
"advancedSettings.defaultSpaceCalloutSubtitle": "Les modifications seront uniquement appliquées à l'espace actuel. Ces paramètres sont destinés aux utilisateurs avancés, car des configurations incorrectes peuvent avoir une incidence négative sur des aspects de Kibana.",
"advancedSettings.defaultSpaceCalloutTitle": "Les modifications affecteront l'espace actuel.",
"advancedSettings.featureCatalogueTitle": "Personnalisez votre expérience Kibana : modifiez le format de date, activez le mode sombre, et bien plus encore.",
"advancedSettings.field.changeImageLinkText": "Modifier l'image",
"advancedSettings.field.codeEditorSyntaxErrorMessage": "Syntaxe JSON non valide",
"advancedSettings.field.customSettingAriaLabel": "Paramètre personnalisé",
"advancedSettings.field.customSettingTooltip": "Paramètre personnalisé",
"advancedSettings.field.helpText": "Ce paramètre est défini par le serveur Kibana et ne peut pas être modifié.",
"advancedSettings.field.imageChangeErrorMessage": "Impossible denregistrer l'image",
"advancedSettings.field.invalidIconLabel": "Non valide",
"advancedSettings.field.offLabel": "Désactivé",
"advancedSettings.field.onLabel": "Activé",
"advancedSettings.field.resetToDefaultLinkText": "Réinitialiser à la valeur par défaut",
"advancedSettings.field.settingIsUnsaved": "Le paramètre n'est actuellement pas enregistré.",
"advancedSettings.field.unsavedIconLabel": "Non enregistré",
"advancedSettings.form.cancelButtonLabel": "Annuler les modifications",
"advancedSettings.form.clearNoSearchResultText": "(effacer la recherche)",
"advancedSettings.form.clearSearchResultText": "(effacer la recherche)",
"advancedSettings.form.requiresPageReloadToastButtonLabel": "Actualiser la page",
"advancedSettings.form.requiresPageReloadToastDescription": "Un ou plusieurs paramètres nécessitent dactualiser la page pour pouvoir prendre effet.",
"advancedSettings.form.saveButtonLabel": "Enregistrer les modifications",
"advancedSettings.form.saveButtonTooltipWithInvalidChanges": "Corrigez les paramètres non valides avant d'enregistrer.",
"advancedSettings.form.saveErrorMessage": "Enregistrement impossible",
"advancedSettings.globalCalloutSubtitle": "Les modifications seront appliquées à tous les utilisateurs dans l'ensemble des espaces. Cela inclut les utilisateurs Kibana natifs et les utilisateurs qui se connectent via l'authentification unique.",
"advancedSettings.globalCalloutTitle": "Les modifications auront une incidence sur tous les paramètres utilisateur dans l'ensemble des espaces",
"advancedSettings.globalSettingsTabTitle": "Paramètres généraux",
"advancedSettings.searchBar.unableToParseQueryErrorMessage": "Impossible d'analyser la requête",
"advancedSettings.searchBarAriaLabel": "Rechercher dans les paramètres avancés",
"advancedSettings.spaceSettingsTabTitle": "Paramètres de l'espace",
"advancedSettings.voiceAnnouncement.ariaLabel": "Informations de résultat des paramètres avancés",
"autocomplete.conflictIndicesWarning.index.description": "{name} ({count} index)",
"autocomplete.customOptionText": "Ajouter {searchValuePlaceholder} comme champ personnalisé",
"autocomplete.conflictIndicesWarning.description": "Ce champ est défini avec différents types dans les index suivants ou il n'est pas mappé, ce qui peut entraîner des résultats inattendus lors des requêtes.",

View file

@ -76,64 +76,6 @@
}
},
"messages": {
"advancedSettings.field.changeImageLinkAriaLabel": "{ariaName}の変更",
"advancedSettings.field.defaultValueText": "デフォルト:{value}",
"advancedSettings.field.defaultValueTypeJsonText": "デフォルト:{value}",
"advancedSettings.field.deprecationClickAreaLabel": "クリックすると{settingName}のサポート終了に関するドキュメントが表示されます。",
"advancedSettings.field.resetToDefaultLinkAriaLabel": "{ariaName}をデフォルトにリセット",
"advancedSettings.form.countOfSettingsChanged": "{unsavedCount}個の保存されていない{unsavedCount, plural, other {設定}}{hiddenCount, plural, =0 {} other {、#個が非表示です}}",
"advancedSettings.form.noSearchResultText": "{queryText}{clearSearch}の設定が見つかりません",
"advancedSettings.form.searchResultText": "検索用語により、{settingsCount}件の設定{clearSearch}が非表示になっています",
"advancedSettings.voiceAnnouncement.noSearchResultScreenReaderMessage": "{sectionLenght, plural, other {#個のセクション}}に{optionLenght, plural, other {#個のオプションがあります}}",
"advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage": "{query}を検索しました。{sectionLenght, plural, other {#個のセクション}}に{optionLenght, plural, other {#個のオプションがあります}}",
"advancedSettings.advancedSettingsLabel": "高度な設定",
"advancedSettings.badge.readOnly.text": "読み取り専用",
"advancedSettings.badge.readOnly.tooltip": "高度な設定を保存できません",
"advancedSettings.callOutCautionDescription": "これらの設定は特に上級のユーザー向けなのでご注意ください。ここでの変更は Kibana の重要な部分に不具合を生じさせる可能性があります。これらの設定は非公開、サポート対象外、またはテクニカルプレビュー中の場合があります。フィールドにデフォルト値がある場合、そのフィールドを未入力のままにするとデフォルトに戻り、他の構成で許容されないことがあります。カスタム設定を削除すると、Kibana の構成から永久に削除されます。",
"advancedSettings.callOutCautionTitle": "注意:不具合につながる可能性があります",
"advancedSettings.categoryNames.dashboardLabel": "ダッシュボード",
"advancedSettings.categoryNames.discoverLabel": "Discover",
"advancedSettings.categoryNames.enterpriseSearchLabel": "エンタープライズ サーチ",
"advancedSettings.categoryNames.generalLabel": "一般",
"advancedSettings.categoryNames.machineLearningLabel": "機械学習",
"advancedSettings.categoryNames.notificationsLabel": "通知",
"advancedSettings.categoryNames.observabilityLabel": "Observability",
"advancedSettings.categoryNames.reportingLabel": "レポート",
"advancedSettings.categoryNames.searchLabel": "検索",
"advancedSettings.categoryNames.securitySolutionLabel": "セキュリティソリューション",
"advancedSettings.categoryNames.timelionLabel": "Timelion",
"advancedSettings.categoryNames.visualizationsLabel": "ビジュアライゼーション",
"advancedSettings.categorySearchLabel": "カテゴリー",
"advancedSettings.defaultSpaceCalloutSubtitle": "変更は現在のスペースにのみ適用されます。これらの設定は上級ユーザー向けです。構成が正しくない場合は、Kibanaの動作に悪影響を及ぼすおそれがあります。",
"advancedSettings.defaultSpaceCalloutTitle": "変更は現在のスペースに影響します。",
"advancedSettings.featureCatalogueTitle": "日付形式の変更、ダークモードの有効化など、Kibanaエクスペリエンスをカスタマイズします。",
"advancedSettings.field.changeImageLinkText": "画像を変更",
"advancedSettings.field.codeEditorSyntaxErrorMessage": "無効な JSON 構文",
"advancedSettings.field.customSettingAriaLabel": "カスタム設定",
"advancedSettings.field.customSettingTooltip": "カスタム設定",
"advancedSettings.field.helpText": "この設定は Kibana サーバーにより上書きされ、変更することはできません。",
"advancedSettings.field.imageChangeErrorMessage": "画像を保存できませんでした",
"advancedSettings.field.invalidIconLabel": "無効",
"advancedSettings.field.offLabel": "オフ",
"advancedSettings.field.onLabel": "オン",
"advancedSettings.field.resetToDefaultLinkText": "デフォルトにリセット",
"advancedSettings.field.settingIsUnsaved": "設定は現在保存されていません。",
"advancedSettings.field.unsavedIconLabel": "未保存",
"advancedSettings.form.cancelButtonLabel": "変更をキャンセル",
"advancedSettings.form.clearNoSearchResultText": "(検索結果を消去)",
"advancedSettings.form.clearSearchResultText": "(検索結果を消去)",
"advancedSettings.form.requiresPageReloadToastButtonLabel": "ページを再読み込み",
"advancedSettings.form.requiresPageReloadToastDescription": "設定を有効にするためにページの再読み込みが必要です。",
"advancedSettings.form.saveButtonLabel": "変更を保存",
"advancedSettings.form.saveButtonTooltipWithInvalidChanges": "保存前に無効な設定を修正してください。",
"advancedSettings.form.saveErrorMessage": "を保存できませんでした",
"advancedSettings.globalCalloutSubtitle": "変更はすべてのスペースのすべてのユーザーに適用されます。これにはネイティブKibanaユーザーとシングルサインオンユーザーの両方が含まれます。",
"advancedSettings.globalCalloutTitle": "変更はすべてのスペースのすべてのユーザー設定に影響します",
"advancedSettings.globalSettingsTabTitle": "グローバル設定",
"advancedSettings.searchBar.unableToParseQueryErrorMessage": "クエリをパースできません",
"advancedSettings.searchBarAriaLabel": "高度な設定を検索",
"advancedSettings.spaceSettingsTabTitle": "スペース設定",
"advancedSettings.voiceAnnouncement.ariaLabel": "詳細設定結果情報",
"autocomplete.conflictIndicesWarning.index.description": "{name}{count}個のインデックス)",
"autocomplete.customOptionText": "{searchValuePlaceholder}をカスタムフィールドとして追加",
"autocomplete.conflictIndicesWarning.description": "このフィールドは、次のインデックスで別の型として定義されているか、マッピングされていません。これにより、予期しないクエリ結果になる場合があります。",

View file

@ -76,64 +76,6 @@
}
},
"messages": {
"advancedSettings.field.changeImageLinkAriaLabel": "更改 {ariaName}",
"advancedSettings.field.defaultValueText": "默认值:{value}",
"advancedSettings.field.defaultValueTypeJsonText": "默认值:{value}",
"advancedSettings.field.deprecationClickAreaLabel": "单击以查看 {settingName} 的过时文档。",
"advancedSettings.field.resetToDefaultLinkAriaLabel": "将 {ariaName} 重置为默认值",
"advancedSettings.form.countOfSettingsChanged": "{unsavedCount} 个未保存{unsavedCount, plural, other {设置}}{hiddenCount, plural, =0 {} other {# 个已隐藏}}",
"advancedSettings.form.noSearchResultText": "未找到 {queryText} 的设置{clearSearch}",
"advancedSettings.form.searchResultText": "搜索词隐藏了 {settingsCount} 个设置{clearSearch}",
"advancedSettings.voiceAnnouncement.noSearchResultScreenReaderMessage": "{sectionLenght, plural, other {# 个部分}}中有 {optionLenght, plural, other {# 个选项}}",
"advancedSettings.voiceAnnouncement.searchResultScreenReaderMessage": "您已搜索 {query}。{sectionLenght, plural, other {# 个部分}}中有 {optionLenght, plural, other {# 个选项}}",
"advancedSettings.advancedSettingsLabel": "高级设置",
"advancedSettings.badge.readOnly.text": "只读",
"advancedSettings.badge.readOnly.tooltip": "无法保存高级设置",
"advancedSettings.callOutCautionDescription": "此处请谨慎操作,这些设置仅供高级用户使用。您在这里所做的更改可能使 Kibana 的大部分功能出现问题。这些设置有一部分可能未在文档中说明、不受支持或处于技术预览状态。如果字段有默认值,将字段留空会将其设置为默认值,其他配置指令可能不接受其默认值。删除定制设置会将其从 Kibana 的配置中永久删除。",
"advancedSettings.callOutCautionTitle": "注意:在这里您可能会使问题出现",
"advancedSettings.categoryNames.dashboardLabel": "仪表板",
"advancedSettings.categoryNames.discoverLabel": "Discover",
"advancedSettings.categoryNames.enterpriseSearchLabel": "Enterprise Search",
"advancedSettings.categoryNames.generalLabel": "常规",
"advancedSettings.categoryNames.machineLearningLabel": "Machine Learning",
"advancedSettings.categoryNames.notificationsLabel": "通知",
"advancedSettings.categoryNames.observabilityLabel": "Observability",
"advancedSettings.categoryNames.reportingLabel": "报告",
"advancedSettings.categoryNames.searchLabel": "搜索",
"advancedSettings.categoryNames.securitySolutionLabel": "安全解决方案",
"advancedSettings.categoryNames.timelionLabel": "Timelion",
"advancedSettings.categoryNames.visualizationsLabel": "可视化",
"advancedSettings.categorySearchLabel": "类别",
"advancedSettings.defaultSpaceCalloutSubtitle": "将仅对当前工作区应用更改。这些设置适用于高级用户,因为配置错误可能会对 Kibana 的某些方面造成负面影响。",
"advancedSettings.defaultSpaceCalloutTitle": "更改将影响当前工作区。",
"advancedSettings.featureCatalogueTitle": "定制您的 Kibana 体验 — 更改日期格式、打开深色模式,等等。",
"advancedSettings.field.changeImageLinkText": "更改图片",
"advancedSettings.field.codeEditorSyntaxErrorMessage": "JSON 语法无效",
"advancedSettings.field.customSettingAriaLabel": "定制设置",
"advancedSettings.field.customSettingTooltip": "定制设置",
"advancedSettings.field.helpText": "此设置已由 Kibana 服务器覆盖,无法更改。",
"advancedSettings.field.imageChangeErrorMessage": "图片无法保存",
"advancedSettings.field.invalidIconLabel": "无效",
"advancedSettings.field.offLabel": "关闭",
"advancedSettings.field.onLabel": "开启",
"advancedSettings.field.resetToDefaultLinkText": "重置为默认值",
"advancedSettings.field.settingIsUnsaved": "设备当前未保存。",
"advancedSettings.field.unsavedIconLabel": "未保存",
"advancedSettings.form.cancelButtonLabel": "取消更改",
"advancedSettings.form.clearNoSearchResultText": "(清除搜索)",
"advancedSettings.form.clearSearchResultText": "(清除搜索)",
"advancedSettings.form.requiresPageReloadToastButtonLabel": "重新加载页面",
"advancedSettings.form.requiresPageReloadToastDescription": "一个或多个设置需要您重新加载页面才能生效。",
"advancedSettings.form.saveButtonLabel": "保存更改",
"advancedSettings.form.saveButtonTooltipWithInvalidChanges": "保存前请修复无效的设置。",
"advancedSettings.form.saveErrorMessage": "无法保存",
"advancedSettings.globalCalloutSubtitle": "将对所有工作区的所有用户应用更改。这包括本机 Kibana 用户和单点登录用户。",
"advancedSettings.globalCalloutTitle": "更改将影响所有工作区的所有用户设置",
"advancedSettings.globalSettingsTabTitle": "常规设置",
"advancedSettings.searchBar.unableToParseQueryErrorMessage": "无法解析查询",
"advancedSettings.searchBarAriaLabel": "搜索高级设置",
"advancedSettings.spaceSettingsTabTitle": "工作区设置",
"advancedSettings.voiceAnnouncement.ariaLabel": "“高级设置”的结果信息",
"autocomplete.conflictIndicesWarning.index.description": "{name}{count} 个索引)",
"autocomplete.customOptionText": "将 {searchValuePlaceholder} 添加为字段",
"autocomplete.conflictIndicesWarning.description": "此字段在以下索引中定义为不同类型或未映射。这可能导致意外的查询结果。",

View file

@ -40,21 +40,21 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
// clicking on the toggle button
it('adv settings - toggle ', async () => {
await testSubjects.click('advancedSetting-editField-csv:quoteValues');
await testSubjects.click('management-settings-editField-csv:quoteValues');
await toasts.dismissAllToasts();
await a11y.testAppSnapshot();
});
// clicking on editor panel
it('adv settings - edit ', async () => {
await testSubjects.click('advancedSetting-editField-csv:separator');
await testSubjects.click('management-settings-editField-csv:separator');
await toasts.dismissAllToasts();
await a11y.testAppSnapshot();
});
// clicking on save button
it('adv settings - save', async () => {
await testSubjects.click('advancedSetting-saveButton');
await testSubjects.click('settings-save-button');
await toasts.dismissAllToasts();
await a11y.testAppSnapshot();
});

View file

@ -15,7 +15,7 @@ export const SOURCERER = {
badgeAlerts: '[data-test-subj="sourcerer-alerts-badge"]',
badgeAlertsOption: '[data-test-subj="security-alerts-option-badge"]',
siemDefaultIndexInput:
'[data-test-subj="advancedSetting-editField-securitySolution:defaultIndex"]',
'[data-test-subj="management-settings-editField-securitySolution:defaultIndex"]',
popoverTitle: '[data-test-subj="sourcerer-title"]',
resetButton: 'button[data-test-subj="sourcerer-reset"]',
saveButton: 'button[data-test-subj="sourcerer-save"]',

View file

@ -96,7 +96,7 @@ export const resetSourcerer = () => {
export const clickAlertCheckbox = () => cy.get(SOURCERER.alertCheckbox).check({ force: true });
export const addIndexToDefault = (index: string) => {
visitWithTimeRange(`/app/management/kibana/settings?query=category:(securitySolution)`);
visitWithTimeRange(`/app/management/kibana/settings?query=categories:(securitySolution)`);
cy.get(SOURCERER.siemDefaultIndexInput)
.invoke('val')
.then((patterns) => {
@ -107,8 +107,8 @@ export const addIndexToDefault = (index: string) => {
}
});
cy.get('button[data-test-subj="advancedSetting-saveButton"]').click();
cy.get('button[data-test-subj="windowReloadButton"]').click();
cy.get('button[data-test-subj="settings-save-button"]').click();
cy.get('button[data-test-subj="pageReloadButton"]').click();
visitWithTimeRange(hostsUrl('allHosts'));
});
};

View file

@ -18,7 +18,7 @@ export default function ({ getService, getPageObjects }: FtrProviderContext) {
it('should not have cache setting', async () => {
await testSubjects.missingOrFail(
'advancedSetting-editField-data_views\\:cache_max_age-group'
'management-settings-editField-data_views\\:cache_max_age-group'
);
});
});