From 7b0237f5f8cdc0817ff2f8e1cea07ab00548da27 Mon Sep 17 00:00:00 2001 From: Katerina Koukiou Date: Fri, 13 Jan 2023 13:24:47 +0100 Subject: [PATCH 01/11] feat(init): initial creation of extracted react-console --- .eslintignore | 35 + .eslintrc-md.json | 28 + .eslintrc.json | 97 + .github/workflows/build-lint-test.yml | 159 + .github/workflows/build.yml | 55 + .github/workflows/check-pr.yml | 8 + .github/workflows/release.yml | 43 + .gitignore | 34 + .prettierignore | 35 + .prettierrc.json | 8 + LICENSE | 21 + README.md | 51 +- babel.config.js | 8 + fed-mini-modules.js | 64 + jest.config.js | 17 + package.json | 54 + packages/module/generate-fed-package-json.js | 65 + packages/module/package.json | 63 + packages/module/patternfly-a11y.config.js | 28 + .../design-guidelines/design-guidelines.md | 23 + .../react-console/examples/Basic.tsx | 4 + .../react-console/examples/basic.md | 29 + .../react-console/design-guidelines.js | 51 + .../extensions/react-console/react.js | 59 + .../module/patternfly-docs/generated/index.js | 20 + .../module/patternfly-docs/pages/index.js | 27 + .../patternfly-docs/patternfly-docs.config.js | 6 + .../patternfly-docs/patternfly-docs.css.js | 8 + .../patternfly-docs/patternfly-docs.routes.js | 12 + .../patternfly-docs/patternfly-docs.source.js | 24 + .../AccessConsoles/AccessConsoles.tsx | 139 + .../__tests__/AccessConsoles.test.tsx | 111 + .../AccessConsoles.test.tsx.snap | 171 + .../AccessConsoles/examples/AccessConsoles.md | 49 + .../examples/SerialConsoleCustom.jsx | 24 + .../src/components/AccessConsoles/index.ts | 1 + .../DesktopViewer/ConnectWithRemoteViewer.tsx | 132 + .../DesktopViewer/ConsoleDetailPropType.tsx | 5 + .../DesktopViewer/DesktopViewer.tsx | 89 + .../DesktopViewer/ManualConnection.tsx | 83 + .../MoreInformationDefaultContent.tsx | 40 + .../MoreInformationDefaultRDPContent.tsx | 22 + .../MoreInformationInstallVariant.tsx | 21 + .../__tests__/DesktopViewer.test.tsx | 113 + .../__snapshots__/DesktopViewer.test.tsx.snap | 1298 ++ .../consoleDescriptorGenerator.tsx | 118 + .../DesktopViewer/examples/DesktopViewer.md | 21 + .../src/components/DesktopViewer/index.ts | 3 + .../SerialConsole/SerialConsole.tsx | 144 + .../SerialConsole/SerialConsoleActions.tsx | 28 + .../src/components/SerialConsole/XTerm.tsx | 135 + .../__tests__/SerialConsole.test.tsx | 74 + .../__tests__/SerialConsoleActions.test.tsx | 20 + .../SerialConsole/__tests__/XTerm.test.tsx | 26 + .../__snapshots__/SerialConsole.test.tsx.snap | 426 + .../SerialConsoleActions.test.tsx.snap | 59 + .../__snapshots__/XTerm.test.tsx.snap | 380 + .../src/components/SerialConsole/index.ts | 1 + .../components/SpiceConsole/SpiceActions.tsx | 34 + .../components/SpiceConsole/SpiceConsole.tsx | 122 + .../src/components/SpiceConsole/index.ts | 1 + .../src/components/VncConsole/VncActions.tsx | 53 + .../src/components/VncConsole/VncConsole.tsx | 253 + .../VncConsole/__tests__/VncActions.test.tsx | 56 + .../VncConsole/__tests__/VncConsole.test.tsx | 9 + .../__snapshots__/VncActions.test.tsx.snap | 181 + .../__snapshots__/VncConsole.test.tsx.snap | 46 + .../module/src/components/VncConsole/index.ts | 1 + .../src/components/common/constants.tsx | 37 + .../module/src/components/common/helpers.tsx | 6 + packages/module/src/components/index.ts | 7 + packages/module/src/declarations.d.ts | 15 + packages/module/src/index.ts | 1 + packages/module/tsconfig.json | 73 + renovate.json | 21 + styleMock.js | 2 + yarn.lock | 12297 ++++++++++++++++ 77 files changed, 18083 insertions(+), 1 deletion(-) create mode 100644 .eslintignore create mode 100644 .eslintrc-md.json create mode 100644 .eslintrc.json create mode 100644 .github/workflows/build-lint-test.yml create mode 100644 .github/workflows/build.yml create mode 100644 .github/workflows/check-pr.yml create mode 100644 .github/workflows/release.yml create mode 100644 .gitignore create mode 100644 .prettierignore create mode 100644 .prettierrc.json create mode 100644 LICENSE create mode 100644 babel.config.js create mode 100644 fed-mini-modules.js create mode 100644 jest.config.js create mode 100644 package.json create mode 100644 packages/module/generate-fed-package-json.js create mode 100644 packages/module/package.json create mode 100644 packages/module/patternfly-a11y.config.js create mode 100644 packages/module/patternfly-docs/content/extensions/react-console/design-guidelines/design-guidelines.md create mode 100644 packages/module/patternfly-docs/content/extensions/react-console/examples/Basic.tsx create mode 100644 packages/module/patternfly-docs/content/extensions/react-console/examples/basic.md create mode 100644 packages/module/patternfly-docs/generated/extensions/react-console/design-guidelines.js create mode 100644 packages/module/patternfly-docs/generated/extensions/react-console/react.js create mode 100644 packages/module/patternfly-docs/generated/index.js create mode 100644 packages/module/patternfly-docs/pages/index.js create mode 100644 packages/module/patternfly-docs/patternfly-docs.config.js create mode 100644 packages/module/patternfly-docs/patternfly-docs.css.js create mode 100644 packages/module/patternfly-docs/patternfly-docs.routes.js create mode 100644 packages/module/patternfly-docs/patternfly-docs.source.js create mode 100644 packages/module/src/components/AccessConsoles/AccessConsoles.tsx create mode 100644 packages/module/src/components/AccessConsoles/__tests__/AccessConsoles.test.tsx create mode 100644 packages/module/src/components/AccessConsoles/__tests__/__snapshots__/AccessConsoles.test.tsx.snap create mode 100644 packages/module/src/components/AccessConsoles/examples/AccessConsoles.md create mode 100644 packages/module/src/components/AccessConsoles/examples/SerialConsoleCustom.jsx create mode 100644 packages/module/src/components/AccessConsoles/index.ts create mode 100644 packages/module/src/components/DesktopViewer/ConnectWithRemoteViewer.tsx create mode 100644 packages/module/src/components/DesktopViewer/ConsoleDetailPropType.tsx create mode 100644 packages/module/src/components/DesktopViewer/DesktopViewer.tsx create mode 100644 packages/module/src/components/DesktopViewer/ManualConnection.tsx create mode 100644 packages/module/src/components/DesktopViewer/MoreInformationDefaultContent.tsx create mode 100644 packages/module/src/components/DesktopViewer/MoreInformationDefaultRDPContent.tsx create mode 100644 packages/module/src/components/DesktopViewer/MoreInformationInstallVariant.tsx create mode 100644 packages/module/src/components/DesktopViewer/__tests__/DesktopViewer.test.tsx create mode 100644 packages/module/src/components/DesktopViewer/__tests__/__snapshots__/DesktopViewer.test.tsx.snap create mode 100644 packages/module/src/components/DesktopViewer/consoleDescriptorGenerator.tsx create mode 100644 packages/module/src/components/DesktopViewer/examples/DesktopViewer.md create mode 100644 packages/module/src/components/DesktopViewer/index.ts create mode 100644 packages/module/src/components/SerialConsole/SerialConsole.tsx create mode 100644 packages/module/src/components/SerialConsole/SerialConsoleActions.tsx create mode 100644 packages/module/src/components/SerialConsole/XTerm.tsx create mode 100644 packages/module/src/components/SerialConsole/__tests__/SerialConsole.test.tsx create mode 100644 packages/module/src/components/SerialConsole/__tests__/SerialConsoleActions.test.tsx create mode 100644 packages/module/src/components/SerialConsole/__tests__/XTerm.test.tsx create mode 100644 packages/module/src/components/SerialConsole/__tests__/__snapshots__/SerialConsole.test.tsx.snap create mode 100644 packages/module/src/components/SerialConsole/__tests__/__snapshots__/SerialConsoleActions.test.tsx.snap create mode 100644 packages/module/src/components/SerialConsole/__tests__/__snapshots__/XTerm.test.tsx.snap create mode 100644 packages/module/src/components/SerialConsole/index.ts create mode 100644 packages/module/src/components/SpiceConsole/SpiceActions.tsx create mode 100644 packages/module/src/components/SpiceConsole/SpiceConsole.tsx create mode 100644 packages/module/src/components/SpiceConsole/index.ts create mode 100644 packages/module/src/components/VncConsole/VncActions.tsx create mode 100644 packages/module/src/components/VncConsole/VncConsole.tsx create mode 100644 packages/module/src/components/VncConsole/__tests__/VncActions.test.tsx create mode 100644 packages/module/src/components/VncConsole/__tests__/VncConsole.test.tsx create mode 100644 packages/module/src/components/VncConsole/__tests__/__snapshots__/VncActions.test.tsx.snap create mode 100644 packages/module/src/components/VncConsole/__tests__/__snapshots__/VncConsole.test.tsx.snap create mode 100644 packages/module/src/components/VncConsole/index.ts create mode 100644 packages/module/src/components/common/constants.tsx create mode 100644 packages/module/src/components/common/helpers.tsx create mode 100644 packages/module/src/components/index.ts create mode 100644 packages/module/src/declarations.d.ts create mode 100644 packages/module/src/index.ts create mode 100644 packages/module/tsconfig.json create mode 100644 renovate.json create mode 100644 styleMock.js create mode 100644 yarn.lock diff --git a/.eslintignore b/.eslintignore new file mode 100644 index 0000000..a628326 --- /dev/null +++ b/.eslintignore @@ -0,0 +1,35 @@ +# Javascript builds +node_modules +dist +tsc_out +.out +.changelog +.DS_Store +coverage +.cache +.tmp +.eslintcache +generated + +# package managers +yarn-error.log +lerna-debug.log + +# IDEs and editors +.idea +.project +.classpath +.c9 +*.launch +.settings +*.sublime-workspace +.history +.vscode +.yo-rc.json + +# IDE - VSCode +.vscode +# For vim +*.swp + +public \ No newline at end of file diff --git a/.eslintrc-md.json b/.eslintrc-md.json new file mode 100644 index 0000000..1945c90 --- /dev/null +++ b/.eslintrc-md.json @@ -0,0 +1,28 @@ +{ + "plugins": [ + "markdown", + "react" + ], + "parserOptions": { + "ecmaVersion": 9, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "settings": { + "react": { + "version": "16.4.0" + } + }, + "rules": { + "eol-last": 2, + "spaced-comment": 2, + "no-unused-vars": 0, + "no-this-before-super": 2, + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/no-unknown-property": 2, + "react/jsx-no-undef": 2 + } +} \ No newline at end of file diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 0000000..571e04e --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,97 @@ +{ + "env": { + "browser": true, + "node": true, + "es6": true + }, + "extends": [ + "eslint:recommended", + "plugin:react/recommended", + "plugin:react-hooks/recommended", + "plugin:@typescript-eslint/recommended", + "prettier" + ], + "overrides": [ + { + "files": ["**/patternfly-docs/pages/*"], + "rules": { + "arrow-body-style": "off" + } + } + ], + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + } + }, + "settings": { + "react": { + "version": "detect" + } + }, + "plugins": ["react", "react-hooks", "@typescript-eslint"], + "rules": { + "@typescript-eslint/adjacent-overload-signatures": "error", + "@typescript-eslint/array-type": "error", + "@typescript-eslint/consistent-type-assertions": "error", + "@typescript-eslint/consistent-type-definitions": "error", + "@typescript-eslint/no-misused-new": "error", + "@typescript-eslint/no-namespace": "error", + "@typescript-eslint/no-unused-vars": [ + "error", + { + "argsIgnorePattern": "^_" + } + ], + "@typescript-eslint/prefer-for-of": "error", + "@typescript-eslint/prefer-function-type": "error", + "@typescript-eslint/prefer-namespace-keyword": "error", + "@typescript-eslint/unified-signatures": "error", + "@typescript-eslint/no-var-requires": "off", + "arrow-body-style": "error", + "camelcase": [ + "error", + { + "ignoreDestructuring": true + } + ], + "constructor-super": "error", + "curly": "error", + "dot-notation": "error", + "eqeqeq": ["error", "smart"], + "guard-for-in": "error", + "max-classes-per-file": ["error", 1], + "no-nested-ternary": "error", + "no-bitwise": "error", + "no-caller": "error", + "no-cond-assign": "error", + "no-console": "error", + "no-debugger": "error", + "no-empty": "error", + "no-eval": "error", + "no-new-wrappers": "error", + "no-undef-init": "error", + "no-unsafe-finally": "error", + "no-unused-expressions": [ + "error", + { + "allowTernary": true, + "allowShortCircuit": true + } + ], + "no-unused-labels": "error", + "no-var": "error", + "object-shorthand": "error", + "one-var": ["error", "never"], + "prefer-const": "error", + "radix": ["error", "as-needed"], + "react/prop-types": 0, + "react/display-name": 0, + "react-hooks/exhaustive-deps": "warn", + "react/no-unescaped-entities": ["error", { "forbid": [">", "}"] }], + "spaced-comment": "error", + "use-isnan": "error" + } +} diff --git a/.github/workflows/build-lint-test.yml b/.github/workflows/build-lint-test.yml new file mode 100644 index 0000000..728e743 --- /dev/null +++ b/.github/workflows/build-lint-test.yml @@ -0,0 +1,159 @@ +name: build-lint-test +on: + workflow_call: +jobs: + build: + runs-on: ubuntu-latest + env: + GH_PR_NUM: ${{ github.event.number }} + steps: + - uses: actions/checkout@v2 + - run: | + if [[ ! -z "${GH_PR_NUM}" ]]; then + echo "Checking out PR" + git fetch origin pull/$GH_PR_NUM/head:tmp + git checkout tmp + fi + - uses: actions/setup-node@v1 + with: + node-version: '14' + - uses: actions/cache@v2 + id: yarn-cache + name: Cache npm deps + with: + path: | + node_modules + **/node_modules + key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} + - run: yarn install --frozen-lockfile + if: steps.yarn-cache.outputs.cache-hit != 'true' + - uses: actions/cache@v2 + id: dist + name: Cache dist + with: + path: | + packages/*/dist + key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} + - name: Build dist + run: yarn build + if: steps.dist.outputs.cache-hit != 'true' + lint: + runs-on: ubuntu-latest + env: + GH_PR_NUM: ${{ github.event.number }} + needs: build + steps: + - uses: actions/checkout@v2 + - run: | + if [[ ! -z "${GH_PR_NUM}" ]]; then + echo "Checking out PR" + git fetch origin pull/$GH_PR_NUM/head:tmp + git checkout tmp + fi + - uses: actions/setup-node@v1 + with: + node-version: '14' + - uses: actions/cache@v2 + id: yarn-cache + name: Cache npm deps + with: + path: | + node_modules + **/node_modules + key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} + - run: yarn install --frozen-lockfile + if: steps.yarn-cache.outputs.cache-hit != 'true' + - uses: actions/cache@v2 + id: lint-cache + name: Load lint cache + with: + path: '.eslintcache' + key: ${{ runner.os }}-lint-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} + - name: ESLint + run: yarn lint:js + - name: MDLint + run: yarn lint:md + test_jest: + runs-on: ubuntu-latest + env: + GH_PR_NUM: ${{ github.event.number }} + needs: build + steps: + - uses: actions/checkout@v2 + # Yes, we really want to checkout the PR + - run: | + if [[ ! -z "${GH_PR_NUM}" ]]; then + echo "Checking out PR" + git fetch origin pull/$GH_PR_NUM/head:tmp + git checkout tmp + fi + - uses: actions/setup-node@v1 + with: + node-version: '14' + - uses: actions/cache@v2 + id: yarn-cache + name: Cache npm deps + with: + path: | + node_modules + **/node_modules + ~/.cache/Cypress + key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} + - run: yarn install --frozen-lockfile + if: steps.yarn-cache.outputs.cache-hit != 'true' + - uses: actions/cache@v2 + id: dist + name: Cache dist + with: + path: | + packages/*/dist + packages/react-styles/css + key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} + - name: Build dist + run: yarn build + if: steps.dist.outputs.cache-hit != 'true' + - name: PF4 Jest Tests + run: yarn test --maxWorkers=2 + test_a11y: + runs-on: ubuntu-latest + env: + GH_PR_NUM: ${{ github.event.number }} + needs: build + steps: + - uses: actions/checkout@v2 + # Yes, we really want to checkout the PR + - run: | + if [[ ! -z "${GH_PR_NUM}" ]]; then + echo "Checking out PR" + git fetch origin pull/$GH_PR_NUM/head:tmp + git checkout tmp + fi + - uses: actions/setup-node@v1 + with: + node-version: '14' + - uses: actions/cache@v2 + id: yarn-cache + name: Cache npm deps + with: + path: | + node_modules + **/node_modules + ~/.cache/Cypress + key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} + - run: yarn install --frozen-lockfile + if: steps.yarn-cache.outputs.cache-hit != 'true' + - uses: actions/cache@v2 + id: dist + name: Cache dist + with: + path: | + packages/*/dist + packages/react-styles/css + key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} + - name: Build dist + run: yarn build + if: steps.dist.outputs.cache-hit != 'true' + - name: Build docs + run: yarn build:docs + - name: A11y tests + run: yarn serve:docs & yarn test:a11y diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..a0cccfa --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,55 @@ +name: build +on: + workflow_call: +jobs: + build: + runs-on: ubuntu-latest + env: + GH_PR_NUM: ${{ github.event.number }} + steps: + - uses: actions/checkout@v2 + - run: | + if [[ ! -z "${GH_PR_NUM}" ]]; then + echo "Checking out PR" + git fetch origin pull/$GH_PR_NUM/head:tmp + git checkout tmp + fi + - uses: actions/cache@v2 + id: setup-cache + name: Cache setup + with: + path: | + README.md + package.json + .tmplr.yml + packages/*/package.json + packages/*/patternfly-docs/content/** + packages/*/patternfly-docs/generated/** + key: ${{ runner.os }}-setup-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('package.json', 'packages/module/package.json') }} + - name: Run build script + run: ./devSetup.sh + shell: bash + if: steps.setup-cache.outputs.cache-hit != 'true' + - uses: actions/setup-node@v1 + with: + node-version: '14' + - uses: actions/cache@v2 + id: yarn-cache + name: Cache npm deps + with: + path: | + node_modules + **/node_modules + key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} + - run: yarn install --frozen-lockfile + if: steps.yarn-cache.outputs.cache-hit != 'true' + - uses: actions/cache@v2 + id: dist + name: Cache dist + with: + path: | + packages/*/dist + key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} + - name: Build dist + run: yarn build + if: steps.dist.outputs.cache-hit != 'true' \ No newline at end of file diff --git a/.github/workflows/check-pr.yml b/.github/workflows/check-pr.yml new file mode 100644 index 0000000..fa99007 --- /dev/null +++ b/.github/workflows/check-pr.yml @@ -0,0 +1,8 @@ +name: check-pr +on: + pull_request: + branches: + - main +jobs: + call-build-lint-test-workflow: + uses: ./.github/workflows/build-lint-test.yml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..2648b8a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,43 @@ +name: release +on: + push: + branches: + - main +jobs: + call-build-lint-test-workflow: + uses: ./.github/workflows/build-lint-test.yml + deploy: + runs-on: ubuntu-latest + needs: [call-build-lint-test-workflow] + env: + NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-node@v1 + with: + node-version: '14' + - uses: actions/cache@v2 + id: yarn-cache + name: Cache npm deps + with: + path: | + node_modules + **/node_modules + ~/.cache/Cypress + key: ${{ runner.os }}-yarn-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock') }} + - run: yarn install --frozen-lockfile + if: steps.yarn-cache.outputs.cache-hit != 'true' + - uses: actions/cache@v2 + id: dist + name: Cache dist + with: + path: | + packages/*/dist + packages/react-styles/css + key: ${{ runner.os }}-dist-14-${{ secrets.CACHE_VERSION }}-${{ hashFiles('yarn.lock', 'package.json', 'packages/*/*', '!packages/*/dist', '!packages/*/node_modules') }} + - name: Build dist + run: yarn build + if: steps.dist.outputs.cache-hit != 'true' + - name: Release to NPM + run: cd packages/module && npx semantic-release@19.0.5 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..e61a201 --- /dev/null +++ b/.gitignore @@ -0,0 +1,34 @@ +# Javascript builds +node_modules +dist +tsc_out +.out +.changelog +.DS_Store +coverage +.cache +.tmp +.eslintcache + +# package managers +yarn-error.log +lerna-debug.log + +# IDEs and editors +.idea +.project +.classpath +.c9 +*.launch +.settings +*.sublime-workspace +.history +.vscode +.yo-rc.json + +# IDE - VSCode +.vscode +# For vim +*.swp + +public \ No newline at end of file diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..a628326 --- /dev/null +++ b/.prettierignore @@ -0,0 +1,35 @@ +# Javascript builds +node_modules +dist +tsc_out +.out +.changelog +.DS_Store +coverage +.cache +.tmp +.eslintcache +generated + +# package managers +yarn-error.log +lerna-debug.log + +# IDEs and editors +.idea +.project +.classpath +.c9 +*.launch +.settings +*.sublime-workspace +.history +.vscode +.yo-rc.json + +# IDE - VSCode +.vscode +# For vim +*.swp + +public \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..0265439 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false, + "printWidth": 120 +} diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1bc6afd --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Red Hat, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 1257f6c..3ea1ab7 100644 --- a/README.md +++ b/README.md @@ -1 +1,50 @@ -# react-console" +# React Console + +This package provides VncConsole, SerialConsole and DesktopViewer React components +to be used alongside patternfly-react to access virtual machine or server consoles. + +### Installing + +``` +yarn add @patternfly/react-console +``` + +or + +``` +npm install @patternfly/react-console --save +``` + +### Usage + +It's strongly advised to use the PatternFly Base CSS in your whole project, or some components may diverge in appearance: + +```js +import '@patternfly/react-core/dist/styles/base.css'; +``` + +```js +import { VncConsole, SerialConsole } from '@patternfly/react-console'; +``` + +### Building + +``` +yarn build +``` + +Note the build scripts for this are located in the root package.json under `yarn build`. + +### Testing + +Testing is done at the root of this repo. + +``` +yarn test +``` + +### Publishing + +``` +yarn publish +``` diff --git a/babel.config.js b/babel.config.js new file mode 100644 index 0000000..0fdfb56 --- /dev/null +++ b/babel.config.js @@ -0,0 +1,8 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: 'current' } }], + ['@babel/preset-react', { runtime: 'automatic' }], + '@babel/preset-flow', + '@babel/preset-typescript' + ] +}; \ No newline at end of file diff --git a/fed-mini-modules.js b/fed-mini-modules.js new file mode 100644 index 0000000..532390a --- /dev/null +++ b/fed-mini-modules.js @@ -0,0 +1,64 @@ +const fse = require('fs-extra'); +const glob = require('glob'); +const path = require('path'); + +const root = process.cwd(); + +const sourceFiles = glob + .sync(`${root}/src/*/`) + .map((name) => name.replace(/\/$/, '')); + +const indexTypings = glob.sync(`${root}/src/index.d.ts`); + +async function copyTypings(files, dest) { + const cmds = []; + files.forEach((file) => { + const fileName = file.split('/').pop(); + cmds.push(fse.copyFile(file, `${dest}/${fileName}`)); + }); + return Promise.all(cmds); +} + +async function createPackage(file) { + const fileName = file.split('/').pop(); + const esmSource = glob.sync(`${root}/esm/${fileName}/**/index.js`)[0]; + /** + * Prevent creating package.json for directories with no JS files (like CSS directories) + */ + if (!esmSource) { + return; + } + + const destFile = `${path.resolve(root, file.split('/src/').pop())}/package.json`; + + const esmRelative = path.relative(file.replace('/src', ''), esmSource); + const content = { + main: 'index.js', + module: esmRelative, + }; + const typings = glob.sync(`${root}/src/${fileName}/*.d.ts`); + let cmds = []; + content.typings = 'index.d.ts'; + cmds.push(copyTypings(typings, `${root}/${fileName}`)); + cmds.push(fse.writeJSON(destFile, content)); + return Promise.all(cmds); +} + +async function generatePackages(files) { + const cmds = files.map((file) => createPackage(file)); + return Promise.all(cmds); +} + +async function run(files) { + try { + await generatePackages(files); + if (indexTypings.length === 1) { + copyTypings(indexTypings, root); + } + } catch (error) { + console.error(error); + process.exit(1); + } +} + +run(sourceFiles); \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 0000000..1a9d559 --- /dev/null +++ b/jest.config.js @@ -0,0 +1,17 @@ +module.exports = { + clearMocks: true, + testMatch: ['**/__tests__/**/*.{js,ts}?(x)', '**/*.test.{js,ts}?(x)'], + modulePathIgnorePatterns: [ + '/packages/*.*/dist/*.*', + '/packages/*.*/public/*.*', + '/packages/*.*/.cache/*.*' + ], + roots: ['/packages'], + transform: { + '^.+\\.[jt]sx?$': 'babel-jest' + }, + moduleNameMapper: { + '\\.(css|less)$': '/styleMock.js' + }, + testEnvironment: 'jsdom' +}; diff --git a/package.json b/package.json new file mode 100644 index 0000000..3e97654 --- /dev/null +++ b/package.json @@ -0,0 +1,54 @@ +{ + "name": "@patternfly/react-console-root", + "private": true, + "version": "0.0.0", + "description": "This library provides patternfly extensions", + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "scripts": { + "build": "yarn workspace @patternfly/react-console build", + "build:docs": "yarn workspace @patternfly/react-console docs:build", + "build:fed:packages": "yarn workspace @patternfly/react-console build:fed:packages", + "start": "yarn build && concurrently --kill-others \"yarn workspace @patternfly/react-console docs:develop\"", + "serve:docs": "yarn workspace @patternfly/react-console docs:serve", + "clean": "yarn workspace @patternfly/react-console clean", + "lint:js": "node --max-old-space-size=4096 node_modules/.bin/eslint packages --ext js,jsx,ts,tsx --cache", + "lint:md": "yarn eslint packages --ext md --no-eslintrc --config .eslintrc-md.json --cache", + "lint": "yarn lint:js && yarn lint:md", + "test": "TZ=EST jest packages", + "test:a11y": "yarn workspace @patternfly/react-console test:a11y", + "serve:a11y": "yarn workspace @patternfly/react-console serve:a11y" + }, + "devDependencies": { + "react": "^17", + "react-dom": "^17", + "concurrently": "^5.3.0", + "eslint": "^8.0.1", + "eslint-plugin-import": "^2.25.2", + "eslint-plugin-markdown": "^1.0.2", + "eslint-plugin-prettier": "^3.1.4", + "eslint-plugin-react": "^7.21.4", + "eslint-config-standard-with-typescript": "^23.0.0", + "eslint-plugin-n": "^15.0.0", + "eslint-plugin-promise": "^6.0.0", + "eslint-plugin-react-hooks": "^4.6.0", + "eslint-config-prettier": "8.5.0", + "@typescript-eslint/eslint-plugin": "^5.42.0", + "@typescript-eslint/parser": "^5.42.0", + "prettier": "2.7.1", + "jest":"^29.2.2", + "babel-jest":"^29.2.2", + "@babel/core": "^7.19.6", + "@babel/preset-env": "^7.19.4", + "@babel/preset-react":"^7.18.6", + "@babel/preset-flow": "^7.18.6", + "@babel/preset-typescript": "^7.18.6", + "@testing-library/react":"^12.1.5", + "@testing-library/user-event": "13.5.0", + "@testing-library/jest-dom":"5.16.5", + "jest-environment-jsdom": "^29.2.2", + "serve": "^14.1.2" + } +} diff --git a/packages/module/generate-fed-package-json.js b/packages/module/generate-fed-package-json.js new file mode 100644 index 0000000..a72dc02 --- /dev/null +++ b/packages/module/generate-fed-package-json.js @@ -0,0 +1,65 @@ +const fse = require('fs-extra'); +const glob = require('glob'); +const path = require('path'); + +const root = process.cwd(); + +const sourceFiles = glob + .sync(`${root}/src/*/`) + .map((name) => name.replace(/\/$/, '')); + +const indexTypings = glob.sync(`${root}/src/index.d.ts`); + +async function copyTypings(files, dest) { + const cmds = []; + files.forEach((file) => { + const fileName = file.split('/').pop(); + cmds.push(fse.copyFile(file, `${dest}/${fileName}`)); + }); + return Promise.all(cmds); +} + +async function createPackage(file) { + const fileName = file.split('/').pop(); + const esmSource = glob.sync(`${root}/dist/esm/${fileName}/**/index.js`)[0]; + /** + * Prevent creating package.json for directories with no JS files (like CSS directories) + */ + if (!esmSource) { + return; + } + + const destFile = `${path.resolve(`${root}/dist/esm`, fileName)}/package.json`; + + const esmRelative = path.relative(file.replace('/dist/esm', ''), esmSource); + const content = { + main: 'index.js', + module: esmRelative, + }; + const typings = glob.sync(`${root}/src/${fileName}/*.d.ts`); + const cmds = []; + content.typings = 'index.d.ts'; + cmds.push(copyTypings(typings, `${root}/dist/${fileName}`)); + cmds.push(fse.writeJSON(destFile, content)); + return Promise.all(cmds); +} + +async function generatePackages(files) { + const cmds = files.map((file) => createPackage(file)); + return Promise.all(cmds); +} + +async function run(files) { + try { + await generatePackages(files); + if (indexTypings.length === 1) { + copyTypings(indexTypings, root); + } + } catch (error) { + // eslint-disable-next-line no-console + console.error(error); + process.exit(1); + } +} + +run(sourceFiles); \ No newline at end of file diff --git a/packages/module/package.json b/packages/module/package.json new file mode 100644 index 0000000..59646e3 --- /dev/null +++ b/packages/module/package.json @@ -0,0 +1,63 @@ +{ + "name": "@patternfly/react-console", + "version": "4.93.25", + "description": "This package provides VncConsole, SerialConsole and DesktopViewer React components to be used alongside patternfly-react to access virtual machine or server consoles.", + "main": "dist/esm/index.js", + "module": "dist/esm/index.js", + "scripts": { + "build": "tsc --build --verbose ./tsconfig.json", + "build:fed:packages": "node generate-fed-package-json.js", + "clean": "rimraf dist", + "docs:develop": "pf-docs-framework start", + "docs:build": "pf-docs-framework build all --output public", + "docs:serve": "pf-docs-framework serve public --port 5000", + "docs:screenshots": "pf-docs-framework screenshots --urlPrefix http://localhost:5000", + "test:a11y": "patternfly-a11y --config patternfly-a11y.config", + "serve:a11y": "yarn serve coverage" + }, + "repository": "git+https://github.com/patternfly/patternfly-react/.git", + "keywords": [ + "react", + "patternfly", + "console" + ], + "author": "Red Hat", + "license": "MIT", + "bugs": { + "url": "https://github.com/patternfly/react-console//issues" + }, + "homepage": "https://github.com/patternfly/react-console/#readme", + "release": { + "branches": "main" + }, + "publishConfig": { + "access": "public" + }, + "dependencies": { + "@novnc/novnc": "^1.3.0", + "@patternfly/react-core": "^4.268.0", + "@spice-project/spice-html5": "^0.2.1", + "@types/file-saver": "^2.0.1", + "file-saver": "^1.3.8", + "tslib": "^2.0.0", + "xterm": "^4.8.1", + "xterm-addon-fit": "^0.2.1" + }, + "peerDependencies": { + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18" + }, + "devDependencies": { + "@patternfly/documentation-framework": "^1.2.55", + "@patternfly/patternfly": "^4.217.1", + "@patternfly/react-table": "^4.111.4", + "@patternfly/react-code-editor": "^4.82.26", + "rimraf": "^2.6.2", + "typescript": "^4.7.4", + "react": "^16.8 || ^17 || ^18", + "react-dom": "^16.8 || ^17 || ^18", + "@types/react": "^17.0.0", + "@types/react-dom": "^17.0.0", + "@patternfly/patternfly-a11y": "4.3.1" + } +} diff --git a/packages/module/patternfly-a11y.config.js b/packages/module/patternfly-a11y.config.js new file mode 100644 index 0000000..0601252 --- /dev/null +++ b/packages/module/patternfly-a11y.config.js @@ -0,0 +1,28 @@ +const { fullscreenRoutes } = require('@patternfly/documentation-framework/routes'); + +/** + * Wait for a selector before running axe + * + * @param page page from puppeteer + */ +async function waitFor(page) { + await page.waitForSelector('#root > *'); +} + +const urls = Object.keys(fullscreenRoutes) + .map((key) => (fullscreenRoutes[key].isFullscreenOnly ? key : fullscreenRoutes[key].path.replace(/\/react$/, ''))) + .reduce((result, item) => (result.includes(item) ? result : [...result, item]), []); + +module.exports = { + prefix: 'http://localhost:5000', + waitFor, + crawl: false, + urls: [...urls], + ignoreRules: [ + 'color-contrast', + 'landmark-no-duplicate-main', + 'landmark-main-is-top-level', + 'scrollable-region-focusable' + ].join(','), + ignoreIncomplete: true +}; diff --git a/packages/module/patternfly-docs/content/extensions/react-console/design-guidelines/design-guidelines.md b/packages/module/patternfly-docs/content/extensions/react-console/design-guidelines/design-guidelines.md new file mode 100644 index 0000000..e4c1f15 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/react-console/design-guidelines/design-guidelines.md @@ -0,0 +1,23 @@ +--- +# Sidenav top-level section +# should be the same for all markdown files for each extension +section: extensions +# Sidenav secondary level section +# should be the same for all markdown files for each extension +id: react-console +# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) +source: design-guidelines +--- + +Design guidelines intro + +## Header + +### Sub-header + +Guidelines: + +1. A +1. list +1. using +1. markdown \ No newline at end of file diff --git a/packages/module/patternfly-docs/content/extensions/react-console/examples/Basic.tsx b/packages/module/patternfly-docs/content/extensions/react-console/examples/Basic.tsx new file mode 100644 index 0000000..625d5f1 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/react-console/examples/Basic.tsx @@ -0,0 +1,4 @@ +import React from 'react'; +import { ExtendedButton } from '@patternfly/react-console'; + +export const BasicExample: React.FunctionComponent = () => My custom extension button; diff --git a/packages/module/patternfly-docs/content/extensions/react-console/examples/basic.md b/packages/module/patternfly-docs/content/extensions/react-console/examples/basic.md new file mode 100644 index 0000000..a5111ac --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/react-console/examples/basic.md @@ -0,0 +1,29 @@ +--- +# Sidenav top-level section +# should be the same for all markdown files +section: extensions +# Sidenav secondary level section +# should be the same for all markdown files +id: react-console +# Tab (react | react-demos | html | html-demos | design-guidelines | accessibility) +source: react +# If you use typescript, the name of the interface to display props for +# These are found through the sourceProps function provided in patternfly-docs.source.js +propComponents: ['ExtendedButton'] +--- + +import { ExtendedButton } from "@patternfly/react-console"; + +## Basic usage + +### Example + +```js file="./Basic.tsx" + +``` + +### Fullscreen example + +```js file="./Basic.tsx" isFullscreen + +``` diff --git a/packages/module/patternfly-docs/generated/extensions/react-console/design-guidelines.js b/packages/module/patternfly-docs/generated/extensions/react-console/design-guidelines.js new file mode 100644 index 0000000..1d0cdae --- /dev/null +++ b/packages/module/patternfly-docs/generated/extensions/react-console/design-guidelines.js @@ -0,0 +1,51 @@ +import React from 'react'; +import { AutoLinkHeader, Example, Link as PatternflyThemeLink } from '@patternfly/documentation-framework/components'; + +const pageData = { + "id": "react-console", + "section": "extensions", + "source": "design-guidelines", + "slug": "/extensions/react-console/design-guidelines", + "sourceLink": "https://github.com/patternfly/patternfly-org/blob/main/packages/module/patternfly-docs/content/extensions/react-console/design-guidelines/design-guidelines.md" +}; +pageData.relativeImports = { + +}; +pageData.examples = { + +}; + +const Component = () => ( + +

+ {`Design guidelines intro`} +

+ + {`Header`} + + + {`Sub-header`} + +

+ {`Guidelines:`} +

+
    +
  1. + {`A`} +
  2. +
  3. + {`list`} +
  4. +
  5. + {`using`} +
  6. +
  7. + {`markdown`} +
  8. +
+
+); +Component.displayName = 'ExtensionsPatternflyExtensionSeedDesignGuidelinesDocs'; +Component.pageData = pageData; + +export default Component; diff --git a/packages/module/patternfly-docs/generated/extensions/react-console/react.js b/packages/module/patternfly-docs/generated/extensions/react-console/react.js new file mode 100644 index 0000000..7d1de39 --- /dev/null +++ b/packages/module/patternfly-docs/generated/extensions/react-console/react.js @@ -0,0 +1,59 @@ +import React from 'react'; +import { AutoLinkHeader, Example, Link as PatternflyThemeLink } from '@patternfly/documentation-framework/components'; +import { ExtendedButton } from "@patternfly/react-console"; +const pageData = { + "id": "react-console", + "section": "extensions", + "source": "react", + "slug": "/extensions/react-console/react", + "sourceLink": "https://github.com/patternfly/patternfly-react/blob/main/packages/module/patternfly-docs/content/extensions/react-console/examples/basic.md", + "propComponents": [ + { + "name": "ExtendedButton", + "description": "", + "props": [ + { + "name": "children", + "type": "React.ReactNode", + "description": "Content to render inside the extended button component" + } + ] + } + ], + "examples": [ + "Example" + ], + "fullscreenExamples": [ + "Fullscreen example" + ] +}; +pageData.liveContext = { + ExtendedButton +}; +pageData.relativeImports = { + +}; +pageData.examples = { + 'Example': props => + My custom extension button;\n","title":"Example","lang":"js"}}> + + , + 'Fullscreen example': props => + My custom extension button;\n","title":"Fullscreen example","lang":"js","isFullscreen":true}}> + + +}; + +const Component = () => ( + + + {`Basic usage`} + + {React.createElement(pageData.examples["Example"])} + {React.createElement(pageData.examples["Fullscreen example"])} + +); +Component.displayName = 'ExtensionsPatternflyExtensionSeedReactDocs'; +Component.pageData = pageData; + +export default Component; diff --git a/packages/module/patternfly-docs/generated/index.js b/packages/module/patternfly-docs/generated/index.js new file mode 100644 index 0000000..1d5da69 --- /dev/null +++ b/packages/module/patternfly-docs/generated/index.js @@ -0,0 +1,20 @@ +module.exports = { + '/extensions/react-console/design-guidelines': { + id: "react-console", + title: "react-console", + toc: [{"text":"Header"},[{"text":"Sub-header"}]], + section: "extensions", + source: "design-guidelines", + Component: () => import(/* webpackChunkName: "extensions/react-console/design-guidelines/index" */ './extensions/react-console/design-guidelines') + }, + '/extensions/react-console/react': { + id: "react-console", + title: "react-console", + toc: [{"text":"Basic usage"},[{"text":"Example"},{"text":"Fullscreen example"}]], + examples: ["Example"], + fullscreenExamples: ["Fullscreen example"], + section: "extensions", + source: "react", + Component: () => import(/* webpackChunkName: "extensions/react-console/react/index" */ './extensions/react-console/react') + } +}; \ No newline at end of file diff --git a/packages/module/patternfly-docs/pages/index.js b/packages/module/patternfly-docs/pages/index.js new file mode 100644 index 0000000..a3b8bde --- /dev/null +++ b/packages/module/patternfly-docs/pages/index.js @@ -0,0 +1,27 @@ +import React from 'react'; +import { Title, PageSection } from '@patternfly/react-core'; + +const centerStyle = { + flexGrow: 1, + display: 'flex', + alignItems: 'center', + justifyContent: 'center' +}; + +const IndexPage = () => { + return ( + +
+ + My extension docs + + + {'Hi people!'} + +

Welcome to my extension docs.

+
+
+ ); +}; + +export default IndexPage; diff --git a/packages/module/patternfly-docs/patternfly-docs.config.js b/packages/module/patternfly-docs/patternfly-docs.config.js new file mode 100644 index 0000000..7a4cdb6 --- /dev/null +++ b/packages/module/patternfly-docs/patternfly-docs.config.js @@ -0,0 +1,6 @@ +// This module is shared between NodeJS and babelled ES5 +module.exports = { + sideNavItems: [{ section: 'extensions' }], + topNavItems: [], + port: 8006 +}; diff --git a/packages/module/patternfly-docs/patternfly-docs.css.js b/packages/module/patternfly-docs/patternfly-docs.css.js new file mode 100644 index 0000000..240ac3b --- /dev/null +++ b/packages/module/patternfly-docs/patternfly-docs.css.js @@ -0,0 +1,8 @@ +// Patternfly +import '@patternfly/patternfly/patternfly.css'; +// Patternfly utilities +import '@patternfly/patternfly/patternfly-addons.css'; +// Global theme CSS +import '@patternfly/documentation-framework/global.css'; + +// Add your extension CSS below diff --git a/packages/module/patternfly-docs/patternfly-docs.routes.js b/packages/module/patternfly-docs/patternfly-docs.routes.js new file mode 100644 index 0000000..2abf261 --- /dev/null +++ b/packages/module/patternfly-docs/patternfly-docs.routes.js @@ -0,0 +1,12 @@ +// This module is shared between NodeJS and babelled ES5 +const isClient = Boolean(process.env.NODE_ENV); + +module.exports = { + '/': { + SyncComponent: isClient && require('./pages/index').default + }, + '/404': { + SyncComponent: isClient && require('@patternfly/documentation-framework/pages/404/index').default, + title: '404 Error' + } +}; diff --git a/packages/module/patternfly-docs/patternfly-docs.source.js b/packages/module/patternfly-docs/patternfly-docs.source.js new file mode 100644 index 0000000..29411d4 --- /dev/null +++ b/packages/module/patternfly-docs/patternfly-docs.source.js @@ -0,0 +1,24 @@ +const path = require('path'); + +module.exports = (sourceMD, sourceProps) => { + // Parse source content for props so that we can display them + const propsIgnore = ['**/*.test.tsx', '**/examples/*.tsx']; + const extensionPath = path.join(__dirname, '../src'); + sourceProps(path.join(extensionPath, '/**/*.tsx'), propsIgnore); + + // Parse md files + const contentBase = path.join(__dirname, './content'); + sourceMD(path.join(contentBase, 'extensions/**/*.md'), 'extensions'); + + /** + If you want to parse content from node_modules instead of providing a relative/absolute path, + you can do something similar to this: + const extensionPath = require + .resolve('@patternfly/react-log-viewer/package.json') + .replace('package.json', 'src'); + sourceProps(path.join(extensionPath, '/**\/*.tsx'), propsIgnore); + sourceMD(path.join(extensionPath, '../patternfly-docs/**\/examples/*.md'), 'react'); + sourceMD(path.join(extensionPath, '../patternfly-docs/**\/demos/*.md'), 'react-demos'); + sourceMD(path.join(extensionPath, '../patternfly-docs/**\/design-guidelines/*.md'), 'design-guidelines'); + */ +}; diff --git a/packages/module/src/components/AccessConsoles/AccessConsoles.tsx b/packages/module/src/components/AccessConsoles/AccessConsoles.tsx new file mode 100644 index 0000000..6637c31 --- /dev/null +++ b/packages/module/src/components/AccessConsoles/AccessConsoles.tsx @@ -0,0 +1,139 @@ +import React from 'react'; +import { css } from '@patternfly/react-styles'; +import { Select, SelectOption, SelectOptionObject, SelectVariant } from '@patternfly/react-core'; + +import { constants } from '../common/constants'; + +import styles from '@patternfly/react-styles/css/components/Consoles/AccessConsoles'; +import '@patternfly/react-styles/css/components/Consoles/AccessConsoles.css'; + +const { NONE_TYPE, SERIAL_CONSOLE_TYPE, VNC_CONSOLE_TYPE, DESKTOP_VIEWER_CONSOLE_TYPE } = constants; + +const getChildTypeName = (child: any) => + child && child.props && child.props.type ? child.props.type : (child && child.type && child.type.displayName) || null; + +const isChildOfType = (child: any, type: string) => { + if (child && child.props && child.props.type) { + return child.props.type === type; + } else if (child && child.type) { + return child.type.displayName === type; + } + return false; +}; + +export interface AccessConsolesProps { + /** + * Child element can be either + * - , or + * - or has a property "type" of value either SERIAL_CONSOLE_TYPE or VNC_CONSOLE_TYPE (useful when wrapping (composing) basic console components + */ + children?: React.ReactElement[] | React.ReactNode; + /** Placeholder text for the console selection */ + textSelectConsoleType?: string; + /** The value for the Serial Console option. This can be overriden by the type property of the child component */ + textSerialConsole?: string; + /** The value for the VNC Console option. This can be overriden by the type property of the child component */ + textVncConsole?: string; + /** The value for the Desktop Viewer Console option. This can be overriden by the type property of the child component */ + textDesktopViewerConsole?: string; + /** Initial selection of the Select */ + preselectedType?: string; // NONE_TYPE | SERIAL_CONSOLE_TYPE | VNC_CONSOLE_TYPE | DESKTOP_VIEWER_CONSOLE_TYPE; +} + +export const AccessConsoles: React.FunctionComponent = ({ + children, + textSelectConsoleType = 'Select console type', + textSerialConsole = 'Serial console', + textVncConsole = 'VNC console', + textDesktopViewerConsole = 'Desktop viewer', + preselectedType = null +}) => { + const typeMap = { + [SERIAL_CONSOLE_TYPE]: textSerialConsole, + [VNC_CONSOLE_TYPE]: textVncConsole, + [DESKTOP_VIEWER_CONSOLE_TYPE]: textDesktopViewerConsole + }; + const [type, setType] = React.useState( + preselectedType !== NONE_TYPE + ? ({ value: preselectedType, toString: () => typeMap[preselectedType] } as SelectOptionObject) + : null + ); + const [isOpen, setIsOpen] = React.useState(false); + + const getConsoleForType = (type: any) => + React.Children.map(children as React.ReactElement[], (child: any) => { + if (getChildTypeName(child) === type.value) { + return {child}; + } else { + return null; + } + }); + + const onToggle = (isOpen: boolean) => { + setIsOpen(isOpen); + }; + + const selectOptions: any[] = []; + + React.Children.toArray(children as React.ReactElement[]).forEach((child: any) => { + if (isChildOfType(child, VNC_CONSOLE_TYPE)) { + selectOptions.push( + textVncConsole } as SelectOptionObject} + /> + ); + } else if (isChildOfType(child, SERIAL_CONSOLE_TYPE)) { + selectOptions.push( + textSerialConsole } as SelectOptionObject} + /> + ); + } else if (isChildOfType(child, DESKTOP_VIEWER_CONSOLE_TYPE)) { + selectOptions.push( + textDesktopViewerConsole } as SelectOptionObject} + /> + ); + } else { + const typeText = getChildTypeName(child); + selectOptions.push( + typeText } as SelectOptionObject} + /> + ); + } + }); + return ( +
+ {React.Children.toArray(children).length > 1 && ( +
+ +
+ )} + {type && getConsoleForType(type)} +
+ ); +}; +AccessConsoles.displayName = 'AccessConsoles'; diff --git a/packages/module/src/components/AccessConsoles/__tests__/AccessConsoles.test.tsx b/packages/module/src/components/AccessConsoles/__tests__/AccessConsoles.test.tsx new file mode 100644 index 0000000..c51ea51 --- /dev/null +++ b/packages/module/src/components/AccessConsoles/__tests__/AccessConsoles.test.tsx @@ -0,0 +1,111 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { AccessConsoles } from '../AccessConsoles'; +import { SerialConsole } from '../../SerialConsole'; +import { VncConsole } from '../../VncConsole'; +import { DesktopViewer } from '../../DesktopViewer'; +import { constants } from '../../common/constants'; + +const { SERIAL_CONSOLE_TYPE, LOADING } = constants; + +const MyVncConsoleTestWrapper = () =>

VNC console text

; +const SerialConsoleConnected = () =>

Serial console text

; + +const vnc = { + address: 'my.host.com', + port: 5902, + tlsPort: '5903' +}; + +describe('AccessConsoles', () => { + beforeAll(() => { + window.HTMLCanvasElement.prototype.getContext = () => ({ canvas: {} } as any); + }); + + test('with SerialConsole as a single child', () => { + const { asFragment } = render( + + + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + test('with VncConsole as a single child', () => { + const { asFragment } = render( + + + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + test('with SerialConsole and VncConsole as children', () => { + const { asFragment } = render( + + + + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + test('with wrapped SerialConsole as a child', () => { + const { asFragment } = render( + + + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + test('with preselected SerialConsole', () => { + const { asFragment } = render( + + + + ); + + expect(asFragment()).toMatchSnapshot(); + }); + + test('switching SerialConsole and VncConsole', async () => { + const user = userEvent.setup(); + + render( + + + + + ); + + // VNC (first option) is initially selected + expect(screen.queryByText(/Loading/)).toBeNull(); + expect(screen.getByText('VNC console text')).toBeInTheDocument(); + + // Open dropdown and select "Serial console" option + await user.click(screen.getByRole('button', { name: 'Options menu' })); + await user.click(screen.getByText('Serial console', { selector: 'button' })); + + // VNC content is no longer visible, and loading contents of the Serial console are visible. + expect(screen.getByText(/Loading/)).toBeInTheDocument(); + expect(screen.queryByText('VNC console text')).toBeNull(); + }); + + test('Empty', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + test('with DesktopViewer', () => { + const { asFragment } = render( + + + + ); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/packages/module/src/components/AccessConsoles/__tests__/__snapshots__/AccessConsoles.test.tsx.snap b/packages/module/src/components/AccessConsoles/__tests__/__snapshots__/AccessConsoles.test.tsx.snap new file mode 100644 index 0000000..15c7a43 --- /dev/null +++ b/packages/module/src/components/AccessConsoles/__tests__/__snapshots__/AccessConsoles.test.tsx.snap @@ -0,0 +1,171 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AccessConsoles Empty 1`] = ` + +
+ +`; + +exports[`AccessConsoles with DesktopViewer 1`] = ` + +
+ +`; + +exports[`AccessConsoles with SerialConsole and VncConsole as children 1`] = ` + +
+
+
+ +
+
+
+
+`; + +exports[`AccessConsoles with SerialConsole as a single child 1`] = ` + +
+ +`; + +exports[`AccessConsoles with VncConsole as a single child 1`] = ` + +
+ +`; + +exports[`AccessConsoles with preselected SerialConsole 1`] = ` + +
+
+ + +
+
+
+
+
+ + + + + +
+
+ Loading ... +
+
+
+
+
+
+`; + +exports[`AccessConsoles with wrapped SerialConsole as a child 1`] = ` + +
+

+ Serial console text +

+
+
+`; diff --git a/packages/module/src/components/AccessConsoles/examples/AccessConsoles.md b/packages/module/src/components/AccessConsoles/examples/AccessConsoles.md new file mode 100644 index 0000000..66d90d2 --- /dev/null +++ b/packages/module/src/components/AccessConsoles/examples/AccessConsoles.md @@ -0,0 +1,49 @@ +--- +id: Access consoles +section: extensions +propComponents: ['AccessConsoles'] +ouia: false +beta: true +--- + +### Note +AccessConsoles lives in its own package at [`@patternfly/react-console`](https://www.npmjs.com/package/@patternfly/react-console) + +import { AccessConsoles, SerialConsole, VncConsole, DesktopViewer } from '@patternfly/react-console'; +import { SerialConsoleCustom } from './SerialConsoleCustom.jsx'; + +## Examples + +### Basic Usage +```js +import React from 'react'; +import { AccessConsoles, SerialConsole, VncConsole, DesktopViewer } from '@patternfly/react-console'; +import { SerialConsoleCustom } from './SerialConsoleCustom.jsx'; + +AccessConsolesVariants = () => { + const [status, setStatus] = React.useState('disconnected'); + const setConnected = React.useRef(debounce(() => setStatus('connected'), 3000)).current; + const ref = React.createRef(); + + return ( + + + { + setStatus('loading'); + setConnected(); + }} + status={status} + onDisconnect={() => setStatus('disconnected')} + onData={data => { + ref.current.onDataReceived(data); + }} + ref={ref} + /> + + + + ); +}; +``` + diff --git a/packages/module/src/components/AccessConsoles/examples/SerialConsoleCustom.jsx b/packages/module/src/components/AccessConsoles/examples/SerialConsoleCustom.jsx new file mode 100644 index 0000000..2cd4962 --- /dev/null +++ b/packages/module/src/components/AccessConsoles/examples/SerialConsoleCustom.jsx @@ -0,0 +1,24 @@ +import React from 'react'; +import { debounce } from '@patternfly/react-core'; +import { SerialConsole } from '@patternfly/react-console'; + +export const SerialConsoleCustom = () => { + const [status, setStatus] = React.useState('disconnected'); + const setConnected = React.useRef(debounce(() => setStatus('connected'), 3000)).current; + const ref2 = React.createRef(); + + return ( + { + setStatus('loading'); + setConnected(); + }} + onDisconnect={() => setStatus('disconnected')} + onData={data => { + ref2.current.onDataReceived(data); + }} + status={status} + ref={ref2} + /> + ); +}; diff --git a/packages/module/src/components/AccessConsoles/index.ts b/packages/module/src/components/AccessConsoles/index.ts new file mode 100644 index 0000000..d3e928a --- /dev/null +++ b/packages/module/src/components/AccessConsoles/index.ts @@ -0,0 +1 @@ +export * from './AccessConsoles'; diff --git a/packages/module/src/components/DesktopViewer/ConnectWithRemoteViewer.tsx b/packages/module/src/components/DesktopViewer/ConnectWithRemoteViewer.tsx new file mode 100644 index 0000000..9ca7494 --- /dev/null +++ b/packages/module/src/components/DesktopViewer/ConnectWithRemoteViewer.tsx @@ -0,0 +1,132 @@ +import React from 'react'; +import { Button, ExpandableSection } from '@patternfly/react-core'; + +import { constants } from '../common/constants'; + +import { MoreInformationDefaultContent } from './MoreInformationDefaultContent'; +import { MoreInformationDefaultRDPContent } from './MoreInformationDefaultRDPContent'; + +import { + generateDescriptorFile, + downloadFile, + onGenerateFunctionType, + onDownloadFunctionType +} from './consoleDescriptorGenerator'; +import { ConsoleDetailPropType } from './ConsoleDetailPropType'; + +const { + VNC_CONSOLE_TYPE, + SPICE_CONSOLE_TYPE, + RDP_CONSOLE_TYPE, + DEFAULT_VV_FILENAME, + DEFAULT_VV_MIMETYPE, + DEFAULT_RDP_FILENAME, + DEFAULT_RDP_MIMETYPE +} = constants; + +export interface ConnectWithRemoteViewerProps extends React.HTMLProps { + /** Custom content of more-info section */ + children?: React.ReactNode; + + /** Connection details for Spice */ + spice?: ConsoleDetailPropType; + /** Connection details for VNC */ + vnc?: ConsoleDetailPropType; + /** Connection details for RDP */ + rdp?: ConsoleDetailPropType; + + /** Callback function. Generate content of .vv file. + * Parameters: ({ _console, type }) => ({ + * content, // required string value + * mimeType, // optional, default application/x-virt-viewer + * fileName // optional, default: console.vv + * }) + */ + onGenerate?: onGenerateFunctionType; + /** Callback function. Perform file download. + * Default implementation is usually good enough, but i.e. in case of environment with tight + * content security policy set, this might be required. + * + * Examples for alternative file-download implementations: + * - use of iframe + * - use of http-server + * + * Parameters: (fileName, content, mimeType) => {} + */ + onDownload?: onDownloadFunctionType; + + textConnectWithRemoteViewer?: string; + textMoreInfo?: string; + textMoreInfoContent?: string | React.ReactNode; + textConnectWithRDP?: string; + textMoreRDPInfo?: string; + textMoreRDPInfoContent?: string | React.ReactNode; +} + +export const ConnectWithRemoteViewer: React.FunctionComponent = ({ + onGenerate = generateDescriptorFile, + onDownload = downloadFile, + spice = null, + vnc = null, + rdp = null, + textConnectWithRemoteViewer = 'Launch Remote Viewer', + textConnectWithRDP = 'Launch Remote Desktop', + textMoreInfo = 'Remote Viewer Details', + textMoreInfoContent = '', + textMoreRDPInfo = 'Remote Desktop Details', + textMoreRDPInfoContent = '' +}: ConnectWithRemoteViewerProps) => { + const [isExpandedDefault, setIsExpandedDefault] = React.useState(false); + const [isExpandedRDP, setIsExpandedRDP] = React.useState(false); + + const _console = spice || vnc; // strictly prefer spice over vnc + + const onClickVV = () => { + const type = spice ? SPICE_CONSOLE_TYPE : VNC_CONSOLE_TYPE; + if (_console) { + const vv = onGenerate(_console, type); + return onDownload(DEFAULT_VV_FILENAME, vv.content, vv.mimeType || DEFAULT_VV_MIMETYPE); + } + }; + + const onClickRDP = () => { + const rdpFile = onGenerate(rdp, RDP_CONSOLE_TYPE); + return onDownload(DEFAULT_RDP_FILENAME, rdpFile.content, rdpFile.mimeType || DEFAULT_RDP_MIMETYPE); + }; + + // RDP button is rendered only if the protocol is available + // If none of Spice or VNC is available, the .vv button is disabled (but rendered) + return ( +
+
+ + {!!rdp && ( + + )} +
+ {!!_console && ( + setIsExpandedDefault(isExpanded)} + > + + + )} + {!!rdp && ( + setIsExpandedRDP(isExpanded)} + > + + + )} +
+ ); +}; +ConnectWithRemoteViewer.displayName = 'ConnectWithRemoteViewer'; diff --git a/packages/module/src/components/DesktopViewer/ConsoleDetailPropType.tsx b/packages/module/src/components/DesktopViewer/ConsoleDetailPropType.tsx new file mode 100644 index 0000000..f4f73b5 --- /dev/null +++ b/packages/module/src/components/DesktopViewer/ConsoleDetailPropType.tsx @@ -0,0 +1,5 @@ +export interface ConsoleDetailPropType { + address: string; + port: string | number; + tlsPort?: string | number; +} diff --git a/packages/module/src/components/DesktopViewer/DesktopViewer.tsx b/packages/module/src/components/DesktopViewer/DesktopViewer.tsx new file mode 100644 index 0000000..6db7b27 --- /dev/null +++ b/packages/module/src/components/DesktopViewer/DesktopViewer.tsx @@ -0,0 +1,89 @@ +import React from 'react'; + +import { css } from '@patternfly/react-styles'; +import { ManualConnection } from './ManualConnection'; +import { ConnectWithRemoteViewer, ConnectWithRemoteViewerProps } from './ConnectWithRemoteViewer'; +import { ConsoleDetailPropType } from './ConsoleDetailPropType'; + +import styles from '@patternfly/react-styles/css/components/Consoles/DesktopViewer'; +import '@patternfly/react-styles/css/components/Consoles/DesktopViewer.css'; + +export interface DesktopViewerProps extends ConnectWithRemoteViewerProps { + /** Custom content of more-info section */ + children?: React.ReactNode; + /** Connection details for Spice */ + spice?: ConsoleDetailPropType; + /** Connection details for VNC */ + vnc?: ConsoleDetailPropType; + /** Connection details for RDP */ + rdp?: ConsoleDetailPropType; + + textManualConnection?: string; + textNoProtocol?: string; + textConnectWith?: string; + + textAddress?: string; + textSpiceAddress?: string; + textVNCAddress?: string; + textSpicePort?: string; + textVNCPort?: string; + textSpiceTlsPort?: string; + textVNCTlsPort?: string; + textRDPPort?: string; + textRdpAddress?: string; + + textConnectWithRemoteViewer?: string; + textConnectWithRDP?: string; + /** Text that appears in the toggle */ + textMoreInfo?: string; + /** The information content appearing above the description list for guidelines to install virt-viewer */ + textMoreInfoContent?: string | React.ReactNode; + /** Text that appears in the toggle */ + textMoreRDPInfo?: string; + /** The information content appearing above the description list for guidelines to install virt-viewer */ + textMoreRDPInfoContent?: string | React.ReactNode; +} + +export const DesktopViewer: React.FunctionComponent = ({ + children = null, + spice = null, + vnc = null, + rdp = null, + ...props +}: DesktopViewerProps) => ( +
+ + {children} + + +
+); +DesktopViewer.displayName = 'DesktopViewer'; diff --git a/packages/module/src/components/DesktopViewer/ManualConnection.tsx b/packages/module/src/components/DesktopViewer/ManualConnection.tsx new file mode 100644 index 0000000..a65a599 --- /dev/null +++ b/packages/module/src/components/DesktopViewer/ManualConnection.tsx @@ -0,0 +1,83 @@ +import React from 'react'; + +import { ConsoleDetailPropType } from './ConsoleDetailPropType'; +import { + DescriptionList, + DescriptionListGroup, + DescriptionListTerm, + DescriptionListDescription, + Title +} from '@patternfly/react-core'; + +export interface DetailProps extends React.HTMLProps { + title?: string; + value: string | number; +} +const Detail: React.FunctionComponent = ({ title, value }: DetailProps) => ( + + {title} + {value} + +); + +export interface ManualConnectionProps extends React.HTMLProps { + spice?: ConsoleDetailPropType; + vnc?: ConsoleDetailPropType; + rdp?: ConsoleDetailPropType; + + textManualConnection: string; + textNoProtocol: string; + textConnectWith: string; + + textAddress: string; + textSpiceAddress: string; + textVNCAddress: string; + textSpicePort: string; + textVNCPort: string; + textSpiceTlsPort: string; + textVNCTlsPort: string; + textRDPPort: string; + textRdpAddress: string; +} +export const ManualConnection: React.FunctionComponent = ({ + spice = null, + vnc = null, + rdp = null, + textManualConnection = 'Manual Connection', + textNoProtocol = 'No connection available.', + textConnectWith = 'Connect with any viewer application for following protocols', + textAddress = 'Address', + textSpiceAddress = 'SPICE Address', + textVNCAddress = 'VNC Address', + textSpicePort = 'SPICE Port', + textVNCPort = 'VNC Port', + textSpiceTlsPort = 'SPICE TLS Port', + textVNCTlsPort = 'VNC TLS Port', + textRDPPort = 'RDP Port', + textRdpAddress = 'RDP Address' +}: ManualConnectionProps) => { + const msg = spice || vnc ? textConnectWith : textNoProtocol; + const address = spice && vnc && spice.address === vnc.address && spice.address; + const rdpAddress = rdp && rdp.address !== address ? rdp.address : null; + + return ( +
+ + {textManualConnection} + +

{msg}

+ + {address && } + {!address && spice && } + {rdpAddress && } + {spice && spice.port && } + {spice && spice.tlsPort && } + {!address && vnc && } + {vnc && vnc.port && } + {vnc && vnc.tlsPort && } + {rdp && rdp.port && } + +
+ ); +}; +ManualConnection.displayName = 'ManualConnection'; diff --git a/packages/module/src/components/DesktopViewer/MoreInformationDefaultContent.tsx b/packages/module/src/components/DesktopViewer/MoreInformationDefaultContent.tsx new file mode 100644 index 0000000..68fb507 --- /dev/null +++ b/packages/module/src/components/DesktopViewer/MoreInformationDefaultContent.tsx @@ -0,0 +1,40 @@ +import React from 'react'; + +import { MoreInformationInstallVariant } from './MoreInformationInstallVariant'; +import { DescriptionList } from '@patternfly/react-core'; + +export interface MoreInformationDefaultContentProps { + textMoreInfoContent?: string | React.ReactNode; +} +export const MoreInformationDefaultContent: React.FunctionComponent = ({ + textMoreInfoContent = ( + <> +

+ Clicking "Launch Remote Viewer" will download a .vv file and launch Remote Viewer +

+

+ Remote Viewer is available for most operating systems. To install it, search for it in GNOME Software or + run the following: +

+ + ) +}: MoreInformationDefaultContentProps) => ( + <> + {textMoreInfoContent} + + + + + + +
+ Download the MSI from{' '} + + virt-manager.org + +
+
+
+ +); +MoreInformationDefaultContent.displayName = 'MoreInformationDefaultContent'; diff --git a/packages/module/src/components/DesktopViewer/MoreInformationDefaultRDPContent.tsx b/packages/module/src/components/DesktopViewer/MoreInformationDefaultRDPContent.tsx new file mode 100644 index 0000000..931743c --- /dev/null +++ b/packages/module/src/components/DesktopViewer/MoreInformationDefaultRDPContent.tsx @@ -0,0 +1,22 @@ +import React from 'react'; + +export interface MoreInformationDefaultRDPContentProps { + textMoreRDPInfoContent?: string | React.ReactNode; +} +export const MoreInformationDefaultRDPContent: React.FunctionComponent = ({ + textMoreRDPInfoContent = ( + <> +

+ Clicking "Launch Remote Desktop" will download an .rdp file and launch Remote Desktop Viewer. +

+

+ Since the RDP is native Windows protocol, the best experience is achieved when used on Windows-based desktop. +

+

+ For other operating systems, the Remote Viewer is recommended. If RDP needs to be accessed anyway, the{' '} + Remmina client is available. +

+ + ) +}: MoreInformationDefaultRDPContentProps) => <>{textMoreRDPInfoContent}; +MoreInformationDefaultRDPContent.displayName = 'MoreInformationDefaultRDPContent'; diff --git a/packages/module/src/components/DesktopViewer/MoreInformationInstallVariant.tsx b/packages/module/src/components/DesktopViewer/MoreInformationInstallVariant.tsx new file mode 100644 index 0000000..4ca3c29 --- /dev/null +++ b/packages/module/src/components/DesktopViewer/MoreInformationInstallVariant.tsx @@ -0,0 +1,21 @@ +import React from 'react'; + +import { DescriptionListTerm, DescriptionListGroup, DescriptionListDescription } from '@patternfly/react-core'; + +export interface MoreInformationInstallVariantProps { + children?: React.ReactNode; + os: string; + content?: string | React.ReactNode; +} + +export const MoreInformationInstallVariant: React.FunctionComponent = ({ + os = '', + content = null as string | React.ReactNode, + children = null +}: MoreInformationInstallVariantProps) => ( + + {os} + {content || children} + +); +MoreInformationInstallVariant.displayName = 'MoreInformationInstallVariant'; diff --git a/packages/module/src/components/DesktopViewer/__tests__/DesktopViewer.test.tsx b/packages/module/src/components/DesktopViewer/__tests__/DesktopViewer.test.tsx new file mode 100644 index 0000000..3031505 --- /dev/null +++ b/packages/module/src/components/DesktopViewer/__tests__/DesktopViewer.test.tsx @@ -0,0 +1,113 @@ +import React from 'react'; + +import { render, screen } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import { DesktopViewer } from '../DesktopViewer'; +import { MoreInformationDefaultContent } from '../MoreInformationDefaultContent'; +import { generateDescriptorFile } from '../consoleDescriptorGenerator'; +import { constants } from '../../common/constants'; + +const { SPICE_CONSOLE_TYPE, RDP_CONSOLE_TYPE, DEFAULT_RDP_PORT } = constants; + +const spice = { + address: 'my.host.com', + port: 5900, + tlsPort: '5901' +}; +const vnc = { + address: 'my.host.com', + port: 5902, + tlsPort: '5903' +}; + +const rdp = { + address: 'my.host.com', + port: DEFAULT_RDP_PORT +}; + +const rdp2 = { + address: 'my.differenthost.com', + port: 1234 +}; + +describe('DesktopViewer', () => { + test('empty', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + test('with Spice and VNC', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + test('with Spice, VNC and RDP', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + test('with Spice, VNC and RDP (different hostname)', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + test('launch button', async () => { + const onDownload = jest.fn(); + const onGenerate = jest.fn().mockReturnValue({ content: 'Foo' }); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Launch Remote Viewer' })); + expect(onGenerate).toHaveBeenCalledTimes(1); + expect(onDownload).toHaveBeenCalledTimes(1); + }); + + test('RDP launch button', async () => { + const onDownload = jest.fn(); + const onGenerate = jest.fn().mockReturnValue({ content: 'Foo' }); + const user = userEvent.setup(); + + render(); + + await user.click(screen.getByRole('button', { name: 'Launch Remote Desktop' })); + expect(onGenerate).toHaveBeenCalledTimes(1); + expect(onDownload).toHaveBeenCalledTimes(1); + }); + + test('with custom more-info content', async () => { + const user = userEvent.setup(); + + render( + +

My more-info content

+
+ ); + + expect(screen.queryByText('My more-info content')).toBeNull(); + + await user.click(screen.getByRole('button', { name: 'Remote Viewer Details' })); + // If one of the items is shown in the description list, the rest will be in the document as well. + expect(screen.getByText('RHEL, CentOS')).toBeInTheDocument(); + }); + + test('default MoreInformationContent', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); + + test('default implementation of generateVVFile()', () => { + const output = generateDescriptorFile(spice, SPICE_CONSOLE_TYPE); + expect(output.mimeType).toMatch('application/x-virt-viewer'); + expect(output.content).toMatch( + '[virt-viewer]\ntype=spice\nhost=my.host.com\nport=5900\ndelete-this-file=1\nfullscreen=0\n' + ); + }); + + test('default implementation of generateRDPFile()', () => { + const output = generateDescriptorFile(rdp, RDP_CONSOLE_TYPE); + expect(output.mimeType).toMatch('application/rdp'); + expect(output.content).toEqual(expect.stringContaining('full address:s:my.host.com:3389\n')); // the rest is a constant so far + }); +}); diff --git a/packages/module/src/components/DesktopViewer/__tests__/__snapshots__/DesktopViewer.test.tsx.snap b/packages/module/src/components/DesktopViewer/__tests__/__snapshots__/DesktopViewer.test.tsx.snap new file mode 100644 index 0000000..c970a89 --- /dev/null +++ b/packages/module/src/components/DesktopViewer/__tests__/__snapshots__/DesktopViewer.test.tsx.snap @@ -0,0 +1,1298 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DesktopViewer default MoreInformationContent 1`] = ` + +

+ Clicking "Launch Remote Viewer" will download a .vv file and launch + + Remote Viewer + +

+

+ + Remote Viewer + + is available for most operating systems. To install it, search for it in GNOME Software or run the following: +

+
+
+
+ + RHEL, CentOS + +
+
+
+ sudo yum install virt-viewer +
+
+
+
+
+ + Fedora + +
+
+
+ sudo dnf install virt-viewer +
+
+
+
+
+ + SLE, openSUSE + +
+
+
+ sudo zypper install virt-viewer +
+
+
+
+
+ + Ubuntu, Debian + +
+
+
+ sudo apt-get install virt-viewer +
+
+
+
+
+ + Windows + +
+
+
+
+ Download the MSI from + + virt-manager.org + +
+
+
+
+
+
+`; + +exports[`DesktopViewer empty 1`] = ` + +
+
+
+ +
+
+
+

+ Manual Connection +

+

+ No connection available. +

+
+
+
+
+`; + +exports[`DesktopViewer with Spice and VNC 1`] = ` + +
+
+
+ +
+
+ + +
+
+
+

+ Manual Connection +

+

+ Connect with any viewer application for following protocols +

+
+
+
+ + Address + +
+
+
+ my.host.com +
+
+
+
+
+ + SPICE Port + +
+
+
+ 5900 +
+
+
+
+
+ + SPICE TLS Port + +
+
+
+ 5901 +
+
+
+
+
+ + VNC Port + +
+
+
+ 5902 +
+
+
+
+
+ + VNC TLS Port + +
+
+
+ 5903 +
+
+
+
+
+
+
+`; + +exports[`DesktopViewer with Spice, VNC and RDP (different hostname) 1`] = ` + +
+
+
+ + +
+
+ + +
+
+ + +
+
+

+ Manual Connection +

+

+ Connect with any viewer application for following protocols +

+
+
+
+ + Address + +
+
+
+ my.host.com +
+
+
+
+
+ + RDP Address + +
+
+
+ my.differenthost.com +
+
+
+
+
+ + SPICE Port + +
+
+
+ 5900 +
+
+
+
+
+ + SPICE TLS Port + +
+
+
+ 5901 +
+
+
+
+
+ + VNC Port + +
+
+
+ 5902 +
+
+
+
+
+ + VNC TLS Port + +
+
+
+ 5903 +
+
+
+
+
+ + RDP Port + +
+
+
+ 1234 +
+
+
+
+
+
+ +`; + +exports[`DesktopViewer with Spice, VNC and RDP 1`] = ` + +
+
+
+ + +
+
+ + +
+
+ + +
+
+

+ Manual Connection +

+

+ Connect with any viewer application for following protocols +

+
+
+
+ + Address + +
+
+
+ my.host.com +
+
+
+
+
+ + SPICE Port + +
+
+
+ 5900 +
+
+
+
+
+ + SPICE TLS Port + +
+
+
+ 5901 +
+
+
+
+
+ + VNC Port + +
+
+
+ 5902 +
+
+
+
+
+ + VNC TLS Port + +
+
+
+ 5903 +
+
+
+
+
+ + RDP Port + +
+
+
+ 3389 +
+
+
+
+
+
+ +`; diff --git a/packages/module/src/components/DesktopViewer/consoleDescriptorGenerator.tsx b/packages/module/src/components/DesktopViewer/consoleDescriptorGenerator.tsx new file mode 100644 index 0000000..3f626ac --- /dev/null +++ b/packages/module/src/components/DesktopViewer/consoleDescriptorGenerator.tsx @@ -0,0 +1,118 @@ +import { saveAs } from 'file-saver'; + +import { ConsoleDetailPropType } from './ConsoleDetailPropType'; +import { constants } from '../common/constants'; + +const { + VNC_CONSOLE_TYPE, + SPICE_CONSOLE_TYPE, + RDP_CONSOLE_TYPE, + DEFAULT_VV_MIMETYPE, + DEFAULT_RDP_MIMETYPE, + DEFAULT_RDP_PORT +} = constants; + +export type onDownloadFunctionType = (fileName: string, content: string, mimeType: string) => void; + +/** + * @param {string} fileName Default vv filename + * @param {string} content Content of the file + * @param {string} mimeType Defaylt vv mimeType + */ +export function downloadFile(fileName: string, content: string, mimeType: string) { + const blob = new Blob([content], { type: mimeType }); + saveAs(blob, fileName); +} + +/** + * @param {string} _console Object describing the console file content + * @param {string} type VNC_CONSOLE_TYPE | SPICE_CONSOLE_TYPE + */ +function generateVVFile(_console: ConsoleDetailPropType, type: string): { content: string; mimeType: string } { + const TYPES = { + [VNC_CONSOLE_TYPE]: 'vnc', + [SPICE_CONSOLE_TYPE]: 'spice' + }; + + const content = + '[virt-viewer]\n' + + `type=${TYPES[type] || type}\n` + // vnc or spice + `host=${_console.address}\n` + + `port=${_console.port}\n` + + 'delete-this-file=1\n' + + 'fullscreen=0\n'; + + return { + content, + mimeType: DEFAULT_VV_MIMETYPE + }; +} + +/** + * @param {string} _console Object describing the console file content + */ +function generateRDPFile(_console: ConsoleDetailPropType): { content: string; mimeType: string } { + const port = typeof _console.port !== 'undefined' && _console.port !== null ? _console.port : DEFAULT_RDP_PORT; + const content = [ + `full address:s:${_console.address}:${port}`, + '\nusername:s:Administrator', + '\nscreen mode id:i:2', // set 2 for full screen + '\nprompt for credentials:i:1', + '\ndesktopwidth:i:0', + '\ndesktopheight:i:0', + '\nauthentication level:i:2', + '\nredirectclipboard:i:1', + '\nsession bpp:i:32', + '\ncompression:i:1', + '\nkeyboardhook:i:2', + '\naudiocapturemode:i:0', + '\nvideoplaybackmode:i:1', + '\nconnection type:i:2', + '\ndisplayconnectionbar:i:1', + '\ndisable wallpaper:i:1', + '\nallow font smoothing:i:1', + '\nallow desktop composition:i:0', + '\ndisable full window drag:i:1', + '\ndisable menu anims:i:1', + '\ndisable themes:i:0', + '\ndisable cursor setting:i:0', + '\nbitmapcachepersistenable:i:1', + '\naudiomode:i:0', + '\nredirectcomports:i:0', + '\nredirectposdevices:i:0', + '\nredirectdirectx:i:1', + '\nautoreconnection enabled:i:1', + '\nnegotiate security layer:i:1', + '\nremoteapplicationmode:i:0', + '\nalternate shell:s:', + '\nshell working directory:s:', + '\ngatewayhostname:s:', + '\ngatewayusagemethod:i:4', + '\ngatewaycredentialssource:i:4', + '\ngatewayprofileusagemethod:i:0', + '\npromptcredentialonce:i:1', + '\nuse redirection server name:i:0', + '\n' + ].join(''); + + return { + content, + mimeType: DEFAULT_RDP_MIMETYPE + }; +} + +export type onGenerateFunctionType = ( + _console: ConsoleDetailPropType, + type: string +) => { content: string; mimeType: string }; + +/** + * @param {string} _console Object describing the console file content + * @param {string} type VNC_CONSOLE_TYPE | SPICE_CONSOLE_TYPE + */ +export function generateDescriptorFile( + _console: ConsoleDetailPropType, + type: string +): { content: string; mimeType: string } { + return type === RDP_CONSOLE_TYPE ? generateRDPFile(_console) : generateVVFile(_console, type); +} diff --git a/packages/module/src/components/DesktopViewer/examples/DesktopViewer.md b/packages/module/src/components/DesktopViewer/examples/DesktopViewer.md new file mode 100644 index 0000000..4015283 --- /dev/null +++ b/packages/module/src/components/DesktopViewer/examples/DesktopViewer.md @@ -0,0 +1,21 @@ +--- +id: DesktopViewer +section: consoles +propComponents: ['DesktopViewer'] +ouia: false +beta: true +--- +### Note +DesktopViewer lives in its own package at [`@patternfly/react-console`](https://www.npmjs.com/package/@patternfly/react-console) + +import { DesktopViewer } from '@patternfly/react-console'; + +## Examples + +### Basic Usage +```js +import React from 'react'; +import { DesktopViewer } from '@patternfly/react-console'; + + +``` diff --git a/packages/module/src/components/DesktopViewer/index.ts b/packages/module/src/components/DesktopViewer/index.ts new file mode 100644 index 0000000..0c6fb7d --- /dev/null +++ b/packages/module/src/components/DesktopViewer/index.ts @@ -0,0 +1,3 @@ +export * from './DesktopViewer'; +export * from './MoreInformationDefaultContent'; +export * from './MoreInformationInstallVariant'; diff --git a/packages/module/src/components/SerialConsole/SerialConsole.tsx b/packages/module/src/components/SerialConsole/SerialConsole.tsx new file mode 100644 index 0000000..f26f8c8 --- /dev/null +++ b/packages/module/src/components/SerialConsole/SerialConsole.tsx @@ -0,0 +1,144 @@ +import React from 'react'; + +import { css } from '@patternfly/react-styles'; +import { Button, EmptyState, EmptyStateBody, EmptyStateIcon, Spinner } from '@patternfly/react-core'; + +import { XTerm, XTermProps } from './XTerm'; +import { SerialConsoleActions } from './SerialConsoleActions'; + +import { constants } from '../common/constants'; + +import styles from '@patternfly/react-styles/css/components/Consoles/SerialConsole'; +import '@patternfly/react-styles/css/components/Consoles/xterm.css'; +import '@patternfly/react-styles/css/components/Consoles/SerialConsole.css'; + +const { CONNECTED, DISCONNECTED, LOADING } = constants; + +export interface SerialConsoleProps extends XTermProps { + /** Initiate connection to backend. In other words, the calling components manages connection state. */ + onConnect: () => void; + /** Close connection to backend */ + onDisconnect: () => void; + /** Terminal produced data, like key-press */ + onData: (e: string) => void; + /** Terminal title has been changed */ + onTitleChanged?: () => void; + /** Connection status; a value from [''connected'; 'disconnected'; 'loading']. Default is 'loading' for a not matching value. */ + /** The number of columns to resize to */ + cols?: number; + /** The number of rows to resize to */ + rows?: number; + fontFamily?: string; + fontSize?: number; + status?: string; + /** Text content rendered inside the Connect button */ + textConnect?: string; + /** Text content rendered inside the Disconnect button */ + textDisconnect?: string; + /** Text content rendered inside the Reset button */ + textReset?: string; + /* Text content rendered inside the EmptyState for when console is disconnnected */ + textDisconnected?: string; + /* Text content rendered inside the EmptyState for when console is loading */ + textLoading?: string; + /** A reference object to attach to the SerialConsole. */ + innerRef?: React.RefObject; +} + +const SerialConsoleBase: React.FunctionComponent = ({ + onConnect, + onDisconnect, + onTitleChanged = () => {}, + onData, + cols, + rows, + fontFamily, + fontSize, + status = 'loading', + textConnect = 'Connect', + textDisconnect, + textReset, + textDisconnected = 'Click Connect to open serial console.', + textLoading = 'Loading ...', + innerRef +}) => { + React.useEffect(() => { + onConnect(); + return () => { + onDisconnect(); + }; + }, [onConnect, onDisconnect]); + + const onConnectClick = () => { + onConnect(); + focusTerminal(); + }; + + const onDisconnectClick = () => { + onDisconnect(); + focusTerminal(); + }; + + const onResetClick = () => { + onDisconnect(); + onConnect(); + focusTerminal(); + }; + + const focusTerminal = () => { + innerRef && innerRef.current && innerRef.current.focusTerminal(); + }; + + let terminal; + switch (status) { + case CONNECTED: + terminal = ( + + ); + break; + case DISCONNECTED: + terminal = ( + + {textDisconnected} + + + ); + break; + case LOADING: + default: + terminal = ( + + + {textLoading} + + ); + break; + } + + return ( + <> + {status !== DISCONNECTED && ( + + )} +
{terminal}
+ + ); +}; +SerialConsoleBase.displayName = 'SerialConsoleBase'; +export const SerialConsole = React.forwardRef((props: SerialConsoleProps, ref: React.Ref) => ( + } {...props} /> +)); +SerialConsole.displayName = 'SerialConsole'; diff --git a/packages/module/src/components/SerialConsole/SerialConsoleActions.tsx b/packages/module/src/components/SerialConsole/SerialConsoleActions.tsx new file mode 100644 index 0000000..770f557 --- /dev/null +++ b/packages/module/src/components/SerialConsole/SerialConsoleActions.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { css } from '@patternfly/react-styles'; +import { Button } from '@patternfly/react-core'; + +import styles from '@patternfly/react-styles/css/components/Consoles/SerialConsole'; + +export interface SerialConsoleActionsProps extends React.HTMLProps { + onDisconnect: () => void; + onReset: () => void; + textDisconnect?: string; + textReset?: string; +} + +export const SerialConsoleActions: React.FunctionComponent = ({ + textDisconnect = 'Disconnect', + textReset = 'Reset', + ...props +}: SerialConsoleActionsProps) => ( +
+ + +
+); +SerialConsoleActions.displayName = 'SerialConsoleActions'; diff --git a/packages/module/src/components/SerialConsole/XTerm.tsx b/packages/module/src/components/SerialConsole/XTerm.tsx new file mode 100644 index 0000000..12d8a44 --- /dev/null +++ b/packages/module/src/components/SerialConsole/XTerm.tsx @@ -0,0 +1,135 @@ +import React, { useImperativeHandle } from 'react'; + +import { Terminal } from 'xterm'; +import { FitAddon } from 'xterm-addon-fit'; + +import { debounce, canUseDOM } from '@patternfly/react-core'; + +export interface XTermProps { + /** The number of columns to resize to */ + cols?: number; + /** The number of rows to resize to */ + rows?: number; + fontFamily?: string; + fontSize?: number; + /** Terminal title has been changed. */ + onTitleChanged?: (title: string) => void; + /** Data to be sent from terminal to backend; (data) => {} */ + onData?: (e: string) => void; + /** A reference object to attach to the xterm */ + innerRef?: React.RefObject; +} + +export const XTerm: React.FunctionComponent = ({ + cols = 80, + rows = 25, + fontFamily, + fontSize, + onTitleChanged, + onData, + innerRef +}) => { + let terminal: Terminal; + const ref = React.useRef(); + + useImperativeHandle(innerRef, () => ({ + focusTerminal() { + if (terminal) { + terminal.focus(); + } + }, + /** + * Backend sent data. + * + * @param {string} data String content to be writen into the terminal + */ + onDataReceived: (data: string) => { + if (terminal) { + terminal.write(data); + } + }, + /** + * Backend closed connection. + * + * @param {string} reason String error to be written into the terminal + */ + onConnectionClosed: (reason: string) => { + if (terminal) { + terminal.write(`\x1b[31m${reason || 'disconnected'}\x1b[m\r\n`); + terminal.refresh(terminal.rows, terminal.rows); // start to end row + } + } + })); + + React.useEffect(() => { + const fitAddon = new FitAddon(); + terminal = new Terminal({ + cols, + rows, + cursorBlink: true, + fontFamily, + fontSize, + screenReaderMode: true + }); + + const onWindowResize = () => { + const geometry = fitAddon.proposeDimensions(); + if (geometry) { + terminal.resize(geometry.rows, geometry.cols); + } + }; + + if (onData) { + terminal.onData(onData); + } + + if (onTitleChanged) { + terminal.onTitleChange(onTitleChanged); + } + + terminal.loadAddon(fitAddon); + + terminal.open(ref.current); + + const resizeListener = debounce(onWindowResize, 100); + + if (!rows) { + if (canUseDOM) { + window.addEventListener('resize', resizeListener); + } + onWindowResize(); + } + terminal.focus(); + + return () => { + terminal.dispose(); + if (canUseDOM) { + window.removeEventListener('resize', resizeListener); + } + onFocusOut(); + }; + }, []); + + const onBeforeUnload = (event: any) => { + // Firefox requires this when the page is in an iframe + event.preventDefault(); + + // see "an almost cross-browser solution" at + // https://developer.mozilla.org/en-US/docs/Web/Events/beforeunload + event.returnValue = ''; + return ''; + }; + + const onFocusIn = () => { + window.addEventListener('beforeunload', onBeforeUnload); + }; + + const onFocusOut = () => { + window.removeEventListener('beforeunload', onBeforeUnload); + }; + + // ensure react never reuses this div by keying it with the terminal widget + // Workaround for xtermjs/xterm.js#3172 + return
; +}; +XTerm.displayName = 'XTerm'; diff --git a/packages/module/src/components/SerialConsole/__tests__/SerialConsole.test.tsx b/packages/module/src/components/SerialConsole/__tests__/SerialConsole.test.tsx new file mode 100644 index 0000000..cf0e420 --- /dev/null +++ b/packages/module/src/components/SerialConsole/__tests__/SerialConsole.test.tsx @@ -0,0 +1,74 @@ +import React from 'react'; + +import { render } from '@testing-library/react'; + +import { SerialConsole } from '../SerialConsole'; +import { constants } from '../../common/constants'; + +const { CONNECTED, DISCONNECTED, LOADING } = constants; + +describe('SerialConsole', () => { + beforeAll(() => { + window.HTMLCanvasElement.prototype.getContext = () => ({ canvas: {} } as any); + }); + + test('in the LOADING state', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + test('in the DISCONNECTED state', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); + }); + + describe('with CONNECTED state', () => { + beforeAll(() => { + window.HTMLCanvasElement.prototype.getContext = () => + ({ canvas: {}, createLinearGradient: jest.fn(), fillRect: jest.fn() } as any); + global.window.matchMedia = () => ({ + addListener: jest.fn(), + removeListener: jest.fn(), + matches: true, + media: undefined, + onchange: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + }); + }); + + test('renders', () => { + const { asFragment } = render( + + ); + + expect(asFragment()).toMatchSnapshot(); + }); + }); +}); diff --git a/packages/module/src/components/SerialConsole/__tests__/SerialConsoleActions.test.tsx b/packages/module/src/components/SerialConsole/__tests__/SerialConsoleActions.test.tsx new file mode 100644 index 0000000..03d2417 --- /dev/null +++ b/packages/module/src/components/SerialConsole/__tests__/SerialConsoleActions.test.tsx @@ -0,0 +1,20 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { SerialConsoleActions } from '../SerialConsoleActions'; + +test('Render SerialConsoleActions', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); +}); + +test('Render SerialConsoleActions with custom texts', () => { + const { asFragment } = render( + + ); + expect(asFragment()).toMatchSnapshot(); +}); diff --git a/packages/module/src/components/SerialConsole/__tests__/XTerm.test.tsx b/packages/module/src/components/SerialConsole/__tests__/XTerm.test.tsx new file mode 100644 index 0000000..efa6bb4 --- /dev/null +++ b/packages/module/src/components/SerialConsole/__tests__/XTerm.test.tsx @@ -0,0 +1,26 @@ +import React from 'react'; +import { render } from '@testing-library/react'; +import { XTerm } from '../XTerm'; + +describe('XTerm', () => { + beforeAll(() => { + window.HTMLCanvasElement.prototype.getContext = () => + ({ canvas: {}, createLinearGradient: jest.fn(), fillRect: jest.fn() } as any); + + global.window.matchMedia = () => ({ + addListener: jest.fn(), + removeListener: jest.fn(), + matches: true, + media: undefined, + onchange: jest.fn(), + addEventListener: jest.fn(), + removeEventListener: jest.fn(), + dispatchEvent: jest.fn() + }); + }); + + test('Render empty XTerm component', () => { + const { asFragment } = render(); + expect(asFragment()).toMatchSnapshot(); + }); +}); diff --git a/packages/module/src/components/SerialConsole/__tests__/__snapshots__/SerialConsole.test.tsx.snap b/packages/module/src/components/SerialConsole/__tests__/__snapshots__/SerialConsole.test.tsx.snap new file mode 100644 index 0000000..1cd2ff3 --- /dev/null +++ b/packages/module/src/components/SerialConsole/__tests__/__snapshots__/SerialConsole.test.tsx.snap @@ -0,0 +1,426 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SerialConsole in the DISCONNECTED state 1`] = ` + +
+
+
+
+ My text for Disconnected +
+ +
+
+
+
+`; + +exports[`SerialConsole in the LOADING state 1`] = ` + +
+ + +
+
+
+
+
+ + + + + +
+
+ My text for Loading +
+
+
+
+
+`; + +exports[`SerialConsole with CONNECTED state renders 1`] = ` + +
+ + +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+