Skip to content

Commit

Permalink
[Advanced Settings] Add tab for global settings (#174301)
Browse files Browse the repository at this point in the history
Closes #172921

## Summary

This PR adds tabs for Space/Global settings to the new settings
application in `packages/kbn-management/settings`. If no global settings
exist (as is the case in serverless), the tabs are simply not rendered
and only the space settings are displayed.

**When global settings exist:**

<img width="1641" alt="Screenshot 2024-01-08 at 15 49 21"
src="https://github.com/elastic/kibana/assets/59341489/8b78cb13-fc2e-4122-8141-eaa3a7fc2bde">
<img width="1641" alt="Screenshot 2024-01-08 at 15 49 25"
src="https://github.com/elastic/kibana/assets/59341489/10fa79dc-4a39-454b-8989-3bb27c849118">

<br><br>

**When no global settings exist:**

<img width="1641" alt="Screenshot 2024-01-08 at 15 49 47"
src="https://github.com/elastic/kibana/assets/59341489/ca40478d-da85-4ff5-8a7b-f4d2e71f1f0a">



### How to test

1. Testing in Storybook:
The Application component can be tested in the [Storybook
deployment](https://ci-artifacts.kibana.dev/storybooks/pr-174301/d14d00aa03ffdb3b166ff78161102ac1867074e6/index.html)
in this PR.

2. Testing in Serverless:

**Verify that the Advanced settings app in serverless is not affected:**
Since there are no global settings in serverless (as there are no
spaces), these changes shouldn't affect how the Advanced settings app
behaves in serverless; that is, there shouldn't be any tabs.

**Test the tabs by allowlisting global settings:**
It is possible to test the tabs in serverless by allowlisting some of
the [global settings in
Kibana](https://www.elastic.co/guide/en/kibana/current/advanced-options.html#kibana-custom-branding-settings).
For example, add the following setting Id's to the allowlist of common
settings in `packages/serverless/settings/common/index.ts`:
```
export const ALL_COMMON_SETTINGS = [
  ...
  ...DISCOVER_SETTINGS,
  ...NOTIFICATION_SETTINGS,
  'xpackCustomBranding:logo',
  'xpackCustomBranding:customizedLogo',
  'xpackCustomBranding:pageTitle',
];
```
Then run Es with `yarn es serverless` and Kibana with `yarn
serverless-{es/oblt/security}` and verify that the Advanced settings app
contains a Global settings tab with the allowlisted global settings
above and that it behaves as the original Advanced settings app behaves
in stateful Kibana.

### Checklist

- [x] Any text added follows [EUI's writing
guidelines](https://elastic.github.io/eui/#/guidelines/writing), uses
sentence case text and includes [i18n
support](https://github.com/elastic/kibana/blob/main/packages/kbn-i18n/README.md)
- [x]
[Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html)
was added for features that require explanation or tutorials
- [x] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios
- [x] Any UI touched in this PR is usable by keyboard only (learn more
about [keyboard accessibility](https://webaim.org/techniques/keyboard/))
- [x] Any UI touched in this PR does not create any new axe failures
(run axe in browser:
[FF](https://addons.mozilla.org/en-US/firefox/addon/axe-devtools/),
[Chrome](https://chrome.google.com/webstore/detail/axe-web-accessibility-tes/lhdoppojpmngadmnindnejefpokejbdd?hl=en-US))
- [x] This renders correctly on smaller devices using a responsive
layout. (You can test this [in your
browser](https://www.browserstack.com/guide/responsive-testing-on-local-server))
- [x] This was checked for [cross-browser
compatibility](https://www.elastic.co/support/matrix#matrix_browsers)

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
  • Loading branch information
ElenaStoeva and kibanamachine authored Jan 18, 2024
1 parent 541174a commit 57fb10d
Show file tree
Hide file tree
Showing 34 changed files with 513 additions and 121 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,12 @@ import type { ComponentMeta, Story } from '@storybook/react';
import { action } from '@storybook/addon-actions';

import { Subscription } from 'rxjs';
import {
getGlobalSettingsMock,
getSettingsMock,
} from '@kbn/management-settings-utilities/mocks/settings.mock';
import { UiSettingsScope } from '@kbn/core-ui-settings-common';
import { SettingsApplication as Component } from '../application';
import { useApplicationStory } from './use_application_story';
import { SettingsApplicationProvider } from '../services';

export default {
Expand All @@ -25,30 +29,45 @@ export default {
},
} as ComponentMeta<typeof Component>;

export const SettingsApplication: Story = () => {
const { getAllowListedSettings } = useApplicationStory();
/**
* Props for a {@link SettinggApplication} Storybook story.
*/
export interface StoryProps {
hasGlobalSettings: boolean;
}

const getSettingsApplicationStory = ({ hasGlobalSettings }: StoryProps) => (
<SettingsApplicationProvider
showDanger={action('showDanger')}
links={{ deprecationKey: 'link/to/deprecation/docs' }}
getAllowlistedSettings={(scope: UiSettingsScope) =>
scope === 'namespace' ? getSettingsMock() : hasGlobalSettings ? getGlobalSettingsMock() : {}
}
isCustomSetting={() => false}
isOverriddenSetting={() => false}
saveChanges={action('saveChanges')}
showError={action('showError')}
showReloadPagePrompt={action('showReloadPagePrompt')}
subscribeToUpdates={() => new Subscription()}
addUrlToHistory={action('addUrlToHistory')}
validateChange={async (key, value) => {
action(`validateChange`)({
key,
value,
});
return { successfulValidation: true, valid: true };
}}
>
<Component />
</SettingsApplicationProvider>
);

export const SettingsApplicationWithGlobalSettings: Story = () =>
getSettingsApplicationStory({
hasGlobalSettings: true,
});

return (
<SettingsApplicationProvider
showDanger={action('showDanger')}
links={{ deprecationKey: 'link/to/deprecation/docs' }}
getAllowlistedSettings={getAllowListedSettings}
isCustomSetting={() => false}
isOverriddenSetting={() => false}
saveChanges={action('saveChanges')}
showError={action('showError')}
showReloadPagePrompt={action('showReloadPagePrompt')}
subscribeToUpdates={() => new Subscription()}
addUrlToHistory={action('addUrlToHistory')}
validateChange={async (key, value) => {
action(`validateChange`)({
key,
value,
});
return { successfulValidation: true, valid: true };
}}
>
<Component />
</SettingsApplicationProvider>
);
};
export const SettingsApplicationWithoutGlobal: Story = () =>
getSettingsApplicationStory({
hasGlobalSettings: false,
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,5 +9,5 @@
import { getSettingsMock } from '@kbn/management-settings-utilities/mocks/settings.mock';

export const useApplicationStory = () => {
return { getAllowListedSettings: getSettingsMock };
return { getAllowlistedSettings: getSettingsMock };
};
68 changes: 64 additions & 4 deletions packages/kbn-management/settings/application/application.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,24 @@

import React from 'react';
import { act, fireEvent, render, waitFor } from '@testing-library/react';
import { SettingsApplication, DATA_TEST_SUBJ_SETTINGS_TITLE } from './application';
import {
SettingsApplication,
DATA_TEST_SUBJ_SETTINGS_TITLE,
SPACE_SETTINGS_TAB_ID,
GLOBAL_SETTINGS_TAB_ID,
} from './application';
import { DATA_TEST_SUBJ_SETTINGS_SEARCH_BAR } from './query_input';
import {
DATA_TEST_SUBJ_SETTINGS_EMPTY_STATE,
DATA_TEST_SUBJ_SETTINGS_CLEAR_SEARCH_LINK,
} from './empty_state';
import { DATA_TEST_SUBJ_PREFIX_TAB } from './tab';
import { DATA_TEST_SUBJ_SETTINGS_CATEGORY } from '@kbn/management-settings-components-field-category/category';
import { wrap, createSettingsApplicationServicesMock } from './mocks';
import { SettingsApplicationServices } from './services';

const categories = ['general', 'dashboard', 'notifications'];
const spaceCategories = ['general', 'dashboard', 'notifications'];
const globalCategories = ['custom branding'];

describe('Settings application', () => {
beforeEach(() => {
Expand All @@ -32,7 +39,7 @@ describe('Settings application', () => {
expect(getByTestId(DATA_TEST_SUBJ_SETTINGS_TITLE)).toBeInTheDocument();
expect(getByTestId(DATA_TEST_SUBJ_SETTINGS_SEARCH_BAR)).toBeInTheDocument();
// Verify that all category panels are rendered
for (const category of categories) {
for (const category of spaceCategories) {
expect(getByTestId(`${DATA_TEST_SUBJ_SETTINGS_CATEGORY}-${category}`)).toBeInTheDocument();
}
});
Expand Down Expand Up @@ -68,8 +75,61 @@ describe('Settings application', () => {
fireEvent.click(clearSearchLink);
});

for (const category of categories) {
for (const category of spaceCategories) {
expect(getByTestId(`${DATA_TEST_SUBJ_SETTINGS_CATEGORY}-${category}`)).toBeInTheDocument();
}
});

describe('Tabs', () => {
const spaceSettingsTestSubj = `${DATA_TEST_SUBJ_PREFIX_TAB}-${SPACE_SETTINGS_TAB_ID}`;
const globalSettingsTestSubj = `${DATA_TEST_SUBJ_PREFIX_TAB}-${GLOBAL_SETTINGS_TAB_ID}`;

it("doesn't render tabs when there are no global settings", () => {
const services: SettingsApplicationServices = createSettingsApplicationServicesMock(false);

const { container, queryByTestId } = render(wrap(<SettingsApplication />, services));

expect(container).toBeInTheDocument();
expect(queryByTestId(spaceSettingsTestSubj)).toBeNull();
expect(queryByTestId(globalSettingsTestSubj)).toBeNull();
});

it('renders tabs when global settings are enabled', () => {
const services: SettingsApplicationServices = createSettingsApplicationServicesMock(true);

const { container, getByTestId } = render(wrap(<SettingsApplication />, services));

expect(container).toBeInTheDocument();
expect(getByTestId(spaceSettingsTestSubj)).toBeInTheDocument();
expect(getByTestId(globalSettingsTestSubj)).toBeInTheDocument();
});

it('can switch between tabs', () => {
const services: SettingsApplicationServices = createSettingsApplicationServicesMock(true);

const { getByTestId } = render(wrap(<SettingsApplication />, services));

const spaceTab = getByTestId(spaceSettingsTestSubj);
const globalTab = getByTestId(globalSettingsTestSubj);

// Initially the Space tab should be selected
expect(spaceTab.className).toContain('selected');
expect(globalTab.className).not.toContain('selected');

act(() => {
fireEvent.click(globalTab);
});

expect(spaceTab.className).not.toContain('selected');
expect(globalTab.className).toContain('selected');

// Should render the page correctly with the Global tab selected
expect(getByTestId(DATA_TEST_SUBJ_SETTINGS_TITLE)).toBeInTheDocument();
expect(getByTestId(DATA_TEST_SUBJ_SETTINGS_SEARCH_BAR)).toBeInTheDocument();
// Verify that all category panels are rendered
for (const category of globalCategories) {
expect(getByTestId(`${DATA_TEST_SUBJ_SETTINGS_CATEGORY}-${category}`)).toBeInTheDocument();
}
});
});
});
98 changes: 73 additions & 25 deletions packages/kbn-management/settings/application/application.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,22 +7,28 @@
*/
import React, { useState } from 'react';

import { EuiText, EuiSpacer, EuiFlexGroup, EuiFlexItem, Query } from '@elastic/eui';
import { i18n as i18nLib } from '@kbn/i18n';

import {
EuiText,
EuiSpacer,
EuiFlexGroup,
EuiFlexItem,
Query,
EuiTabs,
EuiCallOut,
} from '@elastic/eui';
import { getCategoryCounts } from '@kbn/management-settings-utilities';
import { Form } from '@kbn/management-settings-components-form';
import { categorizeFields } from '@kbn/management-settings-utilities';

import { useFields } from './hooks/use_fields';
import { QueryInput, QueryInputProps } from './query_input';
import { SettingsTabs } from '@kbn/management-settings-types/tab';
import { EmptyState } from './empty_state';
import { i18nTexts } from './i18n_texts';
import { Tab } from './tab';
import { useScopeFields } from './hooks/use_scope_fields';
import { QueryInput, QueryInputProps } from './query_input';
import { useServices } from './services';

const title = i18nLib.translate('management.settings.advancedSettingsLabel', {
defaultMessage: 'Advanced Settings',
});

export const DATA_TEST_SUBJ_SETTINGS_TITLE = 'managementSettingsTitle';
export const SPACE_SETTINGS_TAB_ID = 'space-settings';
export const GLOBAL_SETTINGS_TAB_ID = 'global-settings';

function addQueryParam(url: string, param: string, value: string) {
const urlObj = new URL(url);
Expand Down Expand Up @@ -52,42 +58,84 @@ export const SettingsApplication = () => {
const queryParam = getQueryParam(window.location.href);
const [query, setQuery] = useState<Query>(Query.parse(queryParam));

const allFields = useFields();
const filteredFields = useFields(query);

const onQueryChange: QueryInputProps['onQueryChange'] = (newQuery = Query.parse('')) => {
setQuery(newQuery);

const search = addQueryParam(window.location.href, 'query', newQuery.text);
addUrlToHistory(search);
};

const categorizedFields = categorizeFields(allFields);
const categories = Object.keys(categorizedFields);
const categoryCounts: { [category: string]: number } = {};
for (const category of categories) {
categoryCounts[category] = categorizedFields[category].count;
const [spaceAllFields, globalAllFields] = useScopeFields();
const [spaceFilteredFields, globalFilteredFields] = useScopeFields(query);

const globalSettingsEnabled = globalAllFields.length > 0;

const tabs: SettingsTabs = {
[SPACE_SETTINGS_TAB_ID]: {
name: i18nTexts.spaceTabTitle,
fields: spaceFilteredFields,
categoryCounts: getCategoryCounts(spaceAllFields),
callOutTitle: i18nTexts.spaceCalloutTitle,
callOutText: i18nTexts.spaceCalloutText,
},
};
// Only add a Global settings tab if there are any global settings
if (globalSettingsEnabled) {
tabs[GLOBAL_SETTINGS_TAB_ID] = {
name: i18nTexts.globalTabTitle,
fields: globalFilteredFields,
categoryCounts: getCategoryCounts(globalAllFields),
callOutTitle: i18nTexts.globalCalloutTitle,
callOutText: i18nTexts.globalCalloutText,
};
}

const [selectedTabId, setSelectedTabId] = useState(SPACE_SETTINGS_TAB_ID);
const selectedTab = tabs[selectedTabId];

return (
<div>
<EuiFlexGroup>
<EuiFlexItem>
<EuiText>
<h1 data-test-subj={DATA_TEST_SUBJ_SETTINGS_TITLE}>{title}</h1>
<h1 data-test-subj={DATA_TEST_SUBJ_SETTINGS_TITLE}>
{i18nTexts.advancedSettingsTitle}
</h1>
</EuiText>
</EuiFlexItem>
<EuiFlexItem>
<QueryInput {...{ categories, query, onQueryChange }} />
<QueryInput
{...{ categories: Object.keys(selectedTab.categoryCounts), query, onQueryChange }}
/>
</EuiFlexItem>
</EuiFlexGroup>
<EuiSpacer size="xxl" />
{filteredFields.length ? (
<EuiSpacer size="m" />
{globalSettingsEnabled && (
<>
<EuiTabs>
{Object.keys(tabs).map((id) => (
<Tab
id={id}
name={tabs[id].name}
onChangeSelectedTab={() => setSelectedTabId(id)}
isSelected={id === selectedTabId}
/>
))}
</EuiTabs>
<EuiSpacer size="xl" />
<EuiCallOut title={selectedTab.callOutTitle} iconType="warning">
<p>{selectedTab.callOutText}</p>
</EuiCallOut>
</>
)}
<EuiSpacer size="xl" />
{selectedTab.fields.length ? (
<Form
fields={filteredFields}
categoryCounts={categoryCounts}
fields={selectedTab.fields}
categoryCounts={selectedTab.categoryCounts}
isSavingEnabled={true}
onClearQuery={() => onQueryChange()}
scope={selectedTabId === SPACE_SETTINGS_TAB_ID ? 'namespace' : 'global'}
/>
) : (
<EmptyState {...{ queryText: query?.text, onClearQuery: () => onQueryChange() }} />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@

export { useFields } from './use_fields';
export { useSettings } from './use_settings';
export { useScopeFields } from './use_scope_fields';
13 changes: 9 additions & 4 deletions packages/kbn-management/settings/application/hooks/use_fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,24 @@
import { Query } from '@elastic/eui';
import { getFieldDefinitions } from '@kbn/management-settings-field-definition';
import { FieldDefinition } from '@kbn/management-settings-types';
import { UiSettingsScope } from '@kbn/core-ui-settings-common';
import { useServices } from '../services';
import { useSettings } from './use_settings';

/**
* React hook which retrieves settings and returns an observed collection of
* {@link FieldDefinition} objects derived from those settings.
* @param scope The {@link UiSettingsScope} of the settings to be retrieved.
* @param query The {@link Query} to execute for filtering the fields.
* @returns An array of {@link FieldDefinition} objects.
*/
export const useFields = (query?: Query): FieldDefinition[] => {
const { isCustomSetting: isCustom, isOverriddenSetting: isOverridden } = useServices();
const settings = useSettings();
const fields = getFieldDefinitions(settings, { isCustom, isOverridden });
export const useFields = (scope: UiSettingsScope, query?: Query): FieldDefinition[] => {
const { isCustomSetting, isOverriddenSetting } = useServices();
const settings = useSettings(scope);
const fields = getFieldDefinitions(settings, {
isCustom: (key) => isCustomSetting(key, scope),
isOverridden: (key) => isOverriddenSetting(key, scope),
});
if (query) {
return Query.execute(query, fields);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
/*
* 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 { Query } from '@elastic/eui';
import { FieldDefinition } from '@kbn/management-settings-types';
import { useFields } from './use_fields';

/**
* React hook which retrieves the fields for each scope (`namespace` and `global`)
* and returns two collections of {@link FieldDefinition} objects.
* @param query The {@link Query} to execute for filtering the fields.
* @returns Two arrays of {@link FieldDefinition} objects.
*/
export const useScopeFields = (query?: Query): [FieldDefinition[], FieldDefinition[]] => {
const spaceFields = useFields('namespace', query);
const globalFields = useFields('global', query);
return [spaceFields, globalFields];
};
Loading

0 comments on commit 57fb10d

Please sign in to comment.