├── .ci-operator.yaml ├── .dockerignore ├── .editorconfig ├── .eslintignore ├── .gitignore ├── .prettierrc ├── .vscode └── settings.json ├── .yarn ├── plugins │ └── @yarnpkg │ │ └── plugin-workspace-tools.cjs └── releases │ └── yarn-3.8.1.cjs ├── .yarnrc.yml ├── LICENSE ├── OWNERS ├── README.md ├── api-extractor.json ├── clean.sh ├── docker ├── Dockerfile.ci-operator-buildroot └── Dockerfile.ci-operator-buildroot-test ├── docs ├── developer-setup.md ├── images │ ├── plugins-overview-app.png │ ├── plugins-overview-basic.png │ ├── plugins-overview-plugin.png │ └── plugins-overview.drawio ├── plugins-overview.md ├── publish-packages.md └── react-version.md ├── generate-api-docs.sh ├── jest.config.ts ├── package.json ├── packages ├── common │ ├── .eslintrc.js │ ├── jest.config.ts │ ├── jest │ │ ├── jest-config-base.ts │ │ ├── jest-config-node.ts │ │ ├── jest-config-react.ts │ │ └── setup-react.ts │ ├── package.json │ ├── rollup │ │ ├── plugins │ │ │ └── writeJSONFile.js │ │ └── rollup-configs.js │ ├── src │ │ ├── errors │ │ │ ├── CustomError.ts │ │ │ └── ErrorWithCause.ts │ │ ├── index.ts │ │ ├── types │ │ │ └── common.ts │ │ └── utils │ │ │ ├── logger.ts │ │ │ ├── objects.test.ts │ │ │ └── objects.ts │ └── tsconfig-bases │ │ ├── all-targets.json │ │ ├── app-react-esm.json │ │ ├── lib-node-cjs.json │ │ └── lib-react-esm.json ├── eslint-plugin-internal │ ├── .eslintrc.js │ ├── package.json │ └── src │ │ ├── configs │ │ ├── all-bases.js │ │ ├── base-node.js │ │ ├── base-react.js │ │ ├── prettier.js │ │ └── typescript.js │ │ ├── index.js │ │ ├── rules │ │ ├── all-bases.js │ │ ├── node-typescript-prettier.js │ │ ├── react-typescript-prettier.js │ │ └── typescript.js │ │ └── runtime-rules │ │ └── lib-restricted-external-imports.js ├── lib-core │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── api-extractor.json │ ├── jest.config.ts │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── constants.ts │ │ ├── index.ts │ │ ├── runtime │ │ │ ├── PluginLoader.ts │ │ │ ├── PluginStore.ts │ │ │ ├── PluginStoreContext.tsx │ │ │ ├── coderefs.ts │ │ │ ├── useExtensions.ts │ │ │ ├── useFeatureFlag.ts │ │ │ ├── usePluginInfo.ts │ │ │ ├── usePluginSubscription.ts │ │ │ └── useResolvedExtensions.ts │ │ ├── shared-webpack.ts │ │ ├── testing │ │ │ └── TestPluginStore.ts │ │ ├── types │ │ │ ├── extension.ts │ │ │ ├── fetch.ts │ │ │ ├── loader.ts │ │ │ ├── plugin.ts │ │ │ ├── runtime.ts │ │ │ └── store.ts │ │ ├── utils │ │ │ ├── basic-fetch.ts │ │ │ ├── promise.ts │ │ │ ├── scripts.ts │ │ │ └── url.ts │ │ └── yup-schemas.ts │ ├── tsconfig.api-extractor.json │ └── tsconfig.json ├── lib-extensions │ ├── .eslintrc.js │ ├── README.md │ ├── api-extractor.json │ ├── jest.config.ts │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── extensions │ │ │ └── core │ │ │ │ ├── actions.ts │ │ │ │ ├── catalog.ts │ │ │ │ ├── context-providers.ts │ │ │ │ ├── feature-flags.ts │ │ │ │ ├── index.ts │ │ │ │ ├── model-metadata.ts │ │ │ │ ├── navigations.ts │ │ │ │ ├── pages.ts │ │ │ │ ├── redux.ts │ │ │ │ ├── resources.ts │ │ │ │ ├── telemetry.ts │ │ │ │ └── yaml-templates.ts │ │ ├── index.ts │ │ └── types │ │ │ └── common.ts │ └── tsconfig.json ├── lib-utils │ ├── .eslintrc.js │ ├── README.md │ ├── api-extractor.json │ ├── jest.config.ts │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── app │ │ │ ├── AppInitSDK.tsx │ │ │ ├── api-discovery │ │ │ │ ├── discovery-cache.ts │ │ │ │ └── index.ts │ │ │ └── redux │ │ │ │ ├── ReduxExtensionProvider.test.tsx │ │ │ │ ├── ReduxExtensionProvider.tsx │ │ │ │ ├── actions │ │ │ │ └── k8s.ts │ │ │ │ ├── index.ts │ │ │ │ └── reducers │ │ │ │ ├── index.ts │ │ │ │ └── k8s │ │ │ │ ├── index.ts │ │ │ │ ├── k8s.ts │ │ │ │ └── selector.ts │ │ ├── config.ts │ │ ├── hooks │ │ │ ├── index.ts │ │ │ ├── useEventListener.ts │ │ │ ├── useLocalStorage.ts │ │ │ ├── useWorkspace.test.tsx │ │ │ └── useWorkspace.ts │ │ ├── index.ts │ │ ├── k8s │ │ │ ├── hooks │ │ │ │ ├── index.ts │ │ │ │ ├── k8s-watch-types.ts │ │ │ │ ├── k8s-watcher.ts │ │ │ │ ├── use-model-types.ts │ │ │ │ ├── useDeepCompareMemoize.ts │ │ │ │ ├── useK8sModel.ts │ │ │ │ ├── useK8sModels.ts │ │ │ │ ├── useK8sWatchResource.test.ts │ │ │ │ ├── useK8sWatchResource.ts │ │ │ │ ├── useK8sWatchResources.test.ts │ │ │ │ ├── useK8sWatchResources.ts │ │ │ │ ├── useModelsLoaded.ts │ │ │ │ ├── usePrevious.ts │ │ │ │ └── watch-resource-types.ts │ │ │ ├── k8s-errors.ts │ │ │ ├── k8s-resource.ts │ │ │ ├── k8s-utils.test.ts │ │ │ └── k8s-utils.ts │ │ ├── types │ │ │ ├── api-discovery.ts │ │ │ ├── images.ts │ │ │ ├── k8s.ts │ │ │ └── redux.ts │ │ ├── utils │ │ │ ├── WorkspaceContext.ts │ │ │ ├── WorkspaceProvider.tsx │ │ │ ├── common-fetch.ts │ │ │ └── workspaceState.ts │ │ └── web-socket │ │ │ ├── WebSocketFactory.ts │ │ │ ├── types.ts │ │ │ └── utils.ts │ └── tsconfig.json ├── lib-webpack │ ├── .eslintrc.js │ ├── CHANGELOG.md │ ├── README.md │ ├── api-extractor.json │ ├── jest.config.ts │ ├── package.json │ ├── rollup.config.js │ ├── src │ │ ├── index.ts │ │ ├── types │ │ │ ├── plugin.ts │ │ │ └── webpack.ts │ │ ├── utils │ │ │ └── plugin-chunks.ts │ │ ├── webpack │ │ │ ├── DynamicRemotePlugin.ts │ │ │ ├── GenerateManifestPlugin.ts │ │ │ ├── PatchEntryCallbackPlugin.ts │ │ │ └── ValidateCompilationPlugin.ts │ │ └── yup-schemas.ts │ ├── tsconfig.api-extractor.json │ └── tsconfig.json ├── sample-app │ ├── .eslintrc.js │ ├── cypress.config.ts │ ├── package.json │ ├── src │ │ ├── app-index.html.ejs │ │ ├── app-styles.ts │ │ ├── app.css │ │ ├── app.tsx │ │ ├── components │ │ │ ├── ErrorBoundary.tsx │ │ │ ├── ErrorBoundaryFallback.css │ │ │ ├── ErrorBoundaryFallback.tsx │ │ │ ├── FeatureFlagTable.tsx │ │ │ ├── LabelWithTooltipIcon.tsx │ │ │ ├── LoadPluginModal.tsx │ │ │ ├── Loading.tsx │ │ │ ├── PageContent.cy.tsx │ │ │ ├── PageContent.tsx │ │ │ ├── PageHeader.tsx │ │ │ ├── PageLayout.tsx │ │ │ ├── PluginInfoTable.cy.tsx │ │ │ └── PluginInfoTable.tsx │ │ ├── cypress │ │ │ ├── component-index.html │ │ │ └── component-setup.tsx │ │ ├── e2e │ │ │ └── app.cy.ts │ │ ├── images │ │ │ ├── favicon.png │ │ │ └── pfColorLogo.svg │ │ ├── index.d.ts │ │ ├── shared-scope.ts │ │ ├── test-mocks.ts │ │ ├── types.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── webpack.config.ts └── sample-plugin │ ├── .dockerignore │ ├── .eslintrc.js │ ├── Caddyfile │ ├── Dockerfile │ ├── README.md │ ├── package.json │ ├── plugin-extensions.ts │ ├── plugin-metadata.ts │ ├── src │ └── telemetry-listener.ts │ ├── tsconfig.json │ └── webpack.config.ts ├── prow-codecov.sh ├── reports ├── lib-core.api.md ├── lib-extensions.api.md ├── lib-utils.api.md └── lib-webpack.api.md ├── test-prow-e2e.sh ├── test.sh └── yarn.lock /.ci-operator.yaml: -------------------------------------------------------------------------------- 1 | build_root_image: 2 | name: release 3 | namespace: openshift 4 | tag: dynamic-plugin-sdk 5 | -------------------------------------------------------------------------------- /.dockerignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /docs/generated 3 | /screenshots 4 | **/node_modules 5 | **/dist 6 | **/.DS_Store 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # https://editorconfig.org/ 2 | 3 | root = true 4 | 5 | [*] 6 | charset = utf-8 7 | end_of_line = lf 8 | indent_style = space 9 | indent_size = 2 10 | insert_final_newline = true 11 | trim_trailing_whitespace = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false 15 | -------------------------------------------------------------------------------- /.eslintignore: -------------------------------------------------------------------------------- 1 | **/node_modules 2 | **/dist 3 | **/.eslintrc.js 4 | **/rollup.config.js 5 | 6 | # TODO(vojtech): node-typescript-prettier preset needs tweaking 7 | /packages/lib-webpack/ 8 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /coverage 2 | /docs/generated 3 | /screenshots 4 | **/node_modules 5 | **/dist 6 | **/.DS_Store 7 | 8 | # https://next.yarnpkg.com/getting-started/qa#which-files-should-be-gitignored 9 | .pnp.* 10 | .yarn/* 11 | !.yarn/patches 12 | !.yarn/plugins 13 | !.yarn/releases 14 | !.yarn/sdks 15 | !.yarn/versions 16 | -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 100, 4 | "singleQuote": true, 5 | "trailingComma": "all" 6 | } 7 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "typescript.tsdk": "./node_modules/typescript/lib", 3 | "editor.defaultFormatter": "esbenp.prettier-vscode", 4 | 5 | "search.exclude": { 6 | "**/.git": true, 7 | "**/.yarn": true, 8 | "**/node_modules": true, 9 | "**/dist": true 10 | }, 11 | "files.watcherExclude": { 12 | "**/.git": true, 13 | "**/.yarn": true, 14 | "**/node_modules": true, 15 | "**/dist": true 16 | }, 17 | "files.associations": { 18 | "**/api-extractor.json": "jsonc" 19 | }, 20 | 21 | "eslint.options": { 22 | "rulePaths": ["./packages/eslint-plugin-internal/src/runtime-rules"] 23 | }, 24 | 25 | // https://code.visualstudio.com/docs/languages/identifiers 26 | "[javascript]": { 27 | "editor.formatOnSave": true 28 | }, 29 | "[javascriptreact]": { 30 | "editor.formatOnSave": true 31 | }, 32 | "[typescript]": { 33 | "editor.formatOnSave": true 34 | }, 35 | "[typescriptreact]": { 36 | "editor.formatOnSave": true 37 | }, 38 | "[json]": { 39 | "editor.formatOnSave": true 40 | }, 41 | "[jsonc]": { 42 | "editor.formatOnSave": true 43 | }, 44 | "[yaml]": { 45 | "editor.formatOnSave": true 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /.yarnrc.yml: -------------------------------------------------------------------------------- 1 | nodeLinker: node-modules 2 | 3 | plugins: 4 | - path: .yarn/plugins/@yarnpkg/plugin-workspace-tools.cjs 5 | spec: '@yarnpkg/plugin-workspace-tools' 6 | 7 | yarnPath: .yarn/releases/yarn-3.8.1.cjs 8 | 9 | logFilters: 10 | # Suppress YN0060 (INCOMPATIBLE_PEER_DEPENDENCY) log messages for react-virtualized package 11 | # Related issue: https://github.com/bvaughn/react-virtualized/pull/1740 12 | - pattern: "* provides react* with version *, which doesn't satisfy what react-virtualized requests" 13 | level: 'discard' 14 | -------------------------------------------------------------------------------- /OWNERS: -------------------------------------------------------------------------------- 1 | reviewers: 2 | - BlakeHolifield 3 | - fhlavac 4 | - florkbr 5 | - karelhala 6 | - vojtechszocs 7 | approvers: 8 | - andrewballantyne 9 | - florkbr 10 | - vojtechszocs 11 | component: Dynamic Plugin SDK 12 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OpenShift Dynamic Plugin SDK 2 | 3 | > Provides APIs, utilities and types to develop and run dynamic plugins in host web applications. 4 | 5 | ## Overview 6 | 7 | Host applications can load and interpret plugins from remote sources at runtime by utilizing 8 | [webpack module federation](https://webpack.js.org/concepts/module-federation/). Module federation 9 | allows a JavaScript application to load additional code while sharing common runtime dependencies. 10 | 11 | Both host applications and plugins can be built, released and deployed independently from each 12 | other. This reduces the coupling between an application and its plugins and allows individual 13 | plugins to be updated more frequently. 14 | 15 | Tools provided by this SDK are [React](https://reactjs.org/) focused. Host applications are React 16 | web apps built with [webpack](https://webpack.js.org/). Plugins use React APIs (hooks, components, 17 | etc.) to extend the functionality of the application itself and/or other plugins. 18 | 19 | ## Quick References 20 | 21 | - [Developer Setup](./docs/developer-setup.md) 22 | - [Publishing Distributable Packages](./docs/publish-packages.md) 23 | 24 | ## Distributable Packages 25 | 26 | Following SDK packages are distributed via [npmjs](https://www.npmjs.com/): 27 | 28 | | Package Name | Sources | 29 | | ------------ | ------- | 30 | | `@openshift/dynamic-plugin-sdk` | [packages/lib-core](./packages/lib-core/) | 31 | | `@openshift/dynamic-plugin-sdk-extensions` | [packages/lib-extensions](./packages/lib-extensions/) | 32 | | `@openshift/dynamic-plugin-sdk-utils` | [packages/lib-utils](./packages/lib-utils/) | 33 | | `@openshift/dynamic-plugin-sdk-webpack` | [packages/lib-webpack](./packages/lib-webpack/) | 34 | 35 | Each package is versioned and published independently from other packages. 36 | -------------------------------------------------------------------------------- /api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | // Matches the end_of_line setting in .editorconfig 3 | "newlineKind": "lf", 4 | 5 | "dtsRollup": { 6 | "enabled": true, 7 | "untrimmedFilePath": "/dist/index.d.ts" 8 | }, 9 | 10 | "apiReport": { 11 | "enabled": true, 12 | "reportFolder": "reports", 13 | "reportTempFolder": "/dist/api" 14 | }, 15 | 16 | "docModel": { 17 | "enabled": true 18 | }, 19 | 20 | // Avoid generating tsdoc-metadata.json 21 | // https://github.com/microsoft/rushstack/pull/1628#issuecomment-553665782 22 | "tsdocMetadata": { 23 | "enabled": false 24 | }, 25 | 26 | "messages": { 27 | "extractorMessageReporting": { 28 | // Avoid having to explicitly mark every exported API member with @public tag 29 | "ae-missing-release-tag": { 30 | "logLevel": "none" 31 | }, 32 | // Avoid issues with TSDoc parser unable to process @link references 33 | "ae-unresolved-link": { 34 | "logLevel": "none" 35 | } 36 | }, 37 | 38 | "tsdocMessageReporting": { 39 | // https://github.com/Microsoft/tsdoc/issues/19 40 | "tsdoc-param-tag-with-invalid-name": { 41 | "logLevel": "none" 42 | } 43 | } 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /clean.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | for dir in node_modules dist; do 5 | find . -type d -name "$dir" -prune -exec rm -rf {} \; 6 | done 7 | -------------------------------------------------------------------------------- /docker/Dockerfile.ci-operator-buildroot: -------------------------------------------------------------------------------- 1 | # This Docker image is used for testing via the OpenShift CI workflow. 2 | # 3 | # To build this image, run the following command in project root directory: 4 | # docker build -t ci-operator-buildroot --progress plain - < docker/Dockerfile.ci-operator-buildroot 5 | 6 | FROM quay.io/centos/centos:stream8 7 | 8 | # Disable color output to make log files more readable. 9 | ENV NO_COLOR=1 10 | 11 | # Update the system and install the necessary system packages. 12 | # Note that ci-operator requires git to be installed on the system. 13 | # https://docs.ci.openshift.org/docs/architecture/ci-operator/#build-root-image 14 | RUN dnf update -y && \ 15 | dnf install -y git python2 jq openssl && \ 16 | git config --system --add safe.directory '*' && \ 17 | git config --system advice.detachedHead false 18 | 19 | # Create /go directory and make it accessible to users in the root group. 20 | # Note that ci-operator requires /go directory to exist with write permission. 21 | # https://docs.openshift.com/container-platform/4.11/openshift_images/create-images.html#use-uid_create-images 22 | RUN mkdir /go && \ 23 | chgrp -R 0 /go && \ 24 | chmod -R g=u /go 25 | 26 | # Configure npm package manager (installed below) via environment variables. 27 | # https://docs.npmjs.com/cli/v8/using-npm/config#environment-variables 28 | ENV npm_config_cache=/go/.npm \ 29 | npm_config_update_notifier=false 30 | 31 | # Install Node.js via Node Version Manager. 32 | # This also updates npm package manager to the latest available version. 33 | # https://github.com/nvm-sh/nvm#manual-install 34 | ENV NVM_DIR=/go/.nvm \ 35 | NVM_VERSION=v0.39.5 \ 36 | NODE_VERSION=v18.18.0 \ 37 | NPM_VERSION=9.8.1 38 | RUN git clone -b $NVM_VERSION --depth 1 https://github.com/nvm-sh/nvm.git $NVM_DIR && \ 39 | . $NVM_DIR/nvm.sh && \ 40 | nvm install $NODE_VERSION && \ 41 | npm install -g npm@$NPM_VERSION 42 | ENV PATH=$NVM_DIR/versions/node/$NODE_VERSION/bin:$PATH 43 | 44 | # Install global npm package dependencies. 45 | RUN npm install -g yarn 46 | 47 | # Install Cypress related dependencies. 48 | # https://docs.cypress.io/guides/continuous-integration/introduction.html#Dependencies 49 | RUN dnf install -y xorg-x11-server-Xvfb gtk2-devel gtk3-devel libnotify-devel GConf2 nss libXScrnSaver alsa-lib 50 | 51 | # Clean up temporary files. 52 | RUN dnf clean all 53 | 54 | # Print post-build system information. 55 | RUN echo "node $(node -v)" && \ 56 | echo "npm $(npm -v)" && \ 57 | echo "yarn $(yarn -v)" && \ 58 | openssl version 59 | -------------------------------------------------------------------------------- /docker/Dockerfile.ci-operator-buildroot-test: -------------------------------------------------------------------------------- 1 | # This Docker image is used to verify the locally built ci-operator-buildroot image. 2 | # 3 | # To build this image, run the following command in project root directory: 4 | # docker build -t ci-operator-buildroot-test --progress plain -f docker/Dockerfile.ci-operator-buildroot-test . 5 | 6 | FROM ci-operator-buildroot 7 | 8 | ARG TEST_SCRIPT='test.sh' 9 | 10 | COPY . /go/src/github.com/openshift/dynamic-plugin-sdk 11 | WORKDIR /go/src/github.com/openshift/dynamic-plugin-sdk 12 | 13 | RUN ./$TEST_SCRIPT 14 | -------------------------------------------------------------------------------- /docs/developer-setup.md: -------------------------------------------------------------------------------- 1 | # Developer Setup 2 | 3 | ## Prerequisites 4 | 5 | - [Node.js](https://nodejs.org/) version used by 6 | [ci-operator-buildroot](../docker/Dockerfile.ci-operator-buildroot) image 7 | - [Yarn](https://yarnpkg.com/getting-started/install) package manager 8 | 9 | ## GitHub repo setup 10 | 11 | Fork the upstream [OpenShift Dynamic Plugin SDK](https://github.com/openshift/dynamic-plugin-sdk) repo, 12 | then `git clone` your forked repo. Refer to [GitHub docs](https://docs.github.com/en/get-started/quickstart/fork-a-repo) 13 | for details. 14 | 15 | We suggest renaming the Git remote representing the upstream repo to `upstream`. For example, assuming 16 | your GitHub user name is `your_user_name`: 17 | 18 | ``` 19 | $ git remote -v 20 | upstream https://github.com/openshift/dynamic-plugin-sdk.git (fetch) 21 | upstream https://github.com/openshift/dynamic-plugin-sdk.git (push) 22 | your_user_name https://github.com/your_user_name/dynamic-plugin-sdk.git (fetch) 23 | your_user_name https://github.com/your_user_name/dynamic-plugin-sdk.git (push) 24 | ``` 25 | 26 | ## Steps after cloning 27 | 28 | ```sh 29 | yarn install 30 | yarn build-libs 31 | ``` 32 | 33 | Alternatively, run `test.sh` which builds, lints and tests all the packages. 34 | 35 | ## Lint and test specific file paths 36 | 37 | ```sh 38 | yarn eslint path/to/lint 39 | yarn jest path/to/test 40 | ``` 41 | -------------------------------------------------------------------------------- /docs/images/plugins-overview-app.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift/dynamic-plugin-sdk/5941c2eb17520a4945a6ce23070f996c70d8b2be/docs/images/plugins-overview-app.png -------------------------------------------------------------------------------- /docs/images/plugins-overview-basic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift/dynamic-plugin-sdk/5941c2eb17520a4945a6ce23070f996c70d8b2be/docs/images/plugins-overview-basic.png -------------------------------------------------------------------------------- /docs/images/plugins-overview-plugin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift/dynamic-plugin-sdk/5941c2eb17520a4945a6ce23070f996c70d8b2be/docs/images/plugins-overview-plugin.png -------------------------------------------------------------------------------- /docs/publish-packages.md: -------------------------------------------------------------------------------- 1 | # Publishing Distributable Packages 2 | 3 | ## Check Node.js version 4 | 5 | Use the [Node.js](https://nodejs.org/) version used by 6 | [ci-operator-buildroot](../docker/Dockerfile.ci-operator-buildroot) image. 7 | 8 | ## Check npm version 9 | 10 | To check if the current installed version is outdated: 11 | 12 | ```sh 13 | npm outdated -g npm 14 | ``` 15 | 16 | To install the latest version: 17 | 18 | ```sh 19 | npm install -g npm@VERSION 20 | ``` 21 | 22 | ## Sync with upstream main branch 23 | 24 | Make sure you're in sync with the upstream `main` branch: 25 | 26 | ```sh 27 | git fetch upstream && git rebase upstream/main 28 | ``` 29 | 30 | ## Build packages 31 | 32 | To update dependencies and build all distributable SDK packages: 33 | 34 | ```sh 35 | yarn install 36 | yarn build-libs 37 | ``` 38 | 39 | Alternatively, you can build a specific SDK package: 40 | 41 | ```sh 42 | (cd ./packages/PKG_DIR ; yarn build) 43 | ``` 44 | 45 | ## Check package versions 46 | 47 | Make sure the `version` field in the relevant `package.json` file(s) has the right value: 48 | 49 | ```sh 50 | jq -r .version < ./packages/PKG_DIR/package.json 51 | npm pkg set version=NEW_VERSION -workspace ./packages/PKG_DIR 52 | ``` 53 | 54 | Since our packages adhere to [Semantic Versioning](https://semver.org/) specification, 55 | any backwards incompatible API changes _must_ be published under a new major version. 56 | 57 | ## Check package changelogs 58 | 59 | If present, make sure the `CHANGELOG.md` file of the given package(s) is up to date: 60 | 61 | - Each changelog entry describes a notable change that may impact consumers of the package. 62 | - Each version section may contain a notice with additional information, e.g. how to upgrade 63 | from a previous version. 64 | 65 | See [Common Changelog](https://common-changelog.org/) for details on good changelog practices. 66 | 67 | ## Log into npmjs account 68 | 69 | Only members of npmjs [openshift organization](https://www.npmjs.com/org/openshift) can publish 70 | packages maintained in this repo. 71 | 72 | ```sh 73 | npm login --scope=@openshift 74 | ``` 75 | 76 | ## Publish packages 77 | 78 | To see the latest published version of the given package: 79 | 80 | ```sh 81 | npm view $(jq -r .name < ./packages/PKG_DIR/package.json) dist-tags.latest 82 | ``` 83 | 84 | To verify the package before publishing: 85 | 86 | ```sh 87 | npm publish ./packages/PKG_DIR --no-git-tag-version --dry-run 88 | ``` 89 | 90 | To publish the package, run the above command without `--dry-run` parameter. 91 | -------------------------------------------------------------------------------- /docs/react-version.md: -------------------------------------------------------------------------------- 1 | # React Version Requirement 2 | 3 | Tools provided by dynamic plugin SDK are [React](https://reactjs.org/) focused. 4 | 5 | If your host application or plugin uses [PatternFly](https://www.patternfly.org/), you should 6 | use a React version that is officially supported by your PatternFly major version. 7 | 8 | Host applications will typically provide React specific modules such as `react` and `react-dom` 9 | to their plugins via webpack shared scope object. 10 | 11 | ## PatternFly 5 example 12 | 13 | The [manifest][pf-react-core-5.0.0] for `@patternfly/react-core` package version `5.0.0` contains 14 | the following peer dependencies: 15 | 16 | ``` 17 | "react": "^17 || ^18", 18 | "react-dom": "^17 || ^18" 19 | ``` 20 | 21 | This combines officially supported React versions (`^18`) and older and/or newer React versions 22 | for the sake of technical compatibility (`^17`) into a single version range. 23 | 24 | [pf-react-core-5.0.0]: https://github.com/patternfly/patternfly-react/blob/v5.0.0/packages/react-core/package.json 25 | -------------------------------------------------------------------------------- /generate-api-docs.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | yarn install 5 | yarn build-libs 6 | 7 | mkdir -p docs/generated && rm -rf docs/generated/* 8 | 9 | for dir in packages/lib-*; do 10 | cp $dir/dist/api/$(basename $dir).api.json docs/generated 11 | done 12 | 13 | yarn api-documenter markdown -i docs/generated -o docs/generated/api 14 | -------------------------------------------------------------------------------- /jest.config.ts: -------------------------------------------------------------------------------- 1 | import type { InitialOptionsTsJest } from 'ts-jest'; 2 | 3 | const config: InitialOptionsTsJest = { 4 | projects: [ 5 | '/packages/common', 6 | '/packages/lib-core', 7 | '/packages/lib-utils', 8 | '/packages/lib-webpack', 9 | ], 10 | 11 | collectCoverage: true, 12 | coverageDirectory: 'coverage', 13 | coverageReporters: ['lcov'], 14 | collectCoverageFrom: [ 15 | 'src/**/*.{js,jsx,ts,tsx}', 16 | '!**/(node_modules|dist)/**', 17 | '!**/*.{test,stories}.*', 18 | ], 19 | 20 | // https://github.com/kulshekhar/ts-jest/issues/259#issuecomment-888978737 21 | maxWorkers: 1, 22 | logHeapUsage: !!process.env.CI, 23 | }; 24 | 25 | export default config; 26 | -------------------------------------------------------------------------------- /packages/common/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@monorepo/eslint-plugin-internal/react-typescript-prettier'], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/common/jest.config.ts: -------------------------------------------------------------------------------- 1 | import reactConfig from '@monorepo/common/jest/jest-config-react'; 2 | import type { InitialOptionsTsJest } from 'ts-jest'; 3 | 4 | const config: InitialOptionsTsJest = { 5 | ...reactConfig, 6 | displayName: 'common', 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /packages/common/jest/jest-config-base.ts: -------------------------------------------------------------------------------- 1 | import type { InitialOptionsTsJest } from 'ts-jest'; 2 | 3 | const config: InitialOptionsTsJest = { 4 | // https://kulshekhar.github.io/ts-jest/docs/getting-started/presets 5 | preset: 'ts-jest/presets/js-with-ts', 6 | 7 | testMatch: ['**/*.test.(js|jsx|ts|tsx)'], 8 | transformIgnorePatterns: ['/node_modules/'], 9 | 10 | moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json'], 11 | moduleNameMapper: { 12 | '\\.(css|scss)$': 'identity-obj-proxy', 13 | }, 14 | 15 | clearMocks: true, 16 | }; 17 | 18 | export default config; 19 | -------------------------------------------------------------------------------- /packages/common/jest/jest-config-node.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type { InitialOptionsTsJest } from 'ts-jest'; 3 | import baseConfig from './jest-config-base'; 4 | 5 | const config: InitialOptionsTsJest = { 6 | ...baseConfig, 7 | testEnvironment: 'node', 8 | 9 | globals: { 10 | 'ts-jest': { 11 | tsconfig: path.resolve(__dirname, '../tsconfig-bases/lib-node-cjs.json'), 12 | }, 13 | }, 14 | }; 15 | 16 | export default config; 17 | -------------------------------------------------------------------------------- /packages/common/jest/jest-config-react.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import type { InitialOptionsTsJest } from 'ts-jest'; 3 | import baseConfig from './jest-config-base'; 4 | 5 | const config: InitialOptionsTsJest = { 6 | ...baseConfig, 7 | testEnvironment: 'jsdom', 8 | 9 | // https://github.com/jsdom/jsdom#simple-options 10 | testEnvironmentOptions: { 11 | url: 'http://localhost/', 12 | }, 13 | 14 | setupFilesAfterEnv: [path.resolve(__dirname, 'setup-react.ts')], 15 | 16 | globals: { 17 | 'ts-jest': { 18 | tsconfig: path.resolve(__dirname, '../tsconfig-bases/lib-react-esm.json'), 19 | }, 20 | }, 21 | }; 22 | 23 | export default config; 24 | -------------------------------------------------------------------------------- /packages/common/jest/setup-react.ts: -------------------------------------------------------------------------------- 1 | // This adds custom Jest matchers for working with the DOM 2 | // https://github.com/testing-library/jest-dom#custom-matchers 3 | import '@testing-library/jest-dom'; 4 | 5 | import { toHaveNoViolations } from 'jest-axe'; 6 | import { noop } from 'lodash'; 7 | 8 | expect.extend(toHaveNoViolations); 9 | 10 | jest.mock('react', () => ({ 11 | ...jest.requireActual('react'), 12 | useLayoutEffect: jest.requireActual('react').useEffect, 13 | })); 14 | 15 | // Following APIs are not implemented in jsdom 16 | Element.prototype.scrollTo = noop; 17 | -------------------------------------------------------------------------------- /packages/common/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monorepo/common", 3 | "version": "0.0.0-fixed", 4 | "description": "Common code and configuration used by all monorepo packages", 5 | "private": true, 6 | "main": "src/index.ts", 7 | "scripts": { 8 | "lint": "yarn run -T eslint $INIT_CWD", 9 | "test": "yarn run -T test $INIT_CWD" 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/common/rollup/plugins/writeJSONFile.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Emits a JSON file to the build output. 3 | * 4 | * @param {object} options 5 | * @param {string} options.fileName 6 | * @param {import('type-fest').JsonValue} options.value 7 | * @returns {import('rollup').Plugin} 8 | */ 9 | export const writeJSONFile = ({ fileName, value }) => ({ 10 | name: 'write-json-file', 11 | 12 | generateBundle() { 13 | this.emitFile({ 14 | type: 'asset', 15 | fileName, 16 | source: JSON.stringify(value, null, 2), 17 | }); 18 | }, 19 | }); 20 | -------------------------------------------------------------------------------- /packages/common/src/errors/CustomError.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Base class for custom errors. 3 | */ 4 | export class CustomError extends Error { 5 | constructor(message?: string) { 6 | super(message); 7 | 8 | // Set name as constructor name, while keeping its property descriptor compatible with Error.name 9 | // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new.target#new.target_in_constructors 10 | Object.defineProperty(this, 'name', { 11 | value: new.target.name, 12 | configurable: true, 13 | }); 14 | 15 | // Populate stack property via Error.captureStackTrace (when available) or manually via new Error() 16 | if (typeof Error.captureStackTrace === 'function') { 17 | Error.captureStackTrace(this, this.constructor); 18 | } else { 19 | this.stack = new Error(message).stack; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /packages/common/src/errors/ErrorWithCause.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from './CustomError'; 2 | 3 | /** 4 | * Custom error with a `cause` property. 5 | * 6 | * This shouldn't be needed once https://github.com/tc39/proposal-error-cause receives widespread support. 7 | */ 8 | export class ErrorWithCause extends CustomError { 9 | constructor(message: string, readonly cause: unknown) { 10 | super(message); 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/common/src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './errors/CustomError'; 2 | export * from './errors/ErrorWithCause'; 3 | export * from './types/common'; 4 | export * from './utils/logger'; 5 | export * from './utils/objects'; 6 | -------------------------------------------------------------------------------- /packages/common/src/types/common.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * The type `{}` doesn't mean "any empty object", it means "any non-nullish value". 3 | * 4 | * Use the `AnyObject` type for objects whose structure is unknown. 5 | * 6 | * @see https://github.com/typescript-eslint/typescript-eslint/issues/2063#issuecomment-675156492 7 | */ 8 | export type AnyObject = Record; 9 | 10 | /** 11 | * Replace existing direct properties of `T` with ones declared in `R`. 12 | */ 13 | export type ReplaceProperties = { 14 | [K in keyof T]: K extends keyof R ? R[K] : T[K]; 15 | }; 16 | 17 | /** 18 | * Never allow any properties of `T`. 19 | * 20 | * Utility type, probably never a reason to export. 21 | */ 22 | export type Never = { 23 | [K in keyof T]?: never; 24 | }; 25 | 26 | /** 27 | * Either TypeA properties or TypeB properties -- never both. 28 | * 29 | * @example 30 | * ```ts 31 | * type MyType = EitherNotBoth<{ foo: boolean }, { bar: boolean }>; 32 | * 33 | * // Valid usages: 34 | * const objA: MyType = { 35 | * foo: true, 36 | * }; 37 | * const objB: MyType = { 38 | * bar: true, 39 | * }; 40 | * 41 | * // TS Error -- can't have both properties: 42 | * const objBoth: MyType = { 43 | * foo: true, 44 | * bar: true, 45 | * }; 46 | * 47 | * // TS Error -- must have at least one property: 48 | * const objNeither: MyType = { 49 | * }; 50 | * ``` 51 | */ 52 | export type EitherNotBoth = (TypeA & Never) | (TypeB & Never); 53 | 54 | /** 55 | * Either TypeA properties or TypeB properties or neither of the properties -- never both. 56 | * 57 | * @example 58 | * ```ts 59 | * type MyType = EitherOrNone<{ foo: boolean }, { bar: boolean }>; 60 | * 61 | * // Valid usages: 62 | * const objA: MyType = { 63 | * foo: true, 64 | * }; 65 | * const objB: MyType = { 66 | * bar: true, 67 | * }; 68 | * const objNeither: MyType = { 69 | * }; 70 | * 71 | * // TS Error -- can't have both properties: 72 | * const objBoth: MyType = { 73 | * foo: true, 74 | * bar: true, 75 | * }; 76 | * ``` 77 | */ 78 | export type EitherOrNone = 79 | | EitherNotBoth 80 | | (Never & Never); 81 | -------------------------------------------------------------------------------- /packages/common/src/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import { noop } from 'lodash'; 2 | 3 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 4 | export type LogFunction = (message?: any, ...optionalParams: any[]) => void; 5 | 6 | /** 7 | * Minimal logger interface. 8 | */ 9 | export type Logger = Record<'info' | 'warn' | 'error', LogFunction>; 10 | 11 | const isProdEnv = process.env.NODE_ENV === 'production'; 12 | 13 | /** 14 | * {@link Logger} implementation that uses the {@link console} API. 15 | */ 16 | export const consoleLogger: Logger = { 17 | /* eslint-disable no-console */ 18 | info: isProdEnv ? noop : console.info, 19 | warn: console.warn, 20 | error: console.error, 21 | /* eslint-enable no-console */ 22 | }; 23 | -------------------------------------------------------------------------------- /packages/common/src/utils/objects.test.ts: -------------------------------------------------------------------------------- 1 | import { applyDefaults } from './objects'; 2 | 3 | describe('applyDefaults', () => { 4 | it('should recursively assign defaults for properties which are undefined', () => { 5 | expect( 6 | applyDefaults( 7 | { 8 | foo: 1, 9 | bar: { 10 | qux: 'test', 11 | }, 12 | }, 13 | { 14 | foo: true, 15 | bar: { 16 | qux: 2, 17 | mux: false, 18 | }, 19 | baz: 'bang', 20 | }, 21 | ), 22 | ).toEqual({ 23 | foo: 1, 24 | bar: { 25 | qux: 'test', 26 | mux: false, 27 | }, 28 | baz: 'bang', 29 | }); 30 | }); 31 | 32 | it('should return a new object', () => { 33 | const obj = { foo: 1 }; 34 | expect(applyDefaults(obj, {})).not.toBe(obj); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/common/src/utils/objects.ts: -------------------------------------------------------------------------------- 1 | import { defaultsDeep, forOwn, isPlainObject } from 'lodash'; 2 | import type { AnyObject } from '../types/common'; 3 | 4 | /** 5 | * Create new object by recursively assigning property defaults to `obj`. 6 | */ 7 | export const applyDefaults = (obj: TObject, defaults: unknown): TObject => 8 | defaultsDeep({}, obj, defaults); 9 | 10 | /** 11 | * Create new object by recursively assigning property overrides to `obj`. 12 | */ 13 | export const applyOverrides = (obj: TObject, overrides: unknown): TObject => 14 | defaultsDeep({}, overrides, obj); 15 | 16 | /** 17 | * Recursive equivalent of Lodash `forOwn` function that traverses objects and arrays. 18 | */ 19 | export const visitDeep = ( 20 | obj: AnyObject, 21 | predicate: (value: unknown) => value is TValue, 22 | valueCallback: (value: TValue, key: string, container: AnyObject) => void, 23 | isObject: (obj: unknown) => obj is AnyObject = (o): o is AnyObject => isPlainObject(o), 24 | ) => { 25 | forOwn(obj, (value: unknown, key: string, container: AnyObject) => { 26 | if (predicate(value)) { 27 | valueCallback(value, key, container); 28 | } else if (isObject(value)) { 29 | visitDeep(value, predicate, valueCallback, isObject); 30 | } else if (Array.isArray(value)) { 31 | value.forEach((element) => { 32 | visitDeep(element, predicate, valueCallback, isObject); 33 | }); 34 | } 35 | }); 36 | }; 37 | -------------------------------------------------------------------------------- /packages/common/tsconfig-bases/all-targets.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "compilerOptions": { 4 | "allowJs": true, 5 | "strict": true, 6 | "esModuleInterop": true, 7 | "resolveJsonModule": true, 8 | "skipLibCheck": true, 9 | "noUnusedLocals": true, 10 | "noImplicitOverride": true 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/common/tsconfig-bases/app-react-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./all-targets.json", 4 | "compilerOptions": { 5 | "target": "es2020", 6 | "module": "es2020", 7 | "moduleResolution": "node", 8 | "sourceMap": true, 9 | "jsx": "react" 10 | }, 11 | "ts-node": { 12 | "files": true, 13 | "transpileOnly": true, 14 | "compilerOptions": { 15 | "module": "commonjs" 16 | } 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /packages/common/tsconfig-bases/lib-node-cjs.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./all-targets.json", 4 | "compilerOptions": { 5 | "target": "es2021", 6 | "module": "commonjs", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "declarationMap": true 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/common/tsconfig-bases/lib-react-esm.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "https://json.schemastore.org/tsconfig", 3 | "extends": "./all-targets.json", 4 | "compilerOptions": { 5 | "target": "es2021", 6 | "module": "esnext", 7 | "moduleResolution": "node", 8 | "declaration": true, 9 | "declarationMap": true, 10 | "jsx": "react" 11 | } 12 | } 13 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@monorepo/eslint-plugin-internal/node-typescript-prettier'], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monorepo/eslint-plugin-internal", 3 | "version": "0.0.0-fixed", 4 | "description": "Internal ESLint plugin for linting monorepo packages", 5 | "private": true, 6 | "main": "src/index.js", 7 | "scripts": { 8 | "lint": "yarn run -T eslint $INIT_CWD" 9 | }, 10 | "engines": { 11 | "node": ">=16" 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/configs/all-bases.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | settings: { 3 | 'import/internal-regex': /^@monorepo\//, 4 | }, 5 | }; 6 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/configs/base-node.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['airbnb-base', 'plugin:promise/recommended', 'plugin:node/recommended'], 3 | 4 | parserOptions: { 5 | ecmaVersion: 2021, 6 | sourceType: 'module', 7 | }, 8 | 9 | env: { 10 | es2021: true, 11 | node: true, 12 | }, 13 | 14 | settings: { 15 | ...require('./all-bases').settings, 16 | }, 17 | 18 | plugins: ['promise', 'node', 'lodash'], 19 | 20 | rules: require('../rules/all-bases'), 21 | }; 22 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/configs/base-react.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // airbnb config covers rules for plugins: import, react, react-hooks, jsx-a11y 3 | // airbnb rules for React hooks need to be enabled explicitly via airbnb/hooks 4 | extends: ['airbnb', 'airbnb/hooks', 'plugin:promise/recommended'], 5 | 6 | parserOptions: { 7 | ecmaVersion: 2021, 8 | ecmaFeatures: { jsx: true }, 9 | sourceType: 'module', 10 | }, 11 | 12 | env: { 13 | es2021: true, 14 | browser: true, 15 | }, 16 | 17 | settings: { 18 | ...require('./all-bases').settings, 19 | }, 20 | 21 | plugins: ['promise', 'lodash'], 22 | 23 | rules: require('../rules/all-bases'), 24 | }; 25 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/configs/prettier.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | extends: ['plugin:prettier/recommended'], 3 | 4 | plugins: ['prettier'], 5 | }; 6 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/configs/typescript.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | overrides: [ 3 | { 4 | files: ['*.ts', '*.tsx'], 5 | 6 | extends: ['plugin:@typescript-eslint/recommended'], 7 | 8 | parser: '@typescript-eslint/parser', 9 | 10 | settings: { 11 | 'import/parsers': { 12 | '@typescript-eslint/parser': ['.ts', '.tsx'], 13 | }, 14 | 'import/resolver': { 15 | typescript: { 16 | alwaysTryTypes: true, 17 | project: 'packages/*/tsconfig.json', 18 | }, 19 | }, 20 | }, 21 | 22 | plugins: ['@typescript-eslint'], 23 | 24 | rules: require('../rules/typescript'), 25 | }, 26 | ], 27 | }; 28 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/index.js: -------------------------------------------------------------------------------- 1 | const componentConfigs = { 2 | // Base configs: choose one 3 | 'base-node': require('./configs/base-node'), 4 | 'base-react': require('./configs/base-react'), 5 | 6 | // TypeScript support (optional) 7 | typescript: require('./configs/typescript'), 8 | 9 | // Prettier must go last (optional) 10 | prettier: require('./configs/prettier'), 11 | }; 12 | 13 | const commonPresetConfig = { 14 | // Report unused eslint-disable comments as warnings 15 | reportUnusedDisableDirectives: true, 16 | }; 17 | 18 | const presetConfigs = { 19 | 'node-typescript-prettier': { 20 | ...commonPresetConfig, 21 | extends: [ 22 | 'plugin:@monorepo/eslint-plugin-internal/base-node', 23 | 'plugin:@monorepo/eslint-plugin-internal/typescript', 24 | 'plugin:@monorepo/eslint-plugin-internal/prettier', 25 | ], 26 | rules: require('./rules/node-typescript-prettier'), 27 | }, 28 | 29 | 'react-typescript-prettier': { 30 | ...commonPresetConfig, 31 | extends: [ 32 | 'plugin:@monorepo/eslint-plugin-internal/base-react', 33 | 'plugin:@monorepo/eslint-plugin-internal/typescript', 34 | 'plugin:@monorepo/eslint-plugin-internal/prettier', 35 | ], 36 | rules: require('./rules/react-typescript-prettier'), 37 | }, 38 | }; 39 | 40 | module.exports = { 41 | configs: { ...componentConfigs, ...presetConfigs }, 42 | }; 43 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/rules/all-bases.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Prefer a specific import scope, using destructured members 3 | 'lodash/import-scope': ['error', 'member'], 4 | 5 | // Enforce a maximum number of classes per file 6 | 'max-classes-per-file': 'off', 7 | 8 | // Enforce a convention in the order of require() / import statements 9 | 'import/order': [ 10 | 'error', 11 | { 12 | groups: ['builtin', 'external', 'internal', 'parent', 'sibling', 'index'], 13 | alphabetize: { 14 | order: 'asc', 15 | caseInsensitive: true, 16 | }, 17 | }, 18 | ], 19 | 20 | // When there is only a single export from a module, prefer using default export over named export 21 | 'import/prefer-default-export': 'off', 22 | 23 | // Replaced by lib-restricted-external-imports, see the replacement rule documentation for details 24 | 'import/no-extraneous-dependencies': 'off', 25 | 26 | // Enforce imported external modules to be declared in dependencies or peerDependencies within the closest parent package.json 27 | 'lib-restricted-external-imports': [ 28 | 'error', 29 | [ 30 | { 31 | includeFiles: 'packages/+(lib-core|lib-webpack)/src/**', 32 | excludeFiles: '**/*.test.*', 33 | excludeModules: ['@monorepo/common'], 34 | }, 35 | { 36 | includeFiles: 'packages/+(lib-extensions|lib-utils)/src/**', 37 | excludeFiles: '**/*.+(test|stories).*', 38 | }, 39 | ], 40 | ], 41 | }; 42 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/rules/node-typescript-prettier.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Deprecated in ESLint v7.0.0, replaced by node/global-require 3 | 'global-require': 'off', 4 | 5 | // Disallow require() expressions which import extraneous modules 6 | 'node/no-extraneous-require': 'off', 7 | 8 | // Disallow require() expressions which import private modules 9 | 'node/no-unpublished-require': 'off', 10 | }; 11 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/rules/react-typescript-prettier.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Ensure consistent use of file extension within the import path 3 | 'import/extensions': 'off', 4 | 5 | // Disallow unnecessary fragments 6 | 'react/jsx-no-useless-fragment': [ 7 | 'error', 8 | { 9 | allowExpressions: true, 10 | }, 11 | ], 12 | 13 | // Enforce a specific function type for function components 14 | 'react/function-component-definition': [ 15 | 'error', 16 | { 17 | namedComponents: 'arrow-function', 18 | unnamedComponents: 'arrow-function', 19 | }, 20 | ], 21 | 22 | // Enforce a defaultProps definition for every prop that is not a required prop 23 | 'react/require-default-props': 'off', 24 | 25 | // Enforce that block statements are wrapped in curly braces 26 | curly: ['error', 'all'], 27 | }; 28 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/rules/typescript.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | // Disallow the use of variables before they are defined 3 | 'no-use-before-define': 'off', 4 | '@typescript-eslint/no-use-before-define': 'error', 5 | 6 | // Disallow variable declarations from shadowing variables declared in the outer scope 7 | 'no-shadow': 'off', 8 | '@typescript-eslint/no-shadow': 'error', 9 | 10 | // Enforce default parameters to be last 11 | 'default-param-last': 'off', 12 | '@typescript-eslint/default-param-last': 'error', 13 | 14 | // Enforces consistent usage of type imports 15 | '@typescript-eslint/consistent-type-imports': 'error', 16 | 17 | // Restrict file extensions that may contain JSX 18 | 'react/jsx-filename-extension': [ 19 | 'error', 20 | { 21 | allow: 'as-needed', 22 | extensions: ['.tsx'], 23 | }, 24 | ], 25 | }; 26 | -------------------------------------------------------------------------------- /packages/eslint-plugin-internal/src/runtime-rules/lib-restricted-external-imports.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const findUp = require('find-up'); 3 | const minimatch = require('minimatch'); 4 | 5 | /** @typedef {{ includeFiles: string, excludeFiles: string, excludeModules: string[] }} RuleSetting */ 6 | 7 | /** 8 | * @param {import('eslint').Rule.RuleContext} context 9 | * @param {import('estree').ImportDeclaration} node 10 | */ 11 | const checkImport = (context, node) => { 12 | const specifier = node.source.value; 13 | 14 | if (typeof specifier !== 'string' || /^[./]/.test(specifier)) { 15 | return; 16 | } 17 | 18 | const moduleName = /^@[^/\s]+\//.test(specifier) 19 | ? specifier.split('/').slice(0, 2).join('/') 20 | : specifier.split('/')[0]; 21 | 22 | const relativeFilePath = path.relative(context.getCwd(), context.getFilename()); 23 | 24 | /** @type {RuleSetting[]} */ 25 | const ruleSettings = context.options[0] ?? []; 26 | 27 | const shouldLint = ruleSettings.some( 28 | ({ includeFiles, excludeFiles, excludeModules }) => 29 | !( 30 | (includeFiles && !minimatch(relativeFilePath, includeFiles)) || 31 | (excludeFiles && minimatch(relativeFilePath, excludeFiles)) || 32 | (excludeModules && excludeModules.includes(moduleName)) 33 | ), 34 | ); 35 | 36 | if (!shouldLint) { 37 | return; 38 | } 39 | 40 | const pkgPath = findUp.sync('package.json', { cwd: path.dirname(context.getFilename()) }); 41 | 42 | if (!pkgPath) { 43 | context.report({ 44 | node, 45 | message: `Cannot find the closest parent package.json.`, 46 | }); 47 | } 48 | 49 | // eslint-disable-next-line import/no-dynamic-require 50 | const { dependencies = {}, peerDependencies = {} } = require(pkgPath); 51 | 52 | if (![...Object.keys(dependencies), ...Object.keys(peerDependencies)].includes(moduleName)) { 53 | context.report({ 54 | node, 55 | message: `'${moduleName}' should be listed in dependencies or peerDependencies.`, 56 | }); 57 | } 58 | }; 59 | 60 | /** 61 | * Enforce imported external modules to be declared in `dependencies` or `peerDependencies` 62 | * within the closest parent `package.json`. 63 | * 64 | * This rule is meant to replace `import/no-extraneous-dependencies` which seems to have 65 | * some behavioral inconsistencies as well as suboptimal support for monorepo projects. 66 | * 67 | * This rule differs from `import/no-extraneous-dependencies` in the following ways: 68 | * - only the first closest parent `package.json` found is taken into account 69 | * - external modules must be declared in either `dependencies` or `peerDependencies` 70 | * - supports optional criteria that determine whether a file should be linted 71 | * - `includeFiles` and `excludeFiles` - `minimatch` compatible file glob patterns 72 | * - `excludeModules` - external modules to exclude from linting 73 | * 74 | * @type {import('eslint').Rule.RuleModule} 75 | */ 76 | module.exports = { 77 | meta: { 78 | type: 'problem', 79 | schema: [ 80 | { 81 | type: 'array', 82 | items: { 83 | type: 'object', 84 | properties: { 85 | includeFiles: { 86 | type: 'string', 87 | }, 88 | excludeFiles: { 89 | type: 'string', 90 | }, 91 | excludeModules: { 92 | type: 'array', 93 | items: { 94 | type: 'string', 95 | }, 96 | }, 97 | }, 98 | additionalProperties: false, 99 | }, 100 | }, 101 | ], 102 | }, 103 | 104 | create(context) { 105 | return { 106 | ImportDeclaration: (node) => { 107 | checkImport(context, node); 108 | }, 109 | }; 110 | }, 111 | }; 112 | -------------------------------------------------------------------------------- /packages/lib-core/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@monorepo/eslint-plugin-internal/react-typescript-prettier'], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/lib-core/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for `@openshift/dynamic-plugin-sdk` 2 | 3 | ## 5.0.1 - 2024-01-15 4 | 5 | - Ensure `transformPluginManifest` is always called before loading a plugin ([#253]) 6 | 7 | ## 5.0.0 - 2023-11-03 8 | 9 | > This release adds the ability to provide your own plugin loader implementation when creating 10 | > the `PluginStore`. Note that the `useResolvedExtensions` hook does not automatically disable 11 | > plugins whose extensions have code reference resolution errors. 12 | 13 | - Rename `postProcessManifest` loader option to `transformPluginManifest` ([#236]) 14 | - Support passing custom plugin loader implementation to `PluginStore` ([#232]) 15 | - Add `TestPluginStore` intended for React component testing purposes ([#232]) 16 | - Add options to `useResolvedExtensions` hook to customize its default behavior ([#241]) 17 | 18 | ## 4.0.0 - 2023-04-13 19 | 20 | > This release removes the `PluginLoader` export. Pass the former `PluginLoader` 21 | > options object as `loaderOptions` when creating the `PluginStore`. 22 | 23 | - Modify `PluginStore.loadPlugin` signature to accept plugin manifest ([#212]) 24 | - Ensure `PluginStore.loadPlugin` returns the same Promise for pending plugins ([#212]) 25 | - Treat `PluginLoader` as an implementation detail of `PluginStore` ([#212]) 26 | - Replace `entryCallbackName` loader option with `entryCallbackSettings.name` ([#212]) 27 | - Add `entryCallbackSettings.autoRegisterCallback` loader option ([#212]) 28 | - Support tracking pending plugins via `PluginStore.getPluginInfo` ([#212]) 29 | - Provide access to raw plugin manifest in all `PluginInfoEntry` objects ([#212]) 30 | - Support CommonJS build output and improve generated Lodash imports ([#215]) 31 | 32 | ## 3.0.0 - 2023-03-02 33 | 34 | > This release adds new mandatory field to plugin manifest: `baseURL`. 35 | > Use the `PluginLoader` option `postProcessManifest` to adapt existing manifests. 36 | 37 | - Allow plugins to pass custom properties via plugin manifest ([#204]) 38 | - Add `sdkVersion` to `PluginStore` for better runtime diagnostics ([#200]) 39 | - Provide direct access to raw plugin manifest data ([#207]) 40 | - Remove `PluginStore` option `postProcessExtensions` ([#207]) 41 | - Add technical compatibility with React 18 ([#208]) 42 | 43 | ## 2.0.1 - 2023-01-27 44 | 45 | - Call `postProcessManifest` regardless of plugin manifest origin ([#190]) 46 | 47 | ## 2.0.0 - 2023-01-23 48 | 49 | > This release adds new mandatory fields to plugin manifest: `loadScripts`, `registrationMethod`. 50 | > Use the `PluginLoader` option `postProcessManifest` to adapt existing manifests. 51 | 52 | - Support loading plugins built with webpack library type other than `jsonp` ([#182]) 53 | - Allow reloading plugins which are already loaded ([#182]) 54 | - Allow providing custom manifest object in `PluginStore.loadPlugin` ([#182]) 55 | - Provide direct access to plugin modules via `PluginStore.getExposedModule` ([#180]) 56 | - Fix `useResolvedExtensions` hook to reset result before restarting resolution ([#182]) 57 | - Ensure that all APIs referenced through the package index are exported ([#184]) 58 | 59 | ## 1.0.0 - 2022-10-27 60 | 61 | > Initial release. 62 | 63 | [#180]: https://github.com/openshift/dynamic-plugin-sdk/pull/180 64 | [#182]: https://github.com/openshift/dynamic-plugin-sdk/pull/182 65 | [#184]: https://github.com/openshift/dynamic-plugin-sdk/pull/184 66 | [#190]: https://github.com/openshift/dynamic-plugin-sdk/pull/190 67 | [#200]: https://github.com/openshift/dynamic-plugin-sdk/pull/200 68 | [#204]: https://github.com/openshift/dynamic-plugin-sdk/pull/204 69 | [#207]: https://github.com/openshift/dynamic-plugin-sdk/pull/207 70 | [#208]: https://github.com/openshift/dynamic-plugin-sdk/pull/208 71 | [#212]: https://github.com/openshift/dynamic-plugin-sdk/pull/212 72 | [#215]: https://github.com/openshift/dynamic-plugin-sdk/pull/215 73 | [#232]: https://github.com/openshift/dynamic-plugin-sdk/pull/232 74 | [#236]: https://github.com/openshift/dynamic-plugin-sdk/pull/236 75 | [#241]: https://github.com/openshift/dynamic-plugin-sdk/pull/241 76 | [#253]: https://github.com/openshift/dynamic-plugin-sdk/pull/253 77 | -------------------------------------------------------------------------------- /packages/lib-core/README.md: -------------------------------------------------------------------------------- 1 | # `@openshift/dynamic-plugin-sdk` 2 | 3 | > Allows loading, managing and interpreting dynamic plugins. 4 | -------------------------------------------------------------------------------- /packages/lib-core/api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../api-extractor.json", 3 | "mainEntryPointFilePath": "dist/types/lib-core/src/index.d.ts", 4 | "bundledPackages": ["@monorepo/common"], 5 | "compiler": { "tsconfigFilePath": "tsconfig.api-extractor.json" }, 6 | "apiReport": { "reportFileName": "lib-core.api.md" }, 7 | "docModel": { "apiJsonFilePath": "dist/api/lib-core.api.json" } 8 | } 9 | -------------------------------------------------------------------------------- /packages/lib-core/jest.config.ts: -------------------------------------------------------------------------------- 1 | import reactConfig from '@monorepo/common/jest/jest-config-react'; 2 | import type { InitialOptionsTsJest } from 'ts-jest'; 3 | 4 | const config: InitialOptionsTsJest = { 5 | ...reactConfig, 6 | displayName: 'lib-core', 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /packages/lib-core/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openshift/dynamic-plugin-sdk", 3 | "version": "5.0.1", 4 | "description": "Allows loading, managing and interpreting dynamic plugins", 5 | "license": "Apache-2.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/openshift/dynamic-plugin-sdk.git", 9 | "directory": "packages/lib-core" 10 | }, 11 | "files": [ 12 | "dist/index.cjs.js", 13 | "dist/index.esm.js", 14 | "dist/index.d.ts", 15 | "dist/build-metadata.json", 16 | "CHANGELOG.md" 17 | ], 18 | "main": "dist/index.cjs.js", 19 | "module": "dist/index.esm.js", 20 | "types": "dist/index.d.ts", 21 | "scripts": { 22 | "prepack": "yarn build", 23 | "prepublishOnly": "yarn test", 24 | "build": "rm -rf dist && yarn run -T rollup -c && yarn api-extractor", 25 | "lint": "yarn run -T eslint $INIT_CWD", 26 | "test": "yarn run -T test $INIT_CWD", 27 | "api-extractor": "yarn run -T api-extractor -c $INIT_CWD/api-extractor.json" 28 | }, 29 | "peerDependencies": { 30 | "react": "^17 || ^18" 31 | }, 32 | "dependencies": { 33 | "lodash": "^4.17.21", 34 | "semver": "^7.3.7", 35 | "uuid": "^8.3.2", 36 | "yup": "^0.32.11" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/lib-core/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { tsBuildConfig } from '../common/rollup/rollup-configs'; 2 | import pkg from './package.json'; 3 | 4 | export default tsBuildConfig({ pkg, format: 'cjs-and-esm' }); 5 | -------------------------------------------------------------------------------- /packages/lib-core/src/constants.ts: -------------------------------------------------------------------------------- 1 | export const DEFAULT_REMOTE_ENTRY_CALLBACK = '__load_plugin_entry__'; 2 | -------------------------------------------------------------------------------- /packages/lib-core/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Core runtime package of the dynamic plugin SDK. 3 | * 4 | * @remarks 5 | * This package allows loading, managing and interpreting dynamic plugins at runtime. 6 | * 7 | * @packageDocumentation 8 | */ 9 | 10 | // Common types and utilities 11 | export { 12 | AnyObject, 13 | ReplaceProperties, 14 | Never, 15 | EitherNotBoth, 16 | EitherOrNone, 17 | CustomError, 18 | applyDefaults, 19 | applyOverrides, 20 | LogFunction, 21 | Logger, 22 | consoleLogger, 23 | } from '@monorepo/common'; 24 | 25 | // Core components 26 | export { PluginLoaderOptions } from './runtime/PluginLoader'; 27 | export { PluginStore, PluginStoreOptions, PluginStoreLoaderSettings } from './runtime/PluginStore'; 28 | export { 29 | PluginStoreProvider, 30 | PluginStoreProviderProps, 31 | usePluginStore, 32 | } from './runtime/PluginStoreContext'; 33 | 34 | // React hooks 35 | export { useExtensions } from './runtime/useExtensions'; 36 | export { 37 | useResolvedExtensions, 38 | UseResolvedExtensionsResult, 39 | UseResolvedExtensionsOptions, 40 | } from './runtime/useResolvedExtensions'; 41 | export { usePluginInfo } from './runtime/usePluginInfo'; 42 | export { useFeatureFlag, UseFeatureFlagResult } from './runtime/useFeatureFlag'; 43 | 44 | // Testing utilities 45 | export { TestPluginStore } from './testing/TestPluginStore'; 46 | 47 | // Core types 48 | export { 49 | CodeRef, 50 | EncodedCodeRef, 51 | Extension, 52 | ExtensionFlags, 53 | ExtensionPredicate, 54 | EncodedExtension, 55 | LoadedExtension, 56 | ResolvedExtension, 57 | MapCodeRefsToEncodedCodeRefs, 58 | MapCodeRefsToValues, 59 | ExtractExtensionProperties, 60 | } from './types/extension'; 61 | export { ResourceFetch } from './types/fetch'; 62 | export { PluginLoadResult, PluginLoaderInterface } from './types/loader'; 63 | export { 64 | PluginRegistrationMethod, 65 | PluginRuntimeMetadata, 66 | PluginManifest, 67 | PendingPlugin, 68 | LoadedPlugin, 69 | FailedPlugin, 70 | } from './types/plugin'; 71 | export { PluginEntryModule } from './types/runtime'; 72 | export { 73 | PluginEventType, 74 | PluginInfoEntry, 75 | PendingPluginInfoEntry, 76 | LoadedPluginInfoEntry, 77 | FailedPluginInfoEntry, 78 | FeatureFlags, 79 | PluginStoreInterface, 80 | } from './types/store'; 81 | -------------------------------------------------------------------------------- /packages/lib-core/src/runtime/PluginStoreContext.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { PluginStoreInterface } from '../types/store'; 3 | 4 | const PluginStoreContext = React.createContext(undefined); 5 | 6 | /** 7 | * React Context provider for passing the {@link PluginStore} down the component tree. 8 | */ 9 | export const PluginStoreProvider: React.FC = ({ store, children }) => ( 10 | {children} 11 | ); 12 | 13 | export type PluginStoreProviderProps = React.PropsWithChildren<{ 14 | store: PluginStoreInterface; 15 | }>; 16 | 17 | /** 18 | * React hook that provides access to the {@link PluginStore} functionality. 19 | */ 20 | export const usePluginStore = () => { 21 | const store = React.useContext(PluginStoreContext); 22 | 23 | if (store === undefined) { 24 | throw new Error('usePluginStore hook called outside a PluginStoreProvider'); 25 | } 26 | 27 | return store; 28 | }; 29 | -------------------------------------------------------------------------------- /packages/lib-core/src/runtime/useExtensions.ts: -------------------------------------------------------------------------------- 1 | import { isEqualWith } from 'lodash'; 2 | import * as React from 'react'; 3 | import type { Extension, LoadedExtension, ExtensionPredicate } from '../types/extension'; 4 | import type { PluginStoreInterface } from '../types/store'; 5 | import { PluginEventType } from '../types/store'; 6 | import { usePluginSubscription } from './usePluginSubscription'; 7 | 8 | const eventTypes = [PluginEventType.ExtensionsChanged]; 9 | 10 | const getData = (pluginStore: PluginStoreInterface) => pluginStore.getExtensions(); 11 | 12 | const isSameData = (prevData: LoadedExtension[], nextData: LoadedExtension[]) => 13 | isEqualWith(prevData, nextData, (a, b) => a === b); 14 | 15 | /** 16 | * React hook for consuming extensions which are currently in use. 17 | * 18 | * The optional `predicate` parameter may be used to filter resulting extensions. 19 | * 20 | * This hook re-renders the component whenever the list of matching extensions changes. 21 | * 22 | * The hook's result is guaranteed to be referentially stable across re-renders. 23 | */ 24 | export const useExtensions = ( 25 | predicate?: ExtensionPredicate, 26 | ): LoadedExtension[] => { 27 | const extensions = usePluginSubscription(eventTypes, getData, isSameData); 28 | 29 | return React.useMemo( 30 | () => 31 | extensions.reduce[]>( 32 | (acc, e) => ((predicate ?? (() => true))(e) ? [...acc, e] : acc), 33 | [], 34 | ), 35 | [extensions, predicate], 36 | ); 37 | }; 38 | -------------------------------------------------------------------------------- /packages/lib-core/src/runtime/useFeatureFlag.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { PluginStoreInterface } from '../types/store'; 3 | import { PluginEventType } from '../types/store'; 4 | import { usePluginStore } from './PluginStoreContext'; 5 | import { usePluginSubscription } from './usePluginSubscription'; 6 | 7 | const eventTypes = [PluginEventType.FeatureFlagsChanged]; 8 | 9 | const isSameData = (prevData: boolean, nextData: boolean) => prevData === nextData; 10 | 11 | export type UseFeatureFlagResult = [currentValue: boolean, setValue: (newValue: boolean) => void]; 12 | 13 | /** 14 | * React hook that provides access to a feature flag. 15 | * 16 | * This hook re-renders the component whenever the value of the given flag is updated. 17 | * 18 | * The hook's result is guaranteed to be referentially stable across re-renders. 19 | * 20 | * @example 21 | * ```ts 22 | * const [flag, setFlag] = useFeatureFlag('FOO'); 23 | * setFlag(true); 24 | * ``` 25 | */ 26 | export const useFeatureFlag = (name: string): UseFeatureFlagResult => { 27 | const getData = React.useCallback( 28 | (pluginStore: PluginStoreInterface) => pluginStore.getFeatureFlags()[name], 29 | [name], 30 | ); 31 | const currentValue = usePluginSubscription(eventTypes, getData, isSameData); 32 | const pluginStore = usePluginStore(); 33 | 34 | const setValue = React.useCallback( 35 | (value: boolean) => { 36 | pluginStore.setFeatureFlags({ [name]: value }); 37 | }, 38 | [pluginStore, name], 39 | ); 40 | 41 | return [currentValue, setValue]; 42 | }; 43 | -------------------------------------------------------------------------------- /packages/lib-core/src/runtime/usePluginInfo.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from 'lodash'; 2 | import type { PluginInfoEntry, PluginStoreInterface } from '../types/store'; 3 | import { PluginEventType } from '../types/store'; 4 | import { usePluginSubscription } from './usePluginSubscription'; 5 | 6 | const eventTypes = [PluginEventType.PluginInfoChanged]; 7 | 8 | const getData = (pluginStore: PluginStoreInterface) => pluginStore.getPluginInfo(); 9 | 10 | /** 11 | * React hook for consuming current information about plugins. 12 | * 13 | * This hook re-renders the component whenever the plugin information changes. 14 | * 15 | * The hook's result is guaranteed to be referentially stable across re-renders. 16 | */ 17 | export const usePluginInfo = (): PluginInfoEntry[] => { 18 | return usePluginSubscription(eventTypes, getData, isEqual); 19 | }; 20 | -------------------------------------------------------------------------------- /packages/lib-core/src/runtime/usePluginSubscription.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { PluginEventType, PluginStoreInterface } from '../types/store'; 3 | import { usePluginStore } from './PluginStoreContext'; 4 | 5 | const isSameReference = (a: unknown, b: unknown) => a === b; 6 | 7 | /** 8 | * React hook for subscribing to `PluginStore` events. 9 | * 10 | * This hook implements the common `PluginStore` usage pattern, returning the current 11 | * data via the `getData` function. 12 | * 13 | * @example 14 | * ``` 15 | * pluginStore.subscribe(eventTypes, () => { 16 | * // get current data from plugin store 17 | * // compare current data with previous data 18 | * // re-render the component on data change 19 | * }); 20 | * ``` 21 | * 22 | * Return value changes only if both of the following conditions are true: 23 | * - `PluginStore` has emitted events of interest 24 | * - current data has changed, compared to previously referenced data 25 | * 26 | * The referential stability of the hook's result depends on a stable implementation 27 | * of `isSameData` function. If not specified, the hook performs a reference equality 28 | * comparison. 29 | */ 30 | export const usePluginSubscription = ( 31 | eventTypes: PluginEventType[], 32 | getData: (pluginStore: PluginStoreInterface) => TPluginData, 33 | isSameData: (prevData: TPluginData, nextData: TPluginData) => boolean = isSameReference, 34 | ): TPluginData => { 35 | const pluginStore = usePluginStore(); 36 | 37 | const getDataRef = React.useRef(getData); 38 | getDataRef.current = getData; 39 | 40 | const isSameDataRef = React.useRef(isSameData); 41 | isSameDataRef.current = isSameData; 42 | 43 | const [hookResult, setHookResult] = React.useState(() => getData(pluginStore)); 44 | 45 | const updateResult = React.useCallback(() => { 46 | const nextData = getDataRef.current(pluginStore); 47 | 48 | setHookResult((prevData) => (isSameDataRef.current(prevData, nextData) ? prevData : nextData)); 49 | }, [pluginStore]); 50 | 51 | React.useEffect(() => updateResult(), [getData, isSameData, updateResult]); 52 | 53 | React.useEffect( 54 | () => pluginStore.subscribe(eventTypes, updateResult), 55 | [pluginStore, eventTypes, updateResult], 56 | ); 57 | 58 | return hookResult; 59 | }; 60 | -------------------------------------------------------------------------------- /packages/lib-core/src/shared-webpack.ts: -------------------------------------------------------------------------------- 1 | // Code shared internally with lib-webpack package, do not re-export via lib-core index 2 | export * from './constants'; 3 | export * from './types/extension'; 4 | export * from './types/plugin'; 5 | export * from './yup-schemas'; 6 | -------------------------------------------------------------------------------- /packages/lib-core/src/testing/TestPluginStore.ts: -------------------------------------------------------------------------------- 1 | import { PluginStore } from '../runtime/PluginStore'; 2 | import type { PluginManifest } from '../types/plugin'; 3 | import type { PluginEntryModule } from '../types/runtime'; 4 | 5 | /** 6 | * `PluginStore` implementation intended for testing purposes. 7 | */ 8 | export class TestPluginStore extends PluginStore { 9 | override addPendingPlugin(manifest: PluginManifest) { 10 | super.addPendingPlugin(manifest); 11 | } 12 | 13 | override addLoadedPlugin(manifest: PluginManifest, entryModule: PluginEntryModule) { 14 | super.addLoadedPlugin(manifest, entryModule); 15 | } 16 | 17 | override addFailedPlugin(manifest: PluginManifest, errorMessage: string, errorCause?: unknown) { 18 | super.addFailedPlugin(manifest, errorMessage, errorCause); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/lib-core/src/types/extension.ts: -------------------------------------------------------------------------------- 1 | import type { AnyObject, ReplaceProperties } from '@monorepo/common'; 2 | 3 | export type ExtensionFlags = Partial<{ 4 | required: string[]; 5 | disallowed: string[]; 6 | }>; 7 | 8 | export type Extension = { 9 | type: TType; 10 | properties: TProperties; 11 | flags?: ExtensionFlags; 12 | [customProperty: string]: unknown; 13 | }; 14 | 15 | export type LoadedExtension = TExtension & { 16 | pluginName: string; 17 | uid: string; 18 | }; 19 | 20 | export type ExtensionPredicate = (e: Extension) => e is TExtension; 21 | 22 | export type EncodedCodeRef = { $codeRef: string }; 23 | 24 | export type CodeRef = () => Promise; 25 | 26 | // TODO(vojtech): apply the recursive part only on object properties or array elements 27 | export type MapCodeRefsToValues = { 28 | [K in keyof T]: T[K] extends CodeRef ? TValue : MapCodeRefsToValues; 29 | }; 30 | 31 | // TODO(vojtech): apply the recursive part only on object properties or array elements 32 | export type MapCodeRefsToEncodedCodeRefs = { 33 | [K in keyof T]: T[K] extends CodeRef ? EncodedCodeRef : MapCodeRefsToEncodedCodeRefs; 34 | }; 35 | 36 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 37 | export type ExtractExtensionProperties = T extends Extension 38 | ? TProperties 39 | : never; 40 | 41 | export type ResolvedExtension = ReplaceProperties< 42 | TExtension, 43 | { 44 | properties: ReplaceProperties< 45 | ExtractExtensionProperties, 46 | MapCodeRefsToValues> 47 | >; 48 | } 49 | >; 50 | 51 | export type EncodedExtension = ReplaceProperties< 52 | TExtension, 53 | { 54 | properties: ReplaceProperties< 55 | ExtractExtensionProperties, 56 | MapCodeRefsToEncodedCodeRefs> 57 | >; 58 | } 59 | >; 60 | -------------------------------------------------------------------------------- /packages/lib-core/src/types/fetch.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Fetch a resource over HTTP and return the {@link Response} object. 3 | */ 4 | export type ResourceFetch = ( 5 | url: string, 6 | requestInit?: RequestInit, 7 | isK8sAPIRequest?: boolean, 8 | ) => Promise; 9 | -------------------------------------------------------------------------------- /packages/lib-core/src/types/loader.ts: -------------------------------------------------------------------------------- 1 | import type { PluginManifest } from './plugin'; 2 | import type { PluginEntryModule } from './runtime'; 3 | 4 | export type PluginLoadResult = 5 | | { 6 | success: true; 7 | entryModule: PluginEntryModule; 8 | } 9 | | { 10 | success: false; 11 | errorMessage: string; 12 | errorCause?: unknown; 13 | }; 14 | 15 | export type PluginLoaderInterface = { 16 | /** 17 | * Load plugin manifest from the given URL. 18 | * 19 | * This should include validating the manifest object as necessary. 20 | */ 21 | loadPluginManifest: (manifestURL: string) => Promise; 22 | 23 | /** 24 | * Transform the plugin manifest before loading the associated plugin. 25 | */ 26 | transformPluginManifest: (manifest: PluginManifest) => PluginManifest; 27 | 28 | /** 29 | * Load plugin from the given manifest. 30 | * 31 | * The resulting Promise never rejects; any plugin load error(s) will be contained 32 | * within the {@link PluginLoadResult} object. 33 | */ 34 | loadPlugin: (manifest: PluginManifest) => Promise; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/lib-core/src/types/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { AnyObject } from '@monorepo/common'; 2 | import type { Extension, LoadedExtension } from './extension'; 3 | import type { PluginEntryModule } from './runtime'; 4 | 5 | export type PluginRegistrationMethod = 'callback' | 'custom'; 6 | 7 | export type PluginRuntimeMetadata = { 8 | name: string; 9 | version: string; 10 | dependencies?: Record; 11 | customProperties?: AnyObject; 12 | }; 13 | 14 | export type PluginManifest = PluginRuntimeMetadata & { 15 | baseURL: string; 16 | extensions: Extension[]; 17 | loadScripts: string[]; 18 | registrationMethod: PluginRegistrationMethod; 19 | buildHash?: string; 20 | }; 21 | 22 | export type PendingPlugin = { 23 | manifest: Readonly; 24 | }; 25 | 26 | export type LoadedPlugin = { 27 | manifest: Readonly; 28 | loadedExtensions: Readonly; 29 | entryModule: PluginEntryModule; 30 | enabled: boolean; 31 | disableReason?: string; 32 | }; 33 | 34 | export type FailedPlugin = { 35 | manifest: Readonly; 36 | errorMessage: string; 37 | errorCause?: unknown; 38 | }; 39 | -------------------------------------------------------------------------------- /packages/lib-core/src/types/runtime.ts: -------------------------------------------------------------------------------- 1 | import type { AnyObject } from '@monorepo/common'; 2 | 3 | /** 4 | * Remote webpack container interface. 5 | * 6 | * @see https://webpack.js.org/concepts/module-federation/#dynamic-remote-containers 7 | */ 8 | export type PluginEntryModule = { 9 | /** Initialize the container with shared modules. */ 10 | init: (sharedScope: AnyObject) => void | Promise; 11 | /** Get a module exposed through the container. */ 12 | get: (moduleRequest: string) => Promise<() => TModule>; 13 | }; 14 | 15 | /** 16 | * Called by plugin entry scripts to register the given plugin with host application. 17 | */ 18 | export type PluginEntryCallback = (pluginName: string, entryModule: PluginEntryModule) => void; 19 | -------------------------------------------------------------------------------- /packages/lib-core/src/utils/basic-fetch.ts: -------------------------------------------------------------------------------- 1 | import { CustomError, applyDefaults } from '@monorepo/common'; 2 | import type { ResourceFetch } from '../types/fetch'; 3 | 4 | class FetchError extends CustomError { 5 | constructor(message: string, readonly status: number, readonly response: Response) { 6 | super(message); 7 | } 8 | } 9 | 10 | /** 11 | * Basic implementation of {@link ResourceFetch} that uses the {@link fetch} API. 12 | */ 13 | export const basicFetch: ResourceFetch = async (url, requestInit = {}) => { 14 | const response = await fetch(url, applyDefaults(requestInit, { method: 'GET' })); 15 | 16 | if (!response.ok) { 17 | throw new FetchError(response.statusText, response.status, response); 18 | } 19 | 20 | return response; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/lib-core/src/utils/promise.ts: -------------------------------------------------------------------------------- 1 | const isPromiseFulfilledResult = (r: PromiseSettledResult): r is PromiseFulfilledResult => 2 | r.status === 'fulfilled'; 3 | 4 | const isPromiseRejectedResult = (r: PromiseSettledResult): r is PromiseRejectedResult => 5 | r.status === 'rejected'; 6 | 7 | /** 8 | * Unwrap the results of `Promise.allSettled()` call for easier processing. 9 | */ 10 | const unwrapPromiseSettledResults = ( 11 | results: PromiseSettledResult[], 12 | ): [fulfilledValues: T[], rejectedReasons: unknown[]] => [ 13 | results.filter(isPromiseFulfilledResult).map((r) => r.value), 14 | results.filter(isPromiseRejectedResult).map((r) => r.reason), 15 | ]; 16 | 17 | /** 18 | * Await `Promise.allSettled(promises)` and unwrap the results. 19 | * 20 | * Note that the Promise returned by `Promise.allSettled()` never rejects. 21 | */ 22 | export const settleAllPromises = async (promises: Promise[]) => { 23 | const results = await Promise.allSettled(promises); 24 | return unwrapPromiseSettledResults(results); 25 | }; 26 | -------------------------------------------------------------------------------- /packages/lib-core/src/utils/scripts.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Load a script from the given URL by injecting new HTML `script` element into the document. 3 | */ 4 | export const injectScriptElement = (url: string, id: string, getDocument = () => document) => 5 | new Promise((resolve, reject) => { 6 | const script = getDocument().createElement('script'); 7 | 8 | script.async = true; 9 | script.src = url; 10 | script.id = id; 11 | 12 | script.onload = () => { 13 | resolve(); 14 | }; 15 | 16 | script.onerror = (event) => { 17 | reject(event); 18 | }; 19 | 20 | getDocument().head.appendChild(script); 21 | }); 22 | 23 | /** 24 | * Get the corresponding HTML `script` element or `null` if not present in the document. 25 | */ 26 | export const getScriptElement = (id: string, getDocument = () => document) => 27 | Array.from(getDocument().scripts).find((script) => script.id === id) ?? null; 28 | -------------------------------------------------------------------------------- /packages/lib-core/src/utils/url.ts: -------------------------------------------------------------------------------- 1 | import { identity } from 'lodash'; 2 | 3 | /** 4 | * Return `true` if the given URL is absolute. 5 | * 6 | * @see https://stackoverflow.com/a/38979205 7 | */ 8 | export const isAbsoluteURL = (url: string) => url.indexOf('://') > 0 || url.indexOf('//') === 0; 9 | 10 | /** 11 | * Resolve URL to the given resource using a base URL. 12 | * 13 | * If `base` is not an absolute URL, it's considered to be relative to the document origin. 14 | * 15 | * If `to` is an absolute URL, `base` is ignored (as per standard {@link URL} constructor semantics). 16 | */ 17 | export const resolveURL = ( 18 | base: string, 19 | to: string, 20 | processURL: (url: URL) => URL = identity, 21 | getDocumentOrigin = () => window.location.origin, 22 | ) => 23 | processURL( 24 | new URL(to, isAbsoluteURL(base) ? base : new URL(base, getDocumentOrigin())), 25 | ).toString(); 26 | -------------------------------------------------------------------------------- /packages/lib-core/src/yup-schemas.ts: -------------------------------------------------------------------------------- 1 | // TODO(vojtech): suppress false positive https://github.com/jsx-eslint/eslint-plugin-react/pull/3326 2 | /* eslint-disable react/forbid-prop-types */ 3 | import * as yup from 'yup'; 4 | import type { PluginRegistrationMethod } from './types/plugin'; 5 | 6 | /** 7 | * Schema for a valid semver string. 8 | * 9 | * @see https://semver.org/#is-there-a-suggested-regular-expression-regex-to-check-a-semver-string 10 | */ 11 | const semverStringSchema = yup 12 | .string() 13 | .required() 14 | .matches( 15 | /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/, 16 | ); 17 | 18 | /** 19 | * Schema for a valid plugin name. 20 | * 21 | * @example 22 | * ``` 23 | * foo 24 | * foo-bar 25 | * foo.bar 26 | * foo.bar-Test 27 | * Foo-Bar-abc.123 28 | * ``` 29 | */ 30 | const pluginNameSchema = yup 31 | .string() 32 | .required() 33 | .matches(/^[a-zA-Z]+(?:[-.]?[a-zA-Z0-9]+)*$/); 34 | 35 | /** 36 | * Schema for a valid extension type. 37 | * 38 | * @example 39 | * ``` 40 | * app.foo 41 | * app.foo-bar 42 | * app.foo/bar 43 | * My-app.Foo-Bar 44 | * My-app.Foo-Bar/abcTest 45 | * ``` 46 | */ 47 | const extensionTypeSchema = yup 48 | .string() 49 | .required() 50 | .matches(/^[a-zA-Z]+(?:-[a-zA-Z]+)*\.[a-zA-Z]+(?:-[a-zA-Z]+)*(?:\/[a-zA-Z]+(?:-[a-zA-Z]+)*)*$/); 51 | 52 | /** 53 | * Schema for a valid feature flag name. 54 | * 55 | * @example 56 | * ``` 57 | * FOO 58 | * FOO_BAR 59 | * FOO_BAR123 60 | * ``` 61 | */ 62 | const featureFlagNameSchema = yup 63 | .string() 64 | .required() 65 | .matches(/^[A-Z]+[A-Z0-9_]*$/); 66 | 67 | /** 68 | * Schema for `Extension` objects. 69 | */ 70 | export const extensionSchema = yup 71 | .object() 72 | .required() 73 | .shape({ 74 | type: extensionTypeSchema, 75 | properties: yup.object().required(), 76 | flags: yup.object().shape({ 77 | required: yup.array().of(featureFlagNameSchema), 78 | disallowed: yup.array().of(featureFlagNameSchema), 79 | }), 80 | }); 81 | 82 | /** 83 | * Schema for an array of `Extension` objects. 84 | */ 85 | export const extensionArraySchema = yup.array().of(extensionSchema).required(); 86 | 87 | /** 88 | * Schema for `PluginRegistrationMethod` objects. 89 | */ 90 | export const pluginRegistrationMethodSchema = yup 91 | .mixed() 92 | .oneOf(['callback', 'custom']) 93 | .required(); 94 | 95 | /** 96 | * Schema for `PluginRuntimeMetadata` objects. 97 | */ 98 | export const pluginRuntimeMetadataSchema = yup.object().required().shape({ 99 | name: pluginNameSchema, 100 | version: semverStringSchema, 101 | // TODO(vojtech): Yup lacks native support for map-like structures with arbitrary keys 102 | // TODO(vojtech): we need to validate dependency values as semver ranges 103 | dependencies: yup.object(), 104 | customProperties: yup.object(), 105 | }); 106 | 107 | /** 108 | * Schema for `PluginManifest` objects. 109 | */ 110 | export const pluginManifestSchema = pluginRuntimeMetadataSchema.shape({ 111 | baseURL: yup.string().required(), 112 | extensions: extensionArraySchema, 113 | loadScripts: yup.array().of(yup.string().required()).required(), 114 | registrationMethod: pluginRegistrationMethodSchema, 115 | buildHash: yup.string(), 116 | }); 117 | -------------------------------------------------------------------------------- /packages/lib-core/tsconfig.api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "paths": { 5 | "@monorepo/common": ["dist/types/common/src/index.d.ts"] 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /packages/lib-core/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@monorepo/common/tsconfig-bases/lib-react-esm.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declarationDir": "types" 6 | }, 7 | "include": ["src", "../common/src"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/lib-extensions/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@monorepo/eslint-plugin-internal/react-typescript-prettier'], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/lib-extensions/README.md: -------------------------------------------------------------------------------- 1 | # `@openshift/dynamic-plugin-sdk-extensions` 2 | 3 | > Provides extension types for dynamic plugins. 4 | -------------------------------------------------------------------------------- /packages/lib-extensions/api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../api-extractor.json", 3 | "mainEntryPointFilePath": "dist/types/index.d.ts", 4 | "apiReport": { "reportFileName": "lib-extensions.api.md" }, 5 | "docModel": { "apiJsonFilePath": "dist/api/lib-extensions.api.json" } 6 | } 7 | -------------------------------------------------------------------------------- /packages/lib-extensions/jest.config.ts: -------------------------------------------------------------------------------- 1 | import reactConfig from '@monorepo/common/jest/jest-config-react'; 2 | import type { InitialOptionsTsJest } from 'ts-jest'; 3 | 4 | const config: InitialOptionsTsJest = { 5 | ...reactConfig, 6 | displayName: 'lib-extensions', 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /packages/lib-extensions/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openshift/dynamic-plugin-sdk-extensions", 3 | "version": "1.4.0", 4 | "description": "Provides extension types for dynamic plugins", 5 | "license": "Apache-2.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/openshift/dynamic-plugin-sdk.git", 9 | "directory": "packages/lib-extensions" 10 | }, 11 | "files": [ 12 | "dist/index.cjs.js", 13 | "dist/index.esm.js", 14 | "dist/index.d.ts", 15 | "dist/build-metadata.json" 16 | ], 17 | "main": "dist/index.cjs.js", 18 | "module": "dist/index.esm.js", 19 | "types": "dist/index.d.ts", 20 | "scripts": { 21 | "prepack": "yarn build", 22 | "prepublishOnly": "yarn test", 23 | "build": "rm -rf dist && yarn run -T rollup -c && yarn api-extractor", 24 | "lint": "yarn run -T eslint $INIT_CWD", 25 | "test": "yarn run -T test $INIT_CWD", 26 | "api-extractor": "yarn run -T api-extractor -c $INIT_CWD/api-extractor.json" 27 | }, 28 | "peerDependencies": { 29 | "@openshift/dynamic-plugin-sdk": "^4 || ^5", 30 | "react": "^17 || ^18", 31 | "react-redux": "^7 || ^8", 32 | "react-router": "^5.2.1", 33 | "redux": "^4.1.2" 34 | }, 35 | "peerDependenciesMeta": { 36 | "react-redux": { 37 | "optional": true 38 | }, 39 | "react-router": { 40 | "optional": true 41 | }, 42 | "redux": { 43 | "optional": true 44 | } 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/lib-extensions/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { tsBuildConfig } from '../common/rollup/rollup-configs'; 2 | import pkg from './package.json'; 3 | 4 | export default tsBuildConfig({ pkg, format: 'cjs-and-esm' }); 5 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/context-providers.ts: -------------------------------------------------------------------------------- 1 | import type { CodeRef, Extension } from '@openshift/dynamic-plugin-sdk'; 2 | import type { Provider } from 'react'; 3 | 4 | /** Adds new React context provider to Console application root. */ 5 | export type ContextProvider = Extension< 6 | 'core.context-provider', 7 | { 8 | /** Context Provider component. */ 9 | provider: CodeRef>; 10 | /** Hook for the Context value. */ 11 | useValueHook: CodeRef<() => TValue>; 12 | } 13 | >; 14 | 15 | // Type guards 16 | 17 | export const isContextProvider = (e: Extension): e is ContextProvider => 18 | e.type === 'core.context-provider'; 19 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/feature-flags.ts: -------------------------------------------------------------------------------- 1 | import type { CodeRef, Extension } from '@openshift/dynamic-plugin-sdk'; 2 | import type { ExtensionK8sResourceIdentifier } from '../../types/common'; 3 | 4 | /** Gives full control over host application's feature flags. */ 5 | export type FeatureFlag = Extension< 6 | 'core.flag', 7 | { 8 | /** Used to set/unset arbitrary feature flags. */ 9 | handler: CodeRef<(callback: SetFeatureFlag) => void>; 10 | } 11 | >; 12 | 13 | /** Adds new feature flag to host application driven by the presence of a CRD on the cluster. */ 14 | export type ModelFeatureFlag = Extension< 15 | 'core.flag/model', 16 | { 17 | /** The name of the flag to set once the CRD is detected. */ 18 | flag: string; 19 | /** The model which refers to a `CustomResourceDefinition`. */ 20 | model: ExtensionK8sResourceIdentifier & { 21 | group: string; 22 | version: string; 23 | kind: string; 24 | }; 25 | } 26 | >; 27 | 28 | // Type guards 29 | 30 | export const isFeatureFlag = (e: Extension): e is FeatureFlag => e.type === 'core.flag'; 31 | export const isModelFeatureFlag = (e: Extension): e is ModelFeatureFlag => 32 | e.type === 'core.flag/model'; 33 | 34 | // Arbitrary types 35 | 36 | export type SetFeatureFlag = (flag: string, enabled: boolean) => void; 37 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/index.ts: -------------------------------------------------------------------------------- 1 | export * from './actions'; 2 | export * from './catalog'; 3 | export * from './context-providers'; 4 | export * from './feature-flags'; 5 | export * from './model-metadata'; 6 | export * from './navigations'; 7 | export * from './pages'; 8 | export * from './redux'; 9 | export * from './resources'; 10 | export * from './telemetry'; 11 | export * from './yaml-templates'; 12 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/model-metadata.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@openshift/dynamic-plugin-sdk'; 2 | import type { ExtensionK8sResourceIdentifier } from '../../types/common'; 3 | 4 | /** Customize the display of models by overriding values retrieved and generated through API discovery. */ 5 | export type ModelMetadata = Extension<'core.model-metadata', Metatdata>; 6 | 7 | // Type guards 8 | 9 | export const isModelMetadata = (e: Extension): e is ModelMetadata => 10 | e.type === 'core.model-metadata'; 11 | 12 | // Arbitrary types 13 | 14 | export type Metatdata = { 15 | /** The model to customize. May specify only a group, or optional version and kind. */ 16 | model: ExtensionK8sResourceIdentifier & { 17 | group: string; 18 | }; 19 | /** Whether to consider this model reference as tech preview or dev preview. */ 20 | badge?: 'tech' | 'dev'; 21 | /** The color to associate to this model. */ 22 | color?: string; 23 | /** Override the label. Requires `kind` be provided. */ 24 | label?: string; 25 | /** Override the plural label. Requires `kind` be provided. */ 26 | labelPlural?: string; 27 | /** Customize the abbreviation. Defaults to All uppercase chars in the kind up to 4 characters long. Requires `kind` be provided. */ 28 | abbr?: string; 29 | }; 30 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/navigations.ts: -------------------------------------------------------------------------------- 1 | import type { Extension } from '@openshift/dynamic-plugin-sdk'; 2 | import type { ExtensionK8sResourceIdentifier } from '../../types/common'; 3 | 4 | export type NavItem = Extension< 5 | 'core.navigation/href', 6 | NavItemProperties & { 7 | name: string; 8 | } 9 | >; 10 | 11 | export type HrefNavItem = Extension< 12 | 'core.navigation/href', 13 | NavItemProperties & { 14 | /** The name of this item. */ 15 | name: string; 16 | /** The link href value. */ 17 | href: string; 18 | /** if true, adds /ns/active-namespace to the end */ 19 | namespaced?: boolean; 20 | /** if true, adds /k8s/ns/active-namespace to the begining */ 21 | prefixNamespaced?: boolean; 22 | /** if true, app should open new tab with the href */ 23 | isExternal?: boolean; 24 | } 25 | >; 26 | 27 | export type ResourceNSNavItem = Extension< 28 | 'core.navigation/resource-ns', 29 | NavItemProperties & { 30 | /** The model for which this nav item links to. */ 31 | model: ExtensionK8sResourceIdentifier & { 32 | group: string; 33 | version: string; 34 | kind: string; 35 | }; 36 | } 37 | >; 38 | 39 | export type Separator = Extension< 40 | 'core.navigation/separator', 41 | Omit 42 | >; 43 | 44 | export type NavSection = Extension< 45 | 'core.navigation/section', 46 | Omit & { 47 | /** Name of this section. If not supplied, only a separator will be shown above the section. */ 48 | name?: string; 49 | } 50 | >; 51 | 52 | export type NavGroup = Extension< 53 | 'core.navigation/group', 54 | NavItemProperties & { 55 | name: string; 56 | /** Patternfly icon name in dashed type. */ 57 | icon?: string; 58 | } 59 | >; 60 | 61 | // Type guards 62 | 63 | export const isHrefNavItem = (e: Extension): e is HrefNavItem => e.type === 'core.navigation/href'; 64 | export const isResourceNSNavItem = (e: Extension): e is ResourceNSNavItem => 65 | e.type === 'core.navigation/resource-ns'; 66 | export const isSeparator = (e: Extension): e is Separator => e.type === 'core.navigation/separator'; 67 | export const isNavSection = (e: Extension): e is NavSection => e.type === 'core.navigation/section'; 68 | export const isNavGroup = (e: Extension): e is NavGroup => e.type === 'core.navigation/group'; 69 | 70 | // Arbitrary types 71 | 72 | export type NavItemProperties = { 73 | /** A unique identifier for this item. */ 74 | id: string; 75 | /** The name of this item. If not supplied the name of the link will equal the plural value of the model. */ 76 | name?: string; 77 | /** The perspective ID to which this item belongs to. If not specified, contributes to the default perspective. */ 78 | perspective?: string; 79 | /** Navigation section to which this item belongs to. If not specified, render this item as a top level link. */ 80 | section?: string; 81 | /** Navigation group to which this item belongs to. If not specified, render this item as a top level link. */ 82 | groupId?: string; 83 | /** Adds data attributes to the DOM. */ 84 | dataAttributes?: { [key: string]: string }; 85 | /** Mark this item as active when the URL starts with one of these paths. */ 86 | startsWith?: string[]; 87 | /** Insert this item before the item referenced here. For arrays, the first one found in order is used. */ 88 | insertBefore?: string | string[]; 89 | /** Insert this item after the item referenced here. For arrays, the first one found in order is used. `insertBefore` takes precedence. */ 90 | insertAfter?: string | string[]; 91 | }; 92 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/pages.ts: -------------------------------------------------------------------------------- 1 | import type { CodeRef, Extension } from '@openshift/dynamic-plugin-sdk'; 2 | import type { ComponentType } from 'react'; 3 | import type { RouteComponentProps } from 'react-router'; 4 | import type { ExtensionK8sResourceIdentifier } from '../../types/common'; 5 | 6 | /** Adds new page to host application's React router. */ 7 | export type RoutePage = Extension<'core.page/route', RoutePageProperties>; 8 | 9 | /** Adds new resource list page to host application's React router. */ 10 | export type ResourceListPage = Extension<'core.page/resource/list', ResourcePageProperties>; 11 | 12 | /** Adds new resource details page to host application's React router. */ 13 | export type ResourceDetailsPage = Extension<'core.page/resource/details', ResourcePageProperties>; 14 | 15 | // Type guards 16 | 17 | export const isRoutePage = (e: Extension): e is RoutePage => e.type === 'core.page/route'; 18 | export const isResourceListPage = (e: Extension): e is ResourceListPage => 19 | e.type === 'core.page/resource/list'; 20 | export const isResourceDetailsPage = (e: Extension): e is ResourceDetailsPage => 21 | e.type === 'core/Resource/details'; 22 | 23 | // Arbitrary types 24 | 25 | export type RoutePageProperties = { 26 | /** The perspective to which this page belongs to. If not specified, contributes to all perspectives. */ 27 | perspective?: string; 28 | /** The component to be rendered when the route matches. */ 29 | component: CodeRef>; 30 | /** Valid URL path or array of paths that `path-to-regexp@^1.7.0` understands. */ 31 | path: string | string[]; 32 | /** When true, will only match if the path matches the `location.pathname` exactly. */ 33 | exact?: boolean; 34 | }; 35 | 36 | export type ResourcePageProperties = { 37 | /** The model for which this resource page links to. */ 38 | model: ExtensionK8sResourceIdentifier & { 39 | group: string; 40 | kind: string; 41 | }; 42 | /** The component to be rendered when the route matches. */ 43 | component: CodeRef< 44 | ComponentType<{ 45 | match: RouteComponentProps['match']; 46 | /** The namespace for which this resource page links to. */ 47 | namespace: string; 48 | /** The model for which this resource page links to. */ 49 | model: ExtensionK8sResourceIdentifier & { 50 | group: string; 51 | version: string; 52 | kind: string; 53 | }; 54 | }> 55 | >; 56 | }; 57 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/redux.ts: -------------------------------------------------------------------------------- 1 | import type { CodeRef, Extension } from '@openshift/dynamic-plugin-sdk'; 2 | import type { ReactReduxContextValue } from 'react-redux'; 3 | import type { Reducer, Store } from 'redux'; 4 | 5 | /** 6 | * Adds new reducer to host application's Redux store which operates on `plugins.` substate. 7 | * 8 | * @deprecated use the `core.redux-provider` extension instead 9 | */ 10 | export type ReduxReducer = Extension< 11 | 'core.redux-reducer', 12 | { 13 | /** The key to represent the reducer-managed substate within the Redux state object. */ 14 | scope: string; 15 | /** The reducer function, operating on the reducer-managed substate. */ 16 | reducer: CodeRef; 17 | } 18 | >; 19 | 20 | /** Provides a configuration for establishing new Redux store instance scoped to the contributing plugin. */ 21 | export type ReduxProvider = Extension< 22 | 'core.redux-provider', 23 | { 24 | /** The configured Redux store object; configured with reducers, middleware, etc. */ 25 | store: CodeRef; 26 | /** The Redux React context object for which the instance will be scoped to. */ 27 | context: CodeRef>; 28 | } 29 | >; 30 | 31 | // Type guards 32 | 33 | /** 34 | * @deprecated use the `core.redux-provider` extension instead 35 | */ 36 | export const isReduxReducer = (e: Extension): e is ReduxReducer => e.type === 'core.redux-reducer'; 37 | 38 | export const isReduxProvider = (e: Extension): e is ReduxProvider => 39 | e.type === 'core.redux-reducer'; 40 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/resources.ts: -------------------------------------------------------------------------------- 1 | import type { CodeRef, Extension } from '@openshift/dynamic-plugin-sdk'; 2 | import type { ComponentType } from 'react'; 3 | import type { ExtensionK8sResourceIdentifier } from '../../types/common'; 4 | 5 | export type CreateResource = Extension< 6 | 'core.resource/create', 7 | { 8 | /** The model for which this create resource page will be rendered. */ 9 | model: ExtensionK8sResourceIdentifier & { 10 | group: string; 11 | version: string; 12 | kind: string; 13 | }; 14 | /** The component to be rendered when the model matches */ 15 | component: CodeRef>; 16 | } 17 | >; 18 | 19 | // Type guards 20 | 21 | export const isCreateResource = (e: Extension): e is CreateResource => 22 | e.type === 'core.resource/create'; 23 | 24 | // Arbitrary types 25 | 26 | /** Properties of custom CreateResource component. */ 27 | export type CreateResourceComponentProps = { namespace?: string }; 28 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/telemetry.ts: -------------------------------------------------------------------------------- 1 | import type { AnyObject, Extension, CodeRef } from '@openshift/dynamic-plugin-sdk'; 2 | 3 | export type TelemetryListener = Extension< 4 | 'core.telemetry/listener', 5 | { 6 | /** Listen for telemetry events */ 7 | listener: CodeRef; 8 | } 9 | >; 10 | 11 | // TProperties should be valid JSON 12 | export type TelemetryEventListener = ( 13 | eventType: string, 14 | properties?: TProperties, 15 | ) => void; 16 | 17 | // Type guards 18 | 19 | export const isTelemetryListener = (e: Extension): e is TelemetryListener => { 20 | return e.type === 'core.telemetry/listener'; 21 | }; 22 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/extensions/core/yaml-templates.ts: -------------------------------------------------------------------------------- 1 | import type { Extension, CodeRef } from '@openshift/dynamic-plugin-sdk'; 2 | import type { ExtensionK8sResourceIdentifier } from '../../types/common'; 3 | 4 | /** YAML templates for editing resources via the yaml editor. */ 5 | export type YAMLTemplate = Extension< 6 | 'core.yaml-template', 7 | { 8 | /** Model associated with the template. */ 9 | model: ExtensionK8sResourceIdentifier & { 10 | group: string; 11 | version: string; 12 | kind: string; 13 | }; 14 | /** The YAML template. */ 15 | template: CodeRef; 16 | /** The name of the template. Use the name `default` to mark this as the default template. */ 17 | name: string | 'default'; 18 | } 19 | >; 20 | 21 | // Type guards 22 | 23 | export const isYAMLTemplate = (e: Extension): e is YAMLTemplate => e.type === 'core.yaml-template'; 24 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Extension types that can be interpreted at runtime. 3 | * 4 | * @remarks 5 | * This package provides extension types and corresponding type guard functions 6 | * used to interpret the corresponding extension objects at runtime. 7 | * 8 | * @packageDocumentation 9 | */ 10 | 11 | export * from './extensions/core'; 12 | export * from './types/common'; 13 | -------------------------------------------------------------------------------- /packages/lib-extensions/src/types/common.ts: -------------------------------------------------------------------------------- 1 | import type { AnyObject } from '@openshift/dynamic-plugin-sdk'; 2 | 3 | // Type for extension hook 4 | export type ExtensionHook = ( 5 | options: TOptions, 6 | ) => [TResult, boolean, unknown]; 7 | 8 | // TODO(vojtech): use types like RequiredProperties and OptionalProperties 9 | // to change optionality of specific properties, instead of redefining them via "&" intersection 10 | export type ExtensionK8sResourceIdentifier = { 11 | group?: string; 12 | version?: string; 13 | kind?: string; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/lib-extensions/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@monorepo/common/tsconfig-bases/lib-react-esm.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declarationDir": "types" 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/lib-utils/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@monorepo/eslint-plugin-internal/react-typescript-prettier'], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/lib-utils/README.md: -------------------------------------------------------------------------------- 1 | # `@openshift/dynamic-plugin-sdk-utils` 2 | 3 | > Provides React focused dynamic plugin SDK utilities. 4 | -------------------------------------------------------------------------------- /packages/lib-utils/api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../api-extractor.json", 3 | "mainEntryPointFilePath": "dist/types/index.d.ts", 4 | "apiReport": { "reportFileName": "lib-utils.api.md" }, 5 | "docModel": { "apiJsonFilePath": "dist/api/lib-utils.api.json" } 6 | } 7 | -------------------------------------------------------------------------------- /packages/lib-utils/jest.config.ts: -------------------------------------------------------------------------------- 1 | import reactConfig from '@monorepo/common/jest/jest-config-react'; 2 | import type { InitialOptionsTsJest } from 'ts-jest'; 3 | 4 | const config: InitialOptionsTsJest = { 5 | ...reactConfig, 6 | displayName: 'lib-utils', 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /packages/lib-utils/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openshift/dynamic-plugin-sdk-utils", 3 | "version": "5.0.0", 4 | "description": "Provides React focused dynamic plugin SDK utilities", 5 | "license": "Apache-2.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/openshift/dynamic-plugin-sdk.git", 9 | "directory": "packages/lib-utils" 10 | }, 11 | "files": [ 12 | "dist/index.cjs.js", 13 | "dist/index.esm.js", 14 | "dist/index.d.ts", 15 | "dist/index.css", 16 | "dist/build-metadata.json" 17 | ], 18 | "main": "dist/index.cjs.js", 19 | "module": "dist/index.esm.js", 20 | "types": "dist/index.d.ts", 21 | "scripts": { 22 | "prepack": "yarn build", 23 | "prepublishOnly": "yarn test", 24 | "build": "rm -rf dist && yarn run -T rollup -c && yarn api-extractor", 25 | "lint": "yarn run -T eslint $INIT_CWD", 26 | "test": "yarn run -T test $INIT_CWD", 27 | "api-extractor": "yarn run -T api-extractor -c $INIT_CWD/api-extractor.json" 28 | }, 29 | "peerDependencies": { 30 | "@openshift/dynamic-plugin-sdk": "^4 || ^5", 31 | "@openshift/dynamic-plugin-sdk-extensions": "^1.4.0", 32 | "react": "^17 || ^18", 33 | "react-redux": "^7 || ^8", 34 | "redux": "^4.1.2", 35 | "redux-thunk": "^2.4.1" 36 | }, 37 | "dependencies": { 38 | "immutable": "^3.8.2", 39 | "lodash": "^4.17.21", 40 | "pluralize": "^8.0.0", 41 | "typesafe-actions": "^4.4.2", 42 | "uuid": "^8.3.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /packages/lib-utils/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { tsBuildConfig } from '../common/rollup/rollup-configs'; 2 | import pkg from './package.json'; 3 | 4 | export default tsBuildConfig({ pkg, format: 'cjs-and-esm' }); 5 | -------------------------------------------------------------------------------- /packages/lib-utils/src/app/AppInitSDK.tsx: -------------------------------------------------------------------------------- 1 | import { consoleLogger, PluginStoreProvider } from '@openshift/dynamic-plugin-sdk'; 2 | import type { PluginStore } from '@openshift/dynamic-plugin-sdk'; 3 | import * as React from 'react'; 4 | import { Provider } from 'react-redux'; 5 | import type { UtilsConfig } from '../config'; 6 | import { isUtilsConfigSet, setUtilsConfig } from '../config'; 7 | import type { InitAPIDiscovery } from '../types/api-discovery'; 8 | import { initAPIDiscovery } from './api-discovery'; 9 | import { useReduxStore } from './redux'; 10 | 11 | export type AppInitSDKProps = { 12 | configurations: { 13 | apiDiscovery?: InitAPIDiscovery; 14 | apiPriorityList?: string[]; 15 | appFetch: UtilsConfig['appFetch']; 16 | pluginStore: PluginStore; 17 | wsAppSettings: UtilsConfig['wsAppSettings']; 18 | }; 19 | }; 20 | 21 | /** 22 | * Initializes the host application to work with Kubernetes and related SDK utilities. 23 | * Add this at app-level to make use of app's redux store and pass configurations prop needed to initialize the app, preferred to have it under Provider. 24 | * It checks for store instance if present or not. 25 | * If the store is there then the reference is persisted to be used in SDK else it creates a new store and passes it to the children with the provider 26 | * 27 | * @example 28 | * ```tsx 29 | * return ( 30 | * 31 | * 32 | * 33 | * 34 | * 35 | * ) 36 | * ``` 37 | */ 38 | const AppInitSDK: React.FC = ({ children, configurations }) => { 39 | const { store, storeContextPresent } = useReduxStore(); 40 | 41 | const { 42 | appFetch, 43 | pluginStore, 44 | wsAppSettings, 45 | apiDiscovery = initAPIDiscovery, 46 | apiPriorityList, 47 | } = configurations; 48 | 49 | React.useEffect(() => { 50 | try { 51 | if (!isUtilsConfigSet()) { 52 | setUtilsConfig({ appFetch, wsAppSettings }); 53 | } 54 | apiDiscovery(store, apiPriorityList); 55 | } catch (e) { 56 | consoleLogger.warn('Error while initializing AppInitSDK', e); 57 | } 58 | }, [apiDiscovery, appFetch, store, wsAppSettings, apiPriorityList]); 59 | 60 | return ( 61 | 62 | {!storeContextPresent ? {children} : children} 63 | 64 | ); 65 | }; 66 | 67 | export default AppInitSDK; 68 | -------------------------------------------------------------------------------- /packages/lib-utils/src/app/api-discovery/discovery-cache.ts: -------------------------------------------------------------------------------- 1 | import { consoleLogger } from '@openshift/dynamic-plugin-sdk'; 2 | import { keyBy, merge, mergeWith } from 'lodash'; 3 | import { getReferenceForModel } from '../../k8s/k8s-utils'; 4 | import type { DiscoveryResources } from '../../types/api-discovery'; 5 | import type { K8sModelCommon } from '../../types/k8s'; 6 | 7 | const SDK_API_DISCOVERY_RESOURCES_LOCAL_STORAGE_KEY = 'sdk/api-discovery-resources'; 8 | 9 | const mergeByKey = (prev: K8sModelCommon[], next: K8sModelCommon[]) => 10 | Object.values(merge(keyBy(prev, getReferenceForModel), keyBy(next, getReferenceForModel))); 11 | 12 | const getLocalResources = () => { 13 | try { 14 | return JSON.parse(localStorage.getItem(SDK_API_DISCOVERY_RESOURCES_LOCAL_STORAGE_KEY) || '{}'); 15 | } catch (e) { 16 | consoleLogger.error('Cannot load cached API resources', e); 17 | throw e; 18 | } 19 | }; 20 | 21 | const setLocalResources = (resources: DiscoveryResources[]) => { 22 | try { 23 | localStorage.setItem(SDK_API_DISCOVERY_RESOURCES_LOCAL_STORAGE_KEY, JSON.stringify(resources)); 24 | } catch (e) { 25 | consoleLogger.error('Error caching API resources in localStorage', e); 26 | throw e; 27 | } 28 | }; 29 | export const cacheResources = (resources: DiscoveryResources[]) => { 30 | const allResources = [...[getLocalResources()], ...resources].reduce( 31 | (acc, curr) => 32 | mergeWith(acc, curr, (first, second) => { 33 | if (Array.isArray(first) && first[0]?.constructor?.name === 'Object') { 34 | return mergeByKey(first, second); 35 | } 36 | 37 | return undefined; 38 | }), 39 | {}, 40 | ); 41 | setLocalResources(allResources); 42 | }; 43 | 44 | export const getCachedResources = () => { 45 | const resourcesJSON = localStorage.getItem(SDK_API_DISCOVERY_RESOURCES_LOCAL_STORAGE_KEY); 46 | if (!resourcesJSON) { 47 | consoleLogger.error( 48 | `No API resources found in localStorage for key ${SDK_API_DISCOVERY_RESOURCES_LOCAL_STORAGE_KEY}`, 49 | ); 50 | return null; 51 | } 52 | 53 | // Clear cached resources after load as a safeguard. If there's any errors 54 | // with the content that prevents the console from working, the bad data 55 | // will not be loaded when the user refreshes the console. The cache will 56 | // be refreshed when discovery completes. 57 | localStorage.removeItem(SDK_API_DISCOVERY_RESOURCES_LOCAL_STORAGE_KEY); 58 | 59 | const resources = JSON.parse(resourcesJSON); 60 | consoleLogger.info('Loaded cached API resources from localStorage'); 61 | return resources; 62 | }; 63 | -------------------------------------------------------------------------------- /packages/lib-utils/src/app/redux/ReduxExtensionProvider.test.tsx: -------------------------------------------------------------------------------- 1 | import type { ResolvedExtension, LoadedExtension } from '@openshift/dynamic-plugin-sdk'; 2 | import { useResolvedExtensions } from '@openshift/dynamic-plugin-sdk'; 3 | import type { ReduxProvider } from '@openshift/dynamic-plugin-sdk-extensions'; 4 | import { render } from '@testing-library/react'; 5 | import * as React from 'react'; 6 | import type { ReactReduxContextValue } from 'react-redux'; 7 | import { createSelectorHook } from 'react-redux'; 8 | import type { Store } from 'redux'; 9 | import { createStore } from 'redux'; 10 | import ReduxExtensionProvider from './ReduxExtensionProvider'; 11 | 12 | type LRReduxReducer = LoadedExtension>; 13 | 14 | jest.mock('@openshift/dynamic-plugin-sdk', () => ({ 15 | useResolvedExtensions: jest.fn(), 16 | })); 17 | 18 | const useResolvedExtensionsMock = jest.mocked(useResolvedExtensions, false); 19 | 20 | describe('ReduxExtensionProvider', () => { 21 | beforeEach(() => { 22 | jest.clearAllMocks(); 23 | }); 24 | 25 | test('should register multiple providers with separate redux contexts', () => { 26 | type State = { 27 | value: number; 28 | }; 29 | 30 | const store1: Store = createStore((state) => state ?? { value: 1 }); 31 | const store2: Store = createStore((state) => state ?? { value: 2 }); 32 | 33 | // create 2 core.redux-provider extensions 34 | const extensions: LRReduxReducer[] = [ 35 | { 36 | uid: '1', 37 | pluginName: 'test', 38 | type: 'core.redux-provider', 39 | properties: { 40 | store: store1, 41 | context: React.createContext({ 42 | store: store1, 43 | storeState: store1.getState(), 44 | }), 45 | }, 46 | }, 47 | { 48 | uid: '2', 49 | pluginName: 'test', 50 | type: 'core.redux-provider', 51 | properties: { 52 | store: store2, 53 | context: React.createContext({ 54 | store: store2, 55 | storeState: store2.getState(), 56 | }), 57 | }, 58 | }, 59 | ]; 60 | 61 | // mock that returns the test extensions 62 | useResolvedExtensionsMock.mockReturnValue([extensions, true, []]); 63 | 64 | // create 2 contextual redux selectors; one for each extension 65 | const useSelector1 = createSelectorHook(extensions[0].properties.context); 66 | const useSelector2 = createSelectorHook(extensions[1].properties.context); 67 | 68 | let value1 = -1; 69 | let value2 = -1; 70 | 71 | // this component will select values separately from each redux store 72 | const Test: React.FC = () => { 73 | value1 = useSelector1((state) => state.value); 74 | value2 = useSelector2((state) => state.value); 75 | return null; 76 | }; 77 | 78 | render( 79 | 80 | 81 | , 82 | ); 83 | 84 | // assert that the values received by each selector relate to their corresponding redux state 85 | expect(value1).toBe(1); 86 | expect(value2).toBe(2); 87 | }); 88 | 89 | test('should render children', () => { 90 | // mock return value of empty set of extensions 91 | useResolvedExtensionsMock.mockReturnValue([[], true, []]); 92 | 93 | const { container } = render( 94 | 95 | 96 | , 97 | ); 98 | 99 | expect(container.firstElementChild?.tagName).toBe('SPAN'); 100 | expect(container.firstElementChild?.getAttribute('id')).toBe('test'); 101 | }); 102 | 103 | test('should return null if extensions are unresolved', () => { 104 | // mock return value of empty set of extensions 105 | useResolvedExtensionsMock.mockReturnValue([[], false, []]); 106 | 107 | const { container } = render( 108 | 109 | 110 | , 111 | ); 112 | 113 | expect(container.firstElementChild).toBe(null); 114 | }); 115 | }); 116 | -------------------------------------------------------------------------------- /packages/lib-utils/src/app/redux/ReduxExtensionProvider.tsx: -------------------------------------------------------------------------------- 1 | import { useResolvedExtensions } from '@openshift/dynamic-plugin-sdk'; 2 | import { isReduxProvider } from '@openshift/dynamic-plugin-sdk-extensions'; 3 | import type { ReduxProvider } from '@openshift/dynamic-plugin-sdk-extensions'; 4 | import * as React from 'react'; 5 | import { Provider } from 'react-redux'; 6 | 7 | /** 8 | * Renders a Redux.Provider for each `core.redux-provider` extension. 9 | * Should be rendered near the root of the application. 10 | */ 11 | const ReduxExtensionProvider: React.FC = ({ children }) => { 12 | const [reduxProviderExtensions, reduxProvidersResolved] = 13 | useResolvedExtensions(isReduxProvider); 14 | 15 | return reduxProvidersResolved ? ( 16 | <> 17 | {reduxProviderExtensions.reduce( 18 | (c, e) => ( 19 | 20 | {c} 21 | 22 | ), 23 | children, 24 | )} 25 | 26 | ) : null; 27 | }; 28 | 29 | export default ReduxExtensionProvider; 30 | -------------------------------------------------------------------------------- /packages/lib-utils/src/app/redux/index.ts: -------------------------------------------------------------------------------- 1 | import { consoleLogger } from '@openshift/dynamic-plugin-sdk'; 2 | import * as React from 'react'; 3 | import { useStore } from 'react-redux'; 4 | import type { Store } from 'redux'; 5 | import { applyMiddleware, combineReducers, createStore, compose } from 'redux'; 6 | import thunk from 'redux-thunk'; 7 | import { setReduxStore, getReduxStore } from '../../config'; 8 | import { SDKReducers } from './reducers'; 9 | 10 | type UseReduxStoreResult = { 11 | store: Store; 12 | storeContextPresent: boolean; 13 | }; 14 | 15 | /** 16 | * `useReduxStore` will provide the store instance if present or else create one along with info if the context was present. 17 | * 18 | * @example 19 | * ```ts 20 | * function Component () { 21 | * const {store, storeContextPresent} = useReduxStore() 22 | * return ... 23 | * } 24 | * ``` 25 | */ 26 | export const useReduxStore = (): UseReduxStoreResult => { 27 | let storeContext: Store | null = null; 28 | try { 29 | // It'll always be invoked -- it just might blow up 30 | // eslint-disable-next-line react-hooks/rules-of-hooks 31 | storeContext = useStore(); 32 | } catch (e) { 33 | // TODO: remove once proven not needed (redux versioning issue) 34 | consoleLogger.error(e); 35 | } 36 | const [storeContextPresent, setStoreContextPresent] = React.useState(false); 37 | const store = React.useMemo(() => { 38 | if (storeContext) { 39 | setStoreContextPresent(true); 40 | setReduxStore(storeContext); 41 | } else { 42 | consoleLogger.info('Creating the SDK redux store'); 43 | setStoreContextPresent(false); 44 | const storeInstance = createStore( 45 | combineReducers(SDKReducers), 46 | {}, 47 | compose(applyMiddleware(thunk)), 48 | ); 49 | setReduxStore(storeInstance as unknown as Store); 50 | } 51 | return getReduxStore(); 52 | }, [storeContext]); 53 | 54 | return { store, storeContextPresent }; 55 | }; 56 | -------------------------------------------------------------------------------- /packages/lib-utils/src/app/redux/reducers/index.ts: -------------------------------------------------------------------------------- 1 | import { sdkK8sReducer } from './k8s'; 2 | 3 | /** 4 | * Dynamic Plugin SDK Redux store reducers 5 | * 6 | * If the app uses Redux, these can be spread into the root of your store to provide an integrated SDK. 7 | * If the app does not use Redux, these will be provided via the SDK Redux Store. 8 | */ 9 | export const SDKReducers = Object.freeze({ 10 | k8s: sdkK8sReducer, 11 | }); 12 | -------------------------------------------------------------------------------- /packages/lib-utils/src/app/redux/reducers/k8s/index.ts: -------------------------------------------------------------------------------- 1 | export * from './k8s'; 2 | export * from './selector'; 3 | -------------------------------------------------------------------------------- /packages/lib-utils/src/app/redux/reducers/k8s/selector.ts: -------------------------------------------------------------------------------- 1 | import type { K8sState, SDKStoreState } from '../../../../types/redux'; 2 | 3 | export const getReduxIdPayload = (state: SDKStoreState, reduxId: string) => 4 | state?.k8s?.get(reduxId); 5 | 6 | export const getK8sDataById = (state: K8sState, id: string) => state?.getIn([id, 'data']); 7 | -------------------------------------------------------------------------------- /packages/lib-utils/src/config.ts: -------------------------------------------------------------------------------- 1 | import type { ResourceFetch } from '@openshift/dynamic-plugin-sdk'; 2 | import type { Store } from 'redux'; 3 | import type { WebSocketAppSettings, WebSocketOptions } from './web-socket/types'; 4 | 5 | export type UtilsConfig = { 6 | /** 7 | * Resource fetch implementation provided by the host application. 8 | * 9 | * Applications must validate the response before resolving the Promise. 10 | * 11 | * If the request cannot be completed successfully, the Promise should be rejected 12 | * with an appropriate error. 13 | */ 14 | appFetch: ResourceFetch; 15 | 16 | /** 17 | * Configure the web socket settings for your application. 18 | */ 19 | wsAppSettings: ( 20 | options: WebSocketOptions & { wsPrefix?: string; pathPrefix?: string }, 21 | ) => Promise; 22 | }; 23 | 24 | let config: Readonly | undefined; 25 | 26 | /** 27 | * Checks if the {@link UtilsConfig} is set. 28 | */ 29 | export const isUtilsConfigSet = (): boolean => { 30 | return config !== undefined; 31 | }; 32 | 33 | /** 34 | * Set the {@link UtilsConfig} reference. 35 | * 36 | * This must be done before using any of the Kubernetes utilities. 37 | */ 38 | export const setUtilsConfig = (c: UtilsConfig) => { 39 | if (config !== undefined) { 40 | throw new Error('UtilsConfig reference has already been set'); 41 | } 42 | 43 | config = Object.freeze({ ...c }); 44 | }; 45 | 46 | /** 47 | * Get the {@link UtilsConfig} reference. 48 | * 49 | * Throws an error if the reference isn't already set. 50 | */ 51 | export const getUtilsConfig = (): UtilsConfig => { 52 | if (config === undefined) { 53 | throw new Error('UtilsConfig reference has not been set'); 54 | } 55 | 56 | return config; 57 | }; 58 | 59 | let reduxStore: Store; 60 | 61 | /** 62 | * Set the {@link Store} reference. 63 | * 64 | * This must be done before using the AppInitSDK React entrypoint 65 | */ 66 | export const setReduxStore = (storeData: Store) => { 67 | reduxStore = storeData; 68 | }; 69 | 70 | /** 71 | * Get the {@link Store} reference. 72 | * 73 | * Throws an error if the reference isn't already set. 74 | */ 75 | export const getReduxStore = () => { 76 | if (reduxStore === undefined) { 77 | throw new Error('Redux store reference has not been set'); 78 | } 79 | 80 | return reduxStore; 81 | }; 82 | -------------------------------------------------------------------------------- /packages/lib-utils/src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useEventListener } from './useEventListener'; 2 | export { useLocalStorage } from './useLocalStorage'; 3 | export { useWorkspace } from './useWorkspace'; 4 | -------------------------------------------------------------------------------- /packages/lib-utils/src/hooks/useEventListener.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export const useEventListener = ( 4 | eventTarget: EventTarget, 5 | event: keyof WindowEventMap, 6 | cb: EventListener, 7 | ) => { 8 | React.useEffect(() => { 9 | eventTarget.addEventListener(event, cb); 10 | return () => eventTarget.removeEventListener(event, cb); 11 | }, [cb, event, eventTarget]); 12 | }; 13 | -------------------------------------------------------------------------------- /packages/lib-utils/src/hooks/useLocalStorage.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useEventListener } from './useEventListener'; 3 | 4 | const tryJSONParse = (data: string): string | T => { 5 | try { 6 | return JSON.parse(data); 7 | } catch { 8 | return data; 9 | } 10 | }; 11 | 12 | /** 13 | * Local storage value and setter for `key`. 14 | * NOTE: This hook will not update it's value if the same key has been set elsewhere in the current tab. 15 | * 16 | * @returns setter and JSON value if parseable, or else `string`. 17 | */ 18 | export const useLocalStorage = (key: string): [T | string, React.Dispatch] => { 19 | const [value, setValue] = React.useState(tryJSONParse(window.localStorage.getItem(key) ?? '')); 20 | 21 | useEventListener(window, 'storage', () => { 22 | setValue(tryJSONParse(window.localStorage.getItem(key) ?? '')); 23 | }); 24 | 25 | const updateValue = React.useCallback( 26 | (val) => { 27 | const serializedValue = typeof val === 'object' ? JSON.stringify(val) : val; 28 | window.localStorage.setItem(key, serializedValue); 29 | setValue(val); 30 | }, 31 | [key], 32 | ); 33 | 34 | return [value, updateValue]; 35 | }; 36 | -------------------------------------------------------------------------------- /packages/lib-utils/src/hooks/useWorkspace.test.tsx: -------------------------------------------------------------------------------- 1 | import { renderHook } from '@testing-library/react-hooks/native'; 2 | import React from 'react'; 3 | import TestRenderer from 'react-test-renderer'; 4 | import WorkspaceContext from '../utils/WorkspaceContext'; 5 | import { workspaceState } from '../utils/workspaceState'; 6 | import { useWorkspace } from './useWorkspace'; 7 | 8 | const { act } = TestRenderer; 9 | 10 | jest.mock('react-redux', () => ({ 11 | useDispatch: jest.fn(() => jest.fn()), 12 | useSelector: jest.fn(), 13 | })); 14 | 15 | const WORKSPACE_KEY = 'sdk/active-workspace'; 16 | 17 | describe('useWorkspace', () => { 18 | beforeEach(() => { 19 | jest.resetModules(); 20 | localStorage.clear(); 21 | }); 22 | 23 | afterEach(() => { 24 | jest.clearAllMocks(); 25 | }); 26 | 27 | test('an unset workspace should return null', () => { 28 | const wrapper = ({ children }: { children: React.ReactNode }) => ( 29 | {children} 30 | ); 31 | const { result } = renderHook(() => useWorkspace(), { wrapper }); 32 | const [data, setter] = result.current; 33 | 34 | expect(data).toBeNull(); 35 | expect(setter).toBeDefined(); 36 | }); 37 | 38 | test('a set workspace should return the activeWorkspace', () => { 39 | localStorage.setItem(WORKSPACE_KEY, 'platform-experience'); 40 | const wrapper = ({ children }: { children: React.ReactNode }) => ( 41 | {children} 42 | ); 43 | const { result } = renderHook(() => useWorkspace(), { wrapper }); 44 | const [data, setter] = result.current; 45 | 46 | expect(data).toBe('platform-experience'); 47 | expect(setter).toBeDefined(); 48 | }); 49 | 50 | test('updating workspace should return the activeWorkspace', () => { 51 | localStorage.setItem(WORKSPACE_KEY, 'platform-experience'); 52 | const wrapper = ({ children }: { children: React.ReactNode }) => ( 53 | {children} 54 | ); 55 | const { result } = renderHook(() => useWorkspace(), { wrapper }); 56 | const [data, setter] = result.current; 57 | 58 | expect(data).toBe('platform-experience'); 59 | 60 | act(() => { 61 | setter('openshift'); 62 | }); 63 | expect(localStorage.getItem(WORKSPACE_KEY)).toBe('openshift'); 64 | }); 65 | 66 | test('clearing localStorage should return null', () => { 67 | localStorage.setItem(WORKSPACE_KEY, 'platform-experience'); 68 | const wrapper = ({ children }: { children: React.ReactNode }) => ( 69 | {children} 70 | ); 71 | const { result } = renderHook(() => useWorkspace(), { wrapper }); 72 | const [data, setter] = result.current; 73 | 74 | expect(data).toBe('platform-experience'); 75 | expect(setter).toBeDefined(); 76 | 77 | localStorage.clear(); 78 | 79 | expect(localStorage.getItem(WORKSPACE_KEY)).toBeNull(); 80 | }); 81 | }); 82 | -------------------------------------------------------------------------------- /packages/lib-utils/src/hooks/useWorkspace.ts: -------------------------------------------------------------------------------- 1 | import { consoleLogger } from '@openshift/dynamic-plugin-sdk'; 2 | import { useContext, useEffect, useReducer } from 'react'; 3 | import { setActiveWorkspaceLocalStorage } from '../k8s/k8s-utils'; 4 | import WorkspaceContext from '../utils/WorkspaceContext'; 5 | import { UpdateEvents } from '../utils/workspaceState'; 6 | 7 | /** 8 | * Hook that retrieves the active workspace from localStorage. The key for the active workspace is 9 | * always `sdk/active-workspace` 10 | * @returns a value for the activeWorkspace (string | null ) and a setter for updating the active workspace 11 | * 12 | * @example 13 | * ```ts 14 | * const Component: React.FC = () => { 15 | * const [activeWorkspace, setActiveWorkspace] = useWorkspace(); 16 | * 17 | * setActiveWorkspace("openshift-dev"); 18 | * return ... 19 | * } 20 | * ``` 21 | */ 22 | export const useWorkspace = () => { 23 | const { subscribe, unsubscribe, getState, setWorkspaceContext } = useContext(WorkspaceContext); 24 | // There isn't any other way around this for now, but this is an active anti-pattern. We need to reRender the page to 25 | // get the websocket to watch the new workspace on change. This is considered tech-debt and is actively discouraged. 26 | // https://reactjs.org/docs/hooks-faq.html#is-there-something-like-forceupdate 27 | const [, forceRender] = useReducer((count) => count + 1, 0); 28 | 29 | // All returns will be valid workspaces or null. Since there is no "default" workspace, 30 | // a null value means that one needs to be set. Returning a default or empty string will 31 | // break the model 32 | const workspace = getState().activeWorkspace; 33 | 34 | const setActive = (newWorkspace: string) => { 35 | try { 36 | if (typeof window !== 'undefined') { 37 | setActiveWorkspaceLocalStorage(newWorkspace); 38 | setWorkspaceContext(newWorkspace); 39 | } 40 | } catch (error) { 41 | consoleLogger.error(`Failed to get activeWorkspace due to: ${error}`); 42 | } 43 | }; 44 | 45 | useEffect(() => { 46 | const subsId = subscribe(UpdateEvents.activeWorkspace, forceRender); 47 | return () => { 48 | unsubscribe(subsId, UpdateEvents.activeWorkspace); 49 | }; 50 | }, [subscribe, unsubscribe]); 51 | 52 | return [workspace, setActive] as const; 53 | }; 54 | -------------------------------------------------------------------------------- /packages/lib-utils/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Kubernetes and other dynamic plugin SDK utilities. 3 | * 4 | * @remarks 5 | * This package provides various React focused dynamic plugin SDK utilities, 6 | * including APIs for working with Kubernetes and integrating with Redux. 7 | * 8 | * @packageDocumentation 9 | */ 10 | 11 | // Kubernetes utilities 12 | export { default as AppInitSDK, AppInitSDKProps } from './app/AppInitSDK'; 13 | export { useWorkspace } from './hooks/useWorkspace'; 14 | export { default as WorkspaceContext } from './utils/WorkspaceContext'; 15 | export { default as WorkspaceProvider } from './utils/WorkspaceProvider'; 16 | export { UtilsConfig, isUtilsConfigSet, setUtilsConfig, getUtilsConfig } from './config'; 17 | export { commonFetch, commonFetchText, commonFetchJSON } from './utils/common-fetch'; 18 | export { 19 | k8sGetResource, 20 | k8sCreateResource, 21 | k8sUpdateResource, 22 | k8sPatchResource, 23 | k8sDeleteResource, 24 | k8sListResource, 25 | k8sListResourceItems, 26 | K8sResourceBaseOptions, 27 | K8sResourceReadOptions, 28 | K8sResourceUpdateOptions, 29 | K8sResourcePatchOptions, 30 | K8sResourceDeleteOptions, 31 | K8sResourceListOptions, 32 | K8sResourceListResult, 33 | } from './k8s/k8s-resource'; 34 | export { 35 | getK8sResourceURL, 36 | getActiveWorkspace, 37 | setActiveWorkspaceLocalStorage, 38 | isK8sStatus, 39 | } from './k8s/k8s-utils'; 40 | export { K8sStatusError } from './k8s/k8s-errors'; 41 | export { createAPIActions, initAPIDiscovery } from './app/api-discovery'; 42 | export { InitAPIDiscovery, DiscoveryResources, APIActions } from './types/api-discovery'; 43 | export { 44 | K8sModelCommon, 45 | K8sResourceCommon, 46 | K8sGroupVersionKind, 47 | K8sResourceIdentifier, 48 | K8sResourceKindReference, 49 | K8sStatus, 50 | K8sVerb, 51 | GetGroupVersionKindForModel, 52 | GroupVersionKind, 53 | QueryOptions, 54 | QueryParams, 55 | Patch, 56 | Operator, 57 | OwnerReference, 58 | MatchExpression, 59 | MatchLabels, 60 | Selector, 61 | FilterValue, 62 | } from './types/k8s'; 63 | 64 | // WebSocket utilities 65 | export { WebSocketFactory, WebSocketState } from './web-socket/WebSocketFactory'; 66 | export { 67 | BulkMessageHandler, 68 | CloseHandler, 69 | DestroyHandler, 70 | ErrorHandler, 71 | GenericHandler, 72 | MessageHandler, 73 | MessageDataType, 74 | OpenHandler, 75 | WebSocketAppSettings, 76 | WebSocketOptions, 77 | } from './web-socket/types'; 78 | 79 | // Redux utilities 80 | export { SDKReducers } from './app/redux/reducers'; 81 | export { ActionType } from './app/redux/actions/k8s'; 82 | export { default as ReduxExtensionProvider } from './app/redux/ReduxExtensionProvider'; 83 | export { K8sState } from './types/redux'; 84 | 85 | // React hooks 86 | export { useK8sWatchResource, useK8sWatchResources, useK8sModel, useK8sModels } from './k8s/hooks'; 87 | export { UseK8sModel, UseK8sModels } from './k8s/hooks/use-model-types'; 88 | export { Query } from './k8s/hooks/k8s-watch-types'; 89 | export { 90 | WatchK8sResource, 91 | WatchK8sResources, 92 | ResourcesObject, 93 | WatchK8sResult, 94 | WatchK8sResults, 95 | WatchK8sResultsObject, 96 | } from './k8s/hooks/watch-resource-types'; 97 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export { useK8sWatchResource } from './useK8sWatchResource'; 2 | export { useK8sWatchResources } from './useK8sWatchResources'; 3 | export { useK8sModel } from './useK8sModel'; 4 | export { useK8sModels } from './useK8sModels'; 5 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/k8s-watch-types.ts: -------------------------------------------------------------------------------- 1 | import type { K8sModelCommon, Selector } from '../../types/k8s'; 2 | import type { ThunkDispatchFunction } from '../../types/redux'; 3 | import type { WebSocketOptions } from '../../web-socket/types'; 4 | import type { WatchK8sResource } from './watch-resource-types'; 5 | 6 | export type WatchData = { id: string; action: ThunkDispatchFunction }; 7 | 8 | export type GetWatchData = ( 9 | resource?: WatchK8sResource, 10 | k8sModel?: K8sModelCommon, 11 | options?: Partial, 12 | ) => WatchData | null; 13 | 14 | export type Query = { [key: string]: unknown }; 15 | 16 | export type MakeQuery = ( 17 | namespace?: string, 18 | labelSelector?: Selector, 19 | fieldSelector?: string, 20 | name?: string, 21 | limit?: number, 22 | ) => Query; 23 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/k8s-watcher.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from '@openshift/dynamic-plugin-sdk'; 2 | import { isEmpty } from 'lodash'; 3 | import * as k8sActions from '../../app/redux/actions/k8s'; 4 | import type { K8sModelCommon } from '../../types/k8s'; 5 | import type { WebSocketOptions } from '../../web-socket/types'; 6 | import { getReferenceForModel } from '../k8s-utils'; 7 | import type { GetWatchData, MakeQuery, Query } from './k8s-watch-types'; 8 | import type { WatchK8sResource } from './watch-resource-types'; 9 | 10 | export class NoModelError extends CustomError { 11 | constructor() { 12 | super('Model does not exist'); 13 | } 14 | } 15 | 16 | export const makeReduxID = (k8sKind: K8sModelCommon, query: Query) => { 17 | let queryString = ''; 18 | if (!isEmpty(query)) { 19 | queryString = `---${JSON.stringify(query)}`; 20 | } 21 | 22 | return `${getReferenceForModel(k8sKind)}${queryString}`; 23 | }; 24 | 25 | export const makeQuery: MakeQuery = (namespace, labelSelector, fieldSelector, name, limit) => { 26 | const query: Query = {}; 27 | 28 | if (!isEmpty(labelSelector)) { 29 | query.labelSelector = labelSelector; 30 | } 31 | 32 | if (!isEmpty(namespace)) { 33 | query.ns = namespace; 34 | } 35 | 36 | if (!isEmpty(name)) { 37 | query.name = name; 38 | } 39 | 40 | if (fieldSelector) { 41 | query.fieldSelector = fieldSelector; 42 | } 43 | 44 | if (limit) { 45 | query.limit = limit; 46 | } 47 | return query; 48 | }; 49 | 50 | export const INTERNAL_REDUX_IMMUTABLE_TOJSON_CACHE_SYMBOL = Symbol('_cachedToJSResult'); 51 | 52 | /* TODO: Fix ignores in getReduxData -- this is likely a refactor of the method */ 53 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 54 | // @ts-ignore 55 | export const getReduxData = (immutableData, resource: WatchK8sResource) => { 56 | if (!immutableData) { 57 | return null; 58 | } 59 | if (resource.isList) { 60 | // eslint-disable-next-line @typescript-eslint/ban-ts-comment 61 | // @ts-ignore 62 | return immutableData.toArray().map((a) => { 63 | if (!a[INTERNAL_REDUX_IMMUTABLE_TOJSON_CACHE_SYMBOL]) { 64 | // eslint-disable-next-line no-param-reassign 65 | a[INTERNAL_REDUX_IMMUTABLE_TOJSON_CACHE_SYMBOL] = a.toJSON(); 66 | } 67 | return a[INTERNAL_REDUX_IMMUTABLE_TOJSON_CACHE_SYMBOL]; 68 | }); 69 | } 70 | if (immutableData.toJSON) { 71 | if (!immutableData[INTERNAL_REDUX_IMMUTABLE_TOJSON_CACHE_SYMBOL]) { 72 | // eslint-disable-next-line no-param-reassign 73 | immutableData[INTERNAL_REDUX_IMMUTABLE_TOJSON_CACHE_SYMBOL] = immutableData.toJSON(); 74 | } 75 | return immutableData[INTERNAL_REDUX_IMMUTABLE_TOJSON_CACHE_SYMBOL]; 76 | } 77 | return null; 78 | }; 79 | 80 | export const getWatchData: GetWatchData = ( 81 | resource, 82 | k8sModel, 83 | options: Partial< 84 | WebSocketOptions & RequestInit & { wsPrefix?: string; pathPrefix?: string } 85 | > = {}, 86 | ) => { 87 | if (!k8sModel || !resource) { 88 | return null; 89 | } 90 | const query = makeQuery( 91 | resource.namespace, 92 | resource.selector, 93 | resource.fieldSelector, 94 | resource.name, 95 | resource.limit, 96 | ); 97 | 98 | const id = makeReduxID(k8sModel, query); 99 | let action; 100 | if (resource.isList) { 101 | action = k8sActions.watchK8sList( 102 | id, 103 | { ...query }, 104 | k8sModel, 105 | undefined, 106 | resource.partialMetadata, 107 | options, 108 | ); 109 | } else if (resource.name) { 110 | action = k8sActions.watchK8sObject( 111 | id, 112 | resource.name, 113 | resource.namespace || '', 114 | { ...query }, 115 | k8sModel, 116 | resource.partialMetadata, 117 | options, 118 | ); 119 | } else { 120 | return null; 121 | } 122 | return { id, action }; 123 | }; 124 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/use-model-types.ts: -------------------------------------------------------------------------------- 1 | import type { 2 | K8sGroupVersionKind, 3 | K8sModelCommon, 4 | K8sResourceKindReference, 5 | } from '../../types/k8s'; 6 | 7 | export type UseK8sModel = ( 8 | // Use K8sGroupVersionKind type instead of K8sResourceKindReference. Support for type K8sResourceKindReference will be removed in a future release. 9 | groupVersionKind: K8sResourceKindReference | K8sGroupVersionKind, 10 | ) => [K8sModelCommon, boolean]; 11 | 12 | export type UseK8sModels = () => [{ [key: string]: K8sModelCommon }, boolean]; 13 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/useDeepCompareMemoize.ts: -------------------------------------------------------------------------------- 1 | import { isEqual } from 'lodash'; 2 | import * as React from 'react'; 3 | 4 | export const useDeepCompareMemoize = (value: T, stringify?: boolean): T => { 5 | const ref = React.useRef(value); 6 | 7 | if ( 8 | stringify ? JSON.stringify(value) !== JSON.stringify(ref.current) : !isEqual(value, ref.current) 9 | ) { 10 | ref.current = value; 11 | } 12 | 13 | return ref.current; 14 | }; 15 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/useK8sModel.ts: -------------------------------------------------------------------------------- 1 | import { useSelector } from 'react-redux'; 2 | import type { 3 | K8sGroupVersionKind, 4 | K8sModelCommon, 5 | K8sResourceKindReference, 6 | } from '../../types/k8s'; 7 | import type { K8sState, SDKStoreState } from '../../types/redux'; 8 | import { 9 | getGroupVersionKindForReference, 10 | transformGroupVersionKindToReference, 11 | } from '../k8s-utils'; 12 | import type { UseK8sModel } from './use-model-types'; 13 | 14 | export const getK8sModel = ( 15 | k8s: K8sState, 16 | k8sGroupVersionKind: K8sResourceKindReference | K8sGroupVersionKind, 17 | ): K8sModelCommon => { 18 | const kindReference = transformGroupVersionKindToReference(k8sGroupVersionKind); 19 | return kindReference 20 | ? k8s?.getIn(['RESOURCES', 'models', kindReference]) ?? 21 | k8s?.getIn(['RESOURCES', 'models', getGroupVersionKindForReference(kindReference).kind]) 22 | : undefined; 23 | }; 24 | 25 | /** 26 | * Hook that retrieves the k8s model for provided K8sGroupVersionKind from redux. 27 | * @param k8sGroupVersionKind - group, version, kind of k8s resource {@link K8sGroupVersionKind} is preferred alternatively can pass reference for group, version, kind which is deprecated i.e `group~version~kind` {@link K8sResourceKindReference}. 28 | * @returns An array with the first item as k8s model and second item as inFlight status 29 | * 30 | * @example 31 | * ```ts 32 | * const Component: React.FC = () => { 33 | * const [model, inFlight] = useK8sModel({ group: 'app'; version: 'v1'; kind: 'Deployment' }); 34 | * return ... 35 | * } 36 | * ``` 37 | */ 38 | export const useK8sModel: UseK8sModel = (k8sGroupVersionKind) => [ 39 | useSelector(({ k8s }) => getK8sModel(k8s, k8sGroupVersionKind)), 40 | useSelector(({ k8s }) => k8s?.getIn(['RESOURCES', 'inFlight']) ?? false), 41 | ]; 42 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/useK8sModels.ts: -------------------------------------------------------------------------------- 1 | import type { Map as ImmutableMap } from 'immutable'; 2 | import { useSelector } from 'react-redux'; 3 | import type { K8sModelCommon } from '../../types/k8s'; 4 | import type { SDKStoreState } from '../../types/redux'; 5 | import type { UseK8sModels } from './use-model-types'; 6 | 7 | /** 8 | * Hook that retrieves all current k8s models from redux. 9 | * 10 | * @returns An array with the first item as the list of k8s model and second item as inFlight status 11 | * 12 | * @example 13 | * ```ts 14 | * const Component: React.FC = () => { 15 | * const [models, inFlight] = UseK8sModels(); 16 | * return ... 17 | * } 18 | * ``` 19 | */ 20 | export const useK8sModels: UseK8sModels = () => [ 21 | useSelector>(({ k8s }) => 22 | k8s?.getIn(['RESOURCES', 'models']), 23 | )?.toJS() ?? {}, 24 | useSelector(({ k8s }) => k8s?.getIn(['RESOURCES', 'inFlight'])) ?? false, 25 | ]; 26 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/useK8sWatchResource.ts: -------------------------------------------------------------------------------- 1 | import type { Map as ImmutableMap } from 'immutable'; 2 | import * as React from 'react'; 3 | import { useSelector, useDispatch } from 'react-redux'; 4 | import * as k8sActions from '../../app/redux/actions/k8s'; 5 | import { getReduxIdPayload } from '../../app/redux/reducers/k8s/selector'; 6 | import type { K8sModelCommon, K8sResourceCommon } from '../../types/k8s'; 7 | import type { SDKStoreState } from '../../types/redux'; 8 | import WorkspaceContext from '../../utils/WorkspaceContext'; 9 | import type { WebSocketOptions } from '../../web-socket/types'; 10 | import { getWatchData, getReduxData, NoModelError } from './k8s-watcher'; 11 | import { useDeepCompareMemoize } from './useDeepCompareMemoize'; 12 | import { useK8sModel } from './useK8sModel'; 13 | import { useModelsLoaded } from './useModelsLoaded'; 14 | import type { WatchK8sResource, WatchK8sResult } from './watch-resource-types'; 15 | 16 | const NOT_A_VALUE = '__not-a-value__'; 17 | 18 | /** 19 | * Hook that retrieves the k8s resource along with status for loaded and error. 20 | * @param initResource - options needed to watch for resource. 21 | * @param initModel - static model to pull information from when watching a resource. 22 | * @param options - WS and fetch options passed down to WSFactory @see {@link WebSocketFactory} and when pulling the first item. 23 | * @returns An array with first item as resource(s), second item as loaded status and third item as error state if any. 24 | * 25 | * @example 26 | * ```ts 27 | * const Component: React.FC = () => { 28 | * const watchRes = { 29 | ... 30 | } 31 | * const [data, loaded, error] = useK8sWatchResource(watchRes, { wsPrefix: 'wss://localhost:1337/foo' }) 32 | * return ... 33 | * } 34 | * ``` 35 | */ 36 | export const useK8sWatchResource = ( 37 | initResource: WatchK8sResource | null, 38 | initModel?: K8sModelCommon, 39 | options?: Partial, 40 | ): WatchK8sResult => { 41 | const workspaceContext = React.useContext(WorkspaceContext); 42 | const workspace = workspaceContext.getState().activeWorkspace; 43 | const withFallback: WatchK8sResource = initResource || { kind: NOT_A_VALUE }; 44 | const resource = useDeepCompareMemoize(withFallback, true); 45 | 46 | const storedModelsLoaded = useModelsLoaded(); 47 | const modelsLoaded = initModel ? true : storedModelsLoaded; 48 | 49 | const [storedK8sModel] = useK8sModel(resource.groupVersionKind || resource.kind); 50 | const k8sModel = initModel || storedK8sModel; 51 | 52 | const watchData = React.useMemo( 53 | () => getWatchData(resource, k8sModel, options), 54 | [k8sModel, resource, options], 55 | ); 56 | 57 | const dispatch = useDispatch(); 58 | 59 | React.useEffect(() => { 60 | if (watchData) { 61 | dispatch(watchData.action); 62 | } 63 | return () => { 64 | if (watchData) { 65 | dispatch(k8sActions.stopK8sWatch(watchData.id)); 66 | } 67 | }; 68 | }, [dispatch, watchData, workspace]); 69 | 70 | const resourceK8s = useSelector((state) => 71 | watchData ? getReduxIdPayload(state, watchData.id) : null, 72 | ) as ImmutableMap; // TODO: Store state based off of Immutable is problematic 73 | 74 | const batchesInFlight = useSelector(({ k8s }) => 75 | k8s?.getIn(['RESOURCES', 'batchesInFlight']), 76 | ); 77 | 78 | return React.useMemo(() => { 79 | if (!resource || resource.kind === NOT_A_VALUE) { 80 | return [undefined, true, undefined]; 81 | } 82 | if (!resourceK8s) { 83 | const data = resource.isList ? [] : {}; 84 | return modelsLoaded && !k8sModel && !batchesInFlight 85 | ? [data, true, new NoModelError()] 86 | : [data, false, undefined]; 87 | } 88 | 89 | const data = getReduxData(resourceK8s.get('data'), resource); 90 | const loaded = resourceK8s.get('loaded') as boolean; 91 | const loadError = resourceK8s.get('loadError'); 92 | return [data, loaded, loadError]; 93 | }, [resource, resourceK8s, modelsLoaded, k8sModel, batchesInFlight]); 94 | }; 95 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/useModelsLoaded.ts: -------------------------------------------------------------------------------- 1 | import type { Map as ImmutableMap } from 'immutable'; 2 | import * as React from 'react'; 3 | import { useSelector } from 'react-redux'; 4 | import type { K8sModelCommon } from '../../types/k8s'; 5 | import type { SDKStoreState } from '../../types/redux'; 6 | 7 | export const useModelsLoaded = (): boolean => { 8 | const ref = React.useRef(false); 9 | const k8sModels = useSelector>(({ k8s }) => 10 | k8s?.getIn(['RESOURCES', 'models']), 11 | ); 12 | const inFlight = useSelector(({ k8s }) => 13 | k8s?.getIn(['RESOURCES', 'inFlight']), 14 | ); 15 | 16 | if (!ref.current && k8sModels.size && !inFlight) { 17 | ref.current = true; 18 | } 19 | return ref.current; 20 | }; 21 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/usePrevious.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | 3 | export const usePrevious =

(value: P, deps?: unknown[]): P | undefined => { 4 | const ref = React.useRef

(); 5 | 6 | React.useEffect(() => { 7 | ref.current = value; 8 | // eslint-disable-next-line react-hooks/exhaustive-deps 9 | }, deps || [value]); 10 | 11 | return ref.current; 12 | }; 13 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/hooks/watch-resource-types.ts: -------------------------------------------------------------------------------- 1 | import type { EitherNotBoth } from '@openshift/dynamic-plugin-sdk'; 2 | import type { 3 | K8sGroupVersionKind, 4 | K8sResourceCommon, 5 | K8sResourceKindReference, 6 | Selector, 7 | } from '../../types/k8s'; 8 | 9 | export type WatchK8sResult = [ 10 | data: R, 11 | loaded: boolean, 12 | loadError: unknown, 13 | ]; 14 | 15 | export type WatchK8sResultsObject = { 16 | data: R; 17 | loaded: boolean; 18 | loadError: unknown; 19 | }; 20 | 21 | export type WatchK8sResults = { 22 | [K in keyof R]: WatchK8sResultsObject; 23 | }; 24 | 25 | export type WatchK8sResource = EitherNotBoth< 26 | { kind: K8sResourceKindReference }, 27 | { groupVersionKind: K8sGroupVersionKind } 28 | > & { 29 | name?: string; 30 | namespace?: string; 31 | isList?: boolean; 32 | selector?: Selector; 33 | namespaced?: boolean; 34 | limit?: number; 35 | fieldSelector?: string; 36 | optional?: boolean; 37 | partialMetadata?: boolean; 38 | }; 39 | 40 | export type ResourcesObject = { [key: string]: K8sResourceCommon | K8sResourceCommon[] }; 41 | 42 | export type WatchK8sResources = { 43 | [K in keyof R]: WatchK8sResource; 44 | }; 45 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/k8s-errors.ts: -------------------------------------------------------------------------------- 1 | import { CustomError } from '@openshift/dynamic-plugin-sdk'; 2 | import type { K8sStatus } from '../types/k8s'; 3 | 4 | /** 5 | * Error class used when Kubernetes cannot handle a request. 6 | */ 7 | export class K8sStatusError extends CustomError { 8 | constructor(readonly status: K8sStatus) { 9 | super(status.message); 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/lib-utils/src/k8s/k8s-utils.test.ts: -------------------------------------------------------------------------------- 1 | import type { K8sModelCommon, K8sResourceCommon, QueryOptions } from '../types/k8s'; 2 | import { getK8sResourceURL } from './k8s-utils'; 3 | 4 | const resourceModelMock: K8sModelCommon = { 5 | apiGroup: 'appstudio.redhat.com', 6 | apiVersion: 'v1alpha1', 7 | kind: 'Application', 8 | namespaced: true, 9 | plural: 'applications', 10 | }; 11 | 12 | const resourceDataMock: K8sResourceCommon = { 13 | apiVersion: 'appstudio.redhat.com/v1alpha1', 14 | kind: 'Application', 15 | metadata: { 16 | creationTimestamp: '2023-04-29T13:41:21Z', 17 | generation: 1, 18 | name: 'thequickbrownfox', 19 | namespace: 'foobar', 20 | resourceVersion: '414309692', 21 | uid: '602ad43f-1a71-4e71-9314-d93bffbc0772', 22 | }, 23 | }; 24 | 25 | const queryOptionsMock: QueryOptions = { 26 | ns: 'foobar', 27 | name: 'thequickbrownfox', 28 | path: 'path', 29 | queryParams: { 30 | pretty: 'true', 31 | dryRun: 'true', 32 | name: 'thequickbrownfox', 33 | watch: 'true', 34 | }, 35 | }; 36 | 37 | describe('k8s-utils', () => { 38 | describe('getK8sResourceURL', () => { 39 | beforeEach(() => { 40 | jest.resetModules(); 41 | }); 42 | 43 | afterEach(() => { 44 | jest.clearAllMocks(); 45 | }); 46 | 47 | test('should append name to URL path and query params by default', () => { 48 | expect(getK8sResourceURL(resourceModelMock, resourceDataMock, queryOptionsMock)).toBe( 49 | '/apis/appstudio.redhat.com/v1alpha1/namespaces/foobar/applications/thequickbrownfox/path?pretty=true&dryRun=true&name=thequickbrownfox&watch=true', 50 | ); 51 | }); 52 | 53 | test('should omit name from URL path and query params when method is "POST"', () => { 54 | expect(getK8sResourceURL(resourceModelMock, resourceDataMock, queryOptionsMock, true)).toBe( 55 | '/apis/appstudio.redhat.com/v1alpha1/namespaces/foobar/applications/path?pretty=true&dryRun=true', 56 | ); 57 | }); 58 | }); 59 | }); 60 | -------------------------------------------------------------------------------- /packages/lib-utils/src/types/api-discovery.ts: -------------------------------------------------------------------------------- 1 | import type { Store, AnyAction } from 'redux'; 2 | import type { ActionType as Action } from 'typesafe-actions'; 3 | import type { K8sVerb, K8sModelCommon } from './k8s'; 4 | 5 | export type InitAPIDiscovery = ( 6 | store: Store>, 7 | preferenceList?: string[], 8 | ) => void; 9 | 10 | export type APIResourceList = K8sModelCommon & { 11 | kind: 'APIResourceList'; 12 | apiVersion: 'v1'; 13 | groupVersion: string; 14 | resources?: { 15 | name: string; 16 | singularName?: string; 17 | namespaced?: boolean; 18 | kind: string; 19 | verbs: K8sVerb[]; 20 | shortNames?: string[]; 21 | }[]; 22 | }; 23 | 24 | export type DiscoveryResources = { 25 | adminResources: string[]; 26 | allResources: string[]; 27 | configResources: K8sModelCommon[]; 28 | clusterOperatorConfigResources: K8sModelCommon[]; 29 | models: K8sModelCommon[]; 30 | namespacedSet: Set; 31 | safeResources: string[]; 32 | groupVersionMap: { 33 | [key: string]: { 34 | versions: string[]; 35 | preferredVersion: string; 36 | }; 37 | }; 38 | }; 39 | 40 | export type APIActions = { 41 | setResourcesInFlight: (isInFlight: boolean) => void; 42 | setBatchesInFlight: (isInFlight: boolean) => void; 43 | receivedResources: (resource: DiscoveryResources) => void; 44 | }; 45 | -------------------------------------------------------------------------------- /packages/lib-utils/src/types/images.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 3 | const value: any; 4 | export = value; 5 | } 6 | -------------------------------------------------------------------------------- /packages/lib-utils/src/types/redux.ts: -------------------------------------------------------------------------------- 1 | import type { Map as ImmutableMap } from 'immutable'; 2 | import type { AnyAction } from 'redux'; 3 | import type { ThunkDispatch } from 'redux-thunk'; 4 | 5 | export type K8sState = ImmutableMap; 6 | 7 | export type SDKStoreState = { 8 | k8s: K8sState; 9 | }; 10 | 11 | export type DispatchWithThunk = ThunkDispatch; 12 | 13 | export type GetState = () => SDKStoreState; 14 | 15 | export type ThunkDispatchFunction = (dispatch: DispatchWithThunk, state: GetState) => void; 16 | -------------------------------------------------------------------------------- /packages/lib-utils/src/utils/WorkspaceContext.ts: -------------------------------------------------------------------------------- 1 | import { createContext } from 'react'; 2 | import type { workspaceState } from './workspaceState'; 3 | 4 | /** 5 | * 6 | * The WorkspaceContext is a context for the `workspaceState`. It is used to access the methods 7 | * and data used to implement an `activeWorkspace`. 8 | * 9 | * @example 10 | * ``` ts 11 | * const { subscribe, unsubscribe, getState, setWorkspaceContext } = useContext(WorkspaceContext); 12 | * ``` 13 | */ 14 | const WorkspaceContext = createContext>({ 15 | update: () => undefined, 16 | setWorkspaceContext: () => undefined, 17 | subscribe: () => '', 18 | unsubscribe: () => undefined, 19 | getState: () => ({ 20 | subscribtions: {}, 21 | activeWorkspace: null, 22 | }), 23 | }); 24 | 25 | export default WorkspaceContext; 26 | -------------------------------------------------------------------------------- /packages/lib-utils/src/utils/WorkspaceProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import WorkspaceContext from './WorkspaceContext'; 3 | import { workspaceState } from './workspaceState'; 4 | 5 | /** 6 | * Context for passing through the activeWorkspace. The context provides the active value 7 | * to the `useWorkspace` hook. This context maintains the state of the activeWorkspace while 8 | * providing a publish/subscribe model to refresh the kubernetes watches on a workspace change. 9 | * 10 | * @returns A full context with the activeWorkspace's state and internal update methods 11 | * 12 | * @example 13 | * ``` ts 14 | * 15 | 16 | 17 | * ``` 18 | */ 19 | const WorkspaceProvider: React.FC> = ({ children }) => { 20 | const state = React.useMemo(() => workspaceState(), []); 21 | return {children}; 22 | }; 23 | 24 | export default WorkspaceProvider; 25 | -------------------------------------------------------------------------------- /packages/lib-utils/src/utils/workspaceState.ts: -------------------------------------------------------------------------------- 1 | import { v4 as uuidv4 } from 'uuid'; 2 | 3 | const WORKSPACE_KEY = 'sdk/active-workspace'; 4 | export type WorkspaceContextState = { 5 | activeWorkspace: string | null; 6 | }; 7 | 8 | export enum UpdateEvents { 9 | activeWorkspace = 'activeWorkspace', 10 | } 11 | 12 | export const workspaceState = () => { 13 | let state: WorkspaceContextState = { 14 | activeWorkspace: localStorage.getItem(WORKSPACE_KEY), 15 | }; 16 | 17 | // registry of all subscribers (hooks) 18 | const subscriptions: { 19 | [key in UpdateEvents]: { 20 | [id: string]: () => void; 21 | }; 22 | } = { 23 | activeWorkspace: {}, 24 | }; 25 | 26 | // add subscriber (hook) to registry 27 | function subscribe(event: UpdateEvents, onUpdate: () => void) { 28 | const id = uuidv4(); 29 | // const id = `${Date.now()}${Math.random()}`; 30 | subscriptions[event][id] = onUpdate; 31 | // trigger initial update to get the initial data 32 | onUpdate(); 33 | return id; 34 | } 35 | 36 | // remove subscriber from registry 37 | function unsubscribe(id: string, event: UpdateEvents) { 38 | delete subscriptions[event][id]; 39 | } 40 | 41 | // update state attribute and push data to subscribers 42 | function update(event: UpdateEvents, attributes: Partial) { 43 | state = { 44 | ...state, 45 | ...attributes, 46 | }; 47 | const updateSubscriptions = Object.values(subscriptions[event]); 48 | if (updateSubscriptions.length === 0) { 49 | return; 50 | } 51 | 52 | // update the subscribed clients 53 | updateSubscriptions.forEach((onUpdate) => { 54 | onUpdate(); 55 | }); 56 | } 57 | 58 | function setWorkspaceContext(workspace: string | null) { 59 | update(UpdateEvents.activeWorkspace, { activeWorkspace: workspace }); 60 | } 61 | 62 | function getState() { 63 | return state; 64 | } 65 | 66 | // public state manager interface 67 | return { 68 | getState, 69 | setWorkspaceContext, 70 | subscribe, 71 | unsubscribe, 72 | update, 73 | }; 74 | }; 75 | 76 | export default workspaceState; 77 | -------------------------------------------------------------------------------- /packages/lib-utils/src/web-socket/types.ts: -------------------------------------------------------------------------------- 1 | import type { AnyObject } from '@openshift/dynamic-plugin-sdk'; 2 | 3 | /** 4 | * Configuration that is used to configure WebSockets from a host app perspective. 5 | */ 6 | export type WebSocketAppSettings = { 7 | /** 8 | * The host to which the web socket will connect to. 9 | */ 10 | host: string; 11 | 12 | /** 13 | * The sub protocols that you wish to send along with the web socket connection call. 14 | */ 15 | subProtocols: string[]; 16 | 17 | /** 18 | * An optional function to augment the URL after it's constructed and before it is used by the 19 | * web socket. 20 | * @param url - The fully qualified URL 21 | * @returns - A optionally modified fully qualified URL 22 | */ 23 | urlAugment?: (url: string) => string; 24 | }; 25 | 26 | /** 27 | * The web socket configuration options. 28 | */ 29 | export type WebSocketOptions = { 30 | /** 31 | * The path to the resource you wish to watch. 32 | */ 33 | path: string; 34 | 35 | /** 36 | * Overridable web socket host URL for plugins. Normally set by the application. 37 | */ 38 | host?: string; 39 | 40 | /** 41 | * Overridable web socket sub protocols for plugins. Normally set by the application. 42 | * Note: This is ignored if `host` is not set. 43 | */ 44 | subProtocols?: string[]; 45 | 46 | /** 47 | * Set to true if you want automatic reconnection if it fails to create or when the web socket 48 | * closes. 49 | */ 50 | reconnect?: boolean; 51 | 52 | /** 53 | * Set to true if you wish to get your data back in JSON format when the web socket sends a message. 54 | * Note: If it's not valid JSON, a warning will be logged and you get back the raw message. 55 | */ 56 | jsonParse?: boolean; 57 | 58 | /** 59 | * Set a maximum buffer to hold onto between `bufferFlushInterval`s. Messages that exceed the 60 | * buffer are dropped. 61 | * 62 | * Unit is in number of messages. 63 | */ 64 | bufferMax?: number; 65 | 66 | /** 67 | * Configure a duration between messages being flushed out in events. 68 | * 69 | * Note: If `bufferMax` is not set, this is ignored. 70 | * Defaults to 500ms. 71 | */ 72 | bufferFlushInterval?: number; 73 | 74 | /** 75 | * Set a connection limit for when to give up on the current instance of the web socket. 76 | * 77 | * If omitted, the web socket will continue to try to reconnect only if you set the `reconnect` 78 | * flag. 79 | */ 80 | timeout?: number; 81 | }; 82 | 83 | /** 84 | * The WebSocket data can be returned in an object state or in the raw string response passed. 85 | * 86 | * This is configured through `jsonParse` options flag. 87 | * @see WebSocketOptions 88 | */ 89 | export type MessageDataType = AnyObject | string; 90 | 91 | export type GenericHandler = (data: T) => void; 92 | export type OpenHandler = GenericHandler; 93 | export type CloseHandler = GenericHandler; 94 | export type ErrorHandler = GenericHandler; 95 | export type MessageHandler = GenericHandler; 96 | /** 97 | * Data is provided potentially by .destroy() caller. 98 | */ 99 | export type DestroyHandler = GenericHandler; 100 | export type BulkMessageHandler = GenericHandler; 101 | 102 | export type EventHandlers = { 103 | open: OpenHandler[]; 104 | close: CloseHandler[]; 105 | error: ErrorHandler[]; 106 | message: MessageHandler[]; 107 | destroy: DestroyHandler[]; 108 | bulkMessage: BulkMessageHandler[]; 109 | }; 110 | 111 | export type EventHandlerTypes = keyof EventHandlers; 112 | -------------------------------------------------------------------------------- /packages/lib-utils/src/web-socket/utils.ts: -------------------------------------------------------------------------------- 1 | import { getUtilsConfig } from '../config'; 2 | import type { WebSocketOptions } from './types'; 3 | 4 | export const applyConfigHost = async ( 5 | options: WebSocketOptions & { wsPrefix?: string; pathPrefix?: string }, 6 | ): Promise => { 7 | return options.host ?? (await getUtilsConfig().wsAppSettings(options)).host; 8 | }; 9 | 10 | export const createURL = async (options: WebSocketOptions): Promise => { 11 | let url; 12 | 13 | const host = await applyConfigHost(options); 14 | 15 | if (host === 'auto') { 16 | if (window.location.protocol === 'https:') { 17 | url = 'wss://'; 18 | } else { 19 | url = 'ws://'; 20 | } 21 | url += window.location.host; 22 | } else { 23 | url = host; 24 | } 25 | 26 | if (options.path) { 27 | url += options.path; 28 | } 29 | 30 | const { urlAugment } = await getUtilsConfig().wsAppSettings(options); 31 | 32 | return urlAugment ? urlAugment(url) : url; 33 | }; 34 | 35 | export const applyConfigSubProtocols = async ( 36 | options: WebSocketOptions & { wsPrefix?: string; pathPrefix?: string }, 37 | ): Promise => { 38 | return ( 39 | (options.host ? options.subProtocols : undefined) ?? 40 | (await getUtilsConfig().wsAppSettings(options)).subProtocols 41 | ); 42 | }; 43 | -------------------------------------------------------------------------------- /packages/lib-utils/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@monorepo/common/tsconfig-bases/lib-react-esm.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declarationDir": "types" 6 | }, 7 | "include": ["src"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/lib-webpack/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@monorepo/eslint-plugin-internal/node-typescript-prettier'], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/lib-webpack/CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog for `@openshift/dynamic-plugin-sdk-webpack` 2 | 3 | ## 4.1.0 - 2024-04-26 4 | 5 | - Allow overriding core webpack module federation plugins used by DynamicRemotePlugin ([#259]) 6 | 7 | ## 4.0.2 - 2024-03-05 8 | 9 | - Exclude non-user-facing chunk files like `[id].[fullhash].hot-update.js` from processing ([#256]) 10 | 11 | ## 4.0.1 - 2023-11-27 12 | 13 | - Fix bug in `DynamicRemotePlugin` where the plugin manifest may refer to incorrect entry script ([#250]) 14 | 15 | ## 4.0.0 - 2023-11-03 16 | 17 | > Any custom properties in the plugin manifest should be set via the `customProperties` object. 18 | > Use the `DynamicRemotePlugin` option `transformPluginManifest` to add such custom properties 19 | > to the generated plugin manifest. 20 | 21 | - Add `transformPluginManifest` option to `DynamicRemotePlugin` ([#236]) 22 | - Rename `sharedScope` to `sharedScopeName` in `moduleFederationSettings` ([#236]) 23 | - Validate values of plugin metadata `dependencies` object as semver ranges ([#239]) 24 | - Disallow empty strings as values of plugin metadata `dependencies` object ([#240]) 25 | - Fix bug in `DynamicRemotePlugin` where `buildHash` in plugin manifest is not generated properly ([#227]) 26 | 27 | ## 3.0.1 - 2023-04-13 28 | 29 | - Fix bug in `DynamicRemotePlugin` that occurs with webpack `createChildCompiler` usage ([#213]) 30 | - Support CommonJS build output and improve generated Lodash imports ([#215]) 31 | 32 | ## 3.0.0 - 2023-03-02 33 | 34 | - Add base URL for plugin assets to plugin manifest ([#206]) 35 | - Make `DynamicRemotePlugin` options `pluginMetadata` and `extensions` mandatory ([#207]) 36 | - Replace `DynamicRemotePlugin` option `moduleFederationLibraryType` with `moduleFederationSettings` ([#199]) 37 | - Allow building plugins which do not provide any exposed modules ([#199]) 38 | 39 | ## 2.0.0 - 2023-01-23 40 | 41 | - Support building plugins using webpack library type other than `jsonp` ([#182]) 42 | - Emit error when a separate runtime chunk is used with `jsonp` library type ([#182]) 43 | - Allow customizing the filename of entry script and plugin manifest ([#182]) 44 | - Ensure that all APIs referenced through the package index are exported ([#184]) 45 | 46 | ## 1.0.0 - 2022-10-27 47 | 48 | > Initial release. 49 | 50 | [#182]: https://github.com/openshift/dynamic-plugin-sdk/pull/182 51 | [#184]: https://github.com/openshift/dynamic-plugin-sdk/pull/184 52 | [#199]: https://github.com/openshift/dynamic-plugin-sdk/pull/199 53 | [#206]: https://github.com/openshift/dynamic-plugin-sdk/pull/206 54 | [#207]: https://github.com/openshift/dynamic-plugin-sdk/pull/207 55 | [#213]: https://github.com/openshift/dynamic-plugin-sdk/pull/213 56 | [#215]: https://github.com/openshift/dynamic-plugin-sdk/pull/215 57 | [#227]: https://github.com/openshift/dynamic-plugin-sdk/pull/227 58 | [#236]: https://github.com/openshift/dynamic-plugin-sdk/pull/236 59 | [#239]: https://github.com/openshift/dynamic-plugin-sdk/pull/239 60 | [#240]: https://github.com/openshift/dynamic-plugin-sdk/pull/240 61 | [#250]: https://github.com/openshift/dynamic-plugin-sdk/pull/250 62 | [#256]: https://github.com/openshift/dynamic-plugin-sdk/pull/256 63 | [#259]: https://github.com/openshift/dynamic-plugin-sdk/pull/259 64 | -------------------------------------------------------------------------------- /packages/lib-webpack/README.md: -------------------------------------------------------------------------------- 1 | # `@openshift/dynamic-plugin-sdk-webpack` 2 | 3 | > Allows building dynamic plugin assets with webpack. 4 | -------------------------------------------------------------------------------- /packages/lib-webpack/api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../../api-extractor.json", 3 | "mainEntryPointFilePath": "dist/types/lib-webpack/src/index.d.ts", 4 | "bundledPackages": ["@monorepo/common", "@openshift/dynamic-plugin-sdk"], 5 | "compiler": { "tsconfigFilePath": "tsconfig.api-extractor.json" }, 6 | "apiReport": { "reportFileName": "lib-webpack.api.md" }, 7 | "docModel": { "apiJsonFilePath": "dist/api/lib-webpack.api.json" } 8 | } 9 | -------------------------------------------------------------------------------- /packages/lib-webpack/jest.config.ts: -------------------------------------------------------------------------------- 1 | import nodeConfig from '@monorepo/common/jest/jest-config-node'; 2 | import type { InitialOptionsTsJest } from 'ts-jest'; 3 | 4 | const config: InitialOptionsTsJest = { 5 | ...nodeConfig, 6 | displayName: 'lib-webpack', 7 | }; 8 | 9 | export default config; 10 | -------------------------------------------------------------------------------- /packages/lib-webpack/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@openshift/dynamic-plugin-sdk-webpack", 3 | "version": "4.1.0", 4 | "description": "Allows building dynamic plugin assets with webpack", 5 | "license": "Apache-2.0", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/openshift/dynamic-plugin-sdk.git", 9 | "directory": "packages/lib-webpack" 10 | }, 11 | "files": [ 12 | "dist/index.cjs.js", 13 | "dist/index.d.ts", 14 | "dist/build-metadata.json", 15 | "CHANGELOG.md" 16 | ], 17 | "main": "dist/index.cjs.js", 18 | "types": "dist/index.d.ts", 19 | "scripts": { 20 | "prepack": "yarn build", 21 | "prepublishOnly": "yarn test", 22 | "build": "rm -rf dist && yarn run -T rollup -c && yarn api-extractor", 23 | "lint": "yarn run -T eslint $INIT_CWD", 24 | "test": "yarn run -T test $INIT_CWD", 25 | "api-extractor": "yarn run -T api-extractor -c $INIT_CWD/api-extractor.json" 26 | }, 27 | "peerDependencies": { 28 | "webpack": "^5.75.0" 29 | }, 30 | "dependencies": { 31 | "lodash": "^4.17.21", 32 | "semver": "^7.3.7", 33 | "yup": "^0.32.11" 34 | }, 35 | "engines": { 36 | "node": ">=16" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /packages/lib-webpack/rollup.config.js: -------------------------------------------------------------------------------- 1 | import { tsBuildConfig } from '../common/rollup/rollup-configs'; 2 | import pkg from './package.json'; 3 | 4 | export default tsBuildConfig({ pkg, format: 'cjs' }); 5 | -------------------------------------------------------------------------------- /packages/lib-webpack/src/index.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * webpack tools for building dynamic plugin assets. 3 | * 4 | * @remarks 5 | * This package allows building dynamic plugins with webpack based on the concept 6 | * of {@link https://webpack.js.org/concepts/module-federation/ | module federation}. 7 | * 8 | * @packageDocumentation 9 | */ 10 | 11 | export { AnyObject, ReplaceProperties } from '@monorepo/common'; 12 | 13 | export { 14 | CodeRef, 15 | EncodedCodeRef, 16 | Extension, 17 | ExtensionFlags, 18 | EncodedExtension, 19 | MapCodeRefsToEncodedCodeRefs, 20 | ExtractExtensionProperties, 21 | PluginRegistrationMethod, 22 | PluginRuntimeMetadata, 23 | PluginManifest, 24 | } from '@openshift/dynamic-plugin-sdk/src/shared-webpack'; 25 | 26 | export { 27 | DynamicRemotePlugin, 28 | DynamicRemotePluginOptions, 29 | PluginModuleFederationSettings, 30 | PluginEntryCallbackSettings, 31 | } from './webpack/DynamicRemotePlugin'; 32 | 33 | export { PluginBuildMetadata } from './types/plugin'; 34 | export { WebpackSharedConfig, WebpackSharedObject } from './types/webpack'; 35 | -------------------------------------------------------------------------------- /packages/lib-webpack/src/types/plugin.ts: -------------------------------------------------------------------------------- 1 | import type { PluginRuntimeMetadata } from '@openshift/dynamic-plugin-sdk/src/shared-webpack'; 2 | 3 | export type PluginBuildMetadata = PluginRuntimeMetadata & { 4 | exposedModules?: { [moduleName: string]: string }; 5 | }; 6 | -------------------------------------------------------------------------------- /packages/lib-webpack/src/types/webpack.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * webpack `SharedConfig` type. 3 | * 4 | * Advanced configuration for modules that should be shared in the share scope. 5 | * 6 | * @see https://webpack.js.org/plugins/module-federation-plugin/#sharing-hints 7 | */ 8 | export type WebpackSharedConfig = { 9 | eager?: boolean; 10 | import?: string | false; 11 | packageName?: string; 12 | requiredVersion?: string | false; 13 | shareKey?: string; 14 | shareScope?: string; 15 | singleton?: boolean; 16 | strictVersion?: boolean; 17 | version?: string | false; 18 | }; 19 | 20 | /** 21 | * webpack `SharedObject` type. 22 | * 23 | * Modules that should be shared in the share scope. 24 | */ 25 | export type WebpackSharedObject = { 26 | [index: string]: string | WebpackSharedConfig; 27 | }; 28 | -------------------------------------------------------------------------------- /packages/lib-webpack/src/utils/plugin-chunks.ts: -------------------------------------------------------------------------------- 1 | import { Compilation, Chunk, AssetInfo } from 'webpack'; 2 | 3 | export const findPluginChunks = ( 4 | containerName: string, 5 | compilation: Compilation, 6 | ): { 7 | entryChunk: Chunk; 8 | runtimeChunk?: Chunk; 9 | } => { 10 | const allChunks = Array.from(compilation.chunks); 11 | 12 | const entryChunk = allChunks.find((chunk) => chunk.name === containerName); 13 | 14 | if (!entryChunk) { 15 | throw new Error(`Cannot find entry chunk ${containerName}`); 16 | } 17 | 18 | if (entryChunk.hasRuntime()) { 19 | return { entryChunk }; 20 | } 21 | 22 | const runtimeChunk = allChunks.find((chunk) => chunk.name === entryChunk.runtime); 23 | 24 | if (!runtimeChunk) { 25 | throw new Error(`Cannot find runtime chunk for entry chunk ${containerName}`); 26 | } 27 | 28 | return { entryChunk, runtimeChunk }; 29 | }; 30 | 31 | export const getChunkFiles = ( 32 | chunk: Chunk, 33 | compilation: Compilation, 34 | includeFile = (assetInfo: AssetInfo) => !assetInfo.development && !assetInfo.hotModuleReplacement, 35 | ) => 36 | Array.from(chunk.files).filter((fileName) => { 37 | const assetInfo = compilation.assetsInfo.get(fileName); 38 | 39 | if (!assetInfo) { 40 | throw new Error(`Missing asset information for ${fileName}`); 41 | } 42 | 43 | return includeFile(assetInfo); 44 | }); 45 | -------------------------------------------------------------------------------- /packages/lib-webpack/src/webpack/GenerateManifestPlugin.ts: -------------------------------------------------------------------------------- 1 | import type { PluginManifest } from '@openshift/dynamic-plugin-sdk/src/shared-webpack'; 2 | import type { WebpackPluginInstance, Compiler } from 'webpack'; 3 | import { Compilation, sources, WebpackError } from 'webpack'; 4 | import { findPluginChunks, getChunkFiles } from '../utils/plugin-chunks'; 5 | 6 | type InputManifestData = Omit; 7 | 8 | type GenerateManifestPluginOptions = { 9 | containerName: string; 10 | manifestFilename: string; 11 | manifestData: InputManifestData; 12 | transformManifest: (manifest: PluginManifest) => PluginManifest; 13 | }; 14 | 15 | export class GenerateManifestPlugin implements WebpackPluginInstance { 16 | constructor(private readonly options: GenerateManifestPluginOptions) {} 17 | 18 | apply(compiler: Compiler) { 19 | const { containerName, manifestFilename, manifestData, transformManifest } = this.options; 20 | const { publicPath } = compiler.options.output; 21 | 22 | if (!publicPath) { 23 | throw new Error( 24 | 'output.publicPath option must be set to ensure plugin assets are loaded properly in the browser', 25 | ); 26 | } 27 | 28 | compiler.hooks.thisCompilation.tap(GenerateManifestPlugin.name, (compilation) => { 29 | compilation.hooks.processAssets.tap( 30 | { 31 | name: GenerateManifestPlugin.name, 32 | // Using one of the later asset processing stages to ensure all assets 33 | // are already added and optimized within the given webpack compilation 34 | stage: Compilation.PROCESS_ASSETS_STAGE_ANALYSE, 35 | }, 36 | () => { 37 | const { entryChunk, runtimeChunk } = findPluginChunks(containerName, compilation); 38 | const pluginChunks = runtimeChunk ? [runtimeChunk, entryChunk] : [entryChunk]; 39 | 40 | const loadScripts = pluginChunks.reduce( 41 | (acc, chunk) => [...acc, ...getChunkFiles(chunk, compilation)], 42 | [], 43 | ); 44 | 45 | const manifest = transformManifest({ 46 | ...manifestData, 47 | baseURL: compilation.getAssetPath(publicPath, {}), 48 | loadScripts, 49 | buildHash: compilation.fullHash, 50 | }); 51 | 52 | const manifestContent = JSON.stringify( 53 | manifest, 54 | null, 55 | compiler.options.mode === 'production' ? undefined : 2, 56 | ); 57 | 58 | compilation.emitAsset( 59 | manifestFilename, 60 | new sources.RawSource(Buffer.from(manifestContent)), 61 | ); 62 | 63 | const warnings: string[] = []; 64 | 65 | if (manifest.extensions.length === 0) { 66 | warnings.push('Plugin has no extensions'); 67 | } 68 | 69 | if (!manifest.baseURL.endsWith('/')) { 70 | warnings.push('Plugin base URL (output.publicPath) should have a trailing slash'); 71 | } 72 | 73 | warnings.forEach((message) => { 74 | const error = new WebpackError(message); 75 | error.file = manifestFilename; 76 | compilation.warnings.push(error); 77 | }); 78 | }, 79 | ); 80 | }); 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /packages/lib-webpack/src/webpack/PatchEntryCallbackPlugin.ts: -------------------------------------------------------------------------------- 1 | import { WebpackPluginInstance, Compiler, Compilation, sources, WebpackError } from 'webpack'; 2 | import { findPluginChunks, getChunkFiles } from '../utils/plugin-chunks'; 3 | 4 | type PatchEntryCallbackPluginOptions = { 5 | containerName: string; 6 | callbackName: string; 7 | pluginID: string; 8 | }; 9 | 10 | export class PatchEntryCallbackPlugin implements WebpackPluginInstance { 11 | constructor(private readonly options: PatchEntryCallbackPluginOptions) {} 12 | 13 | apply(compiler: Compiler) { 14 | const { containerName, callbackName, pluginID } = this.options; 15 | 16 | compiler.hooks.thisCompilation.tap(PatchEntryCallbackPlugin.name, (compilation) => { 17 | compilation.hooks.processAssets.tap( 18 | { 19 | name: PatchEntryCallbackPlugin.name, 20 | stage: Compilation.PROCESS_ASSETS_STAGE_OPTIMIZE, 21 | }, 22 | () => { 23 | const { entryChunk } = findPluginChunks(containerName, compilation); 24 | 25 | getChunkFiles(entryChunk, compilation).forEach((fileName) => { 26 | compilation.updateAsset(fileName, (source) => { 27 | const newSource = new sources.ReplaceSource(source); 28 | const fromIndex = source.source().toString().indexOf(`${callbackName}(`); 29 | 30 | if (fromIndex >= 0) { 31 | newSource.insert(fromIndex + callbackName.length + 1, `'${pluginID}', `); 32 | } else { 33 | const error = new WebpackError(`Missing call to ${callbackName}`); 34 | error.file = fileName; 35 | error.chunk = entryChunk; 36 | compilation.errors.push(error); 37 | } 38 | 39 | return newSource; 40 | }); 41 | }); 42 | }, 43 | ); 44 | }); 45 | } 46 | } 47 | -------------------------------------------------------------------------------- /packages/lib-webpack/src/webpack/ValidateCompilationPlugin.ts: -------------------------------------------------------------------------------- 1 | import { WebpackPluginInstance, Compiler, WebpackError } from 'webpack'; 2 | import { findPluginChunks } from '../utils/plugin-chunks'; 3 | 4 | type ValidateCompilationPluginOptions = { 5 | containerName: string; 6 | jsonpLibraryType: boolean; 7 | }; 8 | 9 | export class ValidateCompilationPlugin implements WebpackPluginInstance { 10 | constructor(private readonly options: ValidateCompilationPluginOptions) {} 11 | 12 | apply(compiler: Compiler) { 13 | const { containerName, jsonpLibraryType } = this.options; 14 | 15 | compiler.hooks.done.tap(ValidateCompilationPlugin.name, ({ compilation }) => { 16 | const { runtimeChunk } = findPluginChunks(containerName, compilation); 17 | 18 | if (runtimeChunk) { 19 | const errorMessage = jsonpLibraryType 20 | ? 'Detected separate runtime chunk while using jsonp library type.\n' + 21 | 'This configuration is not allowed since it will cause issues when reloading plugins at runtime.\n' + 22 | 'Please update your webpack configuration to avoid emitting a separate runtime chunk.' 23 | : 'Detected separate runtime chunk while using non-jsonp library type.\n' + 24 | 'This configuration is not recommended since it may cause issues when reloading plugins at runtime.\n' + 25 | 'Consider updating your webpack configuration to avoid emitting a separate runtime chunk.'; 26 | 27 | const error = new WebpackError(errorMessage); 28 | error.chunk = runtimeChunk; 29 | (jsonpLibraryType ? compilation.errors : compilation.warnings).push(error); 30 | } 31 | }); 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /packages/lib-webpack/src/yup-schemas.ts: -------------------------------------------------------------------------------- 1 | import * as yup from 'yup'; 2 | import { 3 | extensionArraySchema, 4 | pluginRuntimeMetadataSchema, 5 | } from '@openshift/dynamic-plugin-sdk/src/shared-webpack'; 6 | 7 | /** 8 | * Schema for `PluginBuildMetadata` objects. 9 | */ 10 | export const pluginBuildMetadataSchema = pluginRuntimeMetadataSchema.shape({ 11 | // TODO(vojtech): Yup lacks native support for map-like structures with arbitrary keys 12 | exposedModules: yup.object(), 13 | }); 14 | 15 | /** 16 | * Schema for `PluginModuleFederationSettings` objects. 17 | */ 18 | const pluginModuleFederationSettingsSchema = yup.object().required().shape({ 19 | libraryType: yup.string(), 20 | sharedScopeName: yup.string(), 21 | }); 22 | 23 | /** 24 | * Schema for `PluginEntryCallbackSettings` objects. 25 | */ 26 | const pluginEntryCallbackSettingsSchema = yup.object().required().shape({ 27 | name: yup.string(), 28 | pluginID: yup.string(), 29 | }); 30 | 31 | /** 32 | * Schema for adapted `DynamicRemotePluginOptions` objects. 33 | */ 34 | export const dynamicRemotePluginAdaptedOptionsSchema = yup.object().required().shape({ 35 | pluginMetadata: pluginBuildMetadataSchema, 36 | extensions: extensionArraySchema, 37 | sharedModules: yup.object().required(), 38 | moduleFederationSettings: pluginModuleFederationSettingsSchema, 39 | entryCallbackSettings: pluginEntryCallbackSettingsSchema, 40 | entryScriptFilename: yup.string().required(), 41 | pluginManifestFilename: yup.string().required(), 42 | }); 43 | -------------------------------------------------------------------------------- /packages/lib-webpack/tsconfig.api-extractor.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.json", 3 | "compilerOptions": { 4 | "paths": { 5 | "@monorepo/common": ["dist/types/common/src/index.d.ts"], 6 | "@openshift/dynamic-plugin-sdk/src/shared-webpack": [ 7 | "dist/types/lib-core/src/shared-webpack.d.ts" 8 | ] 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /packages/lib-webpack/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@monorepo/common/tsconfig-bases/lib-node-cjs.json", 3 | "compilerOptions": { 4 | "outDir": "dist", 5 | "declarationDir": "types" 6 | }, 7 | "include": ["src", "../common/src", "../lib-core/src"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/sample-app/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@monorepo/eslint-plugin-internal/react-typescript-prettier'], 4 | overrides: [ 5 | { 6 | files: ['*.cy.ts', '*.cy.tsx'], 7 | rules: { 8 | // Suppress false positives due to https://docs.cypress.io/api/commands/then 9 | 'promise/always-return': 'off', 10 | 'promise/catch-or-return': 'off', 11 | }, 12 | }, 13 | ], 14 | }; 15 | -------------------------------------------------------------------------------- /packages/sample-app/cypress.config.ts: -------------------------------------------------------------------------------- 1 | import { defineConfig } from 'cypress'; 2 | import webpackConfig from './webpack.config'; 3 | 4 | export default defineConfig({ 5 | screenshotsFolder: '../../screenshots', 6 | video: false, 7 | 8 | component: { 9 | devServer: { 10 | framework: 'react', 11 | bundler: 'webpack', 12 | webpackConfig, 13 | }, 14 | specPattern: 'src/components/**/*.cy.{js,jsx,ts,tsx}', 15 | indexHtmlFile: 'src/cypress/component-index.html', 16 | supportFile: 'src/cypress/component-setup.tsx', 17 | }, 18 | 19 | e2e: { 20 | baseUrl: 'http://localhost:9000/', 21 | specPattern: 'src/e2e/**/*.cy.{js,jsx,ts,tsx}', 22 | supportFile: false, 23 | }, 24 | }); 25 | -------------------------------------------------------------------------------- /packages/sample-app/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monorepo/sample-app", 3 | "version": "0.0.0-fixed", 4 | "description": "Sample plugin host application project", 5 | "private": true, 6 | "scripts": { 7 | "clean": "rm -rf dist", 8 | "build": "yarn clean && yarn webpack", 9 | "build-prod": "yarn clean && NODE_ENV=production yarn webpack", 10 | "analyze": "yarn clean && NODE_ENV=production ANALYZE_BUNDLES=true yarn webpack", 11 | "lint": "yarn run -T eslint $INIT_CWD", 12 | "webpack": "node -r ts-node/register ./node_modules/.bin/webpack", 13 | "http-server": "http-server dist -p 9000 -c-1" 14 | }, 15 | "devDependencies": { 16 | "@openshift/dynamic-plugin-sdk": "portal:../lib-core", 17 | "@openshift/dynamic-plugin-sdk-extensions": "portal:../lib-extensions", 18 | "@openshift/dynamic-plugin-sdk-webpack": "portal:../lib-webpack", 19 | "@patternfly/react-core": "^4.202.16", 20 | "@patternfly/react-icons": "^4.53.16", 21 | "@patternfly/react-styles": "^4.52.16", 22 | "@patternfly/react-table": "^4.71.16", 23 | "@patternfly/react-tokens": "^4.58.5", 24 | "@types/webpack-bundle-analyzer": "~4.6.0", 25 | "copy-webpack-plugin": "^10.2.4", 26 | "css-loader": "^6.7.1", 27 | "css-minimizer-webpack-plugin": "^3.4.1", 28 | "cypress": "^12.17.3", 29 | "html-webpack-plugin": "^5.5.0", 30 | "http-server": "^14.1.0", 31 | "lodash": "^4.17.21", 32 | "mini-css-extract-plugin": "^2.6.0", 33 | "react": "^17.0.2", 34 | "react-dom": "^17.0.2", 35 | "style-loader": "^3.3.1", 36 | "ts-loader": "^9.2.8", 37 | "typescript": "~4.4.4", 38 | "webpack": "^5.75.0", 39 | "webpack-bundle-analyzer": "~4.6.0", 40 | "webpack-cli": "^5.0.1" 41 | }, 42 | "installConfig": { 43 | "hoistingLimits": "dependencies" 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /packages/sample-app/src/app-index.html.ejs: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | <%= htmlWebpackPlugin.options.title %> 7 | 8 | 9 | 10 | 11 |

12 | 13 | 14 | -------------------------------------------------------------------------------- /packages/sample-app/src/app-styles.ts: -------------------------------------------------------------------------------- 1 | import '@patternfly/react-core/dist/styles/base.css'; 2 | import './app.css'; 3 | -------------------------------------------------------------------------------- /packages/sample-app/src/app.css: -------------------------------------------------------------------------------- 1 | html, body, #root { 2 | height: 100%; 3 | } 4 | -------------------------------------------------------------------------------- /packages/sample-app/src/app.tsx: -------------------------------------------------------------------------------- 1 | import { PluginStore, PluginStoreProvider } from '@openshift/dynamic-plugin-sdk'; 2 | import * as React from 'react'; 3 | import { render } from 'react-dom'; 4 | import './app-styles'; 5 | import ErrorBoundary from './components/ErrorBoundary'; 6 | import Loading from './components/Loading'; 7 | import PageContent from './components/PageContent'; 8 | import PageHeader from './components/PageHeader'; 9 | import PageLayout from './components/PageLayout'; 10 | import { initSharedScope, getSharedScope } from './shared-scope'; 11 | 12 | const appContainer = document.getElementById('root'); 13 | 14 | render(, appContainer); 15 | 16 | // eslint-disable-next-line promise/catch-or-return, promise/always-return 17 | initSharedScope().then(() => { 18 | const pluginStore = new PluginStore({ 19 | loaderOptions: { 20 | sharedScope: getSharedScope(), 21 | fixedPluginDependencyResolutions: { 'sample-app': '1.0.0' }, 22 | }, 23 | }); 24 | 25 | pluginStore.setFeatureFlags({ TELEMETRY_FLAG: true }); 26 | 27 | // eslint-disable-next-line no-console 28 | console.info(`Using plugin SDK runtime version ${pluginStore.sdkVersion}`); 29 | 30 | render( 31 | 32 | 33 | }> 34 | 35 | 36 | 37 | , 38 | appContainer, 39 | ); 40 | }); 41 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/ErrorBoundary.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import type { AnyObject } from '../types'; 3 | import ErrorBoundaryFallback from './ErrorBoundaryFallback'; 4 | 5 | export type ErrorBoundaryFallbackProps = { 6 | error: Error; 7 | errorInfo: React.ErrorInfo; 8 | }; 9 | 10 | type ErrorBoundaryProps = React.PropsWithChildren; 11 | 12 | type ErrorBoundaryState = 13 | | { 14 | hasError: false; 15 | } 16 | | ({ 17 | hasError: true; 18 | } & ErrorBoundaryFallbackProps); 19 | 20 | /** 21 | * @see https://reactjs.org/docs/error-boundaries.html 22 | */ 23 | class ErrorBoundary extends React.Component { 24 | constructor(props: ErrorBoundaryProps) { 25 | super(props); 26 | this.state = { hasError: false }; 27 | } 28 | 29 | override componentDidCatch(error: Error, errorInfo: React.ErrorInfo) { 30 | this.setState({ hasError: true, error, errorInfo }); 31 | 32 | // eslint-disable-next-line no-console 33 | console.error('Error in a child component', error); 34 | } 35 | 36 | override render() { 37 | const { hasError } = this.state; 38 | const { children } = this.props; 39 | 40 | if (!hasError) { 41 | return children; 42 | } 43 | 44 | const { error, errorInfo } = this.state; 45 | return ; 46 | } 47 | } 48 | 49 | export default ErrorBoundary; 50 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/ErrorBoundaryFallback.css: -------------------------------------------------------------------------------- 1 | .app-error-boundary-fallback { 2 | padding: var(--pf-global--spacer--md); 3 | } 4 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/ErrorBoundaryFallback.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Flex, 3 | FlexItem, 4 | Text, 5 | TextContent, 6 | CodeBlock, 7 | CodeBlockCode, 8 | } from '@patternfly/react-core'; 9 | import * as React from 'react'; 10 | import type { ErrorBoundaryFallbackProps } from './ErrorBoundary'; 11 | 12 | import './ErrorBoundaryFallback.css'; 13 | 14 | const trimEmptyLines = (text: string) => text.replace(/^\s*\n/gm, ''); 15 | 16 | const ErrorBoundaryFallback: React.FC = ({ error, errorInfo }) => ( 17 | 18 | 19 | 20 | Oh no! Something went wrong. 21 | {error.name} 22 | Error message: {error.message ?? '(empty)'} 23 | 24 | 25 | 26 | 27 | Component trace 28 | 29 | 30 | {trimEmptyLines(errorInfo.componentStack)} 31 | 32 | 33 | 34 | 35 | Stack trace 36 | 37 | 38 | {trimEmptyLines(error.stack ?? '(empty)')} 39 | 40 | 41 | 42 | ); 43 | 44 | export default ErrorBoundaryFallback; 45 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/FeatureFlagTable.tsx: -------------------------------------------------------------------------------- 1 | import { useFeatureFlag } from '@openshift/dynamic-plugin-sdk'; 2 | import { Button } from '@patternfly/react-core'; 3 | import { TableComposable, Thead, Tbody, Tr, Th, Td } from '@patternfly/react-table'; 4 | import * as React from 'react'; 5 | 6 | const columnNames = { 7 | name: 'Feature flags', 8 | action: 'Action', 9 | }; 10 | 11 | const FeatureFlagTable: React.FC = () => { 12 | const flagName = 'TELEMETRY_FLAG'; 13 | const [flag, setFlag] = useFeatureFlag(flagName); 14 | 15 | const toggleFlag = React.useCallback(() => { 16 | setFlag(!flag); 17 | }, [flag, setFlag]); 18 | 19 | return ( 20 | 21 | 22 | 23 | {columnNames.name} 24 | {columnNames.action} 25 | 26 | 27 | 28 | 29 | {flagName} 30 | 31 | 34 | 35 | 36 | 37 | 38 | ); 39 | }; 40 | 41 | export default FeatureFlagTable; 42 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/LabelWithTooltipIcon.tsx: -------------------------------------------------------------------------------- 1 | import { Flex, FlexItem, Tooltip } from '@patternfly/react-core'; 2 | import { InfoCircleIcon } from '@patternfly/react-icons'; 3 | // eslint-disable-next-line camelcase 4 | import { global_info_color_100 } from '@patternfly/react-tokens'; 5 | import * as React from 'react'; 6 | 7 | type LabelWithTooltipIconProps = { 8 | label: React.ReactNode; 9 | tooltipContent?: React.ReactNode; 10 | }; 11 | 12 | const LabelWithTooltipIcon: React.FC = ({ label, tooltipContent }) => { 13 | if (!tooltipContent) { 14 | return <>{label}; 15 | } 16 | 17 | return ( 18 | 19 | {label} 20 | 21 | 22 | 23 | 24 | 25 | 26 | ); 27 | }; 28 | 29 | export default LabelWithTooltipIcon; 30 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/LoadPluginModal.tsx: -------------------------------------------------------------------------------- 1 | import { usePluginStore } from '@openshift/dynamic-plugin-sdk'; 2 | import { Button, Checkbox, Form, FormGroup, Modal, TextInput } from '@patternfly/react-core'; 3 | import * as React from 'react'; 4 | import { isValidURL } from '../utils'; 5 | 6 | type LoadPluginModalProps = { 7 | defaultManifestURL?: string; 8 | }; 9 | 10 | export type LoadPluginModalRefProps = { 11 | open: VoidFunction; 12 | }; 13 | 14 | const LoadPluginModal = React.forwardRef( 15 | ({ defaultManifestURL = 'http://localhost:9001/plugin-manifest.json' }, ref) => { 16 | const [isModalOpen, setModalOpen] = React.useState(false); 17 | 18 | const [manifestURL, setManifestURL] = React.useState(defaultManifestURL); 19 | const [manifestURLValid, setManifestURLValid] = React.useState(isValidURL(defaultManifestURL)); 20 | const [forceReload, setForceReload] = React.useState(false); 21 | 22 | const pluginStore = usePluginStore(); 23 | 24 | const onManifestURLChange = React.useCallback( 25 | (value: string) => { 26 | setManifestURL(value); 27 | setManifestURLValid(isValidURL(value)); 28 | }, 29 | [setManifestURL, setManifestURLValid], 30 | ); 31 | 32 | const onForceReloadChange = React.useCallback( 33 | (value: boolean) => { 34 | setForceReload(value); 35 | }, 36 | [setForceReload], 37 | ); 38 | 39 | const closeModal = React.useCallback(() => { 40 | setModalOpen(false); 41 | }, [setModalOpen]); 42 | 43 | const loadPlugin = React.useCallback(() => { 44 | pluginStore.loadPlugin(manifestURL, forceReload); 45 | closeModal(); 46 | }, [pluginStore, manifestURL, forceReload, closeModal]); 47 | 48 | React.useImperativeHandle( 49 | ref, 50 | () => ({ 51 | open: () => { 52 | setModalOpen(true); 53 | }, 54 | }), 55 | [setModalOpen], 56 | ); 57 | 58 | return ( 59 | 74 | Load 75 | , 76 | , 79 | ]} 80 | > 81 |
82 | 89 | 98 | 99 | 100 | 107 | 108 |
109 |
110 | ); 111 | }, 112 | ); 113 | 114 | export default LoadPluginModal; 115 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/Loading.tsx: -------------------------------------------------------------------------------- 1 | import { Bullseye, Spinner } from '@patternfly/react-core'; 2 | import * as React from 'react'; 3 | 4 | const Loading: React.FC = () => ( 5 | 6 | 7 | 8 | ); 9 | 10 | export default Loading; 11 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/PageContent.cy.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mockPluginManifest, mockPluginEntryModule } from '../test-mocks'; 3 | import { RenderExtensions } from './PageContent'; 4 | 5 | describe('RenderExtensions', () => { 6 | beforeEach(() => { 7 | cy.mount(); 8 | }); 9 | 10 | it('Invokes all telemetry listener functions', () => { 11 | cy.getPluginStore().then((pluginStore) => { 12 | const manifest = mockPluginManifest({ 13 | name: 'test', 14 | extensions: [ 15 | { 16 | type: 'core.telemetry/listener', 17 | properties: { 18 | listener: { $codeRef: 'FooModule' }, 19 | }, 20 | }, 21 | { 22 | type: 'core.telemetry/listener', 23 | properties: { 24 | listener: { $codeRef: 'BarModule.fizz' }, 25 | }, 26 | }, 27 | ], 28 | }); 29 | 30 | const entryModule = mockPluginEntryModule({ 31 | FooModule: { default: cy.spy().as('fooListener') }, 32 | BarModule: { fizz: cy.spy().as('barListener') }, 33 | }); 34 | 35 | pluginStore.addLoadedPlugin(manifest, entryModule); 36 | pluginStore.enablePlugins(['test']); 37 | }); 38 | 39 | cy.get('[data-ouia-component-type="PF4/Card"]') 40 | .should('have.length', 2) 41 | .each((element) => { 42 | cy.wrap(element).should('contain.text', 'core.telemetry/listener'); 43 | }); 44 | 45 | cy.get('@fooListener').should('be.calledWith', 'TestEvent'); 46 | cy.get('@barListener').should('be.calledWith', 'TestEvent'); 47 | }); 48 | }); 49 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/PageContent.tsx: -------------------------------------------------------------------------------- 1 | import { useExtensions, useResolvedExtensions } from '@openshift/dynamic-plugin-sdk'; 2 | import type { LoadedExtension } from '@openshift/dynamic-plugin-sdk'; 3 | import { isModelFeatureFlag, isTelemetryListener } from '@openshift/dynamic-plugin-sdk-extensions'; 4 | import { Card, CardBody, Flex, FlexItem, Gallery, GalleryItem } from '@patternfly/react-core'; 5 | import { truncate } from 'lodash'; 6 | import * as React from 'react'; 7 | import FeatureFlagTable from './FeatureFlagTable'; 8 | import LabelWithTooltipIcon from './LabelWithTooltipIcon'; 9 | import PluginInfoTable from './PluginInfoTable'; 10 | 11 | type ExtensionGalleryProps = { 12 | extensions: LoadedExtension[]; 13 | }; 14 | 15 | const ExtensionGallery: React.FC = ({ extensions }) => ( 16 | 17 | {extensions.map((e) => ( 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | ))} 26 | 27 | ); 28 | 29 | /** 30 | * This is an example on how to consume extensions contributed by plugins. 31 | * 32 | * The `useExtensions` hook returns extensions which are currently in use without any further 33 | * transformations. Its argument is a predicate function that filters extensions based on their 34 | * `type`. 35 | * 36 | * The `useResolvedExtensions` hook extends the `useExtensions` functionality by transforming 37 | * the properties of matching extensions, resolving all `CodeRef` functions into `T` values. 38 | * This resolution is inherently asynchronous, so the hook provides the `resolved` flag which 39 | * indicates the completion of the code reference resolution process. 40 | */ 41 | export const RenderExtensions: React.FC = () => { 42 | const extensions = useExtensions(isModelFeatureFlag); 43 | const [resolvedExtensions, resolved] = useResolvedExtensions(isTelemetryListener); 44 | 45 | if (resolved) { 46 | resolvedExtensions.forEach((e) => { 47 | e.properties.listener('TestEvent'); 48 | }); 49 | } 50 | 51 | return resolved ? : null; 52 | }; 53 | 54 | const PageContent: React.FC = () => ( 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | ); 67 | 68 | export default PageContent; 69 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/PageHeader.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Brand, 3 | Button, 4 | Masthead, 5 | MastheadToggle, 6 | MastheadMain, 7 | MastheadBrand, 8 | MastheadContent, 9 | PageToggleButton, 10 | Toolbar, 11 | ToolbarContent, 12 | ToolbarItem, 13 | ToolbarGroup, 14 | } from '@patternfly/react-core'; 15 | import { BarsIcon } from '@patternfly/react-icons'; 16 | import * as React from 'react'; 17 | import pfLogo from '../images/pfColorLogo.svg'; 18 | import LoadPluginModal from './LoadPluginModal'; 19 | import type { LoadPluginModalRefProps } from './LoadPluginModal'; 20 | 21 | const PageHeader: React.FC = () => { 22 | const loadPluginModalRef = React.useRef(null); 23 | 24 | const openLoadPluginModal = () => { 25 | loadPluginModalRef.current?.open(); 26 | }; 27 | 28 | return ( 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | {document.title} 45 | 46 | 47 | 48 | 55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | ); 63 | }; 64 | 65 | export default PageHeader; 66 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/PageLayout.tsx: -------------------------------------------------------------------------------- 1 | import { Page, PageSection } from '@patternfly/react-core'; 2 | import * as React from 'react'; 3 | 4 | type PageLayoutProps = React.PropsWithChildren<{ 5 | header?: React.ReactNode; 6 | }>; 7 | 8 | const PageLayout: React.FC = ({ header, children }) => ( 9 | 10 | {children} 11 | 12 | ); 13 | 14 | export default PageLayout; 15 | -------------------------------------------------------------------------------- /packages/sample-app/src/components/PluginInfoTable.cy.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { mockPluginManifest, mockPluginEntryModule } from '../test-mocks'; 3 | import PluginInfoTable from './PluginInfoTable'; 4 | 5 | describe('PluginInfoTable', () => { 6 | beforeEach(() => { 7 | cy.mount(); 8 | }); 9 | 10 | it('Shows plugin runtime information', () => { 11 | cy.getPluginStore().then((pluginStore) => { 12 | pluginStore.addPendingPlugin(mockPluginManifest({ name: 'test-3' })); 13 | pluginStore.addLoadedPlugin(mockPluginManifest({ name: 'test-2' }), mockPluginEntryModule()); 14 | pluginStore.addFailedPlugin(mockPluginManifest({ name: 'test-1' }), 'Test error message'); 15 | }); 16 | 17 | cy.get('[data-test-id="plugin-table"]') 18 | .find('tbody > tr') 19 | .eq(0) 20 | .within(() => { 21 | cy.get('td[data-label="Name"]').should('contain.text', 'test-1'); 22 | cy.get('td[data-label="Status"]').should('contain.text', 'failed'); 23 | }); 24 | 25 | cy.get('[data-test-id="plugin-table"]') 26 | .find('tbody > tr') 27 | .eq(1) 28 | .within(() => { 29 | cy.get('td[data-label="Name"]').should('contain.text', 'test-2'); 30 | cy.get('td[data-label="Status"]').should('contain.text', 'loaded'); 31 | }); 32 | 33 | cy.get('[data-test-id="plugin-table"]') 34 | .find('tbody > tr') 35 | .eq(2) 36 | .within(() => { 37 | cy.get('td[data-label="Name"]').should('contain.text', 'test-3'); 38 | cy.get('td[data-label="Status"]').should('contain.text', 'pending'); 39 | }); 40 | }); 41 | 42 | it('Allows to manually disable a loaded plugin', () => { 43 | cy.getPluginStore().then((pluginStore) => { 44 | pluginStore.addLoadedPlugin(mockPluginManifest({ name: 'test' }), mockPluginEntryModule()); 45 | pluginStore.enablePlugins(['test']); 46 | }); 47 | 48 | cy.get('[data-test-id="plugin-table"]') 49 | .find('tbody > tr') 50 | .within(() => { 51 | cy.get('td[data-label="Name"]').should('contain.text', 'test'); 52 | cy.get('td[data-label="Status"]').should('contain.text', 'loaded'); 53 | cy.get('td[data-label="Enabled"]').should('contain.text', 'Yes'); 54 | }); 55 | 56 | cy.getPluginStore().then((pluginStore) => { 57 | const entry = pluginStore.getPluginInfo()[0]; 58 | 59 | cy.wrap(entry).its('status').should('equal', 'loaded'); 60 | cy.wrap(entry).its('enabled').should('be.true'); 61 | }); 62 | 63 | cy.get('[data-test-id="plugin-table"]') 64 | .find('tbody > tr') 65 | .within(() => { 66 | cy.get('td[data-label="Actions"] button').contains('Disable').click(); 67 | }); 68 | 69 | cy.get('[data-test-id="plugin-table"]') 70 | .find('tbody > tr') 71 | .within(() => { 72 | cy.get('td[data-label="Name"]').should('contain.text', 'test'); 73 | cy.get('td[data-label="Status"]').should('contain.text', 'loaded'); 74 | cy.get('td[data-label="Enabled"]').should('contain.text', 'No'); 75 | }); 76 | 77 | cy.getPluginStore().then((pluginStore) => { 78 | const entry = pluginStore.getPluginInfo()[0]; 79 | 80 | cy.wrap(entry).its('status').should('equal', 'loaded'); 81 | cy.wrap(entry).its('enabled').should('be.false'); 82 | }); 83 | }); 84 | }); 85 | -------------------------------------------------------------------------------- /packages/sample-app/src/cypress/component-index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Cypress component test page 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /packages/sample-app/src/cypress/component-setup.tsx: -------------------------------------------------------------------------------- 1 | import { TestPluginStore, PluginStoreProvider } from '@openshift/dynamic-plugin-sdk'; 2 | import { mount } from 'cypress/react'; 3 | import * as React from 'react'; 4 | import '../app-styles'; 5 | 6 | declare global { 7 | // TODO(vojtech): suppress false positive https://github.com/typescript-eslint/typescript-eslint/pull/2238 8 | // eslint-disable-next-line @typescript-eslint/no-namespace 9 | namespace Cypress { 10 | interface Chainable { 11 | mount: typeof mount; 12 | getPluginStore(): Chainable; 13 | } 14 | } 15 | } 16 | 17 | let pluginStore: TestPluginStore; 18 | 19 | beforeEach(() => { 20 | pluginStore = new TestPluginStore(); 21 | }); 22 | 23 | Cypress.Commands.add('mount', (component, options = {}) => { 24 | return mount({component}, options); 25 | }); 26 | 27 | Cypress.Commands.add('getPluginStore', () => { 28 | return cy.wrap(pluginStore); 29 | }); 30 | -------------------------------------------------------------------------------- /packages/sample-app/src/e2e/app.cy.ts: -------------------------------------------------------------------------------- 1 | describe('Sample Plugin Host Application', () => { 2 | it('Loads the sample plugin', () => { 3 | cy.visit('/'); 4 | 5 | cy.get('[data-test-id="plugin-table"]') 6 | .find('tbody') 7 | .should('contain.text', 'No plugins detected'); 8 | 9 | cy.get('[data-test-id="plugin-modal-open"]').should('have.text', 'Load plugin').click(); 10 | 11 | cy.get('[data-test-id="plugin-modal-url"]').should( 12 | 'have.value', 13 | 'http://localhost:9001/plugin-manifest.json', 14 | ); 15 | 16 | cy.get('[data-test-id="plugin-modal-load"]') 17 | .should('be.enabled') 18 | .should('have.text', 'Load') 19 | .click(); 20 | 21 | cy.get('[data-test-id="plugin-table"]') 22 | .find('tbody') 23 | .should('not.contain.text', 'No plugins detected'); 24 | 25 | cy.get('[data-test-id="plugin-table"]') 26 | .find('tbody > tr') 27 | .should('have.length', 1) 28 | .within(() => { 29 | cy.get('td[data-label="Name"]').should('contain.text', 'sample-plugin'); 30 | cy.get('td[data-label="Version"]').should('contain.text', '1.2.3'); 31 | cy.get('td[data-label="Status"]').should('contain.text', 'loaded'); 32 | cy.get('td[data-label="Extensions"]').should('contain.text', '2'); 33 | cy.get('td[data-label="Enabled"]').should('contain.text', 'Yes'); 34 | }); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /packages/sample-app/src/images/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/openshift/dynamic-plugin-sdk/5941c2eb17520a4945a6ce23070f996c70d8b2be/packages/sample-app/src/images/favicon.png -------------------------------------------------------------------------------- /packages/sample-app/src/index.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.svg' { 2 | const value: string; 3 | export default value; 4 | } 5 | -------------------------------------------------------------------------------- /packages/sample-app/src/shared-scope.ts: -------------------------------------------------------------------------------- 1 | /// 2 | 3 | const SHARED_SCOPE_NAME = 'default'; 4 | 5 | /** 6 | * Initialize the webpack share scope object. 7 | * 8 | * The host application should use webpack `ModuleFederationPlugin` to declare modules 9 | * shared between the application and its plugins. 10 | * 11 | * @example 12 | * ```ts 13 | * new ModuleFederationPlugin({ 14 | * shared: { 15 | * react: { eager: true, singleton: true } 16 | * } 17 | * }) 18 | * ``` 19 | */ 20 | export const initSharedScope = async () => __webpack_init_sharing__(SHARED_SCOPE_NAME); 21 | 22 | /** 23 | * Get the webpack share scope object. 24 | */ 25 | export const getSharedScope = () => { 26 | if (!Object.keys(__webpack_share_scopes__).includes(SHARED_SCOPE_NAME)) { 27 | throw new Error('Attempt to access share scope object before its initialization'); 28 | } 29 | 30 | return __webpack_share_scopes__[SHARED_SCOPE_NAME]; 31 | }; 32 | -------------------------------------------------------------------------------- /packages/sample-app/src/test-mocks.ts: -------------------------------------------------------------------------------- 1 | import type { AnyObject } from '@monorepo/common'; 2 | import type { PluginManifest, PluginEntryModule } from '@openshift/dynamic-plugin-sdk'; 3 | import { noop } from 'lodash'; 4 | 5 | export const mockPluginManifest = ({ 6 | name, 7 | version = '1.0.0', 8 | baseURL = `http://localhost/${name}/${version}/`, 9 | extensions = [], 10 | loadScripts = ['plugin-entry.js'], 11 | registrationMethod = 'callback', 12 | }: Pick & Partial): PluginManifest => ({ 13 | name, 14 | version, 15 | baseURL, 16 | extensions, 17 | loadScripts, 18 | registrationMethod, 19 | }); 20 | 21 | export const mockPluginEntryModule = ( 22 | pluginModules: { [moduleRequest: string]: AnyObject } = {}, 23 | init: PluginEntryModule['init'] = noop, 24 | ): PluginEntryModule => { 25 | return { 26 | init, 27 | // eslint-disable-next-line @typescript-eslint/no-explicit-any 28 | get: (moduleRequest: string) => Promise.resolve(() => pluginModules[moduleRequest] as any), 29 | }; 30 | }; 31 | -------------------------------------------------------------------------------- /packages/sample-app/src/types.ts: -------------------------------------------------------------------------------- 1 | export type AnyObject = Record; 2 | -------------------------------------------------------------------------------- /packages/sample-app/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const isValidURL = (value: string, allowedProtocols = ['http:', 'https:']) => { 2 | let url: URL; 3 | 4 | try { 5 | url = new URL(value); 6 | } catch (e) { 7 | return false; 8 | } 9 | 10 | return allowedProtocols.includes(url.protocol); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/sample-app/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@monorepo/common/tsconfig-bases/app-react-esm.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/sample-plugin/.dockerignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | -------------------------------------------------------------------------------- /packages/sample-plugin/.eslintrc.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | root: true, 3 | extends: ['plugin:@monorepo/eslint-plugin-internal/react-typescript-prettier'], 4 | }; 5 | -------------------------------------------------------------------------------- /packages/sample-plugin/Caddyfile: -------------------------------------------------------------------------------- 1 | :8000 { 2 | log 3 | @app_match { 4 | path_regexp ^{$PLUGIN_URL}(.*) 5 | } 6 | handle @app_match { 7 | # Substitution for alias from nginx 8 | uri strip_prefix {$PLUGIN_URL} 9 | file_server * { 10 | root /opt/app-root/src/dist 11 | browse 12 | } 13 | } 14 | handle / { 15 | redir {$FALLBACK_URL}index.html permanent 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/sample-plugin/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM quay.io/cloudservices/caddy-ubi:357c825 2 | 3 | COPY ./Caddyfile /opt/app-root/src/Caddyfile 4 | COPY dist /opt/app-root/src/dist/ 5 | COPY ./package.json /opt/app-root/src 6 | WORKDIR /opt/app-root/src 7 | ENV PLUGIN_URL=/ 8 | ENV FALLBACK_URL=/ 9 | CMD ["caddy", "run", "--config", "/opt/app-root/src/Caddyfile"] 10 | -------------------------------------------------------------------------------- /packages/sample-plugin/README.md: -------------------------------------------------------------------------------- 1 | # Sample plugin 2 | 3 | ### Docker config 4 | 5 | There's docker config with caddy server for easier build and run of the plugin. The Caddy config utilizes two environment variables 6 | 7 | * `PLUGIN_URL` - (*defaults to `/`*) defines which URL should be used when pulling assets, **example** `PLUGIN_URL=/foo/bar` will serve the assets on `localhost:8000/foo/bar` 8 | * `FALLBACK_URL` - (*defaults to `/`*) if the file is not found caddy will use this URL as a fallback and will look for index.html (SPA behavior) **example** `FALLBACK_URL=/baz` will serve the index.html from `/baz` folder. 9 | 10 | #### Running with docker 11 | 12 | The caddy server is by default serving content over from port `8000` in order to see it locally you'll have to map your machine's port to caddy's port 13 | 14 | ```bash 15 | > docker run -p 80:8000 sample-plugin 16 | ``` 17 | -------------------------------------------------------------------------------- /packages/sample-plugin/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@monorepo/sample-plugin", 3 | "version": "0.0.0-fixed", 4 | "description": "Sample dynamic plugin project", 5 | "private": true, 6 | "scripts": { 7 | "clean": "rm -rf dist", 8 | "build": "yarn clean && yarn webpack", 9 | "build-prod": "yarn clean && NODE_ENV=production yarn webpack", 10 | "lint": "yarn run -T eslint $INIT_CWD", 11 | "webpack": "node -r ts-node/register ./node_modules/.bin/webpack", 12 | "http-server": "http-server dist -p 9001 -c-1 --cors" 13 | }, 14 | "devDependencies": { 15 | "@openshift/dynamic-plugin-sdk": "portal:../lib-core", 16 | "@openshift/dynamic-plugin-sdk-extensions": "portal:../lib-extensions", 17 | "@openshift/dynamic-plugin-sdk-webpack": "portal:../lib-webpack", 18 | "@patternfly/react-core": "^4.202.16", 19 | "@patternfly/react-table": "^4.71.16", 20 | "css-loader": "^6.7.1", 21 | "css-minimizer-webpack-plugin": "^3.4.1", 22 | "http-server": "^14.1.0", 23 | "react": "^17.0.2", 24 | "react-dom": "^17.0.2", 25 | "style-loader": "^3.3.1", 26 | "ts-loader": "^9.2.8", 27 | "typescript": "~4.4.4", 28 | "webpack": "^5.75.0", 29 | "webpack-cli": "^5.0.1" 30 | }, 31 | "installConfig": { 32 | "hoistingLimits": "dependencies" 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /packages/sample-plugin/plugin-extensions.ts: -------------------------------------------------------------------------------- 1 | import type { EncodedExtension } from '@openshift/dynamic-plugin-sdk'; 2 | import type { ModelFeatureFlag, TelemetryListener } from '@openshift/dynamic-plugin-sdk-extensions'; 3 | 4 | // TODO(vojtech): make EncodedExtension type work with A | B type unions 5 | 6 | const e1: EncodedExtension = { 7 | type: 'core.flag/model', 8 | properties: { 9 | flag: 'EXAMPLE', 10 | model: { 11 | group: 'example.org', 12 | version: 'v1', 13 | kind: 'ExampleModel', 14 | }, 15 | }, 16 | }; 17 | 18 | const e2: EncodedExtension = { 19 | type: 'core.telemetry/listener', 20 | properties: { 21 | listener: { $codeRef: 'telemetryListener' }, 22 | }, 23 | flags: { 24 | required: ['TELEMETRY_FLAG'], 25 | disallowed: [], 26 | }, 27 | }; 28 | 29 | export default [e1, e2]; 30 | -------------------------------------------------------------------------------- /packages/sample-plugin/plugin-metadata.ts: -------------------------------------------------------------------------------- 1 | import type { PluginBuildMetadata } from '@openshift/dynamic-plugin-sdk-webpack'; 2 | 3 | const metadata: PluginBuildMetadata = { 4 | name: 'sample-plugin', 5 | version: '1.2.3', 6 | dependencies: { 7 | 'sample-app': '^1.0.0', 8 | }, 9 | exposedModules: { 10 | telemetryListener: './src/telemetry-listener', 11 | }, 12 | customProperties: { 13 | test: true, 14 | }, 15 | }; 16 | 17 | export default metadata; 18 | -------------------------------------------------------------------------------- /packages/sample-plugin/src/telemetry-listener.ts: -------------------------------------------------------------------------------- 1 | import type { TelemetryEventListener } from '@openshift/dynamic-plugin-sdk-extensions'; 2 | 3 | const telemetryListener: TelemetryEventListener = (eventType, properties) => { 4 | // eslint-disable-next-line no-console 5 | console.info('Telemetry listener', eventType, properties); 6 | }; 7 | 8 | export default telemetryListener; 9 | -------------------------------------------------------------------------------- /packages/sample-plugin/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "@monorepo/common/tsconfig-bases/app-react-esm.json", 3 | "compilerOptions": { 4 | "outDir": "dist" 5 | }, 6 | "include": ["src"], 7 | "exclude": ["node_modules"] 8 | } 9 | -------------------------------------------------------------------------------- /packages/sample-plugin/webpack.config.ts: -------------------------------------------------------------------------------- 1 | import path from 'path'; 2 | import { DynamicRemotePlugin } from '@openshift/dynamic-plugin-sdk-webpack'; 3 | import type { WebpackSharedObject } from '@openshift/dynamic-plugin-sdk-webpack'; 4 | import CSSMinimizerPlugin from 'css-minimizer-webpack-plugin'; 5 | import type { Configuration, WebpackPluginInstance } from 'webpack'; 6 | import { EnvironmentPlugin } from 'webpack'; 7 | import extensions from './plugin-extensions'; 8 | import pluginMetadata from './plugin-metadata'; 9 | 10 | const isProd = process.env.NODE_ENV === 'production'; 11 | 12 | const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); 13 | 14 | /** 15 | * Shared modules consumed and/or provided by this plugin. 16 | * 17 | * A host application typically provides some modules to its plugins. If an application 18 | * provided module is configured as an eager singleton, we suggest using `import: false` 19 | * to avoid bundling a fallback version of the module when building your plugin. 20 | * 21 | * Plugins may provide additional shared modules that can be consumed by other plugins. 22 | * 23 | * @see https://webpack.js.org/plugins/module-federation-plugin/#sharing-hints 24 | */ 25 | const pluginSharedModules: WebpackSharedObject = { 26 | '@openshift/dynamic-plugin-sdk': { singleton: true, import: false }, 27 | '@patternfly/react-core': {}, 28 | '@patternfly/react-table': {}, 29 | react: { singleton: true, import: false }, 30 | 'react-dom': { singleton: true, import: false }, 31 | }; 32 | 33 | const plugins: WebpackPluginInstance[] = [ 34 | new EnvironmentPlugin({ 35 | NODE_ENV: 'development', 36 | }), 37 | new DynamicRemotePlugin({ 38 | pluginMetadata, 39 | extensions, 40 | sharedModules: pluginSharedModules, 41 | entryScriptFilename: isProd ? 'plugin-entry.[contenthash].min.js' : 'plugin-entry.js', 42 | }), 43 | ]; 44 | 45 | const config: Configuration = { 46 | mode: isProd ? 'production' : 'development', 47 | entry: {}, // Plugin container entry is generated by DynamicRemotePlugin 48 | output: { 49 | path: pathTo('dist'), 50 | publicPath: 'http://localhost:9001/', 51 | chunkFilename: isProd ? 'chunks/[id].[chunkhash].min.js' : 'chunks/[id].js', 52 | assetModuleFilename: isProd ? 'assets/[contenthash][ext]' : 'assets/[name][ext]', 53 | }, 54 | resolve: { 55 | extensions: ['.ts', '.tsx', '.js', '.jsx'], 56 | }, 57 | module: { 58 | rules: [ 59 | { 60 | test: /\.(jsx?|tsx?)$/, 61 | exclude: /\/node_modules\//, 62 | use: [ 63 | { 64 | loader: 'ts-loader', 65 | options: { 66 | configFile: pathTo('tsconfig.json'), 67 | }, 68 | }, 69 | ], 70 | }, 71 | { 72 | test: /\.(svg|png|jpg|jpeg|gif)$/, 73 | include: pathTo('src'), 74 | type: 'asset', 75 | parser: { 76 | dataUrlCondition: { 77 | maxSize: 50 * 1024, // Files smaller than 50 kB will be inlined as data URLs 78 | }, 79 | }, 80 | generator: { 81 | filename: isProd ? 'images/[contenthash][ext]' : 'images/[name][ext]', 82 | }, 83 | }, 84 | { 85 | test: /\.(css)$/, 86 | include: pathTo('src'), 87 | use: ['style-loader', 'css-loader'], 88 | }, 89 | ], 90 | }, 91 | plugins, 92 | devtool: isProd ? 'source-map' : 'cheap-source-map', 93 | optimization: { 94 | minimize: isProd, 95 | minimizer: [ 96 | '...', // The '...' string represents the webpack default TerserPlugin instance 97 | new CSSMinimizerPlugin(), 98 | ], 99 | }, 100 | }; 101 | 102 | export default config; 103 | -------------------------------------------------------------------------------- /prow-codecov.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -euo pipefail 3 | 4 | # https://github.com/kubernetes/test-infra/blob/master/prow/jobs.md 5 | # https://docs.codecov.com/docs/codecov-uploader 6 | 7 | JOB_TYPE=${JOB_TYPE:-local} 8 | REF_FLAGS="" 9 | 10 | if [[ "${JOB_TYPE}" == "presubmit" ]]; then 11 | REF_FLAGS="-P ${PULL_NUMBER} -C ${PULL_PULL_SHA}" 12 | fi 13 | 14 | if [[ "${JOB_TYPE}" != "local" ]]; then 15 | curl -Os https://uploader.codecov.io/latest/linux/codecov 16 | chmod +x codecov 17 | ./codecov -t ${CODECOV_TOKEN} -r "openshift/dynamic-plugin-sdk" ${REF_FLAGS} --dir ./coverage 18 | fi 19 | -------------------------------------------------------------------------------- /test-prow-e2e.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -exuo pipefail 3 | 4 | ARTIFACT_DIR=${ARTIFACT_DIR:=/tmp/artifacts} 5 | SCREENSHOTS_DIR=screenshots 6 | 7 | function copyArtifacts { 8 | if [ -d "$ARTIFACT_DIR" ] && [ -d "$SCREENSHOTS_DIR" ]; then 9 | echo "Copying artifacts from $(pwd)..." 10 | cp -r "$SCREENSHOTS_DIR" "${ARTIFACT_DIR}/e2e_test_screenshots" 11 | fi 12 | } 13 | 14 | trap copyArtifacts EXIT 15 | 16 | yarn install 17 | yarn build-libs 18 | yarn build-samples 19 | 20 | # Create a virtual X11 display via Xvfb for use with Cypress E2E testing 21 | Xvfb :99 -screen 0 1920x1080x24 2>&1 > /dev/null & 22 | export DISPLAY=':99.0' 23 | 24 | export CYPRESS_CRASH_REPORTS=0 25 | export CYPRESS_COMMERCIAL_RECOMMENDATIONS=0 26 | 27 | # Start servers for sample app and sample plugin and run Cypress E2E tests 28 | yarn test-e2e 29 | 30 | # Kill the Xvfb background process 31 | pkill Xvfb 32 | -------------------------------------------------------------------------------- /test.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -exuo pipefail 3 | 4 | # Setup environment 5 | export NODE_OPTIONS="--max-old-space-size=4096" 6 | 7 | # Print system information 8 | echo "node $(node -v)" 9 | echo "npm $(npm -v)" 10 | echo "yarn $(yarn -v)" 11 | 12 | # Install dependencies 13 | yarn install --immutable 14 | 15 | # Build packages 16 | yarn build 17 | 18 | # Analyze code for potential problems 19 | yarn lint 20 | 21 | # Run unit tests 22 | yarn test 23 | 24 | # Run Cypress component tests 25 | yarn test-component 26 | 27 | # Upload code coverage 28 | ./prow-codecov.sh 2>/dev/null 29 | --------------------------------------------------------------------------------