From 5212f09b780459c8212697a419d5651b83361d6d Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Tue, 2 Jul 2024 00:11:18 +0800 Subject: [PATCH] [navigation-next] feat: introduce new interface for group (#7060) * feat: introduce new interface for use case Signed-off-by: SuZhou-Joe * Changeset file for PR #7060 created/updated * feat: introduce new interface for use case Signed-off-by: SuZhou-Joe * feat: introduce new interface for use case Signed-off-by: SuZhou-Joe * feat: introduce new interface for use case Signed-off-by: SuZhou-Joe * fix: update test snapshot Signed-off-by: SuZhou-Joe * fix: update based on comment Signed-off-by: SuZhou-Joe * Changeset file for PR #7060 created/updated * fix: update based on comment Signed-off-by: SuZhou-Joe * fix: update based on comment Signed-off-by: SuZhou-Joe * fix: update based on comment Signed-off-by: SuZhou-Joe * feat: update based on comment Signed-off-by: SuZhou-Joe * feat: update comment Signed-off-by: SuZhou-Joe * feat: add default nav group Signed-off-by: SuZhou-Joe * fix: type error Signed-off-by: SuZhou-Joe * fix: type error Signed-off-by: SuZhou-Joe * feat: expose DEFAULT_NAV_GROUPS in src/core/public Signed-off-by: SuZhou-Joe * feat: export NavGroupItemInMap type Signed-off-by: SuZhou-Joe * feat: update README Signed-off-by: SuZhou-Joe * feat: update README Signed-off-by: SuZhou-Joe * feat: update interface Signed-off-by: SuZhou-Joe * refactor: move navGroup related interface into a service Signed-off-by: SuZhou-Joe * feat: remove useless code Signed-off-by: SuZhou-Joe * [Navigation-Next] Add nav group enabled in chrome service(7072) Signed-off-by: SuZhou-Joe * Remove useless code Signed-off-by: SuZhou-Joe * use homepage flag Signed-off-by: SuZhou-Joe * feat: update README Signed-off-by: SuZhou-Joe * feat: expose sorted navLinks Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * fix: bootstrap error Signed-off-by: SuZhou-Joe * fix: snapshot error Signed-off-by: SuZhou-Joe * feat: update according to comment Signed-off-by: SuZhou-Joe * feat: support parent link Signed-off-by: SuZhou-Joe * feat: update according to comment Signed-off-by: SuZhou-Joe * feat: update according to comment Signed-off-by: SuZhou-Joe --------- Signed-off-by: SuZhou-Joe Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Co-authored-by: ZilongX <99905560+ZilongX@users.noreply.github.com> --- changelogs/fragments/7060.yml | 2 + src/core/public/chrome/README.md | 26 +++ src/core/public/chrome/chrome_service.mock.ts | 8 + src/core/public/chrome/chrome_service.test.ts | 8 +- src/core/public/chrome/chrome_service.tsx | 20 +- src/core/public/chrome/index.ts | 1 + src/core/public/chrome/nav_group/index.ts | 12 + .../nav_group/nav_group_service.test.ts | 221 ++++++++++++++++++ .../chrome/nav_group/nav_group_service.ts | 136 +++++++++++ src/core/public/chrome/utils.test.ts | 138 +++++++++++ src/core/public/chrome/utils.ts | 175 ++++++++++++++ src/core/public/core_system.ts | 2 +- src/core/public/index.ts | 5 + src/core/types/index.ts | 1 + src/core/types/nav_group.ts | 27 +++ src/core/utils/default_app_categories.ts | 21 ++ src/core/utils/default_nav_groups.ts | 82 +++++++ src/core/utils/index.ts | 1 + .../dashboard_listing.test.tsx.snap | 20 ++ .../dashboard_top_nav.test.tsx.snap | 24 ++ src/plugins/home/server/ui_settings.ts | 1 + 21 files changed, 927 insertions(+), 4 deletions(-) create mode 100644 changelogs/fragments/7060.yml create mode 100644 src/core/public/chrome/nav_group/index.ts create mode 100644 src/core/public/chrome/nav_group/nav_group_service.test.ts create mode 100644 src/core/public/chrome/nav_group/nav_group_service.ts create mode 100644 src/core/public/chrome/utils.test.ts create mode 100644 src/core/public/chrome/utils.ts create mode 100644 src/core/types/nav_group.ts create mode 100644 src/core/utils/default_nav_groups.ts diff --git a/changelogs/fragments/7060.yml b/changelogs/fragments/7060.yml new file mode 100644 index 000000000000..80c8c85ee3c7 --- /dev/null +++ b/changelogs/fragments/7060.yml @@ -0,0 +1,2 @@ +feat: +- Introduce new interface for group ([#7060](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7060)) \ No newline at end of file diff --git a/src/core/public/chrome/README.md b/src/core/public/chrome/README.md index 6ec765a3bb0b..7d86aa7f2c1c 100644 --- a/src/core/public/chrome/README.md +++ b/src/core/public/chrome/README.md @@ -6,6 +6,7 @@ - [Nav Links Service](#navlinksservice-) - [Recently Accessed Service](#recentlyaccessedservice-) - [Doc Title Service](#doctitleservice-) +- [Nav Group Service](#chromenavgroupservice-) - [UI](#ui-) ## About : @@ -112,6 +113,31 @@ Gets an Observable of the array of recently accessed history :- chrome.docTitle.change('My application title') chrome.docTitle.change(['My application', 'My section']) ``` +### ChromeNavGroupService: +- Interface : ChromeNavGroup +- Signature : ```navGroup: ChromeNavGroupService``` +- Methods : +Add nav links to group :- + +`addNavLinksToGroup: (group: ChromeNavGroup, navLinks: ChromeRegistrationNavLink[]) => void;` + +Gets an Observable of the array of registered groups :- + +`getNavGroupsMap$: Observable>` +##### Register a new group with a navLink + + ```ts + coreSetup.chrome.navGroup.addNavLinksToGroup( + { + id: 'my-group', + title: 'A demo group', + description: 'description for demo group' + }, + [{ + id: 'nav' + }] + ) + ``` ### UI : ###### consists of tsx/scss files && renders UI components from css Library e.g `````` diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index b6ce429528a7..62f35ca693a6 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -36,6 +36,10 @@ import { getLogosMock } from '../../common/mocks'; const createSetupContractMock = () => { return { registerCollapsibleNavHeader: jest.fn(), + navGroup: { + addNavLinksToGroup: jest.fn(), + getNavGroupEnabled: jest.fn(), + }, }; }; @@ -70,6 +74,10 @@ const createStartContractMock = () => { getCenter$: jest.fn(), getRight$: jest.fn(), }, + navGroup: { + getNavGroupsMap$: jest.fn(() => new BehaviorSubject({})), + getNavGroupEnabled: jest.fn(), + }, setAppTitle: jest.fn(), setIsVisible: jest.fn(), getIsVisible$: jest.fn(), diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 154f07caf46c..1ef3fd34e5d9 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -90,6 +90,8 @@ async function start({ }: { options?: any; cspConfigMock?: any; startDeps?: ReturnType } = {}) { const service = new ChromeService(options); + service.setup({ uiSettings: startDeps.uiSettings }); + if (cspConfigMock) { startDeps.injectedMetadata.getCspConfig.mockReturnValue(cspConfigMock); } @@ -119,8 +121,9 @@ describe('setup', () => { const customHeaderMock = React.createElement('TestCustomNavHeader'); const renderMock = jest.fn().mockReturnValue(customHeaderMock); const chrome = new ChromeService({ browserSupportsCsp: true }); + const uiSettings = uiSettingsServiceMock.createSetupContract(); - const chromeSetup = chrome.setup(); + const chromeSetup = chrome.setup({ uiSettings }); chromeSetup.registerCollapsibleNavHeader(renderMock); const chromeStart = await chrome.start(defaultStartDeps()); @@ -135,8 +138,9 @@ describe('setup', () => { const customHeaderMock = React.createElement('TestCustomNavHeader'); const renderMock = jest.fn().mockReturnValue(customHeaderMock); const chrome = new ChromeService({ browserSupportsCsp: true }); + const uiSettings = uiSettingsServiceMock.createSetupContract(); - const chromeSetup = chrome.setup(); + const chromeSetup = chrome.setup({ uiSettings }); // call 1st time chromeSetup.registerCollapsibleNavHeader(renderMock); // call 2nd time diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 2660b4768839..822b4c23822e 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -52,6 +52,11 @@ import { Branding } from '../'; import { getLogos } from '../../common'; import type { Logos } from '../../common/types'; import { OverlayStart } from '../overlays'; +import { + ChromeNavGroupService, + ChromeNavGroupServiceSetupContract, + ChromeNavGroupServiceStartContract, +} from './nav_group'; export { ChromeNavControls, ChromeRecentlyAccessed, ChromeDocTitle }; @@ -90,6 +95,10 @@ interface ConstructorParams { browserSupportsCsp: boolean; } +export interface SetupDeps { + uiSettings: IUiSettingsClient; +} + export interface StartDeps { application: InternalApplicationStart; docLinks: DocLinksStart; @@ -111,6 +120,7 @@ export class ChromeService { private readonly navLinks = new NavLinksService(); private readonly recentlyAccessed = new RecentlyAccessedService(); private readonly docTitle = new DocTitleService(); + private readonly navGroup = new ChromeNavGroupService(); private collapsibleNavHeaderRender?: CollapsibleNavHeaderRender; constructor(private readonly params: ConstructorParams) {} @@ -147,7 +157,8 @@ export class ChromeService { ); } - public setup() { + public setup({ uiSettings }: SetupDeps): ChromeSetup { + const navGroup = this.navGroup.setup({ uiSettings }); return { registerCollapsibleNavHeader: (render: CollapsibleNavHeaderRender) => { if (this.collapsibleNavHeaderRender) { @@ -158,6 +169,7 @@ export class ChromeService { } this.collapsibleNavHeaderRender = render; }, + navGroup, }; } @@ -186,6 +198,7 @@ export class ChromeService { const navLinks = this.navLinks.start({ application, http }); const recentlyAccessed = await this.recentlyAccessed.start({ http }); const docTitle = this.docTitle.start({ document: window.document }); + const navGroup = await this.navGroup.start({ navLinks }); // erase chrome fields from a previous app while switching to a next app application.currentAppId$.subscribe(() => { @@ -254,6 +267,7 @@ export class ChromeService { recentlyAccessed, docTitle, logos, + navGroup, getHeaderComponent: () => (
void; + navGroup: ChromeNavGroupServiceSetupContract; } /** @@ -397,6 +413,8 @@ export interface ChromeStart { recentlyAccessed: ChromeRecentlyAccessed; /** {@inheritdoc ChromeDocTitle} */ docTitle: ChromeDocTitle; + /** {@inheritdoc NavGroupService} */ + navGroup: ChromeNavGroupServiceStartContract; /** {@inheritdoc Logos} */ readonly logos: Logos; diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index eb92ccdc6ba3..ec64291a36d2 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -50,3 +50,4 @@ export { ChromeRecentlyAccessed, ChromeRecentlyAccessedHistoryItem } from './rec export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; export { RightNavigationOrder } from './constants'; +export { ChromeRegistrationNavLink } from './nav_group'; diff --git a/src/core/public/chrome/nav_group/index.ts b/src/core/public/chrome/nav_group/index.ts new file mode 100644 index 000000000000..fae7bd3d0451 --- /dev/null +++ b/src/core/public/chrome/nav_group/index.ts @@ -0,0 +1,12 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +export { + ChromeNavGroupService, + ChromeNavGroupServiceSetupContract, + ChromeNavGroupServiceStartContract, + ChromeRegistrationNavLink, + NavGroupItemInMap, +} from './nav_group_service'; diff --git a/src/core/public/chrome/nav_group/nav_group_service.test.ts b/src/core/public/chrome/nav_group/nav_group_service.test.ts new file mode 100644 index 000000000000..8398b64fcb65 --- /dev/null +++ b/src/core/public/chrome/nav_group/nav_group_service.test.ts @@ -0,0 +1,221 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import * as Rx from 'rxjs'; +import { first } from 'rxjs/operators'; +import { ChromeNavGroupService, ChromeRegistrationNavLink } from './nav_group_service'; +import { uiSettingsServiceMock } from '../../ui_settings/ui_settings_service.mock'; +import { NavLinksService } from '../nav_links'; +import { applicationServiceMock, httpServiceMock } from '../../mocks'; +import { AppCategory } from 'opensearch-dashboards/public'; + +const mockedGroupFoo = { + id: 'foo', + title: 'foo', + description: 'foo', +}; + +const mockedGroupBar = { + id: 'bar', + title: 'bar', + description: 'bar', +}; + +const mockedNavLinkFoo: ChromeRegistrationNavLink = { + id: 'foo', + order: 10, +}; + +const mockedNavLinkBar: ChromeRegistrationNavLink = { + id: 'bar', + order: 20, +}; + +const mockedCategoryFoo: AppCategory = { + id: 'foo', + order: 15, + label: 'foo', +}; + +const mockedCategoryBar: AppCategory = { + id: 'bar', + order: 25, + label: 'bar', +}; + +const mockedHttpService = httpServiceMock.createStartContract(); +const mockedApplicationService = applicationServiceMock.createInternalStartContract(); +const mockedNavLink = new NavLinksService(); +const mockedNavLinkService = mockedNavLink.start({ + http: mockedHttpService, + application: mockedApplicationService, +}); + +const mockedGetNavLinks = jest.fn(); +jest.spyOn(mockedNavLinkService, 'getNavLinks$').mockImplementation(mockedGetNavLinks); +mockedGetNavLinks.mockReturnValue( + new Rx.BehaviorSubject([ + { + id: 'foo', + }, + { + id: 'bar', + }, + { + id: 'foo-in-category-foo', + }, + { + id: 'foo-in-category-bar', + }, + { + id: 'bar-in-category-foo', + }, + { + id: 'bar-in-category-bar', + }, + { + id: 'link-with-parent-nav-link-id', + }, + ]) +); + +describe('ChromeNavGroupService#setup()', () => { + it('should be able to `addNavLinksToGroup`', async () => { + const warnMock = jest.fn(); + jest.spyOn(console, 'warn').mockImplementation(warnMock); + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const chromeNavGroupService = new ChromeNavGroupService(); + const chromeNavGroupServiceSetup = chromeNavGroupService.setup({ uiSettings }); + + chromeNavGroupServiceSetup.addNavLinksToGroup(mockedGroupFoo, [mockedGroupFoo, mockedGroupBar]); + chromeNavGroupServiceSetup.addNavLinksToGroup(mockedGroupBar, [mockedGroupBar]); + const chromeNavGroupServiceStart = await chromeNavGroupService.start({ + navLinks: mockedNavLinkService, + }); + const groupsMap = await chromeNavGroupServiceStart.getNavGroupsMap$().pipe(first()).toPromise(); + expect(groupsMap[mockedGroupFoo.id].navLinks.length).toEqual(2); + expect(groupsMap[mockedGroupBar.id].navLinks.length).toEqual(1); + expect(groupsMap[mockedGroupFoo.id].id).toEqual(mockedGroupFoo.id); + expect(warnMock).toBeCalledTimes(0); + }); + + it('should output warning message if `addNavLinksToGroup` with same group id and navLink id', async () => { + const warnMock = jest.fn(); + jest.spyOn(console, 'warn').mockImplementation(warnMock); + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const chromeNavGroupService = new ChromeNavGroupService(); + const chromeNavGroupServiceSetup = chromeNavGroupService.setup({ uiSettings }); + + chromeNavGroupServiceSetup.addNavLinksToGroup(mockedGroupFoo, [ + mockedNavLinkFoo, + mockedGroupFoo, + ]); + chromeNavGroupServiceSetup.addNavLinksToGroup(mockedGroupBar, [mockedGroupBar]); + const chromeNavGroupServiceStart = await chromeNavGroupService.start({ + navLinks: mockedNavLinkService, + }); + const groupsMap = await chromeNavGroupServiceStart.getNavGroupsMap$().pipe(first()).toPromise(); + expect(groupsMap[mockedGroupFoo.id].navLinks.length).toEqual(1); + expect(groupsMap[mockedGroupBar.id].navLinks.length).toEqual(1); + expect(warnMock).toBeCalledTimes(1); + expect(warnMock).toBeCalledWith( + `[ChromeService] Navlink of ${mockedGroupFoo.id} has already been registered in group ${mockedGroupFoo.id}` + ); + }); + + it('should return navGroupEnabled from ui settings', () => { + const chrome = new ChromeNavGroupService(); + const uiSettings = uiSettingsServiceMock.createSetupContract(); + uiSettings.get$.mockImplementation(() => new Rx.BehaviorSubject(true)); + + const chromeSetup = chrome.setup({ uiSettings }); + expect(chromeSetup.getNavGroupEnabled()).toBe(true); + }); +}); + +describe('ChromeNavGroupService#start()', () => { + it('should be able to get the groups registered through addNavLinksToGroups with sorted order', async () => { + const chromeNavGroupService = new ChromeNavGroupService(); + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const chromeNavGroupServiceSetup = chromeNavGroupService.setup({ uiSettings }); + + chromeNavGroupServiceSetup.addNavLinksToGroup(mockedGroupFoo, [ + mockedNavLinkFoo, + { + ...mockedNavLinkFoo, + id: 'foo-in-category-foo', + category: mockedCategoryFoo, + }, + { + ...mockedNavLinkBar, + id: 'bar-in-category-foo', + category: mockedCategoryFoo, + }, + { + ...mockedNavLinkFoo, + id: 'foo-in-category-bar', + category: mockedCategoryBar, + }, + { + ...mockedNavLinkBar, + id: 'bar-in-category-bar', + category: mockedCategoryBar, + }, + mockedNavLinkBar, + { + id: 'link-with-parent-nav-link-id', + parentNavLinkId: 'not-exist-id', + }, + ]); + chromeNavGroupServiceSetup.addNavLinksToGroup(mockedGroupBar, [mockedNavLinkBar]); + + const chromeStart = await chromeNavGroupService.start({ navLinks: mockedNavLinkService }); + + const groupsMap = await chromeStart.getNavGroupsMap$().pipe(first()).toPromise(); + + expect(Object.keys(groupsMap).length).toEqual(2); + expect(groupsMap[mockedGroupFoo.id].navLinks.map((item) => item.id)).toEqual([ + 'foo', + 'foo-in-category-foo', + 'bar-in-category-foo', + 'bar', + 'foo-in-category-bar', + 'bar-in-category-bar', + 'link-with-parent-nav-link-id', + ]); + expect(groupsMap[mockedGroupBar.id].navLinks.length).toEqual(1); + }); + + it('should return navGroupEnabled from ui settings', async () => { + const chromeNavGroupService = new ChromeNavGroupService(); + const uiSettings = uiSettingsServiceMock.createSetupContract(); + uiSettings.get$.mockImplementation(() => new Rx.BehaviorSubject(true)); + chromeNavGroupService.setup({ uiSettings }); + const chromeNavGroupServiceStart = await chromeNavGroupService.start({ + navLinks: mockedNavLinkService, + }); + + expect(chromeNavGroupServiceStart.getNavGroupEnabled()).toBe(true); + }); + + it('should not update navGroupEnabled after stopped', async () => { + const uiSettings = uiSettingsServiceMock.createSetupContract(); + const navGroupEnabled$ = new Rx.BehaviorSubject(true); + uiSettings.get$.mockImplementation(() => navGroupEnabled$); + + const chromeNavGroupService = new ChromeNavGroupService(); + chromeNavGroupService.setup({ uiSettings }); + const chromeNavGroupServiceStart = await chromeNavGroupService.start({ + navLinks: mockedNavLinkService, + }); + + navGroupEnabled$.next(false); + expect(chromeNavGroupServiceStart.getNavGroupEnabled()).toBe(false); + + chromeNavGroupService.stop(); + navGroupEnabled$.next(true); + expect(chromeNavGroupServiceStart.getNavGroupEnabled()).toBe(false); + }); +}); diff --git a/src/core/public/chrome/nav_group/nav_group_service.ts b/src/core/public/chrome/nav_group/nav_group_service.ts new file mode 100644 index 000000000000..072b9f67f2e7 --- /dev/null +++ b/src/core/public/chrome/nav_group/nav_group_service.ts @@ -0,0 +1,136 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { BehaviorSubject, combineLatest, Observable, ReplaySubject, Subscription } from 'rxjs'; +import { AppCategory, ChromeNavGroup, ChromeNavLink } from 'opensearch-dashboards/public'; +import { map, takeUntil } from 'rxjs/operators'; +import { IUiSettingsClient } from '../../ui_settings'; +import { + flattenLinksOrCategories, + fulfillRegistrationLinksToChromeNavLinks, + getOrderedLinksOrCategories, +} from '../utils'; +import { ChromeNavLinks } from '../nav_links'; + +/** @public */ +export interface ChromeRegistrationNavLink { + id: string; + title?: string; + category?: AppCategory; + order?: number; + + /** + * link with parentNavLinkId field will be displayed as nested items in navigation. + */ + parentNavLinkId?: string; +} + +export type NavGroupItemInMap = ChromeNavGroup & { + navLinks: ChromeRegistrationNavLink[]; +}; + +export interface ChromeNavGroupServiceSetupContract { + addNavLinksToGroup: (navGroup: ChromeNavGroup, navLinks: ChromeRegistrationNavLink[]) => void; + /** + * Get a boolean value to indicates whether use case is enabled + */ + getNavGroupEnabled: () => boolean; +} + +export interface ChromeNavGroupServiceStartContract { + getNavGroupsMap$: () => Observable>; + getNavGroupEnabled: ChromeNavGroupServiceSetupContract['getNavGroupEnabled']; +} + +/** @internal */ +export class ChromeNavGroupService { + private readonly navGroupsMap$ = new BehaviorSubject>({}); + private readonly stop$ = new ReplaySubject(1); + private navLinks$: Observable>> = new BehaviorSubject([]); + private navGroupEnabled: boolean = false; + private navGroupEnabledUiSettingsSubscription: Subscription | undefined; + private addNavLinkToGroup( + currentGroupsMap: Record, + navGroup: ChromeNavGroup, + navLink: ChromeRegistrationNavLink + ) { + const matchedGroup = currentGroupsMap[navGroup.id]; + if (matchedGroup) { + const links = matchedGroup.navLinks; + const isLinkExistInGroup = links.some((link) => link.id === navLink.id); + if (isLinkExistInGroup) { + // eslint-disable-next-line no-console + console.warn( + `[ChromeService] Navlink of ${navLink.id} has already been registered in group ${navGroup.id}` + ); + return currentGroupsMap; + } + matchedGroup.navLinks.push(navLink); + } else { + currentGroupsMap[navGroup.id] = { + ...navGroup, + navLinks: [navLink], + }; + } + + return currentGroupsMap; + } + private getSortedNavGroupsMap$() { + return combineLatest([this.navGroupsMap$, this.navLinks$]) + .pipe(takeUntil(this.stop$)) + .pipe( + map(([navGroupsMap, navLinks]) => { + return Object.keys(navGroupsMap).reduce((sortedNavGroupsMap, navGroupId) => { + const navGroup = navGroupsMap[navGroupId]; + const sortedNavLinks = getOrderedLinksOrCategories( + fulfillRegistrationLinksToChromeNavLinks(navGroup.navLinks, navLinks) + ); + sortedNavGroupsMap[navGroupId] = { + ...navGroup, + navLinks: flattenLinksOrCategories(sortedNavLinks), + }; + return sortedNavGroupsMap; + }, {} as Record); + }) + ); + } + setup({ uiSettings }: { uiSettings: IUiSettingsClient }): ChromeNavGroupServiceSetupContract { + this.navGroupEnabledUiSettingsSubscription = uiSettings + .get$('home:useNewHomePage', false) + .subscribe((value) => { + this.navGroupEnabled = value; + }); + + return { + addNavLinksToGroup: (navGroup: ChromeNavGroup, navLinks: ChromeRegistrationNavLink[]) => { + // Construct a new groups map pointer. + const currentGroupsMap = { ...this.navGroupsMap$.getValue() }; + + const navGroupsMapAfterAdd = navLinks.reduce( + (groupsMap, navLink) => this.addNavLinkToGroup(groupsMap, navGroup, navLink), + currentGroupsMap + ); + + this.navGroupsMap$.next(navGroupsMapAfterAdd); + }, + getNavGroupEnabled: () => this.navGroupEnabled, + }; + } + async start({ + navLinks, + }: { + navLinks: ChromeNavLinks; + }): Promise { + this.navLinks$ = navLinks.getNavLinks$(); + return { + getNavGroupsMap$: () => this.getSortedNavGroupsMap$(), + getNavGroupEnabled: () => this.navGroupEnabled, + }; + } + async stop() { + this.stop$.next(); + this.navGroupEnabledUiSettingsSubscription?.unsubscribe(); + } +} diff --git a/src/core/public/chrome/utils.test.ts b/src/core/public/chrome/utils.test.ts new file mode 100644 index 000000000000..ed163b753eba --- /dev/null +++ b/src/core/public/chrome/utils.test.ts @@ -0,0 +1,138 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChromeRegistrationNavLink } from './nav_group'; +import { ChromeNavLink } from './nav_links'; +import { + getAllCategories, + fulfillRegistrationLinksToChromeNavLinks, + getOrderedLinks, + getOrderedLinksOrCategories, + flattenLinksOrCategories, +} from './utils'; + +const mockedNonCategoryLink = { + id: 'no-category', + title: 'no-category', + baseUrl: '', + href: '', + order: 6, +}; + +const mockedNavLinkA = { + id: 'a', + title: 'a', + baseUrl: '', + href: '', + category: { + id: 'a', + label: 'a', + order: 10, + }, + order: 10, +}; + +const mockedNavLinkB = { + id: 'b', + title: 'b', + baseUrl: '', + href: '', + category: { + id: 'b', + label: 'b', + order: 5, + }, + order: 5, +}; + +describe('getAllCategories', () => { + it('should return all categories', () => { + const links = { + a: [mockedNavLinkA], + b: [mockedNavLinkB], + }; + const categories = getAllCategories(links); + expect(categories).toEqual({ + a: { + id: 'a', + label: 'a', + order: 10, + }, + b: { + id: 'b', + label: 'b', + order: 5, + }, + }); + }); +}); + +describe('fulfillRegistrationLinksToChromeNavLinks', () => { + it('should return fullfilled ChromeNavLink', () => { + const registrationNavLinks: ChromeRegistrationNavLink[] = [ + { + id: 'a', + title: 'a', + category: { + id: 'a', + label: 'a', + order: 10, + }, + }, + { + id: 'b', + }, + ]; + const navLinks: ChromeNavLink[] = [mockedNavLinkA, mockedNavLinkB]; + const fulfilledResult = fulfillRegistrationLinksToChromeNavLinks( + registrationNavLinks, + navLinks + ); + expect(fulfilledResult).toEqual([mockedNavLinkA, mockedNavLinkB]); + }); +}); + +describe('getOrderedLinks', () => { + it('should return ordered links', () => { + const navLinks = [mockedNavLinkA, mockedNavLinkB]; + const orderedLinks = getOrderedLinks(navLinks); + expect(orderedLinks).toEqual([mockedNavLinkB, mockedNavLinkA]); + }); +}); + +describe('getOrderedLinksOrCategories', () => { + it('should return ordered links', () => { + const navLinks = [mockedNonCategoryLink, mockedNavLinkA, mockedNavLinkB]; + const orderedLinks = getOrderedLinksOrCategories(navLinks); + expect(orderedLinks[0]).toEqual( + expect.objectContaining({ + category: mockedNavLinkB.category, + }) + ); + expect(orderedLinks[1]).toEqual( + expect.objectContaining({ + link: mockedNonCategoryLink, + }) + ); + expect(orderedLinks[2]).toEqual( + expect.objectContaining({ + category: mockedNavLinkA.category, + }) + ); + }); +}); + +describe('flattenLinksOrCategories', () => { + it('should return flattened links', () => { + const navLinks = [mockedNonCategoryLink, mockedNavLinkA, mockedNavLinkB]; + const orderedLinks = getOrderedLinksOrCategories(navLinks); + const flattenedLinks = flattenLinksOrCategories(orderedLinks); + expect(flattenedLinks.map((item) => item.id)).toEqual([ + mockedNavLinkB.id, + mockedNonCategoryLink.id, + mockedNavLinkA.id, + ]); + }); +}); diff --git a/src/core/public/chrome/utils.ts b/src/core/public/chrome/utils.ts new file mode 100644 index 000000000000..8a9dec8b2145 --- /dev/null +++ b/src/core/public/chrome/utils.ts @@ -0,0 +1,175 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { AppCategory } from 'opensearch-dashboards/public'; +import { ChromeNavLink } from './nav_links'; +import { ChromeRegistrationNavLink } from './nav_group'; + +type KeyOf = keyof T; + +const sortBy = (key: KeyOf) => { + return (a: T, b: T): number => (a[key] > b[key] ? 1 : b[key] > a[key] ? -1 : 0); +}; + +const groupBy = (array: T[], getKey: (item: T) => string | undefined): Record => { + return array.reduce((result, currentValue) => { + const groupKey = String(getKey(currentValue)); + if (!result[groupKey]) { + result[groupKey] = []; + } + result[groupKey].push(currentValue); + return result; + }, {} as Record); +}; + +export const LinkItemType = { + LINK: 'link', + CATEGORY: 'category', + PARENT_LINK: 'parentLink', +} as const; + +export type LinkItem = { order?: number } & ( + | { itemType: 'link'; link: ChromeNavLink & ChromeRegistrationNavLink } + | { itemType: 'parentLink'; link?: ChromeNavLink & ChromeRegistrationNavLink; links: LinkItem[] } + | { itemType: 'category'; category?: AppCategory; links?: LinkItem[] } +); + +export function getAllCategories( + allCategorizedLinks: Record> +) { + const allCategories = {} as Record; + + for (const [key, value] of Object.entries(allCategorizedLinks)) { + allCategories[key] = value[0].category; + } + + return allCategories; +} + +/** + * This function accept an array of ChromeRegistrationNavLink and an array of ChromeNavLink + * return an fulfilled array of items which are the merged result of the registerNavLinks and navLinks. + * @param registerNavLinks ChromeRegistrationNavLink[] + * @param navLinks ChromeNavLink[] + * @returns Array + */ +export function fulfillRegistrationLinksToChromeNavLinks( + registerNavLinks: ChromeRegistrationNavLink[], + navLinks: ChromeNavLink[] +): Array { + const allExistingNavLinkId = navLinks.map((link) => link.id); + return ( + registerNavLinks + .filter((navLink) => allExistingNavLinkId.includes(navLink.id)) + .map((navLink) => ({ + ...navLinks[allExistingNavLinkId.indexOf(navLink.id)], + ...navLink, + })) || [] + ); +} + +export const getOrderedLinks = (navLinks: ChromeNavLink[]): ChromeNavLink[] => + navLinks.sort(sortBy('order')); + +export function flattenLinksOrCategories(linkItems: LinkItem[]): ChromeNavLink[] { + return linkItems.reduce((acc, item) => { + if (item.itemType === LinkItemType.LINK) { + acc.push(item.link); + } else if (item.itemType === LinkItemType.PARENT_LINK) { + if (item.link) { + acc.push(item.link); + } + acc.push(...flattenLinksOrCategories(item.links)); + } else if (item.itemType === LinkItemType.CATEGORY) { + acc.push(...flattenLinksOrCategories(item.links || [])); + } + + return acc; + }, [] as ChromeNavLink[]); +} + +export const generateItemTypeByLink = ( + navLink: ChromeNavLink & ChromeRegistrationNavLink, + navLinksGroupedByParentNavLink: Record +): LinkItem => { + const navLinksUnderParentId = navLinksGroupedByParentNavLink[navLink.id]; + + if (navLinksUnderParentId) { + return { + itemType: LinkItemType.PARENT_LINK, + link: navLink, + links: getOrderedLinks(navLinksUnderParentId || []).map((navLinkUnderParentId) => + generateItemTypeByLink(navLinkUnderParentId, navLinksGroupedByParentNavLink) + ), + order: navLink.order, + }; + } else { + return { + itemType: LinkItemType.LINK, + link: navLink, + order: navLink.order, + }; + } +}; + +/** + * This function accept navLinks and gives a grouped result for category / parent nav link + * @param navLinks + * @returns LinkItem[] + */ +export function getOrderedLinksOrCategories( + navLinks: Array +): LinkItem[] { + // Get the nav links group by parent nav link + const allNavLinksWithParentNavLink = navLinks.filter((navLink) => navLink.parentNavLinkId); + const navLinksGroupedByParentNavLink = groupBy( + allNavLinksWithParentNavLink, + (navLink) => navLink.parentNavLinkId + ); + + // Group all the nav links without parentNavLinkId + const groupedNavLinks = groupBy( + navLinks.filter((item) => !item.parentNavLinkId), + (link) => link?.category?.id + ); + const { undefined: unknowns = [], ...allCategorizedLinks } = groupedNavLinks; + const categoryDictionary = getAllCategories(allCategorizedLinks); + + // Get all the parent nav ids that defined by nested items but can not find matched parent nav in navLinks + const unusedParentNavLinks = Object.keys(navLinksGroupedByParentNavLink).filter( + (navLinkId) => !navLinks.find((navLink) => navLink.id === navLinkId) + ); + + const result = [ + // Nav links without category, the order is determined by link itself + ...unknowns.map((linkWithoutCategory) => + generateItemTypeByLink(linkWithoutCategory, navLinksGroupedByParentNavLink) + ), + // Nav links with category, the order is determined by category order + ...Object.keys(allCategorizedLinks).map((categoryKey) => { + return { + itemType: LinkItemType.CATEGORY, + category: categoryDictionary[categoryKey], + order: categoryDictionary[categoryKey]?.order, + links: getOrderedLinks(allCategorizedLinks[categoryKey]).map((navLink) => + generateItemTypeByLink(navLink, navLinksGroupedByParentNavLink) + ), + }; + }), + // Nav links that should have belong to a parent nav id + // but not find matched parent nav in navLinks + // should be treated as normal link + ...unusedParentNavLinks.reduce((total, groupId) => { + return [ + ...total, + ...navLinksGroupedByParentNavLink[groupId].map((navLink) => + generateItemTypeByLink(navLink, navLinksGroupedByParentNavLink) + ), + ]; + }, [] as LinkItem[]), + ]; + + return result.sort(sortBy('order')); +} diff --git a/src/core/public/core_system.ts b/src/core/public/core_system.ts index 58e92a1fa355..b01833d3e08b 100644 --- a/src/core/public/core_system.ts +++ b/src/core/public/core_system.ts @@ -171,7 +171,7 @@ export class CoreSystem { }); const application = this.application.setup({ context, http }); this.coreApp.setup({ application, http, injectedMetadata, notifications }); - const chrome = this.chrome.setup(); + const chrome = this.chrome.setup({ uiSettings }); const core: InternalCoreSetup = { application, diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 10d1928d6cf2..a60c81e97ec8 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -71,6 +71,7 @@ import { RightNavigationOrder, RightNavigationButton, RightNavigationButtonProps, + ChromeRegistrationNavLink, } from './chrome'; import { FatalErrorsSetup, FatalErrorsStart, FatalErrorInfo } from './fatal_errors'; import { HttpSetup, HttpStart } from './http'; @@ -103,6 +104,7 @@ export { cleanWorkspaceId, PUBLIC_WORKSPACE_ID, PUBLIC_WORKSPACE_NAME, + DEFAULT_NAV_GROUPS, } from '../utils'; export { AppCategory, @@ -114,6 +116,8 @@ export { StringValidationRegex, StringValidationRegexString, WorkspaceAttribute, + ChromeNavGroup, + NavGroupType, } from '../types'; export { @@ -366,6 +370,7 @@ export { RightNavigationOrder, RightNavigationButton, RightNavigationButtonProps, + ChromeRegistrationNavLink, }; export { __osdBootstrap__ } from './osd_bootstrap'; diff --git a/src/core/types/index.ts b/src/core/types/index.ts index f65d683f6bdc..3e4a15436faf 100644 --- a/src/core/types/index.ts +++ b/src/core/types/index.ts @@ -41,3 +41,4 @@ export * from './serializable'; export * from './custom_branding'; export * from './workspace'; export * from './cross_compatibility'; +export * from './nav_group'; diff --git a/src/core/types/nav_group.ts b/src/core/types/nav_group.ts new file mode 100644 index 000000000000..0359e14b8a07 --- /dev/null +++ b/src/core/types/nav_group.ts @@ -0,0 +1,27 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; + +/** + * There are two types of navGroup: + * 1: system nav group, like data administration / settings and setup + * 2: use case group, like observability. + * + * by default the nav group will be regarded as use case group. + */ +export enum NavGroupType { + SYSTEM = 'system', +} + +/** @public */ +export interface ChromeNavGroup { + id: string; + title: string; + description: string; + order?: number; + icon?: EuiIconType; + type?: NavGroupType; +} diff --git a/src/core/utils/default_app_categories.ts b/src/core/utils/default_app_categories.ts index 3c0920624e1b..e0b748021199 100644 --- a/src/core/utils/default_app_categories.ts +++ b/src/core/utils/default_app_categories.ts @@ -73,4 +73,25 @@ export const DEFAULT_APP_CATEGORIES: Record = Object.freeze order: 5000, euiIconType: 'managementApp', }, + investigate: { + id: 'investigate', + label: i18n.translate('core.ui.investigate.label', { + defaultMessage: 'Investigate', + }), + order: 1000, + }, + dashboardAndReport: { + id: 'dashboardAndReport', + label: i18n.translate('core.ui.dashboardAndReport.label', { + defaultMessage: 'Dashboard and report', + }), + order: 2000, + }, + analyzeSearch: { + id: 'analyzeSearch', + label: i18n.translate('core.ui.analyzeSearch.label', { + defaultMessage: 'Analyze search', + }), + order: 4000, + }, }); diff --git a/src/core/utils/default_nav_groups.ts b/src/core/utils/default_nav_groups.ts new file mode 100644 index 000000000000..eda0a6c4f1a3 --- /dev/null +++ b/src/core/utils/default_nav_groups.ts @@ -0,0 +1,82 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { i18n } from '@osd/i18n'; +import { ChromeNavGroup, NavGroupType } from '../types'; + +const defaultNavGroups = { + dataAdministration: { + id: 'dataAdministration', + title: i18n.translate('core.ui.group.dataAdministration.title', { + defaultMessage: 'data administration', + }), + description: i18n.translate('core.ui.group.dataAdministration.description', { + defaultMessage: 'Apply policies or security on your data.', + }), + order: 1000, + type: NavGroupType.SYSTEM, + }, + settingsAndSetup: { + id: 'settingsAndSetup', + title: i18n.translate('core.ui.group.settingsAndSetup.title', { + defaultMessage: 'settings and setup', + }), + description: i18n.translate('core.ui.group.settingsAndSetup.description', { + defaultMessage: 'Set up your cluster with index patterns.', + }), + order: 2000, + type: NavGroupType.SYSTEM, + }, + observability: { + id: 'observability', + title: i18n.translate('core.ui.group.observability.title', { + defaultMessage: 'Observability', + }), + description: i18n.translate('core.ui.group.observability.description', { + defaultMessage: + 'Gain visibility into system health, performance, and reliability through monitoring and analysis of logs, metrics, and traces.', + }), + order: 3000, + }, + 'security-analytics': { + id: 'security-analytics', + title: i18n.translate('core.ui.group.security.analytics.title', { + defaultMessage: 'Security Analytics', + }), + description: i18n.translate('core.ui.group.security.analytics.description', { + defaultMessage: + 'Detect and investigate potential security threats and vulnerabilities across your systems and data.', + }), + order: 4000, + }, + analytics: { + id: 'analytics', + title: i18n.translate('core.ui.group.analytics.title', { + defaultMessage: 'Analytics', + }), + description: i18n.translate('core.ui.group.analytics.description', { + defaultMessage: + 'Analyze data to derive insights, identify patterns and trends, and make data-driven decisions.', + }), + order: 5000, + }, + search: { + id: 'search', + title: i18n.translate('core.ui.group.search.title', { + defaultMessage: 'Search', + }), + description: i18n.translate('core.ui.group.search.description', { + defaultMessage: + "Quickly find and explore relevant information across your organization's data sources.", + }), + order: 6000, + }, +} as const; + +/** @internal */ +export const DEFAULT_NAV_GROUPS: Record< + keyof typeof defaultNavGroups, + ChromeNavGroup +> = Object.freeze(defaultNavGroups); diff --git a/src/core/utils/index.ts b/src/core/utils/index.ts index 9b58b7ef6d0d..a79b5d0bf2f7 100644 --- a/src/core/utils/index.ts +++ b/src/core/utils/index.ts @@ -44,3 +44,4 @@ export { PUBLIC_WORKSPACE_NAME, } from './constants'; export { getWorkspaceIdFromUrl, formatUrlWithWorkspaceId, cleanWorkspaceId } from './workspace'; +export { DEFAULT_NAV_GROUPS } from './default_nav_groups'; diff --git a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap index f4c5ca0b0a0a..6e60bd8bead2 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_listing/__snapshots__/dashboard_listing.test.tsx.snap @@ -219,6 +219,10 @@ exports[`dashboard listing hideWriteControls 1`] = ` "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], @@ -1393,6 +1397,10 @@ exports[`dashboard listing render table listing with initial filters from URL 1` "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], @@ -2628,6 +2636,10 @@ exports[`dashboard listing renders call to action when no dashboards exist 1`] = "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], @@ -3863,6 +3875,10 @@ exports[`dashboard listing renders table rows 1`] = ` "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], @@ -5098,6 +5114,10 @@ exports[`dashboard listing renders warning when listingLimit is exceeded 1`] = ` "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], diff --git a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap index 831eb5bd61c0..da537e452c4e 100644 --- a/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap +++ b/src/plugins/dashboard/public/application/components/dashboard_top_nav/__snapshots__/dashboard_top_nav.test.tsx.snap @@ -207,6 +207,10 @@ exports[`Dashboard top nav render in embed mode 1`] = ` "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], @@ -1207,6 +1211,10 @@ exports[`Dashboard top nav render in embed mode, and force hide filter bar 1`] = "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], @@ -2207,6 +2215,10 @@ exports[`Dashboard top nav render in embed mode, components can be forced show b "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], @@ -3207,6 +3219,10 @@ exports[`Dashboard top nav render in full screen mode with appended URL param bu "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], @@ -4207,6 +4223,10 @@ exports[`Dashboard top nav render in full screen mode, no componenets should be "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], @@ -5207,6 +5227,10 @@ exports[`Dashboard top nav render with all components 1`] = ` "registerLeft": [MockFunction], "registerRight": [MockFunction], }, + "navGroup": Object { + "getNavGroupEnabled": [MockFunction], + "getNavGroupsMap$": [MockFunction], + }, "navLinks": Object { "enableForcedAppSwitcherNavigation": [MockFunction], "get": [MockFunction], diff --git a/src/plugins/home/server/ui_settings.ts b/src/plugins/home/server/ui_settings.ts index eb94aeb39227..7df590ba5480 100644 --- a/src/plugins/home/server/ui_settings.ts +++ b/src/plugins/home/server/ui_settings.ts @@ -25,5 +25,6 @@ export const uiSettings: Record = { defaultMessage: 'Try the new home page', }), schema: schema.boolean(), + requiresPageReload: true, }, };