From 2f0fadc181045efaf9c3052590c6b4eed5893d36 Mon Sep 17 00:00:00 2001 From: Jon Jackson Date: Fri, 17 Oct 2025 12:59:09 -0400 Subject: [PATCH 01/18] hook up olmv1 to new bridge endpoint --- .../service/CatalogServiceProvider.tsx | 16 +- .../components/query-browser/QueryBrowser.tsx | 2 +- .../console-shared/src/hooks/usePoll.ts} | 1 + .../src/providers/helm-detection-provider.ts | 2 +- .../console-extensions.json | 46 ++--- .../locales/en/olm-v1.json | 5 +- .../package.json | 8 +- .../{ExtensionCatalog.tsx => Catalog.tsx} | 10 +- .../ExtensionCatalogDatabaseContext.ts | 8 - .../src/contexts/types.ts | 4 - ...seExtensionCatalogDatabaseContextValues.ts | 44 ----- .../src/database/indexeddb.ts | 184 ------------------ .../src/database/injest.ts | 108 ---------- .../src/database/jsonl.ts | 48 ----- .../src/database/types.ts | 111 ----------- .../src/fbc/bundles.ts | 48 ----- .../src/fbc/channels.ts | 32 --- .../src/fbc/metadata.ts | 175 ----------------- .../src/fbc/packages.ts | 46 ----- .../src/fbc/type-guards.ts | 19 -- .../src/fbc/types.ts | 105 ---------- .../src/fbc/util.ts | 13 -- .../src/hooks/useCatalogCategories.ts | 31 +++ .../src/hooks/useCatalogItems.ts | 61 ++++++ .../hooks/useExtensionCatalogCategories.ts | 33 ---- .../src/hooks/useExtensionCatalogItems.ts | 50 ----- .../src/types.ts | 25 +++ .../src/{fbc => utils}/catalog-item.tsx | 52 +++-- frontend/public/co-fetch.ts | 2 +- .../components/poll-console-updates.tsx | 4 +- frontend/public/components/utils/index.tsx | 1 - .../public/components/utils/url-poll-hook.ts | 2 +- 32 files changed, 197 insertions(+), 1099 deletions(-) rename frontend/{public/components/utils/poll-hook.ts => packages/console-shared/src/hooks/usePoll.ts} (97%) rename frontend/packages/operator-lifecycle-manager-v1/src/components/{ExtensionCatalog.tsx => Catalog.tsx} (83%) delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/contexts/ExtensionCatalogDatabaseContext.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/contexts/types.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/contexts/useExtensionCatalogDatabaseContextValues.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/database/indexeddb.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/database/injest.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/database/jsonl.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/database/types.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/fbc/bundles.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/fbc/channels.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/fbc/metadata.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/fbc/packages.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/fbc/type-guards.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/fbc/types.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/fbc/util.ts create mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/hooks/useCatalogCategories.ts create mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/hooks/useCatalogItems.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/hooks/useExtensionCatalogCategories.ts delete mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/hooks/useExtensionCatalogItems.ts create mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/types.ts rename frontend/packages/operator-lifecycle-manager-v1/src/{fbc => utils}/catalog-item.tsx (62%) diff --git a/frontend/packages/console-shared/src/components/catalog/service/CatalogServiceProvider.tsx b/frontend/packages/console-shared/src/components/catalog/service/CatalogServiceProvider.tsx index b3348f38e24..c59ebf9d9de 100644 --- a/frontend/packages/console-shared/src/components/catalog/service/CatalogServiceProvider.tsx +++ b/frontend/packages/console-shared/src/components/catalog/service/CatalogServiceProvider.tsx @@ -103,8 +103,13 @@ const CatalogServiceProvider: React.FC = ({ return applyCatalogItemMetadata(preCatalogItems, metadataProviderMap); }, [loaded, preCatalogItems, metadataProviderMap]); - const onCategoryValueResolved = React.useCallback((categories, id) => { - setCategoryProviderMap((prev) => ({ ...prev, [id]: categories })); + const onCategoryValueResolved = React.useCallback(([newCategories], id) => { + setCategoryProviderMap((prev) => { + if (_.isEqual(prev[id], newCategories)) { + return prev; + } + return { ...prev, [id]: newCategories }; + }); }, []); const onValueResolved = React.useCallback((items, uid) => { @@ -157,9 +162,10 @@ const CatalogServiceProvider: React.FC = ({ ? new Error('failed loading catalog data') : new IncompleteDataError(failedExtensions); - const categories = React.useMemo(() => _.flatten(Object.values(categoryProviderMap)), [ - categoryProviderMap, - ]); + const categories = React.useMemo( + () => _.uniqBy(_.flatten(Object.values(categoryProviderMap)), 'id'), + [categoryProviderMap], + ); const catalogService: CatalogService = { type: catalogType, diff --git a/frontend/packages/console-shared/src/components/query-browser/QueryBrowser.tsx b/frontend/packages/console-shared/src/components/query-browser/QueryBrowser.tsx index f3f955b4533..56df742fbbb 100644 --- a/frontend/packages/console-shared/src/components/query-browser/QueryBrowser.tsx +++ b/frontend/packages/console-shared/src/components/query-browser/QueryBrowser.tsx @@ -60,12 +60,12 @@ import { timeFormatter, timeFormatterWithSeconds, } from '@console/internal/components/utils/datetime'; -import { usePoll } from '@console/internal/components/utils/poll-hook'; import { useRefWidth } from '@console/internal/components/utils/ref-width-hook'; import { useSafeFetch } from '@console/internal/components/utils/safe-fetch-hook'; import { LoadingInline } from '@console/internal/components/utils/status-box'; import { humanizeNumberSI } from '@console/internal/components/utils/units'; import { RootState } from '@console/internal/redux'; +import { usePoll } from '../../hooks/usePoll'; import withFallback from '../error/fallbacks/withFallback'; import { queryBrowserTheme } from './theme'; diff --git a/frontend/public/components/utils/poll-hook.ts b/frontend/packages/console-shared/src/hooks/usePoll.ts similarity index 97% rename from frontend/public/components/utils/poll-hook.ts rename to frontend/packages/console-shared/src/hooks/usePoll.ts index 1a741073fe4..ba077d3f5d4 100644 --- a/frontend/public/components/utils/poll-hook.ts +++ b/frontend/packages/console-shared/src/hooks/usePoll.ts @@ -21,6 +21,7 @@ export const usePoll = (callback, delay, ...dependencies) => { const id = setInterval(tick, delay); return () => clearInterval(id); } + return () => {}; // eslint-disable-next-line react-hooks/exhaustive-deps }, [delay, ...dependencies]); }; diff --git a/frontend/packages/helm-plugin/src/providers/helm-detection-provider.ts b/frontend/packages/helm-plugin/src/providers/helm-detection-provider.ts index 714ec8f3c76..9268c7c4cbf 100644 --- a/frontend/packages/helm-plugin/src/providers/helm-detection-provider.ts +++ b/frontend/packages/helm-plugin/src/providers/helm-detection-provider.ts @@ -1,10 +1,10 @@ import { useState, useCallback } from 'react'; import { SetFeatureFlag } from '@console/dynamic-plugin-sdk'; import { settleAllPromises } from '@console/dynamic-plugin-sdk/src/utils/promise'; -import { usePoll } from '@console/internal/components/utils/poll-hook'; import { fetchK8s } from '@console/internal/graphql/client'; import { K8sResourceKind, ListKind } from '@console/internal/module/k8s'; import { useActiveNamespace } from '@console/shared/src/hooks/useActiveNamespace'; +import { usePoll } from '@console/shared/src/hooks/usePoll'; import { FLAG_OPENSHIFT_HELM } from '../const'; import { HelmChartRepositoryModel, ProjectHelmChartRepositoryModel } from '../models'; diff --git a/frontend/packages/operator-lifecycle-manager-v1/console-extensions.json b/frontend/packages/operator-lifecycle-manager-v1/console-extensions.json index cc9af5d286b..70518ded2fa 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/console-extensions.json +++ b/frontend/packages/operator-lifecycle-manager-v1/console-extensions.json @@ -24,26 +24,26 @@ { "type": "console.navigation/href", "properties": { - "id": "extension-catalog", + "id": "olm-v1-catalog", "perspective": "admin", "section": "ecosystem", - "name": "%olm-v1~Extension Catalog%", - "href": "/ecosystem/catalog" + "name": "%olm-v1~OLM v1 Catalog%", + "href": "/olmv1/catalog" }, "flags": { - "required": ["CLUSTER_CATALOG_API", "FALSE"] // TODO re-enable + "required": ["CLUSTER_CATALOG_API"] } }, { "type": "console.page/route", "properties": { - "path": "/ecosystem/catalog", + "path": "/olmv1/catalog", "component": { - "$codeRef": "ExtensionCatalog" + "$codeRef": "Catalog" } }, "flags": { - "required": ["CLUSTER_CATALOG_API", "FALSE"] // TODO re-enable + "required": ["CLUSTER_CATALOG_API"] } }, { @@ -60,35 +60,25 @@ "startsWith": ["olm.operatorframework.io"] }, "flags": { - "required": ["CLUSTER_EXTENSION_API", "FALSE"] // TODO re-enable - } - }, - { - "type": "console.context-provider", - "properties": { - "provider": { "$codeRef": "ExtensionCatalogDatabaseContextProvider" }, - "useValueHook": { "$codeRef": "useExtensionCatalogDatabaseContextValues" } - }, - "flags": { - "required": ["CLUSTER_CATALOG_API", "FALSE"] // TODO re-enable + "required": ["CLUSTER_EXTENSION_API"] } }, { "type": "console.catalog/item-provider", "properties": { - "catalogId": "olm-extension-catalog", - "type": "ExtensionCatalogItem", - "provider": { "$codeRef": "useExtensionCatalogItems" } + "catalogId": "olm-v1-catalog", + "type": "OLMv1CatalogItem", + "provider": { "$codeRef": "useCatalogItems" } }, "flags": { - "required": ["CLUSTER_CATALOG_API", "FALSE"] // TODO re-enable + "required": ["CLUSTER_CATALOG_API"] } }, { "type": "console.catalog/item-type", "properties": { - "type": "ExtensionCatalogItem", - "title": "%olm-v1~Extension Catalog Items%", + "type": "OLMv1CatalogItem", + "title": "%olm-v1~Operators (OLMv1)%", "filters": [ { "label": "%olm-v1~Source%", @@ -128,15 +118,15 @@ ] }, "flags": { - "required": ["CLUSTER_CATALOG_API", "FALSE"] // TODO re-enable + "required": ["CLUSTER_CATALOG_API"] } }, { "type": "console.catalog/categories-provider", "properties": { - "catalogId": "olm-extension-catalog", - "type": "ExtensionCatalogItem", - "provider": { "$codeRef": "useExtensionCatalogCategories" } + "catalogId": "olm-v1-catalog", + "type": "OLMv1CatalogItem", + "provider": { "$codeRef": "useCatalogCategories" } } } ] diff --git a/frontend/packages/operator-lifecycle-manager-v1/locales/en/olm-v1.json b/frontend/packages/operator-lifecycle-manager-v1/locales/en/olm-v1.json index 36f8bd84fe8..f210d7847c7 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/locales/en/olm-v1.json +++ b/frontend/packages/operator-lifecycle-manager-v1/locales/en/olm-v1.json @@ -1,11 +1,12 @@ { - "Extension Catalog": "Extension Catalog", + "OLM v1 Catalog": "OLM v1 Catalog", "Installed Extensions": "Installed Extensions", - "Extension Catalog Items": "Extension Catalog Items", + "Operators (OLMv1)": "Operators (OLMv1)", "Source": "Source", "Provider": "Provider", "Capability level": "Capability level", "Infrastructure features": "Infrastructure features", "Valid subscription": "Valid subscription", + "Extension Catalog": "Extension Catalog", "Discover Operators from the Kubernetes community and Red Hat partners, curated by Red Hat. You can install Operators on your clusters to provide optional add-ons and shared services to your developers.": "Discover Operators from the Kubernetes community and Red Hat partners, curated by Red Hat. You can install Operators on your clusters to provide optional add-ons and shared services to your developers." } \ No newline at end of file diff --git a/frontend/packages/operator-lifecycle-manager-v1/package.json b/frontend/packages/operator-lifecycle-manager-v1/package.json index 27624705173..4732fc8bed3 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/package.json +++ b/frontend/packages/operator-lifecycle-manager-v1/package.json @@ -6,11 +6,9 @@ "consolePlugin": { "entry": "src/plugin.ts", "exposedModules": { - "ExtensionCatalog": "src/components/ExtensionCatalog.tsx", - "useExtensionCatalogDatabaseContextValues": "src/contexts/useExtensionCatalogDatabaseContextValues.ts", - "ExtensionCatalogDatabaseContextProvider": "src/contexts/ExtensionCatalogDatabaseContext.ts", - "useExtensionCatalogItems": "src/hooks/useExtensionCatalogItems.ts", - "useExtensionCatalogCategories": "src/hooks/useExtensionCatalogCategories.ts", + "Catalog": "src/components/Catalog.tsx", + "useCatalogItems": "src/hooks/useCatalogItems.ts", + "useCatalogCategories": "src/hooks/useCatalogCategories.ts", "filters": "src/utils/filters.ts" } } diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/components/ExtensionCatalog.tsx b/frontend/packages/operator-lifecycle-manager-v1/src/components/Catalog.tsx similarity index 83% rename from frontend/packages/operator-lifecycle-manager-v1/src/components/ExtensionCatalog.tsx rename to frontend/packages/operator-lifecycle-manager-v1/src/components/Catalog.tsx index e9fc918080c..86ed484bbb5 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/src/components/ExtensionCatalog.tsx +++ b/frontend/packages/operator-lifecycle-manager-v1/src/components/Catalog.tsx @@ -2,21 +2,21 @@ import { Trans, useTranslation } from 'react-i18next'; import { CatalogController, CatalogServiceProvider } from '@console/shared/src/components/catalog'; import { useActiveNamespace } from '@console/shared/src/hooks/useActiveNamespace'; -const ExtensionCatalog = () => { +const Catalog = () => { const { t } = useTranslation('olm-v1'); const [namespace] = useActiveNamespace(); return ( {(service) => ( Discover Operators from the Kubernetes community and Red Hat partners, curated by Red @@ -30,4 +30,4 @@ const ExtensionCatalog = () => { ); }; -export default ExtensionCatalog; +export default Catalog; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/contexts/ExtensionCatalogDatabaseContext.ts b/frontend/packages/operator-lifecycle-manager-v1/src/contexts/ExtensionCatalogDatabaseContext.ts deleted file mode 100644 index 69cd8de9765..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/contexts/ExtensionCatalogDatabaseContext.ts +++ /dev/null @@ -1,8 +0,0 @@ -import { createContext } from 'react'; -import { ExtensionCatalogDatabaseContextValues } from './types'; - -export const ExtensionCatalogDatabaseContext = createContext( - { done: false, error: null }, -); - -export default ExtensionCatalogDatabaseContext.Provider; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/contexts/types.ts b/frontend/packages/operator-lifecycle-manager-v1/src/contexts/types.ts deleted file mode 100644 index 23629711462..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/contexts/types.ts +++ /dev/null @@ -1,4 +0,0 @@ -export type ExtensionCatalogDatabaseContextValues = { - done: boolean; - error: Error; -}; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/contexts/useExtensionCatalogDatabaseContextValues.ts b/frontend/packages/operator-lifecycle-manager-v1/src/contexts/useExtensionCatalogDatabaseContextValues.ts deleted file mode 100644 index fcd604aff65..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/contexts/useExtensionCatalogDatabaseContextValues.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { useState, useRef, useEffect } from 'react'; -import * as _ from 'lodash'; -import { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src'; -import { useK8sWatchResource } from '@console/dynamic-plugin-sdk/src/api/core-api'; -import { CLUSTER_CATALOG_GROUP_VERSION_KIND } from '../const'; -import { openDatabase } from '../database/indexeddb'; -import { populateExtensionCatalogDatabase } from '../database/injest'; -import { ExtensionCatalogDatabaseContextValues } from './types'; - -export const useExtensionCatalogDatabaseContextValues: UseExtensionCatalogDatabaseContextValues = () => { - const [catalogs] = useK8sWatchResource({ - groupVersionKind: CLUSTER_CATALOG_GROUP_VERSION_KIND, - isList: true, - }); - const [done, setDone] = useState(false); - const [error, setError] = useState(); - const refresh = useRef( - _.debounce((newCatalogs: K8sResourceCommon[]) => { - setDone(false); - setError(null); - openDatabase('olm') - .then((database) => populateExtensionCatalogDatabase(database, newCatalogs)) - .then(() => { - setDone(true); - setError(null); - }) - .catch((e) => { - setDone(true); - setError(e); - }); - }, 5000), - ); - - useEffect(() => { - const currentRefresh = refresh.current; - currentRefresh(catalogs); - return () => currentRefresh.cancel(); - }, [catalogs]); - return { done, error }; -}; - -export default useExtensionCatalogDatabaseContextValues; - -type UseExtensionCatalogDatabaseContextValues = () => ExtensionCatalogDatabaseContextValues; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/database/indexeddb.ts b/frontend/packages/operator-lifecycle-manager-v1/src/database/indexeddb.ts deleted file mode 100644 index f260e90f600..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/database/indexeddb.ts +++ /dev/null @@ -1,184 +0,0 @@ -export const deleteDatabase = (dbName): Promise => - new Promise((resolve, reject) => { - const request = indexedDB.deleteDatabase(dbName); - request.onsuccess = () => { - resolve(); - }; - - request.onerror = () => { - reject(new Error(`Database error: ${request.error?.message}`)); - }; - }); - -export const openDatabase = (dbName: string, version?: number): Promise => - new Promise((resolve, reject) => { - const request = indexedDB.open(dbName, version); - request.onupgradeneeded = () => { - const db = request.result; - - if (!db.objectStoreNames.contains('olm.package')) { - const packageStore = db.createObjectStore('olm.package', { keyPath: 'id' }); - packageStore.createIndex('catalog', 'catalog', { unique: false }); - } - if (!db.objectStoreNames.contains('olm.channel')) { - const channelStore = db.createObjectStore('olm.channel', { keyPath: 'id' }); - channelStore.createIndex('package', 'package', { unique: false }); - channelStore.createIndex('catalog', 'catalog', { unique: false }); - } - if (!db.objectStoreNames.contains('olm.bundle')) { - const bundleStore = db.createObjectStore('olm.bundle', { keyPath: 'id' }); - bundleStore.createIndex('package', 'package', { unique: false }); - bundleStore.createIndex('catalog', 'catalog', { unique: false }); - } - if (!db.objectStoreNames.contains('extension-catalog')) { - const extensionCatalogStore = db.createObjectStore('extension-catalog', { - keyPath: 'id', - }); - extensionCatalogStore.createIndex('categories', 'categories', { - unique: false, - multiEntry: true, - }); - extensionCatalogStore.createIndex('keywords', 'keywords', { - unique: false, - multiEntry: true, - }); - extensionCatalogStore.createIndex('infrastructureFeatures', 'infrastructureFeatures', { - unique: false, - multiEntry: true, - }); - extensionCatalogStore.createIndex('validSubscription', 'validSubscription', { - unique: false, - multiEntry: true, - }); - extensionCatalogStore.createIndex('source', 'source', { unique: false }); - extensionCatalogStore.createIndex('provider', 'provider', { unique: false }); - extensionCatalogStore.createIndex('catalog', 'catalog', { unique: false }); - extensionCatalogStore.createIndex('capabilities', 'capabilities', { unique: false }); - } - }; - - request.onsuccess = () => { - resolve(request.result); - }; - - request.onerror = () => { - reject(new Error(`Database error: ${request.error?.message}`)); - }; - }); - -export const getObjectStore = (db: IDBDatabase, storeName: string, mode: IDBTransactionMode) => { - const transaction = db.transaction([storeName], mode); - return transaction.objectStore(storeName); -}; - -export const addItem = ( - db: IDBDatabase, - storeName: string, - item: Item, -): Promise => - new Promise((resolve, reject) => { - const store = getObjectStore(db, storeName, 'readwrite'); - const request = store.add(item); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - -export const putItem = ( - db: IDBDatabase, - storeName: string, - item: Item, -): Promise => - new Promise((resolve, reject) => { - const store = getObjectStore(db, storeName, 'readwrite'); - const request = store.put(item); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - -export const getItem = ( - db: IDBDatabase, - storeName: string, - key: string, -): Promise => - new Promise((resolve, reject) => { - const store = getObjectStore(db, storeName, 'readonly'); - const request = store.get(key); - request.onsuccess = () => resolve(request.result as Item); - request.onerror = () => reject(request.error); - }); - -export const getItems = (db: IDBDatabase, storeName: string): Promise => - new Promise((resolve, reject) => { - const store = getObjectStore(db, storeName, 'readonly'); - const request = store.getAll(); - request.onsuccess = () => resolve(request.result); - request.onerror = () => reject(request.error); - }); - -export const getIndexedItems = ( - db: IDBDatabase, - storeName: string, - index: string, - value: string, -): Promise => - new Promise((resolve, reject) => { - // Start a transaction - const transaction = db.transaction(storeName, 'readonly'); - const objectStore = transaction.objectStore(storeName); - const storeIndex = objectStore.index(index); - const keyRange = IDBKeyRange.only(value); - const cursorRequest = storeIndex.openCursor(keyRange); - - const objects: Item[] = []; - cursorRequest.onsuccess = () => { - const cursor = cursorRequest.result; - if (cursor) { - objects.push(cursor.value); - cursor.continue(); - } else { - resolve(objects); - } - }; - cursorRequest.onerror = () => { - reject(cursorRequest.error); - }; - }); - -export const getUniqueIndexKeys = ( - db: IDBDatabase, - storeName: string, - index: string, -): Promise => - new Promise((resolve, reject) => { - const transaction = db.transaction(storeName, 'readonly'); - const objectStore = transaction.objectStore(storeName); - const storeIndex = objectStore.index(index); - const request = storeIndex.openKeyCursor(); - const keys = {}; - request.onsuccess = () => { - const cursor = request.result; - if (cursor) { - if (typeof cursor.key === 'string') { - keys[cursor.key] = true; - } - cursor.continue(); - } else { - resolve(Object.keys(keys)); - } - }; - request.onerror = () => { - reject(request.error); - }; - }); - -export const clearObjectStore = (db: IDBDatabase, name: string): Promise => - new Promise((resolve, reject) => { - const objectStore = getObjectStore(db, name, 'readwrite'); - const request = objectStore.clear(); - - request.onsuccess = () => resolve(); - request.onerror = () => reject(request.error); - }); - -export const clearObjectStores = (db: IDBDatabase, ...names: string[]) => - Promise.all(names.map((name) => clearObjectStore(db, name))); diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/database/injest.ts b/frontend/packages/operator-lifecycle-manager-v1/src/database/injest.ts deleted file mode 100644 index dbabd53d546..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/database/injest.ts +++ /dev/null @@ -1,108 +0,0 @@ -/* eslint-disable no-console */ -import { K8sResourceCommon } from '@console/dynamic-plugin-sdk/src/lib-core'; -import { bundleHasProperty } from '../fbc/bundles'; -import { addPackagesToExtensionCatalog } from '../fbc/packages'; -import { isFileBasedCatalogBundle } from '../fbc/type-guards'; -import { FileBasedCatalogObject, FileBasedCatalogPropertyType } from '../fbc/types'; -import { putItem, getItems, clearObjectStores } from './indexeddb'; -import { fetchAndProcessJSONLines } from './jsonl'; - -const populateExtensionCatalogs = async (db: IDBDatabase): Promise => { - const packages = await getItems(db, 'olm.package'); - return addPackagesToExtensionCatalog(db, packages).then((results) => - results.reduce((acc, r) => (r.status === 'fulfilled' && r.value ? acc + 1 : acc), 0), - ); -}; - -const streamFBCObjectsToIndexedDB = ( - db: IDBDatabase, - catalog: string, - reader: ReadableStreamDefaultReader, - count?: number, -): Promise => - reader.read().then(async ({ done, value }) => { - if (done) { - return count; - } - if ( - isFileBasedCatalogBundle(value) && - !bundleHasProperty(value, FileBasedCatalogPropertyType.CSVMetadata) - ) { - return streamFBCObjectsToIndexedDB(db, catalog, reader, count ?? 0); - } - const { schema, ...object } = value; - const packageName = object.package ?? object.name; - const objectName = schema === 'olm.package' ? '' : `~${object.name}`; - const pkg = `${catalog}~${packageName}`; // Fully qualified package includes catalog - const id = `${pkg}${objectName}`; // catalog~package or catalog~package~object - await putItem(db, schema, { ...object, id, catalog, package: pkg }); - return streamFBCObjectsToIndexedDB(db, catalog, reader, (count ?? 0) + 1); - }); - -const injestClusterCatalog = async ( - db: IDBDatabase, - catalog: K8sResourceCommon, -): Promise => { - const catalogName = catalog.metadata.name; - console.log('[Extension Catalog Database] Injesting FBC from ClusterCatalog', catalogName); - return fetchAndProcessJSONLines( - `/api/catalogd/catalogs/${catalogName}/api/v1/all`, - { 'Content-Type': 'application/jsonl' }, - ) - .then((reader) => streamFBCObjectsToIndexedDB(db, catalogName, reader)) - .then((count) => { - console.log( - '[Extension Catalog Database] Successfully injested', - count, - 'objects from ClusterCatalog', - catalogName, - ); - return count; - }) - .catch((e) => { - console.warn( - '[Extension Catalog Database} Failed to injest FBC from ClusterCatalog', - catalogName, - e.toString(), - ); - return 0; - }); -}; - -export const populateExtensionCatalogDatabase = async ( - db: IDBDatabase, - catalogs: K8sResourceCommon[], -): Promise => { - console.time('[Extension Catalog Database] took'); - console.log('[Extension Catalog Database] Refreshing extension catalog database'); - return clearObjectStores(db, 'olm.package', 'olm.channel', 'olm.bundle', 'extension-catalog') - .then(() => { - console.log('[Extension Catalog Database] Object stores cleared'); - return Promise.allSettled(catalogs.map((catalog) => injestClusterCatalog(db, catalog))); - }) - .then((results) => { - const fbcObjectCount = results.reduce( - (acc, result) => (result.status === 'fulfilled' ? acc + result.value : acc), - 0, - ); - console.log( - '[Extension Catalog Database]', - fbcObjectCount, - 'items populated to FBC object stores', - ); - return populateExtensionCatalogs(db); - }) - .then((extensionItemCount) => { - console.log( - '[Extension Catalog Database] Database initialization complete.', - extensionItemCount, - 'items populated to extension catlog object store', - ); - console.timeEnd('[Extension Catalog Database] took'); - }) - .catch((e) => { - console.warn('[Extension Catalog Database] Error encountered', e.toString()); - console.timeEnd('[Extension Catalog Database] took'); - throw new Error(e); - }); -}; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/database/jsonl.ts b/frontend/packages/operator-lifecycle-manager-v1/src/database/jsonl.ts deleted file mode 100644 index bcbe2236419..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/database/jsonl.ts +++ /dev/null @@ -1,48 +0,0 @@ -/* eslint-disable no-console */ -import { consoleFetch } from '@console/dynamic-plugin-sdk/src/lib-core'; - -const enqueueJSON = (controller: TransformStreamDefaultController, jsonString: string) => { - try { - controller.enqueue(JSON.parse(jsonString)); - } catch (e) { - console.warn(`Error parsing JSON line: ${e}\n${jsonString}`); - } -}; - -export const parseJSONLines = () => - new TransformStream({ - start() { - this.buffer = ''; - }, - transform(chunk, controller) { - this.buffer += chunk; - const lines = this.buffer.split('\n').map((l) => l?.trim()); - this.buffer = this.buffer.endsWith('\n') ? '' : lines.pop().trim(); - lines.forEach((line) => { - if (line) { - enqueueJSON(controller, line); - } - }); - }, - flush(controller) { - if (this.buffer) { - enqueueJSON(controller, this.buffer); - } - }, - }); - -// ObjectType is the expected shape of each JSON object (defaults to any). -// HandlerResult is the expected value the handler will resolve when called with ObjectType as an argument -export const fetchAndProcessJSONLines = ( - url: string, - options, -): Promise> => - consoleFetch(url, options).then((response) => { - if (!response.ok) { - throw new Error(`Failed to fetch ${url}: ${response.statusText}`); - } - return response.body - .pipeThrough(new TextDecoderStream()) - .pipeThrough(parseJSONLines()) - .getReader(); - }); diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/database/types.ts b/frontend/packages/operator-lifecycle-manager-v1/src/database/types.ts deleted file mode 100644 index 956a85cb87c..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/database/types.ts +++ /dev/null @@ -1,111 +0,0 @@ -import { InfrastructureFeature } from '@console/operator-lifecycle-manager/src/components/operator-hub'; - -export enum FileBasedCatalogSchema { - package = 'olm.package', - channel = 'olm.channel', - bundle = 'olm.bundle', - csvMetadata = 'olm.csv.metadata', -} - -export enum CSVMetadataKey { - annotations = 'annotations', - capabilities = 'capabilities', - description = 'description', - displayName = 'displayName', - provider = 'provider', - categories = 'categories', - keywords = 'keywords', - validSubscription = 'operators.openshift.io/valid-subscription', - legacyInfrastructureFeatures = 'operators.openshift.io/infrastructure-features', -} - -export enum NormalizedCSVMetadataKey { - validSubscription = 'validSubscription', - infrastructureFeatures = 'infrastructureFeatures', - longDescription = 'longDescription', -} - -export type CommaSeparatedList = string; // foo,bar,baz -export type SerializedJSONArray = string; // '["foo","bar","baz"]' -export type ProviderMetadataValue = { name: string } | string; - -export type FileBasedCatalogMetadata = { - annotations: { - [CSVMetadataKey.categories]?: CommaSeparatedList; - [CSVMetadataKey.capabilities]?: string; - [CSVMetadataKey.description]?: string; - [CSVMetadataKey.displayName]?: string; - [CSVMetadataKey.legacyInfrastructureFeatures]?: SerializedJSONArray; - [CSVMetadataKey.validSubscription]?: SerializedJSONArray; - [key: string]: any; - }; - [CSVMetadataKey.description]?: string; - [CSVMetadataKey.displayName]?: string; - [CSVMetadataKey.keywords]?: string[]; - [CSVMetadataKey.provider]?: ProviderMetadataValue; - [key: string]: any; -}; - -export type FileBasedCatalogItem = { - catalog: string; - id: string; - name: string; - package: string; - schema: string; - [key: string]: any; -}; - -export type FileBasedCatalogProperty = { - type: string; - value: Value; -}; - -export type FileBasedCatalogBundle = FileBasedCatalogItem & { - properties: FileBasedCatalogProperty[]; -}; - -export type FileBasedCatalogChannelEntry = { - name: string; -}; - -export type FileBasedCatalogChannel = FileBasedCatalogItem & { - entries: FileBasedCatalogChannelEntry[]; -}; - -export type FileBasedCatalogPackage = FileBasedCatalogItem & { - icon: { - base64data: string; - mediatype: string; - }; -}; - -export type ExtensionCatalogItemMetadata = { - capabilities?: string; - categories?: string[]; - description?: string; - displayName?: string; - infrastructureFeatures?: InfrastructureFeature[]; - keywords?: string[]; - longDescription?: string; - provider?: string; - source?: string; - validSubscription?: string[]; - creationTimestamp?: string; -}; - -type SemverCoercableString = string; - -export type ExtensionCatalogItemChannels = { - [key: SemverCoercableString]: SemverCoercableString[]; -}; - -export type ExtensionCatalogItem = { - catalog: string; - channels?: ExtensionCatalogItemChannels; - icon: { - mediatype: string; - base64data: string; - }; - id: string; - name: string; -} & ExtensionCatalogItemMetadata; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/bundles.ts b/frontend/packages/operator-lifecycle-manager-v1/src/fbc/bundles.ts deleted file mode 100644 index 94ccdd49bb1..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/bundles.ts +++ /dev/null @@ -1,48 +0,0 @@ -import * as SemVer from 'semver'; -import { getIndexedItems } from '../database/indexeddb'; -import { getBundleMetadata } from './metadata'; -import { FileBasedCatalogBundle, FileBasedCatalogPropertyType, PackageMetadata } from './types'; - -export const bundleHasProperty = (bundle: FileBasedCatalogBundle, propertyType: string) => - (bundle.properties ?? []).some(({ type }) => type === propertyType); - -export const getBundleProperty = ( - bundle: FileBasedCatalogBundle, - propertyType: string, -): V => (bundle.properties ?? []).find(({ type }) => type === propertyType)?.value; - -const getBundleVersion = (bundle: FileBasedCatalogBundle): SemVer.SemVer => { - const versionString = - getBundleProperty(bundle, FileBasedCatalogPropertyType.Package) - ?.version || ''; - return SemVer.parse(versionString); -}; - -export const compareBundleVersions = ( - a: FileBasedCatalogBundle, - b: FileBasedCatalogBundle, -): number => { - const aVersion = getBundleVersion(a); - const bVersion = getBundleVersion(b); - return SemVer.compare(bVersion, aVersion); -}; - -export const getBundleMetadataForPackage = async ( - db: IDBDatabase, - packageName: string, -): Promise => { - const bundles = await getIndexedItems( - db, - 'olm.bundle', - 'package', - packageName, - ).catch((e) => { - // eslint-disable-next-line no-console - console.warn(e); - return []; - }); - const [latestBundle] = bundles?.sort?.(compareBundleVersions) ?? []; - return latestBundle ? getBundleMetadata(latestBundle) : {}; -}; - -type PackagePropertyValue = { version: string }; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/channels.ts b/frontend/packages/operator-lifecycle-manager-v1/src/fbc/channels.ts deleted file mode 100644 index 1d64b7f3e94..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/channels.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { getIndexedItems } from '../database/indexeddb'; -import { ExtensionCatalogItemChannels, FileBasedCatalogChannel } from './types'; - -export const aggregateChannels = (channels: FileBasedCatalogChannel[]): Channels => - channels.reduce((acc, channel) => { - const versions = channel.entries.map(({ name }) => name); - return { - ...acc, - [channel.name]: versions, - }; - }, {}); - -export const getChannelsForPackage = async ( - db: IDBDatabase, - pkgName: string, -): Promise => { - const channels = await getIndexedItems( - db, - 'olm.channel', - 'package', - pkgName, - ).catch((e) => { - // eslint-disable-next-line no-console - console.warn(e); - return []; - }); - return aggregateChannels(channels ?? []); -}; - -type Channels = { - [key: string]: string[]; -}; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/metadata.ts b/frontend/packages/operator-lifecycle-manager-v1/src/fbc/metadata.ts deleted file mode 100644 index 0a523f150fe..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/metadata.ts +++ /dev/null @@ -1,175 +0,0 @@ -/* eslint-disable no-console */ -import * as _ from 'lodash'; -import { - NormalizedOLMAnnotation, - OLMAnnotation, -} from '@console/operator-lifecycle-manager/src/components/operator-hub'; -import { getProviderValue } from '@console/operator-lifecycle-manager/src/components/operator-hub/operator-hub-items'; -import { infrastructureFeatureMap } from '@console/operator-lifecycle-manager/src/components/operator-hub/operator-hub-utils'; -import { - CommaSeparatedList, - PackageMetadata, - FileBasedCatalogBundle, - CSVMetadata, - SerializedJSONArray, - ProviderMetadataValue, -} from './types'; - -const aggregateValue = (acc: PackageMetadata, key: string, value: T): PackageMetadata => ({ - ...acc, - [key]: value, -}); - -const aggregateArray = (acc: PackageMetadata, key: string, value: T[]): PackageMetadata => - aggregateValue(acc, key, _.uniq([...(acc[key] ?? []), ...(value ?? [])])); - -const aggregateSerializedJSONArray = ( - acc: PackageMetadata, - key: string, - value: SerializedJSONArray, -): PackageMetadata => { - if (!value) return acc; - try { - return aggregateArray(acc, key, JSON.parse(value) as T[]); - } catch (e) { - console.warn( - `[Extension Catalog Database] Malformed FBC metadata property: "${key}". Expected serialized JSON array, got "${value}".`, - ); - return aggregateArray(acc, key, [value]); - } -}; - -const aggregateCommaSeparatedList = ( - acc: PackageMetadata, - key: string, - value: CommaSeparatedList, -): PackageMetadata => { - if (!value) return acc; - try { - const newValues = value - .split(',') - .map((e) => e.trim()) - .filter((e) => e); - return newValues.length > 0 ? aggregateArray(acc, key, newValues) : acc; - } catch (e) { - console.warn( - `[Extension Catalog Database] Malformed FBC metadata property: "${key}". Expected comma-separated list, got "${value}".`, - ); - return aggregateArray(acc, key, [value]); - } -}; - -const aggregateLegacyInfrastructureFeatures = (acc, key, value) => { - const { infrastructureFeatures } = aggregateSerializedJSONArray({}, key, value); - if (!infrastructureFeatures) return acc; - const newFeatures = infrastructureFeatures.reduce( - (featureAcc, feature) => - infrastructureFeatureMap[feature] - ? [...featureAcc, infrastructureFeatureMap[feature]] - : featureAcc, - [], - ); - return aggregateArray(acc, NormalizedOLMAnnotation.InfrastructureFeatures, newFeatures); -}; - -const aggregateInfrastructureFeature = ( - acc: PackageMetadata, - key: string, - value: string, -): PackageMetadata => - value === 'true' && infrastructureFeatureMap[key] - ? aggregateArray(acc, NormalizedOLMAnnotation.InfrastructureFeatures, [ - infrastructureFeatureMap[key], - ]) - : acc; - -const aggregateProvider = (acc: PackageMetadata, providerValue: ProviderMetadataValue) => { - const provider = typeof providerValue === 'string' ? providerValue : providerValue?.name ?? ''; - const normalizedProvider = getProviderValue(provider); - return aggregateValue(acc, 'provider', normalizedProvider); -}; - -const aggregateAnnotations = ( - packageMetadata: PackageMetadata, - annotations: CSVMetadata['annotations'], -): PackageMetadata => { - return Object.entries(annotations).reduce((acc, [key, value]) => { - if (!value) return acc; - switch (key) { - case OLMAnnotation.Capabilities: - case OLMAnnotation.CreatedAt: - case OLMAnnotation.Description: - case OLMAnnotation.DisplayName: - case OLMAnnotation.Repository: - case OLMAnnotation.Support: - case OLMAnnotation.ActionText: - case OLMAnnotation.RemoteWorkflow: - case OLMAnnotation.SupportWorkflow: - return aggregateValue(acc, key, value); - case OLMAnnotation.ContainerImage: - return aggregateValue(acc, NormalizedOLMAnnotation.ContainerImage, value); - case OLMAnnotation.ValidSubscription: - return aggregateSerializedJSONArray(acc, NormalizedOLMAnnotation.ValidSubscription, value); - case OLMAnnotation.InfrastructureFeatures: - return aggregateLegacyInfrastructureFeatures( - acc, - NormalizedOLMAnnotation.InfrastructureFeatures, - value, - ); - case OLMAnnotation.Categories: - return aggregateCommaSeparatedList(acc, key, value); - case OLMAnnotation.Disconnected: - case OLMAnnotation.FIPSCompliant: - case OLMAnnotation.ProxyAware: - case OLMAnnotation.CNF: - case OLMAnnotation.CNI: - case OLMAnnotation.CSI: - case OLMAnnotation.TLSProfiles: - case OLMAnnotation.TokenAuthAWS: - case OLMAnnotation.TokenAuthAzure: - case OLMAnnotation.TokenAuthGCP: - return aggregateInfrastructureFeature(acc, key, value); - default: - return acc; - } - }, packageMetadata); -}; - -const aggregateMetadata = ( - csvMetadata: CSVMetadata, - packageMetadata?: PackageMetadata, -): PackageMetadata => { - if (!csvMetadata) return packageMetadata; - return Object.keys(csvMetadata) - .sort() // ensure annotations are handled first - .reduce((acc, key) => { - const value = csvMetadata[key]; - if (!value) return acc; - switch (key) { - case 'annotations': - return aggregateAnnotations(acc, value); - case 'description': - return aggregateValue(acc, 'longDescription', value); - case 'displayName': - case 'image': - return aggregateValue(acc, key, value); - case 'provider': - return aggregateProvider(acc, value); - case 'keywords': - return aggregateArray(acc, key, value); - default: - return acc; - } - }, packageMetadata ?? {}); -}; - -export const getBundleMetadata = (bundle: FileBasedCatalogBundle): PackageMetadata => { - return bundle.properties.reduce((acc, property) => { - switch (property.type) { - case 'olm.csv.metadata': - return aggregateMetadata(property.value, acc); - default: - return acc; - } - }, {}); -}; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/packages.ts b/frontend/packages/operator-lifecycle-manager-v1/src/fbc/packages.ts deleted file mode 100644 index 3ef94376a95..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/packages.ts +++ /dev/null @@ -1,46 +0,0 @@ -/* eslint-disable no-console */ -import { defaultClusterCatalogSourceMap } from '@console/operator-lifecycle-manager/src/components/operator-hub/operator-hub-utils'; -import { PackageSource } from '@console/operator-lifecycle-manager/src/const'; -import { putItem } from '../database/indexeddb'; -import { ExtensionCatalogItem, FileBasedCatalogPackage } from '../database/types'; -import { getBundleMetadataForPackage } from './bundles'; -import { getChannelsForPackage } from './channels'; - -const addPackageToExtensionCatalog = async ( - db: IDBDatabase, - { id, icon, name, catalog }: FileBasedCatalogPackage, -): Promise => { - const channels = await getChannelsForPackage(db, id); - const metadata = await getBundleMetadataForPackage(db, id); - const extensionCatalogItem: ExtensionCatalogItem = { - catalog, - id, - icon, - name, - source: defaultClusterCatalogSourceMap[catalog] || PackageSource.Custom, - channels: { - ...channels, - }, - ...metadata, - }; - // eslint-disable-next-line no-console - await putItem(db, 'extension-catalog', extensionCatalogItem); - return extensionCatalogItem; -}; - -export const addPackagesToExtensionCatalog = ( - db: IDBDatabase, - packages: FileBasedCatalogPackage[], -): Promise[]> => - Promise.allSettled( - packages.map((pkg) => - addPackageToExtensionCatalog(db, pkg).catch((e) => { - console.warn( - '[Extension Catalog Database] Error encountered while creating extension catalog item for', - pkg.name, - e.toString(), - ); - return null; - }), - ), - ); diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/type-guards.ts b/frontend/packages/operator-lifecycle-manager-v1/src/fbc/type-guards.ts deleted file mode 100644 index e8b9199883a..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/type-guards.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { - FileBasedCatalogSchema, - FileBasedCatalogBundle, - FileBasedCatalogObject, - FileBasedCatalogChannel, - FileBasedCatalogPackage, -} from './types'; - -export const isFileBasedCatalogBundle = ( - object: FileBasedCatalogObject, -): object is FileBasedCatalogBundle => object.schema === FileBasedCatalogSchema.Bundle; - -export const isFileBasedCatalogChannel = ( - object: FileBasedCatalogObject, -): object is FileBasedCatalogChannel => object.schema === FileBasedCatalogSchema.Channel; - -export const isFileBasedCatalogPackage = ( - object: FileBasedCatalogObject, -): object is FileBasedCatalogPackage => object.schema === FileBasedCatalogSchema.Package; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/types.ts b/frontend/packages/operator-lifecycle-manager-v1/src/fbc/types.ts deleted file mode 100644 index 584f4d6b91d..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/types.ts +++ /dev/null @@ -1,105 +0,0 @@ -import { - InfrastructureFeature, - OLMAnnotation, -} from '@console/operator-lifecycle-manager/src/components/operator-hub'; - -export enum FileBasedCatalogSchema { - Package = 'olm.package', - Channel = 'olm.channel', - Bundle = 'olm.bundle', -} - -export enum FileBasedCatalogPropertyType { - Package = 'olm.package', - CSVMetadata = 'olm.csv.metadata', -} - -export type CommaSeparatedList = string; // foo,bar,baz -export type SerializedJSONArray = string; // '["foo","bar","baz"]' -export type ProviderMetadataValue = { name: string } | string; - -export type CSVMetadata = { - annotations: { - [OLMAnnotation.Capabilities]?: string; - [OLMAnnotation.Categories]?: CommaSeparatedList; - [OLMAnnotation.ContainerImage]?: string; - [OLMAnnotation.CreatedAt]?: string; - [OLMAnnotation.Description]?: string; - [OLMAnnotation.DisplayName]?: string; - [OLMAnnotation.InfrastructureFeatures]?: SerializedJSONArray; - [OLMAnnotation.Repository]?: string; - [OLMAnnotation.Support]?: string; - [OLMAnnotation.ValidSubscription]?: SerializedJSONArray; - [key: string]: any; - }; - description?: string; - displayName?: string; - keywords?: string[]; - provider?: ProviderMetadataValue; - [key: string]: any; -}; - -export type FileBasedCatalogObject = { - schema: string; - name: string; - package: string; - [key: string]: any; -}; - -export type FileBasedCatalogProperty = { - type: string; - value: Value; -}; - -export type FileBasedCatalogBundle = FileBasedCatalogObject & { - properties: FileBasedCatalogProperty[]; -}; - -export type FileBasedCatalogChannelEntry = { - name: string; -}; - -export type FileBasedCatalogChannel = FileBasedCatalogObject & { - entries: FileBasedCatalogChannelEntry[]; -}; - -export type FileBasedCatalogPackage = FileBasedCatalogObject & { - icon: { - base64data: string; - mediatype: string; - }; -}; - -export type PackageMetadata = { - capabilities?: string; - categories?: string[]; - createdAt?: string; - description?: string; - displayName?: string; - image?: string; - infrastructureFeatures?: InfrastructureFeature[]; - keywords?: string[]; - longDescription?: string; - provider?: string; - repository?: string; - source?: string; - support?: string; - validSubscription?: string[]; -}; - -type SemverCoercableString = string; - -export type ExtensionCatalogItemChannels = { - [key: SemverCoercableString]: SemverCoercableString[]; -}; - -export type ExtensionCatalogItem = { - catalog: string; - channels?: ExtensionCatalogItemChannels; - icon: { - mediatype: string; - base64data: string; - }; - id: string; - name: string; -} & PackageMetadata; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/util.ts b/frontend/packages/operator-lifecycle-manager-v1/src/fbc/util.ts deleted file mode 100644 index db56a742bb5..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/util.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { FileBasedCatalogObject, FileBasedCatalogSchema } from './types'; - -export const getFileBasedCatalogObjectUID = ( - catalog: string, - { schema, package: pkg, name }: FileBasedCatalogObject, -) => { - switch (schema) { - case FileBasedCatalogSchema.Package: - return `${catalog}~${name}`; - default: - return `${catalog}~${pkg}~${name}`; - } -}; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useCatalogCategories.ts b/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useCatalogCategories.ts new file mode 100644 index 00000000000..8bb355eb5fa --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useCatalogCategories.ts @@ -0,0 +1,31 @@ +import * as React from 'react'; +import * as _ from 'lodash'; +import { CatalogCategory } from '@console/dynamic-plugin-sdk/src/extensions/catalog'; +import useCatalogItems from './useCatalogItems'; + +type UseCatalogCategories = () => [CatalogCategory[], boolean, string]; +const useCatalogCategories: UseCatalogCategories = () => { + const [items, loading, error] = useCatalogItems(); + const categories = React.useMemo(() => { + if (loading || error) { + return []; + } + return _.uniq( + items.flatMap(({ data }) => (data.categories ?? []).map((cat) => cat.trim())), + ) + .filter(Boolean) + .sort() + .map((label) => { + const id = label.toLowerCase(); + return { + id, + label, + tags: [id, label], + }; + }); + }, [error, items, loading]); + + return [categories, loading, error]; +}; + +export default useCatalogCategories; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useCatalogItems.ts b/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useCatalogItems.ts new file mode 100644 index 00000000000..502e546e6c0 --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useCatalogItems.ts @@ -0,0 +1,61 @@ +import * as React from 'react'; +import { getConsoleRequestHeaders } from '@console/dynamic-plugin-sdk/dist/core/lib/utils/fetch'; +import { CatalogItem } from '@console/dynamic-plugin-sdk/src'; +import { consoleFetch } from '@console/dynamic-plugin-sdk/src/lib-core'; +import { usePoll } from '@console/shared/src/hooks/usePoll'; +import { OLMCatalogItem } from '../types'; +import { normalizeCatalogItem } from '../utils/catalog-item'; + +export type OLMCatalogItemData = { + categories: string[]; + latestVersion: string; +}; + +type UseCatalogItems = () => [CatalogItem[], boolean, string]; +const useCatalogItems: UseCatalogItems = () => { + const [olmCatalogItems, setOLMCatalogItems] = React.useState([]); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(''); + const [lastModified, setLastModified] = React.useState(''); + + const headers = React.useMemo(() => { + const consoleHeaders = getConsoleRequestHeaders(); + return { + ...consoleHeaders, + 'If-Modified-Since': lastModified, + 'Cache-Control': 'max-age=300', + }; + }, [lastModified]); + + // Fetch function that only updates state on 200 responses + const fetchItems = React.useCallback(() => { + consoleFetch('/api/olm/catalog-items/', { headers }) + .then((response) => { + if (response.status === 304) { + return null; + } + + // Only update state on successful 200 response + if (response.status === 200) { + setLastModified((current) => response.headers.get('Last-Modified') || current); + return response.json(); + } + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + }) + .then((olmItems: OLMCatalogItem[] | null) => { + setOLMCatalogItems((current) => olmItems ?? current); + }) + .catch((err) => { + setError(err.toString()); + setLoading(false); + }); + }, [headers]); + + usePoll(fetchItems, 5000); // TODO, make this longer + + const items = React.useMemo(() => olmCatalogItems.map(normalizeCatalogItem), [olmCatalogItems]); + + return [items, loading, error]; +}; + +export default useCatalogItems; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useExtensionCatalogCategories.ts b/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useExtensionCatalogCategories.ts deleted file mode 100644 index 7c750e59a39..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useExtensionCatalogCategories.ts +++ /dev/null @@ -1,33 +0,0 @@ -import * as React from 'react'; -import { CatalogCategory } from '@console/dynamic-plugin-sdk/src'; -import { usePoll } from '@console/internal/components/utils'; -import { ExtensionCatalogDatabaseContext } from '../contexts/ExtensionCatalogDatabaseContext'; -import { getUniqueIndexKeys, openDatabase } from '../database/indexeddb'; - -export const useExtensionCatalogCategories = (): CatalogCategory[] => { - const { done: initDone, error: initError } = React.useContext(ExtensionCatalogDatabaseContext); - const [categories, setCategories] = React.useState([]); - - const tick = React.useCallback(() => { - if (initDone && !initError) { - openDatabase('olm') - .then((database) => getUniqueIndexKeys(database, 'extension-catalog', 'categories')) - .then((c) => { - setCategories(c); - }) - .catch(() => { - setCategories([]); - }); - } - }, [initDone, initError]); - - // Poll IndexedDB (IDB) every 10 seconds - usePoll(tick, 10000); - const catalogCategories = React.useMemo( - () => categories.map((c) => ({ id: c, label: c, tags: [c] })), - [categories], - ); - return catalogCategories; -}; - -export default useExtensionCatalogCategories; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useExtensionCatalogItems.ts b/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useExtensionCatalogItems.ts deleted file mode 100644 index d51dfb3715d..00000000000 --- a/frontend/packages/operator-lifecycle-manager-v1/src/hooks/useExtensionCatalogItems.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { useContext, useState, useEffect, useCallback, useMemo } from 'react'; -import { CatalogItem } from '@console/dynamic-plugin-sdk/src'; -import { usePoll } from '@console/internal/components/utils'; -import { ExtensionCatalogDatabaseContext } from '../contexts/ExtensionCatalogDatabaseContext'; -import { getItems, openDatabase } from '../database/indexeddb'; -import { normalizeExtensionCatalogItem } from '../fbc/catalog-item'; -import { ExtensionCatalogItem } from '../fbc/types'; - -type UseExtensionCatalogItems = () => [CatalogItem[], boolean, Error]; -export const useExtensionCatalogItems: UseExtensionCatalogItems = () => { - const { done: initDone, error: initError } = useContext(ExtensionCatalogDatabaseContext); - const [items, setItems] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(); - - useEffect(() => { - if (!initDone || initError) { - setLoading(!initDone); - setError(initError); - } - }, [initDone, initError]); - - const tick = useCallback(() => { - if (initDone && !initError) { - openDatabase('olm') - .then((database) => getItems(database, 'extension-catalog')) - .then((i) => { - setItems(i); - setError(null); - setLoading(false); - }) - .catch((e) => { - setError(e); - setLoading(false); - setItems([]); - }); - } - }, [initDone, initError]); - - // Poll IndexedDB (IDB) every 10 seconds - usePoll(tick, 10000); - - const normalizedItems = useMemo(() => items.map(normalizeExtensionCatalogItem), [ - items, - ]); - - return [normalizedItems, loading, error]; -}; - -export default useExtensionCatalogItems; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/types.ts b/frontend/packages/operator-lifecycle-manager-v1/src/types.ts new file mode 100644 index 00000000000..3e20d5148f0 --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager-v1/src/types.ts @@ -0,0 +1,25 @@ +export type OLMCatalogItem = { + id: string; + capabilities: string; + catalog: string; + categories: string[]; + createdAt: string; + description: string; + displayName: string; + image: string; + infrastructureFeatures: string[]; + keywords: string[]; + markdownDescription: string; + name: string; + provider: string; + repository: string; + source: string; + support: string; + validSubscription: string[]; + version: string; +}; + +export type OLMCatalogItemData = { + categories: string[]; + latestVersion: string; +}; diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/catalog-item.tsx b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx similarity index 62% rename from frontend/packages/operator-lifecycle-manager-v1/src/fbc/catalog-item.tsx rename to frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx index 328c3878c90..f4d09546901 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/src/fbc/catalog-item.tsx +++ b/frontend/packages/operator-lifecycle-manager-v1/src/utils/catalog-item.tsx @@ -1,42 +1,52 @@ import { CatalogItem } from '@console/dynamic-plugin-sdk/src'; import { SyncMarkdownView } from '@console/internal/components/markdown-view'; import { CapabilityLevel } from '@console/operator-lifecycle-manager/src/components/operator-hub/operator-hub-item-details'; -import { validSubscriptionReducer } from '@console/operator-lifecycle-manager/src/components/operator-hub/operator-hub-utils'; +import { + infrastructureFeatureMap, + validSubscriptionReducer, +} from '@console/operator-lifecycle-manager/src/components/operator-hub/operator-hub-utils'; import { Timestamp } from '@console/shared/src/components/datetime/Timestamp'; import { ExternalLink } from '@console/shared/src/components/links/ExternalLink'; import PlainList from '@console/shared/src/components/lists/PlainList'; -import { ExtensionCatalogItem } from './types'; +import { OLMCatalogItem, OLMCatalogItemData } from '../types'; -type NormalizeExtensionCatalogItem = (item: ExtensionCatalogItem) => CatalogItem; -export const normalizeExtensionCatalogItem: NormalizeExtensionCatalogItem = (pkg) => { +type NormalizeExtensionCatalogItem = (item: OLMCatalogItem) => CatalogItem; +export const normalizeCatalogItem: NormalizeExtensionCatalogItem = (pkg) => { const { + id, + capabilities, catalog, categories, - capabilities, + createdAt, description, displayName, - icon, + image, infrastructureFeatures, keywords, - longDescription, + markdownDescription, name, provider, repository, - support, - image, source, + support, validSubscription, - createdAt, + version, } = pkg; const [validSubscriptions, validSubscriptionFilters] = validSubscriptionReducer( validSubscription, ); + const normalizedInfrastructureFeatures = infrastructureFeatures?.reduce( + (acc, feature) => + infrastructureFeatureMap[feature] ? [...acc, infrastructureFeatureMap[feature]] : acc, + [], + ); + const tags = (categories ?? []).map((cat) => cat.toLowerCase().trim()).filter(Boolean); return { attributes: { keywords, source, provider, - infrastructureFeatures, + infrastructureFeatures: normalizedInfrastructureFeatures, capabilities, validSubscription: validSubscriptionFilters, }, @@ -45,7 +55,11 @@ export const normalizeExtensionCatalogItem: NormalizeExtensionCatalogItem = (pkg label: 'Install', href: `/ecosystem/catalog/install/${catalog}/${name}`, }, - description: description || longDescription, + description: description || markdownDescription, + data: { + latestVersion: version, + categories, + }, details: { properties: [ { @@ -56,8 +70,8 @@ export const normalizeExtensionCatalogItem: NormalizeExtensionCatalogItem = (pkg { label: 'Provider', value: provider || '-' }, { label: 'Infrastructure features', - value: infrastructureFeatures?.length ? ( - + value: normalizedInfrastructureFeatures?.length ? ( + ) : ( '-' ), @@ -74,16 +88,16 @@ export const normalizeExtensionCatalogItem: NormalizeExtensionCatalogItem = (pkg { label: 'Created at', value: createdAt ? : '-' }, { label: 'Support', value: support || '-' }, ], - descriptions: [{ value: }], + descriptions: [{ value: }], }, displayName, - icon: icon ? { url: `data:${icon.mediatype};base64,${icon.base64data}` } : null, + // icon: icon ? { url: `data:${icon.mediatype};base64,${icon.base64data}` } : null, name: displayName || name, supportUrl: support, provider, - tags: categories, - type: 'ExtensionCatalogItem', + tags, + type: 'OLMv1CatalogItem', typeLabel: source, - uid: `${catalog}-${name}`, + uid: id, }; }; diff --git a/frontend/public/co-fetch.ts b/frontend/public/co-fetch.ts index 3007699dd89..2009cdc6108 100644 --- a/frontend/public/co-fetch.ts +++ b/frontend/public/co-fetch.ts @@ -45,7 +45,7 @@ export const validateStatus = async ( method: string, retry: boolean, ) => { - if (response.ok) { + if (response.ok || response.status === 304) { return response; } diff --git a/frontend/public/components/poll-console-updates.tsx b/frontend/public/components/poll-console-updates.tsx index f9fec7c80d3..34f06ae30f9 100644 --- a/frontend/public/components/poll-console-updates.tsx +++ b/frontend/public/components/poll-console-updates.tsx @@ -2,8 +2,8 @@ import * as _ from 'lodash-es'; import { memo, useState, useCallback, useRef, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { coFetchJSON } from '../co-fetch'; -import { usePoll } from './utils/poll-hook'; -import { useSafeFetch } from './utils/safe-fetch-hook'; +import { useSafeFetch } from './utils'; +import { usePoll } from '@console/shared/src/hooks/usePoll'; import type { ConsolePluginManifestJSON } from '@console/dynamic-plugin-sdk/src/schema/plugin-manifest'; import { settleAllPromises } from '@console/dynamic-plugin-sdk/src/utils/promise'; import { URL_POLL_DEFAULT_DELAY } from '@console/internal/components/utils/url-poll-hook'; diff --git a/frontend/public/components/utils/index.tsx b/frontend/public/components/utils/index.tsx index 0ecb6829397..1c5a4d0fdff 100644 --- a/frontend/public/components/utils/index.tsx +++ b/frontend/public/components/utils/index.tsx @@ -39,7 +39,6 @@ export * from './workload-pause'; export * from './list-dropdown'; export * from './list-input'; export * from './rbac'; -export * from './poll-hook'; export * from './ref-width-hook'; export * from './safe-fetch-hook'; export * from './truncate-middle'; diff --git a/frontend/public/components/utils/url-poll-hook.ts b/frontend/public/components/utils/url-poll-hook.ts index 82a293b0b20..c07dd54f395 100644 --- a/frontend/public/components/utils/url-poll-hook.ts +++ b/frontend/public/components/utils/url-poll-hook.ts @@ -1,7 +1,7 @@ /* eslint-disable react-hooks/exhaustive-deps */ import { useCallback, useState } from 'react'; import { UseURLPoll } from '@console/dynamic-plugin-sdk/src/api/internal-types'; -import { usePoll } from './poll-hook'; +import { usePoll } from '@console/shared/src/hooks/usePoll'; import { useSafeFetch } from './safe-fetch-hook'; export const URL_POLL_DEFAULT_DELAY = 15000; // 15 seconds From d6cacd7f5d2d4f9fc758677b89beae69e9e274ee Mon Sep 17 00:00:00 2001 From: Jon Jackson Date: Thu, 30 Oct 2025 11:45:41 -0400 Subject: [PATCH 02/18] Add TECH_PREVIEW feature flag based on server flag MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implements an application-level TECH_PREVIEW feature flag in the console-app package that is set based on the SERVER_FLAGS.techPreview value from the backend. Frontend changes: - Add FLAG_TECH_PREVIEW constant to console-app/src/consts.ts - Create useTechPreviewFlagProvider hook to set flag from server - Register hook provider in console-app console-extensions.json - Export provider in console-app package.json - Add techPreview type to SERVER_FLAGS interface Backend changes: - Add TechPreview field to Server struct - Add techPreview to jsGlobals struct - Pass techPreview value to frontend in index handler 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- cmd/bridge/main.go | 1 + frontend/@types/console/index.d.ts | 1 + frontend/packages/console-app/console-extensions.json | 6 ++++++ frontend/packages/console-app/package.json | 1 + frontend/packages/console-app/src/consts.ts | 1 + .../console-app/src/hooks/useTechPreviewFlagProvider.ts | 9 +++++++++ pkg/server/server.go | 3 +++ 7 files changed, 22 insertions(+) create mode 100644 frontend/packages/console-app/src/hooks/useTechPreviewFlagProvider.ts diff --git a/cmd/bridge/main.go b/cmd/bridge/main.go index 75d862defe8..1fd27be5a1b 100644 --- a/cmd/bridge/main.go +++ b/cmd/bridge/main.go @@ -358,6 +358,7 @@ func main() { NodeOperatingSystems: nodeOperatingSystems, K8sMode: *fK8sMode, CopiedCSVsDisabled: *fCopiedCSVsDisabled, + TechPreview: *fTechPreview, Capabilities: capabilities, } diff --git a/frontend/@types/console/index.d.ts b/frontend/@types/console/index.d.ts index 6539a368d8d..6939d18e592 100644 --- a/frontend/@types/console/index.d.ts +++ b/frontend/@types/console/index.d.ts @@ -71,6 +71,7 @@ declare interface Window { nodeOperatingSystems: string[]; hubConsoleURL: string; k8sMode: string; + techPreview: boolean; capabilities: { name: string; visibility: { state: 'Enabled' | 'Disabled' }; diff --git a/frontend/packages/console-app/console-extensions.json b/frontend/packages/console-app/console-extensions.json index ff5979854b9..69167a7c8b1 100644 --- a/frontend/packages/console-app/console-extensions.json +++ b/frontend/packages/console-app/console-extensions.json @@ -2527,6 +2527,12 @@ "provider": { "$codeRef": "defaultProvider.useDefaultActionsProvider" } } }, + { + "type": "console.flag/hookProvider", + "properties": { + "handler": { "$codeRef": "useTechPreviewFlagProvider" } + } + }, { "type": "console.navigation/href", "properties": { diff --git a/frontend/packages/console-app/package.json b/frontend/packages/console-app/package.json index 44fb122b930..9d1a316b059 100644 --- a/frontend/packages/console-app/package.json +++ b/frontend/packages/console-app/package.json @@ -77,6 +77,7 @@ "consolePluginBackendDetail": "src/components/console-operator/ConsolePluginBackendDetail.tsx", "consolePluginProxyDetail": "src/components/console-operator/ConsolePluginProxyDetail.tsx", "getConsoleOperatorConfigFlag": "src/hooks/useCanGetConsoleOperatorConfig.ts", + "useTechPreviewFlagProvider": "src/hooks/useTechPreviewFlagProvider.ts", "usePerspectivesAvailable": "src/components/user-preferences/perspective/usePerspectivesAvailable.ts", "defaultProvider": "src/actions/providers/default-provider.ts", "OperatorStatus": "src/components/dashboards-page/OperatorStatus.tsx", diff --git a/frontend/packages/console-app/src/consts.ts b/frontend/packages/console-app/src/consts.ts index f7d5aabf7e5..f452b376559 100644 --- a/frontend/packages/console-app/src/consts.ts +++ b/frontend/packages/console-app/src/consts.ts @@ -8,6 +8,7 @@ export const FLAG_DEVELOPER_PERSPECTIVE = 'DEVELOPER_PERSPECTIVE'; export const ACM_PERSPECTIVE_ID = 'acm'; export const ADMIN_PERSPECTIVE_ID = 'admin'; export const FLAG_CAN_GET_CONSOLE_OPERATOR_CONFIG = 'CAN_GET_CONSOLE_OPERATOR_CONFIG'; +export const FLAG_TECH_PREVIEW = 'TECH_PREVIEW'; export const FAVORITES_CONFIG_MAP_KEY = 'console.favorites'; export const FAVORITES_LOCAL_STORAGE_KEY = `${STORAGE_PREFIX}/favorites`; diff --git a/frontend/packages/console-app/src/hooks/useTechPreviewFlagProvider.ts b/frontend/packages/console-app/src/hooks/useTechPreviewFlagProvider.ts new file mode 100644 index 00000000000..da0222acf50 --- /dev/null +++ b/frontend/packages/console-app/src/hooks/useTechPreviewFlagProvider.ts @@ -0,0 +1,9 @@ +import { SetFeatureFlag } from '@console/dynamic-plugin-sdk/src/extensions/feature-flags'; +import { FLAG_TECH_PREVIEW } from '../consts'; + +type UseTechPreviewFlagProvider = (setFeatureFlag: SetFeatureFlag) => void; +const useTechPreviewFlagProvider: UseTechPreviewFlagProvider = (setFeatureFlag) => { + setFeatureFlag(FLAG_TECH_PREVIEW, !!window.SERVER_FLAGS.techPreview); +}; + +export default useTechPreviewFlagProvider; diff --git a/pkg/server/server.go b/pkg/server/server.go index f56296b47df..a826e937872 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -141,6 +141,7 @@ type jsGlobals struct { StatuspageID string `json:"statuspageID"` Telemetry serverconfig.MultiKeyValue `json:"telemetry"` ThanosPublicURL string `json:"thanosPublicURL"` + TechPreview bool `json:"techPreview"` UserSettingsLocation string `json:"userSettingsLocation"` DevConsoleProxyAvailable bool `json:"devConsoleProxyAvailable"` } @@ -171,6 +172,7 @@ type Server struct { CopiedCSVsDisabled bool CSRFVerifier *csrfverifier.CSRFVerifier CustomLogoFiles serverconfig.LogosKeyValue + TechPreview bool CustomFaviconFiles serverconfig.LogosKeyValue CustomProductName string DevCatalogCategories string @@ -772,6 +774,7 @@ func (s *Server) indexHandler(w http.ResponseWriter, r *http.Request) { StatuspageID: s.StatuspageID, Telemetry: s.Telemetry, ThanosPublicURL: s.ThanosPublicURL.String(), + TechPreview: s.TechPreview, UserSettingsLocation: s.UserSettingsLocation, DevConsoleProxyAvailable: true, } From fa5028e9e9a5dc3f421e69b518c3659dcc0debfd Mon Sep 17 00:00:00 2001 From: Jon Jackson Date: Wed, 29 Oct 2025 16:03:35 -0400 Subject: [PATCH 03/18] Add CatalogToolbarItem extension point and test coverage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a new extension point allowing plugins to contribute toolbar items to the Catalog view for specific catalog types. This enables plugins to provide custom UI controls (like toggles, filters, or actions) that appear in the catalog toolbar alongside the existing search, sort, and grouping controls. Changes include: - New CatalogToolbarItem extension type in console-dynamic-plugin-sdk - Extension consumption in console-shared catalog components - Unit tests for useCatalogExtensions hook covering toolbar item filtering - React Testing Library tests for CatalogToolbar component rendering 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../docs/console-extensions.md | 157 ++++++++++-------- .../src/extensions/catalog.ts | 20 ++- .../components/catalog/CatalogController.tsx | 2 + .../__tests__/CatalogController.spec.tsx | 16 +- .../catalog/catalog-view/CatalogToolbar.tsx | 12 ++ .../catalog/catalog-view/CatalogView.tsx | 8 +- .../__tests__/CatalogToolbar.spec.tsx | 122 ++++++++++++++ .../__tests__/useCatalogExtensions.spec.ts | 139 ++++++++++++++++ .../catalog/hooks/useCatalogExtensions.ts | 17 +- .../service/CatalogServiceProvider.tsx | 2 + .../src/components/catalog/utils/types.ts | 2 + .../SampleGettingStartedCard.data.ts | 2 + 12 files changed, 424 insertions(+), 75 deletions(-) create mode 100644 frontend/packages/console-shared/src/components/catalog/catalog-view/__tests__/CatalogToolbar.spec.tsx diff --git a/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md b/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md index 6763cca0492..ae27ec20dce 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md +++ b/frontend/packages/console-dynamic-plugin-sdk/docs/console-extensions.md @@ -11,75 +11,76 @@ 9. [console.catalog/item-provider](#consolecatalogitem-provider) 10. [console.catalog/item-type](#consolecatalogitem-type) 11. [console.catalog/item-type-metadata](#consolecatalogitem-type-metadata) -12. [console.cluster-overview/inventory-item](#consolecluster-overviewinventory-item) -13. [console.cluster-overview/multiline-utilization-item](#consolecluster-overviewmultiline-utilization-item) -14. [console.cluster-overview/utilization-item](#consolecluster-overviewutilization-item) -15. [console.context-provider](#consolecontext-provider) -16. [console.create-project-modal](#consolecreate-project-modal) -17. [console.dashboards/card](#consoledashboardscard) -18. [console.dashboards/custom/overview/detail/item](#consoledashboardscustomoverviewdetailitem) -19. [console.dashboards/overview/activity/resource](#consoledashboardsoverviewactivityresource) -20. [console.dashboards/overview/health/operator](#consoledashboardsoverviewhealthoperator) -21. [console.dashboards/overview/health/prometheus](#consoledashboardsoverviewhealthprometheus) -22. [console.dashboards/overview/health/resource](#consoledashboardsoverviewhealthresource) -23. [console.dashboards/overview/health/url](#consoledashboardsoverviewhealthurl) -24. [console.dashboards/overview/inventory/item](#consoledashboardsoverviewinventoryitem) -25. [console.dashboards/overview/inventory/item/group](#consoledashboardsoverviewinventoryitemgroup) -26. [console.dashboards/overview/inventory/item/replacement](#consoledashboardsoverviewinventoryitemreplacement) -27. [console.dashboards/overview/prometheus/activity/resource](#consoledashboardsoverviewprometheusactivityresource) -28. [console.dashboards/project/overview/item](#consoledashboardsprojectoverviewitem) -29. [console.dashboards/tab](#consoledashboardstab) -30. [console.file-upload](#consolefile-upload) -31. [console.flag](#consoleflag) -32. [console.flag/hookProvider](#consoleflaghookProvider) -33. [console.flag/model](#consoleflagmodel) -34. [console.global-config](#consoleglobal-config) -35. [console.model-metadata](#consolemodel-metadata) -36. [console.navigation/href](#consolenavigationhref) -37. [console.navigation/resource-cluster](#consolenavigationresource-cluster) -38. [console.navigation/resource-ns](#consolenavigationresource-ns) -39. [console.navigation/section](#consolenavigationsection) -40. [console.navigation/separator](#consolenavigationseparator) -41. [console.page/resource/details](#consolepageresourcedetails) -42. [console.page/resource/list](#consolepageresourcelist) -43. [console.page/route](#consolepageroute) -44. [console.page/route/standalone](#consolepageroutestandalone) -45. [console.perspective](#consoleperspective) -46. [console.project-overview/inventory-item](#consoleproject-overviewinventory-item) -47. [console.project-overview/utilization-item](#consoleproject-overviewutilization-item) -48. [console.pvc/alert](#consolepvcalert) -49. [console.pvc/create-prop](#consolepvccreate-prop) -50. [console.pvc/delete](#consolepvcdelete) -51. [console.pvc/status](#consolepvcstatus) -52. [console.redux-reducer](#consoleredux-reducer) -53. [console.resource/create](#consoleresourcecreate) -54. [console.resource/details-item](#consoleresourcedetails-item) -55. [console.storage-class/provisioner](#consolestorage-classprovisioner) -56. [console.storage-provider](#consolestorage-provider) -57. [console.tab](#consoletab) -58. [console.tab/horizontalNav](#consoletabhorizontalNav) -59. [console.telemetry/listener](#consoletelemetrylistener) -60. [console.topology/adapter/build](#consoletopologyadapterbuild) -61. [console.topology/adapter/network](#consoletopologyadapternetwork) -62. [console.topology/adapter/pod](#consoletopologyadapterpod) -63. [console.topology/component/factory](#consoletopologycomponentfactory) -64. [console.topology/create/connector](#consoletopologycreateconnector) -65. [console.topology/data/factory](#consoletopologydatafactory) -66. [console.topology/decorator/provider](#consoletopologydecoratorprovider) -67. [console.topology/details/resource-alert](#consoletopologydetailsresource-alert) -68. [console.topology/details/resource-link](#consoletopologydetailsresource-link) -69. [console.topology/details/tab](#consoletopologydetailstab) -70. [console.topology/details/tab-section](#consoletopologydetailstab-section) -71. [console.topology/display/filters](#consoletopologydisplayfilters) -72. [console.topology/relationship/provider](#consoletopologyrelationshipprovider) -73. [console.user-preference/group](#consoleuser-preferencegroup) -74. [console.user-preference/item](#consoleuser-preferenceitem) -75. [console.yaml-template](#consoleyaml-template) -76. [dev-console.add/action](#dev-consoleaddaction) -77. [dev-console.add/action-group](#dev-consoleaddaction-group) -78. [dev-console.import/environment](#dev-consoleimportenvironment) -79. [DEPRECATED] [console.dashboards/overview/detail/item](#consoledashboardsoverviewdetailitem) -80. [DEPRECATED] [console.page/resource/tab](#consolepageresourcetab) +12. [console.catalog/toolbar-item](#consolecatalogtoolbar-item) +13. [console.cluster-overview/inventory-item](#consolecluster-overviewinventory-item) +14. [console.cluster-overview/multiline-utilization-item](#consolecluster-overviewmultiline-utilization-item) +15. [console.cluster-overview/utilization-item](#consolecluster-overviewutilization-item) +16. [console.context-provider](#consolecontext-provider) +17. [console.create-project-modal](#consolecreate-project-modal) +18. [console.dashboards/card](#consoledashboardscard) +19. [console.dashboards/custom/overview/detail/item](#consoledashboardscustomoverviewdetailitem) +20. [console.dashboards/overview/activity/resource](#consoledashboardsoverviewactivityresource) +21. [console.dashboards/overview/health/operator](#consoledashboardsoverviewhealthoperator) +22. [console.dashboards/overview/health/prometheus](#consoledashboardsoverviewhealthprometheus) +23. [console.dashboards/overview/health/resource](#consoledashboardsoverviewhealthresource) +24. [console.dashboards/overview/health/url](#consoledashboardsoverviewhealthurl) +25. [console.dashboards/overview/inventory/item](#consoledashboardsoverviewinventoryitem) +26. [console.dashboards/overview/inventory/item/group](#consoledashboardsoverviewinventoryitemgroup) +27. [console.dashboards/overview/inventory/item/replacement](#consoledashboardsoverviewinventoryitemreplacement) +28. [console.dashboards/overview/prometheus/activity/resource](#consoledashboardsoverviewprometheusactivityresource) +29. [console.dashboards/project/overview/item](#consoledashboardsprojectoverviewitem) +30. [console.dashboards/tab](#consoledashboardstab) +31. [console.file-upload](#consolefile-upload) +32. [console.flag](#consoleflag) +33. [console.flag/hookProvider](#consoleflaghookProvider) +34. [console.flag/model](#consoleflagmodel) +35. [console.global-config](#consoleglobal-config) +36. [console.model-metadata](#consolemodel-metadata) +37. [console.navigation/href](#consolenavigationhref) +38. [console.navigation/resource-cluster](#consolenavigationresource-cluster) +39. [console.navigation/resource-ns](#consolenavigationresource-ns) +40. [console.navigation/section](#consolenavigationsection) +41. [console.navigation/separator](#consolenavigationseparator) +42. [console.page/resource/details](#consolepageresourcedetails) +43. [console.page/resource/list](#consolepageresourcelist) +44. [console.page/route](#consolepageroute) +45. [console.page/route/standalone](#consolepageroutestandalone) +46. [console.perspective](#consoleperspective) +47. [console.project-overview/inventory-item](#consoleproject-overviewinventory-item) +48. [console.project-overview/utilization-item](#consoleproject-overviewutilization-item) +49. [console.pvc/alert](#consolepvcalert) +50. [console.pvc/create-prop](#consolepvccreate-prop) +51. [console.pvc/delete](#consolepvcdelete) +52. [console.pvc/status](#consolepvcstatus) +53. [console.redux-reducer](#consoleredux-reducer) +54. [console.resource/create](#consoleresourcecreate) +55. [console.resource/details-item](#consoleresourcedetails-item) +56. [console.storage-class/provisioner](#consolestorage-classprovisioner) +57. [console.storage-provider](#consolestorage-provider) +58. [console.tab](#consoletab) +59. [console.tab/horizontalNav](#consoletabhorizontalNav) +60. [console.telemetry/listener](#consoletelemetrylistener) +61. [console.topology/adapter/build](#consoletopologyadapterbuild) +62. [console.topology/adapter/network](#consoletopologyadapternetwork) +63. [console.topology/adapter/pod](#consoletopologyadapterpod) +64. [console.topology/component/factory](#consoletopologycomponentfactory) +65. [console.topology/create/connector](#consoletopologycreateconnector) +66. [console.topology/data/factory](#consoletopologydatafactory) +67. [console.topology/decorator/provider](#consoletopologydecoratorprovider) +68. [console.topology/details/resource-alert](#consoletopologydetailsresource-alert) +69. [console.topology/details/resource-link](#consoletopologydetailsresource-link) +70. [console.topology/details/tab](#consoletopologydetailstab) +71. [console.topology/details/tab-section](#consoletopologydetailstab-section) +72. [console.topology/display/filters](#consoletopologydisplayfilters) +73. [console.topology/relationship/provider](#consoletopologyrelationshipprovider) +74. [console.user-preference/group](#consoleuser-preferencegroup) +75. [console.user-preference/item](#consoleuser-preferenceitem) +76. [console.yaml-template](#consoleyaml-template) +77. [dev-console.add/action](#dev-consoleaddaction) +78. [dev-console.add/action-group](#dev-consoleaddaction-group) +79. [dev-console.import/environment](#dev-consoleimportenvironment) +80. [DEPRECATED] [console.dashboards/overview/detail/item](#consoledashboardsoverviewdetailitem) +81. [DEPRECATED] [console.page/resource/tab](#consolepageresourcetab) --- @@ -264,6 +265,22 @@ This extension allows plugins to contribute extra metadata like custom filters o --- +## `console.catalog/toolbar-item` + +### Summary + +This extension allows plugins to contribute toolbar items to the Catalog view for a specific catalog type. + +### Properties + +| Name | Value Type | Optional | Description | +| ---- | ---------- | -------- | ----------- | +| `component` | `CodeRef>` | no | The component to render in the catalog toolbar. | +| `catalogId` | `string` | yes | The catalog ID the toolbar item is for. If not specified, the toolbar item will be available for all catalogs. | +| `type` | `string` | yes | The catalog item type for this toolbar item. If not specified, the toolbar item will be available for all types. | + +--- + ## `console.cluster-overview/inventory-item` ### Summary @@ -826,8 +843,8 @@ Adds new standalone page (rendered outside the common page layout) to Console ro | Name | Value Type | Optional | Description | | ---- | ---------- | -------- | ----------- | -| `path` | `string \| string[]` | no | Valid URL path or array of paths that `path-to-regexp@^1.7.0` understands. | | `component` | `CodeRef>` | no | The component to be rendered when the route matches. | +| `path` | `string \| string[]` | no | Valid URL path or array of paths that `path-to-regexp@^1.7.0` understands. | | `exact` | `boolean` | yes | When true, will only match if the path matches the `location.pathname` exactly. | --- diff --git a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/catalog.ts b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/catalog.ts index 83a205fdb65..c764596b664 100644 --- a/frontend/packages/console-dynamic-plugin-sdk/src/extensions/catalog.ts +++ b/frontend/packages/console-dynamic-plugin-sdk/src/extensions/catalog.ts @@ -99,13 +99,27 @@ export type CatalogCategoriesProvider = ExtensionDeclaration< } >; +/** This extension allows plugins to contribute toolbar items to the Catalog view for a specific catalog type. */ +export type CatalogToolbarItem = ExtensionDeclaration< + 'console.catalog/toolbar-item', + { + /** The catalog ID the toolbar item is for. If not specified, the toolbar item will be available for all catalogs. */ + catalogId?: string; + /** The catalog item type for this toolbar item. If not specified, the toolbar item will be available for all types. */ + type?: string; + /** The component to render in the catalog toolbar. */ + component: CodeRef; + } +>; + export type SupportedCatalogExtensions = | CatalogItemType | CatalogItemTypeMetadata | CatalogItemProvider | CatalogItemFilter | CatalogItemMetadataProvider - | CatalogCategoriesProvider; + | CatalogCategoriesProvider + | CatalogToolbarItem; // Type guards @@ -133,6 +147,10 @@ export const isCatalogCategoriesProvider = (e: Extension): e is CatalogCategorie return e.type === 'console.catalog/categories-provider'; }; +export const isCatalogToolbarItem = (e: Extension): e is CatalogToolbarItem => { + return e.type === 'console.catalog/toolbar-item'; +}; + // Support types export type CatalogExtensionHookOptions = { diff --git a/frontend/packages/console-shared/src/components/catalog/CatalogController.tsx b/frontend/packages/console-shared/src/components/catalog/CatalogController.tsx index 9efab42e95e..558b0075f33 100644 --- a/frontend/packages/console-shared/src/components/catalog/CatalogController.tsx +++ b/frontend/packages/console-shared/src/components/catalog/CatalogController.tsx @@ -40,6 +40,7 @@ const CatalogController: React.FC = ({ loaded, loadError, catalogExtensions, + toolbarExtensions, enableDetailsPanel, title: defaultTitle, description: defaultDescription, @@ -199,6 +200,7 @@ const CatalogController: React.FC = ({ filterGroups={filterGroups} filterGroupMap={filterGroupMap} groupings={groupings} + toolbarExtensions={toolbarExtensions} renderTile={renderTile} hideSidebar={hideSidebar} sortFilterGroups={sortFilterGroups} diff --git a/frontend/packages/console-shared/src/components/catalog/__tests__/CatalogController.spec.tsx b/frontend/packages/console-shared/src/components/catalog/__tests__/CatalogController.spec.tsx index bbb38184e82..0440a435db3 100644 --- a/frontend/packages/console-shared/src/components/catalog/__tests__/CatalogController.spec.tsx +++ b/frontend/packages/console-shared/src/components/catalog/__tests__/CatalogController.spec.tsx @@ -44,6 +44,7 @@ describe('CatalogController', () => { uid: '@console/helm-plugin[9]', }, ], + toolbarExtensions: [], items: [], itemsMap: { HelmChart: [] }, loaded: true, @@ -64,7 +65,20 @@ describe('CatalogController', () => { type: 'HelmChart', title: 'Default title', description: 'Default description', - catalogExtensions: [], + catalogExtensions: [ + { + pluginID: '@console/helm-plugin', + pluginName: '@console/helm-plugin', + properties: { + catalogDescription: null, + title: null, + type: 'HelmChart', + }, + type: 'console.catalog/item-type', + uid: '@console/helm-plugin[9]', + }, + ], + toolbarExtensions: [], items: [], itemsMap: { HelmChart: [] }, loaded: true, diff --git a/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogToolbar.tsx b/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogToolbar.tsx index bf94ff2cb5d..e88e194af21 100644 --- a/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogToolbar.tsx +++ b/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogToolbar.tsx @@ -2,6 +2,8 @@ import { forwardRef } from 'react'; import { Flex, FlexItem, SearchInput } from '@patternfly/react-core'; import * as _ from 'lodash'; import { useTranslation } from 'react-i18next'; +import { CatalogToolbarItem } from '@console/dynamic-plugin-sdk/src/extensions'; +import { ResolvedExtension } from '@console/dynamic-plugin-sdk/src/types'; import { ConsoleSelect } from '@console/internal/components/utils/console-select'; import { useDebounceCallback } from '@console/shared/src/hooks/debounce'; import { NO_GROUPING } from '../utils/category-utils'; @@ -18,6 +20,7 @@ type CatalogToolbarProps = { sortOrder: CatalogSortOrder; groupings: CatalogStringMap; activeGrouping: string; + toolbarExtensions?: ResolvedExtension[]; onGroupingChange: (grouping: string) => void; onSearchKeywordChange: (searchKeyword: string) => void; onSortOrderChange: (sortOrder: CatalogSortOrder) => void; @@ -32,6 +35,7 @@ const CatalogToolbar = forwardRef( sortOrder, groupings, activeGrouping, + toolbarExtensions, onGroupingChange, onSearchKeywordChange, onSortOrderChange, @@ -94,6 +98,14 @@ const CatalogToolbar = forwardRef( /> )} + {toolbarExtensions?.map((extension) => { + const Component = extension.properties.component; + return ( + + + + ); + })} {t('console-shared~{{totalItems}} items', { totalItems })} diff --git a/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogView.tsx b/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogView.tsx index 1e396723a76..3750e83b477 100644 --- a/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogView.tsx +++ b/frontend/packages/console-shared/src/components/catalog/catalog-view/CatalogView.tsx @@ -1,8 +1,9 @@ import * as React from 'react'; import * as _ from 'lodash'; import { useTranslation } from 'react-i18next'; -import { CatalogCategory } from '@console/dynamic-plugin-sdk/src'; +import { CatalogCategory, CatalogToolbarItem } from '@console/dynamic-plugin-sdk/src'; import { CatalogItem } from '@console/dynamic-plugin-sdk/src/extensions'; +import { ResolvedExtension } from '@console/dynamic-plugin-sdk/src/types'; import { isModalOpen } from '@console/internal/components/modals'; import { useQueryParams } from '../../../hooks/useQueryParams'; import PaneBody from '../../layout/PaneBody'; @@ -57,6 +58,7 @@ type CatalogViewProps = { filterGroups: string[]; filterGroupMap: CatalogFilterGroupMap; groupings: CatalogStringMap; + toolbarExtensions?: ResolvedExtension[]; renderTile: (item: CatalogItem) => React.ReactNode; hideSidebar?: boolean; sortFilterGroups: boolean; @@ -71,6 +73,7 @@ const CatalogView: React.FC = ({ filterGroups, filterGroupMap, groupings, + toolbarExtensions, renderTile, hideSidebar, sortFilterGroups, @@ -118,7 +121,7 @@ const CatalogView: React.FC = ({ } }, [catalogType, items.length]); - const handleCategoryChange = (categoryId) => { + const handleCategoryChange = (categoryId: string) => { updateURLParams(CatalogQueryParams.CATEGORY, categoryId); }; @@ -341,6 +344,7 @@ const CatalogView: React.FC = ({ sortOrder={sortOrder} groupings={groupings} activeGrouping={activeGrouping} + toolbarExtensions={toolbarExtensions} onGroupingChange={handleGroupingChange} onSortOrderChange={handleSortOrderChange} onSearchKeywordChange={handleSearchKeywordChange} diff --git a/frontend/packages/console-shared/src/components/catalog/catalog-view/__tests__/CatalogToolbar.spec.tsx b/frontend/packages/console-shared/src/components/catalog/catalog-view/__tests__/CatalogToolbar.spec.tsx new file mode 100644 index 00000000000..a0af155dc66 --- /dev/null +++ b/frontend/packages/console-shared/src/components/catalog/catalog-view/__tests__/CatalogToolbar.spec.tsx @@ -0,0 +1,122 @@ +import { render } from '@testing-library/react'; +import { CatalogToolbarItem } from '@console/dynamic-plugin-sdk/src/extensions'; +import { ResolvedExtension } from '@console/dynamic-plugin-sdk/src/types'; +import { CatalogSortOrder } from '../../utils/types'; +import CatalogToolbar from '../CatalogToolbar'; + +jest.mock('react-i18next', () => ({ + useTranslation: () => ({ t: (key: string) => key }), + withTranslation: () => (Component: any) => Component, +})); + +jest.mock('@console/internal/components/utils/console-select', () => ({ + ConsoleSelect: jest.fn(() => null), +})); + +jest.mock('@console/shared', () => ({ + ...jest.requireActual('@console/shared'), + useDebounceCallback: (fn: any) => fn, +})); + +const mockToolbarComponent = () =>
Mock Toolbar Item
; + +describe('CatalogToolbar', () => { + const defaultProps = { + title: 'Test Catalog', + totalItems: 10, + searchKeyword: '', + sortOrder: CatalogSortOrder.ASC, + groupings: {}, + activeGrouping: '', + onGroupingChange: jest.fn(), + onSearchKeywordChange: jest.fn(), + onSortOrderChange: jest.fn(), + }; + + it('should render toolbar without toolbar extensions', () => { + const { getByText } = render(); + getByText('Test Catalog'); + getByText('console-shared~{{totalItems}} items'); + }); + + it('should render toolbar items when toolbarExtensions are provided', () => { + const toolbarExtensions: ResolvedExtension[] = [ + { + type: 'console.catalog/toolbar-item', + properties: { + component: mockToolbarComponent, + }, + pluginID: 'test-plugin', + pluginName: 'test-plugin', + uid: 'test-toolbar-item-1', + }, + ]; + + const { getByTestId } = render( + , + ); + getByTestId('mock-toolbar-item'); + }); + + it('should render multiple toolbar items', () => { + const toolbarExtensions: ResolvedExtension[] = [ + { + type: 'console.catalog/toolbar-item', + properties: { + component: () =>
Item 1
, + }, + pluginID: 'test-plugin', + pluginName: 'test-plugin', + uid: 'test-toolbar-item-1', + }, + { + type: 'console.catalog/toolbar-item', + properties: { + component: () =>
Item 2
, + }, + pluginID: 'test-plugin', + pluginName: 'test-plugin', + uid: 'test-toolbar-item-2', + }, + ]; + + const { getByTestId } = render( + , + ); + getByTestId('toolbar-item-1'); + getByTestId('toolbar-item-2'); + }); + + it('should render each toolbar extension with unique uid as key', () => { + const toolbarExtensions: ResolvedExtension[] = [ + { + type: 'console.catalog/toolbar-item', + properties: { + component: () =>
Unique Item
, + }, + pluginID: 'test-plugin', + pluginName: 'test-plugin', + uid: 'unique-toolbar-item-id', + }, + ]; + + const { getByTestId } = render( + , + ); + + // Verify the toolbar item component is rendered + getByTestId('item-1'); + }); + + it('should not render toolbar items when toolbarExtensions is undefined', () => { + const { queryByTestId } = render( + , + ); + expect(queryByTestId('mock-toolbar-item')).toBeNull(); + }); + + it('should not render toolbar items when toolbarExtensions is empty array', () => { + const { queryByTestId } = render(); + expect(queryByTestId('mock-toolbar-item')).toBeNull(); + }); +}); diff --git a/frontend/packages/console-shared/src/components/catalog/hooks/__tests__/useCatalogExtensions.spec.ts b/frontend/packages/console-shared/src/components/catalog/hooks/__tests__/useCatalogExtensions.spec.ts index fa95a92b76e..3219d0dceee 100644 --- a/frontend/packages/console-shared/src/components/catalog/hooks/__tests__/useCatalogExtensions.spec.ts +++ b/frontend/packages/console-shared/src/components/catalog/hooks/__tests__/useCatalogExtensions.spec.ts @@ -4,6 +4,7 @@ import { CatalogItemProvider, CatalogItemFilter, CatalogItemMetadataProvider, + CatalogToolbarItem, } from '@console/dynamic-plugin-sdk/src/extensions'; import { testHook } from '@console/shared/src/test-utils/hooks-utils'; import useCatalogExtensions from '../useCatalogExtensions'; @@ -14,6 +15,7 @@ let mockExtensions: ( | CatalogItemTypeMetadata | CatalogItemFilter | CatalogItemMetadataProvider + | CatalogToolbarItem )[] = []; jest.mock('@console/dynamic-plugin-sdk/src/api/useResolvedExtensions', () => ({ @@ -224,4 +226,141 @@ describe('useCatalogExtensions', () => { .current[3]; expect(extensions).toEqual([mockExtensions[1]]); }); + + it('should return toolbar item extensions', () => { + mockExtensions = [ + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'test-catalog', + component: jest.fn() as any, + }, + }, + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'test-catalog', + type: 'type1', + component: jest.fn() as any, + }, + }, + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'test-catalog', + type: 'type2', + component: jest.fn() as any, + }, + }, + ]; + + const allExtensions = testHook(() => useCatalogExtensions('test-catalog')).result.current[5]; + expect(allExtensions).toEqual([mockExtensions[0]]); + + const extensions = testHook(() => useCatalogExtensions('test-catalog', 'type2')).result + .current[5]; + expect(extensions).toEqual([mockExtensions[0], mockExtensions[2]]); + }); + + it('should filter toolbar items by catalogId', () => { + mockExtensions = [ + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'test-catalog', + type: 'type1', + component: jest.fn() as any, + }, + }, + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'other-catalog', + type: 'type1', + component: jest.fn() as any, + }, + }, + ]; + + const extensions = testHook(() => useCatalogExtensions('test-catalog', 'type1')).result + .current[5]; + expect(extensions).toEqual([mockExtensions[0]]); + }); + + it('should include toolbar items without catalogId or type specified', () => { + mockExtensions = [ + { + type: 'console.catalog/toolbar-item', + properties: { + component: jest.fn() as any, + }, + }, + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'test-catalog', + component: jest.fn() as any, + }, + }, + { + type: 'console.catalog/toolbar-item', + properties: { + type: 'type1', + component: jest.fn() as any, + }, + }, + ]; + + const extensions = testHook(() => useCatalogExtensions('test-catalog', 'type1')).result + .current[5]; + expect(extensions).toEqual([mockExtensions[0], mockExtensions[1], mockExtensions[2]]); + }); + + it('should filter toolbar items when type does not match', () => { + mockExtensions = [ + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'test-catalog', + type: 'type1', + component: jest.fn() as any, + }, + }, + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'test-catalog', + type: 'type2', + component: jest.fn() as any, + }, + }, + ]; + + const extensions = testHook(() => useCatalogExtensions('test-catalog', 'type1')).result + .current[5]; + expect(extensions).toEqual([mockExtensions[0]]); + }); + + it('should filter toolbar items when catalogType is not provided but extension has type', () => { + mockExtensions = [ + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'test-catalog', + component: jest.fn() as any, + }, + }, + { + type: 'console.catalog/toolbar-item', + properties: { + catalogId: 'test-catalog', + type: 'type1', + component: jest.fn() as any, + }, + }, + ]; + + const extensions = testHook(() => useCatalogExtensions('test-catalog')).result.current[5]; + expect(extensions).toEqual([mockExtensions[0]]); + }); }); diff --git a/frontend/packages/console-shared/src/components/catalog/hooks/useCatalogExtensions.ts b/frontend/packages/console-shared/src/components/catalog/hooks/useCatalogExtensions.ts index fd708c34411..1578d3f9a44 100644 --- a/frontend/packages/console-shared/src/components/catalog/hooks/useCatalogExtensions.ts +++ b/frontend/packages/console-shared/src/components/catalog/hooks/useCatalogExtensions.ts @@ -7,12 +7,14 @@ import { CatalogItemType, CatalogItemMetadataProvider, CatalogItemTypeMetadata, + CatalogToolbarItem, isCatalogItemFilter, isCatalogItemProvider, isCatalogItemType, isCatalogItemTypeMetadata, isCatalogItemMetadataProvider, isCatalogCategoriesProvider, + isCatalogToolbarItem, CatalogCategoriesProvider, } from '@console/dynamic-plugin-sdk/src/extensions'; @@ -25,6 +27,7 @@ const useCatalogExtensions = ( ResolvedExtension[], ResolvedExtension[], ResolvedExtension[], + ResolvedExtension[], boolean, ] => { const [itemTypeExtensions, itemTypesResolved] = useResolvedExtensions( @@ -91,6 +94,16 @@ const useCatalogExtensions = ( ), ); + const [toolbarItemExtensions, toolbarItemsResolved] = useResolvedExtensions( + useCallback( + (e): e is CatalogToolbarItem => + isCatalogToolbarItem(e) && + (!e.properties.catalogId || e.properties.catalogId === catalogId) && + (!e.properties.type || e.properties.type === catalogType), + [catalogId, catalogType], + ), + ); + const catalogTypeExtensions = useMemo[]>( () => (catalogType @@ -140,12 +153,14 @@ const useCatalogExtensions = ( catalogFilterExtensions, catalogMetadataProviderExtensions, categoryProviderExtensions, + toolbarItemExtensions, providersResolved && filtersResolved && itemTypesResolved && itemTypeMetadataResolved && metadataProvidersResolved && - categoryProvidersResolved, + categoryProvidersResolved && + toolbarItemsResolved, ]; }; diff --git a/frontend/packages/console-shared/src/components/catalog/service/CatalogServiceProvider.tsx b/frontend/packages/console-shared/src/components/catalog/service/CatalogServiceProvider.tsx index c59ebf9d9de..5269ed48aea 100644 --- a/frontend/packages/console-shared/src/components/catalog/service/CatalogServiceProvider.tsx +++ b/frontend/packages/console-shared/src/components/catalog/service/CatalogServiceProvider.tsx @@ -52,6 +52,7 @@ const CatalogServiceProvider: React.FC = ({ catalogFilterExtensions, catalogBadgeProviderExtensions, categoryProviderExtensions, + toolbarItemExtensions, extensionsResolved, ] = useCatalogExtensions(catalogId, catalogType); const [disabledSubCatalogs] = useGetAllDisabledSubCatalogs(); @@ -175,6 +176,7 @@ const CatalogServiceProvider: React.FC = ({ loadError, searchCatalog, catalogExtensions: catalogTypeExtensions, + toolbarExtensions: toolbarItemExtensions, categories, }; diff --git a/frontend/packages/console-shared/src/components/catalog/utils/types.ts b/frontend/packages/console-shared/src/components/catalog/utils/types.ts index 2a26606aed5..8de4165391f 100644 --- a/frontend/packages/console-shared/src/components/catalog/utils/types.ts +++ b/frontend/packages/console-shared/src/components/catalog/utils/types.ts @@ -2,6 +2,7 @@ import { CatalogItem, CatalogItemAttribute, CatalogItemType, + CatalogToolbarItem, CatalogCategory, } from '@console/dynamic-plugin-sdk/src/extensions'; import { @@ -58,5 +59,6 @@ export type CatalogService = { loadError: any; searchCatalog: (query: string) => CatalogItem[]; catalogExtensions: ResolvedExtension[]; + toolbarExtensions: ResolvedExtension[]; categories?: CatalogCategory[]; }; diff --git a/frontend/public/components/dashboard/project-dashboard/getting-started/__tests__/SampleGettingStartedCard.data.ts b/frontend/public/components/dashboard/project-dashboard/getting-started/__tests__/SampleGettingStartedCard.data.ts index bc0926e988e..6f66f29698c 100644 --- a/frontend/public/components/dashboard/project-dashboard/getting-started/__tests__/SampleGettingStartedCard.data.ts +++ b/frontend/public/components/dashboard/project-dashboard/getting-started/__tests__/SampleGettingStartedCard.data.ts @@ -7,6 +7,7 @@ export const loadingCatalogService: CatalogService = { loaded: false, loadError: null, searchCatalog: () => [], + toolbarExtensions: [], catalogExtensions: [ { type: 'console.catalog/item-type', @@ -158,6 +159,7 @@ export const loadingCatalogService: CatalogService = { export const loadedCatalogService: CatalogService = { type: '', searchCatalog: () => [], + toolbarExtensions: [], categories: [], items: [ { From 715ef94a7245b1b4a6fbad693798b77db0343c10 Mon Sep 17 00:00:00 2001 From: Jon Jackson Date: Wed, 29 Oct 2025 16:26:27 -0400 Subject: [PATCH 04/18] Add OLMv1 toggle to operator catalog toolbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a toolbar toggle component that allows users to enable/disable OLMv1 operators in the developer catalog. The toggle uses user settings to persist state and includes a tech preview label with informational popover. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .../console-extensions.json | 11 ++++ .../package.json | 3 +- .../src/components/OLMv1ToolbarToggle.tsx | 64 +++++++++++++++++++ 3 files changed, 77 insertions(+), 1 deletion(-) create mode 100644 frontend/packages/operator-lifecycle-manager-v1/src/components/OLMv1ToolbarToggle.tsx diff --git a/frontend/packages/operator-lifecycle-manager-v1/console-extensions.json b/frontend/packages/operator-lifecycle-manager-v1/console-extensions.json index 70518ded2fa..8db526fb32f 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/console-extensions.json +++ b/frontend/packages/operator-lifecycle-manager-v1/console-extensions.json @@ -128,5 +128,16 @@ "type": "OLMv1CatalogItem", "provider": { "$codeRef": "useCatalogCategories" } } + }, + { + "type": "console.catalog/toolbar-item", + "properties": { + "catalogId": "dev-catalog", + "type": "operator", + "component": { "$codeRef": "OLMv1ToolbarToggle" } + }, + "flags": { + "required": ["TECH_PREVIEW"] + } } ] diff --git a/frontend/packages/operator-lifecycle-manager-v1/package.json b/frontend/packages/operator-lifecycle-manager-v1/package.json index 4732fc8bed3..008afb95ced 100644 --- a/frontend/packages/operator-lifecycle-manager-v1/package.json +++ b/frontend/packages/operator-lifecycle-manager-v1/package.json @@ -9,7 +9,8 @@ "Catalog": "src/components/Catalog.tsx", "useCatalogItems": "src/hooks/useCatalogItems.ts", "useCatalogCategories": "src/hooks/useCatalogCategories.ts", - "filters": "src/utils/filters.ts" + "filters": "src/utils/filters.ts", + "OLMv1ToolbarToggle": "src/components/OLMv1ToolbarToggle.tsx" } } } diff --git a/frontend/packages/operator-lifecycle-manager-v1/src/components/OLMv1ToolbarToggle.tsx b/frontend/packages/operator-lifecycle-manager-v1/src/components/OLMv1ToolbarToggle.tsx new file mode 100644 index 00000000000..668d947ca00 --- /dev/null +++ b/frontend/packages/operator-lifecycle-manager-v1/src/components/OLMv1ToolbarToggle.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import { Button, Flex, FlexItem, Label, Popover, Switch } from '@patternfly/react-core'; +import { OutlinedQuestionCircleIcon } from '@patternfly/react-icons/dist/esm/icons/outlined-question-circle-icon'; +import { useTranslation } from 'react-i18next'; +import { useUserSettings } from '@console/shared'; + +/** + * Toolbar component for toggling OLMv1 UI visibility in the operator catalog. + * Uses user settings to persist the toggle state. + */ +const OLMv1ToolbarToggle: React.FC = () => { + const { t } = useTranslation(); + const [olmv1Enabled, setOlmv1Enabled] = useUserSettings( + 'console.olmv1.enabled', + false, + true, + ); + + const handleToggle = React.useCallback( + (_event: React.FormEvent, checked: boolean) => { + setOlmv1Enabled(checked); + }, + [setOlmv1Enabled], + ); + + const popoverContent = ( +
+ {t( + 'olm-v1~The OLMv1 catalog is a technology preview feature. Enabling this will show only OLMv1-based operators in the catalog.', + )} +
+ ); + + return ( + + + + + + + + + +