diff --git a/packages/lib-core/CHANGELOG.md b/packages/lib-core/CHANGELOG.md index d5f8fd69..fbaf0711 100644 --- a/packages/lib-core/CHANGELOG.md +++ b/packages/lib-core/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog for `@openshift/dynamic-plugin-sdk` +## 9.0.0 - TBD + +- BREAKING: Modify `useResolvedExtensions` hook to require `extensions` array ([#313]) +- Expose `visitDeep` utility function ([#313]) + ## 8.2.0 - 2026-03-19 - Expose additional utilities for processing code references ([#310]) @@ -138,3 +143,4 @@ [#305]: https://github.com/openshift/dynamic-plugin-sdk/pull/305 [#309]: https://github.com/openshift/dynamic-plugin-sdk/pull/309 [#310]: https://github.com/openshift/dynamic-plugin-sdk/pull/310 +[#313]: https://github.com/openshift/dynamic-plugin-sdk/pull/313 diff --git a/packages/lib-core/package.json b/packages/lib-core/package.json index 30df4bba..2c05cdd6 100644 --- a/packages/lib-core/package.json +++ b/packages/lib-core/package.json @@ -1,6 +1,6 @@ { "name": "@openshift/dynamic-plugin-sdk", - "version": "8.1.0", + "version": "9.0.0", "description": "Allows loading, managing and interpreting dynamic plugins", "license": "Apache-2.0", "repository": { diff --git a/packages/lib-core/src/index.ts b/packages/lib-core/src/index.ts index f0bffd14..0fdedce2 100644 --- a/packages/lib-core/src/index.ts +++ b/packages/lib-core/src/index.ts @@ -17,6 +17,7 @@ export { CustomError, applyDefaults, applyOverrides, + visitDeep, cloneDeepOnlyCloneableValues, LogFunction, Logger, diff --git a/packages/lib-core/src/runtime/useResolvedExtensions.ts b/packages/lib-core/src/runtime/useResolvedExtensions.ts index 9efc1caf..801f87f0 100644 --- a/packages/lib-core/src/runtime/useResolvedExtensions.ts +++ b/packages/lib-core/src/runtime/useResolvedExtensions.ts @@ -1,9 +1,8 @@ import { consoleLogger } from '@monorepo/common'; import { useState, useEffect, useMemo } from 'react'; -import type { Extension, LoadedAndResolvedExtension, ExtensionPredicate } from '../types/extension'; +import type { Extension, LoadedExtension, LoadedAndResolvedExtension } from '../types/extension'; import { settleAllPromises } from '../utils/promise'; import { resolveCodeRefValues } from './coderefs'; -import { useExtensions } from './useExtensions'; export type UseResolvedExtensionsResult = [ resolvedExtensions: LoadedAndResolvedExtension[], @@ -25,29 +24,23 @@ export type UseResolvedExtensionsOptions = Partial<{ * Default value: `false`. */ includeExtensionsWithResolutionErrors: boolean; - - /** - * Custom implementation of the {@link useExtensions} hook to use instead of the standard hook. - */ - useExtensionsImpl: typeof useExtensions; }>; const defaultOptions: Required = { includeExtensionsWithResolutionErrors: false, - useExtensionsImpl: useExtensions, }; /** - * React hook that calls `useExtensions` and resolves all code references in all matching extensions. + * React hook that resolves all code references in the provided extensions. * * Resolving code references to their corresponding values is an asynchronous operation. Initially, * this hook returns a pending result tuple `[resolvedExtensions: [], resolved: false, errors: []]`. * * Once the resolution is complete, this hook re-renders the component with a result tuple containing - * all matching extensions that had their code references resolved successfully along with any errors - * that occurred during the process. + * extensions that had their code references resolved successfully along with any errors that occurred + * during the process. * - * When the list of matching extensions changes, the resolution is restarted. In such case, the hook + * When the list of provided extensions changes, the resolution is restarted. In such case, the hook * will _not_ re-render the component with empty initial result since it's preferable to use existing * state until the current resolution completes. * @@ -59,14 +52,15 @@ const defaultOptions: Required = { * @example * ```tsx * const MyComponent = () => { - * const [extensions, resolved] = useResolvedExtensions(isSampleAppExtension); + * const extensions = useExtensions(isSampleAppExtension); + * const [resolvedExtensions, resolved] = useResolvedExtensions(extensions); * * let renderExtensions = null; * * if (resolved) { - * renderExtensions = extensions.map((e) => ( + * renderExtensions = resolvedExtensions.map((e) => ( *
- * + * *
* )); * } @@ -78,9 +72,13 @@ const defaultOptions: Required = { * @see {@link useExtensions} */ export const useResolvedExtensions = ( - predicate?: ExtensionPredicate, + extensions: LoadedExtension[], options: UseResolvedExtensionsOptions = defaultOptions, ): UseResolvedExtensionsResult => { + if (!Array.isArray(extensions)) { + throw new Error('useResolvedExtensions hook requires an extensions array'); + } + const includeExtensionsWithResolutionErrors = useMemo( () => options.includeExtensionsWithResolutionErrors ?? @@ -88,13 +86,6 @@ export const useResolvedExtensions = ( [options.includeExtensionsWithResolutionErrors], ); - const useExtensionsImpl = useMemo( - () => options.useExtensionsImpl ?? defaultOptions.useExtensionsImpl, - [options.useExtensionsImpl], - ); - - const extensions = useExtensionsImpl(predicate); - const [resolvedExtensions, setResolvedExtensions] = useState< LoadedAndResolvedExtension[] >([]); diff --git a/packages/sample-app/src/components/PageContent.tsx b/packages/sample-app/src/components/PageContent.tsx index 64efb8d0..c0853573 100644 --- a/packages/sample-app/src/components/PageContent.tsx +++ b/packages/sample-app/src/components/PageContent.tsx @@ -15,6 +15,7 @@ import { import { PuzzlePieceIcon } from '@patternfly/react-icons'; import type { FC, PropsWithChildren } from 'react'; import { useMemo } from 'react'; +import { useReplaceTextExtensions } from '../hooks/useReplaceTextExtensions'; import type { SampleAppExtensionWithText, SampleAppExtensionWithComponent, @@ -72,16 +73,19 @@ const ComponentExtensionCard: FC<{ * The `useExtensions` hook returns extensions which are currently in use without any further * transformations. Its argument is a predicate that filters extensions based on their `type`. * - * The `useResolvedExtensions` hook extends the `useExtensions` functionality by resolving all + * The `useResolvedExtensions` hook transforms the provided extensions by resolving all * `CodeRef` functions into corresponding `T` values within each extension's `properties` * object. This is an asynchronous operation that completes when all code references in all - * matching extensions have been processed. + * provided extensions have been processed. + * + * The `useReplaceTextExtensions` hook is an example on how to implement custom extension + * transformations, replacing `%key%` placeholders within each extension's `properties` object. */ export const RenderExtensions: FC = () => { - const textExtensions = useExtensions(isSampleAppExtensionWithText); + const textExtensions = useReplaceTextExtensions(useExtensions(isSampleAppExtensionWithText)); const [componentExtensions, componentExtensionsResolved] = useResolvedExtensions( - isSampleAppExtensionWithComponent, + useExtensions(isSampleAppExtensionWithComponent), ); const extensionsAvailable = useMemo( diff --git a/packages/sample-app/src/hooks/useReplaceTextExtensions.ts b/packages/sample-app/src/hooks/useReplaceTextExtensions.ts new file mode 100644 index 00000000..ef648bc9 --- /dev/null +++ b/packages/sample-app/src/hooks/useReplaceTextExtensions.ts @@ -0,0 +1,55 @@ +import type { Extension, LoadedExtension } from '@openshift/dynamic-plugin-sdk'; +import { cloneDeepOnlyCloneableValues, visitDeep } from '@openshift/dynamic-plugin-sdk'; +import { useMemo } from 'react'; + +const replaceValues: Record = { + APP_NAME: 'Sample Application', +}; + +const replacePlaceholders = (text: string) => { + let result = text; + + Array.from(text.matchAll(/%[^%\s]+%/g)) + .map((match) => match[0]) + .forEach((placeholder) => { + const key = placeholder.slice(1, -1); + const value = replaceValues[key]; + + if (value) { + result = result.replace(placeholder, value); + } + }); + + return result; +}; + +const isString = (obj: unknown): obj is string => typeof obj === 'string'; + +/** + * React hook that replaces `%key%` placeholders within each extension's `properties` object. + * + * Modifying the original extension objects is strongly discouraged. This hook is an example on how + * to implement custom extension transformations by cloning extensions and modifying their properties. + */ +export const useReplaceTextExtensions = ( + extensions: LoadedExtension[], +): LoadedExtension[] => { + return useMemo( + () => + extensions.map((e) => { + const clonedExtension = cloneDeepOnlyCloneableValues(e); + + visitDeep(clonedExtension.properties, isString, (value, key, obj) => { + const newValue = replacePlaceholders(value); + + if (newValue !== value) { + // eslint-disable-next-line no-param-reassign + obj[key] = newValue; + } + }); + + return clonedExtension; + }), + [extensions], + ); +}; diff --git a/packages/sample-app/src/local-plugins.tsx b/packages/sample-app/src/local-plugins.tsx index ccc56f99..c69d83e1 100644 --- a/packages/sample-app/src/local-plugins.tsx +++ b/packages/sample-app/src/local-plugins.tsx @@ -36,7 +36,7 @@ const barManifest: LocalPluginManifest = { registrationMethod: 'local', customProperties: { sampleApp: { - greeting: 'Greetings from local-bar plugin', + greeting: 'Hello from local-bar plugin', }, }, }; diff --git a/packages/sample-plugin/plugin-extensions.ts b/packages/sample-plugin/plugin-extensions.ts index 5ab48c75..7d153364 100644 --- a/packages/sample-plugin/plugin-extensions.ts +++ b/packages/sample-plugin/plugin-extensions.ts @@ -12,7 +12,7 @@ const extensions: ConsumedExtension[] = [ { type: 'sample-app.text', properties: { - text: 'Plasma reactors online', + text: 'Text in %APP_NAME%', }, }, { diff --git a/reports/lib-core.api.md b/reports/lib-core.api.md index bb0aeae4..5caa4b87 100644 --- a/reports/lib-core.api.md +++ b/reports/lib-core.api.md @@ -363,12 +363,11 @@ export const usePluginInfo: () => PluginInfoEntry[]; export const usePluginStore: () => PluginStoreInterface; // @public -export const useResolvedExtensions: >(predicate?: ExtensionPredicate | undefined, options?: UseResolvedExtensionsOptions) => UseResolvedExtensionsResult; +export const useResolvedExtensions: >(extensions: LoadedExtension[], options?: UseResolvedExtensionsOptions) => UseResolvedExtensionsResult; // @public (undocumented) export type UseResolvedExtensionsOptions = Partial<{ includeExtensionsWithResolutionErrors: boolean; - useExtensionsImpl: typeof useExtensions; }>; // @public (undocumented) @@ -378,4 +377,7 @@ resolved: boolean, errors: unknown[] ]; +// @public +export const visitDeep: (obj: AnyObject, predicate: (value: unknown) => value is TValue, valueCallback: (value: TValue, key: string, container: AnyObject) => void, isObject?: (obj: unknown) => obj is AnyObject) => void; + ```