From 61d5c4905bce1709d29629ade132723d87fb6897 Mon Sep 17 00:00:00 2001 From: Zhongnan Su Date: Tue, 30 Jul 2024 19:47:14 -0700 Subject: [PATCH 01/32] [Look&Feel] Use small context menu across the board (#7486) Signed-off-by: Zhongnan Su --- .../__snapshots__/sample_data_view_data_button.test.js.snap | 2 +- .../application/components/sample_data_view_data_button.js | 2 +- .../vis_type_vega/public/components/vega_actions_menu.tsx | 2 +- src/plugins/vis_type_vega/public/components/vega_help_menu.tsx | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/plugins/home/public/application/components/__snapshots__/sample_data_view_data_button.test.js.snap b/src/plugins/home/public/application/components/__snapshots__/sample_data_view_data_button.test.js.snap index 5bcc020fe153..3230c2c5d8ca 100644 --- a/src/plugins/home/public/application/components/__snapshots__/sample_data_view_data_button.test.js.snap +++ b/src/plugins/home/public/application/components/__snapshots__/sample_data_view_data_button.test.js.snap @@ -51,7 +51,7 @@ exports[`should render popover when appLinks is not empty 1`] = ` }, ] } - size="m" + size="s" /> `; diff --git a/src/plugins/home/public/application/components/sample_data_view_data_button.js b/src/plugins/home/public/application/components/sample_data_view_data_button.js index acd78b7fb3b6..b0ae8da375e6 100644 --- a/src/plugins/home/public/application/components/sample_data_view_data_button.js +++ b/src/plugins/home/public/application/components/sample_data_view_data_button.js @@ -127,7 +127,7 @@ export class SampleDataViewDataButton extends React.Component { anchorPosition="downCenter" data-test-subj={`launchSampleDataSet${this.props.id}`} > - + ); } diff --git a/src/plugins/vis_type_vega/public/components/vega_actions_menu.tsx b/src/plugins/vis_type_vega/public/components/vega_actions_menu.tsx index cb0f02c3b476..bbdcd6f4c6ce 100644 --- a/src/plugins/vis_type_vega/public/components/vega_actions_menu.tsx +++ b/src/plugins/vis_type_vega/public/components/vega_actions_menu.tsx @@ -93,7 +93,7 @@ function VegaActionsMenu({ formatHJson, formatJson }: VegaActionsMenuProps) { panelPaddingSize="none" anchorPosition="downLeft" > - + ); } diff --git a/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx b/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx index e504f807f16d..95d37bbe64ac 100644 --- a/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx +++ b/src/plugins/vis_type_vega/public/components/vega_help_menu.tsx @@ -97,7 +97,7 @@ function VegaHelpMenu() { panelPaddingSize="none" anchorPosition="downLeft" > - + ); } From d09895a134d92904eb540778796b63aaa6ee6a2f Mon Sep 17 00:00:00 2001 From: Kawika Avilla Date: Thu, 1 Aug 2024 18:01:30 -0700 Subject: [PATCH 02/32] [dataset-nav][bug] get connections by cluster and update client namespace (#7609) * changes from abby Signed-off-by: Kawika Avilla * refactor api Signed-off-by: abbyhu2000 * polling db not populating Signed-off-by: Kawika Avilla * fixing datasource cache Signed-off-by: Sean Li * fixing session id stuff and running async queries Signed-off-by: Sean Li * Changeset file for PR #7609 created/updated --------- Signed-off-by: Kawika Avilla Signed-off-by: abbyhu2000 Signed-off-by: Sean Li Co-authored-by: abbyhu2000 Co-authored-by: Sean Li Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7609.yml | 2 + src/plugins/data/public/index.ts | 3 - .../dataset_navigator/dataset_navigator.tsx | 59 ++---- .../public/ui/dataset_navigator/index.tsx | 1 - .../lib/catalog_cache/cache_loader.tsx | 45 +++-- .../lib/catalog_cache/cache_manager.ts | 86 +++----- .../ui/dataset_navigator/lib/constants.ts | 190 ++++++++++-------- .../public/ui/dataset_navigator/lib/index.tsx | 1 - .../dataset_navigator/lib/requests/index.tsx | 6 - .../ui/dataset_navigator/lib/requests/sql.ts | 60 ------ .../public/ui/dataset_navigator/lib/types.tsx | 2 +- .../lib/utils/fetch_external_data_sources.ts | 40 ++-- .../ui/dataset_navigator/lib/utils/index.ts | 1 - .../lib/utils/query_session_utils.ts | 25 --- .../ui/dataset_navigator/lib/utils/shared.ts | 7 +- src/plugins/data/public/ui/index.ts | 7 +- .../data/public/ui/settings/settings.ts | 16 ++ .../query_enhancements/common/constants.ts | 2 + .../public/search/sql_search_interceptor.ts | 13 +- .../query_enhancements/server/index.ts | 9 +- .../query_enhancements/server/plugin.ts | 14 +- .../routes/data_source_connection/routes.ts | 78 ++++++- .../query_enhancements/server/routes/index.ts | 3 +- .../query_enhancements/server/search/index.ts | 1 + .../server/search/ppl_raw_search_strategy.ts | 4 +- .../server/search/ppl_search_strategy.ts | 2 +- .../search/sql_async_search_strategy.ts | 15 +- .../server/search/sql_search_strategy.ts | 2 +- .../query_enhancements/server/types.ts | 5 +- .../query_enhancements/server/utils/facet.ts | 16 +- .../query_enhancements/server/utils/index.ts | 2 +- .../server/utils/plugins.ts | 43 ++-- 32 files changed, 374 insertions(+), 386 deletions(-) create mode 100644 changelogs/fragments/7609.yml delete mode 100644 src/plugins/data/public/ui/dataset_navigator/lib/requests/index.tsx delete mode 100644 src/plugins/data/public/ui/dataset_navigator/lib/requests/sql.ts delete mode 100644 src/plugins/data/public/ui/dataset_navigator/lib/utils/query_session_utils.ts diff --git a/changelogs/fragments/7609.yml b/changelogs/fragments/7609.yml new file mode 100644 index 000000000000..edb1d5a60c64 --- /dev/null +++ b/changelogs/fragments/7609.yml @@ -0,0 +1,2 @@ +fix: +- Dataset nav to load external connections and update namespace ([#7609](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7609)) \ No newline at end of file diff --git a/src/plugins/data/public/index.ts b/src/plugins/data/public/index.ts index e8a64a0bcb6a..948bebed11ba 100644 --- a/src/plugins/data/public/index.ts +++ b/src/plugins/data/public/index.ts @@ -446,9 +446,6 @@ export { // for BWC, keeping the old name IUiStart as DataPublicPluginStartUi, DataSetNavigator, - setAsyncSessionId, - getAsyncSessionId, - setAsyncSessionIdByObj, } from './ui'; /** diff --git a/src/plugins/data/public/ui/dataset_navigator/dataset_navigator.tsx b/src/plugins/data/public/ui/dataset_navigator/dataset_navigator.tsx index bfeaad00bd4e..4912abd92614 100644 --- a/src/plugins/data/public/ui/dataset_navigator/dataset_navigator.tsx +++ b/src/plugins/data/public/ui/dataset_navigator/dataset_navigator.tsx @@ -40,13 +40,7 @@ import { getSearchService, getUiService, } from '../../services'; -import { - fetchDataSources, - fetchIndexPatterns, - fetchIndices, - isCatalogCacheFetching, - fetchIfExternalDataSourcesEnabled, -} from './lib'; +import { fetchDataSources, fetchIndexPatterns, fetchIndices, isCatalogCacheFetching } from './lib'; import { useDataSetManager } from '../search_bar/lib/use_dataset_manager'; import { DataSetContract } from '../../query'; @@ -76,7 +70,6 @@ export const DataSetNavigator: React.FC = ({ const isInitialized = useRef(false); const [isOpen, setIsOpen] = useState(false); const [isLoading, setIsLoading] = useState(false); - const [isExternalDataSourcesEnabled, setIsExternalDataSourcesEnabled] = useState(false); const [dataSources, setDataSources] = useState([]); const [externalDataSources, setExternalDataSources] = useState([]); const [indexPatterns, setIndexPatterns] = useState([]); @@ -85,6 +78,7 @@ export const DataSetNavigator: React.FC = ({ const [selectedDataSetState, setSelectedDataSetState] = useState< SelectedDataSetState | undefined >(undefined); + const isExternalDataSourcesEnabled = externalDataSources.length > 0; const { loadStatus: dataSourcesLoadStatus, @@ -104,7 +98,7 @@ export const DataSetNavigator: React.FC = ({ const onRefresh = useCallback(() => { if (!isCatalogCacheFetching(dataSourcesLoadStatus) && dataSources.length > 0) { - startLoadingDataSources(dataSources.map((dataSource) => dataSource.id)); + startLoadingDataSources(dataSources); } }, [dataSourcesLoadStatus, dataSources, startLoadingDataSources]); @@ -158,19 +152,27 @@ export const DataSetNavigator: React.FC = ({ setIsLoading(true); try { - const [ - fetchedIndexPatterns, - fetchedDataSources, - fetchedIsExternalDataSourcesEnabled, - ] = await Promise.all([ + const [fetchedIndexPatterns, fetchedDataSources] = await Promise.all([ fetchIndexPatterns(savedObjectsClient!, ''), fetchDataSources(savedObjectsClient!), - fetchIfExternalDataSourcesEnabled(http!), ]); + const externalDataSourcesCache = CatalogCacheManager.getExternalDataSourcesCache(); + if (externalDataSourcesCache.status === CachedDataSourceStatus.Updated) { + setExternalDataSources( + externalDataSourcesCache.dataSources.map((ds) => ({ + id: ds.dataSourceRef, + name: ds.name, + type: SIMPLE_DATA_SOURCE_TYPES.EXTERNAL, + })) + ); + } else if (fetchedDataSources.length > 0) { + setExternalDataSources(await startLoadingDataSources(fetchedDataSources)); + } + setIndexPatterns(fetchedIndexPatterns); setDataSources(fetchedDataSources); - setIsExternalDataSourcesEnabled(fetchedIsExternalDataSourcesEnabled); + if (dataSet) { setSelectedDataSetState({ id: dataSet.id, @@ -206,7 +208,7 @@ export const DataSetNavigator: React.FC = ({ const externalDataSourcesCache = CatalogCacheManager.getExternalDataSourcesCache(); if (status === DirectQueryLoadingStatus.SUCCESS) { setExternalDataSources( - externalDataSourcesCache.externalDataSources.map((ds) => ({ + externalDataSourcesCache.dataSources.map((ds) => ({ id: ds.dataSourceRef, name: ds.name, type: SIMPLE_DATA_SOURCE_TYPES.EXTERNAL, @@ -462,25 +464,7 @@ export const DataSetNavigator: React.FC = ({ { name: S3DataSourcesLabel, panel: 4, - onClick: () => { - const externalDataSourcesCache = CatalogCacheManager.getExternalDataSourcesCache(); - if ( - (externalDataSourcesCache.status === CachedDataSourceStatus.Empty || - externalDataSourcesCache.status === CachedDataSourceStatus.Failed) && - !isCatalogCacheFetching(dataSourcesLoadStatus) && - dataSources.length > 0 - ) { - startLoadingDataSources(dataSources.map((dataSource) => dataSource.id)); - } else if (externalDataSourcesCache.status === CachedDataSourceStatus.Updated) { - setExternalDataSources( - externalDataSourcesCache.externalDataSources.map((ds) => ({ - id: ds.dataSourceRef, - name: ds.name, - type: SIMPLE_DATA_SOURCE_TYPES.EXTERNAL, - })) - ); - } - }, + onClick: () => {}, }, ] : []), @@ -527,7 +511,7 @@ export const DataSetNavigator: React.FC = ({ ), items: externalDataSources.map((dataSource) => ({ name: dataSource.name, - onClick: () => handleSelectExternalDataSource(dataSource), + onClick: async () => await handleSelectExternalDataSource(dataSource), panel: 5, })), content: isCatalogCacheFetching(dataSourcesLoadStatus) && createLoadingSpinner(), @@ -652,7 +636,6 @@ export const DataSetNavigator: React.FC = ({ databasesLoadStatus, cachedTables, tablesLoadStatus, - startLoadingDataSources, handleSelectedDataSet, handleSelectedDataSource, handleSelectedObject, diff --git a/src/plugins/data/public/ui/dataset_navigator/index.tsx b/src/plugins/data/public/ui/dataset_navigator/index.tsx index 3167afad74d9..e98e52c8421f 100644 --- a/src/plugins/data/public/ui/dataset_navigator/index.tsx +++ b/src/plugins/data/public/ui/dataset_navigator/index.tsx @@ -5,4 +5,3 @@ export { DataSetNavigator, DataSetNavigatorProps } from './dataset_navigator'; export { createDataSetNavigator } from './create_dataset_navigator'; -export { setAsyncSessionId, getAsyncSessionId, setAsyncSessionIdByObj } from './lib'; diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/catalog_cache/cache_loader.tsx b/src/plugins/data/public/ui/dataset_navigator/lib/catalog_cache/cache_loader.tsx index bae33f99a128..f5a6aa8aece0 100644 --- a/src/plugins/data/public/ui/dataset_navigator/lib/catalog_cache/cache_loader.tsx +++ b/src/plugins/data/public/ui/dataset_navigator/lib/catalog_cache/cache_loader.tsx @@ -5,7 +5,7 @@ import { useEffect, useRef, useState } from 'react'; import { HttpStart, NotificationsStart } from 'opensearch-dashboards/public'; -import { ASYNC_POLLING_INTERVAL, SPARK_HIVE_TABLE_REGEX, SPARK_PARTITION_INFO } from '../constants'; +import { ASYNC_QUERY, SPARK } from '../constants'; import { AsyncPollingResult, CachedColumn, @@ -16,12 +16,12 @@ import { DirectQueryLoadingStatus, DirectQueryRequest, } from '../types'; -import { getAsyncSessionId, setAsyncSessionIdByObj } from '../utils/query_session_utils'; +import { SIMPLE_DATA_SOURCE_TYPES, SimpleDataSource } from '../../../../../common'; import { addBackticksIfNeeded, combineSchemaAndDatarows, formatError } from '../utils/shared'; import { usePolling } from '../utils/use_polling'; -import { SQLService } from '../requests/sql'; import { CatalogCacheManager } from './cache_manager'; import { fetchExternalDataSources } from '../utils'; +import { getUiService } from '../../../../services'; export const updateDatabasesToCache = ( dataSourceName: string, @@ -96,10 +96,9 @@ export const updateTablesToCache = ( ); return; } - const combinedData = combineSchemaAndDatarows(pollingResult.schema, pollingResult.datarows); const newTables = combinedData - .filter((row: any) => !SPARK_HIVE_TABLE_REGEX.test(row.information)) + .filter((row: any) => !SPARK.HIVE_TABLE_REGEX.test(row.information)) .map((row: any) => ({ name: row.tableName, })); @@ -184,7 +183,7 @@ export const updateTableColumnsToCache = ( const tableColumns: CachedColumn[] = []; for (const row of combinedData) { - if (row.col_name === SPARK_PARTITION_INFO) { + if (row.col_name === SPARK.PARTITION_INFO) { break; } tableColumns.push({ @@ -282,7 +281,6 @@ export const useLoadToCache = ( http: HttpStart, notifications: NotificationsStart ) => { - const sqlService = new SQLService(http); const [currentDataSourceName, setCurrentDataSourceName] = useState(''); const [currentDatabaseName, setCurrentDatabaseName] = useState(''); const [currentTableName, setCurrentTableName] = useState(''); @@ -298,8 +296,13 @@ export const useLoadToCache = ( startPolling, stopPolling: stopLoading, } = usePolling((params) => { - return sqlService.fetchWithJobId(params, dataSourceMDSClientId.current); - }, ASYNC_POLLING_INTERVAL); + return http.fetch(`../../api/enhancements/datasource/jobs`, { + query: { + id: dataSourceMDSClientId.current, + queryId: params.queryId, + }, + }); + }, ASYNC_QUERY.POLLING_INTERVAL); const onLoadingFailed = () => { setLoadStatus(DirectQueryLoadingStatus.FAILED); @@ -319,6 +322,7 @@ export const useLoadToCache = ( databaseName, tableName, }: StartLoadingParams) => { + const uiService = getUiService(); setLoadStatus(DirectQueryLoadingStatus.SCHEDULED); setCurrentDataSourceName(dataSourceName); setCurrentDatabaseName(databaseName); @@ -331,14 +335,19 @@ export const useLoadToCache = ( datasource: dataSourceName, }; - const sessionId = getAsyncSessionId(dataSourceName); + const sessionId = uiService.Settings.getUserQuerySessionId(dataSourceName); if (sessionId) { requestPayload = { ...requestPayload, sessionId }; } - await sqlService - .fetch(requestPayload, dataSourceMDSId) + await http + .post(`../../api/enhancements/datasource/jobs`, { + body: JSON.stringify(requestPayload), + query: { + id: dataSourceMDSClientId.current, + }, + }) .then((result) => { - setAsyncSessionIdByObj(dataSourceName, result); + uiService.Settings.setUserQuerySessionIdByObj(dataSourceName, result); if (result.queryId) { startPolling({ queryId: result.queryId, @@ -443,7 +452,9 @@ export const useLoadExternalDataSourcesToCache = ( DirectQueryLoadingStatus.INITIAL ); - const loadExternalDataSources = async (connectedClusters: string[]) => { + const loadExternalDataSources = async ( + connectedClusters: SimpleDataSource[] + ): Promise => { setLoadStatus(DirectQueryLoadingStatus.SCHEDULED); CatalogCacheManager.setExternalDataSourcesLoadingStatus(CachedDataSourceStatus.Empty); @@ -452,6 +463,11 @@ export const useLoadExternalDataSourcesToCache = ( CatalogCacheManager.updateExternalDataSources(externalDataSources); setLoadStatus(DirectQueryLoadingStatus.SUCCESS); CatalogCacheManager.setExternalDataSourcesLoadingStatus(CachedDataSourceStatus.Updated); + return externalDataSources.map((dataSource) => ({ + id: dataSource.dataSourceRef, + name: dataSource.name, + type: SIMPLE_DATA_SOURCE_TYPES.EXTERNAL, + })); } catch (error) { setLoadStatus(DirectQueryLoadingStatus.FAILED); CatalogCacheManager.setExternalDataSourcesLoadingStatus(CachedDataSourceStatus.Failed); @@ -459,6 +475,7 @@ export const useLoadExternalDataSourcesToCache = ( title: 'Failed to load external datasources', }); } + return []; }; return { loadStatus, loadExternalDataSources }; diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/catalog_cache/cache_manager.ts b/src/plugins/data/public/ui/dataset_navigator/lib/catalog_cache/cache_manager.ts index 3d0a8e0c982d..5496a8066c06 100644 --- a/src/plugins/data/public/ui/dataset_navigator/lib/catalog_cache/cache_manager.ts +++ b/src/plugins/data/public/ui/dataset_navigator/lib/catalog_cache/cache_manager.ts @@ -3,19 +3,13 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - ASYNC_QUERY_EXTERNAL_DATASOURCES_CACHE, - CATALOG_CACHE_VERSION, - RECENT_DATASET_OPTIONS_CACHE, -} from '../constants'; -import { ASYNC_QUERY_ACCELERATIONS_CACHE, ASYNC_QUERY_DATASOURCE_CACHE } from '../utils/shared'; +import { ASYNC_QUERY, DATASET } from '../constants'; import { AccelerationsCacheData, CachedAccelerationByDataSource, CachedDataSource, CachedDataSourceStatus, CachedDatabase, - DataSetOption, DataSourceCacheData, ExternalDataSource, ExternalDataSourcesCacheData, @@ -27,26 +21,6 @@ import { SimpleDataSet, SimpleObject } from '../../../../../common'; * Manages caching for catalog data including data sources and accelerations. */ export class CatalogCacheManager { - /** - * Key for the data source cache in local storage. - */ - private static readonly datasourceCacheKey = ASYNC_QUERY_DATASOURCE_CACHE; - - /** - * Key for the accelerations cache in local storage. - */ - private static readonly accelerationsCacheKey = ASYNC_QUERY_ACCELERATIONS_CACHE; - - /** - * Key for external datasources cache in local storage - */ - private static readonly externalDataSourcesCacheKey = ASYNC_QUERY_EXTERNAL_DATASOURCES_CACHE; - - /** - * Key for recently selected datasets in local storage - */ - private static readonly recentDataSetCacheKey = RECENT_DATASET_OPTIONS_CACHE; - // TODO: make this an advanced setting private static readonly maxRecentDataSet = 4; @@ -55,7 +29,7 @@ export class CatalogCacheManager { * @param {DataSourceCacheData} cacheData - The data source cache data to save. */ static saveDataSourceCache(cacheData: DataSourceCacheData): void { - sessionStorage.setItem(this.datasourceCacheKey, JSON.stringify(cacheData)); + sessionStorage.setItem(ASYNC_QUERY.CATALOG_CACHE.KEY, JSON.stringify(cacheData)); } /** @@ -63,12 +37,12 @@ export class CatalogCacheManager { * @returns {DataSourceCacheData} The retrieved data source cache. */ static getDataSourceCache(): DataSourceCacheData { - const catalogData = sessionStorage.getItem(this.datasourceCacheKey); + const catalogData = sessionStorage.getItem(ASYNC_QUERY.CATALOG_CACHE.KEY); if (catalogData) { return JSON.parse(catalogData); } else { - const defaultCacheObject = { version: CATALOG_CACHE_VERSION, dataSources: [] }; + const defaultCacheObject = { version: ASYNC_QUERY.CATALOG_CACHE.VERSION, dataSources: [] }; this.saveDataSourceCache(defaultCacheObject); return defaultCacheObject; } @@ -79,7 +53,7 @@ export class CatalogCacheManager { * @param {AccelerationsCacheData} cacheData - The accelerations cache data to save. */ static saveAccelerationsCache(cacheData: AccelerationsCacheData): void { - sessionStorage.setItem(this.accelerationsCacheKey, JSON.stringify(cacheData)); + sessionStorage.setItem(ASYNC_QUERY.ACCELERATIONS_CACHE, JSON.stringify(cacheData)); } /** @@ -87,13 +61,13 @@ export class CatalogCacheManager { * @returns {AccelerationsCacheData} The retrieved accelerations cache. */ static getAccelerationsCache(): AccelerationsCacheData { - const accelerationCacheData = sessionStorage.getItem(this.accelerationsCacheKey); + const accelerationCacheData = sessionStorage.getItem(ASYNC_QUERY.ACCELERATIONS_CACHE); if (accelerationCacheData) { return JSON.parse(accelerationCacheData); } else { const defaultCacheObject = { - version: CATALOG_CACHE_VERSION, + version: ASYNC_QUERY.CATALOG_CACHE.VERSION, dataSources: [], }; this.saveAccelerationsCache(defaultCacheObject); @@ -171,19 +145,11 @@ export class CatalogCacheManager { */ static addOrUpdateDataSource(dataSource: CachedDataSource, dataSourceMDSId?: string): void { const cacheData = this.getDataSourceCache(); - let index; - if (dataSourceMDSId) { - index = cacheData.dataSources.findIndex( - (ds: CachedDataSource) => - ds.name === dataSource.name && ds.dataSourceMDSId === dataSourceMDSId - ); - } - index = cacheData.dataSources.findIndex((ds: CachedDataSource) => ds.name === dataSource.name); - if (index !== -1) { - cacheData.dataSources[index] = dataSource; - } else { - cacheData.dataSources.push(dataSource); - } + const index = cacheData.dataSources.findIndex( + (ds) => + ds.name === dataSource.name && (!dataSourceMDSId || ds.dataSourceMDSId === dataSourceMDSId) + ); + cacheData.dataSources.splice(index, 1, dataSource); this.saveDataSourceCache(cacheData); } @@ -270,11 +236,11 @@ export class CatalogCacheManager { ): SimpleObject { const cachedDatabase = this.getDatabase(dataSourceName, databaseName, dataSourceMDSId); - const cachedTable = cachedDatabase.tables!.find((table) => table.title === tableName); + const cachedTable = cachedDatabase.tables!.find((table) => table.name === tableName); if (!cachedTable) { throw new Error('Table not found exception: ' + tableName); } - return cachedTable; + return { id: cachedTable.name, ...cachedTable }; } /** @@ -316,7 +282,7 @@ export class CatalogCacheManager { * Clears the data source cache from local storage. */ static clearDataSourceCache(): void { - sessionStorage.removeItem(this.datasourceCacheKey); + sessionStorage.removeItem(ASYNC_QUERY.CATALOG_CACHE.KEY); this.clearExternalDataSourcesCache(); } @@ -324,22 +290,22 @@ export class CatalogCacheManager { * Clears the accelerations cache from local storage. */ static clearAccelerationsCache(): void { - sessionStorage.removeItem(this.accelerationsCacheKey); + sessionStorage.removeItem(ASYNC_QUERY.ACCELERATIONS_CACHE); } static saveExternalDataSourcesCache(cacheData: ExternalDataSourcesCacheData): void { - sessionStorage.setItem(this.externalDataSourcesCacheKey, JSON.stringify(cacheData)); + sessionStorage.setItem(ASYNC_QUERY.CATALOG_CACHE.KEY, JSON.stringify(cacheData)); } static getExternalDataSourcesCache(): ExternalDataSourcesCacheData { - const externalDataSourcesData = sessionStorage.getItem(this.externalDataSourcesCacheKey); + const externalDataSourcesData = sessionStorage.getItem(ASYNC_QUERY.CATALOG_CACHE.KEY); if (externalDataSourcesData) { return JSON.parse(externalDataSourcesData); } else { const defaultCacheObject: ExternalDataSourcesCacheData = { - version: CATALOG_CACHE_VERSION, - externalDataSources: [], + version: ASYNC_QUERY.CATALOG_CACHE.VERSION, + dataSources: [], lastUpdated: '', status: CachedDataSourceStatus.Empty, }; @@ -351,7 +317,7 @@ export class CatalogCacheManager { static updateExternalDataSources(externalDataSources: ExternalDataSource[]): void { const currentTime = new Date().toUTCString(); const cacheData = this.getExternalDataSourcesCache(); - cacheData.externalDataSources = externalDataSources; + cacheData.dataSources = externalDataSources; cacheData.lastUpdated = currentTime; cacheData.status = CachedDataSourceStatus.Updated; this.saveExternalDataSourcesCache(cacheData); @@ -362,7 +328,7 @@ export class CatalogCacheManager { } static clearExternalDataSourcesCache(): void { - sessionStorage.removeItem(this.externalDataSourcesCacheKey); + sessionStorage.removeItem(ASYNC_QUERY.CATALOG_CACHE.KEY); } static setExternalDataSourcesLoadingStatus(status: CachedDataSourceStatus): void { @@ -372,17 +338,17 @@ export class CatalogCacheManager { } static saveRecentDataSetsCache(cacheData: RecentDataSetOptionsCacheData): void { - sessionStorage.setItem(this.recentDataSetCacheKey, JSON.stringify(cacheData)); + sessionStorage.setItem(DATASET.OPTIONS_CACHE.KEY, JSON.stringify(cacheData)); } static getRecentDataSetsCache(): RecentDataSetOptionsCacheData { - const recentDataSetOptionsData = sessionStorage.getItem(this.recentDataSetCacheKey); + const recentDataSetOptionsData = sessionStorage.getItem(DATASET.OPTIONS_CACHE.KEY); if (recentDataSetOptionsData) { return JSON.parse(recentDataSetOptionsData); } else { const defaultCacheObject: RecentDataSetOptionsCacheData = { - version: CATALOG_CACHE_VERSION, + version: ASYNC_QUERY.CATALOG_CACHE.VERSION, recentDataSets: [], }; this.saveRecentDataSetsCache(defaultCacheObject); @@ -411,6 +377,6 @@ export class CatalogCacheManager { } static clearRecentDataSetsCache(): void { - sessionStorage.removeItem(this.recentDataSetCacheKey); + sessionStorage.removeItem(DATASET.OPTIONS_CACHE.KEY); } } diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/constants.ts b/src/plugins/data/public/ui/dataset_navigator/lib/constants.ts index e22da95ff4c6..eb970b791b3c 100644 --- a/src/plugins/data/public/ui/dataset_navigator/lib/constants.ts +++ b/src/plugins/data/public/ui/dataset_navigator/lib/constants.ts @@ -3,99 +3,115 @@ * SPDX-License-Identifier: Apache-2.0 */ -export const ASYNC_QUERY_SESSION_ID = 'async-query-session-id'; -export const ASYNC_QUERY_EXTERNAL_DATASOURCES_CACHE = 'async_query_external_datasources_cache'; -export const RECENT_DATASET_OPTIONS_CACHE = 'recent_dataset_options_cache'; +export const ASYNC_QUERY = { + SEARCH_STRATEGY: 'sqlasyncraw', + SESSION_ID: { + KEY: 'async-query-session-id', + }, + CATALOG_CACHE: { + KEY: 'async-query-catalog-cache', + VERSION: '1.0', + }, + ACCELERATIONS_CACHE: 'async-query-acclerations-cache', + POLLING_INTERVAL: 2000, +}; -export const DATA_SOURCE_NAME_URL_PARAM_KEY = 'datasourceName'; -export const DATA_SOURCE_TYPE_URL_PARAM_KEY = 'datasourceType'; -export const OLLY_QUESTION_URL_PARAM_KEY = 'olly_q'; -export const INDEX_URL_PARAM_KEY = 'indexPattern'; -export const DEFAULT_DATA_SOURCE_TYPE = 'DEFAULT_INDEX_PATTERNS'; -export const DEFAULT_DATA_SOURCE_NAME = 'Default cluster'; -export const DEFAULT_DATA_SOURCE_OBSERVABILITY_DISPLAY_NAME = 'OpenSearch'; -export const DEFAULT_DATA_SOURCE_TYPE_NAME = 'Default Group'; -export const enum QUERY_LANGUAGE { - PPL = 'PPL', - SQL = 'SQL', - DQL = 'DQL', -} -export enum DATA_SOURCE_TYPES { - DEFAULT_CLUSTER_TYPE = DEFAULT_DATA_SOURCE_TYPE, - SPARK = 'spark', - S3Glue = 's3glue', -} -export const ASYNC_POLLING_INTERVAL = 2000; +export const DATASET = { + OPTIONS_CACHE: { + KEY: 'recent_dataset_options_cache', + }, +}; -export const CATALOG_CACHE_VERSION = '1.0'; -export const ACCELERATION_DEFUALT_SKIPPING_INDEX_NAME = 'skipping'; -export const ACCELERATION_TIME_INTERVAL = [ - { text: 'millisecond(s)', value: 'millisecond' }, - { text: 'second(s)', value: 'second' }, - { text: 'minutes(s)', value: 'minute' }, - { text: 'hour(s)', value: 'hour' }, - { text: 'day(s)', value: 'day' }, - { text: 'week(s)', value: 'week' }, -]; -export const ACCELERATION_REFRESH_TIME_INTERVAL = [ - { text: 'minutes(s)', value: 'minute' }, - { text: 'hour(s)', value: 'hour' }, - { text: 'day(s)', value: 'day' }, - { text: 'week(s)', value: 'week' }, -]; +export const DEFAULT_DATA_SOURCE = { + TYPE: 'DEFAULT_INDEX_PATTERNS', + NAME: 'Default cluster', + TITLE: 'Default Group', +}; -export const ACCELERATION_ADD_FIELDS_TEXT = '(add fields here)'; -export const ACCELERATION_INDEX_NAME_REGEX = /^[a-z0-9_]+$/; -export const ACCELERATION_S3_URL_REGEX = /^(s3|s3a):\/\/[a-zA-Z0-9.\-]+/; -export const SPARK_HIVE_TABLE_REGEX = /Provider:\s*hive/; -export const SANITIZE_QUERY_REGEX = /\s+/g; -export const SPARK_TIMESTAMP_DATATYPE = 'timestamp'; -export const SPARK_STRING_DATATYPE = 'string'; +export const ACCELERATION = { + DEFUALT_SKIPPING_INDEX_NAME: 'skipping', + TIME_INTERVAL: [ + { text: 'millisecond(s)', value: 'millisecond' }, + { text: 'second(s)', value: 'second' }, + { text: 'minutes(s)', value: 'minute' }, + { text: 'hour(s)', value: 'hour' }, + { text: 'day(s)', value: 'day' }, + { text: 'week(s)', value: 'week' }, + ], + REFRESH_TIME_INTERVAL: [ + { text: 'minutes(s)', value: 'minute' }, + { text: 'hour(s)', value: 'hour' }, + { text: 'day(s)', value: 'day' }, + { text: 'week(s)', value: 'week' }, + ], + ADD_FIELDS_TEXT: '(add fields here)', + INDEX_NAME_REGEX: /^[a-z0-9_]+$/, + S3_URL_REGEX: /^(s3|s3a):\/\/[a-zA-Z0-9.\-]+/, + INDEX_TYPES: [ + { label: 'Skipping Index', value: 'skipping' }, + { label: 'Covering Index', value: 'covering' }, + { label: 'Materialized View', value: 'materialized' }, + ], + INDEX_NAME_INFO: `All OpenSearch acceleration indices have a naming format of pattern: \`prefix__suffix\`. They share a common prefix structure, which is \`flint____\`. Additionally, they may have a suffix that varies based on the index type. + ##### Skipping Index + - For 'Skipping' indices, a fixed index name 'skipping' is used, and this name cannot be modified by the user. The suffix added to this type is \`_index\`. + - An example of a 'Skipping' index name would be: \`flint_mydatasource_mydb_mytable_skipping_index\`. + ##### Covering Index + - 'Covering' indices allow users to specify their index name. The suffix added to this type is \`_index\`. + - For instance, a 'Covering' index name could be: \`flint_mydatasource_mydb_mytable_myindexname_index\`. + ##### Materialized View Index + - 'Materialized View' indices also enable users to define their index name, but they do not have a suffix. + - An example of a 'Materialized View' index name might look like: \`flint_mydatasource_mydb_mytable_myindexname\`. + ##### Note: + - All user given index names must be in lowercase letters, numbers and underscore. Spaces, commas, and characters -, :, ", *, +, /, \, |, ?, #, >, or < are not allowed. + `, + AGGREGRATION_FUNCTIONS: [ + { label: 'window.start' }, + { label: 'count' }, + { label: 'sum' }, + { label: 'avg' }, + { label: 'max' }, + { label: 'min' }, + ], +}; -export const ACCELERATION_INDEX_TYPES = [ - { label: 'Skipping Index', value: 'skipping' }, - { label: 'Covering Index', value: 'covering' }, - { label: 'Materialized View', value: 'materialized' }, -]; +export const SKIPPING_INDEX = { + ACCELERATION_METHODS: [ + { value: 'PARTITION', text: 'Partition' }, + { value: 'VALUE_SET', text: 'Value Set' }, + { value: 'MIN_MAX', text: 'Min Max' }, + { value: 'BLOOM_FILTER', text: 'Bloom Filter' }, + ], +}; -export const ACC_INDEX_TYPE_DOCUMENTATION_URL = - 'https://github.com/opensearch-project/opensearch-spark/blob/main/docs/index.md'; -export const ACC_CHECKPOINT_DOCUMENTATION_URL = - 'https://github.com/opensearch-project/opensearch-spark/blob/main/docs/index.md#create-index-options'; +export const SPARK = { + HIVE_TABLE_REGEX: /Provider:\s*hive/, + TIMESTAMP_DATATYPE: 'timestamp', + STRING_DATATYPE: 'string', + PARTITION_INFO: `# Partition Information`, +}; -export const ACCELERATION_INDEX_NAME_INFO = `All OpenSearch acceleration indices have a naming format of pattern: \`prefix__suffix\`. They share a common prefix structure, which is \`flint___
_\`. Additionally, they may have a suffix that varies based on the index type. -##### Skipping Index -- For 'Skipping' indices, a fixed index name 'skipping' is used, and this name cannot be modified by the user. The suffix added to this type is \`_index\`. - - An example of a 'Skipping' index name would be: \`flint_mydatasource_mydb_mytable_skipping_index\`. -##### Covering Index -- 'Covering' indices allow users to specify their index name. The suffix added to this type is \`_index\`. - - For instance, a 'Covering' index name could be: \`flint_mydatasource_mydb_mytable_myindexname_index\`. -##### Materialized View Index -- 'Materialized View' indices also enable users to define their index name, but they do not have a suffix. - - An example of a 'Materialized View' index name might look like: \`flint_mydatasource_mydb_mytable_myindexname\`. -##### Note: -- All user given index names must be in lowercase letters, numbers and underscore. Spaces, commas, and characters -, :, ", *, +, /, \, |, ?, #, >, or < are not allowed. - `; +export const REGEX = { + SANITIZE_QUERY: /\s+/g, +}; -export const SKIPPING_INDEX_ACCELERATION_METHODS = [ - { value: 'PARTITION', text: 'Partition' }, - { value: 'VALUE_SET', text: 'Value Set' }, - { value: 'MIN_MAX', text: 'Min Max' }, - { value: 'BLOOM_FILTER', text: 'Bloom Filter' }, -]; +export const DOCUMENTATION = { + ACC_INDEX_TYPE_URL: + 'https://github.com/opensearch-project/opensearch-spark/blob/main/docs/index.md', + ACC_CHECKPOINT_URL: + 'https://github.com/opensearch-project/opensearch-spark/blob/main/docs/index.md#create-index-options', +}; -export const ACCELERATION_AGGREGRATION_FUNCTIONS = [ - { label: 'window.start' }, - { label: 'count' }, - { label: 'sum' }, - { label: 'avg' }, - { label: 'max' }, - { label: 'min' }, -]; +export const OBSERVABILITY = { + DEFAULT_CLUSTER: 'observability-default', + S3_DATA_SOURCE: 'observability-s3', +}; -export const SPARK_PARTITION_INFO = `# Partition Information`; -export const OBS_DEFAULT_CLUSTER = 'observability-default'; // prefix key for generating data source id for default cluster in data selector -export const OBS_S3_DATA_SOURCE = 'observability-s3'; // prefix key for generating data source id for s3 data sources in data selector -export const S3_DATA_SOURCE_GROUP_DISPLAY_NAME = 'Amazon S3'; // display group name for Amazon-managed-s3 data sources in data selector -export const S3_DATA_SOURCE_GROUP_SPARK_DISPLAY_NAME = 'Spark'; // display group name for OpenSearch-spark-s3 data sources in data selector -export const SECURITY_DASHBOARDS_LOGOUT_URL = '/logout'; +export const S3_DATA_SOURCE = { + GROUP_DISPLAY_NAME: 'Amazon S3', + GROUP_SPARK_DISPLAY_NAME: 'Spark', +}; + +export const SECURITY = { + DASHBOARDS_LOGOUT_URL: '/logout', +}; diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/index.tsx b/src/plugins/data/public/ui/dataset_navigator/lib/index.tsx index 98c2ef4e9f92..3e55b949c98d 100644 --- a/src/plugins/data/public/ui/dataset_navigator/lib/index.tsx +++ b/src/plugins/data/public/ui/dataset_navigator/lib/index.tsx @@ -4,5 +4,4 @@ */ export * from './catalog_cache'; -export * from './requests'; export * from './utils'; diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/requests/index.tsx b/src/plugins/data/public/ui/dataset_navigator/lib/requests/index.tsx deleted file mode 100644 index 3918a896bd0b..000000000000 --- a/src/plugins/data/public/ui/dataset_navigator/lib/requests/index.tsx +++ /dev/null @@ -1,6 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -export * from './sql'; diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/requests/sql.ts b/src/plugins/data/public/ui/dataset_navigator/lib/requests/sql.ts deleted file mode 100644 index f2c9c30c79b9..000000000000 --- a/src/plugins/data/public/ui/dataset_navigator/lib/requests/sql.ts +++ /dev/null @@ -1,60 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { HttpStart } from 'opensearch-dashboards/public'; -import { DirectQueryRequest } from '../types'; - -export class SQLService { - private http: HttpStart; - - constructor(http: HttpStart) { - this.http = http; - } - - fetch = async ( - params: DirectQueryRequest, - dataSourceMDSId?: string, - errorHandler?: (error: any) => void - ) => { - const query = { - dataSourceMDSId, - }; - return this.http - .post('/api/observability/query/jobs', { - body: JSON.stringify(params), - query, - }) - .catch((error) => { - // eslint-disable-next-line no-console - console.error('fetch error: ', error.body); - if (errorHandler) errorHandler(error); - throw error; - }); - }; - - fetchWithJobId = async ( - params: { queryId: string }, - dataSourceMDSId?: string, - errorHandler?: (error: any) => void - ) => { - return this.http - .get(`/api/observability/query/jobs/${params.queryId}/${dataSourceMDSId ?? ''}`) - .catch((error) => { - // eslint-disable-next-line no-console - console.error('fetch error: ', error.body); - if (errorHandler) errorHandler(error); - throw error; - }); - }; - - deleteWithJobId = async (params: { queryId: string }, errorHandler?: (error: any) => void) => { - return this.http.delete(`/api/observability/query/jobs/${params.queryId}`).catch((error) => { - // eslint-disable-next-line no-console - console.error('delete error: ', error.body); - if (errorHandler) errorHandler(error); - throw error; - }); - }; -} diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/types.tsx b/src/plugins/data/public/ui/dataset_navigator/lib/types.tsx index 6566b2ebe4a5..62b213933f73 100644 --- a/src/plugins/data/public/ui/dataset_navigator/lib/types.tsx +++ b/src/plugins/data/public/ui/dataset_navigator/lib/types.tsx @@ -311,7 +311,7 @@ export interface ExternalDataSource { export interface ExternalDataSourcesCacheData { version: string; - externalDataSources: ExternalDataSource[]; + dataSources: ExternalDataSource[]; lastUpdated: string; status: CachedDataSourceStatus; } diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/utils/fetch_external_data_sources.ts b/src/plugins/data/public/ui/dataset_navigator/lib/utils/fetch_external_data_sources.ts index f64089b3b1ea..37a7d8bfad49 100644 --- a/src/plugins/data/public/ui/dataset_navigator/lib/utils/fetch_external_data_sources.ts +++ b/src/plugins/data/public/ui/dataset_navigator/lib/utils/fetch_external_data_sources.ts @@ -4,32 +4,48 @@ */ import { HttpStart } from 'opensearch-dashboards/public'; -import { DatasourceDetails } from '../types'; +import { CachedDataSourceStatus, DatasourceDetails, ExternalDataSource } from '../types'; +import { SimpleDataSource } from '../../../../../common'; export const fetchIfExternalDataSourcesEnabled = async (http: HttpStart) => { try { - await http.get('/api/dataconnections'); + await http.get('/api/enhancements/datasource/external'); return true; } catch (e) { return false; } }; -export const fetchExternalDataSources = async (http: HttpStart, connectedClusters: string[]) => { - const results = await Promise.all( - connectedClusters.map(async (cluster) => { - const dataSources = await http.get(`/api/dataconnections/dataSourceMDSId=${cluster}`); - return dataSources +export const fetchExternalDataSources = async ( + http: HttpStart, + connectedClusters: SimpleDataSource[] +): Promise => { + let externalDataSources: ExternalDataSource[] = []; + + for (const cluster of connectedClusters) { + try { + const response = await http.fetch(`../../api/enhancements/datasource/external`, { + query: { + id: cluster.id, + }, + }); + + const clusterDataSources = response .filter((dataSource: DatasourceDetails) => dataSource.connector === 'S3GLUE') .map((dataSource: DatasourceDetails) => ({ name: dataSource.name, - status: dataSource.status, - dataSourceRef: cluster, + // status: dataSource.status, + dataSourceRef: cluster.id, + status: CachedDataSourceStatus.Empty, })); - }) - ); - const flattenedResults = results.flat(); + externalDataSources = externalDataSources.concat(clusterDataSources); + } catch (error) { + // Ignore error and continue with the next cluster + } + } + + const flattenedResults = externalDataSources.flat(); const uniqueResults = Array.from( flattenedResults .reduce((map, ds) => { diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/utils/index.ts b/src/plugins/data/public/ui/dataset_navigator/lib/utils/index.ts index 7dbe7ec2d4f4..79e55007654c 100644 --- a/src/plugins/data/public/ui/dataset_navigator/lib/utils/index.ts +++ b/src/plugins/data/public/ui/dataset_navigator/lib/utils/index.ts @@ -8,6 +8,5 @@ export * from './fetch_data_sources'; export * from './fetch_external_data_sources'; export * from './fetch_index_patterns'; export * from './fetch_indices'; -export * from './query_session_utils'; export * from './shared'; export * from './use_polling'; diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/utils/query_session_utils.ts b/src/plugins/data/public/ui/dataset_navigator/lib/utils/query_session_utils.ts deleted file mode 100644 index fc47c8ebd020..000000000000 --- a/src/plugins/data/public/ui/dataset_navigator/lib/utils/query_session_utils.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* - * Copyright OpenSearch Contributors - * SPDX-License-Identifier: Apache-2.0 - */ - -import { ASYNC_QUERY_SESSION_ID } from '../constants'; - -function get(obj: Record, path: string, defaultValue?: T): T { - return path.split('.').reduce((acc: any, part: string) => acc && acc[part], obj) || defaultValue; -} - -export const setAsyncSessionId = (dataSource: string, sessionId: string | null) => { - if (sessionId !== null) { - sessionStorage.setItem(`${ASYNC_QUERY_SESSION_ID}_${dataSource}`, sessionId); - } -}; - -export const setAsyncSessionIdByObj = (dataSource: string, obj: Record) => { - const sessionId = get(obj, 'sessionId', null); - setAsyncSessionId(dataSource, sessionId); -}; - -export const getAsyncSessionId = (dataSource: string) => { - return sessionStorage.getItem(`${ASYNC_QUERY_SESSION_ID}_${dataSource}`); -}; diff --git a/src/plugins/data/public/ui/dataset_navigator/lib/utils/shared.ts b/src/plugins/data/public/ui/dataset_navigator/lib/utils/shared.ts index 3e4afc94e80b..8077aa851051 100644 --- a/src/plugins/data/public/ui/dataset_navigator/lib/utils/shared.ts +++ b/src/plugins/data/public/ui/dataset_navigator/lib/utils/shared.ts @@ -73,7 +73,7 @@ export const DSL_SETTINGS = '/indices.getFieldSettings'; export const OBSERVABILITY_BASE = '/api/observability'; export const INTEGRATIONS_BASE = '/api/integrations'; export const JOBS_BASE = '/query/jobs'; -export const DATACONNECTIONS_BASE = '/api/dataconnections'; +export const DATACONNECTIONS_BASE = '/api/directquery/dataconnections'; export const EDIT = '/edit'; export const DATACONNECTIONS_UPDATE_STATUS = '/status'; export const SECURITY_ROLES = '/api/v1/configuration/roles'; @@ -88,7 +88,6 @@ export const SECURITY_PLUGIN_ACCOUNT_API = '/api/v1/configuration/account'; export const PPL_ENDPOINT = '/_plugins/_ppl'; export const SQL_ENDPOINT = '/_plugins/_sql'; export const DSL_ENDPOINT = '/_plugins/_dsl'; -export const DATACONNECTIONS_ENDPOINT = '/_plugins/_query/_datasources'; export const JOBS_ENDPOINT_BASE = '/_plugins/_async_query'; export const JOB_RESULT_ENDPOINT = '/result'; @@ -319,10 +318,6 @@ export const VISUALIZATION_ERROR = { export const S3_DATA_SOURCE_TYPE = 's3glue'; -export const ASYNC_QUERY_SESSION_ID = 'async-query-session-id'; -export const ASYNC_QUERY_DATASOURCE_CACHE = 'async-query-catalog-cache'; -export const ASYNC_QUERY_ACCELERATIONS_CACHE = 'async-query-acclerations-cache'; - export const DIRECT_DUMMY_QUERY = 'select 1'; export const DEFAULT_START_TIME = 'now-15m'; diff --git a/src/plugins/data/public/ui/index.ts b/src/plugins/data/public/ui/index.ts index 9259a34fad79..400887e51d57 100644 --- a/src/plugins/data/public/ui/index.ts +++ b/src/plugins/data/public/ui/index.ts @@ -49,9 +49,4 @@ export { } from './query_editor'; export { SearchBar, SearchBarProps, StatefulSearchBarProps } from './search_bar'; export { SuggestionsComponent } from './typeahead'; -export { - DataSetNavigator, - setAsyncSessionId, - getAsyncSessionId, - setAsyncSessionIdByObj, -} from './dataset_navigator'; +export { DataSetNavigator } from './dataset_navigator'; diff --git a/src/plugins/data/public/ui/settings/settings.ts b/src/plugins/data/public/ui/settings/settings.ts index 96c806ad0bc3..007800738952 100644 --- a/src/plugins/data/public/ui/settings/settings.ts +++ b/src/plugins/data/public/ui/settings/settings.ts @@ -137,6 +137,22 @@ export class Settings { } } + setUserQuerySessionId(dataSourceName: string, sessionId: string | null) { + if (sessionId !== null) { + sessionStorage.setItem(`async-query-session-id_${dataSourceName}`, sessionId); + } + } + + setUserQuerySessionIdByObj = (dataSourceName: string, obj: Record) => { + const sessionId = + 'sessionId'.split('.').reduce((acc: any, part: string) => acc && acc[part], obj) || null; + this.setUserQuerySessionId(dataSourceName, sessionId); + }; + + getUserQuerySessionId = (dataSourceName: string) => { + return sessionStorage.getItem(`async-query-session-id_${dataSourceName}`); + }; + toJSON(): DataSettings { return { userQueryLanguage: this.getUserQueryLanguage(), diff --git a/src/plugins/query_enhancements/common/constants.ts b/src/plugins/query_enhancements/common/constants.ts index 57316efdf5d2..8e50d82db789 100644 --- a/src/plugins/query_enhancements/common/constants.ts +++ b/src/plugins/query_enhancements/common/constants.ts @@ -25,6 +25,8 @@ export const API = { GENERATE: `${BASE_API}/assist/generate`, }, DATA_SOURCE: { + EXTERNAL: `${BASE_API}/datasource/external`, + ASYNC_JOBS: `${BASE_API}/datasource/jobs`, CONNECTIONS: `${BASE_API}/datasource/connections`, }, }; diff --git a/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts b/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts index cbd04f02a431..9376d4f3bd7c 100644 --- a/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts +++ b/src/plugins/query_enhancements/public/search/sql_search_interceptor.ts @@ -20,8 +20,6 @@ import { ISearchOptions, SearchInterceptor, SearchInterceptorDeps, - getAsyncSessionId, - setAsyncSessionId, } from '../../../data/public'; import { API, @@ -36,6 +34,7 @@ import { QueryEnhancementsPluginStartDependencies } from '../types'; export class SQLSearchInterceptor extends SearchInterceptor { protected queryService!: DataPublicPluginStart['query']; protected aggsService!: DataPublicPluginStart['search']['aggs']; + protected uiService!: DataPublicPluginStart['ui']; constructor(deps: SearchInterceptorDeps) { super(deps); @@ -43,6 +42,7 @@ export class SQLSearchInterceptor extends SearchInterceptor { deps.startServices.then(([coreStart, depsStart]) => { this.queryService = (depsStart as QueryEnhancementsPluginStartDependencies).data.query; this.aggsService = (depsStart as QueryEnhancementsPluginStartDependencies).data.search.aggs; + this.uiService = (depsStart as QueryEnhancementsPluginStartDependencies).data.ui; }); } @@ -122,7 +122,9 @@ export class SQLSearchInterceptor extends SearchInterceptor { ...dataFrame.meta.queryConfig, ...dataSourceRef, }, - sessionId: dataSourceRef ? getAsyncSessionId(dataSourceRef.dataSourceName!) : {}, + sessionId: dataSourceRef + ? this.uiService.Settings.getUserQuerySessionId(dataSourceRef.dataSourceName!) + : {}, }; const onPollingSuccess = (pollingResult: any) => { @@ -162,7 +164,10 @@ export class SQLSearchInterceptor extends SearchInterceptor { concatMap((jobResponse) => { const df = jobResponse.body; if (dataSourceRef?.dataSourceName && df?.meta?.sessionId) { - setAsyncSessionId(dataSourceRef.dataSourceName, df?.meta?.sessionId); + this.uiService.Settings.setUserQuerySessionId( + dataSourceRef.dataSourceName, + df?.meta?.sessionId + ); } const dataFramePolling = new DataFramePolling( () => fetchDataFramePolling(dfContext, df), diff --git a/src/plugins/query_enhancements/server/index.ts b/src/plugins/query_enhancements/server/index.ts index 4d72ff3eb278..0efc8b66188a 100644 --- a/src/plugins/query_enhancements/server/index.ts +++ b/src/plugins/query_enhancements/server/index.ts @@ -18,12 +18,5 @@ export function plugin(initializerContext: PluginInitializerContext) { return new QueryEnhancementsPlugin(initializerContext); } -export { - Facet, - FacetProps, - OpenSearchPPLPlugin, - OpenSearchObservabilityPlugin, - shimStats, - shimSchemaRow, -} from './utils'; +export { Facet, FacetProps, OpenSearchEnhancements, shimStats, shimSchemaRow } from './utils'; export { QueryEnhancementsPluginSetup, QueryEnhancementsPluginStart } from './types'; diff --git a/src/plugins/query_enhancements/server/plugin.ts b/src/plugins/query_enhancements/server/plugin.ts index 6c5357af70f1..789896961550 100644 --- a/src/plugins/query_enhancements/server/plugin.ts +++ b/src/plugins/query_enhancements/server/plugin.ts @@ -18,6 +18,7 @@ import { ConfigSchema } from '../common/config'; import { defineRoutes } from './routes'; import { pplSearchStrategyProvider, + pplRawSearchStrategyProvider, sqlSearchStrategyProvider, sqlAsyncSearchStrategyProvider, } from './search'; @@ -25,10 +26,8 @@ import { QueryEnhancementsPluginSetup, QueryEnhancementsPluginSetupDependencies, QueryEnhancementsPluginStart, - QueryEnhancementsPluginStartDependencies, } from './types'; -import { OpenSearchObservabilityPlugin, OpenSearchPPLPlugin } from './utils'; -import { pplRawSearchStrategyProvider } from './search/ppl_raw_search_strategy'; +import { OpenSearchEnhancements } from './utils'; export class QueryEnhancementsPlugin implements Plugin { @@ -43,13 +42,12 @@ export class QueryEnhancementsPlugin this.logger.debug('queryEnhancements: Setup'); const router = core.http.createRouter(); // Register server side APIs - const client = core.opensearch.legacy.createClient('opensearch_observability', { - plugins: [OpenSearchPPLPlugin, OpenSearchObservabilityPlugin], + const client = core.opensearch.legacy.createClient('opensearch_enhancements', { + plugins: [OpenSearchEnhancements], }); if (dataSource) { - dataSource.registerCustomApiSchema(OpenSearchPPLPlugin); - dataSource.registerCustomApiSchema(OpenSearchObservabilityPlugin); + dataSource.registerCustomApiSchema(OpenSearchEnhancements); } const pplSearchStrategy = pplSearchStrategyProvider(this.config$, this.logger, client); @@ -84,7 +82,7 @@ export class QueryEnhancementsPlugin dataSourceEnabled: !!dataSource, })); - defineRoutes(this.logger, router, { + defineRoutes(this.logger, router, client, { ppl: pplSearchStrategy, sql: sqlSearchStrategy, sqlasync: sqlAsyncSearchStrategy, diff --git a/src/plugins/query_enhancements/server/routes/data_source_connection/routes.ts b/src/plugins/query_enhancements/server/routes/data_source_connection/routes.ts index 162cc7e8f103..8407c20314bc 100644 --- a/src/plugins/query_enhancements/server/routes/data_source_connection/routes.ts +++ b/src/plugins/query_enhancements/server/routes/data_source_connection/routes.ts @@ -4,10 +4,13 @@ */ import { schema } from '@osd/config-schema'; -import { IRouter } from 'opensearch-dashboards/server'; +import { IRouter, ILegacyClusterClient } from 'opensearch-dashboards/server'; import { API } from '../../../common'; -export function registerDataSourceConnectionsRoutes(router: IRouter) { +export function registerDataSourceConnectionsRoutes( + router: IRouter, + defaultClient: ILegacyClusterClient +) { router.get( { path: API.DATA_SOURCE.CONNECTIONS, @@ -44,4 +47,75 @@ export function registerDataSourceConnectionsRoutes(router: IRouter) { return response.ok({ body: resp }); } ); + + router.get( + { + path: `${API.DATA_SOURCE.EXTERNAL}`, + validate: { + query: schema.object({ + id: schema.string(), + name: schema.nullable(schema.string()), + }), + }, + }, + async (context, request, response) => { + const client = request.query.id + ? context.dataSource.opensearch.legacy.getClient(request.query.id).callAPI + : defaultClient.asScoped(request).callAsCurrentUser; + + const resp = request.query.name + ? await client('enhancements.getDataConnectionById', { + dataconnection: request.query.name, + }) + : await client('enhancements.getDataConnections'); + return response.ok({ body: resp }); + } + ); + + router.get( + { + path: `${API.DATA_SOURCE.ASYNC_JOBS}`, + validate: { + query: schema.object({ + id: schema.string(), + queryId: schema.nullable(schema.string()), + }), + }, + }, + async (context, request, response) => { + const client = request.query.id + ? context.dataSource.opensearch.legacy.getClient(request.query.id).callAPI + : defaultClient.asScoped(request).callAsCurrentUser; + + const resp = await client('enhancements.getJobStatus', { + queryId: request.query.queryId, + }); + return response.ok({ body: resp }); + } + ); + + router.post( + { + path: `${API.DATA_SOURCE.ASYNC_JOBS}`, + validate: { + query: schema.object({ + id: schema.string(), + }), + body: schema.object({ + query: schema.string(), + datasource: schema.string(), + lang: schema.string(), + sessionId: schema.nullable(schema.string()), + }), + }, + }, + async (context, request, response) => { + const client = request.query.id + ? context.dataSource.opensearch.legacy.getClient(request.query.id).callAPI + : defaultClient.asScoped(request).callAsCurrentUser; + + const resp = await client('enhancements.runDirectQuery', { body: request.body }); + return response.ok({ body: resp }); + } + ); } diff --git a/src/plugins/query_enhancements/server/routes/index.ts b/src/plugins/query_enhancements/server/routes/index.ts index 8feaecc7b282..3c23db3c87b9 100644 --- a/src/plugins/query_enhancements/server/routes/index.ts +++ b/src/plugins/query_enhancements/server/routes/index.ts @@ -126,6 +126,7 @@ function defineRoute( export function defineRoutes( logger: Logger, router: IRouter, + client: any, searchStrategies: Record< string, ISearchStrategy @@ -134,6 +135,6 @@ export function defineRoutes( defineRoute(logger, router, searchStrategies, SEARCH_STRATEGY.PPL); defineRoute(logger, router, searchStrategies, SEARCH_STRATEGY.SQL); defineRoute(logger, router, searchStrategies, SEARCH_STRATEGY.SQL_ASYNC); - registerDataSourceConnectionsRoutes(router); + registerDataSourceConnectionsRoutes(router, client); registerQueryAssistRoutes(router); } diff --git a/src/plugins/query_enhancements/server/search/index.ts b/src/plugins/query_enhancements/server/search/index.ts index b3a528c57055..129ce971662f 100644 --- a/src/plugins/query_enhancements/server/search/index.ts +++ b/src/plugins/query_enhancements/server/search/index.ts @@ -4,5 +4,6 @@ */ export { pplSearchStrategyProvider } from './ppl_search_strategy'; +export { pplRawSearchStrategyProvider } from './ppl_raw_search_strategy'; export { sqlSearchStrategyProvider } from './sql_search_strategy'; export { sqlAsyncSearchStrategyProvider } from './sql_async_search_strategy'; diff --git a/src/plugins/query_enhancements/server/search/ppl_raw_search_strategy.ts b/src/plugins/query_enhancements/server/search/ppl_raw_search_strategy.ts index 72508db5d7f6..32643f621874 100644 --- a/src/plugins/query_enhancements/server/search/ppl_raw_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/ppl_raw_search_strategy.ts @@ -21,7 +21,9 @@ export const pplRawSearchStrategyProvider = ( : client.asScoped(request.rawRequest).callAsCurrentUser; try { - const rawResponse: any = await runSearch('ppl.pplQuery', { body: request.params.body }); + const rawResponse: any = await runSearch('enhancements.pplQuery', { + body: request.params.body, + }); const data = shimSchemaRow(rawResponse); rawResponse.jsonData = data.jsonData; diff --git a/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts b/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts index 3d12448d58b0..f0b01cd51da4 100644 --- a/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/ppl_search_strategy.ts @@ -27,7 +27,7 @@ export const pplSearchStrategyProvider = ( const pplFacet = new Facet({ client, logger, - endpoint: 'ppl.pplQuery', + endpoint: 'enhancements.pplQuery', useJobs: false, shimResponse: true, }); diff --git a/src/plugins/query_enhancements/server/search/sql_async_search_strategy.ts b/src/plugins/query_enhancements/server/search/sql_async_search_strategy.ts index 1a76dbf85dd9..3c3802eae261 100644 --- a/src/plugins/query_enhancements/server/search/sql_async_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/sql_async_search_strategy.ts @@ -15,6 +15,7 @@ import { createDataFrame, } from '../../../data/common'; import { Facet } from '../utils'; +import { SEARCH_STRATEGY } from '../../common'; export const sqlAsyncSearchStrategyProvider = ( config$: Observable, @@ -22,11 +23,15 @@ export const sqlAsyncSearchStrategyProvider = ( client: ILegacyClusterClient, usage?: SearchUsage ): ISearchStrategy => { - const sqlAsyncFacet = new Facet({ client, logger, endpoint: 'observability.runDirectQuery' }); + const sqlAsyncFacet = new Facet({ + client, + logger, + endpoint: 'enhancements.runDirectQuery', + }); const sqlAsyncJobsFacet = new Facet({ client, logger, - endpoint: 'observability.getJobStatus', + endpoint: 'enhancements.getJobStatus', useJobs: true, }); @@ -39,7 +44,7 @@ export const sqlAsyncSearchStrategyProvider = ( request.body = { query: request.body.query.qs, datasource: df?.meta?.queryConfig?.dataSourceName, - lang: 'sql', + lang: SEARCH_STRATEGY.SQL, sessionId: df?.meta?.sessionId, }; const rawResponse: any = await sqlAsyncFacet.describeQuery(context, request); @@ -60,6 +65,7 @@ export const sqlAsyncSearchStrategyProvider = ( }; const dataFrame = createDataFrame(partial); dataFrame.meta = { + ...dataFrame.meta, query: request.body.query, queryId, sessionId, @@ -76,7 +82,7 @@ export const sqlAsyncSearchStrategyProvider = ( const asyncResponse: any = await sqlAsyncJobsFacet.describeQuery(context, request); const status = asyncResponse.data.status; const partial: PartialDataFrame = { - name: '', + ...request.body.df, fields: asyncResponse?.data?.schema || [], }; const dataFrame = createDataFrame(partial); @@ -87,6 +93,7 @@ export const sqlAsyncSearchStrategyProvider = ( dataFrame.size = asyncResponse?.data?.datarows?.length || 0; dataFrame.meta = { + ...dataFrame.meta, status, queryId, error: status === 'FAILED' && asyncResponse.data?.error, diff --git a/src/plugins/query_enhancements/server/search/sql_search_strategy.ts b/src/plugins/query_enhancements/server/search/sql_search_strategy.ts index 4566e49b0664..b2f6af9ca144 100644 --- a/src/plugins/query_enhancements/server/search/sql_search_strategy.ts +++ b/src/plugins/query_enhancements/server/search/sql_search_strategy.ts @@ -22,7 +22,7 @@ export const sqlSearchStrategyProvider = ( client: ILegacyClusterClient, usage?: SearchUsage ): ISearchStrategy => { - const sqlFacet = new Facet({ client, logger, endpoint: 'ppl.sqlQuery' }); + const sqlFacet = new Facet({ client, logger, endpoint: 'enhancements.sqlQuery' }); return { search: async (context, request: any, _options) => { diff --git a/src/plugins/query_enhancements/server/types.ts b/src/plugins/query_enhancements/server/types.ts index b6a03b672de9..5e8ab7987de9 100644 --- a/src/plugins/query_enhancements/server/types.ts +++ b/src/plugins/query_enhancements/server/types.ts @@ -5,8 +5,6 @@ import { PluginSetup } from 'src/plugins/data/server'; import { DataSourcePluginSetup } from 'src/plugins/data_source/server'; -import { Logger } from '../../../core/server'; -import { ConfigSchema } from '../common/config'; // eslint-disable-next-line @typescript-eslint/no-empty-interface export interface QueryEnhancementsPluginSetup {} @@ -18,6 +16,9 @@ export interface QueryEnhancementsPluginSetupDependencies { dataSource?: DataSourcePluginSetup; } +// eslint-disable-next-line @typescript-eslint/no-empty-interface +export interface QueryEnhancementsPluginStartDependencies {} + export interface ISchema { name: string; type: string; diff --git a/src/plugins/query_enhancements/server/utils/facet.ts b/src/plugins/query_enhancements/server/utils/facet.ts index a6f23efba2a1..e930ce612d9b 100644 --- a/src/plugins/query_enhancements/server/utils/facet.ts +++ b/src/plugins/query_enhancements/server/utils/facet.ts @@ -36,14 +36,14 @@ export class Facet { endpoint: string ): Promise => { try { - const { format, df, ...query } = request.body; + const { format, df, dataSourceId, ...query } = request.body; const params = { body: { ...query }, ...(format !== 'jdbc' && { format }), }; - const dataSourceId = df?.meta?.queryConfig?.dataSourceId; - const client = dataSourceId - ? context.dataSource.opensearch.legacy.getClient(dataSourceId).callAPI + const clientId = dataSourceId ?? df?.meta?.queryConfig?.dataSourceId; + const client = clientId + ? context.dataSource.opensearch.legacy.getClient(clientId).callAPI : this.defaultClient.asScoped(request).callAsCurrentUser; const queryRes = await client(endpoint, params); return { @@ -66,10 +66,10 @@ export class Facet { ): Promise => { try { const params = request.params; - const { df } = request.body; - const dataSourceId = df?.meta?.queryConfig?.dataSourceId; - const client = dataSourceId - ? context.dataSource.opensearch.legacy.getClient(dataSourceId).callAPI + const { df, dataSourceId } = request.body; + const clientId = dataSourceId ?? df?.meta?.queryConfig?.dataSourceId; + const client = clientId + ? context.dataSource.opensearch.legacy.getClient(clientId).callAPI : this.defaultClient.asScoped(request).callAsCurrentUser; const queryRes = await client(endpoint, params); return { diff --git a/src/plugins/query_enhancements/server/utils/index.ts b/src/plugins/query_enhancements/server/utils/index.ts index 86f216973771..5cb088ed2fa3 100644 --- a/src/plugins/query_enhancements/server/utils/index.ts +++ b/src/plugins/query_enhancements/server/utils/index.ts @@ -4,6 +4,6 @@ */ export { Facet, FacetProps } from './facet'; -export { OpenSearchPPLPlugin, OpenSearchObservabilityPlugin } from './plugins'; +export { OpenSearchEnhancements } from './plugins'; export { shimStats } from './shim_stats'; export { shimSchemaRow } from './shim_schema_row'; diff --git a/src/plugins/query_enhancements/server/utils/plugins.ts b/src/plugins/query_enhancements/server/utils/plugins.ts index e8d581bc42f2..bef3bd52099b 100644 --- a/src/plugins/query_enhancements/server/utils/plugins.ts +++ b/src/plugins/query_enhancements/server/utils/plugins.ts @@ -45,51 +45,46 @@ const createAction = ( }); }; -export const OpenSearchPPLPlugin = (client: any, config: any, components: any) => { - client.prototype.ppl = components.clientAction.namespaceFactory(); - const ppl = client.prototype.ppl.prototype; +export const OpenSearchEnhancements = (client: any, config: any, components: any) => { + client.prototype.enhancements = components.clientAction.namespaceFactory(); + const enhancements = client.prototype.enhancements.prototype; - ppl.pplQuery = createAction(client, components, { + enhancements.pplQuery = createAction(client, components, { endpoint: URI.PPL, method: 'POST', needBody: true, }); - ppl.sqlQuery = createAction(client, components, { + enhancements.sqlQuery = createAction(client, components, { endpoint: URI.SQL, method: 'POST', needBody: true, }); - ppl.getDataConnectionById = createAction(client, components, { + enhancements.getDataConnectionById = createAction(client, components, { endpoint: OPENSEARCH_API.DATA_CONNECTIONS, method: 'GET', paramKey: 'dataconnection', }); - ppl.deleteDataConnection = createAction(client, components, { + enhancements.deleteDataConnection = createAction(client, components, { endpoint: OPENSEARCH_API.DATA_CONNECTIONS, method: 'DELETE', paramKey: 'dataconnection', }); - ppl.createDataSource = createAction(client, components, { + enhancements.createDataSource = createAction(client, components, { endpoint: OPENSEARCH_API.DATA_CONNECTIONS, method: 'POST', needBody: true, }); - ppl.modifyDataConnection = createAction(client, components, { + enhancements.modifyDataConnection = createAction(client, components, { endpoint: OPENSEARCH_API.DATA_CONNECTIONS, method: 'PATCH', needBody: true, }); - ppl.getDataConnections = createAction(client, components, { + enhancements.getDataConnections = createAction(client, components, { endpoint: OPENSEARCH_API.DATA_CONNECTIONS, method: 'GET', }); -}; - -export const OpenSearchObservabilityPlugin = (client: any, config: any, components: any) => { - client.prototype.observability = components.clientAction.namespaceFactory(); - const observability = client.prototype.observability.prototype; - observability.getObject = createAction(client, components, { + enhancements.getObject = createAction(client, components, { endpoint: OPENSEARCH_API.PANELS, method: 'GET', params: { @@ -106,32 +101,32 @@ export const OpenSearchObservabilityPlugin = (client: any, config: any, componen }, }); - observability.getObjectById = createAction(client, components, { + enhancements.getObjectById = createAction(client, components, { endpoint: `${OPENSEARCH_API.PANELS}/<%=objectId%>`, method: 'GET', paramKey: 'objectId', }); - observability.createObject = createAction(client, components, { + enhancements.createObject = createAction(client, components, { endpoint: OPENSEARCH_API.PANELS, method: 'POST', needBody: true, }); - observability.updateObjectById = createAction(client, components, { + enhancements.updateObjectById = createAction(client, components, { endpoint: `${OPENSEARCH_API.PANELS}/<%=objectId%>`, method: 'PUT', paramKey: 'objectId', needBody: true, }); - observability.deleteObjectById = createAction(client, components, { + enhancements.deleteObjectById = createAction(client, components, { endpoint: `${OPENSEARCH_API.PANELS}/<%=objectId%>`, method: 'DELETE', paramKey: 'objectId', }); - observability.deleteObjectByIdList = createAction(client, components, { + enhancements.deleteObjectByIdList = createAction(client, components, { endpoint: OPENSEARCH_API.PANELS, method: 'DELETE', params: { @@ -139,19 +134,19 @@ export const OpenSearchObservabilityPlugin = (client: any, config: any, componen }, }); - observability.getJobStatus = createAction(client, components, { + enhancements.getJobStatus = createAction(client, components, { endpoint: `${URI.ASYNC_QUERY}`, method: 'GET', paramKey: 'queryId', }); - observability.deleteJob = createAction(client, components, { + enhancements.deleteJob = createAction(client, components, { endpoint: `${URI.ASYNC_QUERY}/<%=queryId%>`, method: 'DELETE', paramKey: 'queryId', }); - observability.runDirectQuery = createAction(client, components, { + enhancements.runDirectQuery = createAction(client, components, { endpoint: URI.ASYNC_QUERY, method: 'POST', needBody: true, From 424e241df1cda6e509397fa4c22de18d3718a4e6 Mon Sep 17 00:00:00 2001 From: Tianyu Gao Date: Fri, 2 Aug 2024 11:43:11 +0800 Subject: [PATCH 03/32] [Workspace] add validation for data source in get and bulk_get methods (#7596) * add data source validation in get and bulk get Signed-off-by: tygao * Changeset file for PR #7596 created/updated * update wrapper validate implementation Signed-off-by: tygao * update comments Signed-off-by: tygao * test: add cases when not in workspace Signed-off-by: tygao --------- Signed-off-by: tygao Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7596.yml | 2 + ...space_saved_objects_client_wrapper.test.ts | 102 ++++++++++++++++++ .../workspace_saved_objects_client_wrapper.ts | 36 +++++++ 3 files changed, 140 insertions(+) create mode 100644 changelogs/fragments/7596.yml diff --git a/changelogs/fragments/7596.yml b/changelogs/fragments/7596.yml new file mode 100644 index 000000000000..273a8b5c1e23 --- /dev/null +++ b/changelogs/fragments/7596.yml @@ -0,0 +1,2 @@ +fix: +- Add validation for data source in get and bulk_get methods ([#7596](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7596)) \ No newline at end of file diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts index 2190e0fcec27..282d6f9cc84f 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts @@ -58,6 +58,12 @@ const generateWorkspaceSavedObjectsClientWrapper = (role = NO_DASHBOARD_ADMIN) = attributes: { title: 'Workspace 1 data source' }, workspaces: ['workspace-1'], }, + { + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + id: 'workspace-2-data-source', + attributes: { title: 'Workspace 2 data source' }, + workspaces: ['mock-request-workspace-id'], + }, ]; const clientMock = { get: jest.fn().mockImplementation(async (type, id) => { @@ -103,6 +109,7 @@ const generateWorkspaceSavedObjectsClientWrapper = (role = NO_DASHBOARD_ADMIN) = deleteByWorkspace: jest.fn(), }; const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + updateWorkspaceState(requestMock, { requestWorkspaceId: 'mock-request-workspace-id' }); if (role === DASHBOARD_ADMIN) updateWorkspaceState(requestMock, { isDashboardAdmin: true }); const wrapperOptions = { client: clientMock, @@ -557,6 +564,41 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { expect(clientMock.get).toHaveBeenCalledWith(...getArgs); expect(result).toMatchInlineSnapshot(`[Error: Not Found]`); }); + + it('should validate data source workspace field', async () => { + const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.get('data-source', 'workspace-1-data-source'); + } catch (e) { + errorCatched = e; + } + expect(errorCatched?.message).toEqual( + 'Invalid data source permission, please associate it to current workspace' + ); + + const result = await wrapper.get('data-source', 'workspace-2-data-source'); + expect(result).toEqual({ + attributes: { + title: 'Workspace 2 data source', + }, + id: 'workspace-2-data-source', + type: 'data-source', + workspaces: ['mock-request-workspace-id'], + }); + }); + + it('should not validate data source when not in workspace', async () => { + const { wrapper, requestMock } = generateWorkspaceSavedObjectsClientWrapper(); + updateWorkspaceState(requestMock, { requestWorkspaceId: undefined }); + const result = await wrapper.get('data-source', 'workspace-1-data-source'); + expect(result).toEqual({ + type: DATA_SOURCE_SAVED_OBJECT_TYPE, + id: 'workspace-1-data-source', + attributes: { title: 'Workspace 1 data source' }, + workspaces: ['workspace-1'], + }); + }); }); describe('bulk get', () => { it("should call permission validate with object's workspace and throw permission error", async () => { @@ -622,6 +664,65 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { {} ); }); + it('should validate data source workspace field', async () => { + const { wrapper } = generateWorkspaceSavedObjectsClientWrapper(); + let errorCatched; + try { + await wrapper.bulkGet([ + { + type: 'data-source', + id: 'workspace-1-data-source', + }, + ]); + } catch (e) { + errorCatched = e; + } + expect(errorCatched?.message).toEqual( + 'Invalid data source permission, please associate it to current workspace' + ); + + const result = await await wrapper.bulkGet([ + { + type: 'data-source', + id: 'workspace-2-data-source', + }, + ]); + expect(result).toEqual({ + saved_objects: [ + { + attributes: { + title: 'Workspace 2 data source', + }, + id: 'workspace-2-data-source', + type: 'data-source', + workspaces: ['mock-request-workspace-id'], + }, + ], + }); + }); + + it('should not validate data source when not in workspace', async () => { + const { wrapper, requestMock } = generateWorkspaceSavedObjectsClientWrapper(); + updateWorkspaceState(requestMock, { requestWorkspaceId: undefined }); + const result = await wrapper.bulkGet([ + { + type: 'data-source', + id: 'workspace-1-data-source', + }, + ]); + expect(result).toEqual({ + saved_objects: [ + { + attributes: { + title: 'Workspace 1 data source', + }, + id: 'workspace-1-data-source', + type: 'data-source', + workspaces: ['workspace-1'], + }, + ], + }); + }); }); describe('find', () => { it('should call client.find with consistent params when ACLSearchParams and workspaceOperator not provided', async () => { @@ -710,6 +811,7 @@ describe('WorkspaceSavedObjectsClientWrapper', () => { } = generateWorkspaceSavedObjectsClientWrapper(DASHBOARD_ADMIN); expect(getWorkspaceState(requestMock)).toEqual({ isDashboardAdmin: true, + requestWorkspaceId: 'mock-request-workspace-id', }); it('should bypass permission check for call client.delete', async () => { const deleteArgs = ['dashboard', 'not-permitted-dashboard'] as const; diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index 9a156aa6501f..c34e26eb0911 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -33,6 +33,7 @@ import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID, WorkspacePermissionMode, } from '../../common/constants'; +import { DATA_SOURCE_SAVED_OBJECT_TYPE } from '../../../data_source/common'; // Can't throw unauthorized for now, the page will be refreshed if unauthorized const generateWorkspacePermissionError = () => @@ -53,6 +54,15 @@ const generateSavedObjectsPermissionError = () => ) ); +const generateDataSourcePermissionError = () => + SavedObjectsErrorHelpers.decorateForbiddenError( + new Error( + i18n.translate('saved_objects.data_source.invalidate', { + defaultMessage: 'Invalid data source permission, please associate it to current workspace', + }) + ) + ); + const generateOSDAdminPermissionError = () => SavedObjectsErrorHelpers.decorateForbiddenError( new Error( @@ -179,6 +189,15 @@ export class WorkspaceSavedObjectsClientWrapper { return hasPermission; } + // Data source is a workspace level object, validate if the request has access to the data source within the requested workspace. + private validateDataSourcePermissions = ( + object: SavedObject, + request: OpenSearchDashboardsRequest + ) => { + const requestWorkspaceId = getWorkspaceState(request).requestWorkspaceId; + return !requestWorkspaceId || !!object.workspaces?.includes(requestWorkspaceId); + }; + private getWorkspaceTypeEnabledClient(request: OpenSearchDashboardsRequest) { return this.getScopedClient?.(request, { includedHiddenTypes: [WORKSPACE_TYPE], @@ -378,6 +397,16 @@ export class WorkspaceSavedObjectsClientWrapper { ): Promise> => { const objectToGet = await wrapperOptions.client.get(type, id, options); + if (objectToGet.type === DATA_SOURCE_SAVED_OBJECT_TYPE) { + const hasPermission = this.validateDataSourcePermissions( + objectToGet, + wrapperOptions.request + ); + if (!hasPermission) { + throw generateDataSourcePermissionError(); + } + } + if ( !(await this.validateWorkspacesAndSavedObjectsPermissions( objectToGet, @@ -399,6 +428,13 @@ export class WorkspaceSavedObjectsClientWrapper { const objectToBulkGet = await wrapperOptions.client.bulkGet(objects, options); for (const object of objectToBulkGet.saved_objects) { + if (object.type === DATA_SOURCE_SAVED_OBJECT_TYPE) { + const hasPermission = this.validateDataSourcePermissions(object, wrapperOptions.request); + if (!hasPermission) { + throw generateDataSourcePermissionError(); + } + } + if ( !(await this.validateWorkspacesAndSavedObjectsPermissions( object, From 14c71c7ceefbf936ef1c5a5be93486636da80e97 Mon Sep 17 00:00:00 2001 From: Qxisylolo Date: Fri, 2 Aug 2024 11:45:20 +0800 Subject: [PATCH 04/32] [Workspace] update workspace-list-card and home-list-card (#7547) * update workspace-list-card and home-list-card Signed-off-by: Yang * new update home_list_card and workspace_list_card,solve some errors Signed-off-by: Qxisylolo * new update home_list_card and workspace_list_card,solve some errors Signed-off-by: Qxisylolo * Changeset file for PR #7547 created/updated * new update home_list_card and workspace_list_card,solve some errors Signed-off-by: Qxisylolo * new update home_list_card and workspace_list_card,solve some errors, and write new testes Signed-off-by: Qxisylolo * new update home_list_card and workspace_list_card,solve some errors, and write new testes Signed-off-by: Qxisylolo --------- Signed-off-by: Yang Signed-off-by: Qxisylolo Co-authored-by: Yang Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7547.yml | 2 + .../home_list_card.test.tsx.snap | 88 +++++--- .../components/home_list_card.test.tsx | 35 +++ .../application/components/home_list_card.tsx | 76 ++++--- .../workspace_list_card.test.tsx.snap | 181 ++++++++------- .../service_card/workspace_list_card.test.tsx | 21 ++ .../service_card/workspace_list_card.tsx | 207 ++++++++++-------- 7 files changed, 366 insertions(+), 244 deletions(-) create mode 100644 changelogs/fragments/7547.yml diff --git a/changelogs/fragments/7547.yml b/changelogs/fragments/7547.yml new file mode 100644 index 000000000000..8921d0ba4d14 --- /dev/null +++ b/changelogs/fragments/7547.yml @@ -0,0 +1,2 @@ +fix: +- [Workspace] updating workspace-list-card and home-list-card ([#7547](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7547)) \ No newline at end of file diff --git a/src/plugins/home/public/application/components/__snapshots__/home_list_card.test.tsx.snap b/src/plugins/home/public/application/components/__snapshots__/home_list_card.test.tsx.snap index 892d6a8dc225..8ff4e1d85325 100644 --- a/src/plugins/home/public/application/components/__snapshots__/home_list_card.test.tsx.snap +++ b/src/plugins/home/public/application/components/__snapshots__/home_list_card.test.tsx.snap @@ -6,45 +6,65 @@ exports[` should render static content normally 1`] = `
-

- What's New -

+
+
- Get started in minutes with OpenSearch Dashboards - - +
+
+ + Quickstart guide + + + (opens in a new tab or window) + + +
+
+ Get started in minutes with OpenSearch Dashboards +
+
+
+
+
+
diff --git a/src/plugins/home/public/application/components/home_list_card.test.tsx b/src/plugins/home/public/application/components/home_list_card.test.tsx index 3e0a646e7279..8558c62727a4 100644 --- a/src/plugins/home/public/application/components/home_list_card.test.tsx +++ b/src/plugins/home/public/application/components/home_list_card.test.tsx @@ -25,3 +25,38 @@ describe('', () => { expect(baseElement).toMatchSnapshot(); }); }); + +it('should render View All button when allLink is provided', () => { + const mockConfig = { + title: `What's New`, + list: [ + { + label: 'Quickstart guide', + href: 'https://opensearch.org/docs/latest/dashboards/quickstart/', + target: '_blank', + description: 'Get started in minutes with OpenSearch Dashboards', + }, + ], + allLink: 'https://opensearch.org/docs/latest/', + }; + + const { getByText } = render(); + expect(getByText('View all')).toBeInTheDocument(); +}); + +it('should not show View All button when allLink is not provided', () => { + const mockConfig = { + title: `What's New`, + list: [ + { + label: 'Quickstart guide', + href: 'https://opensearch.org/docs/latest/dashboards/quickstart/', + target: '_blank', + description: 'Get started in minutes with OpenSearch Dashboards', + }, + ], + }; + + const { queryByText } = render(); + expect(queryByText('View all')).not.toBeInTheDocument(); +}); diff --git a/src/plugins/home/public/application/components/home_list_card.tsx b/src/plugins/home/public/application/components/home_list_card.tsx index c905ca3272cc..707c22e98ff0 100644 --- a/src/plugins/home/public/application/components/home_list_card.tsx +++ b/src/plugins/home/public/application/components/home_list_card.tsx @@ -9,10 +9,12 @@ import { EuiText, EuiLink, EuiTitle, - EuiPanel, EuiDescriptionListTitle, EuiDescriptionListDescription, + EuiPanel, EuiSpacer, + EuiFlexGroup, + EuiFlexItem, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; @@ -67,35 +69,49 @@ export const HomeListCard = ({ config }: { config: Config }) => { return ( <> - -

{config.title}

-
- - {config.list.length > 0 && ( - - {config.list.map((item) => ( - <> - - - {item.label} - - - {item.description} - - ))} - - )} - - {config.allLink ? ( - <> - - - - View all - - - - ) : null} + + + +

{config.title}

+
+
+ + + {config.list.length > 0 && ( + + {config.list.map((item) => ( + <> + + + {item.label} + + + + {item.description} + + + ))} + + )} + + + + {config.allLink ? ( + + + {i18n.translate('home.list.card.view_all', { + defaultMessage: 'View all', + })} + + + ) : null} + +
); diff --git a/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap b/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap index 35970676eb7e..0607b29799a9 100644 --- a/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap +++ b/src/plugins/workspace/public/components/service_card/__snapshots__/workspace_list_card.test.tsx.snap @@ -6,116 +6,131 @@ exports[`workspace list card render normally should show workspace list card cor class="euiPanel euiPanel--paddingSmall euiPanel--borderRadiusMedium euiPanel--plain euiPanel--noShadow euiPanel--noBorder" >
-

- Workspaces -

-
-
- - - -
-
- -
+
+
+ + +
+
+
+
- + +
+ + +
+
-
-
-
    +
    -
    -

    - No Workspaces found -

    - +
    -
    - Workspaces you have recently viewed will appear here. -
    - + No Workspaces found +

    + +
    +
    + Workspaces you have recently viewed will appear here. +
    + +
    -
-
- +
`; diff --git a/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx b/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx index 24d45d42e725..10efd2b6b591 100644 --- a/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx +++ b/src/plugins/workspace/public/components/service_card/workspace_list_card.test.tsx @@ -63,4 +63,25 @@ describe('workspace list card render normally', () => { expect(getByText('foo')).toBeInTheDocument(); expect(getByText('bar')).toBeInTheDocument(); }); + + it('should render create workspace button when is dashboard admin and navigate to create new workspace page when clicking on plus button', () => { + coreStart.application.capabilities = { + ...coreStart.application.capabilities, + dashboards: { + isDashboardAdmin: true, + }, + }; + + const { getByTestId } = render(); + const mockButton = getByTestId('create_workspace'); + fireEvent.click(mockButton); + expect(coreStart.application.navigateToApp).toHaveBeenCalledWith('workspace_create'); + }); + + it('should navigate to workspace list page when click on View all button', () => { + const { getByText } = render(); + const mockButton = getByText('View all'); + fireEvent.click(mockButton); + expect(coreStart.application.navigateToApp).toHaveBeenCalledWith('workspace_list'); + }); }); diff --git a/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx b/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx index 12b14325ce11..009f6519ce8f 100644 --- a/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx +++ b/src/plugins/workspace/public/components/service_card/workspace_list_card.tsx @@ -5,17 +5,16 @@ import React, { useState, useEffect, useMemo } from 'react'; import { - EuiPanel, + EuiFlexGroup, EuiLink, EuiDescriptionList, EuiIcon, - EuiFlexGroup, EuiFlexItem, EuiSelect, EuiButtonIcon, - EuiSpacer, - EuiListGroup, EuiText, + EuiSpacer, + EuiPanel, EuiTitle, EuiToolTip, EuiEmptyPrompt, @@ -89,104 +88,118 @@ export const WorkspaceListCard = (props: WorkspaceListCardProps) => { return ( - - - -

Workspaces

-
+ + + + + +

Workspaces

+
+
+ + + + + + + { + setFilter(e.target.value); + }} + options={[ + { + value: 'viewed', + text: i18n.translate('workspace.list.card.filter.viewed', { + defaultMessage: 'Recently viewed', + }), + }, + { + value: 'updated', + text: i18n.translate('workspace.list.card.filter.updated', { + defaultMessage: 'Recently updated', + }), + }, + ]} + /> + + {isDashboardAdmin && ( + + + { + application.navigateToApp(WORKSPACE_CREATE_APP_ID); + }} + /> + + + )} +
- - - - + + + {workspaceList && workspaceList.length === 0 ? ( + No Workspaces found

} + body={i18n.translate('workspace.list.card.empty', { + values: { + filter, + }, + defaultMessage: 'Workspaces you have recently {filter} will appear here.', + })} + /> + ) : ( + ({ + title: ( + { + handleSwitchWorkspace(workspace.id); + }} + > + {workspace.name} + + ), + description: ( + + {moment(workspace.time).fromNow()} + + ), + }))} + /> + )}
- - { - setFilter(e.target.value); + + + { + application.navigateToApp(WORKSPACE_LIST_APP_ID); }} - options={[ - { - value: 'viewed', - text: i18n.translate('workspace.list.card.filter.viewed', { - defaultMessage: 'Recently viewed', - }), - }, - { - value: 'updated', - text: i18n.translate('workspace.list.card.filter.updated', { - defaultMessage: 'Recently updated', - }), - }, - ]} - /> + > + + {i18n.translate('workspace.list.card.view_all', { + defaultMessage: 'View all', + })} + + - {isDashboardAdmin && ( - - - { - application.navigateToApp(WORKSPACE_CREATE_APP_ID); - }} - /> - - - )}
- - - - {workspaceList && workspaceList.length === 0 ? ( - No Workspaces found

} - body={i18n.translate('workspace.list.card.empty', { - values: { - filter, - }, - defaultMessage: 'Workspaces you have recently {filter} will appear here.', - })} - /> - ) : ( - ({ - title: ( - { - handleSwitchWorkspace(workspace.id); - }} - > - {workspace.name} - - ), - description: ( - - {moment(workspace.time).fromNow()} - - ), - }))} - /> - )} -
- - { - application.navigateToApp(WORKSPACE_LIST_APP_ID); - }} - > - View all -
); }; From 352d2e9edf3e3603464a35ba9275a780cf73ebdd Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 2 Aug 2024 23:28:49 +0800 Subject: [PATCH 05/32] [navigation]fix: add sample data to left navigation (#7613) * feat: add sample data to left navigation Signed-off-by: SuZhou-Joe * Changeset file for PR #7613 created/updated * feat: add sample data to left navigation 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> --- changelogs/fragments/7613.yml | 2 ++ src/plugins/home/public/plugin.ts | 26 ++++++++++++++++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) create mode 100644 changelogs/fragments/7613.yml diff --git a/changelogs/fragments/7613.yml b/changelogs/fragments/7613.yml new file mode 100644 index 000000000000..ff6c8f6c521a --- /dev/null +++ b/changelogs/fragments/7613.yml @@ -0,0 +1,2 @@ +fix: +- [navigation] add sample data to left navigation ([#7613](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7613)) \ No newline at end of file diff --git a/src/plugins/home/public/plugin.ts b/src/plugins/home/public/plugin.ts index fe1099a8e635..0e555f9a3011 100644 --- a/src/plugins/home/public/plugin.ts +++ b/src/plugins/home/public/plugin.ts @@ -64,7 +64,7 @@ import { PLUGIN_ID, HOME_APP_BASE_PATH, IMPORT_SAMPLE_DATA_APP_ID } from '../com import { DataSourcePluginStart } from '../../data_source/public'; import { workWithDataSection } from './application/components/homepage/sections/work_with_data'; import { learnBasicsSection } from './application/components/homepage/sections/learn_basics'; -import { DEFAULT_NAV_GROUPS } from '../../../core/public'; +import { DEFAULT_NAV_GROUPS, DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { ContentManagementPluginSetup, ContentManagementPluginStart, @@ -179,7 +179,9 @@ export class HomePublicPlugin title: i18n.translate('home.tutorialDirectory.featureCatalogueTitle', { defaultMessage: 'Add sample data', }), - navLinkStatus: AppNavLinkStatus.hidden, + navLinkStatus: core.chrome.navGroup.getNavGroupEnabled() + ? AppNavLinkStatus.visible + : AppNavLinkStatus.hidden, mount: async (params: AppMountParameters) => { const [coreStart] = await core.getStartServices(); setCommonService(); @@ -194,6 +196,26 @@ export class HomePublicPlugin }); urlForwarding.forwardApp('home', 'home'); + const configurationInfoForImportSampleData = { + id: IMPORT_SAMPLE_DATA_APP_ID, + title: i18n.translate('home.nav.sampleData.label', { + defaultMessage: 'Sample data', + }), + order: 400, + category: DEFAULT_APP_CATEGORIES.manage, + }; + + // Register sample data to all of the use cases in 2.16 + [ + DEFAULT_NAV_GROUPS.all, + DEFAULT_NAV_GROUPS.analytics, + DEFAULT_NAV_GROUPS['security-analytics'], + DEFAULT_NAV_GROUPS.observability, + DEFAULT_NAV_GROUPS.search, + ].forEach((navGroup) => + core.chrome.navGroup.addNavLinksToGroup(navGroup, [configurationInfoForImportSampleData]) + ); + const featureCatalogue = { ...this.featuresCatalogueRegistry.setup() }; featureCatalogue.register({ From 43dc2d6de8a7e0f3d8d47382a1ecd26eac6a38bb Mon Sep 17 00:00:00 2001 From: Dan Dong <58446449+danieldong51@users.noreply.github.com> Date: Mon, 5 Aug 2024 13:11:37 -0700 Subject: [PATCH 06/32] [Look&Feel] Update paragraph text sizes across remaining OSD (#7603) * Updated Data Source Connection paragraph size Signed-off-by: Dan Dong * Updated paragraph size Signed-off-by: Dan Dong * Updated paragraph size Signed-off-by: Dan Dong * Updated paragraph size Signed-off-by: Dan Dong * Changeset file for PR #7603 created/updated * Removed paragraph tags Signed-off-by: Dan Dong * Updated Snapshot Testing Signed-off-by: Dan Dong * Updated snapshot testing Signed-off-by: Dan Dong * Updated help panel Signed-off-by: Dan Dong * Updated Snapshot Testing for get_no_items_message Signed-off-by: Dan Dong * Updated Snapshot Testing for get_no_items_message Signed-off-by: Dan Dong * Changeset file for PR #7603 created/updated * Changeset file for PR #7603 created/updated * Updated Testing for get_no_items_message Signed-off-by: Dan Dong --------- Signed-off-by: Dan Dong Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7603.yml | 2 + .../application/components/help_panel.tsx | 160 ++-- .../console_history/console_history.tsx | 5 +- .../get_no_items_message.test.tsx.snap | 758 ++++++++++++++++++ .../utils/get_no_items_message.test.tsx | 36 + .../utils/get_no_items_message.tsx | 58 +- .../create_form/create_data_source_form.tsx | 2 +- .../components/header/header.tsx | 2 +- ...ate_data_source_panel_header.test.tsx.snap | 4 +- .../create_data_source_panel_header.tsx | 2 +- .../data_source_page_header.test.tsx.snap | 4 +- .../data_source_page_header.tsx | 2 +- .../data_source_table.test.tsx.snap | 12 +- .../data_source_table/data_source_table.tsx | 2 +- .../acceleration_table.test.tsx.snap | 2 +- .../acceleration_table.tsx | 2 +- .../associated_objects_tab.tsx | 2 +- .../access_control_tab.test.tsx.snap | 2 +- .../connection_detail/access_control_tab.tsx | 2 +- .../no_access_page.test.tsx.snap | 4 +- .../utils/no_access_page.tsx | 2 +- .../components/header/header.tsx | 35 +- .../edit_index_pattern/edit_index_pattern.tsx | 2 +- .../header/__snapshots__/header.test.tsx.snap | 2 +- .../components/header/header.tsx | 2 +- .../header/__snapshots__/header.test.tsx.snap | 4 +- .../components/header/header.tsx | 2 +- .../__snapshots__/field_editor.test.tsx.snap | 130 +-- .../components/field_editor/field_editor.tsx | 16 +- .../index_pattern_table.tsx | 2 +- 30 files changed, 1056 insertions(+), 204 deletions(-) create mode 100644 changelogs/fragments/7603.yml create mode 100644 src/plugins/dashboard/public/application/utils/__snapshots__/get_no_items_message.test.tsx.snap create mode 100644 src/plugins/dashboard/public/application/utils/get_no_items_message.test.tsx diff --git a/changelogs/fragments/7603.yml b/changelogs/fragments/7603.yml new file mode 100644 index 000000000000..6ca9213c4fcd --- /dev/null +++ b/changelogs/fragments/7603.yml @@ -0,0 +1,2 @@ +Refactor: +- [Look&Feel] Update paragraph text sizes across remaining OSD ([#7603](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7603)) \ No newline at end of file diff --git a/src/plugins/console/public/application/components/help_panel.tsx b/src/plugins/console/public/application/components/help_panel.tsx index 1f0fce03bf14..7031132dfe3b 100644 --- a/src/plugins/console/public/application/components/help_panel.tsx +++ b/src/plugins/console/public/application/components/help_panel.tsx @@ -62,12 +62,14 @@ export function HelpPanel(props: Props) { id="console.helpPage.requestFormatTitle" /> -

- -

+ +

+ +

+

-
-
Ctrl/Cmd + I
-
- -
-
Ctrl/Cmd + /
-
- -
-
Ctrl + Space
-
- -
-
Ctrl/Cmd + Enter
-
- -
-
Ctrl/Cmd + Up/Down
-
- -
-
Ctrl/Cmd + Alt + L
-
- -
-
Ctrl/Cmd + Option + 0
-
- -
-
Down arrow
-
- -
-
Enter/Tab
-
- -
-
Esc
-
- -
-
+ +
+
Ctrl/Cmd + I
+
+ +
+
Ctrl/Cmd + /
+
+ +
+
Ctrl + Space
+
+ +
+
Ctrl/Cmd + Enter
+
+ +
+
Ctrl/Cmd + Up/Down
+
+ +
+
Ctrl/Cmd + Alt + L
+
+ +
+
Ctrl/Cmd + Option + 0
+
+ +
+
Down arrow
+
+ +
+
Enter/Tab
+
+ +
+
Esc
+
+ +
+
+
diff --git a/src/plugins/console/public/application/containers/console_history/console_history.tsx b/src/plugins/console/public/application/containers/console_history/console_history.tsx index 925224c53783..b9b6ddf0bf23 100644 --- a/src/plugins/console/public/application/containers/console_history/console_history.tsx +++ b/src/plugins/console/public/application/containers/console_history/console_history.tsx @@ -41,6 +41,7 @@ import { EuiFlexGroup, EuiSmallButtonEmpty, EuiSmallButton, + EuiText, } from '@elastic/eui'; import { useServicesContext } from '../../contexts'; @@ -192,7 +193,9 @@ export function ConsoleHistory({ close }: Props) { })} aria-selected={isSelected} > - {reqDescription} + + {reqDescription} + diff --git a/src/plugins/dashboard/public/application/utils/__snapshots__/get_no_items_message.test.tsx.snap b/src/plugins/dashboard/public/application/utils/__snapshots__/get_no_items_message.test.tsx.snap new file mode 100644 index 000000000000..3907c106ebf6 --- /dev/null +++ b/src/plugins/dashboard/public/application/utils/__snapshots__/get_no_items_message.test.tsx.snap @@ -0,0 +1,758 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`dashboard listing table with no item and no write controls 1`] = ` + +

+ +

+ + } +> +
+ + + + +
+ + + +
+

+ + Looks like you don't have any dashboards. + +

+
+
+
+
+ +`; + +exports[`dashboard listing table with no item and with write controls 1`] = ` + + + + } + body={ + + +

+ +

+

+ + + , + } + } + /> +

+
+
+ } + iconType="dashboardApp" + intl={ + Object { + "defaultFormats": Object {}, + "defaultLocale": "en", + "formatDate": [Function], + "formatHTMLMessage": [Function], + "formatMessage": [Function], + "formatNumber": [Function], + "formatPlural": [Function], + "formatRelative": [Function], + "formatTime": [Function], + "formats": Object { + "date": Object { + "full": Object { + "day": "numeric", + "month": "long", + "weekday": "long", + "year": "numeric", + }, + "long": Object { + "day": "numeric", + "month": "long", + "year": "numeric", + }, + "medium": Object { + "day": "numeric", + "month": "short", + "year": "numeric", + }, + "short": Object { + "day": "numeric", + "month": "numeric", + "year": "2-digit", + }, + }, + "number": Object { + "currency": Object { + "style": "currency", + }, + "percent": Object { + "style": "percent", + }, + }, + "relative": Object { + "days": Object { + "units": "day", + }, + "hours": Object { + "units": "hour", + }, + "minutes": Object { + "units": "minute", + }, + "months": Object { + "units": "month", + }, + "seconds": Object { + "units": "second", + }, + "years": Object { + "units": "year", + }, + }, + "time": Object { + "full": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "long": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + "timeZoneName": "short", + }, + "medium": Object { + "hour": "numeric", + "minute": "numeric", + "second": "numeric", + }, + "short": Object { + "hour": "numeric", + "minute": "numeric", + }, + }, + }, + "formatters": Object { + "getDateTimeFormat": [Function], + "getMessageFormat": [Function], + "getNumberFormat": [Function], + "getPluralFormat": [Function], + "getRelativeFormat": [Function], + }, + "locale": "en", + "messages": Object {}, + "now": [Function], + "onError": [Function], + "textComponent": Symbol(react.fragment), + "timeZone": null, + } + } + title={ + +

+ +

+
+ } +> +
+ + + + +
+ + + +
+

+ + Create your first dashboard + +

+
+
+
+ + + +
+ + +
+ +
+

+ + You can combine data views from any OpenSearch Dashboards app into one dashboard and see everything in one place. + +

+

+ + + , + } + } + > + New to OpenSearch Dashboards? + + + + to take a test drive. + +

+
+
+
+
+ + + +
+ + + + + + + + +
+ +`; diff --git a/src/plugins/dashboard/public/application/utils/get_no_items_message.test.tsx b/src/plugins/dashboard/public/application/utils/get_no_items_message.test.tsx new file mode 100644 index 000000000000..785e1d334b80 --- /dev/null +++ b/src/plugins/dashboard/public/application/utils/get_no_items_message.test.tsx @@ -0,0 +1,36 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { getNoItemsMessage } from './get_no_items_message'; +import { mountWithIntl } from 'test_utils/enzyme_helpers'; +import { ApplicationStart, Capabilities, PublicAppInfo } from 'opensearch-dashboards/public'; +import { EuiLink } from '@elastic/eui'; +import { RecursiveReadonly } from '@osd/utility-types'; +import { Observable } from 'rxjs'; + +describe('dashboard listing table with no item', () => { + test('and no write controls', () => { + const component = mountWithIntl(getNoItemsMessage(true, jest.fn(), {} as ApplicationStart)); + + expect(component).toMatchSnapshot(); + }); + + test('and with write controls', () => { + const application = { + capabilities: {} as RecursiveReadonly, + applications$: {} as Observable>, + navigateToApp: jest.fn(), + navigateToUrl: jest.fn(), + getUrlForApp: jest.fn(), + registerMountContext: jest.fn(), + currentAppId$: {} as Observable, + }; + const component = mountWithIntl(getNoItemsMessage(false, jest.fn(), application)); + + expect(component).toMatchSnapshot(); + component.find(EuiLink).simulate('click'); + expect(application.navigateToApp).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/plugins/dashboard/public/application/utils/get_no_items_message.tsx b/src/plugins/dashboard/public/application/utils/get_no_items_message.tsx index 460fbe9b877d..15f97ceb6130 100644 --- a/src/plugins/dashboard/public/application/utils/get_no_items_message.tsx +++ b/src/plugins/dashboard/public/application/utils/get_no_items_message.tsx @@ -46,34 +46,36 @@ export const getNoItemsMessage = ( } body={ -

- -

-

- - application.navigateToApp('home', { - path: '#/tutorial_directory/sampleData', - }) - } - > - - - ), - }} - /> -

+ +

+ +

+

+ + application.navigateToApp('home', { + path: '#/tutorial_directory/sampleData', + }) + } + > + + + ), + }} + /> +

+
} actions={ diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx index e8f738fc9ffa..7fec74bdc0f9 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/create_form/create_data_source_form.tsx @@ -643,7 +643,7 @@ export class CreateDataSourceForm extends React.Component< - + { - +

- +

{ - +

- +

= () => { - +

- +

- +
{ style={{ textAlign: 'center' }} data-test-subj="datasourceTableEmptyState" > - +

Accelerations

Accelerations optimize query performance by indexing external data into OpenSearch.
Last updated at:
3/14/2024, 4:05:53 AM

Actions
Name
Status
Refreshing
Type
materialized
Database
default
Table
-
Refresh Type
Auto refresh
Destination Index
flint_mys3_default_http_count_view
Name
Status
Refreshing
Type
materialized
Database
default
Table
-
Refresh Type
Auto refresh
Destination Index
flint_mys3_default_http_count_view_alt
Name
Status
Deleted
Type
materialized
Database
default
Table
-
Refresh Type
Auto refresh
Destination Index
flint_mys3_default_http_logs
Name
Status
Active
Type
skipping
Database
default
Table
http_logs
Refresh Type
Manual
Destination Index
-
Name
Status
Refreshing
Type
materialized
Database
other
Table
-
Refresh Type
Auto refresh
Destination Index
flint_mys3_other_http_count_view
"`; +exports[`AccelerationTable Component matches snapshot 1`] = `"

Accelerations

Accelerations optimize query performance by indexing external data into OpenSearch.
Last updated at:
3/14/2024, 4:05:53 AM

Actions
Name
Status
Refreshing
Type
materialized
Database
default
Table
-
Refresh Type
Auto refresh
Destination Index
flint_mys3_default_http_count_view
Name
Status
Refreshing
Type
materialized
Database
default
Table
-
Refresh Type
Auto refresh
Destination Index
flint_mys3_default_http_count_view_alt
Name
Status
Deleted
Type
materialized
Database
default
Table
-
Refresh Type
Auto refresh
Destination Index
flint_mys3_default_http_logs
Name
Status
Active
Type
skipping
Database
default
Table
http_logs
Refresh Type
Manual
Destination Index
-
Name
Status
Refreshing
Type
materialized
Database
other
Table
-
Refresh Type
Auto refresh
Destination Index
flint_mys3_other_http_count_view
"`; diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_management/acceleration_table.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_management/acceleration_table.tsx index bdb6fd50a492..a92313d74a07 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_management/acceleration_table.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_management/acceleration_table.tsx @@ -158,7 +158,7 @@ export const AccelerationTable = ({ <> - +

{ACC_PANEL_TITLE}

{ACC_PANEL_DESC}
diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/associated_object_management/associated_objects_tab.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/associated_object_management/associated_objects_tab.tsx index 648488f50500..69a72226391c 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/associated_object_management/associated_objects_tab.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/associated_object_management/associated_objects_tab.tsx @@ -141,7 +141,7 @@ export const AssociatedObjectsTab: React.FC = (props) return ( - +

{panelTitle}

{panelDescription}
diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/__snapshots__/access_control_tab.test.tsx.snap b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/__snapshots__/access_control_tab.test.tsx.snap index 4771e034f4ad..311cf24611ed 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/__snapshots__/access_control_tab.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/__snapshots__/access_control_tab.test.tsx.snap @@ -46,7 +46,7 @@ exports[`AccessControlTab Component matches snapshot 1`] = ` class="euiFlexItem" >

{ return ( - +

Access control

Control which OpenSearch users have access to this data source.
diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/utils/__snapshots__/no_access_page.test.tsx.snap b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/utils/__snapshots__/no_access_page.test.tsx.snap index 6ffcb27f7e0b..bbbcb2da1b74 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/utils/__snapshots__/no_access_page.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/utils/__snapshots__/no_access_page.test.tsx.snap @@ -13,7 +13,9 @@ exports[`NoAccess renders correctly 1`] = ` } body={ - + You are missing permissions to view connection details. Contact your administrator for permissions. } diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/utils/no_access_page.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/utils/no_access_page.tsx index 1766f7fe1837..9b8ff411c22b 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/utils/no_access_page.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/utils/no_access_page.tsx @@ -13,7 +13,7 @@ export const NoAccess = () => { iconType="alert" title={

{'No permissions to access'}

} body={ - + { 'You are missing permissions to view connection details. Contact your administrator for permissions.' } diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx index 8e016a80299e..a6cadae0ade3 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx @@ -15,6 +15,7 @@ import { EuiConfirmModal, EuiSmallButton, EuiSmallButtonEmpty, + EuiText, } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -121,22 +122,24 @@ export const Header = ({ defaultFocusedButton="confirm" data-test-subj="editDatasourceDeleteConfirmModal" > -

- { - - } -

-

- { - - } -

+ +

+ { + + } +

+

+ { + + } +

+
) : null} diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx index 8cfcadbcc08d..76689ab7c553 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/edit_index_pattern.tsx @@ -222,7 +222,7 @@ export const EditIndexPattern = withRouter(
)} - +

You can use scripted fields in visualizations and display them in your documents. However, you cannot search scripted fields. diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.tsx index 64de651fd4d8..9b5a059626cd 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/scripted_fields_table/components/header/header.tsx @@ -53,7 +53,7 @@ export const Header = withRouter(({ indexPatternId, history }: HeaderProps) => ( />

- +

- +

( /> - +

- +
- - - + + + +
@@ -382,7 +388,9 @@ exports[`FieldEditor should render edit scripted field correctly 1`] = ` /> - +
- - - + + + +
@@ -661,7 +673,9 @@ exports[`FieldEditor should show conflict field warning 1`] = ` /> - +
- - - + + + +
@@ -960,7 +978,9 @@ exports[`FieldEditor should show deprecated lang warning 1`] = ` /> - +
- - - + + + +
@@ -1295,7 +1319,9 @@ exports[`FieldEditor should show multiple type field warning with a table contai /> - +
- - - + + + +
diff --git a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx index 33eef159d1d8..c47de51f9a4e 100644 --- a/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx +++ b/src/plugins/index_pattern_management/public/components/field_editor/field_editor.tsx @@ -603,7 +603,7 @@ export class FieldEditor extends PureComponent - +
- - - + + + + +
diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx index f4e0437824cd..e9ea9d1d9c12 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -269,7 +269,7 @@ export const IndexPatternTable = ({ canSave, history }: Props) => {

{title}

- +

Date: Tue, 6 Aug 2024 09:31:13 -0700 Subject: [PATCH 07/32] [Look&Feel] apply small list pattern guidance to remaning OSD experiences (#7621) Signed-off-by: Zhongnan Su --- .../public/multi_task_todo/multi_task_todo_component.tsx | 1 + src/core/public/chrome/ui/header/recent_items.tsx | 1 + 2 files changed, 2 insertions(+) diff --git a/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_component.tsx b/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_component.tsx index dd81d612183a..46872c1f8e4f 100644 --- a/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_component.tsx +++ b/examples/embeddable_examples/public/multi_task_todo/multi_task_todo_component.tsx @@ -72,6 +72,7 @@ function renderTasks(tasks: MultiTaskTodoInput['tasks'], search?: string) { key={task} data-test-subj="multiTaskTodoTask" label={wrapSearchTerms(task, search)} + size="s" /> )); } diff --git a/src/core/public/chrome/ui/header/recent_items.tsx b/src/core/public/chrome/ui/header/recent_items.tsx index 34d5104e32d7..eef9b86fcebe 100644 --- a/src/core/public/chrome/ui/header/recent_items.tsx +++ b/src/core/public/chrome/ui/header/recent_items.tsx @@ -100,6 +100,7 @@ export const RecentItems = ({ } color="text" + size="s" /> ))} From aa807e55f94e44aa87faab28fcf38b227299d46f Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 7 Aug 2024 07:43:08 +0800 Subject: [PATCH 08/32] fix: get start section cards not aligned (#7624) * fix: get start section cards not aligned The column size was hard coded to 4 previously, not changed so that it can be configured, this addressed the issue when there are more than 4 cards, cards will be displayed in multiple rows Signed-off-by: Yulong Ruan * fix ts type Signed-off-by: Yulong Ruan * tweaks types Signed-off-by: Yulong Ruan * add a todo item Signed-off-by: Yulong Ruan * fix license header Signed-off-by: Yulong Ruan * Changeset file for PR #7624 created/updated --------- Signed-off-by: Yulong Ruan Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7624.yml | 2 ++ .../components/card_container/card_container.tsx | 10 ++-------- .../card_container/card_embeddable.tsx | 8 ++++---- .../components/card_container/card_list.tsx | 13 ++++++++----- .../public/components/card_container/types.ts | 16 ++++++++++++++++ .../public/components/section_input.ts | 3 ++- .../public/services/content_management/types.ts | 1 + 7 files changed, 35 insertions(+), 18 deletions(-) create mode 100644 changelogs/fragments/7624.yml create mode 100644 src/plugins/content_management/public/components/card_container/types.ts diff --git a/changelogs/fragments/7624.yml b/changelogs/fragments/7624.yml new file mode 100644 index 000000000000..cc72302bd5ca --- /dev/null +++ b/changelogs/fragments/7624.yml @@ -0,0 +1,2 @@ +fix: +- [contentManagement] display cards by specifying a column size or display all cards in one row ([#7624](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7624)) \ No newline at end of file diff --git a/src/plugins/content_management/public/components/card_container/card_container.tsx b/src/plugins/content_management/public/components/card_container/card_container.tsx index f3784f1f5fc4..34aeb14c9b99 100644 --- a/src/plugins/content_management/public/components/card_container/card_container.tsx +++ b/src/plugins/content_management/public/components/card_container/card_container.tsx @@ -5,18 +5,12 @@ import React from 'react'; import ReactDOM from 'react-dom'; -import { Container, ContainerInput, EmbeddableStart } from '../../../../embeddable/public'; +import { Container, EmbeddableStart } from '../../../../embeddable/public'; import { CardList } from './card_list'; +import { CardContainerInput } from './types'; export const CARD_CONTAINER = 'CARD_CONTAINER'; -export type CardContainerInput = ContainerInput<{ - description: string; - onClick?: () => void; - getIcon?: () => React.ReactElement; - getFooter?: () => React.ReactElement; -}>; - export class CardContainer extends Container<{}, CardContainerInput> { public readonly type = CARD_CONTAINER; private node?: HTMLElement; diff --git a/src/plugins/content_management/public/components/card_container/card_embeddable.tsx b/src/plugins/content_management/public/components/card_container/card_embeddable.tsx index 0e7b6b2c82e5..588ce1681957 100644 --- a/src/plugins/content_management/public/components/card_container/card_embeddable.tsx +++ b/src/plugins/content_management/public/components/card_container/card_embeddable.tsx @@ -13,8 +13,8 @@ export const CARD_EMBEDDABLE = 'card_embeddable'; export type CardEmbeddableInput = EmbeddableInput & { description: string; onClick?: () => void; - getIcon: () => React.ReactElement; - getFooter: () => React.ReactElement; + getIcon?: () => React.ReactElement; + getFooter?: () => React.ReactElement; }; export class CardEmbeddable extends Embeddable { @@ -37,8 +37,8 @@ export class CardEmbeddable extends Embeddable { description={this.input.description} display="plain" onClick={this.input.onClick} - icon={this.input?.getIcon()} - footer={this.input?.getFooter()} + icon={this.input?.getIcon?.()} + footer={this.input?.getFooter?.()} />, node ); diff --git a/src/plugins/content_management/public/components/card_container/card_list.tsx b/src/plugins/content_management/public/components/card_container/card_list.tsx index b9619d39e0d1..871c6451a8cb 100644 --- a/src/plugins/content_management/public/components/card_container/card_list.tsx +++ b/src/plugins/content_management/public/components/card_container/card_list.tsx @@ -4,7 +4,7 @@ */ import React from 'react'; -import { EuiFlexGrid, EuiFlexItem } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem } from '@elastic/eui'; import { IContainer, @@ -13,10 +13,11 @@ import { ContainerOutput, EmbeddableStart, } from '../../../../embeddable/public'; +import { CardContainerInput } from './types'; interface Props { embeddable: IContainer; - input: ContainerInput; + input: CardContainerInput; embeddableServices: EmbeddableStart; } @@ -29,10 +30,12 @@ const CardListInner = ({ embeddable, input, embeddableServices }: Props) => { ); }); + + // TODO: we should perhaps display the cards in multiple rows when the actual number of cards exceed the column size return ( - - {cards} - + + {input.columns ? cards.slice(0, input.columns) : cards} + ); }; diff --git a/src/plugins/content_management/public/components/card_container/types.ts b/src/plugins/content_management/public/components/card_container/types.ts new file mode 100644 index 000000000000..0358dfecfc11 --- /dev/null +++ b/src/plugins/content_management/public/components/card_container/types.ts @@ -0,0 +1,16 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ContainerInput } from '../../../../embeddable/public'; + +export interface CardExplicitInput { + title: string; + description: string; + onClick?: () => void; + getIcon?: () => React.ReactElement; + getFooter?: () => React.ReactElement; +} + +export type CardContainerInput = ContainerInput & { columns?: number }; diff --git a/src/plugins/content_management/public/components/section_input.ts b/src/plugins/content_management/public/components/section_input.ts index 00bb5b0683c7..c6a68724716f 100644 --- a/src/plugins/content_management/public/components/section_input.ts +++ b/src/plugins/content_management/public/components/section_input.ts @@ -10,8 +10,8 @@ import { Content, Section } from '../services'; import { ViewMode } from '../../../embeddable/public'; import { DashboardContainerInput, SavedObjectDashboard } from '../../../dashboard/public'; import { CUSTOM_CONTENT_EMBEDDABLE } from './custom_content_embeddable'; -import { CardContainerInput } from './card_container/card_container'; import { CARD_EMBEDDABLE } from './card_container/card_embeddable'; +import { CardContainerInput } from './card_container/types'; const DASHBOARD_GRID_COLUMN_COUNT = 48; @@ -30,6 +30,7 @@ export const createCardInput = ( title: section.title ?? '', hidePanelTitles: true, viewMode: ViewMode.VIEW, + columns: section.columns, panels, }; diff --git a/src/plugins/content_management/public/services/content_management/types.ts b/src/plugins/content_management/public/services/content_management/types.ts index 55da19f26b87..7f49732fc2c5 100644 --- a/src/plugins/content_management/public/services/content_management/types.ts +++ b/src/plugins/content_management/public/services/content_management/types.ts @@ -31,6 +31,7 @@ export type Section = id: string; order: number; title?: string; + columns?: number; }; export type Content = From a65a8aa5b13c5ffbca46b5685b5799efb2dd4af9 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 7 Aug 2024 11:34:28 +0800 Subject: [PATCH 09/32] fix bug: management section id not match the key defined in capabilities (#6421) * fix management section id not match the key defined in capabilities Signed-off-by: Yulong Ruan * add descriptions on how the PR solved the problme Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan --- src/plugins/management/common/contants.ts | 14 ++++++++++ .../management_sections_service.test.ts | 28 +++++++++++++++++++ .../public/management_sections_service.ts | 21 ++++++++++++-- .../server/capabilities_provider.ts | 17 ++--------- 4 files changed, 64 insertions(+), 16 deletions(-) diff --git a/src/plugins/management/common/contants.ts b/src/plugins/management/common/contants.ts index 47e418fc620f..037df0ad7c52 100644 --- a/src/plugins/management/common/contants.ts +++ b/src/plugins/management/common/contants.ts @@ -29,3 +29,17 @@ */ export const MANAGEMENT_APP_ID = 'management'; +export const DEFAULT_MANAGEMENT_CAPABILITIES = { + management: { + /* + * Management settings correspond to management section/link ids, and should not be changed + * without also updating those definitions. + */ + opensearchDashboards: { + settings: true, + indexPatterns: true, + objects: true, + dataSources: true, + }, + }, +}; diff --git a/src/plugins/management/public/management_sections_service.test.ts b/src/plugins/management/public/management_sections_service.test.ts index 8f969bce8996..e0358fac99a3 100644 --- a/src/plugins/management/public/management_sections_service.test.ts +++ b/src/plugins/management/public/management_sections_service.test.ts @@ -28,6 +28,7 @@ * under the License. */ +import { DEFAULT_MANAGEMENT_CAPABILITIES } from '../common/contants'; import { ManagementSectionsService, getSectionsServiceStartPrivate, @@ -105,4 +106,31 @@ describe('ManagementService', () => { ] `); }); + + it('should disable apps register in predefined opensearchDashboards section', () => { + // The management capabilities has `opensearchDashboards` as the key + const originalDataSourcesCapability = + DEFAULT_MANAGEMENT_CAPABILITIES.management.opensearchDashboards.dataSources; + + const setup = managementService.setup(); + + // The predefined opensearchDashboards section has id `opensearch-dashboards` which + // doesn't match the capability id `opensearchDashboards` + setup.section.opensearchDashboards.registerApp({ + id: 'dataSources', + title: 'Data source', + mount: jest.fn(), + }); + + // Now set dataSources to capability to false should disable + // the dataSources app registered in opensearchDashboards section + DEFAULT_MANAGEMENT_CAPABILITIES.management.opensearchDashboards.dataSources = false; + + managementService.start({ capabilities: DEFAULT_MANAGEMENT_CAPABILITIES }); + expect( + setup.section.opensearchDashboards.apps.find((app) => app.id === 'dataSources')?.enabled + ).toBe(false); + + DEFAULT_MANAGEMENT_CAPABILITIES.management.opensearchDashboards.dataSources = originalDataSourcesCapability; + }); }); diff --git a/src/plugins/management/public/management_sections_service.ts b/src/plugins/management/public/management_sections_service.ts index 81b8c7ac24af..4a65388d22e9 100644 --- a/src/plugins/management/public/management_sections_service.ts +++ b/src/plugins/management/public/management_sections_service.ts @@ -51,6 +51,22 @@ const [getSectionsServiceStartPrivate, setSectionsServiceStartPrivate] = createG ManagementSectionsStartPrivate >('SectionsServiceStartPrivate'); +/** + * The management capabilities has `opensearchDashboards` as the key + * While when registering the opensearchDashboards section, the section id is `opensearch-dashboards` + * as defined in {@link ManagementSectionId.OpenSearchDashboards} and section id is used as the capability + * id. Here we have a mapping so that the section id opensearch-dashboards can mapping correctly back to the + * capability id: opensearchDashboards + * + * Why not directly change the capability id to opensearch-dashboards? + * The issue was introduced in https://github.com/opensearch-project/OpenSearch-Dashboards/pull/260 + * Since then, the capability id `opensearchDashboards` has been used by plugins, having a mapping here + * is for backward compatibility + */ +const MANAGEMENT_ID_TO_CAPABILITIES: Record = { + 'opensearch-dashboards': 'opensearchDashboards', +}; + export { getSectionsServiceStartPrivate }; export class ManagementSectionsService { @@ -94,8 +110,9 @@ export class ManagementSectionsService { start({ capabilities }: SectionsServiceStartDeps) { this.getAllSections().forEach((section) => { - if (capabilities.management.hasOwnProperty(section.id)) { - const sectionCapabilities = capabilities.management[section.id]; + const capabilityId = MANAGEMENT_ID_TO_CAPABILITIES[section.id] || section.id; + if (capabilities.management.hasOwnProperty(capabilityId)) { + const sectionCapabilities = capabilities.management[capabilityId]; section.apps.forEach((app) => { if (sectionCapabilities.hasOwnProperty(app.id) && sectionCapabilities[app.id] !== true) { app.disable(); diff --git a/src/plugins/management/server/capabilities_provider.ts b/src/plugins/management/server/capabilities_provider.ts index 2786378c9828..9d67c752f52d 100644 --- a/src/plugins/management/server/capabilities_provider.ts +++ b/src/plugins/management/server/capabilities_provider.ts @@ -28,17 +28,6 @@ * under the License. */ -export const capabilitiesProvider = () => ({ - management: { - /* - * Management settings correspond to management section/link ids, and should not be changed - * without also updating those definitions. - */ - opensearchDashboards: { - settings: true, - indexPatterns: true, - objects: true, - dataSources: true, - }, - }, -}); +import { DEFAULT_MANAGEMENT_CAPABILITIES } from '../common/contants'; + +export const capabilitiesProvider = () => DEFAULT_MANAGEMENT_CAPABILITIES; From ef47b5f552245fa0b174fa83e3b6298c89b3a356 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Wed, 7 Aug 2024 14:19:50 +0800 Subject: [PATCH 10/32] [Workspace]Optimize workspace permission validation for bulk operations (#7516) * Optimize workspace permission validation for bulk operations Signed-off-by: Lin Wang * Changeset file for PR #7516 created/updated * Remove isSavedObjectsCacheActive in permission control client Signed-off-by: Lin Wang * Update src/plugins/workspace/server/permission_control/client.ts Co-authored-by: SuZhou-Joe Signed-off-by: Lin Wang * Rename cacheSavedObjects to addToCacheAllowlist Signed-off-by: Lin Wang * Use request uuid as cache Signed-off-by: Lin Wang --------- Signed-off-by: Lin Wang Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> Co-authored-by: SuZhou-Joe --- changelogs/fragments/7516.yml | 2 + .../server/permission_control/client.test.ts | 141 +++++++++++++++++- .../server/permission_control/client.ts | 66 +++++++- src/plugins/workspace/server/plugin.test.ts | 17 +++ src/plugins/workspace/server/plugin.ts | 5 + ...space_saved_objects_client_wrapper.test.ts | 1 + .../workspace_saved_objects_client_wrapper.ts | 33 +++- 7 files changed, 255 insertions(+), 10 deletions(-) create mode 100644 changelogs/fragments/7516.yml diff --git a/changelogs/fragments/7516.yml b/changelogs/fragments/7516.yml new file mode 100644 index 000000000000..3ef007d8fbf2 --- /dev/null +++ b/changelogs/fragments/7516.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace]Optimize workspace permission validation for bulk operations ([#7516](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7516)) \ No newline at end of file diff --git a/src/plugins/workspace/server/permission_control/client.test.ts b/src/plugins/workspace/server/permission_control/client.test.ts index 4d041cc7df56..a585710da54d 100644 --- a/src/plugins/workspace/server/permission_control/client.test.ts +++ b/src/plugins/workspace/server/permission_control/client.test.ts @@ -102,7 +102,16 @@ describe('PermissionControl', () => { }); const batchValidateResult = await permissionControlClient.batchValidate( httpServerMock.createOpenSearchDashboardsRequest(), - [], + [ + { + id: 'foo', + type: 'dashboard', + }, + { + id: 'bar', + type: 'dashboard', + }, + ], ['read'] ); expect(batchValidateResult.success).toEqual(true); @@ -142,7 +151,16 @@ describe('PermissionControl', () => { }); const batchValidateResult = await permissionControlClient.batchValidate( httpServerMock.createOpenSearchDashboardsRequest(), - [], + [ + { + id: 'foo', + type: 'dashboard', + }, + { + id: 'bar', + type: 'dashboard', + }, + ], ['read'] ); expect(batchValidateResult.success).toEqual(true); @@ -197,4 +215,123 @@ describe('PermissionControl', () => { ); }); }); + + describe('saved objects cache', () => { + it('should not call bulk get again if saved objects cached', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + permissionControlClient.addToCacheAllowlist(requestMock, [ + { + type: 'workspace', + id: 'foo', + }, + ]); + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + type: 'workspace', + id: 'foo', + attributes: {}, + references: [], + }, + ], + }); + + await permissionControlClient.validate(requestMock, { id: 'foo', type: 'workspace' }, [ + 'read', + ]); + expect(clientMock.bulkGet).toHaveBeenCalledTimes(1); + + await permissionControlClient.validate(requestMock, { id: 'foo', type: 'workspace' }, [ + 'read', + ]); + expect(clientMock.bulkGet).toHaveBeenCalledTimes(1); + }); + it('should call bulk get again for different requests', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + permissionControlClient.addToCacheAllowlist(requestMock, [ + { + type: 'workspace', + id: 'foo', + }, + ]); + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + type: 'workspace', + id: 'foo', + attributes: {}, + references: [], + }, + ], + }); + + await permissionControlClient.validate(requestMock, { id: 'foo', type: 'workspace' }, [ + 'read', + ]); + expect(clientMock.bulkGet).toHaveBeenCalledTimes(1); + + await permissionControlClient.validate( + httpServerMock.createOpenSearchDashboardsRequest({ + opensearchDashboardsRequestState: { + requestId: '123', + requestUuid: 'another-uuid', + }, + }), + { id: 'foo', type: 'workspace' }, + ['read'] + ); + expect(clientMock.bulkGet).toHaveBeenCalledTimes(2); + }); + it('should call bulk get again after cache been cleared', async () => { + const permissionControlClient = new SavedObjectsPermissionControl(loggerMock.create()); + const getScopedClient = jest.fn(); + const clientMock = savedObjectsClientMock.create(); + const requestMock = httpServerMock.createOpenSearchDashboardsRequest(); + getScopedClient.mockImplementation((request) => { + return clientMock; + }); + permissionControlClient.setup(getScopedClient, mockAuth); + permissionControlClient.addToCacheAllowlist(requestMock, [ + { + type: 'workspace', + id: 'foo', + }, + ]); + clientMock.bulkGet.mockResolvedValue({ + saved_objects: [ + { + type: 'workspace', + id: 'foo', + attributes: {}, + references: [], + }, + ], + }); + + await permissionControlClient.validate(requestMock, { id: 'foo', type: 'workspace' }, [ + 'read', + ]); + expect(clientMock.bulkGet).toHaveBeenCalledTimes(1); + permissionControlClient.clearSavedObjectsCache(requestMock); + + await permissionControlClient.validate(requestMock, { id: 'foo', type: 'workspace' }, [ + 'read', + ]); + expect(clientMock.bulkGet).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/src/plugins/workspace/server/permission_control/client.ts b/src/plugins/workspace/server/permission_control/client.ts index bdc67f830913..0850690325f1 100644 --- a/src/plugins/workspace/server/permission_control/client.ts +++ b/src/plugins/workspace/server/permission_control/client.ts @@ -13,7 +13,6 @@ import { Principals, SavedObject, WORKSPACE_TYPE, - Permissions, HttpAuth, } from '../../../../core/server'; import { WORKSPACE_SAVED_OBJECTS_CLIENT_WRAPPER_ID } from '../../common/constants'; @@ -30,6 +29,8 @@ export class SavedObjectsPermissionControl { private readonly logger: Logger; private _getScopedClient?: SavedObjectsServiceStart['getScopedClient']; private auth?: HttpAuth; + private _savedObjectCache: Map = new Map(); + private _shouldCachedSavedObjects: Map = new Map(); /** * Returns a saved objects client that is able to: * 1. Read objects whose type is `workspace` because workspace is a hidden type and the permission control client will need to get the metadata of a specific workspace to do the permission check. @@ -48,11 +49,46 @@ export class SavedObjectsPermissionControl { this.logger = logger; } + private generateSavedObjectKey = ({ type, id }: { type: string; id: string }) => { + return `${type}:${id}`; + }; + private async bulkGetSavedObjects( request: OpenSearchDashboardsRequest, savedObjects: SavedObjectsBulkGetObject[] ) { - return (await this.getScopedClient?.(request)?.bulkGet(savedObjects))?.saved_objects || []; + const requestKey = request.uuid; + const savedObjectsToGet = savedObjects.filter( + (savedObject) => + !this._savedObjectCache.get(requestKey)?.[this.generateSavedObjectKey(savedObject)] + ); + const retrievedSavedObjects = + savedObjectsToGet.length > 0 + ? (await this.getScopedClient?.(request)?.bulkGet(savedObjectsToGet))?.saved_objects || [] + : []; + + const retrievedSavedObjectsMap: { [key: string]: SavedObject } = {}; + retrievedSavedObjects.forEach((savedObject) => { + const savedObjectKey = this.generateSavedObjectKey(savedObject); + if (this._shouldCachedSavedObjects.get(requestKey)?.includes(savedObjectKey)) { + const cachedSavedObjectsMap = this._savedObjectCache.get(requestKey) || {}; + cachedSavedObjectsMap[savedObjectKey] = savedObject; + this._savedObjectCache.set(requestKey, cachedSavedObjectsMap); + } + retrievedSavedObjectsMap[savedObjectKey] = savedObject; + }); + + const results: SavedObject[] = []; + savedObjects.forEach((savedObject) => { + const savedObjectKey = this.generateSavedObjectKey(savedObject); + const foundedSavedObject = + this._savedObjectCache.get(requestKey)?.[savedObjectKey] || + retrievedSavedObjectsMap[savedObjectKey]; + if (foundedSavedObject) { + results.push(foundedSavedObject); + } + }); + return results; } public async setup(getScopedClient: SavedObjectsServiceStart['getScopedClient'], auth: HttpAuth) { this._getScopedClient = getScopedClient; @@ -175,4 +211,30 @@ export class SavedObjectsPermissionControl { result: hasPermissionToAllObjects, }; } + + public addToCacheAllowlist( + request: OpenSearchDashboardsRequest, + savedObjects: Array> + ) { + const requestKey = request.uuid; + this._shouldCachedSavedObjects.set( + requestKey, + Array.from( + new Set([ + ...(this._shouldCachedSavedObjects.get(requestKey) ?? []), + ...savedObjects.map(this.generateSavedObjectKey), + ]) + ) + ); + } + + public clearSavedObjectsCache(request: OpenSearchDashboardsRequest) { + const requestKey = request.uuid; + if (this._shouldCachedSavedObjects.has(requestKey)) { + this._shouldCachedSavedObjects.delete(requestKey); + } + if (this._savedObjectCache.has(requestKey)) { + this._savedObjectCache.delete(requestKey); + } + } } diff --git a/src/plugins/workspace/server/plugin.test.ts b/src/plugins/workspace/server/plugin.test.ts index df9c9edac5ed..e3e094b94a46 100644 --- a/src/plugins/workspace/server/plugin.test.ts +++ b/src/plugins/workspace/server/plugin.test.ts @@ -8,6 +8,7 @@ import { coreMock, httpServerMock } from '../../../core/server/mocks'; import { WorkspacePlugin } from './plugin'; import { getWorkspaceState, updateWorkspaceState } from '../../../core/server/utils'; import * as utilsExports from './utils'; +import { SavedObjectsPermissionControl } from './permission_control/client'; describe('Workspace server plugin', () => { it('#setup', async () => { @@ -157,6 +158,22 @@ describe('Workspace server plugin', () => { ); expect(toolKitMock.next).toBeCalledTimes(1); }); + + it('should clear saved objects cache', async () => { + jest.spyOn(utilsExports, 'getPrincipalsFromRequest').mockImplementation(() => ({})); + const clearSavedObjectsCacheMock = jest + .spyOn(SavedObjectsPermissionControl.prototype, 'clearSavedObjectsCache') + .mockImplementationOnce(() => {}); + + await workspacePlugin.setup(setupMock); + const toolKitMock = httpServerMock.createToolkit(); + + expect(setupMock.http.registerOnPreResponse).toHaveBeenCalled(); + const preResponseFn = setupMock.http.registerOnPreResponse.mock.calls[0][0]; + + preResponseFn(requestWithWorkspaceInUrl, { statusCode: 200 }, toolKitMock); + expect(clearSavedObjectsCacheMock).toHaveBeenCalled(); + }); }); it('#start', async () => { diff --git a/src/plugins/workspace/server/plugin.ts b/src/plugins/workspace/server/plugin.ts index eed42fb85636..6e7a29322aa0 100644 --- a/src/plugins/workspace/server/plugin.ts +++ b/src/plugins/workspace/server/plugin.ts @@ -101,6 +101,11 @@ export class WorkspacePlugin implements Plugin { + this.permissionControl?.clearSavedObjectsCache(request); + return toolkit.next(); + }); } constructor(initializerContext: PluginInitializerContext) { diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts index 282d6f9cc84f..85d372b76177 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.test.ts @@ -129,6 +129,7 @@ const generateWorkspaceSavedObjectsClientWrapper = (role = NO_DASHBOARD_ADMIN) = getPrincipalsFromRequest: jest.fn().mockImplementation(() => { return { users: ['user-1'] }; }), + addToCacheAllowlist: jest.fn(), }; const wrapper = new WorkspaceSavedObjectsClientWrapper(permissionControlMock); diff --git a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts index c34e26eb0911..c5025da8c9b9 100644 --- a/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts +++ b/src/plugins/workspace/server/saved_objects/workspace_saved_objects_client_wrapper.ts @@ -72,6 +72,18 @@ const generateOSDAdminPermissionError = () => ) ); +const getWorkspacesFromSavedObjects = (savedObjects: SavedObject[]) => { + return savedObjects + .reduce( + (previous, { workspaces }) => Array.from(new Set([...previous, ...(workspaces ?? [])])), + [] + ) + .map((id) => ({ + type: WORKSPACE_TYPE, + id, + })); +}; + const getDefaultValuesForEmpty = (values: T[] | undefined, defaultValues: T[]) => { return !values || values.length === 0 ? defaultValues : values; }; @@ -126,15 +138,12 @@ export class WorkspaceSavedObjectsClientWrapper { return false; } for (const workspaceId of workspaces) { - const validateResult = await this.permissionControl.validate( + const validateResult = await this.validateMultiWorkspacesPermissions( + [workspaceId], request, - { - type: WORKSPACE_TYPE, - id: workspaceId, - }, permissionModes ); - if (validateResult?.result) { + if (validateResult) { return true; } } @@ -265,6 +274,10 @@ export class WorkspaceSavedObjectsClientWrapper { options?: SavedObjectsBulkUpdateOptions ): Promise> => { const objectsToUpdate = await wrapperOptions.client.bulkGet(objects, options); + this.permissionControl.addToCacheAllowlist( + wrapperOptions.request, + getWorkspacesFromSavedObjects(objectsToUpdate.saved_objects) + ); for (const object of objectsToUpdate.saved_objects) { const permitted = await validateUpdateWithWorkspacePermission(object); @@ -322,6 +335,10 @@ export class WorkspaceSavedObjectsClientWrapper { throw error; } } + this.permissionControl.addToCacheAllowlist( + wrapperOptions.request, + getWorkspacesFromSavedObjects([rawObject]) + ); if ( !(await this.validateWorkspacesAndSavedObjectsPermissions( rawObject, @@ -426,6 +443,10 @@ export class WorkspaceSavedObjectsClientWrapper { options: SavedObjectsBaseOptions = {} ): Promise> => { const objectToBulkGet = await wrapperOptions.client.bulkGet(objects, options); + this.permissionControl.addToCacheAllowlist( + wrapperOptions.request, + getWorkspacesFromSavedObjects(objectToBulkGet.saved_objects) + ); for (const object of objectToBulkGet.saved_objects) { if (object.type === DATA_SOURCE_SAVED_OBJECT_TYPE) { From ca89488aef48c8353ef8482bc15387c90f1ee66a Mon Sep 17 00:00:00 2001 From: Jincheng Wan <45655760+Kapian1234@users.noreply.github.com> Date: Wed, 7 Aug 2024 15:43:11 +0800 Subject: [PATCH 11/32] Fix browser warnings (#7550) * fix warnings Signed-off-by: Kapian1234 <2254087899@qq.com> * fix warnings Signed-off-by: Kapian1234 * Changeset file for PR #7548 created/updated * fix warnings Signed-off-by: Kapian1234 * Changeset file for PR #7550 created/updated * Modified the expect of addNavLinksToGroup in workspace plugin.test.js Signed-off-by: Kapian1234 * Modified the expect of addNavLinksToGroup in workspace plugin.test.js Signed-off-by: Kapian1234 * remove the duplicate changelogs Signed-off-by: Kapian1234 * Renamed keys to comply with the directory the file is in Signed-off-by: Kapian1234 --------- Signed-off-by: Kapian1234 <2254087899@qq.com> Signed-off-by: Kapian1234 Signed-off-by: Jincheng Wan <45655760+Kapian1234@users.noreply.github.com> Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7550.yml | 2 ++ .../public/components/page_render.tsx | 2 +- .../application/components/home_list_card.tsx | 4 ++-- .../home/public/application/home_render.tsx | 2 +- .../home_get_start_card/use_case_footer.tsx | 22 +++++++++++-------- src/plugins/workspace/public/plugin.test.ts | 3 ++- src/plugins/workspace/public/plugin.ts | 9 -------- 7 files changed, 21 insertions(+), 23 deletions(-) create mode 100644 changelogs/fragments/7550.yml diff --git a/changelogs/fragments/7550.yml b/changelogs/fragments/7550.yml new file mode 100644 index 000000000000..83cb4d9f0979 --- /dev/null +++ b/changelogs/fragments/7550.yml @@ -0,0 +1,2 @@ +fix: +- Resolve some browser warnings ([#7550](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7550)) \ No newline at end of file diff --git a/src/plugins/content_management/public/components/page_render.tsx b/src/plugins/content_management/public/components/page_render.tsx index 90d6033576bb..1a28d1efec2f 100644 --- a/src/plugins/content_management/public/components/page_render.tsx +++ b/src/plugins/content_management/public/components/page_render.tsx @@ -28,7 +28,7 @@ export const PageRender = ({ page, embeddable, savedObjectsClient }: Props) => { style={{ margin: '10px 20px' }} > {sections.map((section) => ( - + { {config.list.length > 0 && ( {config.list.map((item) => ( - <> + {item.label} @@ -94,7 +94,7 @@ export const HomeListCard = ({ config }: { config: Config }) => { {item.description} - + ))} )} diff --git a/src/plugins/home/public/application/home_render.tsx b/src/plugins/home/public/application/home_render.tsx index 5d0497b7c39e..afddae56ce63 100644 --- a/src/plugins/home/public/application/home_render.tsx +++ b/src/plugins/home/public/application/home_render.tsx @@ -36,7 +36,7 @@ export const setupHome = (contentManagement: ContentManagementPluginSetup) => { <> {contents.map((content) => { if (content.kind === 'custom') { - return content.render(); + return {content.render()}; } return null; diff --git a/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx index fddd542f64d7..922cf3e66f7b 100644 --- a/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx +++ b/src/plugins/workspace/public/components/home_get_start_card/use_case_footer.tsx @@ -22,7 +22,6 @@ import { EuiModalHeaderTitle, } from '@elastic/eui'; import React, { useMemo, useState } from 'react'; -import { FormattedMessage } from 'react-intl'; import { i18n } from '@osd/i18n'; import { BehaviorSubject } from 'rxjs'; import { WORKSPACE_DETAIL_APP_ID } from '../../../common/constants'; @@ -94,7 +93,9 @@ export const UseCaseFooter = ({ onClick={showModal} data-test-subj="useCase.footer.createWorkspace.button" > - + {i18n.translate('workspace.useCase.footer.createWorkspace', { + defaultMessage: 'Create workspace', + })} {isModalVisible && ( @@ -108,7 +109,9 @@ export const UseCaseFooter = ({ - + {i18n.translate('workspace.useCase.footer.modal.close', { + defaultMessage: 'Close', + })} {isDashboardAdmin && ( - + {i18n.translate('workspace.useCase.footer.modal.create', { + defaultMessage: 'Create workspace', + })} )} @@ -137,7 +139,7 @@ export const UseCaseFooter = ({ ); return ( - + {i18n.translate('workspace.useCase.footer.openWorkspace', { defaultMessage: 'Open' })} ); } @@ -175,7 +177,9 @@ export const UseCaseFooter = ({ const button = ( - + {i18n.translate('workspace.useCase.footer.selectWorkspace', { + defaultMessage: 'Select workspace', + })} ); const panels = [ diff --git a/src/plugins/workspace/public/plugin.test.ts b/src/plugins/workspace/public/plugin.test.ts index dc601137789b..d8372fd68a5c 100644 --- a/src/plugins/workspace/public/plugin.test.ts +++ b/src/plugins/workspace/public/plugin.test.ts @@ -171,7 +171,8 @@ describe('Workspace plugin', () => { expect.arrayContaining([ { id: 'workspace_list', - title: 'workspace settings', + order: 150, + title: 'Workspace settings', }, ]) ); diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index dd4dc78fdf2c..6516525bdbf9 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -376,15 +376,6 @@ export class WorkspacePlugin }, ]); - core.chrome.navGroup.addNavLinksToGroup(DEFAULT_NAV_GROUPS.settingsAndSetup, [ - { - id: WORKSPACE_LIST_APP_ID, - title: i18n.translate('workspace.settingsAndSetup.workspaceSettings', { - defaultMessage: 'workspace settings', - }), - }, - ]); - /** * register workspace column into saved objects table */ From d0520a60b8910d2aa6d53d3f0d37b477aff205c2 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Wed, 7 Aug 2024 23:58:22 +0800 Subject: [PATCH 12/32] [Doc] Documentation of content management plugin (#7575) * Add documentation for content management plugin Signed-off-by: Yulong Ruan * update documentation for content management plugin Signed-off-by: Yulong Ruan * Changeset file for PR #7575 created/updated * update documentation to mention that the contents are sorted by `order` Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7575.yml | 2 + src/plugins/content_management/README.md | 237 +++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 changelogs/fragments/7575.yml create mode 100644 src/plugins/content_management/README.md diff --git a/changelogs/fragments/7575.yml b/changelogs/fragments/7575.yml new file mode 100644 index 000000000000..3f1335de1ecc --- /dev/null +++ b/changelogs/fragments/7575.yml @@ -0,0 +1,2 @@ +doc: +- Add documentation for dynamic page creation ([#7575](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7575)) \ No newline at end of file diff --git a/src/plugins/content_management/README.md b/src/plugins/content_management/README.md new file mode 100644 index 000000000000..8d3d96a8062d --- /dev/null +++ b/src/plugins/content_management/README.md @@ -0,0 +1,237 @@ +# Plugin for managing dynamic page creation in OSD +Use this plugin to create pages that multiple plugins can contribute to. A typical use case is the OSD homepage, +which can have contents contributed by different plugins, see the screenshot: + +![image](https://github.com/user-attachments/assets/501c2433-38c5-4b53-9974-de6f63eab94d) + +## Getting started +### Step 1: Add `contentManagement` to `requiredPlugins` +Ensure `contentManagement` is listed in the `requiredPlugins` array of your plugin's manifest file. +```json +{ + "requiredPlugins": ["contentManagement"] +} +``` + +### Step 2: Create a page with defined sections +A section is typically a container on the page, and a page could have multiple sections. Using the homepage as an example: +```typescript +export const HOME_PAGE_ID = 'osd_homepage'; +export enum SECTIONS { + GET_STARTED = `get_started`, + SERVICE_CARDS = `service_cards`, + RECENTLY_VIEWED = `recently_viewed`, +} + +export class MyPlugin implements Plugin { + public setup(core, { contentManagement }) { + contentManagement.registerPage({ + id: HOME_PAGE_ID, + title: 'Home', + sections: [ + { + id: SECTIONS.SERVICE_CARDS, + order: 3000, + kind: 'dashboard', + }, + { + id: SECTIONS.RECENTLY_VIEWED, + order: 2000, + title: 'Recently viewed', + kind: 'custom', + render: (contents) => ( + <> + {contents.map((content) => content.kind === 'custom' ? content.render() : null)} + + ), + }, + { + id: SECTIONS.GET_STARTED, + order: 1000, + title: 'Define your path forward with OpenSearch', + kind: 'card', + }, + ], + }); + } +} +``` +Here we defined a page with three different kinds of sections: `dashboard`, `custom` and `card`, the sections will be sorted by `order` in ascending order. +Each type of section serves a different purpose: + +#### `card` section +A `card` section is one of the pre-defined section type that renders a horizontal list of OuiCard components, to add +contents to a `card` section, call `contentManagement.registerContentProvider` with a `title` and `description`, and the content +will be sorted by its `order` in ascending order. +```typescript +export class MyPlugin implements Plugin { + public start(core, { contentManagement }) { + contentManagement.registerContentProvider({ + id: `home_get_start`, // id for the content provider, could be any unique string + getTargetArea: () => `${HOME_PAGE_ID}/${SECTIONS.GET_STARTED}`, // target area follow the convention: {page_id}/{section_id} + getContent: () => ({ + id: 'card_content_id', // id for the content, could be any unique string + kind: 'card', + order: 1000, + description: 'Card description', + title: 'Card title' + }), + }); + } +} +``` + +#### `dashboard` section +A `dashboard` section is typically a dashboard embeddable container, it can render visualization or dashboard by their id, +or it can render arbitrary React components. + +Add a saved visualization to a `dashboard` section **statically** +```typescript +export class MyPlugin implements Plugin { + public start(core, { contentManagement }) { + contentManagement.registerContentProvider({ + id: `visualization_content_provider`, + getTargetArea: () => `${HOME_PAGE_ID}/${SECTIONS.SERVICE_CARDS}`, + getContent: () => ({ + id: 'visualization_content_id', // id for the content, could be any unique string + kind: 'visualization', + order: 1000, + input: { + kind: 'static', + id: 'c0ba29f0-eb8f-11ed-8e00-17d7d50cd7b2' // the visualization id + } + }), + }); + } +} +``` + +Add a saved visualization to a `dashboard` section **dynamically** +```diff +export class MyPlugin implements Plugin { + public start(core, { contentManagement }) { + contentManagement.registerContentProvider({ + id: `visualization_content_provider`, + getTargetArea: () => `${HOME_PAGE_ID}/${SECTIONS.SERVICE_CARDS}`, + getContent: () => ({ + id: 'visualization_content_id', + kind: 'visualization', // with `visualization` kind + order: 1000, + input: { +- kind: 'static', +- id: 'c0ba29f0-eb8f-11ed-8e00-17d7d50cd7b2' // the visualization id ++ kind: 'dynamic', ++ get: () => Promise.resolve('c0ba29f0-eb8f-11ed-8e00-17d7d50cd7b2') // the visualization id + } + }), + }); + } +} +``` + +Add a saved dashboard to a `dashboard` section **statically** +```typescript +export class MyPlugin implements Plugin { + public start(core, { contentManagement }) { + contentManagement.registerContentProvider({ + id: `dashboard_content_provider`, + getTargetArea: () => `${HOME_PAGE_ID}/${SECTIONS.SERVICE_CARDS}`, + getContent: () => ({ + id: 'dashboard_content_id', + kind: 'dashboard', // with `dashboard` kind + order: 1000, + input: { + kind: 'static', + id: 'c39012d0-eb7a-11ed-8e00-17d7d50cd7b2' // the saved dashboard id + } + }), + }); + } +} +``` + +Similarly, you can add a saved dashboard to a `dashboard` section **dynamically** +```diff +export class MyPlugin implements Plugin { + public start(core, { contentManagement }) { + contentManagement.registerContentProvider({ + id: `dashboard_content_provider`, + getTargetArea: () => `${HOME_PAGE_ID}/${SECTIONS.SERVICE_CARDS}`, + getContent: () => ({ + id: 'dashboard_content_id', + kind: 'dashboard', // with `dashboard` kind + order: 1000, + input: { +- kind: 'static', +- id: 'c39012d0-eb7a-11ed-8e00-17d7d50cd7b2' // the saved dashboard id ++ kind: 'dynamic', ++ get: () => Promise.resolve('c39012d0-eb7a-11ed-8e00-17d7d50cd7b2') // the saved dashboard id + } + }), + }); + } +} +``` + +You can also add custom components to a `dashboard` section +```typescript +export class MyPlugin implements Plugin { + public start(core, { contentManagement }) { + contentManagement.registerContentProvider({ + id: `custom_content_provider`, + getTargetArea: () => `${HOME_PAGE_ID}/${SECTIONS.SERVICE_CARDS}`, + getContent: () => ({ + id: 'custom_content_id', + kind: 'custom', // with `dashboard` kind + order: 1000, + render: () => + }), + }); + } +} +``` + +#### `custom` section +If the existing pre-defined sections do not meet your needs, you could use `custom` section to customize the rendering of the contents, +a custom section is typically defined with `kind: 'custom'` and a `render` function: +```typescript +{ + id: SECTIONS.RECENTLY_VIEWED, + order: 2000, + title: 'Recently viewed', + kind: 'custom', + render: (contents) => ( + <> + {contents.map((content) => content.kind === 'custom' ? content.render() : null)} + + ), +} +``` + +Now adds content to a `custom` section +```typescript +export class MyPlugin implements Plugin { + public start(core, { contentManagement }) { + contentManagement.registerContentProvider({ + id: 'recent_provider_id', + getContent: () => { + return { + order: 1, + id: 'recent_content_id', + kind: 'custom', + render: () => , + }; + }, + getTargetArea: () => `${HOME_PAGE_ID}/${SECTIONS.RECENTLY_VIEWED}`, + }); + } +} +``` + +### Step 3: Render the page +Finally, render the page in your application: +```typescript + + {contentManagement.renderPage(HOME_PAGE_ID)} + +``` From f08dbdf859481d864ac421c34cd8f09f7bb7e5fc Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Thu, 8 Aug 2024 00:04:05 +0800 Subject: [PATCH 13/32] fix: do not throw exception if content provider id exists (#7633) * fix: do not throw exception if content provider id exists instead it will overwrite existing content provider --------- Signed-off-by: Yulong Ruan Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7633.yml | 2 + .../public/components/section_input.test.ts | 16 ++--- .../public/components/section_input.ts | 6 +- .../content_management_service.ts | 4 -- .../services/content_management/page.test.ts | 67 +++++++++++++++++-- .../services/content_management/page.ts | 14 ++-- 6 files changed, 84 insertions(+), 25 deletions(-) create mode 100644 changelogs/fragments/7633.yml diff --git a/changelogs/fragments/7633.yml b/changelogs/fragments/7633.yml new file mode 100644 index 000000000000..685398726fe0 --- /dev/null +++ b/changelogs/fragments/7633.yml @@ -0,0 +1,2 @@ +feat: +- Register section and content with the same id will not throw error but overrides the exist one ([#7633](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7633)) \ No newline at end of file diff --git a/src/plugins/content_management/public/components/section_input.test.ts b/src/plugins/content_management/public/components/section_input.test.ts index 22420e071438..6ec40f950f89 100644 --- a/src/plugins/content_management/public/components/section_input.test.ts +++ b/src/plugins/content_management/public/components/section_input.test.ts @@ -246,7 +246,7 @@ test('it should create section with a dashboard as content', async () => { id: 'dashboard-id-static', attributes: { panelsJSON: - '[{"version":"3.0.0","gridData":{"x":0,"y":0,"w":48,"h":5,"i":"debc95ec-7d43-49ee-84c8-95ad7b0b03ea"},"panelIndex":"debc95ec-7d43-49ee-84c8-95ad7b0b03ea","embeddableConfig":{"hidePanelTitles":true},"panelRefName":"panel_0"}]', + '[{"version":"3.0.0","gridData":{"x":0,"y":0,"w":48,"h":5,"i":"i"},"panelIndex":"1","embeddableConfig":{"hidePanelTitles":true},"panelRefName":"panel_0"}]', }, references: [ { id: 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2', name: 'panel_0', type: 'visualization' }, @@ -258,14 +258,14 @@ test('it should create section with a dashboard as content', async () => { savedObjectsClient: clientMock, }); expect(input.panels).toEqual({ - 'debc95ec-7d43-49ee-84c8-95ad7b0b03ea': { + 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2': { explicitInput: { - id: 'debc95ec-7d43-49ee-84c8-95ad7b0b03ea', + id: 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2', savedObjectId: 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2', }, gridData: { h: 5, - i: 'debc95ec-7d43-49ee-84c8-95ad7b0b03ea', + i: 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2', w: 48, x: 0, y: 0, @@ -292,7 +292,7 @@ test('it should create section with a dynamic dashboard as content', async () => id: 'dashboard-id-static', attributes: { panelsJSON: - '[{"version":"3.0.0","gridData":{"x":0,"y":0,"w":48,"h":5,"i":"debc95ec-7d43-49ee-84c8-95ad7b0b03ea"},"panelIndex":"debc95ec-7d43-49ee-84c8-95ad7b0b03ea","embeddableConfig":{"hidePanelTitles":true},"panelRefName":"panel_0"}]', + '[{"version":"3.0.0","gridData":{"x":0,"y":0,"w":48,"h":5,"i":"1"},"panelIndex":"1","embeddableConfig":{"hidePanelTitles":true},"panelRefName":"panel_0"}]', }, references: [ { id: 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2', name: 'panel_0', type: 'visualization' }, @@ -304,14 +304,14 @@ test('it should create section with a dynamic dashboard as content', async () => savedObjectsClient: clientMock, }); expect(input.panels).toEqual({ - 'debc95ec-7d43-49ee-84c8-95ad7b0b03ea': { + 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2': { explicitInput: { - id: 'debc95ec-7d43-49ee-84c8-95ad7b0b03ea', + id: 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2', savedObjectId: 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2', }, gridData: { h: 5, - i: 'debc95ec-7d43-49ee-84c8-95ad7b0b03ea', + i: 'ce24dd10-eb8a-11ed-8e00-17d7d50cd7b2', w: 48, x: 0, y: 0, diff --git a/src/plugins/content_management/public/components/section_input.ts b/src/plugins/content_management/public/components/section_input.ts index c6a68724716f..cd6ec6f23a08 100644 --- a/src/plugins/content_management/public/components/section_input.ts +++ b/src/plugins/content_management/public/components/section_input.ts @@ -103,11 +103,11 @@ export const createDashboardInput = async ( } const reference = references.find((ref) => ref.name === panel.panelRefName); if (reference) { - panels[panel.panelIndex] = { - gridData: panel.gridData, + panels[reference.id] = { + gridData: { ...panel.gridData, i: reference.id }, type: reference.type, explicitInput: { - id: panel.panelIndex, + id: reference.id, savedObjectId: reference.id, }, }; diff --git a/src/plugins/content_management/public/services/content_management/content_management_service.ts b/src/plugins/content_management/public/services/content_management/content_management_service.ts index 82ce4640c4fc..1babdb06089d 100644 --- a/src/plugins/content_management/public/services/content_management/content_management_service.ts +++ b/src/plugins/content_management/public/services/content_management/content_management_service.ts @@ -33,10 +33,6 @@ export class ContentManagementService { }; registerContentProvider = (provider: ContentProvider) => { - if (this.contentProviders.get(provider.id)) { - throw new Error(`Cannot register content provider with the same id ${provider.id}`); - } - this.contentProviders.set(provider.id, provider); const targetArea = provider.getTargetArea(); diff --git a/src/plugins/content_management/public/services/content_management/page.test.ts b/src/plugins/content_management/public/services/content_management/page.test.ts index 5ce36fb21f5a..13a0ec03c453 100644 --- a/src/plugins/content_management/public/services/content_management/page.test.ts +++ b/src/plugins/content_management/public/services/content_management/page.test.ts @@ -17,12 +17,12 @@ test('it should create sections', () => { expect(page.getSections()).toHaveLength(2); }); -test('it should not create section with existing id', () => { +test('creating section with the same id should override the previous section', () => { const page = new Page({ id: 'page1' }); page.createSection({ id: 'section1', kind: 'dashboard', order: 2000 }); - expect(() => - page.createSection({ id: 'section1', kind: 'dashboard', order: 1000 }) - ).toThrowError(); + page.createSection({ id: 'section1', kind: 'card', order: 1000 }); + expect(page.getSections()).toHaveLength(1); + expect(page.getSections()[0]).toEqual({ id: 'section1', kind: 'card', order: 1000 }); }); test('it should return sections in order', () => { @@ -102,3 +102,62 @@ test('it should return contents in order', () => { }, ]); }); + +test('it should only allow to add one dashboard to a section', () => { + const page = new Page({ id: 'page1' }); + page.createSection({ id: 'dashboard-section', kind: 'dashboard', order: 1000 }); + + page.addContent('dashboard-section', { + id: 'dashboard-content-1', + kind: 'dashboard', + order: 10, + input: { kind: 'static', id: 'dashboard-id-1' }, + }); + + // add another dashboard to the same section + page.addContent('dashboard-section', { + id: 'dashboard-content-1', + kind: 'dashboard', + order: 10, + input: { kind: 'static', id: 'dashboard-id-1' }, + }); + + // but it should only have one dashboard content + expect(page.getContents('dashboard-section')).toHaveLength(1); + expect(page.getContents('dashboard-section')).toEqual([ + { + id: 'dashboard-content-1', + kind: 'dashboard', + order: 10, + input: { kind: 'static', id: 'dashboard-id-1' }, + }, + ]); + + // add non-dashboard content to a section which already has a dashboard will override the dashboard + page.addContent('dashboard-section', { + id: 'vis-content-1', + kind: 'visualization', + order: 10, + input: { kind: 'static', id: 'vis-id-1' }, + }); + page.addContent('dashboard-section', { + id: 'vis-content-2', + kind: 'visualization', + order: 20, + input: { kind: 'static', id: 'vis-id-2' }, + }); + expect(page.getContents('dashboard-section')).toEqual([ + { + id: 'vis-content-1', + kind: 'visualization', + order: 10, + input: { kind: 'static', id: 'vis-id-1' }, + }, + { + id: 'vis-content-2', + kind: 'visualization', + order: 20, + input: { kind: 'static', id: 'vis-id-2' }, + }, + ]); +}); diff --git a/src/plugins/content_management/public/services/content_management/page.ts b/src/plugins/content_management/public/services/content_management/page.ts index fea90e57f037..07fb18451be3 100644 --- a/src/plugins/content_management/public/services/content_management/page.ts +++ b/src/plugins/content_management/public/services/content_management/page.ts @@ -19,9 +19,6 @@ export class Page { } createSection(section: Section) { - if (this.sections.has(section.id)) { - throw new Error(`Section id exists: ${section.id}`); - } this.sections.set(section.id, section); this.sections$.next(this.getSections()); } @@ -37,8 +34,13 @@ export class Page { addContent(sectionId: string, content: Content) { const sectionContents = this.contents.get(sectionId); if (sectionContents) { - if (content.kind === 'dashboard' && sectionContents.length > 0) { - throw new Error('Section type "dashboard" can only have one content type of "dashboard"'); + /** + * `dashboard` type of content is exclusive, one section can only hold one `dashboard` + * if adding a `dashboard` to an existing section, it will replace the contents of section + * if adding a non-dashboard content to an section with `dashboard`, it will replace the dashboard + */ + if (content.kind === 'dashboard' || sectionContents.some((c) => c.kind === 'dashboard')) { + sectionContents.length = 0; } sectionContents.push(content); // sort content by order @@ -48,7 +50,7 @@ export class Page { } if (this.contentObservables.get(sectionId)) { - this.contentObservables.get(sectionId)?.next(this.contents.get(sectionId) ?? []); + this.contentObservables.get(sectionId)?.next([...(this.contents.get(sectionId) ?? [])]); } else { this.contentObservables.set( sectionId, From 045e55f8c9b11c0889e53ab279939e840254ad7a Mon Sep 17 00:00:00 2001 From: Zhongnan Su Date: Wed, 7 Aug 2024 13:13:15 -0700 Subject: [PATCH 14/32] [Look&Feel] Apply small tabs pattern guidance to remaining OSD experiences (#7586) Signed-off-by: Zhongnan Su --- .../__snapshots__/data_source_home_panel.test.tsx.snap | 4 +++- .../data_source_home_panel/data_source_home_panel.tsx | 2 +- .../__snapshots__/acceleration_details_flyout.test.tsx.snap | 2 +- .../acceleration_management/acceleration_details_flyout.tsx | 4 +++- .../connection_detail/direct_query_connection_detail.tsx | 2 +- .../__snapshots__/workspace_detail.test.tsx.snap | 2 +- .../public/components/workspace_detail/workspace_detail.tsx | 1 + 7 files changed, 11 insertions(+), 6 deletions(-) diff --git a/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_home_panel.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_home_panel.test.tsx.snap index 40402f99f0a0..e4144646e0b0 100644 --- a/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_home_panel.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_home_panel.test.tsx.snap @@ -41,7 +41,9 @@ exports[`DataSourceHomePanel renders correctly 1`] = ` - + = ({ - {renderTabs()} + {renderTabs()} diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_management/__snapshots__/acceleration_details_flyout.test.tsx.snap b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_management/__snapshots__/acceleration_details_flyout.test.tsx.snap index a5621f3a69d0..eda5cf4a322c 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_management/__snapshots__/acceleration_details_flyout.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_management/__snapshots__/acceleration_details_flyout.test.tsx.snap @@ -95,7 +95,7 @@ Array [ className="euiSpacer euiSpacer--m" />

- {renderTabs()} + + {renderTabs()} + {renderTabContent(selectedTab)} {showConfirmationOverlay && operationType && ( diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/direct_query_connection_detail.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/direct_query_connection_detail.tsx index 0d8e9c98222e..db616d9bbaa8 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/direct_query_connection_detail.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/direct_query_connection_detail.tsx @@ -459,7 +459,7 @@ export const DirectQueryDataConnectionDetail: React.FC - + )} diff --git a/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap b/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap index 24a70b2a0352..562eadabefd7 100644 --- a/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap +++ b/src/plugins/workspace/public/components/workspace_detail/__snapshots__/workspace_detail.test.tsx.snap @@ -45,7 +45,7 @@ exports[`WorkspaceDetail render workspace detail page normally 1`] = ` data-test-subj="workspaceTabs" >
diff --git a/src/plugins/console/public/application/components/help_panel.tsx b/src/plugins/console/public/application/components/help_panel.tsx index 7031132dfe3b..34125dd3e8d8 100644 --- a/src/plugins/console/public/application/components/help_panel.tsx +++ b/src/plugins/console/public/application/components/help_panel.tsx @@ -30,14 +30,7 @@ import React from 'react'; import { FormattedMessage } from '@osd/i18n/react'; -import { - EuiText, - EuiFlyout, - EuiFlyoutHeader, - EuiFlyoutBody, - EuiTitle, - EuiSpacer, -} from '@elastic/eui'; +import { EuiText, EuiFlyout, EuiFlyoutHeader, EuiFlyoutBody, EuiSpacer } from '@elastic/eui'; import { EditorExample } from './editor_example'; interface Props { @@ -48,11 +41,11 @@ export function HelpPanel(props: Props) { return ( - +

-
+
diff --git a/src/plugins/console/public/application/components/import_flyout.tsx b/src/plugins/console/public/application/components/import_flyout.tsx index 5bfefb307dc6..0dd0112048e1 100644 --- a/src/plugins/console/public/application/components/import_flyout.tsx +++ b/src/plugins/console/public/application/components/import_flyout.tsx @@ -6,7 +6,6 @@ import { EuiFlyout, EuiFlyoutHeader, - EuiTitle, EuiFlyoutBody, EuiFlyoutFooter, EuiCallOut, @@ -276,14 +275,14 @@ export const ImportFlyout = ({ close, refresh }: ImportFlyoutProps) => { return ( - +

-
+
diff --git a/src/plugins/console/public/application/components/settings_modal.tsx b/src/plugins/console/public/application/components/settings_modal.tsx index d97b7c72a03f..ccfc4ffc38da 100644 --- a/src/plugins/console/public/application/components/settings_modal.tsx +++ b/src/plugins/console/public/application/components/settings_modal.tsx @@ -45,6 +45,7 @@ import { EuiModalHeader, EuiModalHeaderTitle, EuiCompressedSwitch, + EuiText, } from '@elastic/eui'; import { DevToolsSettings } from '../../services'; @@ -179,7 +180,14 @@ export function DevToolsSettingsModal(props: Props) { > - + +

+ +

+
diff --git a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/header/header.tsx b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/header/header.tsx index 370e83ecefeb..75eda9af9809 100644 --- a/src/plugins/data_source_management/public/components/create_data_source_wizard/components/header/header.tsx +++ b/src/plugins/data_source_management/public/components/create_data_source_wizard/components/header/header.tsx @@ -5,7 +5,7 @@ import React from 'react'; -import { EuiSpacer, EuiTitle, EuiText, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; +import { EuiSpacer, EuiText, EuiFlexItem, EuiFlexGroup } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; import { useOpenSearchDashboards } from '../../../../../../opensearch_dashboards_react/public'; @@ -25,7 +25,7 @@ export const Header = () => {
- +

{ { /> }

-
+

diff --git a/src/plugins/data_source_management/public/components/data_source_creation_panel/__snapshots__/create_data_source_panel_header.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_creation_panel/__snapshots__/create_data_source_panel_header.test.tsx.snap index 052b2ca8e2c0..00ffbb2bb3e0 100644 --- a/src/plugins/data_source_management/public/components/data_source_creation_panel/__snapshots__/create_data_source_panel_header.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_creation_panel/__snapshots__/create_data_source_panel_header.test.tsx.snap @@ -8,15 +8,17 @@ exports[`CreateDataSourcePanelHeader renders correctly 1`] = ` - -

+ +

-

- +

+
diff --git a/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel_header.test.tsx b/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel_header.test.tsx index 05af9a27d554..b027ef91db19 100644 --- a/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel_header.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel_header.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { CreateDataSourcePanelHeader } from './create_data_source_panel_header'; -import { EuiFlexGroup, EuiTitle, EuiText } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; describe('CreateDataSourcePanelHeader', () => { @@ -20,11 +20,11 @@ describe('CreateDataSourcePanelHeader', () => { test('contains correct title and description', () => { const wrapper = shallowComponent(); - const titleMessage = wrapper.find(EuiTitle).find(FormattedMessage); + const titleMessage = wrapper.find(EuiText).at(0).find(FormattedMessage); expect(titleMessage.prop('id')).toEqual('dataSourcesManagement.createDataSourcePanel.title'); expect(titleMessage.prop('defaultMessage')).toEqual('Create Data Source'); - const descriptionMessage = wrapper.find(EuiText).find(FormattedMessage); + const descriptionMessage = wrapper.find(EuiText).at(1).find(FormattedMessage); expect(descriptionMessage.prop('id')).toEqual( 'dataSourcesManagement.createDataSourcePanel.description' ); diff --git a/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel_header.tsx b/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel_header.tsx index 504e0578f4e1..3351192dbf22 100644 --- a/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel_header.tsx +++ b/src/plugins/data_source_management/public/components/data_source_creation_panel/create_data_source_panel_header.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@osd/i18n/react'; @@ -11,14 +11,14 @@ export const CreateDataSourcePanelHeader: React.FC = () => { return ( - -

+ +

-

- +

+

diff --git a/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_page_header.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_page_header.test.tsx.snap index 0a4d6b777a1b..cc50fc98f593 100644 --- a/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_page_header.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_home_panel/__snapshots__/data_source_page_header.test.tsx.snap @@ -8,15 +8,17 @@ exports[`DataSourceHeader renders correctly 1`] = ` - -

+ +

-

- +

+
diff --git a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_page_header.test.tsx b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_page_header.test.tsx index 8773bb0b3055..ca9d959e01e9 100644 --- a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_page_header.test.tsx +++ b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_page_header.test.tsx @@ -6,7 +6,7 @@ import React from 'react'; import { shallow } from 'enzyme'; import { DataSourceHeader } from './data_source_page_header'; -import { EuiTitle, EuiText } from '@elastic/eui'; +import { EuiText } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; describe('DataSourceHeader', () => { @@ -25,12 +25,11 @@ describe('DataSourceHeader', () => { test('contains correct title and description', () => { const wrapper = shallowComponent(); - - const titleMessage = wrapper.find(EuiTitle).find(FormattedMessage); + const titleMessage = wrapper.find(EuiText).at(0).find(FormattedMessage); expect(titleMessage.prop('id')).toEqual('dataSourcesManagement.dataSourcesTable.title'); expect(titleMessage.prop('defaultMessage')).toEqual('Data Sources'); - const descriptionMessage = wrapper.find(EuiText).find(FormattedMessage); + const descriptionMessage = wrapper.find(EuiText).at(1).find(FormattedMessage); expect(descriptionMessage.prop('id')).toEqual( 'dataSourcesManagement.dataSourcesTable.description' ); diff --git a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_page_header.tsx b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_page_header.tsx index 7c019650379f..c69147dff090 100644 --- a/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_page_header.tsx +++ b/src/plugins/data_source_management/public/components/data_source_home_panel/data_source_page_header.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText, EuiTitle } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiSpacer, EuiText } from '@elastic/eui'; import React from 'react'; import { FormattedMessage } from '@osd/i18n/react'; import { RouteComponentProps } from 'react-router-dom'; @@ -14,14 +14,14 @@ export const DataSourceHeader: React.FC = () => { return ( - -

+ +

-

- +

+

diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_header.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_header.tsx index 57a887fbe275..dc99a71aff6b 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_header.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/acceleration_creation/create/create_acceleration_header.tsx @@ -3,14 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import { - EuiLink, - EuiPageHeader, - EuiPageHeaderSection, - EuiSpacer, - EuiText, - EuiTitle, -} from '@elastic/eui'; +import { EuiLink, EuiPageHeader, EuiPageHeaderSection, EuiSpacer, EuiText } from '@elastic/eui'; import React from 'react'; import { OPENSEARCH_ACC_DOCUMENTATION_URL } from '../../../constants'; @@ -19,9 +12,9 @@ export const CreateAccelerationHeader = () => {

- +

Accelerate data

-
+
diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/direct_query_connection_detail.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/direct_query_connection_detail.tsx index db616d9bbaa8..5746454e946d 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/direct_query_connection_detail.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/connection_detail/direct_query_connection_detail.tsx @@ -11,7 +11,6 @@ import { EuiPageHeaderSection, EuiFlexGroup, EuiFlexItem, - EuiTitle, EuiPanel, EuiSpacer, EuiText, @@ -434,9 +433,9 @@ export const DirectQueryDataConnectionDetail: React.FC - +

{datasourceDetails.name}

-
+
diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/__snapshots__/configure_amazon_s3_data_source.test.tsx.snap b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/__snapshots__/configure_amazon_s3_data_source.test.tsx.snap index fcff59af7cba..43a97eea699d 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/__snapshots__/configure_amazon_s3_data_source.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/__snapshots__/configure_amazon_s3_data_source.test.tsx.snap @@ -148,13 +148,17 @@ exports[`ConfigureS3DatasourcePanel renders correctly 1`] = `
- -

+
- Configure Amazon S3 data source -

-
+

+ Configure Amazon S3 data source +

+
+ diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/__snapshots__/review_amazon_s3_data_source.test.tsx.snap b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/__snapshots__/review_amazon_s3_data_source.test.tsx.snap index 72b9a71e6449..1283d828b769 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/__snapshots__/review_amazon_s3_data_source.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/__snapshots__/review_amazon_s3_data_source.test.tsx.snap @@ -57,14 +57,19 @@ exports[`ReviewS3Datasource renders correctly 1`] = `
- -

+
- Review Amazon S3 data source configuration -

-
+

+ Review Amazon S3 data source configuration +

+
+ diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/configure_amazon_s3_data_source.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/configure_amazon_s3_data_source.tsx index 535298693e5e..7eeeda357304 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/configure_amazon_s3_data_source.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/configure_amazon_s3_data_source.tsx @@ -86,9 +86,9 @@ export const ConfigureS3DatasourcePanel: React.FC = return (
- +

{`Configure Amazon S3 data source`}

-
+ diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/review_amazon_s3_data_source.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/review_amazon_s3_data_source.tsx index 0a13bbedbec6..8b6adfd0ef32 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/review_amazon_s3_data_source.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/amazon_s3/review_amazon_s3_data_source.tsx @@ -5,7 +5,6 @@ import { EuiPanel, - EuiTitle, EuiSpacer, EuiText, EuiFlexGroup, @@ -41,9 +40,9 @@ export const ReviewS3Datasource = (props: ConfigureS3DatasourceProps) => { return (
- +

{`Review Amazon S3 data source configuration`}

-
+ diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/__snapshots__/configure_prometheus_data_source.test.tsx.snap b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/__snapshots__/configure_prometheus_data_source.test.tsx.snap index c49c259f8cf9..2032dacce601 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/__snapshots__/configure_prometheus_data_source.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/__snapshots__/configure_prometheus_data_source.test.tsx.snap @@ -74,13 +74,17 @@ exports[`ConfigurePrometheusDatasourcePanel renders correctly 1`] = `
- -

+
- Configure Prometheus data source -

-
+

+ Configure Prometheus data source +

+
+ diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/__snapshots__/review_prometheus_data_source.test.tsx.snap b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/__snapshots__/review_prometheus_data_source.test.tsx.snap index 15e5117c99f1..3f8efc268957 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/__snapshots__/review_prometheus_data_source.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/__snapshots__/review_prometheus_data_source.test.tsx.snap @@ -57,14 +57,19 @@ exports[`ReviewPrometheusDatasource renders correctly 1`] = `
- -

+
- Review Prometheus data source configuration -

-
+

+ Review Prometheus data source configuration +

+
+ diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/configure_prometheus_data_source.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/configure_prometheus_data_source.tsx index 4c15364e129d..3d1b02bb60ac 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/configure_prometheus_data_source.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/configure_prometheus_data_source.tsx @@ -5,7 +5,6 @@ import { EuiPanel, - EuiTitle, EuiSpacer, EuiText, EuiLink, @@ -88,9 +87,9 @@ export const ConfigurePrometheusDatasourcePanel = (props: ConfigurePrometheusDat return (
- -

{`Configure Prometheus data source`}

-
+ +

{`Configure Prometheus data source`}

+
{`Connect to Prometheus with OpenSearch and OpenSearch Dashboards. `} diff --git a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/review_prometheus_data_source.tsx b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/review_prometheus_data_source.tsx index 02fad81a98ae..3fea8702d813 100644 --- a/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/review_prometheus_data_source.tsx +++ b/src/plugins/data_source_management/public/components/direct_query_data_sources_components/direct_query_data_source_configuration/prometheus/review_prometheus_data_source.tsx @@ -5,7 +5,6 @@ import { EuiPanel, - EuiTitle, EuiSpacer, EuiText, EuiFlexGroup, @@ -41,9 +40,9 @@ export const ReviewPrometheusDatasource = (props: ConfigurePrometheusDatasourceP return (
- +

{`Review Prometheus data source configuration`}

-
+ diff --git a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx index a6cadae0ade3..3e76ab838f9d 100644 --- a/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx +++ b/src/plugins/data_source_management/public/components/edit_data_source/components/header/header.tsx @@ -7,7 +7,6 @@ import React, { useState } from 'react'; import { EuiSpacer, - EuiTitle, EuiFlexItem, EuiFlexGroup, EuiToolTip, @@ -169,9 +168,9 @@ export const Header = ({ {/* Title */}
- +

{dataSourceName}

-
+
diff --git a/src/plugins/home/public/application/components/tutorial_directory.js b/src/plugins/home/public/application/components/tutorial_directory.js index d9ddcc167e05..e2569c76739e 100644 --- a/src/plugins/home/public/application/components/tutorial_directory.js +++ b/src/plugins/home/public/application/components/tutorial_directory.js @@ -44,8 +44,8 @@ import { EuiFlexGrid, EuiFlexGroup, EuiSpacer, - EuiTitle, EuiPageBody, + EuiText, } from '@elastic/eui'; import { getTutorials } from '../load_tutorials'; @@ -280,14 +280,14 @@ class TutorialDirectoryUi extends React.Component { <> - +

-
+
{headerLinks ? {headerLinks} : null}
diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/__snapshots__/header.test.tsx.snap b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/__snapshots__/header.test.tsx.snap index 8d2423121710..35c9543af3c7 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/__snapshots__/header.test.tsx.snap +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/__snapshots__/header.test.tsx.snap @@ -23,24 +23,28 @@ exports[`Header should render a different name, prompt, and beta tag if provided } >
- -

+
- Create test index pattern - - - + Create test index pattern + + - Beta - - -

-
+ + Beta + + + +
+ @@ -158,13 +162,17 @@ exports[`Header should render normally 1`] = ` indexPatternName="test index pattern" >
- -

+
- Create test index pattern -

-
+

+ Create test index pattern +

+
+ @@ -272,13 +280,17 @@ exports[`Header should render without including system indices 1`] = ` indexPatternName="test index pattern" >
- -

+
- Create test index pattern -

-
+

+ Create test index pattern +

+
+ diff --git a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx index 8caffa19ed9a..5a0ab5018d2e 100644 --- a/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx +++ b/src/plugins/index_pattern_management/public/components/create_index_pattern_wizard/components/header/header.tsx @@ -30,7 +30,7 @@ import React from 'react'; -import { EuiBetaBadge, EuiSpacer, EuiTitle, EuiText, EuiCode, EuiLink } from '@elastic/eui'; +import { EuiBetaBadge, EuiSpacer, EuiText, EuiCode, EuiLink } from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { FormattedMessage } from '@osd/i18n/react'; @@ -63,7 +63,7 @@ export const Header = ({ return (
- +

{createIndexPatternHeader} {isBeta ? ( @@ -77,7 +77,7 @@ export const Header = ({ ) : null}

-
+

diff --git a/src/plugins/index_pattern_management/public/components/edit_index_pattern/index_header/index_header.tsx b/src/plugins/index_pattern_management/public/components/edit_index_pattern/index_header/index_header.tsx index 4bb917be4955..386e0d840e1d 100644 --- a/src/plugins/index_pattern_management/public/components/edit_index_pattern/index_header/index_header.tsx +++ b/src/plugins/index_pattern_management/public/components/edit_index_pattern/index_header/index_header.tsx @@ -30,7 +30,7 @@ import React from 'react'; import { i18n } from '@osd/i18n'; -import { EuiFlexGroup, EuiToolTip, EuiFlexItem, EuiTitle, EuiSmallButtonIcon } from '@elastic/eui'; +import { EuiFlexGroup, EuiToolTip, EuiFlexItem, EuiSmallButtonIcon, EuiText } from '@elastic/eui'; import { IIndexPattern } from 'src/plugins/data/public'; interface IndexHeaderProps { @@ -81,9 +81,9 @@ export function IndexHeader({ return ( - +

{indexPattern.title}

- +
diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx index e9ea9d1d9c12..737182031f84 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -38,7 +38,6 @@ import { EuiText, EuiBadgeGroup, EuiPageContent, - EuiTitle, } from '@elastic/eui'; import { FormattedMessage } from '@osd/i18n/react'; import { withRouter, RouteComponentProps } from 'react-router-dom'; @@ -265,9 +264,9 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { - -

{title}

-
+ +

{title}

+

diff --git a/src/plugins/management_overview/public/__snapshots__/application.test.tsx.snap b/src/plugins/management_overview/public/__snapshots__/application.test.tsx.snap index 666f1b50c696..cf5d62327a33 100644 --- a/src/plugins/management_overview/public/__snapshots__/application.test.tsx.snap +++ b/src/plugins/management_overview/public/__snapshots__/application.test.tsx.snap @@ -8,13 +8,15 @@ exports[`Overview page rendering should render normally 1`] = `

-

- - Overview - -

+

+ + Overview + +

+
diff --git a/src/plugins/management_overview/public/application.tsx b/src/plugins/management_overview/public/application.tsx index 805c43081fdd..528cc418ab98 100644 --- a/src/plugins/management_overview/public/application.tsx +++ b/src/plugins/management_overview/public/application.tsx @@ -6,7 +6,7 @@ import ReactDOM from 'react-dom'; import { I18nProvider, FormattedMessage } from '@osd/i18n/react'; import React, { useMemo } from 'react'; -import { EuiFlexGrid, EuiFlexItem, EuiPage, EuiPageBody, EuiSpacer, EuiTitle } from '@elastic/eui'; +import { EuiFlexGrid, EuiFlexItem, EuiPage, EuiPageBody, EuiSpacer, EuiText } from '@elastic/eui'; import useObservable from 'react-use/lib/useObservable'; import { ApplicationStart, AppNavLinkStatus, CoreStart } from '../../../core/public'; import { OverviewApp } from '.'; @@ -31,11 +31,11 @@ export function ManagementOverviewWrapper(props: ManagementOverviewProps) { return ( - +

-
+ {availableApps?.map((app) => ( diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap index 45deb7c2f2f4..911839e621b6 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/__snapshots__/saved_objects_table.test.tsx.snap @@ -837,15 +837,21 @@ exports[`SavedObjectsTable export should allow the user to choose when exporting > - + +

+ +

+
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap index 4329f6164ff8..e0d7f197c88e 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/flyout.test.tsx.snap @@ -8,8 +8,8 @@ exports[`Flyout conflicts should allow conflict resolution 1`] = ` -

-
+
-

-
+ -

-
+ @@ -692,8 +692,8 @@ exports[`Flyout should render import step 1`] = ` -

-
+
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap index 43cf0823c827..2ec785fe8dec 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/__snapshots__/relationships.test.tsx.snap @@ -7,14 +7,14 @@ exports[`Relationships from legacy app should render dashboards normally 1`] = ` -

MyDashboard

-
+
@@ -155,14 +155,14 @@ exports[`Relationships from legacy app should render errors 1`] = ` -

MyDashboard

-
+
-

MyIndexPattern*

-
+
@@ -343,14 +343,14 @@ exports[`Relationships from legacy app should render searches normally 1`] = ` -

MySearch

-
+
@@ -496,14 +496,14 @@ exports[`Relationships from legacy app should render visualizations normally 1`] -

MyViz

-
+
@@ -644,14 +644,14 @@ exports[`Relationships should render augment-vis objects normally 1`] = ` -

MyAugmentVisObject

-
+
@@ -778,14 +778,14 @@ exports[`Relationships should render dashboards normally 1`] = ` -

MyDashboard

-
+
@@ -926,14 +926,14 @@ exports[`Relationships should render errors 1`] = ` -

MyDashboard

-
+
-

MyIndexPattern*

-
+
@@ -1114,14 +1114,14 @@ exports[`Relationships should render searches normally 1`] = ` -

MySearch

-
+
@@ -1267,14 +1267,14 @@ exports[`Relationships should render visualizations normally 1`] = ` -

MyViz

-
+
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx index 074a185390cc..38226d85399a 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/flyout.tsx @@ -1027,14 +1027,14 @@ export class Flyout extends Component { return ( - +

-
+
diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx index 0115111da0ac..480cb9758c12 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/components/relationships.tsx @@ -30,7 +30,6 @@ import React, { Component } from 'react'; import { - EuiTitle, EuiFlyout, EuiFlyoutBody, EuiFlyoutHeader, @@ -336,9 +335,9 @@ export class Relationships extends Component - +

{savedObject.meta.title || getDefaultTitle(savedObject)}

-
+
{this.renderRelationships()} diff --git a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx index 7b9f1a09b49b..76d2ce72c51c 100644 --- a/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx +++ b/src/plugins/saved_objects_management/public/management_section/objects_table/saved_objects_table.tsx @@ -960,13 +960,17 @@ export class SavedObjectsTable extends Component - + +

+ +

+
From 389fd28b3441df1d48aa49c11f9bcd1fea887289 Mon Sep 17 00:00:00 2001 From: SuZhou-Joe Date: Fri, 9 Aug 2024 08:37:01 +0800 Subject: [PATCH 21/32] [navigation]feat: make parent item unclickable and fix duplicate items in landing page. (#7619) * feat: make parent item unclickable Signed-off-by: SuZhou-Joe * Changeset file for PR #7619 created/updated * feat: do not show parent item in landing page Signed-off-by: SuZhou-Joe * fix: unit test Signed-off-by: SuZhou-Joe * temp: save Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: update Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * fix: nav group not reflected when switching to analytics workspace Signed-off-by: SuZhou-Joe * feat: optimize code Signed-off-by: SuZhou-Joe * feat: optimize code based on comment Signed-off-by: SuZhou-Joe * feat: optimize code based on 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> --- changelogs/fragments/7619.yml | 2 + src/core/public/chrome/index.ts | 2 +- .../chrome/nav_group/nav_group_service.ts | 29 +++--- ...ollapsible_nav_group_enabled.test.tsx.snap | 71 ++++++++++----- .../header/collapsible_nav_group_enabled.scss | 31 +++++-- .../collapsible_nav_group_enabled.test.tsx | 11 ++- .../header/collapsible_nav_group_enabled.tsx | 43 +++++++-- src/core/public/chrome/utils.test.ts | 21 +++-- src/core/public/chrome/utils.ts | 67 +++++++++++--- src/core/public/index.ts | 4 + src/plugins/management/public/plugin.ts | 88 ++++++++++++++----- src/plugins/workspace/public/plugin.ts | 6 +- 12 files changed, 279 insertions(+), 96 deletions(-) create mode 100644 changelogs/fragments/7619.yml diff --git a/changelogs/fragments/7619.yml b/changelogs/fragments/7619.yml new file mode 100644 index 000000000000..4ad2ddcee19b --- /dev/null +++ b/changelogs/fragments/7619.yml @@ -0,0 +1,2 @@ +feat: +- Make parent item unclickable and fix duplicate items in landing page. ([#7619](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7619)) \ No newline at end of file diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index d37f14dc9e8a..5347266d9c33 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -60,4 +60,4 @@ export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; export { RightNavigationOrder } from './constants'; export { ChromeRegistrationNavLink, ChromeNavGroupUpdater, NavGroupItemInMap } from './nav_group'; -export { fulfillRegistrationLinksToChromeNavLinks } from './utils'; +export { fulfillRegistrationLinksToChromeNavLinks, LinkItemType, getSortedNavLinks } from './utils'; diff --git a/src/core/public/chrome/nav_group/nav_group_service.ts b/src/core/public/chrome/nav_group/nav_group_service.ts index 883eceacb871..88689d88f2fc 100644 --- a/src/core/public/chrome/nav_group/nav_group_service.ts +++ b/src/core/public/chrome/nav_group/nav_group_service.ts @@ -14,11 +14,7 @@ import { import { map, switchMap, takeUntil } from 'rxjs/operators'; import { i18n } from '@osd/i18n'; import { IUiSettingsClient } from '../../ui_settings'; -import { - flattenLinksOrCategories, - fulfillRegistrationLinksToChromeNavLinks, - getOrderedLinksOrCategories, -} from '../utils'; +import { fulfillRegistrationLinksToChromeNavLinks, getSortedNavLinks } from '../utils'; import { ChromeNavLinks } from '../nav_links'; import { InternalApplicationStart } from '../../application'; import { NavGroupStatus } from '../../../../core/types'; @@ -117,10 +113,8 @@ export class ChromeNavGroupService { navGroup: NavGroupItemInMap, allValidNavLinks: Array> ) { - return flattenLinksOrCategories( - getOrderedLinksOrCategories( - fulfillRegistrationLinksToChromeNavLinks(navGroup.navLinks, allValidNavLinks) - ) + return getSortedNavLinks( + fulfillRegistrationLinksToChromeNavLinks(navGroup.navLinks, allValidNavLinks) ); } @@ -267,14 +261,17 @@ export class ChromeNavGroupService { if (appId && navGroupMap) { const appIdNavGroupMap = new Map>(); // iterate navGroupMap - Object.keys(navGroupMap).forEach((navGroupId) => { - navGroupMap[navGroupId].navLinks.forEach((navLink) => { - const navLinkId = navLink.id; - const navGroupSet = appIdNavGroupMap.get(navLinkId) || new Set(); - navGroupSet.add(navGroupId); - appIdNavGroupMap.set(navLinkId, navGroupSet); + Object.keys(navGroupMap) + // Nav group of Hidden status should be filtered out when counting navGroups the currentApp belongs to + .filter((navGroupId) => navGroupMap[navGroupId].status !== NavGroupStatus.Hidden) + .forEach((navGroupId) => { + navGroupMap[navGroupId].navLinks.forEach((navLink) => { + const navLinkId = navLink.id; + const navGroupSet = appIdNavGroupMap.get(navLinkId) || new Set(); + navGroupSet.add(navGroupId); + appIdNavGroupMap.set(navLinkId, navGroupSet); + }); }); - }); const navGroups = appIdNavGroupMap.get(appId); if (navGroups && navGroups.size === 1) { diff --git a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap index fd19a03cb08b..2c6835acdaeb 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/collapsible_nav_group_enabled.test.tsx.snap @@ -431,39 +431,29 @@ exports[` should render correctly 1`] = ` id="euiSideNavContent_generated-id" >
@@ -510,11 +532,11 @@ exports[` should render correctly 1`] = ` class="euiSideNavItem__items" > +
span { + flex-direction: row-reverse; + + > * { + margin-right: $euiSizeS; + margin-left: 2px; + } + } + } + + .nav-link-fake-item { + margin-top: 0; + } + + .nav-link-fake-item-button { + display: none; + } + .nav-nested-item { + margin-bottom: 4px; + + &::after { + height: unset; + } + .nav-link-item-btn { padding-left: 0; padding-right: 0; @@ -31,7 +52,7 @@ .left-navigation-wrapper { display: flex; flex-direction: column; - border-right: $ouiBorderThin; + border-right: $euiBorderThin; } .scrollable-container { diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx index 4f14100e29e3..141b3eaf5abe 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.test.tsx @@ -43,7 +43,7 @@ describe('', () => { it('should render correctly', () => { const navigateToApp = jest.fn(); const onNavItemClick = jest.fn(); - const { container, getByTestId } = render( + const { container, getByTestId, queryByTestId } = render( ', () => { expect(container).toMatchSnapshot(); expect(container.querySelectorAll('.nav-link-item-btn').length).toEqual(5); fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); - expect(navigateToApp).toBeCalledWith('pure'); + expect(navigateToApp).toBeCalledTimes(0); + // The accordion is collapsed + expect(queryByTestId('collapsibleNavAppLink-subLink')).toBeNull(); + + // Expand the accordion + fireEvent.click(getByTestId('collapsibleNavAppLink-pure')); + fireEvent.click(getByTestId('collapsibleNavAppLink-subLink')); + expect(navigateToApp).toBeCalledWith('subLink'); }); }); diff --git a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx index c0d2a87635ed..ab24309551f4 100644 --- a/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx +++ b/src/core/public/chrome/ui/header/collapsible_nav_group_enabled.tsx @@ -77,6 +77,8 @@ const titleForSeeAll = i18n.translate('core.ui.primaryNav.seeAllLabel', { defaultMessage: 'See all...', }); +const LEVEL_FOR_ROOT_ITEMS = 1; + export function NavGroups({ navLinks, suffix, @@ -114,7 +116,11 @@ export function NavGroups({ 'aria-label': link.title, }; }; - const createSideNavItem = (navLink: LinkItem, className?: string): EuiSideNavItemType<{}> => { + const createSideNavItem = ( + navLink: LinkItem, + level: number, + className?: string + ): EuiSideNavItemType<{}> => { if (navLink.itemType === LinkItemType.LINK) { if (navLink.link.title === titleForSeeAll) { const navItem = createNavItem({ @@ -135,18 +141,43 @@ export function NavGroups({ } if (navLink.itemType === LinkItemType.PARENT_LINK && navLink.link) { - return { - ...createNavItem({ link: navLink.link }), + const props = createNavItem({ link: navLink.link }); + const parentItem = { + ...props, forceOpen: true, - items: navLink.links.map((subNavLink) => createSideNavItem(subNavLink, 'nav-nested-item')), + /** + * The href and onClick should both be undefined to make parent item rendered as accordion. + */ + href: undefined, + onClick: undefined, + className: classNames(props.className, 'nav-link-parent-item'), + buttonClassName: classNames(props.buttonClassName, 'nav-link-parent-item-button'), + items: navLink.links.map((subNavLink) => + createSideNavItem(subNavLink, level + 1, 'nav-nested-item') + ), }; + /** + * OuiSideBar will never render items of first level as accordion, + * in order to display accordion, we need to render a fake parent item. + */ + if (level === LEVEL_FOR_ROOT_ITEMS) { + return { + className: 'nav-link-fake-item', + buttonClassName: 'nav-link-fake-item-button', + name: '', + items: [parentItem], + id: `fake_${props.id}`, + }; + } + + return parentItem; } if (navLink.itemType === LinkItemType.CATEGORY) { return { id: navLink.category?.id ?? '', name:
{navLink.category?.label ?? ''}
, - items: navLink.links?.map((link) => createSideNavItem(link)), + items: navLink.links?.map((link) => createSideNavItem(link, level + 1)), 'aria-label': navLink.category?.label, }; } @@ -155,7 +186,7 @@ export function NavGroups({ }; const orderedLinksOrCategories = getOrderedLinksOrCategories(navLinks); const sideNavItems = orderedLinksOrCategories - .map((navLink) => createSideNavItem(navLink)) + .map((navLink) => createSideNavItem(navLink, LEVEL_FOR_ROOT_ITEMS)) .filter((item): item is EuiSideNavItemType<{}> => !!item); return ( diff --git a/src/core/public/chrome/utils.test.ts b/src/core/public/chrome/utils.test.ts index ed163b753eba..18a027e1bed3 100644 --- a/src/core/public/chrome/utils.test.ts +++ b/src/core/public/chrome/utils.test.ts @@ -10,7 +10,7 @@ import { fulfillRegistrationLinksToChromeNavLinks, getOrderedLinks, getOrderedLinksOrCategories, - flattenLinksOrCategories, + getSortedNavLinks, } from './utils'; const mockedNonCategoryLink = { @@ -47,6 +47,15 @@ const mockedNavLinkB = { order: 5, }; +const mockedSubNavLinkA = { + id: 'sub_a', + parentNavLinkId: 'a', + title: 'sub_a', + baseUrl: '', + href: '', + order: 10, +}; + describe('getAllCategories', () => { it('should return all categories', () => { const links = { @@ -124,15 +133,15 @@ describe('getOrderedLinksOrCategories', () => { }); }); -describe('flattenLinksOrCategories', () => { +describe('getSortedNavLinks', () => { 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([ + const navLinks = [mockedNonCategoryLink, mockedNavLinkA, mockedNavLinkB, mockedSubNavLinkA]; + const sortedNavLinks = getSortedNavLinks(navLinks); + expect(sortedNavLinks.map((item) => item.id)).toEqual([ mockedNavLinkB.id, mockedNonCategoryLink.id, mockedNavLinkA.id, + mockedSubNavLinkA.id, ]); }); }); diff --git a/src/core/public/chrome/utils.ts b/src/core/public/chrome/utils.ts index 8a9dec8b2145..4def78651f2c 100644 --- a/src/core/public/chrome/utils.ts +++ b/src/core/public/chrome/utils.ts @@ -73,21 +73,36 @@ export function fulfillRegistrationLinksToChromeNavLinks( 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)); +function walkLinkItemsTree( + props: { + linkItems: LinkItem[]; + parentItem?: LinkItem; + }, + callback: (props: { currentItem: LinkItem; parentItem?: LinkItem }) => void +) { + props.linkItems.forEach((item) => { + callback({ + parentItem: props.parentItem, + currentItem: item, + }); + if (item.itemType === LinkItemType.PARENT_LINK) { + walkLinkItemsTree( + { + linkItems: item.links, + parentItem: item, + }, + callback + ); } else if (item.itemType === LinkItemType.CATEGORY) { - acc.push(...flattenLinksOrCategories(item.links || [])); + walkLinkItemsTree( + { + linkItems: item.links || [], + parentItem: item, + }, + callback + ); } - - return acc; - }, [] as ChromeNavLink[]); + }); } export const generateItemTypeByLink = ( @@ -173,3 +188,29 @@ export function getOrderedLinksOrCategories( return result.sort(sortBy('order')); } + +export const getSortedNavLinks = ( + navLinks: ChromeNavLink[], + enricher?: (currentItem: LinkItem, parentItem?: LinkItem) => LinkItem +) => { + const sortedNavLinksTree = getOrderedLinksOrCategories(navLinks); + const acc: ChromeNavLink[] = []; + walkLinkItemsTree( + { + linkItems: sortedNavLinksTree, + }, + (props) => { + const { currentItem, parentItem } = props; + const enricheredResult = enricher ? enricher(currentItem, parentItem) : currentItem; + if ( + enricheredResult.itemType === LinkItemType.LINK || + enricheredResult.itemType === LinkItemType.PARENT_LINK + ) { + if (enricheredResult.link) { + acc.push(enricheredResult.link); + } + } + } + ); + return acc; +}; diff --git a/src/core/public/index.ts b/src/core/public/index.ts index 9da3aed89736..946babb2f893 100644 --- a/src/core/public/index.ts +++ b/src/core/public/index.ts @@ -77,6 +77,8 @@ import { NavGroupItemInMap, fulfillRegistrationLinksToChromeNavLinks, createRecentNavLink, + LinkItemType, + getSortedNavLinks, } from './chrome'; import { FatalErrorsSetup, FatalErrorsStart, FatalErrorInfo } from './fatal_errors'; import { HttpSetup, HttpStart } from './http'; @@ -381,6 +383,8 @@ export { NavGroupItemInMap, fulfillRegistrationLinksToChromeNavLinks, createRecentNavLink, + LinkItemType, + getSortedNavLinks, }; export { __osdBootstrap__ } from './osd_bootstrap'; diff --git a/src/plugins/management/public/plugin.ts b/src/plugins/management/public/plugin.ts index d376feeafe7e..ef91e776a962 100644 --- a/src/plugins/management/public/plugin.ts +++ b/src/plugins/management/public/plugin.ts @@ -46,6 +46,7 @@ import { AppNavLinkStatus, DEFAULT_NAV_GROUPS, WorkspaceAvailability, + ChromeNavLink, } from '../../../core/public'; import { MANAGEMENT_APP_ID } from '../common/contants'; @@ -56,7 +57,11 @@ import { import { ManagementOverViewPluginSetup } from '../../management_overview/public'; import { toMountPoint } from '../../opensearch_dashboards_react/public'; import { SettingsIcon } from './components/settings_icon'; -import { fulfillRegistrationLinksToChromeNavLinks } from '../../../core/public'; +import { + fulfillRegistrationLinksToChromeNavLinks, + LinkItemType, + getSortedNavLinks, +} from '../../../core/public'; interface ManagementSetupDependencies { home?: HomePublicPluginSetup; @@ -118,6 +123,59 @@ export class ManagementPlugin implements Plugin { + const [coreStart] = await core.getStartServices(); + const navGroupMap = await coreStart.chrome.navGroup + .getNavGroupsMap$() + .pipe(first()) + .toPromise(); + const navLinks = navGroupMap[navGroupId]?.navLinks; + return getSortedNavLinks( + fulfillRegistrationLinksToChromeNavLinks( + navLinks || [], + coreStart.chrome.navLinks.getAll() + ).filter((item) => !item.hidden), + (currentItem, parentItem) => { + // Hide all the sub items because we will only show parent item in landing page. + if ( + currentItem.itemType === LinkItemType.LINK && + parentItem?.itemType === LinkItemType.PARENT_LINK + ) { + return { + ...currentItem, + link: { + ...currentItem.link, + hidden: true, + }, + }; + } + + /** + * Jump to first sub items when click on parent item in landing page + */ + if (currentItem.itemType === LinkItemType.PARENT_LINK) { + let payload = currentItem.link; + if (payload) { + if (currentItem.links?.[0].itemType === LinkItemType.LINK) { + payload = { + ...payload, + ...currentItem.links?.[0].link, + title: payload.title, + }; + } + } + + return { + ...currentItem, + link: payload, + }; + } + + return currentItem; + } + ).filter((navLink) => !navLink.hidden); + }; + core.application.register({ id: settingsLandingPageId, title: settingsLandingPageTitle, @@ -129,21 +187,15 @@ export class ManagementPlugin implements Plugin { const { renderApp } = await import('./landing_page_application'); const [coreStart] = await core.getStartServices(); - const navGroupMap = await coreStart.chrome.navGroup - .getNavGroupsMap$() - .pipe(first()) - .toPromise(); - const navLinks = navGroupMap[DEFAULT_NAV_GROUPS.settingsAndSetup.id]?.navLinks; - const fulfilledNavLink = fulfillRegistrationLinksToChromeNavLinks( - navLinks || [], - coreStart.chrome.navLinks.getAll() - ).filter((navLink) => navLink.id !== settingsLandingPageId && !navLink.hidden); + const navLinks = ( + await getNavLinksByNavGroupId(DEFAULT_NAV_GROUPS.settingsAndSetup.id) + ).filter((navLink) => navLink.id !== settingsLandingPageId); return renderApp({ mountElement: params.element, props: { navigateToApp: coreStart.application.navigateToApp, - navLinks: fulfilledNavLink, + navLinks, pageTitle: settingsLandingPageTitle, getStartedCards: [], }, @@ -162,21 +214,15 @@ export class ManagementPlugin implements Plugin { const { renderApp } = await import('./landing_page_application'); const [coreStart] = await core.getStartServices(); - const navGroupMap = await coreStart.chrome.navGroup - .getNavGroupsMap$() - .pipe(first()) - .toPromise(); - const navLinks = navGroupMap[DEFAULT_NAV_GROUPS.dataAdministration.id]?.navLinks; - const fulfilledNavLink = fulfillRegistrationLinksToChromeNavLinks( - navLinks || [], - coreStart.chrome.navLinks.getAll() - ).filter((navLink) => navLink.id !== dataAdministrationLandingPageId && !navLink.hidden); + const navLinks = ( + await getNavLinksByNavGroupId(DEFAULT_NAV_GROUPS.dataAdministration.id) + ).filter((navLink) => navLink.id !== dataAdministrationLandingPageId); return renderApp({ mountElement: params.element, props: { navigateToApp: coreStart.application.navigateToApp, - navLinks: fulfilledNavLink, + navLinks, pageTitle: dataAdministrationPageTitle, getStartedCards: [], }, diff --git a/src/plugins/workspace/public/plugin.ts b/src/plugins/workspace/public/plugin.ts index 5a208d33c0fc..de7efe10eb00 100644 --- a/src/plugins/workspace/public/plugin.ts +++ b/src/plugins/workspace/public/plugin.ts @@ -150,15 +150,15 @@ export class WorkspacePlugin /** * The following logic determines whether a navigation group should be hidden or not based on the workspace's feature configurations. * It checks the following conditions: - * 1. The navigation group is not a system-level group (system groups are always visible). + * 1. The navigation group is not a system-level group (system groups are always visible except all use case). * 2. The current workspace has feature configurations set up. - * 3. The current workspace's use case it not "All use case". + * 3. The current workspace's use case is not "All use case". * 4. The current navigation group is not included in the feature configurations of the workspace. * * If all these conditions are true, it means that the navigation group should be hidden. */ if ( - navGroup.type !== NavGroupType.SYSTEM && + (navGroup.type !== NavGroupType.SYSTEM || navGroup.id === ALL_USE_CASE_ID) && currentWorkspace.features && getFirstUseCaseOfFeatureConfigs(currentWorkspace.features) !== ALL_USE_CASE_ID && !isNavGroupInFeatureConfigs(navGroup.id, currentWorkspace.features) From 1bf63e36dc534c404d5e482fffaefa3eab19c1c8 Mon Sep 17 00:00:00 2001 From: Yulong Ruan Date: Sat, 10 Aug 2024 12:18:58 +0800 Subject: [PATCH 22/32] feat: provide new embeddable option to hide embeddable panel action button (#7503) * feat: provide new embeddable option to hide embeddable panel action button Signed-off-by: Yulong Ruan * Changeset file for PR #7503 created/updated * cleanup unnecessary getInput() calls Signed-off-by: Yulong Ruan --------- Signed-off-by: Yulong Ruan Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7503.yml | 2 + src/plugins/embeddable/common/types.ts | 1 + .../lib/panel/embeddable_panel.test.tsx | 53 +++++++++++++++++++ .../public/lib/panel/embeddable_panel.tsx | 23 +++++--- .../lib/panel/panel_header/panel_header.tsx | 30 ++++++----- 5 files changed, 91 insertions(+), 18 deletions(-) create mode 100644 changelogs/fragments/7503.yml diff --git a/changelogs/fragments/7503.yml b/changelogs/fragments/7503.yml new file mode 100644 index 000000000000..5f33fce038f7 --- /dev/null +++ b/changelogs/fragments/7503.yml @@ -0,0 +1,2 @@ +feat: +- Provide new embeddable option to hide embeddable panel action button ([#7503](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7503)) \ No newline at end of file diff --git a/src/plugins/embeddable/common/types.ts b/src/plugins/embeddable/common/types.ts index c513d2dcea04..7c2a04a6b2ec 100644 --- a/src/plugins/embeddable/common/types.ts +++ b/src/plugins/embeddable/common/types.ts @@ -48,6 +48,7 @@ export type EmbeddableInput = { id: string; lastReloadRequestTime?: number; hidePanelTitles?: boolean; + hidePanelActions?: boolean; /** * Reserved key for enhancements added by other plugins. diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx index 4db6b30c9b57..a1480177e0ab 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.test.tsx @@ -407,6 +407,59 @@ test('Updates when hidePanelTitles is toggled', async () => { expect(title.length).toBe(1); }); +test('Updates when hidePanelActions is toggled', async () => { + const inspector = inspectorPluginMock.createStartContract(); + + const container = new HelloWorldContainer( + { id: '123', panels: {}, viewMode: ViewMode.VIEW, hidePanelActions: false }, + { getEmbeddableFactory } as any + ); + + const embeddable = await container.addNewEmbeddable< + ContactCardEmbeddableInput, + ContactCardEmbeddableOutput, + ContactCardEmbeddable + >(CONTACT_CARD_EMBEDDABLE, { + firstName: 'Rob', + lastName: 'Stark', + }); + + const component = mount( + + Promise.resolve([])} + getAllEmbeddableFactories={start.getEmbeddableFactories} + getEmbeddableFactory={start.getEmbeddableFactory} + notifications={{} as any} + overlays={{} as any} + application={applicationMock} + inspector={inspector} + SavedObjectFinder={() => null} + /> + + ); + + let actionButton = findTestSubject(component, 'embeddablePanelToggleMenuIcon'); + expect(actionButton.length).toBe(1); + + container.updateInput({ hidePanelActions: true }); + + await nextTick(); + component.update(); + + actionButton = findTestSubject(component, 'embeddablePanelToggleMenuIcon'); + expect(actionButton.length).toBe(0); + + container.updateInput({ hidePanelActions: false }); + + await nextTick(); + component.update(); + + actionButton = findTestSubject(component, 'embeddablePanelToggleMenuIcon'); + expect(actionButton.length).toBe(1); +}); + test('Check when hide header option is false', async () => { const inspector = inspectorPluginMock.createStartContract(); diff --git a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx index 5a340d3a701c..02ebf8b7f567 100644 --- a/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx +++ b/src/plugins/embeddable/public/lib/panel/embeddable_panel.tsx @@ -94,6 +94,7 @@ interface State { focusedPanelIndex?: string; viewMode: ViewMode; hidePanelTitle: boolean; + hidePanelAction: boolean; closeContextMenu: boolean; badges: Array>; notifications: Array>; @@ -112,15 +113,19 @@ export class EmbeddablePanel extends React.Component { constructor(props: Props) { super(props); const { embeddable } = this.props; - const viewMode = embeddable.getInput().viewMode ?? ViewMode.EDIT; - const hidePanelTitle = - Boolean(embeddable.parent?.getInput()?.hidePanelTitles) || - Boolean(embeddable.getInput()?.hidePanelTitles); + const input = embeddable.getInput(); + const parentInput = embeddable.parent?.getInput(); + + const viewMode = input?.viewMode ?? ViewMode.EDIT; + const hidePanelTitle = Boolean(parentInput?.hidePanelTitles) || Boolean(input?.hidePanelTitles); + const hidePanelAction = + Boolean(parentInput?.hidePanelActions) || Boolean(input?.hidePanelActions); this.state = { panels: [], viewMode, hidePanelTitle, + hidePanelAction, closeContextMenu: false, badges: [], notifications: [], @@ -182,10 +187,15 @@ export class EmbeddablePanel extends React.Component { if (parent) { this.parentSubscription = parent.getInput$().subscribe(async () => { if (this.mounted && parent) { + const input = embeddable.getInput(); + const parentInput = parent.getInput(); this.setState({ hidePanelTitle: - Boolean(embeddable.parent?.getInput()?.hidePanelTitles) || - Boolean(embeddable.getInput()?.hidePanelTitles), + Boolean(parentInput?.hidePanelTitles) || Boolean(input?.hidePanelTitles), + }); + this.setState({ + hidePanelAction: + Boolean(parentInput?.hidePanelActions) || Boolean(input?.hidePanelActions), }); this.refreshBadges(); @@ -245,6 +255,7 @@ export class EmbeddablePanel extends React.Component { Promise; closeContextMenu: boolean; badges: Array>; @@ -129,6 +130,7 @@ export function PanelHeader({ title, isViewMode, hidePanelTitle, + hidePanelAction, getActionContextMenuPanel, closeContextMenu, badges, @@ -166,12 +168,14 @@ export function PanelHeader({ if (!showPanelBar) { return (
- + {!hidePanelAction && ( + + )} {getAriaLabel()}
); @@ -210,12 +214,14 @@ export function PanelHeader({ {renderBadges(badges, embeddable)} {renderNotifications(notifications, embeddable)} - + {!hidePanelAction && ( + + )} ); } From 248c8ff0c3ef598c45f140bdf70f8964e6da2aa2 Mon Sep 17 00:00:00 2001 From: Lin Wang Date: Mon, 12 Aug 2024 15:43:38 +0800 Subject: [PATCH 23/32] [Workspace]Fix page crash caused by invalid workspace color (#7671) * Add validation for workspace color Signed-off-by: Lin Wang * Changeset file for PR #7671 created/updated --------- Signed-off-by: Lin Wang Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7671.yml | 2 + .../workspace/common/__tests__/utils.test.ts | 38 ++++++++++++++++ src/plugins/workspace/common/utils.ts | 8 ++++ .../public/components/workspace_form/types.ts | 1 + .../components/workspace_form/utils.test.ts | 44 +++++++++++++++++++ .../public/components/workspace_form/utils.ts | 17 ++++++- .../workspace_form_error_callout.test.tsx | 13 ++++++ .../workspace_form_error_callout.tsx | 12 +++++ .../workspace_menu/workspace_menu.tsx | 10 +++-- src/plugins/workspace/server/routes/index.ts | 11 ++++- 10 files changed, 151 insertions(+), 5 deletions(-) create mode 100644 changelogs/fragments/7671.yml create mode 100644 src/plugins/workspace/common/__tests__/utils.test.ts create mode 100644 src/plugins/workspace/common/utils.ts diff --git a/changelogs/fragments/7671.yml b/changelogs/fragments/7671.yml new file mode 100644 index 000000000000..8d88fec48eee --- /dev/null +++ b/changelogs/fragments/7671.yml @@ -0,0 +1,2 @@ +fix: +- [Workspace]Fix page crash caused by invalid workspace color ([#7671](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7671)) \ No newline at end of file diff --git a/src/plugins/workspace/common/__tests__/utils.test.ts b/src/plugins/workspace/common/__tests__/utils.test.ts new file mode 100644 index 000000000000..c9369fe29f6b --- /dev/null +++ b/src/plugins/workspace/common/__tests__/utils.test.ts @@ -0,0 +1,38 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { validateWorkspaceColor } from '../utils'; + +describe('validateWorkspaceColor', () => { + it('should return true for a valid 6-digit hex color code', () => { + expect(validateWorkspaceColor('#ABCDEF')).toBe(true); + expect(validateWorkspaceColor('#123456')).toBe(true); + }); + + it('should return true for a valid 3-digit hex color code', () => { + expect(validateWorkspaceColor('#ABC')).toBe(true); + expect(validateWorkspaceColor('#DEF')).toBe(true); + }); + + it('should return false for an invalid color code', () => { + expect(validateWorkspaceColor('#GHI')).toBe(false); + expect(validateWorkspaceColor('#12345')).toBe(false); + expect(validateWorkspaceColor('#ABCDEFG')).toBe(false); + expect(validateWorkspaceColor('ABCDEF')).toBe(false); + }); + + it('should return false for an empty string', () => { + expect(validateWorkspaceColor('')).toBe(false); + }); + + it('should return false for undefined', () => { + expect(validateWorkspaceColor()).toBe(false); + }); + + it('should be case-insensitive', () => { + expect(validateWorkspaceColor('#abcdef')).toBe(true); + expect(validateWorkspaceColor('#ABC')).toBe(true); + }); +}); diff --git a/src/plugins/workspace/common/utils.ts b/src/plugins/workspace/common/utils.ts new file mode 100644 index 000000000000..8cc67a44644e --- /dev/null +++ b/src/plugins/workspace/common/utils.ts @@ -0,0 +1,8 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +// Reference https://github.com/opensearch-project/oui/blob/main/src/services/color/is_valid_hex.ts +export const validateWorkspaceColor = (color?: string) => + !!color && /(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(color); diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index 0e2ab1631fc9..7b83bc36ddd3 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -55,6 +55,7 @@ export enum WorkspaceFormErrorCode { PermissionSettingOwnerMissing, InvalidDataSource, DuplicateDataSource, + InvalidColor, } export interface WorkspaceFormError { diff --git a/src/plugins/workspace/public/components/workspace_form/utils.test.ts b/src/plugins/workspace/public/components/workspace_form/utils.test.ts index 3256f255b0a4..3a45165044d7 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.test.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.test.ts @@ -179,6 +179,12 @@ describe('validateWorkspaceForm', () => { message: 'Name is invalid. Enter a valid name.', }); }); + it('should return error if color is invalid', () => { + expect(validateWorkspaceForm({ color: 'QWERTY' }, false).color).toEqual({ + code: WorkspaceFormErrorCode.InvalidColor, + message: 'Color is invalid. Enter a valid color.', + }); + }); it('should return error if use case is empty', () => { expect(validateWorkspaceForm({}, false).features).toEqual({ code: WorkspaceFormErrorCode.UseCaseMissing, @@ -393,6 +399,18 @@ describe('getNumberOfErrors', () => { }) ).toEqual(1); }); + + it('should return consistent color errors count', () => { + expect( + getNumberOfErrors({ + name: { + code: WorkspaceFormErrorCode.InvalidColor, + message: '', + }, + }) + ).toEqual(1); + }); + it('should return consistent permission settings errors count', () => { expect( getNumberOfErrors({ @@ -461,6 +479,32 @@ describe('getNumberOfChanges', () => { ) ).toEqual(1); }); + it('should return consistent color changes count', () => { + expect( + getNumberOfChanges( + { + name: 'foo', + color: '#000', + }, + { + name: 'foo', + color: '#000', + } + ) + ).toEqual(0); + expect( + getNumberOfChanges( + { + name: 'foo', + color: '#000', + }, + { + name: 'foo', + color: '#001', + } + ) + ).toEqual(1); + }); it('should return consistent features changes count', () => { expect( getNumberOfChanges( diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index 7588178d8c94..2ea76756d3d8 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -30,6 +30,7 @@ import { WorkspaceUserPermissionSetting, } from './types'; import { DataSource } from '../../../common/types'; +import { validateWorkspaceColor } from '../../../common/utils'; export const appendDefaultFeatureIds = (ids: string[]) => { // concat default checked ids and unique the result @@ -62,6 +63,9 @@ export const getNumberOfErrors = (formErrors: WorkspaceFormErrors) => { if (formErrors.features) { numberOfErrors += 1; } + if (formErrors.color) { + numberOfErrors += 1; + } return numberOfErrors; }; @@ -308,7 +312,7 @@ export const validateWorkspaceForm = ( isPermissionEnabled: boolean ) => { const formErrors: WorkspaceFormErrors = {}; - const { name, permissionSettings, features, selectedDataSources } = formData; + const { name, permissionSettings, color, features, selectedDataSources } = formData; if (name && name.trim()) { if (!isValidFormTextInput(name)) { formErrors.name = { @@ -334,6 +338,14 @@ export const validateWorkspaceForm = ( }), }; } + if (color && !validateWorkspaceColor(color)) { + formErrors.color = { + code: WorkspaceFormErrorCode.InvalidColor, + message: i18n.translate('workspace.form.features.empty', { + defaultMessage: 'Color is invalid. Enter a valid color.', + }), + }; + } if (isPermissionEnabled) { formErrors.permissionSettings = validatePermissionSetting(permissionSettings); } @@ -463,6 +475,9 @@ export const getNumberOfChanges = ( if (newFormData.description !== initialFormData.description) { count++; } + if (newFormData.color !== initialFormData.color) { + count++; + } if ( newFormData.features?.length !== initialFormData.features?.length || newFormData.features?.some((item) => !initialFormData.features?.includes(item)) diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.test.tsx index e248bf202257..d8a582ce97eb 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.test.tsx @@ -48,6 +48,19 @@ describe('WorkspaceFormErrorCallout', () => { expect(renderResult.getByText('Name: Enter a valid name.')).toBeInTheDocument(); }); + it('should render color suggestion', () => { + const { renderResult } = setup({ + errors: { + color: { + code: WorkspaceFormErrorCode.InvalidColor, + message: '', + }, + }, + }); + + expect(renderResult.getByText('Color: Enter a valid color.')).toBeInTheDocument(); + }); + it('should render use case suggestion', () => { const { renderResult } = setup({ errors: { diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.tsx index e7388f1909b3..1d90235ed82c 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form_error_callout.tsx @@ -42,6 +42,10 @@ const getSuggestionFromErrorCode = (error: WorkspaceFormError) => { return i18n.translate('workspace.form.errorCallout.permissionSettingOwnerMissing', { defaultMessage: 'Add a workspace owner.', }); + case WorkspaceFormErrorCode.InvalidColor: + return i18n.translate('workspace.form.errorCallout.invalidColor', { + defaultMessage: 'Enter a valid color.', + }); default: return error.message; } @@ -106,6 +110,14 @@ export const WorkspaceFormErrorCallout = ({ errors }: WorkspaceFormErrorCalloutP message={getSuggestionFromErrorCode(errors.name)} /> )} + {errors.color && ( + + )} {errors.features && ( + validateWorkspaceColor(color) ? color : undefined; + interface Props { coreStart: CoreStart; registeredUseCases$: BehaviorSubject; @@ -111,7 +115,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { size="s" type="space" name={currentWorkspace.name} - color={currentWorkspace.color} + color={getValidWorkspaceColor(currentWorkspace.color)} initialsLength={2} /> @@ -148,7 +152,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { size="s" type="space" name={workspace.name} - color={workspace.color} + color={getValidWorkspaceColor(workspace.color)} initialsLength={2} /> } @@ -196,7 +200,7 @@ export const WorkspaceMenu = ({ coreStart, registeredUseCases$ }: Props) => { size="m" type="space" name={currentWorkspaceName} - color={currentWorkspace?.color} + color={getValidWorkspaceColor(currentWorkspace?.color)} initialsLength={2} />
diff --git a/src/plugins/workspace/server/routes/index.ts b/src/plugins/workspace/server/routes/index.ts index b57b0a529d1c..4676a743f9d7 100644 --- a/src/plugins/workspace/server/routes/index.ts +++ b/src/plugins/workspace/server/routes/index.ts @@ -10,6 +10,7 @@ import { IWorkspaceClientImpl, WorkspaceAttributeWithPermission } from '../types import { SavedObjectsPermissionControlContract } from '../permission_control/client'; import { registerDuplicateRoute } from './duplicate'; import { transferCurrentUserInPermissions } from '../utils'; +import { validateWorkspaceColor } from '../../common/utils'; export const WORKSPACES_API_BASE_URL = '/api/workspaces'; @@ -40,7 +41,15 @@ const settingsSchema = schema.object({ const workspaceOptionalAttributesSchema = { description: schema.maybe(schema.string()), features: schema.maybe(schema.arrayOf(schema.string())), - color: schema.maybe(schema.string()), + color: schema.maybe( + schema.string({ + validate: (color) => { + if (!validateWorkspaceColor(color)) { + return 'invalid workspace color format'; + } + }, + }) + ), icon: schema.maybe(schema.string()), defaultVISTheme: schema.maybe(schema.string()), reserved: schema.maybe(schema.boolean()), From cd33e15933fb81a28cb7c1dd6bfd4bfb0e6e3cca Mon Sep 17 00:00:00 2001 From: Jincheng Wan <45655760+Kapian1234@users.noreply.github.com> Date: Mon, 12 Aug 2024 17:02:08 +0800 Subject: [PATCH 24/32] [Workspace] Set the default color to first color from the palette (#7627) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * src/plugins/workspace/public/components/workspace_form/use_workspace_form.ts Signed-off-by: Kapian1234 * Changeset file for PR #7627 created/updated * Separated a new WorkspaceDetailedFormProps for WorkspaceDetailedForm, and updated the type definition of getNumberOfChanges to support partial form data Signed-off-by: Kapian1234 * Separated defaultWorkspaceFormValues to a constant Signed-off-by: Kapian1234 * Changeset file for PR #7627 created/updated * Modified code format Signed-off-by: Kapian1234 --------- Signed-off-by: Kapian1234 Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7627.yml | 2 ++ .../workspace_creator/workspace_creator.tsx | 15 ++++++++++++++- .../public/components/workspace_form/types.ts | 6 +++++- .../public/components/workspace_form/utils.ts | 4 ++-- .../workspace_form/workspace_detail_form.tsx | 4 ++-- 5 files changed, 25 insertions(+), 6 deletions(-) create mode 100644 changelogs/fragments/7627.yml diff --git a/changelogs/fragments/7627.yml b/changelogs/fragments/7627.yml new file mode 100644 index 000000000000..8b5a7e830062 --- /dev/null +++ b/changelogs/fragments/7627.yml @@ -0,0 +1,2 @@ +feat: +- [Workspace] Set default color for workspace create form ([#7627](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7627)) \ No newline at end of file diff --git a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx index 88c46e973c00..c46d17b701ac 100644 --- a/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx +++ b/src/plugins/workspace/public/components/workspace_creator/workspace_creator.tsx @@ -4,7 +4,13 @@ */ import React, { useCallback } from 'react'; -import { EuiPage, EuiPageBody, EuiPageHeader, EuiPageContent } from '@elastic/eui'; +import { + EuiPage, + EuiPageBody, + EuiPageHeader, + EuiPageContent, + euiPaletteColorBlind, +} from '@elastic/eui'; import { i18n } from '@osd/i18n'; import { useObservable } from 'react-use'; import { BehaviorSubject } from 'rxjs'; @@ -18,6 +24,7 @@ import { convertPermissionSettingsToPermissions } from '../workspace_form'; import { DataSource } from '../../../common/types'; import { DataSourceManagementPluginSetup } from '../../../../../plugins/data_source_management/public'; import { WorkspaceUseCase } from '../../types'; +import { WorkspaceFormData } from '../workspace_form/types'; export interface WorkspaceCreatorProps { registeredUseCases$: BehaviorSubject; @@ -37,6 +44,11 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { workspaceClient: WorkspaceClient; dataSourceManagement?: DataSourceManagementPluginSetup; }>(); + + const defaultWorkspaceFormValues: Partial = { + color: euiPaletteColorBlind()[0], + }; + const isPermissionEnabled = application?.capabilities.workspaces.permissionEnabled; const availableUseCases = useObservable(props.registeredUseCases$, []); @@ -107,6 +119,7 @@ export const WorkspaceCreator = (props: WorkspaceCreatorProps) => { permissionEnabled={isPermissionEnabled} dataSourceManagement={dataSourceManagement} availableUseCases={availableUseCases} + defaultValues={defaultWorkspaceFormValues} /> )} diff --git a/src/plugins/workspace/public/components/workspace_form/types.ts b/src/plugins/workspace/public/components/workspace_form/types.ts index 7b83bc36ddd3..cbcf7e8ded26 100644 --- a/src/plugins/workspace/public/components/workspace_form/types.ts +++ b/src/plugins/workspace/public/components/workspace_form/types.ts @@ -80,10 +80,14 @@ export interface WorkspaceFormProps { application: ApplicationStart; savedObjects: SavedObjectsStart; onSubmit?: (formData: WorkspaceFormSubmitData) => void; - defaultValues?: WorkspaceFormData; + defaultValues?: Partial; operationType: WorkspaceOperationType; permissionEnabled?: boolean; detailTab?: DetailTab; dataSourceManagement?: DataSourceManagementPluginSetup; availableUseCases: WorkspaceUseCase[]; } + +export interface WorkspaceDetailedFormProps extends WorkspaceFormProps { + defaultValues?: WorkspaceFormData; +} diff --git a/src/plugins/workspace/public/components/workspace_form/utils.ts b/src/plugins/workspace/public/components/workspace_form/utils.ts index 2ea76756d3d8..a1a340a78db6 100644 --- a/src/plugins/workspace/public/components/workspace_form/utils.ts +++ b/src/plugins/workspace/public/components/workspace_form/utils.ts @@ -461,12 +461,12 @@ const isSamePermissionSetting = (a: PermissionSettingLike, b: PermissionSettingL }; export const getNumberOfChanges = ( - newFormData: Omit, 'permissionSettings'> & { + newFormData: Partial> & { permissionSettings?: Array< Pick & Partial >; }, - initialFormData: Omit + initialFormData: Partial> ) => { let count = 0; if (newFormData.name !== initialFormData.name) { diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx index 3834e7a23628..90ea5ed193ed 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx @@ -8,7 +8,7 @@ import React, { useRef } from 'react'; import { EuiPanel, EuiSpacer, EuiForm, EuiFlexGroup, EuiFlexItem, EuiTitle } from '@elastic/eui'; import { WorkspaceBottomBar } from './workspace_bottom_bar'; -import { WorkspaceFormProps } from './types'; +import { WorkspaceDetailedFormProps } from './types'; import { useWorkspaceForm } from './use_workspace_form'; import { WorkspaceUseCase } from './workspace_use_case'; import { WorkspacePermissionSettingPanel } from './workspace_permission_setting_panel'; @@ -41,7 +41,7 @@ const FormGroup = ({ title, children }: FormGroupProps) => ( ); -export const WorkspaceDetailForm = (props: WorkspaceFormProps) => { +export const WorkspaceDetailForm = (props: WorkspaceDetailedFormProps) => { const { detailTab, application, From f59c2b969cf001cab4c27b17abfb7b0939caa066 Mon Sep 17 00:00:00 2001 From: Tianyu Gao Date: Mon, 12 Aug 2024 17:46:29 +0800 Subject: [PATCH 25/32] [Workspace] feat: only allow essential use case when creating workspace if all data sources are serverless (#7612) * feat: only allow essential use case when all data sources are serverless Signed-off-by: tygao * Changeset file for PR #7612 created/updated * update and only keep workspace create case Signed-off-by: tygao * Changeset file for PR #7612 created/updated * remove useless disabled logic Signed-off-by: tygao * use useMemo Signed-off-by: tygao * update filter and tests to update with essiential use case change Signed-off-by: tygao --------- Signed-off-by: tygao Co-authored-by: opensearch-changeset-bot[bot] <154024398+opensearch-changeset-bot[bot]@users.noreply.github.com> --- changelogs/fragments/7612.yml | 2 + .../data_source/common/data_sources/types.ts | 1 + .../workspace/opensearch_dashboards.json | 2 +- .../workspace_form/workspace_detail_form.tsx | 2 + .../workspace_form/workspace_form.tsx | 2 + .../workspace_use_case.test.tsx | 22 ++++++- .../workspace_form/workspace_use_case.tsx | 63 +++++++++++++------ src/plugins/workspace/public/utils.test.ts | 57 ++++++++++++++++- src/plugins/workspace/public/utils.ts | 16 ++++- 9 files changed, 144 insertions(+), 23 deletions(-) create mode 100644 changelogs/fragments/7612.yml diff --git a/changelogs/fragments/7612.yml b/changelogs/fragments/7612.yml new file mode 100644 index 000000000000..16e3601f9389 --- /dev/null +++ b/changelogs/fragments/7612.yml @@ -0,0 +1,2 @@ +feat: +- Only allow essential use case when creating workspace if all data sources are serverless ([#7612](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7612)) \ No newline at end of file diff --git a/src/plugins/data_source/common/data_sources/types.ts b/src/plugins/data_source/common/data_sources/types.ts index cde21f648c61..32432def6515 100644 --- a/src/plugins/data_source/common/data_sources/types.ts +++ b/src/plugins/data_source/common/data_sources/types.ts @@ -47,6 +47,7 @@ export enum AuthType { SigV4 = 'sigv4', } +// src/plugins/workspace/public/utils.ts Workspace plugin depends on this to do use case limitation. export enum SigV4ServiceName { OpenSearch = 'es', OpenSearchServerless = 'aoss', diff --git a/src/plugins/workspace/opensearch_dashboards.json b/src/plugins/workspace/opensearch_dashboards.json index 99a66fb1743a..d0b0e0b55ea8 100644 --- a/src/plugins/workspace/opensearch_dashboards.json +++ b/src/plugins/workspace/opensearch_dashboards.json @@ -8,5 +8,5 @@ "opensearchDashboardsReact" ], "optionalPlugins": ["savedObjectsManagement","management","dataSourceManagement","contentManagement"], - "requiredBundles": ["opensearchDashboardsReact", "home"] + "requiredBundles": ["opensearchDashboardsReact", "home","dataSource"] } diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx index 90ea5ed193ed..b4b42743b410 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_detail_form.tsx @@ -113,6 +113,8 @@ export const WorkspaceDetailForm = (props: WorkspaceDetailedFormProps) => { onChange={handleUseCaseChange} formErrors={formErrors} availableUseCases={availableUseCases} + savedObjects={savedObjects} + operationType={operationType} /> diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx index c7e319b48435..70d2a34d7585 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_form.tsx @@ -89,6 +89,8 @@ export const WorkspaceForm = (props: WorkspaceFormProps) => { onChange={handleUseCaseChange} formErrors={formErrors} availableUseCases={availableUseCases} + savedObjects={savedObjects} + operationType={operationType} /> diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx index 3eef13c15436..83bd8482f7d4 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.test.tsx @@ -4,14 +4,23 @@ */ import React from 'react'; -import { fireEvent, render } from '@testing-library/react'; +import { fireEvent, render, waitFor } from '@testing-library/react'; import { WORKSPACE_USE_CASES } from '../../../common/constants'; import { WorkspaceUseCase, WorkspaceUseCaseProps } from './workspace_use_case'; import { WorkspaceFormErrors } from './types'; +import { coreMock } from '../../../../../core/public/mocks'; +import { WorkspaceOperationType } from './constants'; +import { getIsOnlyAllowEssentialUseCase } from '../../utils'; + +jest.mock('../../utils', () => ({ + getIsOnlyAllowEssentialUseCase: jest.fn().mockResolvedValue(false), +})); +const mockCoreStart = coreMock.createStart(); const setup = (options?: Partial) => { const onChangeMock = jest.fn(); const formErrors: WorkspaceFormErrors = {}; + const savedObjects = mockCoreStart.savedObjects; const renderResult = render( ) => { value="" onChange={onChangeMock} formErrors={formErrors} + operationType={WorkspaceOperationType.Create} + savedObjects={savedObjects} {...options} /> ); @@ -63,4 +74,13 @@ describe('WorkspaceUseCase', () => { fireEvent.click(renderResult.getByText('Observability')); expect(onChangeMock).not.toHaveBeenCalled(); }); + it('should only display essential use case when creating workspace if getIsOnlyAllowEssentialUseCase returns true', async () => { + (getIsOnlyAllowEssentialUseCase as jest.Mock).mockResolvedValue(true); + + const { renderResult } = setup(); + await waitFor(() => { + expect(renderResult.queryByText('Essentials')).toBeInTheDocument(); + expect(renderResult.queryByText('Observability')).not.toBeInTheDocument(); + }); + }); }); diff --git a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx index 24568dfbfe8d..1a222238fc0c 100644 --- a/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx +++ b/src/plugins/workspace/public/components/workspace_form/workspace_use_case.tsx @@ -3,7 +3,7 @@ * SPDX-License-Identifier: Apache-2.0 */ -import React, { useCallback } from 'react'; +import React, { useCallback, useState, useEffect, useMemo } from 'react'; import { i18n } from '@osd/i18n'; import { EuiCheckableCard, @@ -17,6 +17,9 @@ import { DEFAULT_NAV_GROUPS } from '../../../../../core/public'; import { WorkspaceUseCase as WorkspaceUseCaseObject } from '../../types'; import { WorkspaceFormErrors } from './types'; import './workspace_use_case.scss'; +import type { SavedObjectsStart } from '../../../../../core/public'; +import { getIsOnlyAllowEssentialUseCase } from '../../utils'; +import { WorkspaceOperationType } from './constants'; interface WorkspaceUseCaseCardProps { id: string; @@ -54,13 +57,15 @@ const WorkspaceUseCaseCard = ({ ); }; +type AvailableUseCase = Pick; + export interface WorkspaceUseCaseProps { value: string | undefined; onChange: (newValue: string) => void; formErrors: WorkspaceFormErrors; - availableUseCases: Array< - Pick - >; + availableUseCases: AvailableUseCase[]; + savedObjects: SavedObjectsStart; + operationType: WorkspaceOperationType; } export const WorkspaceUseCase = ({ @@ -68,7 +73,32 @@ export const WorkspaceUseCase = ({ onChange, formErrors, availableUseCases, + savedObjects, + operationType, }: WorkspaceUseCaseProps) => { + const [isOnlyAllowEssential, setIsOnlyAllowEssential] = useState(false); + + useEffect(() => { + if (operationType === WorkspaceOperationType.Create) { + getIsOnlyAllowEssentialUseCase(savedObjects.client).then((result: boolean) => { + setIsOnlyAllowEssential(result); + }); + } + }, [savedObjects, operationType]); + + const displayedUseCases = useMemo(() => { + let allAvailableUseCases = availableUseCases + .filter((item) => !item.systematic) + .concat(DEFAULT_NAV_GROUPS.all); + // When creating and isOnlyAllowEssential is true, only display essential use case + if (isOnlyAllowEssential && operationType === WorkspaceOperationType.Create) { + allAvailableUseCases = allAvailableUseCases.filter( + (item) => item.id === DEFAULT_NAV_GROUPS.essentials.id + ); + } + return allAvailableUseCases; + }, [availableUseCases, isOnlyAllowEssential, operationType]); + return ( - {availableUseCases - .filter((item) => !item.systematic) - .concat(DEFAULT_NAV_GROUPS.all) - .map(({ id, title, description }) => ( - - - - ))} + {displayedUseCases.map(({ id, title, description }) => ( + + + + ))} ); diff --git a/src/plugins/workspace/public/utils.test.ts b/src/plugins/workspace/public/utils.test.ts index 53b256dc326c..545b4b3bfa5d 100644 --- a/src/plugins/workspace/public/utils.test.ts +++ b/src/plugins/workspace/public/utils.test.ts @@ -15,10 +15,12 @@ import { isEqualWorkspaceUseCase, USE_CASE_PREFIX, prependWorkspaceToBreadcrumbs, + getIsOnlyAllowEssentialUseCase, } from './utils'; import { WorkspaceAvailability } from '../../../core/public'; import { coreMock } from '../../../core/public/mocks'; import { WORKSPACE_DETAIL_APP_ID, WORKSPACE_USE_CASES } from '../common/constants'; +import { SigV4ServiceName } from '../../../plugins/data_source/common/data_sources'; const startMock = coreMock.createStart(); const STATIC_USE_CASES = [ @@ -379,7 +381,7 @@ describe('workspace utils: getDataSourcesList', () => { { id: 'id1', get: () => { - return 'title1'; + return 'mock_value'; }, }, ], @@ -387,7 +389,8 @@ describe('workspace utils: getDataSourcesList', () => { expect(await getDataSourcesList(mockedSavedObjectClient, [])).toStrictEqual([ { id: 'id1', - title: 'title1', + title: 'mock_value', + auth: 'mock_value', }, ]); }); @@ -398,6 +401,56 @@ describe('workspace utils: getDataSourcesList', () => { }); }); +describe('workspace utils: getIsOnlyAllowEssentialUseCase', () => { + const mockedSavedObjectClient = startMock.savedObjects.client; + + it('should return true when all data sources are serverless', async () => { + mockedSavedObjectClient.find = jest.fn().mockResolvedValue({ + savedObjects: [ + { + id: 'id1', + get: () => { + return { + credentials: { + service: SigV4ServiceName.OpenSearchServerless, + }, + }; + }, + }, + ], + }); + expect(await getIsOnlyAllowEssentialUseCase(mockedSavedObjectClient)).toBe(true); + }); + + it('should return false when not all data sources are serverless', async () => { + mockedSavedObjectClient.find = jest.fn().mockResolvedValue({ + savedObjects: [ + { + id: 'id1', + get: () => { + return { + credentials: { + service: SigV4ServiceName.OpenSearchServerless, + }, + }; + }, + }, + { + id: 'id2', + get: () => { + return { + credentials: { + service: SigV4ServiceName.OpenSearch, + }, + }; + }, + }, + ], + }); + expect(await getIsOnlyAllowEssentialUseCase(mockedSavedObjectClient)).toBe(false); + }); +}); + describe('workspace utils: convertNavGroupToWorkspaceUseCase', () => { it('should convert nav group to consistent workspace use case', () => { expect( diff --git a/src/plugins/workspace/public/utils.ts b/src/plugins/workspace/public/utils.ts index 8e0846a7593a..dc01e64182eb 100644 --- a/src/plugins/workspace/public/utils.ts +++ b/src/plugins/workspace/public/utils.ts @@ -23,6 +23,7 @@ import { } from '../../../core/public'; import { DEFAULT_SELECTED_FEATURES_IDS, WORKSPACE_DETAIL_APP_ID } from '../common/constants'; import { WorkspaceUseCase } from './types'; +import { SigV4ServiceName } from '../../../plugins/data_source/common/data_sources'; export const USE_CASE_PREFIX = 'use-case-'; @@ -202,7 +203,7 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac return client .find({ type: 'data-source', - fields: ['id', 'title'], + fields: ['id', 'title', 'auth'], perPage: 10000, workspaces, }) @@ -212,9 +213,11 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac return objects.map((source) => { const id = source.id; const title = source.get('title'); + const auth = source.get('auth'); return { id, title, + auth, }; }); } else { @@ -223,6 +226,17 @@ export const getDataSourcesList = (client: SavedObjectsStart['client'], workspac }); }; +// If all connected data sources are serverless, will only allow to select essential use case. +export const getIsOnlyAllowEssentialUseCase = async (client: SavedObjectsStart['client']) => { + const allDataSources = await getDataSourcesList(client, ['*']); + if (allDataSources.length > 0) { + return allDataSources.every( + (ds) => ds?.auth?.credentials?.service === SigV4ServiceName.OpenSearchServerless + ); + } + return false; +}; + export const convertNavGroupToWorkspaceUseCase = ({ id, title, From 265a176390f337ddc2f3cc20dcd367e77b9abd9f Mon Sep 17 00:00:00 2001 From: Miki Date: Mon, 12 Aug 2024 10:42:38 -0700 Subject: [PATCH 26/32] Introduce the redesign page and applications headers behind a switch (#7637) * Bump OUI to 1.9.0 Signed-off-by: Miki * Introduce the redesigned page header Signed-off-by: Miki Update UX of breadcrumbs, menu toggle, and the new contribution points Signed-off-by: Miki Add renderElement option in HeaderControls Signed-off-by: Shenoy Pratik Update application mocks and rearrange header layout Signed-off-by: Shenoy Pratik Break and restyle breadcrumb Signed-off-by: Zhongnan Su Implement header updates Signed-off-by: Miki * Introduce HeaderVariant Signed-off-by: Miki * Organize Header's layout Signed-off-by: Miki * Fix header control spacing Signed-off-by: Miki * Conditionally append breadcrumb to recent popover Fix mock for recent items Co-authored-by: Zhongnan Su Co-authored-by: Shenoy Pratik * Update top nav render and add app header Use ScreenTitle instead of appname from topnav menu Signed-off-by: Shenoy Pratik * Compress QueryStringInput appearance Signed-off-by: Miki * Update header for applications Signed-off-by: Miki * Eliminate colors from the borders of grouped action menu items Signed-off-by: Miki * Update TopNavControl*Data type to controlType for consistency Signed-off-by: Miki * Add tests for chrome Header Signed-off-by: Shenoy Pratik * Update Breadcrumbs tests Signed-off-by: Shenoy Pratik * Add tests for HeaderControlsContainer Signed-off-by: Miki * Add tests for TopNavControls and TopNavControlItem Signed-off-by: Shenoy Pratik * Updated tests for TopNavMenu and TopNavMenuItem Signed-off-by: Shenoy Pratik * Fix `uiSettingsServiceMock` missing `start` Signed-off-by: Miki * Add the `target` property to TopNavControlItem Signed-off-by: Miki * Update Navigation mock and start contract Signed-off-by: Miki * Add createGetterSetter mock in dashboards app state Signed-off-by: Shenoy Pratik * Add tests for setting and unsetting header variant Signed-off-by: Miki * Add tests for setting header controls Signed-off-by: Miki * Re-skin DataSource selection's trigger button Signed-off-by: Miki * Conditionally change where theme management menu item shows up Signed-off-by: Miki * Conditionally change where the help menu items shows up Signed-off-by: Miki * Make IndexPatternTable page conditionally use the new page header Signed-off-by: Miki * Make Discover conditionally use the new application header Signed-off-by: Miki * Make Dashboards conditionally use the new application header Signed-off-by: Miki * Add changelog fragment Signed-off-by: Shenoy Pratik Signed-off-by: Miki * Add tracking issue for empty label for DataSourceMenuPopoverButton Signed-off-by: Miki * Use EUI aliases in CSS variables Signed-off-by: Miki * Remove TopNavMenuLink Signed-off-by: Miki * Make sure OuiHeader doesn't contribute to the background Also: * Remove unused code Signed-off-by: Miki * Better border hiding for DSM popover button Signed-off-by: Miki * Make popover button overflow later Signed-off-by: Miki --------- Signed-off-by: Miki Signed-off-by: Shenoy Pratik Co-authored-by: Zhongnan Su Co-authored-by: Shenoy Pratik --- changelogs/fragments/7637.yml | 2 + package.json | 2 +- packages/osd-ui-framework/package.json | 2 +- packages/osd-ui-shared-deps/package.json | 2 +- .../application_service.test.ts.snap | 6 + .../application/application_service.mock.ts | 18 + .../application/application_service.test.ts | 32 +- .../application/application_service.tsx | 142 + .../integration_tests/router.test.tsx | 6 + src/core/public/application/types.ts | 34 +- .../application/ui/app_container.test.tsx | 24 + .../public/application/ui/app_container.tsx | 25 + src/core/public/application/ui/app_router.tsx | 39 +- src/core/public/chrome/chrome_service.mock.ts | 3 + src/core/public/chrome/chrome_service.test.ts | 69 +- src/core/public/chrome/chrome_service.tsx | 90 +- src/core/public/chrome/constants.ts | 14 + src/core/public/chrome/index.ts | 2 +- .../header/__snapshots__/header.test.tsx.snap | 14776 ++++++++++++---- .../header_breadcrumbs.test.tsx.snap | 15 + .../__snapshots__/recent_items.test.tsx.snap | 2 +- src/core/public/chrome/ui/header/_index.scss | 2 +- .../collapsible_nav_group_enabled_top.tsx | 1 - src/core/public/chrome/ui/header/header.scss | 61 + .../public/chrome/ui/header/header.test.tsx | 59 +- src/core/public/chrome/ui/header/header.tsx | 463 +- .../ui/header/header_action_menu.test.tsx | 10 +- .../chrome/ui/header/header_action_menu.tsx | 4 +- .../chrome/ui/header/header_breadcrumbs.scss | 35 + .../ui/header/header_breadcrumbs.test.tsx | 26 +- .../chrome/ui/header/header_breadcrumbs.tsx | 21 +- .../ui/header/header_controls_container.scss | 29 + .../header/header_controls_container.test.tsx | 131 + .../ui/header/header_controls_container.tsx | 95 + .../chrome/ui/header/header_help_menu.tsx | 20 +- .../public/chrome/ui/header/recent_items.scss | 22 + .../chrome/ui/header/recent_items.test.tsx | 9 +- .../public/chrome/ui/header/recent_items.tsx | 24 +- src/core/public/core_system.ts | 24 +- src/core/public/index.ts | 2 + src/core/public/mocks.ts | 20 +- src/core/public/plugins/plugin_context.ts | 6 + .../ui_settings/ui_settings_service.mock.ts | 2 + .../public/header_user_theme_menu.tsx | 48 +- .../advanced_settings/public/plugin.ts | 2 +- .../public/register_nav_control.tsx | 4 +- .../components/dashboard_editor.tsx | 15 +- .../dashboard_top_nav.test.tsx.snap | 212 +- .../dashboard_top_nav.test.tsx | 4 +- .../dashboard_top_nav/dashboard_top_nav.tsx | 75 +- .../top_nav/get_top_nav_config.ts | 192 +- .../utils/create_dashboard_app_state.test.tsx | 1 + .../ui/filter_bar/_global_filter_group.scss | 20 +- .../ui/query_string_input/_query_bar.scss | 4 +- .../query_string_input/query_bar_top_row.tsx | 11 +- .../query_string_input/query_string_input.tsx | 2 +- .../public/components/button_title.scss | 7 - .../data_source_aggregated_view.test.tsx.snap | 203 + .../data_source_aggregated_view.tsx | 1 + .../data_source_error_menu.test.tsx.snap | 6 + .../create_data_source_menu.test.tsx.snap | 123 +- .../data_source_menu.test.tsx.snap | 76 +- .../data_source_selectable.test.tsx.snap | 82 +- .../data_source_selectable.tsx | 4 +- .../data_source_selector.tsx | 2 +- .../data_source_view.test.tsx.snap | 172 +- .../data_source_view/data_source_view.tsx | 1 + .../popover_button/popover_button.scss | 80 + .../popover_button/popover_button.test.tsx | 16 +- .../popover_button/popover_button.tsx | 23 +- .../default_discover_table/_table_cell.scss | 4 +- .../default_discover_table/_table_header.scss | 2 +- .../components/top_nav/get_top_nav_links.tsx | 240 +- .../view_components/canvas/index.tsx | 14 +- .../view_components/canvas/top_nav.tsx | 20 +- .../opensearch_dashboards.json | 2 +- .../index_pattern_table.tsx | 69 +- .../mount_management_section.tsx | 14 +- .../index_pattern_management/public/plugin.ts | 2 + .../index_pattern_management/public/types.ts | 2 + src/plugins/navigation/public/index.ts | 18 +- src/plugins/navigation/public/mocks.ts | 1 + src/plugins/navigation/public/plugin.ts | 11 +- .../top_nav_controls.test.tsx.snap | 122 + .../public/top_nav_menu/_index.scss | 66 + .../top_nav_menu/create_top_nav_menu.tsx | 20 +- .../navigation/public/top_nav_menu/index.ts | 25 +- .../top_nav_menu/top_nav_control_data.tsx | 93 + .../top_nav_control_item.test.tsx | 108 + .../top_nav_menu/top_nav_control_item.tsx | 150 + .../top_nav_menu/top_nav_controls.test.tsx | 67 + .../public/top_nav_menu/top_nav_controls.tsx | 71 + .../public/top_nav_menu/top_nav_menu.test.tsx | 105 +- .../public/top_nav_menu/top_nav_menu.tsx | 126 +- .../public/top_nav_menu/top_nav_menu_data.tsx | 65 +- .../top_nav_menu/top_nav_menu_item.test.tsx | 60 +- .../public/top_nav_menu/top_nav_menu_item.tsx | 118 +- src/plugins/navigation/public/types.ts | 7 +- .../region_map_options.test.tsx.snap | 12 +- .../saved_objects_table.test.tsx.snap | 6 + .../plugins/osd_tp_run_pipeline/package.json | 2 +- .../osd_sample_panel_action/package.json | 2 +- .../osd_tp_custom_visualizations/package.json | 2 +- yarn.lock | 8 +- 104 files changed, 14834 insertions(+), 4557 deletions(-) create mode 100644 changelogs/fragments/7637.yml create mode 100644 src/core/public/chrome/ui/header/header.scss create mode 100644 src/core/public/chrome/ui/header/header_breadcrumbs.scss create mode 100644 src/core/public/chrome/ui/header/header_controls_container.scss create mode 100644 src/core/public/chrome/ui/header/header_controls_container.test.tsx create mode 100644 src/core/public/chrome/ui/header/header_controls_container.tsx create mode 100644 src/core/public/chrome/ui/header/recent_items.scss delete mode 100644 src/plugins/data_source_management/public/components/button_title.scss create mode 100644 src/plugins/data_source_management/public/components/popover_button/popover_button.scss create mode 100644 src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx create mode 100644 src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx diff --git a/changelogs/fragments/7637.yml b/changelogs/fragments/7637.yml new file mode 100644 index 000000000000..02db1c24d3d2 --- /dev/null +++ b/changelogs/fragments/7637.yml @@ -0,0 +1,2 @@ +feat: +- Introduce the redesign page and applications headers behind a switch ([#7637](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7637)) diff --git a/package.json b/package.json index 3392b44d0072..1934c949fefe 100644 --- a/package.json +++ b/package.json @@ -144,7 +144,7 @@ "dependencies": { "@aws-crypto/client-node": "^3.1.1", "@elastic/datemath": "5.0.3", - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@elastic/good": "^9.0.1-kibana3", "@elastic/numeral": "npm:@amoo-miki/numeral@2.6.0", "@elastic/request-crypto": "2.0.0", diff --git a/packages/osd-ui-framework/package.json b/packages/osd-ui-framework/package.json index f1decec5302c..7cfe87f7e2fd 100644 --- a/packages/osd-ui-framework/package.json +++ b/packages/osd-ui-framework/package.json @@ -23,7 +23,7 @@ "enzyme-adapter-react-16": "^1.9.1" }, "devDependencies": { - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@osd/babel-preset": "1.0.0", "@osd/optimizer": "1.0.0", "comment-stripper": "^0.0.4", diff --git a/packages/osd-ui-shared-deps/package.json b/packages/osd-ui-shared-deps/package.json index 45611a198bc9..05ccf6b50255 100644 --- a/packages/osd-ui-shared-deps/package.json +++ b/packages/osd-ui-shared-deps/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@elastic/charts": "31.1.0", - "@elastic/eui": "npm:@opensearch-project/oui@1.8.1", + "@elastic/eui": "npm:@opensearch-project/oui@1.9.0", "@elastic/numeral": "npm:@amoo-miki/numeral@2.6.0", "@opensearch/datemath": "5.0.3", "@osd/i18n": "1.0.0", diff --git a/src/core/public/application/__snapshots__/application_service.test.ts.snap b/src/core/public/application/__snapshots__/application_service.test.ts.snap index a6c9eb27e338..687977044cde 100644 --- a/src/core/public/application/__snapshots__/application_service.test.ts.snap +++ b/src/core/public/application/__snapshots__/application_service.test.ts.snap @@ -81,7 +81,13 @@ exports[`#start() getComponent returns renderable JSX tree 1`] = ` } mounters={Map {}} setAppActionMenu={[Function]} + setAppBadgeControls={[Function]} + setAppBottomControls={[Function]} + setAppCenterControls={[Function]} + setAppDescriptionControls={[Function]} setAppLeaveHandler={[Function]} + setAppLeftControls={[Function]} + setAppRightControls={[Function]} setIsMounting={[Function]} /> `; diff --git a/src/core/public/application/application_service.mock.ts b/src/core/public/application/application_service.mock.ts index b70a34095f0c..e3897f746cfb 100644 --- a/src/core/public/application/application_service.mock.ts +++ b/src/core/public/application/application_service.mock.ts @@ -65,6 +65,12 @@ const createStartContractMock = (): jest.Mocked => { navigateToUrl: jest.fn(), getUrlForApp: jest.fn(), registerMountContext: jest.fn(), + setAppLeftControls: jest.fn(), + setAppCenterControls: jest.fn(), + setAppRightControls: jest.fn(), + setAppBadgeControls: jest.fn(), + setAppDescriptionControls: jest.fn(), + setAppBottomControls: jest.fn(), }; }; @@ -98,6 +104,18 @@ const createInternalStartContractMock = (): jest.Mocked(undefined), + currentLeftControls$: new BehaviorSubject(undefined), + currentCenterControls$: new BehaviorSubject(undefined), + currentRightControls$: new BehaviorSubject(undefined), + currentBadgeControls$: new BehaviorSubject(undefined), + currentDescriptionControls$: new BehaviorSubject(undefined), + currentBottomControls$: new BehaviorSubject(undefined), + setAppLeftControls: jest.fn(), + setAppCenterControls: jest.fn(), + setAppRightControls: jest.fn(), + setAppBadgeControls: jest.fn(), + setAppDescriptionControls: jest.fn(), + setAppBottomControls: jest.fn(), getComponent: jest.fn(), getUrlForApp: jest.fn(), navigateToApp: jest.fn().mockImplementation((appId) => currentAppId$.next(appId)), diff --git a/src/core/public/application/application_service.test.ts b/src/core/public/application/application_service.test.ts index 52786446ad55..a614e39205c9 100644 --- a/src/core/public/application/application_service.test.ts +++ b/src/core/public/application/application_service.test.ts @@ -35,7 +35,7 @@ import { } from './application_service.test.mocks'; import { createElement } from 'react'; -import { BehaviorSubject, Subject } from 'rxjs'; +import { BehaviorSubject, Subject, Observable } from 'rxjs'; import { bufferCount, take, takeUntil } from 'rxjs/operators'; import { shallow, mount } from 'enzyme'; @@ -51,7 +51,9 @@ import { AppStatus, AppUpdater, WorkspaceAvailability, + InternalApplicationStart, } from './types'; +import { MountPoint } from '../types'; import { act } from 'react-dom/test-utils'; import { workspacesServiceMock } from '../mocks'; @@ -937,6 +939,34 @@ describe('#start()', () => { expect(setupDeps.redirectTo).not.toHaveBeenCalled(); }); }); + + describe('AppControls', () => { + test.each(['Left', 'Center', 'Right', 'Badge', 'Description', 'Bottom'])( + 'records the App%sControls', + async (container) => { + const { register } = service.setup(setupDeps); + + register(Symbol(), createApp({ id: `app${container}` })); + const appStart = await service.start(startDeps); + const setControls = appStart[ + `setApp${container}Controls` as keyof InternalApplicationStart + ] as (mount: MountPoint | undefined) => void; + const currentControls$ = appStart[ + `current${container}Controls$` as keyof InternalApplicationStart + ] as Observable; + + const oldMountPoint = jest.fn(); + const expectedMountPoint = jest.fn(); + + await appStart.navigateToApp(`app${container}`); + setControls(oldMountPoint); + setControls(expectedMountPoint); + + const mountPoint = await currentControls$.pipe(take(1)).toPromise(); + expect(mountPoint).toBe(expectedMountPoint); + } + ); + }); }); describe('#stop()', () => { diff --git a/src/core/public/application/application_service.tsx b/src/core/public/application/application_service.tsx index 76747490a305..630d97476b05 100644 --- a/src/core/public/application/application_service.tsx +++ b/src/core/public/application/application_service.tsx @@ -37,6 +37,7 @@ import { RecursiveReadonly } from '@osd/utility-types'; import { MountPoint } from '../types'; import { HttpSetup, HttpStart } from '../http'; import { OverlayStart } from '../overlays'; +import { HeaderControlsContainer } from '../chrome/constants'; import { ContextSetup, IContextContainer } from '../context'; import { PluginOpaqueId } from '../plugins'; import { AppRouter } from './ui'; @@ -104,6 +105,12 @@ interface AppUpdaterWrapper { interface AppInternalState { leaveHandler?: AppLeaveHandler; actionMenu?: MountPoint; + leftControls?: MountPoint; + centerControls?: MountPoint; + rightControls?: MountPoint; + badgeControls?: MountPoint; + descriptionControls?: MountPoint; + bottomControls?: MountPoint; } /** @@ -117,6 +124,15 @@ export class ApplicationService { private readonly appInternalStates = new Map(); private currentAppId$ = new BehaviorSubject(undefined); private currentActionMenu$ = new BehaviorSubject(undefined); + + // HeaderControls + private currentLeftControls$ = new BehaviorSubject(undefined); + private currentCenterControls$ = new BehaviorSubject(undefined); + private currentRightControls$ = new BehaviorSubject(undefined); + private currentBadgeControls$ = new BehaviorSubject(undefined); + private currentDescriptionControls$ = new BehaviorSubject(undefined); + private currentBottomControls$ = new BehaviorSubject(undefined); + private readonly statusUpdaters$ = new BehaviorSubject>(new Map()); private readonly subscriptions: Subscription[] = []; private stop$ = new Subject(); @@ -291,6 +307,15 @@ export class ApplicationService { this.currentAppId$.subscribe(() => this.refreshCurrentActionMenu()); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.LEFT)); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.CENTER)); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.RIGHT)); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.BADGE)); + this.currentAppId$.subscribe(() => + this.refreshCurrentControls(HeaderControlsContainer.DESCRIPTION) + ); + this.currentAppId$.subscribe(() => this.refreshCurrentControls(HeaderControlsContainer.BOTTOM)); + return { applications$: applications$.pipe( map((apps) => new Map([...apps.entries()].map(([id, app]) => [id, getAppInfo(app)]))), @@ -306,6 +331,46 @@ export class ApplicationService { distinctUntilChanged(), takeUntil(this.stop$) ), + + // HeaderControls + currentLeftControls$: this.currentLeftControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentCenterControls$: this.currentCenterControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentRightControls$: this.currentRightControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentBadgeControls$: this.currentBadgeControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentDescriptionControls$: this.currentDescriptionControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + currentBottomControls$: this.currentBottomControls$.pipe( + distinctUntilChanged(), + takeUntil(this.stop$) + ), + + setAppLeftControls: (mount: MountPoint | undefined) => + this.setAppLeftControls(this.currentAppId$.value, mount), + setAppCenterControls: (mount: MountPoint | undefined) => + this.setAppCenterControls(this.currentAppId$.value, mount), + setAppRightControls: (mount: MountPoint | undefined) => + this.setAppRightControls(this.currentAppId$.value, mount), + setAppBadgeControls: (mount: MountPoint | undefined) => + this.setAppBadgeControls(this.currentAppId$.value, mount), + setAppDescriptionControls: (mount: MountPoint | undefined) => + this.setAppDescriptionControls(this.currentAppId$.value, mount), + setAppBottomControls: (mount: MountPoint | undefined) => + this.setAppBottomControls(this.currentAppId$.value, mount), + history: this.history!, registerMountContext: this.mountContext.registerContext, getUrlForApp: ( @@ -339,6 +404,12 @@ export class ApplicationService { appStatuses$={applicationStatuses$} setAppLeaveHandler={this.setAppLeaveHandler} setAppActionMenu={this.setAppActionMenu} + setAppLeftControls={this.setAppLeftControls} + setAppCenterControls={this.setAppCenterControls} + setAppRightControls={this.setAppRightControls} + setAppBadgeControls={this.setAppBadgeControls} + setAppDescriptionControls={this.setAppDescriptionControls} + setAppBottomControls={this.setAppBottomControls} setIsMounting={(isMounting) => httpLoadingCount$.next(isMounting ? 1 : 0)} /> ); @@ -367,6 +438,71 @@ export class ApplicationService { this.currentActionMenu$.next(currentActionMenu); }; + private setAppLeftControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.LEFT); + + private setAppCenterControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.CENTER); + + private setAppRightControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.RIGHT); + + private setAppBadgeControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.BADGE); + + private setAppDescriptionControls = ( + appPath: string | undefined, + mount: MountPoint | undefined + ) => this.setAppControls(appPath, mount, HeaderControlsContainer.DESCRIPTION); + + private setAppBottomControls = (appPath: string | undefined, mount: MountPoint | undefined) => + this.setAppControls(appPath, mount, HeaderControlsContainer.BOTTOM); + + private setAppControls = ( + appPath: string | undefined, + mount: MountPoint | undefined, + container: HeaderControlsContainer + ) => { + if (!appPath) return; + + this.appInternalStates.set(appPath, { + ...(this.appInternalStates.get(appPath) ?? {}), + [`${container}Controls`]: mount, + }); + + this.refreshCurrentControls(container); + }; + + private refreshCurrentControls = (container: HeaderControlsContainer) => { + const appId = this.currentAppId$.getValue(); + switch (container) { + case HeaderControlsContainer.LEFT: + return this.currentLeftControls$.next( + appId ? this.appInternalStates.get(appId)?.leftControls : undefined + ); + case HeaderControlsContainer.CENTER: + return this.currentCenterControls$.next( + appId ? this.appInternalStates.get(appId)?.centerControls : undefined + ); + case HeaderControlsContainer.RIGHT: + return this.currentRightControls$.next( + appId ? this.appInternalStates.get(appId)?.rightControls : undefined + ); + case HeaderControlsContainer.BADGE: + return this.currentBadgeControls$.next( + appId ? this.appInternalStates.get(appId)?.badgeControls : undefined + ); + case HeaderControlsContainer.DESCRIPTION: + return this.currentDescriptionControls$.next( + appId ? this.appInternalStates.get(appId)?.descriptionControls : undefined + ); + case HeaderControlsContainer.BOTTOM: + return this.currentBottomControls$.next( + appId ? this.appInternalStates.get(appId)?.bottomControls : undefined + ); + } + }; + private async shouldNavigate(overlays: OverlayStart): Promise { const currentAppId = this.currentAppId$.value; if (currentAppId === undefined) { @@ -402,6 +538,12 @@ export class ApplicationService { this.stop$.next(); this.currentAppId$.complete(); this.currentActionMenu$.complete(); + this.currentLeftControls$.complete(); + this.currentCenterControls$.complete(); + this.currentRightControls$.complete(); + this.currentBadgeControls$.complete(); + this.currentDescriptionControls$.complete(); + this.currentBottomControls$.complete(); this.statusUpdaters$.complete(); this.subscriptions.forEach((sub) => sub.unsubscribe()); window.removeEventListener('beforeunload', this.onBeforeUnload); diff --git a/src/core/public/application/integration_tests/router.test.tsx b/src/core/public/application/integration_tests/router.test.tsx index 7e1bc437ca9d..88876f65f054 100644 --- a/src/core/public/application/integration_tests/router.test.tsx +++ b/src/core/public/application/integration_tests/router.test.tsx @@ -70,6 +70,12 @@ describe('AppRouter', () => { appStatuses$={mountersToAppStatus$()} setAppLeaveHandler={noop} setAppActionMenu={noop} + setAppLeftControls={noop} + setAppCenterControls={noop} + setAppRightControls={noop} + setAppBadgeControls={noop} + setAppDescriptionControls={noop} + setAppBottomControls={noop} setIsMounting={noop} /> ); diff --git a/src/core/public/application/types.ts b/src/core/public/application/types.ts index 7546b49620a4..63cbc5605561 100644 --- a/src/core/public/application/types.ts +++ b/src/core/public/application/types.ts @@ -35,7 +35,7 @@ import { RecursiveReadonly } from '@osd/utility-types'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; import { MountPoint } from '../types'; import { Capabilities } from './capabilities'; -import { ChromeStart } from '../chrome'; +import { ChromeStart, HeaderVariant } from '../chrome'; import { IContextProvider } from '../context'; import { DocLinksStart } from '../doc_links'; import { HttpStart } from '../http'; @@ -227,6 +227,11 @@ export interface App { */ chromeless?: boolean; + /** + * The application-wide header variant to use. Defaults to `page`. + */ + headerVariant?: HeaderVariant; + /** * A mount function called when the user navigates to this app's route. May have signature of {@link AppMount} or * {@link AppMountDeprecated}. @@ -535,6 +540,13 @@ export interface AppMountParameters { * ``` */ setHeaderActionMenu: (menuMount: MountPoint | undefined) => void; + + setHeaderLeftControls: (menuMount: MountPoint | undefined) => void; + setHeaderCenterControls: (menuMount: MountPoint | undefined) => void; + setHeaderRightControls: (menuMount: MountPoint | undefined) => void; + setHeaderBadgeControls: (menuMount: MountPoint | undefined) => void; + setHeaderDescriptionControls: (menuMount: MountPoint | undefined) => void; + setHeaderBottomControls: (menuMount: MountPoint | undefined) => void; /** * Optional datasource id to pass while mounting app */ @@ -828,6 +840,13 @@ export interface ApplicationStart { * An observable that emits the current application id and each subsequent id update. */ currentAppId$: Observable; + + setAppLeftControls: (mount: MountPoint | undefined) => void; + setAppCenterControls: (mount: MountPoint | undefined) => void; + setAppRightControls: (mount: MountPoint | undefined) => void; + setAppBadgeControls: (mount: MountPoint | undefined) => void; + setAppDescriptionControls: (mount: MountPoint | undefined) => void; + setAppBottomControls: (mount: MountPoint | undefined) => void; } /** @internal */ @@ -858,6 +877,19 @@ export interface InternalApplicationStart extends Omit; + /** + * The potential header controls set by the currently mounted app. + * Consumed by the chrome header. + * + * @internal + */ + currentLeftControls$: Observable; + currentCenterControls$: Observable; + currentRightControls$: Observable; + currentBadgeControls$: Observable; + currentDescriptionControls$: Observable; + currentBottomControls$: Observable; + /** * The global history instance, exposed only to Core. * @internal diff --git a/src/core/public/application/ui/app_container.test.tsx b/src/core/public/application/ui/app_container.test.tsx index 3e658fa25665..2c33ca040e85 100644 --- a/src/core/public/application/ui/app_container.test.tsx +++ b/src/core/public/application/ui/app_container.test.tsx @@ -43,6 +43,12 @@ describe('AppContainer', () => { const setAppLeaveHandler = jest.fn(); const setAppActionMenu = jest.fn(); const setIsMounting = jest.fn(); + const setAppLeftControls = jest.fn(); + const setAppRightControls = jest.fn(); + const setAppCenterControls = jest.fn(); + const setAppBadgeControls = jest.fn(); + const setAppDescriptionControls = jest.fn(); + const setAppBottomControls = jest.fn(); beforeEach(() => { setAppLeaveHandler.mockClear(); @@ -89,6 +95,12 @@ describe('AppContainer', () => { mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} setAppActionMenu={setAppActionMenu} + setAppLeftControls={setAppLeftControls} + setAppCenterControls={setAppCenterControls} + setAppRightControls={setAppRightControls} + setAppBadgeControls={setAppBadgeControls} + setAppDescriptionControls={setAppDescriptionControls} + setAppBottomControls={setAppBottomControls} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -130,6 +142,12 @@ describe('AppContainer', () => { mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} setAppActionMenu={setAppActionMenu} + setAppLeftControls={setAppLeftControls} + setAppCenterControls={setAppCenterControls} + setAppRightControls={setAppRightControls} + setAppBadgeControls={setAppBadgeControls} + setAppDescriptionControls={setAppDescriptionControls} + setAppBottomControls={setAppBottomControls} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location @@ -172,6 +190,12 @@ describe('AppContainer', () => { mounter={mounter} setAppLeaveHandler={setAppLeaveHandler} setAppActionMenu={setAppActionMenu} + setAppLeftControls={setAppLeftControls} + setAppCenterControls={setAppCenterControls} + setAppRightControls={setAppRightControls} + setAppBadgeControls={setAppBadgeControls} + setAppDescriptionControls={setAppDescriptionControls} + setAppBottomControls={setAppBottomControls} setIsMounting={setIsMounting} createScopedHistory={(appPath: string) => // Create a history using the appPath as the current location diff --git a/src/core/public/application/ui/app_container.tsx b/src/core/public/application/ui/app_container.tsx index b7d0619a0f9f..8e81db2af34a 100644 --- a/src/core/public/application/ui/app_container.tsx +++ b/src/core/public/application/ui/app_container.tsx @@ -52,6 +52,12 @@ interface Props { appStatus: AppStatus; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; + setAppLeftControls: (appId: string, mount: MountPoint | undefined) => void; + setAppCenterControls: (appId: string, mount: MountPoint | undefined) => void; + setAppRightControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBadgeControls: (appId: string, mount: MountPoint | undefined) => void; + setAppDescriptionControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBottomControls: (appId: string, mount: MountPoint | undefined) => void; createScopedHistory: (appUrl: string) => ScopedHistory; setIsMounting: (isMounting: boolean) => void; } @@ -62,6 +68,12 @@ export const AppContainer: FunctionComponent = ({ appPath, setAppLeaveHandler, setAppActionMenu, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, createScopedHistory, appStatus, setIsMounting, @@ -99,6 +111,13 @@ export const AppContainer: FunctionComponent = ({ element: elementRef.current!, onAppLeave: (handler) => setAppLeaveHandler(appId, handler), setHeaderActionMenu: (menuMount) => setAppActionMenu(appId, menuMount), + setHeaderLeftControls: (menuMount) => setAppLeftControls(appId, menuMount), + setHeaderCenterControls: (menuMount) => setAppCenterControls(appId, menuMount), + setHeaderRightControls: (menuMount) => setAppRightControls(appId, menuMount), + setHeaderBadgeControls: (menuMount) => setAppBadgeControls(appId, menuMount), + setHeaderDescriptionControls: (menuMount) => + setAppDescriptionControls(appId, menuMount), + setHeaderBottomControls: (menuMount) => setAppBottomControls(appId, menuMount), })) || null; } catch (e) { // TODO: add error UI @@ -122,6 +141,12 @@ export const AppContainer: FunctionComponent = ({ createScopedHistory, setAppLeaveHandler, setAppActionMenu, + setAppLeftControls, + setAppRightControls, + setAppCenterControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, appPath, setIsMounting, ]); diff --git a/src/core/public/application/ui/app_router.tsx b/src/core/public/application/ui/app_router.tsx index 9cfada1f3334..e5de0b406479 100644 --- a/src/core/public/application/ui/app_router.tsx +++ b/src/core/public/application/ui/app_router.tsx @@ -45,6 +45,12 @@ interface Props { appStatuses$: Observable>; setAppLeaveHandler: (appId: string, handler: AppLeaveHandler) => void; setAppActionMenu: (appId: string, mount: MountPoint | undefined) => void; + setAppLeftControls: (appId: string, mount: MountPoint | undefined) => void; + setAppCenterControls: (appId: string, mount: MountPoint | undefined) => void; + setAppRightControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBadgeControls: (appId: string, mount: MountPoint | undefined) => void; + setAppDescriptionControls: (appId: string, mount: MountPoint | undefined) => void; + setAppBottomControls: (appId: string, mount: MountPoint | undefined) => void; setIsMounting: (isMounting: boolean) => void; } @@ -57,6 +63,12 @@ export const AppRouter: FunctionComponent = ({ mounters, setAppLeaveHandler, setAppActionMenu, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, appStatuses$, setIsMounting, }) => { @@ -79,7 +91,19 @@ export const AppRouter: FunctionComponent = ({ appPath={path} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ appId, mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} + {...{ + appId, + mounter, + setAppLeaveHandler, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, + setAppActionMenu, + setIsMounting, + }} /> )} /> @@ -101,7 +125,18 @@ export const AppRouter: FunctionComponent = ({ appId={id ?? appId} appStatus={appStatuses.get(appId) ?? AppStatus.inaccessible} createScopedHistory={createScopedHistory} - {...{ mounter, setAppLeaveHandler, setAppActionMenu, setIsMounting }} + {...{ + mounter, + setAppLeaveHandler, + setAppLeftControls, + setAppCenterControls, + setAppRightControls, + setAppBadgeControls, + setAppDescriptionControls, + setAppBottomControls, + setAppActionMenu, + setIsMounting, + }} /> ); }} diff --git a/src/core/public/chrome/chrome_service.mock.ts b/src/core/public/chrome/chrome_service.mock.ts index 8e8d8c893cc9..c63232186672 100644 --- a/src/core/public/chrome/chrome_service.mock.ts +++ b/src/core/public/chrome/chrome_service.mock.ts @@ -86,6 +86,8 @@ const createStartContractMock = () => { setAppTitle: jest.fn(), setIsVisible: jest.fn(), getIsVisible$: jest.fn(), + setHeaderVariant: jest.fn(), + getHeaderVariant$: jest.fn(), addApplicationClass: jest.fn(), removeApplicationClass: jest.fn(), getApplicationClasses$: jest.fn(), @@ -104,6 +106,7 @@ const createStartContractMock = () => { }; startContract.navLinks.getAll.mockReturnValue([]); startContract.getIsVisible$.mockReturnValue(new BehaviorSubject(false)); + startContract.getHeaderVariant$.mockReturnValue(new BehaviorSubject(undefined)); startContract.getApplicationClasses$.mockReturnValue(new BehaviorSubject(['class-name'])); startContract.getBadge$.mockReturnValue(new BehaviorSubject({} as ChromeBadge)); startContract.getBreadcrumbs$.mockReturnValue(new BehaviorSubject([{} as ChromeBreadcrumb])); diff --git a/src/core/public/chrome/chrome_service.test.ts b/src/core/public/chrome/chrome_service.test.ts index 09ecbaedb55b..082ffbfa16ed 100644 --- a/src/core/public/chrome/chrome_service.test.ts +++ b/src/core/public/chrome/chrome_service.test.ts @@ -42,12 +42,17 @@ import { uiSettingsServiceMock } from '../ui_settings/ui_settings_service.mock'; import { ChromeService } from './chrome_service'; import { getAppInfo } from '../application/utils'; import { overlayServiceMock, workspacesServiceMock } from '../mocks'; +import { HeaderVariant } from './constants'; class FakeApp implements App { public title: string; public mount = () => () => {}; - constructor(public id: string, public chromeless?: boolean) { + constructor( + public id: string, + public chromeless?: boolean, + public headerVariant?: HeaderVariant + ) { this.title = `${this.id} App`; } } @@ -282,6 +287,68 @@ describe('start', () => { }); }); + describe('header variant', () => { + it('emits undefined when no application is mounted', async () => { + const { chrome, service } = await start(); + const promise = chrome.getHeaderVariant$().pipe(toArray()).toPromise(); + + chrome.setHeaderVariant(HeaderVariant.PAGE); + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + chrome.setHeaderVariant(HeaderVariant.PAGE); + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(`Array []`); + }); + + it('emits application-wide value until manually overridden', async () => { + const startDeps = defaultStartDeps([ + new FakeApp('alpha', undefined, HeaderVariant.APPLICATION), + ]); + const { navigateToApp } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + + const promise = chrome.getHeaderVariant$().pipe(toArray()).toPromise(); + + await navigateToApp('alpha'); + + chrome.setHeaderVariant(HeaderVariant.PAGE); + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + "${HeaderVariant.APPLICATION}", + "${HeaderVariant.PAGE}", + "${HeaderVariant.APPLICATION}", + ] + `); + }); + + it('emits application-wide value after override is removed', async () => { + const startDeps = defaultStartDeps([new FakeApp('alpha', undefined, HeaderVariant.PAGE)]); + const { navigateToApp } = startDeps.application; + const { chrome, service } = await start({ startDeps }); + + const promise = chrome.getHeaderVariant$().pipe(toArray()).toPromise(); + + await navigateToApp('alpha'); + + chrome.setHeaderVariant(HeaderVariant.APPLICATION); + chrome.setHeaderVariant(); + + service.stop(); + + await expect(promise).resolves.toMatchInlineSnapshot(` + Array [ + "${HeaderVariant.PAGE}", + "${HeaderVariant.APPLICATION}", + "${HeaderVariant.PAGE}", + ] + `); + }); + }); + describe('application classes', () => { it('updates/emits the application classes', async () => { const { chrome, service } = await start(); diff --git a/src/core/public/chrome/chrome_service.tsx b/src/core/public/chrome/chrome_service.tsx index 3b61ee3ee945..c56388a9da32 100644 --- a/src/core/public/chrome/chrome_service.tsx +++ b/src/core/public/chrome/chrome_service.tsx @@ -30,8 +30,17 @@ import { EuiBreadcrumb, IconType } from '@elastic/eui'; import React from 'react'; -import { FormattedMessage } from '@osd/i18n/react'; -import { BehaviorSubject, combineLatest, merge, Observable, of, ReplaySubject } from 'rxjs'; +import ReactDOM from 'react-dom'; +import { FormattedMessage, I18nProvider } from '@osd/i18n/react'; +import { + BehaviorSubject, + combineLatest, + merge, + Observable, + of, + ReplaySubject, + Subscription, +} from 'rxjs'; import { flatMap, map, takeUntil } from 'rxjs/operators'; import { EuiLink } from '@elastic/eui'; import { mountReactNode } from '../utils/mount'; @@ -41,13 +50,13 @@ import { HttpStart } from '../http'; import { InjectedMetadataStart } from '../injected_metadata'; import { NotificationsStart } from '../notifications'; import { IUiSettingsClient } from '../ui_settings'; -import { OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK } from './constants'; +import { HeaderVariant, OPENSEARCH_DASHBOARDS_ASK_OPENSEARCH_LINK } from './constants'; import { ChromeDocTitle, DocTitleService } from './doc_title'; import { ChromeNavControls, NavControlsService } from './nav_controls'; import { ChromeNavLinks, NavLinksService, ChromeNavLink } from './nav_links'; import { ChromeRecentlyAccessed, RecentlyAccessedService } from './recently_accessed'; import { Header } from './ui'; -import { ChromeHelpExtensionMenuLink } from './ui/header/header_help_menu'; +import { ChromeHelpExtensionMenuLink, HeaderHelpMenu } from './ui/header/header_help_menu'; import { Branding, WorkspacesStart } from '../'; import { getLogos } from '../../common'; import type { Logos } from '../../common/types'; @@ -119,12 +128,16 @@ type CollapsibleNavHeaderRender = () => JSX.Element | null; export class ChromeService { private isVisible$!: Observable; private isForceHidden$!: BehaviorSubject; + private headerVariant$!: Observable; + private headerVariantOverride$!: BehaviorSubject; private readonly stop$ = new ReplaySubject(1); private readonly navControls = new NavControlsService(); private readonly navLinks = new NavLinksService(); private readonly recentlyAccessed = new RecentlyAccessedService(); private readonly docTitle = new DocTitleService(); private readonly navGroup = new ChromeNavGroupService(); + private useUpdatedHeader = false; + private updatedHeaderSubscription: Subscription | undefined; private collapsibleNavHeaderRender?: CollapsibleNavHeaderRender; constructor(private readonly params: ConstructorParams) {} @@ -161,6 +174,28 @@ export class ChromeService { ); } + private initHeaderVariant(application: StartDeps['application']) { + this.headerVariantOverride$ = new BehaviorSubject(undefined); + + const appHeaderVariant$ = application.currentAppId$.pipe( + flatMap((appId) => + application.applications$.pipe( + map( + (applications) => + (appId && applications.has(appId) && applications.get(appId)!.headerVariant) as + | HeaderVariant + | undefined + ) + ) + ) + ); + + this.headerVariant$ = combineLatest([appHeaderVariant$, this.headerVariantOverride$]).pipe( + map(([appHeaderVariant, headerVariantOverride]) => headerVariantOverride || appHeaderVariant), + takeUntil(this.stop$) + ); + } + public setup({ uiSettings }: SetupDeps): ChromeSetup { const navGroup = this.navGroup.setup({ uiSettings }); return { @@ -188,6 +223,13 @@ export class ChromeService { workspaces, }: StartDeps): Promise { this.initVisibility(application); + this.initHeaderVariant(application); + + this.updatedHeaderSubscription = uiSettings + .get$('home:useNewHomePage', false) + .subscribe((value) => { + this.useUpdatedHeader = value; + }); const appTitle$ = new BehaviorSubject('Overview'); const applicationClasses$ = new BehaviorSubject>(new Set()); @@ -230,6 +272,29 @@ export class ChromeService { const logos = getLogos(injectedMetadata.getBranding(), http.basePath.serverBasePath); + // Add Help menu + if (this.useUpdatedHeader) { + navControls.registerLeftBottom({ + order: 9000, + mount: (element: HTMLElement) => { + ReactDOM.render( + + + , + element + ); + return () => ReactDOM.unmountComponentAtNode(element); + }, + }); + } + const isIE = () => { const ua = window.navigator.userAgent; const msie = ua.indexOf('MSIE '); // IE 10 or older @@ -298,6 +363,7 @@ export class ChromeService { helpSupportUrl$={helpSupportUrl$.pipe(takeUntil(this.stop$))} homeHref={application.getUrlForApp('home')} isVisible$={this.isVisible$} + headerVariant$={this.headerVariant$} opensearchDashboardsVersion={injectedMetadata.getOpenSearchDashboardsVersion()} navLinks$={navLinks.getNavLinks$()} recentlyAccessed$={recentlyAccessed.get$()} @@ -319,6 +385,7 @@ export class ChromeService { navGroupsMap$={navGroup.getNavGroupsMap$()} setCurrentNavGroup={navGroup.setCurrentNavGroup} workspaceList$={workspaces.workspaceList$} + useUpdatedHeader={this.useUpdatedHeader} /> ), @@ -328,6 +395,10 @@ export class ChromeService { setIsVisible: (isVisible: boolean) => this.isForceHidden$.next(!isVisible), + getHeaderVariant$: () => this.headerVariant$, + + setHeaderVariant: (variant?: HeaderVariant) => this.headerVariantOverride$.next(variant), + getApplicationClasses$: () => applicationClasses$.pipe( map((set) => [...set]), @@ -385,6 +456,7 @@ export class ChromeService { public stop() { this.navLinks.stop(); this.navGroup.stop(); + this.updatedHeaderSubscription?.unsubscribe(); this.stop$.next(); } } @@ -465,6 +537,16 @@ export interface ChromeStart { */ setIsVisible(isVisible: boolean): void; + /** + * Get an observable of the current header variant. + */ + getHeaderVariant$(): Observable; + + /** + * Set or unset the temporary variant for the header. + */ + setHeaderVariant(variant?: HeaderVariant): void; + /** * Get the current set of classNames that will be set on the application container. */ diff --git a/src/core/public/chrome/constants.ts b/src/core/public/chrome/constants.ts index 4f98257ea5f8..ce65af852e07 100644 --- a/src/core/public/chrome/constants.ts +++ b/src/core/public/chrome/constants.ts @@ -37,3 +37,17 @@ export enum RightNavigationOrder { Settings = 10, DevTool = 20, } + +export enum HeaderControlsContainer { + LEFT = 'left', + CENTER = 'center', + RIGHT = 'right', + BADGE = 'badge', + DESCRIPTION = 'description', + BOTTOM = 'bottom', +} + +export enum HeaderVariant { + PAGE = 'page', + APPLICATION = 'application', +} diff --git a/src/core/public/chrome/index.ts b/src/core/public/chrome/index.ts index 5347266d9c33..5403634705c4 100644 --- a/src/core/public/chrome/index.ts +++ b/src/core/public/chrome/index.ts @@ -58,6 +58,6 @@ export { } from './recently_accessed'; export { ChromeNavControl, ChromeNavControls } from './nav_controls'; export { ChromeDocTitle } from './doc_title'; -export { RightNavigationOrder } from './constants'; +export { RightNavigationOrder, HeaderVariant } from './constants'; export { ChromeRegistrationNavLink, ChromeNavGroupUpdater, NavGroupItemInMap } from './nav_group'; export { fulfillRegistrationLinksToChromeNavLinks, LinkItemType, getSortedNavLinks } from './utils'; diff --git a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap index 5489d9fcfdc5..82aa02fe79a0 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header.test.tsx.snap @@ -201,6 +201,60 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, }, }, + "currentBadgeControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentBottomControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentCenterControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentDescriptionControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentLeftControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, + "currentRightControls$": BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [], + "thrownError": null, + }, "getComponent": [MockFunction], "getUrlForApp": [MockFunction], "history": Object { @@ -225,6 +279,12 @@ exports[`Header handles visibility and lock changes 1`] = ` "navigateToApp": [MockFunction], "navigateToUrl": [MockFunction], "registerMountContext": [MockFunction], + "setAppBadgeControls": [MockFunction], + "setAppBottomControls": [MockFunction], + "setAppCenterControls": [MockFunction], + "setAppDescriptionControls": [MockFunction], + "setAppLeftControls": [MockFunction], + "setAppRightControls": [MockFunction], } } badge$={ @@ -337,6 +397,43 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, ], "thrownError": null, } @@ -541,6 +638,55 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + headerVariant$={ + BehaviorSubject { + "_isScalar": false, + "_value": undefined, + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + ], + "thrownError": null, + } + } helpExtension$={ BehaviorSubject { "_isScalar": false, @@ -2101,6 +2247,7 @@ exports[`Header handles visibility and lock changes 1`] = ` } } survey="/" + useUpdatedHeader={false} workspaceList$={ BehaviorSubject { "_isScalar": false, @@ -4084,6 +4231,43 @@ exports[`Header handles visibility and lock changes 1`] = ` "syncErrorThrown": false, "syncErrorValue": null, }, + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], + "closed": false, + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, + }, + "isStopped": false, + "syncErrorThrowable": true, + "syncErrorThrown": false, + "syncErrorValue": null, + }, ], "thrownError": null, } @@ -4137,6 +4321,7 @@ exports[`Header handles visibility and lock changes 1`] = ` "thrownError": null, } } + useUpdatedHeader={false} > @@ -4258,9 +4444,10 @@ exports[`Header handles visibility and lock changes 1`] = ` >
@@ -7022,7 +7210,7 @@ exports[`Header handles visibility and lock changes 1`] = ` `; -exports[`Header renders condensed header 1`] = ` +exports[`Header renders application header without title and breadcrumbs 1`] = `
- -
+ - -
- -
- - - - - - - - - - , - } - } - className="euiHeaderSectionItemButton" - color="text" + aria-pressed="false" + class="euiButtonEmpty euiButtonEmpty--text euiHeaderSectionItemButton newAppTopNavExpander" data-test-subj="toggleNavButton" - onClick={[Function]} + type="button" > - - - -
-
- + + , + } + } + className="euiHeaderSectionItemButton newAppTopNavExpander" + color="text" + data-test-subj="toggleNavButton" + onClick={[Function]} > -
- + + + + + + + + + + + + + + +
+ +
+ -
-
- -
- + } + workspaceList$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], + "_parentOrParents": null, + "_parentSubscriber": [Circular], + "_subscriptions": null, + "closed": false, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], + }, + "isStopped": false, + "syncErrorThrowable": false, + "syncErrorThrown": false, + "syncErrorValue": null, }, "isStopped": false, - "syncErrorThrowable": false, + "syncErrorThrowable": true, "syncErrorThrown": false, "syncErrorValue": null, }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, - } - } - logos={ - Object { - "AnimatedMark": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_spinner_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_spinner_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_spinner_on_light.svg", - }, - "Application": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_dashboards_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_dashboards_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_dashboards_on_light.svg", - }, - "CenterMark": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_center_mark_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_center_mark_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_center_mark_on_light.svg", - }, - "Mark": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_mark_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_mark_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_mark_on_light.svg", - }, - "OpenSearch": Object { - "dark": Object { - "type": "default", - "url": "/ui/logos/opensearch_on_dark.svg", - }, - "light": Object { - "type": "default", - "url": "/ui/logos/opensearch_on_light.svg", - }, - "type": "default", - "url": "/ui/logos/opensearch_on_light.svg", - }, - "colorScheme": "light", - } - } - navLinks$={ - BehaviorSubject { - "_isScalar": false, - "_value": Array [], - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - Subscriber { - "_parentOrParents": null, - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - "isStopped": false, - "syncErrorThrowable": true, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, + ], + "thrownError": null, + } } - } - navigateToApp={[MockFunction]} - > - - + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + initialFocus={false} + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + repositionOnScroll={true} > - - - - + + -
-
-
-
+ class="euiHeaderSectionItemButton__content" + > + + +
-
-
- , - } - } - className="euiHeaderSectionItemButton header__homeLoaderNavButton" - color="text" - data-test-subj="homeLoader" - href="/" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - > - - , } } + className="euiHeaderSectionItemButton headerRecentItemsButton" + color="text" + data-test-subj="recentItemsSectionButton" + onClick={[Function]} + size="s" > - - -
- - -
-
- - - - - -
+
+
- - -
-
- - - -
-
-
-
-
-
- - + + + +
+
+ + +
+ + +
+ +
+ +
+ +
+ + - - - - - - -
- -
-
- -
- -
- -
- -
- - -
+ + - + -
-
- -
+
+ +
+ + - + -
-
- -
- +
+
+
+ +
+
+
+
+ + + + + +`; + +exports[`Header renders condensed header 1`] = ` +
+
+
+ +
+ +
+ +
+ + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton" + color="text" + data-test-subj="toggleNavButton" + onClick={[Function]} + > + + + +
+
+ +
+ +
+
+ +
+ + + + + + + + +
+ +
+
+
+
+
+
+ , + } + } + className="euiHeaderSectionItemButton header__homeLoaderNavButton" + color="text" + data-test-subj="homeLoader" + href="/" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + + + + + +
+ + + + + +
+
+ + + + + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + + + + + + + +
+ +
+
+ +
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+
+ +
+ + + + + + + + } + closePopover={[Function]} + data-test-subj="helpMenuButton" + display="inlineBlock" + hasArrow={true} + id="headerHelpMenu" + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + repositionOnScroll={true} + > +
+
+ + + + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton" + color="text" + onBlur={[Function]} + onClick={[Function]} + onFocus={[Function]} + > + + + + + +
+
+
+
+
+
+
+
+ +
+ +
+ + + +
+
+`; + +exports[`Header renders page header with application title 1`] = ` +
+
+
+
+ +
+ + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton newPageTopNavExpander" + color="text" + data-test-subj="toggleNavButton" + onClick={[Function]} + > + + + + +
+ +
+ + } + workspaceList$={ + BehaviorSubject { + "_isScalar": false, + "_value": Array [], + "closed": false, + "hasError": false, + "isStopped": false, + "observers": Array [ + Subscriber { + "_parentOrParents": null, + "_subscriptions": Array [ + SubjectSubscription { + "_parentOrParents": [Circular], + "_subscriptions": null, + "closed": false, + "subject": [Circular], + "subscriber": [Circular], + }, + ], "closed": false, - "destination": Subscriber { + "destination": SafeSubscriber { + "_complete": undefined, + "_context": [Circular], + "_error": undefined, + "_next": [Function], "_parentOrParents": null, - "_subscriptions": Array [ - [Circular], - ], + "_parentSubscriber": [Circular], + "_subscriptions": null, "closed": false, - "destination": SafeSubscriber { - "_complete": undefined, - "_context": [Circular], - "_error": undefined, - "_next": [Function], - "_parentOrParents": null, - "_parentSubscriber": [Circular], - "_subscriptions": null, - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "isStopped": false, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, + "destination": Object { + "closed": true, + "complete": [Function], + "error": [Function], + "next": [Function], }, "isStopped": false, - "syncErrorThrowable": true, + "syncErrorThrowable": false, "syncErrorThrown": false, "syncErrorValue": null, }, - "isStopped": true, - "observables": Array [ - BehaviorSubject { - "_isScalar": false, - "_value": undefined, - "closed": false, - "hasError": false, - "isStopped": false, - "observers": Array [ - InnerSubscriber { - "_parentOrParents": [Circular], - "_subscriptions": Array [ - SubjectSubscription { - "_parentOrParents": [Circular], - "_subscriptions": null, - "closed": false, - "subject": [Circular], - "subscriber": [Circular], - }, - ], - "closed": false, - "destination": Object { - "closed": true, - "complete": [Function], - "error": [Function], - "next": [Function], - }, - "index": 1, - "isStopped": false, - "outerIndex": 0, - "outerValue": undefined, - "parent": [Circular], - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, - }, - [Circular], - ], - "resultSelector": undefined, + "isStopped": false, "syncErrorThrowable": true, "syncErrorThrown": false, "syncErrorValue": null, - "toRespond": 0, - "values": Array [ - undefined, - "", - ], }, - "syncErrorThrowable": false, - "syncErrorThrown": false, - "syncErrorValue": null, - }, - ], - "thrownError": null, + ], + "thrownError": null, + } } - } - opensearchDashboardsDocLink="/docs" - opensearchDashboardsVersion="1.0.0" - surveyLink="/" - useDefaultContent={true} + > + + + + } + closePopover={[Function]} + display="inlineBlock" + hasArrow={true} + initialFocus={false} + isOpen={false} + ownFocus={true} + panelPaddingSize="s" + repositionOnScroll={true} + > +
+
+ + + + + + + + + + , + } + } + className="euiHeaderSectionItemButton headerRecentItemsButton" + color="text" + data-test-subj="recentItemsSectionButton" + onClick={[Function]} + size="xs" + > + + + +
+
+
+
+
+
+
+
+ + + +
+ + + + +
+ + +
+ +
+ +
+ +
+

+ testTitle +

+
+
+
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+
+ + +
+ +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+
+ +
+ - - - - - +
+ +
+ + +
+ -
-
- - - - - - - - - - - - , - } - } - className="euiHeaderSectionItemButton" - color="text" - onBlur={[Function]} - onClick={[Function]} - onFocus={[Function]} - > - - - - - -
-
- - - -
-
-
- -
- + } + side="right" + /> +
+
+
+
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+ +
@@ -15303,9 +22231,10 @@ exports[`Header toggles primary navigation menu when clicked 1`] = ` >
diff --git a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap index 5080b23e99c2..441f43729e98 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/header_breadcrumbs.test.tsx.snap @@ -33,3 +33,18 @@ Array [ `; exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable 3`] = `null`; + +exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable with updated header 1`] = `null`; + +exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable with updated header 2`] = ` + + First + +`; + +exports[`HeaderBreadcrumbs renders updates to the breadcrumbs$ observable with updated header 3`] = `null`; diff --git a/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap b/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap index 208b6e181bbb..b9813f1e7d1a 100644 --- a/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap +++ b/src/core/public/chrome/ui/header/__snapshots__/recent_items.test.tsx.snap @@ -10,7 +10,7 @@ exports[`Recent items should render base element normally 1`] = ` class="euiPopover__anchor" > + class="euiButtonContent euiButton__content" + > + + + + + +
@@ -154,25 +163,34 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + + +
, @@ -251,25 +269,34 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + + +
diff --git a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap index 4dc6ce29141a..19ace22584f7 100644 --- a/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_menu/__snapshots__/data_source_menu.test.tsx.snap @@ -75,25 +75,31 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + +
@@ -106,25 +112,31 @@ Object {
- + class="euiButtonContent euiButton__content" + > + + + + +
, diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap index 443ec2faa18c..046869724c8c 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap +++ b/src/plugins/data_source_management/public/components/data_source_selectable/__snapshots__/data_source_selectable.test.tsx.snap @@ -209,27 +209,36 @@ Object {
- + + +
@@ -380,27 +389,36 @@ Object {
- + + +
, diff --git a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx index 3b679c2c047b..8ba9867a898e 100644 --- a/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selectable/data_source_selectable.tsx @@ -11,6 +11,7 @@ import { EuiPanel, EuiSelectable, EuiPopoverTitle, + EuiSelectableOption, } from '@elastic/eui'; import { ApplicationStart, @@ -37,7 +38,6 @@ import { DataSourceItem } from '../data_source_item'; import { NoDataSource } from '../no_data_source'; import './data_source_selectable.scss'; import { DataSourceDropDownHeader } from '../drop_down_header'; -import '../button_title.scss'; import './data_source_selectable.scss'; import { DataSourceMenuPopoverButton } from '../popover_button/popover_button'; @@ -311,7 +311,7 @@ export class DataSourceSelectable extends React.Component< listProps={{ onFocusBadge: false, }} - options={this.state.dataSourceOptions} + options={this.state.dataSourceOptions as Array>} onChange={(newOptions) => this.onChange(newOptions)} singleSelection={'always'} data-test-subj={'dataSourceSelectable'} diff --git a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx index c2d2fe94d992..dbec565dc7b5 100644 --- a/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx +++ b/src/plugins/data_source_management/public/components/data_source_selector/data_source_selector.tsx @@ -250,7 +250,7 @@ export class DataSourceSelector extends React.Component< isDisabled={this.props.disabled} fullWidth={this.props.fullWidth || false} data-test-subj={'dataSourceSelectorComboBox'} - renderOption={(option) => ( + renderOption={(option: EuiComboBoxOptionOption) => ( @@ -60,6 +61,7 @@ exports[`DataSourceView Should return error when provided datasource has been fi button={ @@ -115,6 +117,7 @@ exports[`DataSourceView When selected option is local cluster and hide local Clu button={ @@ -161,6 +164,7 @@ exports[`DataSourceView should call getDataSourceById when only pass id with no button={ @@ -217,6 +221,7 @@ exports[`DataSourceView should render normally with local cluster not hidden 1`] button={ @@ -277,122 +282,31 @@ Object { >
- -
-
- -
-
- ); }; diff --git a/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss index 980a335f35e2..e5af22b21874 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss +++ b/src/plugins/discover/public/application/components/default_discover_table/_table_cell.scss @@ -1,4 +1,4 @@ -$osdDocTableCellPadding: calc($ouiSizeM / 2); // corresponds to DataGrid medium cellPadding (6px) +$osdDocTableCellPadding: calc($euiSizeM / 2); // corresponds to DataGrid medium cellPadding (6px) .osdDocTable__detailsParent { border-top: none !important; @@ -47,7 +47,7 @@ $osdDocTableCellPadding: calc($ouiSizeM / 2); // corresponds to DataGrid medium top: 0; height: 100%; width: 100%; - background-image: linear-gradient(to right, transparent 0, $ouiColorEmptyShade 16px); + background-image: linear-gradient(to right, transparent 0, $euiColorEmptyShade 16px); z-index: 1; } diff --git a/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss b/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss index 59fa7c4dd4d0..92c60f9eafe7 100644 --- a/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss +++ b/src/plugins/discover/public/application/components/default_discover_table/_table_header.scss @@ -4,7 +4,7 @@ // nested for specificity .docTableHeaderField { - padding: calc($ouiSizeM / 2); // corresponds to DataGrid medium cellPadding + padding: calc($euiSizeM / 2); // corresponds to DataGrid medium cellPadding } } diff --git a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx index 3e0b00846930..647b989f42e4 100644 --- a/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx +++ b/src/plugins/discover/public/application/components/top_nav/get_top_nav_links.tsx @@ -8,7 +8,7 @@ import React from 'react'; import { DiscoverViewServices } from '../../../build_services'; import { SavedSearch } from '../../../saved_searches'; import { Adapters } from '../../../../../inspector/public'; -import { TopNavMenuData } from '../../../../../navigation/public'; +import { TopNavMenuData, TopNavMenuIconData } from '../../../../../navigation/public'; import { ISearchSource, unhashUrl } from '../../../opensearch_dashboards_services'; import { OnSaveProps, @@ -26,7 +26,7 @@ import { getRootBreadcrumbs } from '../../helpers/breadcrumbs'; import { syncQueryStateWithUrl } from '../../../../../data/public'; import { OpenSearchPanel } from './open_search_panel'; -export const getTopNavLinks = ( +const getLegacyTopNavLinks = ( services: DiscoverViewServices, inspectorAdapters: Adapters, savedSearch: SavedSearch, @@ -273,6 +273,242 @@ export const getTopNavLinks = ( return topNavLinksArray; }; +export const getTopNavLinks = ( + services: DiscoverViewServices, + inspectorAdapters: Adapters, + savedSearch: SavedSearch, + isEnhancementEnabled: boolean = false +) => { + const { + history, + inspector, + core, + capabilities, + share, + toastNotifications, + chrome, + store, + data: { query }, + osdUrlStateStorage, + uiSettings, + } = services; + + const showActionsInGroup = uiSettings.get('home:useNewHomePage'); + if (!showActionsInGroup) + return getLegacyTopNavLinks(services, inspectorAdapters, savedSearch, isEnhancementEnabled); + + const topNavLinksMap = new Map(); + + // New + const newSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.localMenu.newSearchTitle', { + defaultMessage: 'New', + }), + run() { + core.application.navigateToApp('discover', { + path: '#/', + }); + }, + testId: 'discoverNewButton', + ariaLabel: i18n.translate('discover.topNav.discoverNewButtonLabel', { + defaultMessage: `New Search`, + }), + iconType: 'plusInCircle', + controlType: 'icon', + }; + topNavLinksMap.set('new', newSearch); + + // Open + const openSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.openTitle', { + defaultMessage: 'Open', + }), + testId: 'discoverOpenButton', + ariaLabel: i18n.translate('discover.topNav.discoverOpenButtonLabel', { + defaultMessage: `Open Saved Search`, + }), + run: () => { + const flyoutSession = services.overlays.openFlyout( + toMountPoint( + + flyoutSession?.close?.().then()} + makeUrl={(searchId) => `#/view/${encodeURIComponent(searchId)}`} + /> + + ) + ); + }, + iconType: 'folderOpen', + controlType: 'icon', + }; + topNavLinksMap.set('open', openSearch); + + // Save + if (capabilities.discover?.save) { + const saveSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.saveTitle', { + defaultMessage: 'Save', + }), + testId: 'discoverSaveButton', + ariaLabel: i18n.translate('discover.topNav.discoverSaveButtonLabel', { + defaultMessage: `Save search`, + }), + run: async () => { + const onSave = async ({ + newTitle, + newCopyOnSave, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }: OnSaveProps) => { + const currentTitle = savedSearch.title; + savedSearch.title = newTitle; + savedSearch.copyOnSave = newCopyOnSave; + const saveOptions = { + confirmOverwrite: false, + isTitleDuplicateConfirmed, + onTitleDuplicate, + }; + + const state: DiscoverState = store!.getState().discover; // store is defined before the view is loaded + + savedSearch.columns = state.columns; + savedSearch.sort = state.sort; + + try { + const id = await savedSearch.save(saveOptions); + + // If the title is a duplicate, the id will be an empty string. Checking for this condition here + if (id) { + toastNotifications.addSuccess({ + title: i18n.translate('discover.notifications.savedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was saved`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + 'data-test-subj': 'saveSearchSuccess', + }); + + if (id !== state.savedSearch) { + history().push(`/view/${encodeURIComponent(id)}`); + } else { + chrome.docTitle.change(savedSearch.lastSavedTitle); + chrome.setBreadcrumbs([...getRootBreadcrumbs(), { text: savedSearch.title }]); + } + + // set App state to clean + store!.dispatch({ type: setSavedSearchId.type, payload: id }); + + // starts syncing `_g` portion of url with query services + syncQueryStateWithUrl(query, osdUrlStateStorage); + + return { id }; + } + } catch (error) { + toastNotifications.addDanger({ + title: i18n.translate('discover.notifications.notSavedSearchTitle', { + defaultMessage: `Search '{savedSearchTitle}' was not saved.`, + values: { + savedSearchTitle: savedSearch.title, + }, + }), + text: (error as Error).message, + }); + + // Reset the original title + savedSearch.title = currentTitle; + + return { error }; + } + }; + + const saveModal = ( + {}} + title={savedSearch.title} + showCopyOnSave={!!savedSearch.id} + objectType="search" + description={i18n.translate('discover.localMenu.saveSaveSearchDescription', { + defaultMessage: + 'Save your Discover search so you can use it in visualizations and dashboards', + })} + showDescription={false} + /> + ); + showSaveModal(saveModal, core.i18n.Context); + }, + iconType: 'save', + controlType: 'icon', + }; + topNavLinksMap.set('save', saveSearch); + } + + // Share + if (share) { + const shareSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.shareTitle', { + defaultMessage: 'Share', + }), + testId: 'shareTopNavButton', + ariaLabel: i18n.translate('discover.topNav.discoverShareButtonLabel', { + defaultMessage: `Share search`, + }), + run: async (anchorElement) => { + const state: DiscoverState = store!.getState().discover; // store is defined before the view is loaded + const sharingData = await getSharingData({ + searchSource: savedSearch.searchSource, + state, + services, + }); + share?.toggleShareContextMenu({ + anchorElement, + allowEmbed: false, + allowShortUrl: capabilities.discover?.createShortUrl as boolean, + shareableUrl: unhashUrl(window.location.href), + objectId: savedSearch.id, + objectType: 'search', + sharingData: { + ...sharingData, + title: savedSearch.title, + }, + isDirty: !savedSearch.id || state.isDirty || false, + }); + }, + iconType: 'share', + controlType: 'icon', + }; + topNavLinksMap.set('share', shareSearch); + } + + const inspectSearch: TopNavMenuIconData = { + tooltip: i18n.translate('discover.localMenu.inspectTitle', { + defaultMessage: 'Inspect', + }), + testId: 'openInspectorButton', + ariaLabel: i18n.translate('discover.topNav.discoverInspectorButtonLabel', { + defaultMessage: `Open Inspector for search`, + }), + run() { + inspector.open(inspectorAdapters, { + title: savedSearch?.title || undefined, + }); + }, + iconType: 'inspect', + controlType: 'icon', + }; + topNavLinksMap.set('inspect', inspectSearch); + + // Order their appearance + return ['save', 'open', 'new', 'inspect', 'share'].reduce((acc, item) => { + const itemDef = topNavLinksMap.get(item); + if (itemDef) acc.push(itemDef); + + return acc; + }, [] as TopNavMenuData[]); +}; + // TODO: This does not seem to affect the share menu. need to look into it in future // const getFieldCounts = async () => { // // the field counts aren't set until we have the data back, diff --git a/src/plugins/discover/public/application/view_components/canvas/index.tsx b/src/plugins/discover/public/application/view_components/canvas/index.tsx index 26e32c6c2df0..500c8ebdc80c 100644 --- a/src/plugins/discover/public/application/view_components/canvas/index.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/index.tsx @@ -34,13 +34,18 @@ import { OpenSearchSearchHit } from '../../../application/doc_views/doc_views_ty import { buildColumns } from '../../utils/columns'; import './discover_canvas.scss'; import { getNewDiscoverSetting, setNewDiscoverSetting } from '../../components/utils/local_storage'; +import { HeaderVariant } from '../../../../../../core/public'; // eslint-disable-next-line import/no-default-export export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalRef }: ViewProps) { const panelRef = useRef(null); const { data$, refetch$, indexPattern } = useDiscoverContext(); const { - services: { uiSettings, storage, capabilities }, + services: { + uiSettings, + capabilities, + chrome: { setHeaderVariant }, + }, } = useOpenSearchDashboards(); const { columns } = useSelector((state) => { const stateColumns = state.discover.columns; @@ -110,6 +115,13 @@ export default function DiscoverCanvas({ setHeaderActionMenu, history, optionalR } }, [dispatch, filteredColumns, indexPattern]); + useEffect(() => { + setHeaderVariant?.(HeaderVariant.APPLICATION); + return () => { + setHeaderVariant?.(); + }; + }, [setHeaderVariant]); + const timeField = indexPattern?.timeFieldName ? indexPattern.timeFieldName : undefined; const scrollToTop = () => { if (panelRef.current) { diff --git a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx index 76d6c9789095..fa3ee3524994 100644 --- a/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx +++ b/src/plugins/discover/public/application/view_components/canvas/top_nav.tsx @@ -7,6 +7,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { Query, TimeRange } from 'src/plugins/data/common'; import { createPortal } from 'react-dom'; import { EuiButtonIcon, EuiFlexGroup, EuiFlexItem, EuiToolTip } from '@elastic/eui'; +import { i18n } from '@osd/i18n'; import { AppMountParameters } from '../../../../../../core/public'; import { connectStorageToQueryState, opensearchFilters } from '../../../../../data/public'; import { useOpenSearchDashboards } from '../../../../../opensearch_dashboards_react/public'; @@ -20,6 +21,7 @@ import { useDispatch, setSavedQuery, useSelector } from '../../utils/state_manag import './discover_canvas.scss'; import { useDataSetManager } from '../utils/use_dataset_manager'; +import { TopNavMenuItemRenderType } from '../../../../../navigation/public'; export interface TopNavProps { opts: { @@ -35,6 +37,7 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro const { services } = useOpenSearchDashboards(); const { inspectorAdapters, savedSearch, indexPattern } = useDiscoverContext(); const [indexPatterns, setIndexPatterns] = useState(undefined); + const [screenTitle, setScreenTitle] = useState(''); const state = useSelector((s) => s.discover); const dispatch = useDispatch(); @@ -51,6 +54,8 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro uiSettings, } = services; + const showActionsInGroup = uiSettings.get('home:useNewHomePage'); + const topNavLinks = savedSearch ? getTopNavLinks(services, inspectorAdapters, savedSearch, isEnhancementsEnabled) : []; @@ -100,6 +105,15 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro } }, [chrome, getUrlForApp, savedSearch?.id, savedSearch?.title]); + useEffect(() => { + setScreenTitle( + savedSearch?.title || + i18n.translate('discover.savedSearch.newTitle', { + defaultMessage: 'Untitled', + }) + ); + }, [savedSearch?.title]); + const showDatePicker = useMemo(() => (indexPattern ? indexPattern.isTimeBased() : false), [ indexPattern, ]); @@ -134,8 +148,8 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro className={isEnhancementsEnabled ? 'topNav hidden' : ''} appName={PLUGIN_ID} config={topNavLinks} - showSearchBar - showDatePicker={showDatePicker} + showSearchBar={TopNavMenuItemRenderType.IN_PLACE} + showDatePicker={showDatePicker && TopNavMenuItemRenderType.IN_PORTAL} showSaveQuery={showSaveQuery} useDefaultBehaviors setMenuMountPoint={opts.setHeaderActionMenu} @@ -144,6 +158,8 @@ export const TopNav = ({ opts, showSaveQuery, isEnhancementsEnabled }: TopNavPro savedQueryId={state.savedQuery} onSavedQueryIdChange={updateSavedQueryId} datePickerRef={opts?.optionalRef?.datePickerRef} + groupActions={showActionsInGroup} + screenTitle={screenTitle} /> ); diff --git a/src/plugins/index_pattern_management/opensearch_dashboards.json b/src/plugins/index_pattern_management/opensearch_dashboards.json index 3f5dc47001b3..27d770436247 100644 --- a/src/plugins/index_pattern_management/opensearch_dashboards.json +++ b/src/plugins/index_pattern_management/opensearch_dashboards.json @@ -4,7 +4,7 @@ "server": true, "ui": true, "optionalPlugins": ["dataSource"], - "requiredPlugins": ["management", "data", "urlForwarding"], + "requiredPlugins": ["management", "navigation", "data", "urlForwarding"], "requiredBundles": ["opensearchDashboardsReact", "opensearchDashboardsUtils"], "supportedOSDataSourceVersions": ">=1.0.0" } diff --git a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx index 737182031f84..3a1d6323323c 100644 --- a/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx +++ b/src/plugins/index_pattern_management/public/components/index_pattern_table/index_pattern_table.tsx @@ -98,6 +98,7 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { uiSettings, indexPatternManagementStart, chrome, + navigationUI: { HeaderControl }, docLinks, application, http, @@ -105,6 +106,7 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { data, dataSourceEnabled, } = useOpenSearchDashboards().services; + const [indexPatterns, setIndexPatterns] = useState([]); const [creationOptions, setCreationOptions] = useState([]); const [sources, setSources] = useState([]); @@ -217,15 +219,51 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { }), ]; - const createButton = canSave ? ( - - { + if (!canSave) return null; + + const button = ( + + + + ); + + return showActionsInHeader ? ( + - + ) : ( + {button} + ); + })(); + + const description = ( + + ); + const pageTitleAndDescription = showActionsInHeader ? ( + ) : ( - <> + + +

{title}

+
+ + +

{description}

+
+
); if (isLoadingSources || isLoadingIndexPatterns) { @@ -263,21 +301,8 @@ export const IndexPatternTable = ({ canSave, history }: Props) => { <> - - -

{title}

-
- - -

- -

-
-
- {createButton} + {pageTitleAndDescription} + {createButton}
@@ -118,7 +121,12 @@ export async function mountManagementSection( {params.wrapInPage ? ( - + {content} ) : ( diff --git a/src/plugins/index_pattern_management/public/plugin.ts b/src/plugins/index_pattern_management/public/plugin.ts index 3e98374b3c80..b830680e9756 100644 --- a/src/plugins/index_pattern_management/public/plugin.ts +++ b/src/plugins/index_pattern_management/public/plugin.ts @@ -48,6 +48,7 @@ import { import { ManagementSetup } from '../../management/public'; import { DEFAULT_NAV_GROUPS, AppStatus, DEFAULT_APP_CATEGORIES } from '../../../core/public'; import { getScopedBreadcrumbs } from '../../opensearch_dashboards_react/public'; +import { NavigationPublicPluginStart } from '../../navigation/public'; export interface IndexPatternManagementSetupDependencies { management: ManagementSetup; @@ -57,6 +58,7 @@ export interface IndexPatternManagementSetupDependencies { export interface IndexPatternManagementStartDependencies { data: DataPublicPluginStart; + navigation: NavigationPublicPluginStart; dataSource?: DataSourcePluginStart; } diff --git a/src/plugins/index_pattern_management/public/types.ts b/src/plugins/index_pattern_management/public/types.ts index 7b2cd8575a7e..9a0d92de52cd 100644 --- a/src/plugins/index_pattern_management/public/types.ts +++ b/src/plugins/index_pattern_management/public/types.ts @@ -40,6 +40,7 @@ import { SavedObjectReference, } from 'src/core/public'; import { DataPublicPluginStart } from 'src/plugins/data/public'; +import { NavigationPublicPluginStart } from 'src/plugins/navigation/public'; import { EuiTableFieldDataColumnType } from '@elastic/eui'; import { ManagementAppMountParams } from '../../management/public'; import { IndexPatternManagementStart } from './index'; @@ -50,6 +51,7 @@ export interface IndexPatternManagmentContext { application: ApplicationStart; savedObjects: SavedObjectsStart; uiSettings: IUiSettingsClient; + navigationUI: NavigationPublicPluginStart['ui']; notifications: NotificationsStart; overlays: OverlayStart; http: HttpSetup; diff --git a/src/plugins/navigation/public/index.ts b/src/plugins/navigation/public/index.ts index c409f066730e..fe89f39c44df 100644 --- a/src/plugins/navigation/public/index.ts +++ b/src/plugins/navigation/public/index.ts @@ -35,7 +35,23 @@ export function plugin(initializerContext: PluginInitializerContext) { return new NavigationPublicPlugin(initializerContext); } -export { TopNavMenuData, TopNavMenu } from './top_nav_menu'; +export { + TopNavMenu, + TopNavMenuData, + TopNavMenuButtonData, + TopNavMenuSwitchData, + TopNavMenuIconData, + TopNavMenuLegacyData, + TopNavMenuItemRenderType, + TopNavControls, + TopNavControlData, + TopNavControlButtonData, + TopNavControlLinkData, + TopNavControlIconData, + TopNavControlTextData, + TopNavControlDescriptionData, + TopNavControlComponentData, +} from './top_nav_menu'; export { NavigationPublicPluginSetup, NavigationPublicPluginStart } from './types'; diff --git a/src/plugins/navigation/public/mocks.ts b/src/plugins/navigation/public/mocks.ts index 3c80117b9e7e..24347f988b55 100644 --- a/src/plugins/navigation/public/mocks.ts +++ b/src/plugins/navigation/public/mocks.ts @@ -45,6 +45,7 @@ const createStartContract = (): jest.Mocked => { const startContract = { ui: { TopNavMenu: jest.fn(), + HeaderControl: jest.fn(), }, }; return startContract; diff --git a/src/plugins/navigation/public/plugin.ts b/src/plugins/navigation/public/plugin.ts index 031fdb5153ae..a626f67210d1 100644 --- a/src/plugins/navigation/public/plugin.ts +++ b/src/plugins/navigation/public/plugin.ts @@ -28,13 +28,13 @@ * under the License. */ -import { PluginInitializerContext, CoreSetup, CoreStart, Plugin } from 'src/core/public'; +import { CoreSetup, CoreStart, Plugin, PluginInitializerContext } from 'src/core/public'; +import { TopNavMenuExtensionsRegistry, createTopNav, createTopNavControl } from './top_nav_menu'; import { + NavigationPluginStartDependencies, NavigationPublicPluginSetup, NavigationPublicPluginStart, - NavigationPluginStartDependencies, } from './types'; -import { TopNavMenuExtensionsRegistry, createTopNav } from './top_nav_menu'; export class NavigationPublicPlugin implements Plugin { @@ -51,14 +51,15 @@ export class NavigationPublicPlugin } public start( - { i18n }: CoreStart, + { i18n, chrome }: CoreStart, { data }: NavigationPluginStartDependencies ): NavigationPublicPluginStart { const extensions = this.topNavMenuExtensionsRegistry.getAll(); return { ui: { - TopNavMenu: createTopNav(data, extensions, i18n), + TopNavMenu: createTopNav(data, extensions, i18n, chrome.navGroup.getNavGroupEnabled()), + HeaderControl: createTopNavControl(i18n), }, }; } diff --git a/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap new file mode 100644 index 000000000000..1c62a3c148dc --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/__snapshots__/top_nav_controls.test.tsx.snap @@ -0,0 +1,122 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`TopNavControls renders TopNavControlItems when controls are provided 1`] = ` + +`; diff --git a/src/plugins/navigation/public/top_nav_menu/_index.scss b/src/plugins/navigation/public/top_nav_menu/_index.scss index 988153a1c6af..a4de5f473fb0 100644 --- a/src/plugins/navigation/public/top_nav_menu/_index.scss +++ b/src/plugins/navigation/public/top_nav_menu/_index.scss @@ -1,3 +1,69 @@ +@use "sass:map"; + .osdTopNavMenu { margin-right: $euiSizeXS; } + +.osdTopNavMenuGroupedActions { + background-color: $euiColorEmptyShade; + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > .euiSwitch, + & > .euiButton, + & > .euiButtonIcon, + & > .euiToolTipAnchor > .euiSwitch, + & > .euiToolTipAnchor > .euiButton, + & > .euiToolTipAnchor > .euiButtonIcon { + border-radius: 0; + border: $euiFormInputGroupBorder; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > :not(:first-child), + & > .euiToolTipAnchor:not(:first-child) > .euiSwitch, + & > .euiToolTipAnchor:not(:first-child) > .euiButton, + & > .euiToolTipAnchor:not(:first-child) > .euiButtonIcon { + border-left-width: 0; + border-bottom-left-radius: 0; + border-top-left-radius: 0; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > :not(:last-child), + & > .euiToolTipAnchor:not(:last-child) > .euiSwitch, + & > .euiToolTipAnchor:not(:last-child) > .euiButton, + & > .euiToolTipAnchor:not(:last-child) > .euiButtonIcon { + border-bottom-right-radius: 0; + border-top-right-radius: 0; + + // border-right-color: map.get($euiSwitchColors, "text") !important; + } + + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + & > .osdTopNavGroup-isDisabled:not(:last-child):has(+ .osdTopNavGroup-isDisabled), + & > .osdTopNavGroup-isDisabled.euiToolTipAnchor:not(:last-child):has(+ .osdTopNavGroup-isDisabled) > .euiButton, + & > .osdTopNavGroup-isDisabled.euiToolTipAnchor:not(:last-child):has(+ .osdTopNavGroup-isDisabled) > .euiButtonIcon { + // border-right-color: $euiButtonColorDisabled !important; + } +} + +.osdTopNavGroup { + &--button { + min-width: auto; + } +} + +.osdTopNavGroup-isDisabled { + cursor: not-allowed; +} + +.osdTopNavMenuScreenTitle { + // stylelint-disable-next-line @osd/stylelint/no_modifying_global_selectors + .euiText { + line-height: $euiFormControlCompressedHeight; + white-space: nowrap; + max-width: 18ch; + overflow: hidden; + text-overflow: ellipsis; + } +} diff --git a/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx index dc4ce63e514a..7af0bc49c0b2 100644 --- a/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/create_top_nav_menu.tsx @@ -28,16 +28,18 @@ * under the License. */ -import React from 'react'; import { I18nStart } from 'opensearch-dashboards/public'; +import React from 'react'; import { DataPublicPluginStart } from 'src/plugins/data/public'; -import { TopNavMenuProps, TopNavMenu } from './top_nav_menu'; +import { TopNavControls, TopNavControlsProps } from './top_nav_controls'; +import { TopNavMenu, TopNavMenuProps } from './top_nav_menu'; import { RegisteredTopNavMenuData } from './top_nav_menu_data'; export function createTopNav( data: DataPublicPluginStart, extraConfig: RegisteredTopNavMenuData[], - i18n: I18nStart + i18n: I18nStart, + groupActions?: boolean ) { return (props: TopNavMenuProps) => { const relevantConfig = extraConfig.filter( @@ -47,7 +49,17 @@ export function createTopNav( return ( - + + + ); + }; +} + +export function createTopNavControl(i18n: I18nStart) { + return (props: TopNavControlsProps) => { + return ( + + ); }; diff --git a/src/plugins/navigation/public/top_nav_menu/index.ts b/src/plugins/navigation/public/top_nav_menu/index.ts index fdbf95b95c21..1b84f5503610 100644 --- a/src/plugins/navigation/public/top_nav_menu/index.ts +++ b/src/plugins/navigation/public/top_nav_menu/index.ts @@ -28,9 +28,28 @@ * under the License. */ -export { createTopNav } from './create_top_nav_menu'; -export { TopNavMenu, TopNavMenuProps } from './top_nav_menu'; -export { TopNavMenuData } from './top_nav_menu_data'; +export { createTopNav, createTopNavControl } from './create_top_nav_menu'; +export { TopNavMenu, TopNavMenuProps, TopNavMenuItemRenderType } from './top_nav_menu'; +export { TopNavControls, TopNavControlsProps } from './top_nav_controls'; +export { + TopNavControlData, + TopNavControlButtonData, + TopNavControlLinkData, + TopNavControlIconData, + TopNavControlTextData, + TopNavControlDescriptionData, + TopNavControlComponentData, +} from './top_nav_control_data'; +export { + TopNavMenuData, + TopNavMenuButtonData, + TopNavMenuSwitchData, + TopNavMenuIconData, + TopNavMenuLegacyData, + TopNavMenuSwitchAction, + TopNavMenuClickAction, + TopNavMenuAction, +} from './top_nav_menu_data'; export { TopNavMenuExtensionsRegistrySetup, TopNavMenuExtensionsRegistry, diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx new file mode 100644 index 000000000000..83fa46e69508 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_data.tsx @@ -0,0 +1,93 @@ +/* + * Copyright OpenSearch Contributors + * SPDX-License-Identifier: Apache-2.0 + */ + +import { EuiButtonProps, EuiTextProps, EuiHeaderLinkProps, EuiButtonIconProps } from '@elastic/eui'; + +export type TopNavControlAction = (targetElement: HTMLElement) => void; + +type RequireAtLeastOne = Pick> & + { + [K in Keys]-?: Required> & Partial>>; + }[Keys]; + +interface TopNavControlButtonOrLinkOrIconData { + // @deprecated + id?: string; + testId?: string; + className?: string; + isDisabled?: boolean | (() => boolean); + tooltip?: string | (() => string | undefined); + ariaLabel?: string; + target?: '_blank'; + iconSize?: EuiButtonProps['iconSize']; +} + +export type TopNavControlLinkData = TopNavControlButtonOrLinkOrIconData & + RequireAtLeastOne< + { + label: string; + isLoading?: boolean; + href?: string; + run?: TopNavControlAction; + iconType?: EuiHeaderLinkProps['iconType']; + iconSide?: EuiHeaderLinkProps['iconSide']; + color?: EuiHeaderLinkProps['color']; + controlType: 'link'; + }, + 'href' | 'run' + >; + +export type TopNavControlButtonData = TopNavControlButtonOrLinkOrIconData & + RequireAtLeastOne< + { + label: string; + isLoading?: boolean; + href?: string; + run?: TopNavControlAction; + iconType?: EuiButtonProps['iconType']; + iconSide?: EuiButtonProps['iconSide']; + color?: EuiButtonProps['color']; + fill?: EuiButtonProps['fill']; + controlType?: 'button'; + }, + 'href' | 'run' + >; + +export type TopNavControlIconData = TopNavControlButtonOrLinkOrIconData & + RequireAtLeastOne< + { + iconType: EuiButtonIconProps['iconType']; + ariaLabel: string; + href?: string; + run?: TopNavControlAction; + display?: EuiButtonIconProps['display']; + color?: EuiButtonIconProps['color']; + controlType: 'icon'; + }, + 'href' | 'run' + >; + +export interface TopNavControlTextData { + text: string; + className?: string; + textAlign?: EuiTextProps['textAlign']; + color?: EuiTextProps['color']; +} + +export interface TopNavControlDescriptionData { + description: string; +} + +export interface TopNavControlComponentData { + renderComponent: React.ReactElement; +} + +export type TopNavControlData = + | TopNavControlButtonData + | TopNavControlLinkData + | TopNavControlIconData + | TopNavControlTextData + | TopNavControlDescriptionData + | TopNavControlComponentData; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx new file mode 100644 index 000000000000..06266170d158 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.test.tsx @@ -0,0 +1,108 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +import { EuiButton, EuiButtonIcon, EuiHeaderLink, EuiText, EuiToolTip } from '@elastic/eui'; +import { ShallowWrapper } from 'enzyme'; +import React from 'react'; +import { shallowWithIntl } from '../../../../test_utils/public/enzyme_helpers'; +import { TopNavControlData } from './top_nav_control_data'; +import { TopNavControlItem } from './top_nav_control_item'; + +// Mock props for different scenarios +const buttonProps: TopNavControlData = { + controlType: 'button', + label: 'Button', + run: jest.fn(), +}; + +const linkProps: TopNavControlData = { + controlType: 'link', + label: 'Link', + href: 'http://example.com', +}; + +const iconProps: TopNavControlData = { + controlType: 'icon', + iconType: 'user', + ariaLabel: 'Icon', + run: jest.fn(), +}; + +const textProps: TopNavControlData = { + text: 'Text Content', +}; + +const descriptionProps: TopNavControlData = { + description: 'Description Content', +}; + +const componentProps: TopNavControlData = { + renderComponent:
Custom Component
, +}; + +describe('TopNavControlItem', () => { + it('renders a button control', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiButton)).toHaveLength(1); + expect(wrapper.find(EuiButton).prop('onClick')).toBeDefined(); + }); + + it('renders a link control', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiHeaderLink)).toHaveLength(1); + expect(wrapper.find(EuiHeaderLink).prop('href')).toEqual(linkProps.href); + }); + + it('renders an icon control', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiButtonIcon)).toHaveLength(1); + expect(wrapper.find(EuiButtonIcon).prop('iconType')).toEqual(iconProps.iconType); + }); + + it('renders text content', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiText)).toHaveLength(1); + expect(wrapper.find(EuiText).children().text()).toEqual(textProps.text); + }); + + it('renders description content', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiText)).toHaveLength(1); + expect(wrapper.find(EuiText).children().text()).toEqual(descriptionProps.description); + }); + + it('renders a custom component', () => { + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.contains(componentProps.renderComponent)).toBe(true); + }); + + it('handles disabled state correctly', () => { + const disabledProps = { ...buttonProps, isDisabled: true }; + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiButton).prop('isDisabled')).toBe(true); + }); + + it('handles tooltip correctly', () => { + const tooltipProps = { ...buttonProps, tooltip: 'Tooltip text' }; + const wrapper: ShallowWrapper = shallowWithIntl(); + expect(wrapper.find(EuiToolTip)).toHaveLength(1); + expect(wrapper.find(EuiToolTip).prop('content')).toEqual('Tooltip text'); + }); + + it('calls run function on button click', () => { + const mockEvent = { currentTarget: document.createElement('button') } as React.MouseEvent< + HTMLButtonElement + >; + const wrapper: ShallowWrapper = shallowWithIntl(); + wrapper.find(EuiButton).simulate('click', mockEvent); + expect(buttonProps.run).toHaveBeenCalledWith(mockEvent.currentTarget); + }); +}); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx new file mode 100644 index 000000000000..ada02dba27bf --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_control_item.tsx @@ -0,0 +1,150 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import { EuiButton, EuiHeaderLink, EuiButtonIcon, EuiText, EuiToolTip } from '@elastic/eui'; +import { upperFirst } from 'lodash'; +import React, { MouseEvent } from 'react'; +import { TopNavControlData } from './top_nav_control_data'; + +export function TopNavControlItem(props: TopNavControlData) { + if ('renderComponent' in props) return props.renderComponent; + + if ('text' in props) { + const { text, ...rest } = props; + return ( + + {text} + + ); + } + + if ('description' in props) { + return ( + + {props.description} + + ); + } + + function isDisabled(): boolean { + if ('isDisabled' in props) { + const val = typeof props.isDisabled === 'function' ? props.isDisabled() : props.isDisabled; + return val || false; + } + return false; + } + + function getTooltip(): string { + if ('tooltip' in props) { + const val = typeof props.tooltip === 'function' ? props.tooltip() : props.tooltip; + return val || ''; + } + return ''; + } + + function handleClick(e: MouseEvent) { + if ('run' in props && !isDisabled()) props.run?.(e.currentTarget); + } + + let component; + switch (props.controlType) { + case 'icon': + component = ( + + ); + break; + + case 'link': + component = ( + + {upperFirst(props.label || props.id)} + + ); + break; + + default: + component = ( + <> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {upperFirst(props.label || props.id)} + + + ); + } + + const tooltip = getTooltip(); + if (tooltip) { + return {component}; + } + return component; +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx new file mode 100644 index 000000000000..34419f6229af --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.test.tsx @@ -0,0 +1,67 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React from 'react'; +import { mountWithIntl } from '../../../../test_utils/public/enzyme_helpers'; +import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; +import { TopNavControlData } from './top_nav_control_data'; +import { TopNavControls, TopNavControlsProps } from './top_nav_controls'; + +// Mock props for different scenarios +const controls: TopNavControlData[] = [ + { controlType: 'button', label: 'Button', run: jest.fn() }, + { controlType: 'link', label: 'Link', href: 'http://example.com' }, +]; + +describe('TopNavControls', () => { + it('renders null when controls is not provided', () => { + const props: TopNavControlsProps = {}; + const wrapper = mountWithIntl(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders null when controls is an empty array', () => { + const props: TopNavControlsProps = { controls: [] }; + const wrapper = mountWithIntl(); + expect(wrapper.isEmptyRender()).toBe(true); + }); + + it('renders TopNavControlItems when controls are provided', () => { + const props: TopNavControlsProps = { controls }; + const wrapper = mountWithIntl(); + expect(wrapper).toMatchSnapshot(); + }); + + it('renders MountPointPortal when setMountPoint is provided', () => { + const setMountPoint = jest.fn(); + const props: TopNavControlsProps = { controls, setMountPoint }; + const wrapper = mountWithIntl(); + expect(wrapper.find(MountPointPortal)).toHaveLength(1); + }); +}); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx new file mode 100644 index 000000000000..4b1731143b65 --- /dev/null +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_controls.tsx @@ -0,0 +1,71 @@ +/* + * SPDX-License-Identifier: Apache-2.0 + * + * The OpenSearch Contributors require contributions made to + * this file be licensed under the Apache-2.0 license or a + * compatible open source license. + * + * Any modifications Copyright OpenSearch Contributors. See + * GitHub history for details. + */ + +/* + * Licensed to Elasticsearch B.V. under one or more contributor + * license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright + * ownership. Elasticsearch B.V. licenses this file to you under + * the Apache License, Version 2.0 (the "License"); you may + * not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +import React, { ReactElement } from 'react'; +import { EuiHeaderSectionItem } from '@elastic/eui'; + +import { MountPoint } from 'opensearch-dashboards/public'; +import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; +import { TopNavControlItem } from './top_nav_control_item'; +import { TopNavControlData } from './top_nav_control_data'; + +export interface TopNavControlsProps { + controls?: TopNavControlData[]; + className?: string; + setMountPoint?: (menuMount: MountPoint | undefined) => void; +} + +export function TopNavControls(props: TopNavControlsProps): ReactElement | null { + const { controls } = props; + + if (!Array.isArray(controls) || controls.length === 0) { + return null; + } + + function renderItems(): ReactElement[] { + return controls!.map((menuItem: TopNavControlData, i: number) => { + return ( + + + + ); + }); + } + + function renderLayout() { + const { setMountPoint } = props; + + return setMountPoint ? ( + {renderItems()} + ) : null; + } + + return renderLayout(); +} diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx index 99644e49bde0..6e9b4fc1810f 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.test.tsx @@ -32,9 +32,10 @@ import React from 'react'; import { ReactWrapper } from 'enzyme'; import { act } from 'react-dom/test-utils'; import { MountPoint } from 'opensearch-dashboards/public'; -import { TopNavMenu } from './top_nav_menu'; +import { mountWithIntl, shallowWithIntl } from 'test_utils/enzyme_helpers'; +import { TopNavMenu, TopNavMenuItemRenderType } from './top_nav_menu'; import { TopNavMenuData } from './top_nav_menu_data'; -import { shallowWithIntl, mountWithIntl } from 'test_utils/enzyme_helpers'; +import { applicationServiceMock, uiSettingsServiceMock } from '../../../../core/public/mocks'; import * as testUtils from '../../../data_source_management/public/components/utils'; import { DataSourceSelectionService } from '../../../data_source_management/public/service/data_source_selection_service'; @@ -222,5 +223,105 @@ describe('TopNavMenu', () => { // menu is rendered outside of the component expect(component.find(TOP_NAV_ITEM_SELECTOR).length).toBe(0); }); + + it('mounts the data source menu with group actions enabled', async () => { + spyOn(testUtils, 'getApplication').and.returnValue(applicationServiceMock); + spyOn(testUtils, 'getUiSettings').and.returnValue( + uiSettingsServiceMock.createStartContract() + ); + spyOn(testUtils, 'getHideLocalCluster').and.returnValue(true); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + expect(component.find('.osdTopNavMenuScreenTitle').exists()).toBeFalsy(); + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(0); + }); + + it('mounts without data source menu with group actions enabled and showSearchBar in portal', async () => { + spyOn(testUtils, 'getApplication').and.returnValue(applicationServiceMock); + spyOn(testUtils, 'getUiSettings').and.returnValue( + uiSettingsServiceMock.createStartContract() + ); + spyOn(testUtils, 'getHideLocalCluster').and.returnValue(false); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + await (() => { + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); + expect(component.find('.osdTopNavMenuScreenTitle').exists()).toBeTruthy(); + }); + }); + + it('mounts without data source menu with group actions enabled and showSearchBar in place', async () => { + spyOn(testUtils, 'getApplication').and.returnValue(applicationServiceMock); + spyOn(testUtils, 'getUiSettings').and.returnValue( + uiSettingsServiceMock.createStartContract() + ); + spyOn(testUtils, 'getHideLocalCluster').and.returnValue(false); + spyOn(testUtils, 'getDataSourceSelection').and.returnValue(dataSourceSelection); + + const component = mountWithIntl( + + ); + + act(() => { + mountPoint(portalTarget); + }); + + await refresh(); + + await (() => { + expect(component.find(SEARCH_BAR_SELECTOR).length).toBe(1); + expect(component.find('.osdTopNavMenuScreenTitle').exists()).toBeTruthy(); + expect(component.find('.globalDatePicker').exists()).toBeTruthy(); + }); + }); }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx index 7bce0e01470d..e8e3489fe285 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu.tsx @@ -28,32 +28,39 @@ * under the License. */ -import React, { ReactElement } from 'react'; -import { EuiHeaderLinks } from '@elastic/eui'; +import { EuiFlexGroup, EuiFlexItem, EuiHeaderLinks, EuiText } from '@elastic/eui'; import classNames from 'classnames'; +import React, { ReactElement, useRef } from 'react'; import { MountPoint } from '../../../../core/public'; -import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; import { - StatefulSearchBarProps, DataPublicPluginStart, SearchBarProps, + StatefulSearchBarProps, } from '../../../data/public'; +import { DataSourceMenuProps, createDataSourceMenu } from '../../../data_source_management/public'; +import { MountPointPortal } from '../../../opensearch_dashboards_react/public'; import { TopNavMenuData } from './top_nav_menu_data'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { DataSourceMenuProps, createDataSourceMenu } from '../../../data_source_management/public'; -export type TopNavMenuProps = StatefulSearchBarProps & - Omit & { +export enum TopNavMenuItemRenderType { + IN_PORTAL = 'in_portal', + IN_PLACE = 'in_place', + OMITTED = 'omitted', +} + +export type TopNavMenuProps = Omit & + Omit & { config?: TopNavMenuData[]; dataSourceMenuConfig?: DataSourceMenuProps; - showSearchBar?: boolean; + showSearchBar?: boolean | TopNavMenuItemRenderType; showQueryBar?: boolean; showQueryInput?: boolean; - showDatePicker?: boolean; + showDatePicker?: boolean | TopNavMenuItemRenderType; showFilterBar?: boolean; showDataSourceMenu?: boolean; data?: DataPublicPluginStart; + groupActions?: boolean; className?: string; datePickerRef?: any; /** @@ -90,11 +97,16 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { const { config, showSearchBar, + showDatePicker, showDataSourceMenu, dataSourceMenuConfig, + groupActions, + screenTitle, ...searchBarProps } = props; + const datePickerRef = useRef(null); + if ( (!config || config.length === 0) && (!showSearchBar || !props.data) && @@ -103,11 +115,17 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { return null; } - function renderItems(): ReactElement[] | null { + function renderItems(): ReactElement | ReactElement[] | null { if (!config || config.length === 0) return null; - return config.map((menuItem: TopNavMenuData, i: number) => { + const renderedItems = config.map((menuItem: TopNavMenuData, i: number) => { return ; }); + + return groupActions ? ( +
{renderedItems}
+ ) : ( + renderedItems + ); } function renderMenu(className: string): ReactElement | null { @@ -127,17 +145,84 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { return ; } - function renderSearchBar(): ReactElement | null { + function renderSearchBar(overrides: Partial = {}): ReactElement | null { // Validate presence of all required fields if (!showSearchBar || !props.data) return null; const { SearchBar } = props.data.ui; - return ; + return ( + + ); } function renderLayout() { const { setMenuMountPoint } = props; const menuClassName = classNames('osdTopNavMenu', props.className); + if (setMenuMountPoint) { + if (groupActions) { + switch (showSearchBar) { + case TopNavMenuItemRenderType.IN_PORTAL: + return ( + <> + + + + {screenTitle} + + {renderMenu(menuClassName)} + {renderSearchBar()} + + + + ); + + case false: + case TopNavMenuItemRenderType.OMITTED: + return ( + <> + + {renderMenu(menuClassName)} + + + ); + + // Show the SearchBar in-place + default: + if (showDatePicker === TopNavMenuItemRenderType.IN_PORTAL) { + return ( + <> + + + + {screenTitle} + + {renderMenu(menuClassName)} + +
+ + + + {renderSearchBar({ datePickerRef })} + + ); + } + + return ( + <> + + {renderMenu(menuClassName)} + + {renderSearchBar()} + + ); + } + } + + // Legacy rendering behavior when setMenuMountPoint is set return ( <> @@ -146,14 +231,14 @@ export function TopNavMenu(props: TopNavMenuProps): ReactElement | null { {renderSearchBar()} ); - } else { - return ( - <> - {renderMenu(menuClassName)} - {renderSearchBar()} - - ); } + + return ( + <> + {renderMenu(menuClassName)} + {renderSearchBar()} + + ); } return renderLayout(); @@ -167,4 +252,5 @@ TopNavMenu.defaultProps = { showFilterBar: true, showDataSourceMenu: false, screenTitle: '', + groupActions: false, }; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx index c7a3220a896e..6596be3949e4 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_data.tsx @@ -28,12 +28,15 @@ * under the License. */ -import { EuiButtonProps } from '@elastic/eui'; +import { EuiButtonProps, EuiButtonIconProps } from '@elastic/eui'; import { EuiIconType } from '@elastic/eui/src/components/icon/icon'; export type TopNavMenuAction = (anchorElement: HTMLElement) => void; +export type TopNavMenuClickAction = (targetElement: HTMLElement) => void; +export type TopNavMenuSwitchAction = (targetElement: HTMLElement, checked: boolean) => void; -export interface TopNavMenuData { +// @deprecated +export interface TopNavMenuLegacyData { id?: string; label: string; run: TopNavMenuAction; @@ -50,6 +53,60 @@ export interface TopNavMenuData { type?: 'toggle' | 'button'; } -export interface RegisteredTopNavMenuData extends TopNavMenuData { - appName?: string; +type RequireAtLeastOne = Pick> & + { + [K in Keys]-?: Required> & Partial>>; + }[Keys]; + +interface TopNavMenuCommonData { + testId?: string; + className?: string; + disabled?: boolean | (() => boolean); + tooltip?: string | (() => string | undefined); } + +export type TopNavMenuButtonData = TopNavMenuCommonData & + RequireAtLeastOne< + { + label: string; + iconType?: EuiButtonProps['iconType']; + iconSide?: EuiButtonProps['iconSide']; + ariaLabel?: string; + isLoading?: boolean; + run?: TopNavMenuClickAction; + href?: string; + controlType: 'button'; + }, + 'href' | 'run' + >; + +export type TopNavMenuIconData = TopNavMenuCommonData & + RequireAtLeastOne< + { + iconType: EuiButtonIconProps['iconType']; + ariaLabel: string; + run?: TopNavMenuClickAction; + href?: string; + tooltip: string | (() => string | undefined); + controlType: 'icon'; + }, + 'href' | 'run' + >; + +export type TopNavMenuSwitchData = TopNavMenuCommonData & { + label: string; + ariaLabel?: string; + checked: boolean | (() => boolean); + run: TopNavMenuSwitchAction; + controlType: 'switch'; +}; + +export type TopNavMenuData = + | TopNavMenuLegacyData + | TopNavMenuButtonData + | TopNavMenuIconData + | TopNavMenuSwitchData; + +export type RegisteredTopNavMenuData = TopNavMenuData & { + appName?: string; +}; diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx index 7e759d2a6c09..7dba80698893 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.test.tsx @@ -28,9 +28,15 @@ * under the License. */ +import { EuiButton, EuiButtonIcon, EuiSwitch, EuiToolTip } from '@elastic/eui'; import React from 'react'; import { TopNavMenuItem } from './top_nav_menu_item'; -import { TopNavMenuData } from './top_nav_menu_data'; +import { + TopNavMenuData, + TopNavMenuButtonData, + TopNavMenuIconData, + TopNavMenuSwitchData, +} from './top_nav_menu_data'; import { shallowWithIntl } from 'test_utils/enzyme_helpers'; describe('TopNavMenu', () => { @@ -138,4 +144,56 @@ describe('TopNavMenu', () => { run: jest.fn(), }); }); + + const defaultProps = { + label: 'Test Label', + run: jest.fn(), + testId: 'test-id', + }; + + it('Should render a button with tooltip', () => { + const props = { + ...defaultProps, + controlType: 'button', + tooltip: 'Test Tooltip', + } as TopNavMenuButtonData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiToolTip).length).toBe(1); + expect(wrapper.find(EuiButton).length).toBe(1); + }); + + it('Should render an icon button', () => { + const props = { + ...defaultProps, + controlType: 'icon', + iconType: 'alert', + tooltip: 'Test Tooltip', + ariaLabel: 'Test', + } as TopNavMenuIconData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiButtonIcon).length).toBe(1); + }); + + it('Should render a switch', () => { + const props = { ...defaultProps, controlType: 'switch', checked: true } as TopNavMenuSwitchData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiSwitch).length).toBe(1); + }); + + it('Should handles button click', () => { + const props = { ...defaultProps, controlType: 'button' } as TopNavMenuButtonData; + const wrapper = shallowWithIntl(); + wrapper.find(EuiButton).simulate('click', { currentTarget: {} }); + expect(props.run).toHaveBeenCalled(); + }); + + it('Should disable the button when disabled is true', () => { + const props = { + ...defaultProps, + controlType: 'button', + disabled: true, + } as TopNavMenuButtonData; + const wrapper = shallowWithIntl(); + expect(wrapper.find(EuiButton).props().isDisabled).toBe(true); + }); }); diff --git a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx index 629ae019407c..f594a092833a 100644 --- a/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx +++ b/src/plugins/navigation/public/top_nav_menu/top_nav_menu_item.tsx @@ -30,10 +30,25 @@ import { upperFirst, isFunction } from 'lodash'; import React, { MouseEvent } from 'react'; -import { EuiToolTip, EuiButton, EuiHeaderLink, EuiCompressedSwitch } from '@elastic/eui'; -import { TopNavMenuData } from './top_nav_menu_data'; +import classNames from 'classnames'; +import { + EuiToolTip, + EuiButton, + EuiHeaderLink, + EuiCompressedSwitch, + EuiButtonIcon, + EuiSwitch, + EuiSwitchEvent, +} from '@elastic/eui'; +import { + TopNavMenuClickAction, + TopNavMenuData, + TopNavMenuLegacyData, + TopNavMenuSwitchAction, + TopNavMenuSwitchData, +} from './top_nav_menu_data'; -export function TopNavMenuItem(props: TopNavMenuData) { +function TopNavMenuLegacyItem(props: TopNavMenuLegacyData) { function isDisabled(): boolean { const val = isFunction(props.disableButton) ? props.disableButton() : props.disableButton; return val!; @@ -91,6 +106,103 @@ export function TopNavMenuItem(props: TopNavMenuData) { return component; } +export function TopNavMenuItem(props: TopNavMenuData) { + if (!('controlType' in props)) return TopNavMenuLegacyItem(props); + + const { disabled, tooltip, run } = props as Exclude; + + const isDisabled = () => Boolean(typeof disabled === 'function' ? disabled() : disabled); + + const handleClick = (e: MouseEvent) => { + if (!isDisabled()) (run as TopNavMenuClickAction)?.(e.currentTarget); + }; + + const getComponent = (addTypeClassName: boolean = false) => { + const className = classNames(props.className, { + [`osdTopNavGroup--${props.controlType}`]: addTypeClassName, + 'osdTopNavGroup-isDisabled': isDisabled(), + }); + switch (props.controlType) { + case 'button': + return ( + <> + {/* eslint-disable-next-line @elastic/eui/href-or-on-click */} + + {props.label} + + + ); + + case 'icon': + return ( + + ); + + case 'switch': + const { checked } = props as TopNavMenuSwitchData; + + const isChecked = () => Boolean(typeof checked === 'function' ? checked() : checked); + + const handleSwitch = (e: EuiSwitchEvent) => { + if (!isDisabled()) (run as TopNavMenuSwitchAction)?.(e.currentTarget, e.target.checked); + }; + + return ( + + ); + } + }; + + const tooltipContent = typeof tooltip === 'function' ? tooltip() : tooltip; + + if (tooltipContent) { + const className = classNames(`osdTopNavGroup--${props.controlType}`, { + 'osdTopNavGroup-isDisabled': isDisabled(), + }); + return ( + + {getComponent()} + + ); + } + + return getComponent(true); +} + TopNavMenuItem.defaultProps = { disableButton: false, tooltip: '', diff --git a/src/plugins/navigation/public/types.ts b/src/plugins/navigation/public/types.ts index 89b0a3ca6322..9e1c3ffc812a 100644 --- a/src/plugins/navigation/public/types.ts +++ b/src/plugins/navigation/public/types.ts @@ -28,7 +28,11 @@ * under the License. */ -import { TopNavMenuProps, TopNavMenuExtensionsRegistrySetup } from './top_nav_menu'; +import { + TopNavMenuProps, + TopNavMenuExtensionsRegistrySetup, + TopNavControlsProps, +} from './top_nav_menu'; import { DataPublicPluginStart } from '../../data/public'; export interface NavigationPublicPluginSetup { @@ -38,6 +42,7 @@ export interface NavigationPublicPluginSetup { export interface NavigationPublicPluginStart { ui: { TopNavMenu: React.ComponentType; + HeaderControl: React.ComponentType; }; } diff --git a/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap b/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap index 447d5876fa10..8f16f7a650f0 100644 --- a/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap +++ b/src/plugins/region_map/public/components/__snapshots__/region_map_options.test.tsx.snap @@ -271,7 +271,7 @@ exports[`region_map_options renders the RegionMapOptions with custom option if c onMouseOver={[Function]} >
+
+
-
-

- Workspaces -

-
-
-

- Workspace allow you to save and organize library items, such as index patterns, visualizations, dashboards, saved searches, and share them with other OpenSearch Dashboards users. You can control which features are visible in each workspace, and which users and groups have read and write access to the library items in the workspace. -

-
-
-
-
- -
-
-
+
- -
- -
+ aria-hidden="true" + class="euiFormControlLayoutCustomIcon__icon" + data-euiicon-type="search" + /> +
-
-
-
+
+
+
+
+
+
-
-
- -
+ +
- - - - - - - - + + + +
+
- - -
-
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + - + +
+
+ + + + + + + Description + + + + + + Use case + + + + + + Actions + + +
+
+ Name +
+
+ + + +
+
+
+ ID +
+
+ + id1 + +
+
+
+ Description +
+
+ +
+
+
+ Use case +
+
+ Analytics (All) +
+
+
+ + + + Edit + + + + + + Delete - - -
+ + +
+
- + +
+
+
+ ID +
+
+ + id2 + +
+
+
+ Description +
+
+ +
+
+
+ Use case +
+
+
+
+ + + Edit + + + + + + Delete - - -
+ + +
+
+ Name +
+
+ + +
+
+
+ ID +
+
+ + id3 + +
+
+
+ Description +
+
+ +
+
+
+ Use case +
+
+ Observability +
+
+
+ + - Description + Edit - -
+ - Use case + Delete - + +
+ +
+
+
+
+
+
+
-
- No items found + + 1 + -
-
+ + + + + +
diff --git a/src/plugins/workspace/public/components/workspace_list/index.test.tsx b/src/plugins/workspace/public/components/workspace_list/index.test.tsx index bbff833da7b1..2284008d9d36 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.test.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.test.tsx @@ -39,11 +39,18 @@ function getWrapWorkspaceListInContext( }, }; + const mockHeaderControl = ({ controls }) => { + return controls?.[0].description ?? controls?.[0].renderComponent ?? null; + }; + const services = { ...coreStartMock, workspaces: { workspaceList$: of(workspaceList), }, + navigationUI: { + HeaderControl: mockHeaderControl, + }, }; return ( @@ -59,12 +66,10 @@ function getWrapWorkspaceListInContext( describe('WorkspaceList', () => { it('should render title and table normally', () => { - const { getByText, getByRole, container } = render( - - ); - expect(getByText('Workspaces')).toBeInTheDocument(); + const { getByText, getByRole, container } = render(getWrapWorkspaceListInContext()); + expect( + getByText('Organize collaborative projects with use-case-specific workspaces.') + ).toBeInTheDocument(); expect(getByRole('table')).toBeInTheDocument(); expect(container).toMatchSnapshot(); }); diff --git a/src/plugins/workspace/public/components/workspace_list/index.tsx b/src/plugins/workspace/public/components/workspace_list/index.tsx index 3d6604122e48..931862b919a7 100644 --- a/src/plugins/workspace/public/components/workspace_list/index.tsx +++ b/src/plugins/workspace/public/components/workspace_list/index.tsx @@ -6,8 +6,6 @@ import React, { useState, useMemo, useCallback } from 'react'; import { EuiPage, - EuiPageBody, - EuiPageHeader, EuiPageContent, EuiLink, EuiSmallButton, @@ -24,15 +22,10 @@ import { navigateToWorkspaceDetail } from '../utils/workspace'; import { WORKSPACE_CREATE_APP_ID } from '../../../common/constants'; -import { cleanWorkspaceId } from '../../../../../core/public'; import { DeleteWorkspaceModal } from '../delete_workspace_modal'; import { getFirstUseCaseOfFeatureConfigs } from '../../utils'; import { WorkspaceUseCase } from '../../types'; - -const WORKSPACE_LIST_PAGE_DESCRIPTION = i18n.translate('workspace.list.description', { - defaultMessage: - 'Workspace allow you to save and organize library items, such as index patterns, visualizations, dashboards, saved searches, and share them with other OpenSearch Dashboards users. You can control which features are visible in each workspace, and which users and groups have read and write access to the library items in the workspace.', -}); +import { NavigationPublicPluginStart } from '../../../../../plugins/navigation/public'; export interface WorkspaceListProps { registeredUseCases$: BehaviorSubject; @@ -40,8 +33,15 @@ export interface WorkspaceListProps { export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { const { - services: { workspaces, application, http }, - } = useOpenSearchDashboards(); + services: { + workspaces, + application, + http, + navigationUI: { HeaderControl }, + }, + } = useOpenSearchDashboards<{ + navigationUI: NavigationPublicPluginStart['ui']; + }>(); const registeredUseCases = useObservable(registeredUseCases$); const isDashboardAdmin = application?.capabilities?.dashboards?.isDashboardAdmin; @@ -79,6 +79,40 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { return workspaceList; }, [workspaceList, queryInput]); + const workspaceCreateUrl = useMemo(() => { + if (!application) { + return ''; + } + + const appUrl = application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { + absolute: false, + }); + if (!appUrl) return ''; + + return appUrl; + }, [application]); + + const renderCreateWorkspaceButton = () => { + const button = ( + + {i18n.translate('workspace.list.buttons.createWorkspace', { + defaultMessage: 'Create workspace', + })} + + ); + return ( + + ); + }; + const columns = [ { field: 'name', @@ -143,19 +177,6 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { }, ]; - const workspaceCreateUrl = useMemo(() => { - if (!application || !http) { - return ''; - } - - const appUrl = application.getUrlForApp(WORKSPACE_CREATE_APP_ID, { - absolute: false, - }); - if (!appUrl) return ''; - - return cleanWorkspaceId(appUrl); - }, [application, http]); - const debouncedSetQueryInput = useMemo(() => { return debounce(setQueryInput, 300); }, [setQueryInput]); @@ -172,61 +193,48 @@ export const WorkspaceList = ({ registeredUseCases$ }: WorkspaceListProps) => { box: { incremental: true, }, - toolsRight: [ - ...(isDashboardAdmin - ? [ - - {i18n.translate('workspace.workspaceList.buttons.createWorkspace', { - defaultMessage: 'Create workspace', - })} - , - ] - : []), - ], }; return ( - - - + + {isDashboardAdmin && renderCreateWorkspaceButton()} + + + setPagination((prev) => { + return { ...prev, pageIndex: index, pageSize: size }; + }) + } + pagination={pagination} + sorting={{ + sort: { + field: initialSortField, + direction: initialSortDirection, + }, + }} + isSelectable={true} + search={search} /> - - - setPagination((prev) => { - return { ...prev, pageIndex: index, pageSize: size }; - }) - } - pagination={pagination} - sorting={{ - sort: { - field: initialSortField, - direction: initialSortDirection, - }, - }} - isSelectable={true} - search={search} - /> - - + {deletedWorkspace && ( , { savedObjectsManagement, management, dataSourceManagement }: WorkspacePluginSetupDeps ) { const workspaceClient = new WorkspaceClient(core.http, core.workspaces); @@ -295,11 +297,13 @@ export class WorkspacePlugin } const mountWorkspaceApp = async (params: AppMountParameters, renderApp: WorkspaceAppType) => { - const [coreStart] = await core.getStartServices(); + const [coreStart, { navigation }] = await core.getStartServices(); + const services = { ...coreStart, workspaceClient, dataSourceManagement, + navigationUI: navigation.ui, }; return renderApp(params, services, { @@ -431,7 +435,7 @@ export class WorkspacePlugin }); } - public start(core: CoreStart, { contentManagement }: WorkspacePluginStartDeps) { + public start(core: CoreStart, { contentManagement, navigation }: WorkspacePluginStartDeps) { this.coreStart = core; this.currentWorkspaceIdSubscription = this._changeSavedObjectCurrentWorkspace(); diff --git a/src/plugins/workspace/public/types.ts b/src/plugins/workspace/public/types.ts index 79fed7fa81ac..d5cfc224416f 100644 --- a/src/plugins/workspace/public/types.ts +++ b/src/plugins/workspace/public/types.ts @@ -6,10 +6,12 @@ import { CoreStart } from '../../../core/public'; import { WorkspaceClient } from './workspace_client'; import { DataSourceManagementPluginSetup } from '../../../plugins/data_source_management/public'; +import { NavigationPublicPluginStart } from '../../../plugins/navigation/public'; export type Services = CoreStart & { workspaceClient: WorkspaceClient; dataSourceManagement?: DataSourceManagementPluginSetup; + navigationUI?: NavigationPublicPluginStart['ui']; }; export interface WorkspaceUseCase {