Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions packages/lib-core/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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])
Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion packages/lib-core/package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
1 change: 1 addition & 0 deletions packages/lib-core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export {
CustomError,
applyDefaults,
applyOverrides,
visitDeep,
cloneDeepOnlyCloneableValues,
LogFunction,
Logger,
Expand Down
37 changes: 14 additions & 23 deletions packages/lib-core/src/runtime/useResolvedExtensions.ts
Original file line number Diff line number Diff line change
@@ -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<TExtension extends Extension> = [
resolvedExtensions: LoadedAndResolvedExtension<TExtension>[],
Expand All @@ -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<UseResolvedExtensionsOptions> = {
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.
*
Expand All @@ -59,14 +52,15 @@ const defaultOptions: Required<UseResolvedExtensionsOptions> = {
* @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) => (
* <div key={e.uid}>
* <extension.properties.component />
* <e.properties.component />
* </div>
* ));
* }
Expand All @@ -78,23 +72,20 @@ const defaultOptions: Required<UseResolvedExtensionsOptions> = {
* @see {@link useExtensions}
*/
export const useResolvedExtensions = <TExtension extends Extension>(
predicate?: ExtensionPredicate<TExtension>,
extensions: LoadedExtension<TExtension>[],
options: UseResolvedExtensionsOptions = defaultOptions,
): UseResolvedExtensionsResult<TExtension> => {
if (!Array.isArray(extensions)) {
throw new Error('useResolvedExtensions hook requires an extensions array');
}

const includeExtensionsWithResolutionErrors = useMemo(
() =>
options.includeExtensionsWithResolutionErrors ??
defaultOptions.includeExtensionsWithResolutionErrors,
[options.includeExtensionsWithResolutionErrors],
);

const useExtensionsImpl = useMemo(
() => options.useExtensionsImpl ?? defaultOptions.useExtensionsImpl,
[options.useExtensionsImpl],
);

const extensions = useExtensionsImpl(predicate);

const [resolvedExtensions, setResolvedExtensions] = useState<
LoadedAndResolvedExtension<TExtension>[]
>([]);
Expand Down
12 changes: 8 additions & 4 deletions packages/sample-app/src/components/PageContent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<T>` 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(
Expand Down
55 changes: 55 additions & 0 deletions packages/sample-app/src/hooks/useReplaceTextExtensions.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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 = <TExtension extends Extension>(
extensions: LoadedExtension<TExtension>[],
): LoadedExtension<TExtension>[] => {
return useMemo(
() =>
extensions.map((e) => {
const clonedExtension = cloneDeepOnlyCloneableValues(e);

visitDeep<string>(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],
);
};
2 changes: 1 addition & 1 deletion packages/sample-app/src/local-plugins.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ const barManifest: LocalPluginManifest = {
registrationMethod: 'local',
customProperties: {
sampleApp: {
greeting: 'Greetings from local-bar plugin',
greeting: 'Hello from local-bar plugin',
},
},
};
Expand Down
2 changes: 1 addition & 1 deletion packages/sample-plugin/plugin-extensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ const extensions: ConsumedExtension[] = [
{
type: 'sample-app.text',
properties: {
text: 'Plasma reactors online',
text: 'Text in %APP_NAME%',
},
},
{
Expand Down
6 changes: 4 additions & 2 deletions reports/lib-core.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -363,12 +363,11 @@ export const usePluginInfo: () => PluginInfoEntry[];
export const usePluginStore: () => PluginStoreInterface;

// @public
export const useResolvedExtensions: <TExtension extends Extension<string, AnyObject>>(predicate?: ExtensionPredicate<TExtension> | undefined, options?: UseResolvedExtensionsOptions) => UseResolvedExtensionsResult<TExtension>;
export const useResolvedExtensions: <TExtension extends Extension<string, AnyObject>>(extensions: LoadedExtension<TExtension>[], options?: UseResolvedExtensionsOptions) => UseResolvedExtensionsResult<TExtension>;

// @public (undocumented)
export type UseResolvedExtensionsOptions = Partial<{
includeExtensionsWithResolutionErrors: boolean;
useExtensionsImpl: typeof useExtensions;
}>;

// @public (undocumented)
Expand All @@ -378,4 +377,7 @@ resolved: boolean,
errors: unknown[]
];

// @public
export const visitDeep: <TValue>(obj: AnyObject, predicate: (value: unknown) => value is TValue, valueCallback: (value: TValue, key: string, container: AnyObject) => void, isObject?: (obj: unknown) => obj is AnyObject) => void;

```