diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml new file mode 100644 index 000000000..fa4324377 --- /dev/null +++ b/.github/workflows/frontend.yml @@ -0,0 +1,37 @@ +name: frontend + +on: + push: + branches: + - main + pull_request: + branches: + - main + +env: + CI: true + +jobs: + test: + name: test + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + + steps: + - uses: actions/checkout@v4 + - name: Use Node.js + uses: actions/setup-node@v4 + with: + node-version: 18.x + - name: Verify package-lock.json + run: ./scripts/verify_lock.mjs + - name: Install + run: npm clean-install --ignore-scripts + - name: Lint sources + run: npm run lint + - name: Build + run: npm run build + - name: Test + run: npm run test -- --coverage --watchAll=false diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 000000000..a6f610eee --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,2 @@ +node_modules +*/dist/ diff --git a/frontend/.editorconfig b/frontend/.editorconfig new file mode 100644 index 000000000..8bb29e3b2 --- /dev/null +++ b/frontend/.editorconfig @@ -0,0 +1,13 @@ +root=true + +[*] +# standard prettier behaviors +charset = utf-8 +insert_final_newline = true +trim_trailing_whitespace = true + +# Configurable prettier behaviors +end_of_line = lf +indent_style = space +indent_size = 2 +max_line_length = 80 diff --git a/frontend/.eslintrc.cjs b/frontend/.eslintrc.cjs new file mode 100644 index 000000000..c3a69ba4a --- /dev/null +++ b/frontend/.eslintrc.cjs @@ -0,0 +1,94 @@ +/* eslint-env node */ + +module.exports = { + root: true, + + env: { + browser: true, + es2020: true, + jest: true, + }, + + parser: "@typescript-eslint/parser", + parserOptions: { + ecmaFeatures: { + jsx: true, + }, + ecmaVersion: 2020, // keep in sync with tsconfig.json + sourceType: "module", + }, + + // eslint-disable-next-line prettier/prettier + extends: [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "prettier", + ], + + // eslint-disable-next-line prettier/prettier + plugins: [ + "prettier", + "unused-imports", // or eslint-plugin-import? + "@typescript-eslint", + "react", + "react-hooks", + "@tanstack/query", + ], + + // NOTE: Tweak the rules as needed when bulk fixes get merged + rules: { + // TODO: set to "error" when prettier v2 to v3 style changes are fixed + "prettier/prettier": ["warn"], + + // TODO: set to "error" when all resolved, but keep the `argsIgnorePattern` + "@typescript-eslint/no-unused-vars": ["warn", { argsIgnorePattern: "^_" }], + + // TODO: each one of these can be removed or set to "error" when they're all resolved + "unused-imports/no-unused-imports": ["warn"], + "@typescript-eslint/ban-types": "warn", + "@typescript-eslint/no-explicit-any": "warn", + "react/jsx-key": "warn", + "react-hooks/rules-of-hooks": "warn", + "react-hooks/exhaustive-deps": "warn", + "no-extra-boolean-cast": "warn", + "prefer-const": "warn", + + // Allow the "cy-data" property for trustification-ui-test (but should really be "data-cy" w/o this rule) + "react/no-unknown-property": ["error", { ignore: ["cy-data"] }], + + "@tanstack/query/exhaustive-deps": "error", + "@tanstack/query/prefer-query-object-syntax": "error", + }, + + settings: { + react: { version: "detect" }, + }, + + ignorePatterns: [ + // don't ignore dot files so config files get linted + "!.*.js", + "!.*.cjs", + "!.*.mjs", + + // take the place of `.eslintignore` + "dist/", + "generated/", + "node_modules/", + ], + + // this is a hack to make sure eslint will look at all of the file extensions we + // care about without having to put it on the command line + overrides: [ + { + files: [ + "**/*.js", + "**/*.jsx", + "**/*.cjs", + "**/*.mjs", + "**/*.ts", + "**/*.tsx", + ], + }, + ], +}; diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 000000000..5089c820f --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,30 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +node_modules/ +/.pnp +.pnp.js + +# testing +coverage/ + +# production +dist/ +/qa/build + +# misc +.DS_Store +.env.local +.env.development.local +.env.test.local +.env.production.local + +npm-debug.log* + +.eslintcache + +# VSCode +.vscode/* + +# Intellij IDEA +.idea/ diff --git a/frontend/.npmrc b/frontend/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/frontend/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/frontend/.prettierignore b/frontend/.prettierignore new file mode 100644 index 000000000..ccd6c3041 --- /dev/null +++ b/frontend/.prettierignore @@ -0,0 +1,12 @@ +# Library, IDE and build locations +**/node_modules/ +**/coverage/ +**/dist/ +.vscode/ +.idea/ +.eslintcache/ + +# +# NOTE: Could ignore anything that eslint will look at since eslint also applies +# prettier. +# diff --git a/frontend/.prettierrc.mjs b/frontend/.prettierrc.mjs new file mode 100644 index 000000000..c4d9a638a --- /dev/null +++ b/frontend/.prettierrc.mjs @@ -0,0 +1,14 @@ +/** @type {import("prettier").Config} */ +const config = { + trailingComma: "es5", // es5 was the default in prettier v2 + semi: true, + singleQuote: false, + + // Values used from .editorconfig: + // - printWidth == max_line_length + // - tabWidth == indent_size + // - useTabs == indent_style + // - endOfLine == end_of_line +}; + +export default config; diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 000000000..f84148a5c --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,38 @@ +# Builder image +FROM registry.access.redhat.com/ubi9/nodejs-18:latest as builder + +USER 1001 +COPY --chown=1001 . . +RUN npm clean-install && npm run build && npm run dist + +# Runner image +FROM registry.access.redhat.com/ubi9/nodejs-18-minimal:latest + +# Add ps package to allow liveness probe for k8s cluster +# Add tar package to allow copying files with kubectl scp +USER 0 +RUN microdnf -y install tar procps-ng && microdnf clean all + +USER 1001 + +LABEL name="trustification/trustification-ui" \ + description="Trustification - User Interface" \ + help="For more information visit https://trustification.github.io/" \ + license="Apache License 2.0" \ + maintainer="carlosthe19916@gmail.com" \ + summary="Trustification - User Interface" \ + url="https://quay.io/repository/trustification/trustification-ui" \ + usage="podman run -p 80 -v trustification/trustification-ui:latest" \ + io.k8s.display-name="trustification-ui" \ + io.k8s.description="Trustification - User Interface" \ + io.openshift.expose-services="80:http" \ + io.openshift.tags="operator,trustification,ui,nodejs18" \ + io.openshift.min-cpu="100m" \ + io.openshift.min-memory="350Mi" + +COPY --from=builder /opt/app-root/src/dist /opt/app-root/dist/ + +ENV DEBUG=1 + +WORKDIR /opt/app-root/dist +ENTRYPOINT ["./entrypoint.sh"] diff --git a/frontend/README.md b/frontend/README.md index f02cc023d..08cde5bfe 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -1 +1,27 @@ -# Where the frontend lives. +## dev-env + +Install dependencies: + +```shell +npm clean-install --ignore-scripts +``` + +Init the dev server: + +```shell +npm run start:dev +``` + +Open brower at http://localhost:3000 + +## Environment variables + +| ENV VAR | Description | Defaul value | +| ---------------------- | ----------------------------- | ------------------------------------ | +| TRUSTIFICATION_HUB_URL | Set Trustification API URL | http://localhost:8080 | +| AUTH_REQUIRED | Enable/Disable authentication | false | +| OIDC_CLIENT_ID | Set Oidc Client | frontend | +| OIDC_SERVER_URL | Set Oidc Server URL | http://localhost:8090/realms/chicken | +| OIDC_Scope | Set Oidc Scope | openid | +| ANALYTICS_ENABLED | Enable/Disable analytics | false | +| ANALYTICS_WRITE_KEY | Set Segment Write key | null | diff --git a/frontend/branding/favicon.ico b/frontend/branding/favicon.ico new file mode 100644 index 000000000..130b38c8d Binary files /dev/null and b/frontend/branding/favicon.ico differ diff --git a/frontend/branding/images/logo.png b/frontend/branding/images/logo.png new file mode 100644 index 000000000..9a640c940 Binary files /dev/null and b/frontend/branding/images/logo.png differ diff --git a/frontend/branding/images/logo192.png b/frontend/branding/images/logo192.png new file mode 100644 index 000000000..9a640c940 Binary files /dev/null and b/frontend/branding/images/logo192.png differ diff --git a/frontend/branding/images/logo512.png b/frontend/branding/images/logo512.png new file mode 100644 index 000000000..9f72223b7 Binary files /dev/null and b/frontend/branding/images/logo512.png differ diff --git a/frontend/branding/images/masthead-logo.svg b/frontend/branding/images/masthead-logo.svg new file mode 100644 index 000000000..57080f42b --- /dev/null +++ b/frontend/branding/images/masthead-logo.svg @@ -0,0 +1,40 @@ + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/frontend/branding/manifest.json b/frontend/branding/manifest.json new file mode 100644 index 000000000..b0013e63b --- /dev/null +++ b/frontend/branding/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "trustification-ui", + "name": "Trustification UI", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/frontend/branding/strings.json b/frontend/branding/strings.json new file mode 100644 index 000000000..0d2e2f2cc --- /dev/null +++ b/frontend/branding/strings.json @@ -0,0 +1,21 @@ +{ + "application": { + "title": "Trustification", + "name": "Trustification UI", + "description": "Trustification UI" + }, + "about": { + "displayName": "Trustification", + "imageSrc": "<%= brandingRoot %>/images/masthead-logo.svg", + "documentationUrl": "https://trustification.io/" + }, + "masthead": { + "leftBrand": { + "src": "<%= brandingRoot %>/images/masthead-logo.svg", + "alt": "brand", + "height": "40px" + }, + "leftTitle": null, + "rightBrand": null + } +} diff --git a/frontend/client/config/jest.config.ts b/frontend/client/config/jest.config.ts new file mode 100644 index 000000000..23d6246e6 --- /dev/null +++ b/frontend/client/config/jest.config.ts @@ -0,0 +1,50 @@ +import type { JestConfigWithTsJest } from "ts-jest"; + +// For a detailed explanation regarding each configuration property, visit: +// https://jestjs.io/docs/en/configuration.html + +const config: JestConfigWithTsJest = { + // Automatically clear mock calls and instances between every test + clearMocks: true, + + // Indicates whether the coverage information should be collected while executing the test + collectCoverage: false, + + // The directory where Jest should output its coverage files + coverageDirectory: "coverage", + + // Stub out resources and provide handling for tsconfig.json paths + moduleNameMapper: { + // stub out files that don't matter for tests + "\\.(css|less)$": "/__mocks__/styleMock.js", + "\\.(xsd)$": "/__mocks__/styleMock.js", + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": + "/__mocks__/fileMock.js", + "@patternfly/react-icons/dist/esm/icons/": + "/__mocks__/fileMock.js", + + // match the paths in tsconfig.json + "@app/(.*)": "/src/app/$1", + "@assets/(.*)": + "../node_modules/@patternfly/react-core/dist/styles/assets/$1", + }, + + // A list of paths to directories that Jest should use to search for files + roots: ["/src"], + + // The test environment that will be used for testing + testEnvironment: "jest-environment-jsdom", + + // The pattern or patterns Jest uses to find test files + testMatch: ["/src/**/*.{test,spec}.{js,jsx,ts,tsx}"], + + // Process js/jsx/mjs/mjsx/ts/tsx/mts/mtsx with `ts-jest` + transform: { + "^.+\\.(js|mjs|ts|mts)x?$": "ts-jest", + }, + + // Code to set up the testing framework before each test file in the suite is executed + setupFilesAfterEnv: ["/src/app/test-config/setupTests.ts"], +}; + +export default config; diff --git a/frontend/client/config/monacoConstants.ts b/frontend/client/config/monacoConstants.ts new file mode 100644 index 000000000..35c16dc6f --- /dev/null +++ b/frontend/client/config/monacoConstants.ts @@ -0,0 +1,22 @@ +import { EditorLanguage } from "monaco-editor/esm/metadata"; + +export const LANGUAGES_BY_FILE_EXTENSION = { + java: "java", + go: "go", + xml: "xml", + js: "javascript", + ts: "typescript", + html: "html", + htm: "html", + css: "css", + yaml: "yaml", + yml: "yaml", + json: "json", + md: "markdown", + php: "php", + py: "python", + pl: "perl", + rb: "ruby", + sh: "shell", + bash: "shell", +} as const satisfies Record; diff --git a/frontend/client/config/stylePaths.js b/frontend/client/config/stylePaths.js new file mode 100644 index 000000000..a7f76aada --- /dev/null +++ b/frontend/client/config/stylePaths.js @@ -0,0 +1,17 @@ +/* eslint-env node */ + +import * as path from "path"; + +export const stylePaths = [ + // Include our sources + path.resolve(__dirname, "../src"), + + // Include =PF4 paths, even if nested under another package because npm cannot hoist + // a single package to the root node_modules/ + /node_modules\/@patternfly\/patternfly/, + /node_modules\/@patternfly\/react-core\/.*\.css/, + /node_modules\/@patternfly\/react-styles/, +]; diff --git a/frontend/client/config/webpack.common.ts b/frontend/client/config/webpack.common.ts new file mode 100644 index 000000000..fe4d63046 --- /dev/null +++ b/frontend/client/config/webpack.common.ts @@ -0,0 +1,221 @@ +import path from "path"; +import { Configuration } from "webpack"; +import CopyPlugin from "copy-webpack-plugin"; +import Dotenv from "dotenv-webpack"; +import { TsconfigPathsPlugin } from "tsconfig-paths-webpack-plugin"; +import MonacoWebpackPlugin from "monaco-editor-webpack-plugin"; + +import { brandingAssetPath } from "@trustification-ui/common"; +import { LANGUAGES_BY_FILE_EXTENSION } from "./monacoConstants"; + +const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); + +const brandingPath = brandingAssetPath(); +const manifestPath = path.resolve(brandingPath, "manifest.json"); + +const BG_IMAGES_DIRNAME = "images"; + +const config: Configuration = { + entry: { + app: [pathTo("../src/index.tsx")], + }, + + output: { + path: pathTo("../dist"), + publicPath: "auto", + clean: true, + }, + + module: { + rules: [ + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: "ts-loader", + options: { + transpileOnly: true, + }, + }, + }, + { + test: /\.(svg|ttf|eot|woff|woff2)$/, + // only process modules with this loader + // if they live under a 'fonts' or 'pficon' directory + include: [ + pathTo("../../node_modules/patternfly/dist/fonts"), + pathTo( + "../../node_modules/@patternfly/react-core/dist/styles/assets/fonts" + ), + pathTo( + "../../node_modules/@patternfly/react-core/dist/styles/assets/pficon" + ), + pathTo("../../node_modules/@patternfly/patternfly/assets/fonts"), + pathTo("../../node_modules/@patternfly/patternfly/assets/pficon"), + ], + use: { + loader: "file-loader", + options: { + // Limit at 50k. larger files emited into separate files + limit: 5000, + outputPath: "fonts", + name: "[name].[ext]", + }, + }, + }, + { + test: /\.(xsd)$/, + include: [pathTo("../src")], + use: { + loader: "raw-loader", + options: { + esModule: true, + }, + }, + }, + { + test: /\.svg$/, + include: (input) => input.indexOf("background-filter.svg") > 1, + use: [ + { + loader: "url-loader", + options: { + limit: 5000, + outputPath: "svgs", + name: "[name].[ext]", + }, + }, + ], + type: "javascript/auto", + }, + { + test: /\.svg$/, + // only process SVG modules with this loader if they live under a 'bgimages' directory + // this is primarily useful when applying a CSS background using an SVG + include: (input) => input.indexOf(BG_IMAGES_DIRNAME) > -1, + use: { + loader: "svg-url-loader", + options: {}, + }, + }, + { + test: /\.svg$/, + // only process SVG modules with this loader when they don't live under a 'bgimages', + // 'fonts', or 'pficon' directory, those are handled with other loaders + include: (input) => + input.indexOf(BG_IMAGES_DIRNAME) === -1 && + input.indexOf("fonts") === -1 && + input.indexOf("background-filter") === -1 && + input.indexOf("pficon") === -1, + use: { + loader: "raw-loader", + options: {}, + }, + type: "javascript/auto", + }, + { + test: /\.(jpg|jpeg|png|gif)$/i, + include: [ + pathTo("../src"), + pathTo("../../node_modules/patternfly"), + pathTo("../../node_modules/@patternfly/patternfly/assets/images"), + pathTo( + "../../node_modules/@patternfly/react-styles/css/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-core/dist/styles/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-core/node_modules/@patternfly/react-styles/css/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-table/node_modules/@patternfly/react-styles/css/assets/images" + ), + pathTo( + "../../node_modules/@patternfly/react-inline-edit-extension/node_modules/@patternfly/react-styles/css/assets/images" + ), + ], + use: [ + { + loader: "url-loader", + options: { + limit: 5000, + outputPath: "images", + name: "[name].[ext]", + }, + }, + ], + type: "javascript/auto", + }, + { + test: pathTo("../../node_modules/xmllint/xmllint.js"), + loader: "exports-loader", + options: { + exports: "xmllint", + }, + }, + { + test: /\.yaml$/, + use: "raw-loader", + }, + + // For monaco-editor-webpack-plugin + { + test: /\.css$/, + include: [pathTo("../../node_modules/monaco-editor")], + use: ["style-loader", "css-loader"], + }, + { + test: /\.ttf$/, + type: "asset/resource", + }, + // <--- For monaco-editor-webpack-plugin + ], + }, + + plugins: [ + // new CaseSensitivePathsWebpackPlugin(), + new Dotenv({ + systemvars: true, + silent: true, + }), + new CopyPlugin({ + patterns: [ + { + from: manifestPath, + to: ".", + }, + { + from: brandingPath, + to: "./branding/", + }, + ], + }), + new MonacoWebpackPlugin({ + filename: "monaco/[name].worker.js", + languages: Object.values(LANGUAGES_BY_FILE_EXTENSION), + }), + ], + + resolve: { + // alias: { + // "react-dom": "@hot-loader/react-dom", + // }, + extensions: [".js", ".ts", ".tsx", ".jsx"], + plugins: [ + new TsconfigPathsPlugin({ + configFile: pathTo("../tsconfig.json"), + }), + ], + symlinks: false, + cacheWithContext: false, + fallback: { crypto: false, fs: false, path: false }, + }, + + externals: { + // required by xmllint (but not really used in the browser) + ws: "{}", + }, +}; + +export default config; diff --git a/frontend/client/config/webpack.dev.ts b/frontend/client/config/webpack.dev.ts new file mode 100644 index 000000000..c7bd52987 --- /dev/null +++ b/frontend/client/config/webpack.dev.ts @@ -0,0 +1,121 @@ +import path from "path"; +import { mergeWithRules } from "webpack-merge"; +import type { Configuration as WebpackConfiguration } from "webpack"; +import type { Configuration as DevServerConfiguration } from "webpack-dev-server"; +import CopyPlugin from "copy-webpack-plugin"; +import HtmlWebpackPlugin from "html-webpack-plugin"; +import ReactRefreshTypeScript from "react-refresh-typescript"; +import ReactRefreshWebpackPlugin from "@pmmmwh/react-refresh-webpack-plugin"; +import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin"; + +import { + encodeEnv, + TRUSTIFICATION_ENV, + SERVER_ENV_KEYS, + proxyMap, + brandingStrings, + brandingAssetPath, +} from "@trustification-ui/common"; +import { stylePaths } from "./stylePaths"; +import commonWebpackConfiguration from "./webpack.common"; + +const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); +const faviconPath = path.resolve(brandingAssetPath(), "favicon.ico"); + +interface Configuration extends WebpackConfiguration { + devServer?: DevServerConfiguration; +} + +const devServer: DevServerConfiguration = { + port: 3000, + historyApiFallback: { + disableDotRule: true, + }, + hot: true, + proxy: proxyMap, +}; + +const config: Configuration = mergeWithRules({ + module: { + rules: { + test: "match", + use: { + loader: "match", + options: "replace", + }, + }, + }, +})(commonWebpackConfiguration, { + mode: "development", + devtool: "eval-source-map", + output: { + filename: "[name].js", + chunkFilename: "js/[name].js", + assetModuleFilename: "assets/[name][ext]", + }, + + devServer, + + module: { + rules: [ + { + test: /\.[jt]sx?$/, + exclude: /node_modules/, + use: { + loader: "ts-loader", + options: { + transpileOnly: true, // HMR in webpack-dev-server requires transpileOnly + getCustomTransformers: () => ({ + before: [ReactRefreshTypeScript()], + }), + }, + }, + }, + { + test: /\.css$/, + include: [...stylePaths], + use: ["style-loader", "css-loader"], + }, + ], + }, + + plugins: [ + new ReactRefreshWebpackPlugin(), + new ForkTsCheckerWebpackPlugin({ + typescript: { + mode: "readonly", + }, + }), + new CopyPlugin({ + patterns: [ + { + from: pathTo("../public/mockServiceWorker.js"), + }, + ], + }), + + // index.html generated at compile time to inject `_env` + new HtmlWebpackPlugin({ + filename: "index.html", + template: pathTo("../public/index.html.ejs"), + templateParameters: { + _env: encodeEnv(TRUSTIFICATION_ENV, SERVER_ENV_KEYS), + branding: brandingStrings, + }, + favicon: faviconPath, + minify: { + collapseWhitespace: false, + keepClosingSlash: true, + minifyJS: true, + removeEmptyAttributes: true, + removeRedundantAttributes: true, + }, + }), + ], + + watchOptions: { + // ignore watching everything except @trustification-ui packages + ignored: /node_modules\/(?!@trustification-ui\/)/, + }, +} as Configuration); +export default config; diff --git a/frontend/client/config/webpack.prod.ts b/frontend/client/config/webpack.prod.ts new file mode 100644 index 000000000..3d0b9e245 --- /dev/null +++ b/frontend/client/config/webpack.prod.ts @@ -0,0 +1,72 @@ +import path from "path"; +import merge from "webpack-merge"; +import webpack, { Configuration } from "webpack"; +import MiniCssExtractPlugin from "mini-css-extract-plugin"; +import CssMinimizerPlugin from "css-minimizer-webpack-plugin"; +import HtmlWebpackPlugin from "html-webpack-plugin"; + +import { brandingAssetPath } from "@trustification-ui/common"; +import { stylePaths } from "./stylePaths"; +import commonWebpackConfiguration from "./webpack.common"; + +const pathTo = (relativePath: string) => path.resolve(__dirname, relativePath); +const faviconPath = path.resolve(brandingAssetPath(), "favicon.ico"); + +const config = merge(commonWebpackConfiguration, { + mode: "production", + devtool: "nosources-source-map", // used to map stack traces on the client without exposing all of the source code + output: { + filename: "[name].[contenthash:8].min.js", + chunkFilename: "js/[name].[chunkhash:8].min.js", + assetModuleFilename: "assets/[name].[contenthash:8][ext]", + }, + + optimization: { + minimize: true, + minimizer: [ + "...", // The '...' string represents the webpack default TerserPlugin instance + new CssMinimizerPlugin(), + ], + }, + + module: { + rules: [ + { + test: /\.css$/, + include: [...stylePaths], + use: [MiniCssExtractPlugin.loader, "css-loader"], + }, + ], + }, + + plugins: [ + new MiniCssExtractPlugin({ + filename: "[name].[contenthash:8].css", + chunkFilename: "css/[name].[chunkhash:8].min.css", + }), + new CssMinimizerPlugin({ + minimizerOptions: { + preset: ["default", { mergeLonghand: false }], + }, + }), + new webpack.EnvironmentPlugin({ + NODE_ENV: "production", + }), + + // index.html generated at runtime via the express server to inject `_env` + new HtmlWebpackPlugin({ + filename: "index.html.ejs", + template: `!!raw-loader!${pathTo("../public/index.html.ejs")}`, + favicon: faviconPath, + minify: { + collapseWhitespace: false, + keepClosingSlash: true, + minifyJS: true, + removeEmptyAttributes: true, + removeRedundantAttributes: true, + }, + }), + ], +}); + +export default config; diff --git a/frontend/client/package.json b/frontend/client/package.json new file mode 100644 index 000000000..0404f7f8e --- /dev/null +++ b/frontend/client/package.json @@ -0,0 +1,112 @@ +{ + "name": "@trustification-ui/client", + "version": "0.1.0", + "license": "Apache-2.0", + "private": true, + "scripts": { + "analyze": "source-map-explorer 'dist/static/js/*.js'", + "clean": "rimraf ./dist", + "prebuild": "npm run clean && npm run tsc -- --noEmit", + "build": "NODE_ENV=production webpack --config ./config/webpack.prod.ts", + "build:dev": "NODE_ENV=development webpack --config ./config/webpack.dev.ts", + "start:dev": "NODE_ENV=development webpack serve --config ./config/webpack.dev.ts", + "test": "NODE_ENV=test jest --rootDir=. --config=./config/jest.config.ts", + "lint": "eslint .", + "tsc": "tsc -p ./tsconfig.json" + }, + "lint-staged": { + "*.{js,cjs,mjs,jsx,ts,cts,mts,tsx}": "eslint --fix", + "*.{css,json,md,yaml,yml}": "prettier --write" + }, + "dependencies": { + "@carlosthe19916-latest/react-table-batteries": "^0.0.3", + "@hookform/resolvers": "^2.9.11", + "@patternfly/patternfly": "^5.2.1", + "@patternfly/react-charts": "^7.2.1", + "@patternfly/react-code-editor": "^5.2.1", + "@patternfly/react-component-groups": "^5.0.0", + "@patternfly/react-core": "^5.2.1", + "@patternfly/react-table": "^5.2.1", + "@patternfly/react-tokens": "^5.2.1", + "@segment/analytics-next": "^1.64.0", + "@tanstack/react-query": "^5.22.2", + "@tanstack/react-query-devtools": "^5.24.0", + "axios": "^0.21.2", + "dayjs": "^1.11.7", + "ejs": "^3.1.7", + "file-saver": "^2.0.5", + "monaco-editor": "0.34.1", + "oidc-client-ts": "^2.4.0", + "packageurl-js": "^1.2.1", + "pretty-bytes": "^6.1.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-hook-form": "^7.43.1", + "react-markdown": "^8.0.7", + "react-monaco-editor": "0.51.0", + "react-oidc-context": "^2.3.1", + "react-router-dom": "^6.21.1", + "usehooks-ts": "^2.14.0", + "web-vitals": "^0.2.4", + "xmllint": "^0.1.1", + "yaml": "^1.10.2", + "yup": "^0.32.11" + }, + "devDependencies": { + "@pmmmwh/react-refresh-webpack-plugin": "^0.5.10", + "@testing-library/jest-dom": "^5.17.0", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.4.3", + "@types/dotenv-webpack": "^7.0.3", + "@types/ejs": "^3.1.0", + "@types/file-saver": "^2.0.2", + "@types/react": "^18.2.0", + "@types/react-dom": "^18.2.0", + "browserslist": "^4.19.1", + "case-sensitive-paths-webpack-plugin": "^2.4.0", + "copy-webpack-plugin": "^11.0.0", + "css-loader": "^5.2.7", + "css-minimizer-webpack-plugin": "^3.4.1", + "dotenv-webpack": "^7.0.3", + "exports-loader": "^3.1.0", + "file-loader": "^6.2.0", + "fork-ts-checker-webpack-plugin": "^8.0.0", + "html-webpack-plugin": "^5.5.0", + "mini-css-extract-plugin": "^2.5.2", + "monaco-editor-webpack-plugin": "^7.0.1", + "msw": "^1.2.3", + "raw-loader": "^4.0.2", + "react-refresh": "^0.14.0", + "react-refresh-typescript": "^2.0.9", + "sass-loader": "^12.4.0", + "source-map-explorer": "^2.5.2", + "style-loader": "^3.3.1", + "svg-url-loader": "^7.1.1", + "terser-webpack-plugin": "^5.3.0", + "ts-loader": "^9.4.1", + "tsconfig-paths-webpack-plugin": "^4.0.0", + "url-loader": "^4.1.1", + "webpack": "^5.88.2", + "webpack-cli": "^5.1.4", + "webpack-dev-server": "^4.15.1", + "webpack-merge": "^5.9.0" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + }, + "nyc": { + "exclude": "client/src/reportWebVitals.ts" + }, + "msw": { + "workerDirectory": "public" + } +} diff --git a/frontend/client/public/index.html.ejs b/frontend/client/public/index.html.ejs new file mode 100644 index 000000000..4fc0deb38 --- /dev/null +++ b/frontend/client/public/index.html.ejs @@ -0,0 +1,21 @@ + + + + <%= branding.application.title %> + + + + + + + + + + + + +
+ + \ No newline at end of file diff --git a/frontend/client/public/manifest.json b/frontend/client/public/manifest.json new file mode 100644 index 000000000..b0013e63b --- /dev/null +++ b/frontend/client/public/manifest.json @@ -0,0 +1,15 @@ +{ + "short_name": "trustification-ui", + "name": "Trustification UI", + "icons": [ + { + "src": "favicon.ico", + "sizes": "64x64 32x32 24x24 16x16", + "type": "image/x-icon" + } + ], + "start_url": ".", + "display": "standalone", + "theme_color": "#000000", + "background_color": "#ffffff" +} diff --git a/frontend/client/public/mockServiceWorker.js b/frontend/client/public/mockServiceWorker.js new file mode 100644 index 000000000..e5aa935a6 --- /dev/null +++ b/frontend/client/public/mockServiceWorker.js @@ -0,0 +1,303 @@ +/* eslint-disable */ +/* tslint:disable */ + +/** + * Mock Service Worker (1.2.3). + * @see https://github.com/mswjs/msw + * - Please do NOT modify this file. + * - Please do NOT serve this file on production. + */ + +const INTEGRITY_CHECKSUM = "3d6b9f06410d179a7f7404d4bf4c3c70"; +const activeClientIds = new Set(); + +self.addEventListener("install", function () { + self.skipWaiting(); +}); + +self.addEventListener("activate", function (event) { + event.waitUntil(self.clients.claim()); +}); + +self.addEventListener("message", async function (event) { + const clientId = event.source.id; + + if (!clientId || !self.clients) { + return; + } + + const client = await self.clients.get(clientId); + + if (!client) { + return; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + switch (event.data) { + case "KEEPALIVE_REQUEST": { + sendToClient(client, { + type: "KEEPALIVE_RESPONSE", + }); + break; + } + + case "INTEGRITY_CHECK_REQUEST": { + sendToClient(client, { + type: "INTEGRITY_CHECK_RESPONSE", + payload: INTEGRITY_CHECKSUM, + }); + break; + } + + case "MOCK_ACTIVATE": { + activeClientIds.add(clientId); + + sendToClient(client, { + type: "MOCKING_ENABLED", + payload: true, + }); + break; + } + + case "MOCK_DEACTIVATE": { + activeClientIds.delete(clientId); + break; + } + + case "CLIENT_CLOSED": { + activeClientIds.delete(clientId); + + const remainingClients = allClients.filter((client) => { + return client.id !== clientId; + }); + + // Unregister itself when there are no more clients + if (remainingClients.length === 0) { + self.registration.unregister(); + } + + break; + } + } +}); + +self.addEventListener("fetch", function (event) { + const { request } = event; + const accept = request.headers.get("accept") || ""; + + // Bypass server-sent events. + if (accept.includes("text/event-stream")) { + return; + } + + // Bypass navigation requests. + if (request.mode === "navigate") { + return; + } + + // Opening the DevTools triggers the "only-if-cached" request + // that cannot be handled by the worker. Bypass such requests. + if (request.cache === "only-if-cached" && request.mode !== "same-origin") { + return; + } + + // Bypass all requests when there are no active clients. + // Prevents the self-unregistered worked from handling requests + // after it's been deleted (still remains active until the next reload). + if (activeClientIds.size === 0) { + return; + } + + // Generate unique request ID. + const requestId = Math.random().toString(16).slice(2); + + event.respondWith( + handleRequest(event, requestId).catch((error) => { + if (error.name === "NetworkError") { + console.warn( + '[MSW] Successfully emulated a network error for the "%s %s" request.', + request.method, + request.url + ); + return; + } + + // At this point, any exception indicates an issue with the original request/response. + console.error( + `\ +[MSW] Caught an exception from the "%s %s" request (%s). This is probably not a problem with Mock Service Worker. There is likely an additional logging output above.`, + request.method, + request.url, + `${error.name}: ${error.message}` + ); + }) + ); +}); + +async function handleRequest(event, requestId) { + const client = await resolveMainClient(event); + const response = await getResponse(event, client, requestId); + + // Send back the response clone for the "response:*" life-cycle events. + // Ensure MSW is active and ready to handle the message, otherwise + // this message will pend indefinitely. + if (client && activeClientIds.has(client.id)) { + (async function () { + const clonedResponse = response.clone(); + sendToClient(client, { + type: "RESPONSE", + payload: { + requestId, + type: clonedResponse.type, + ok: clonedResponse.ok, + status: clonedResponse.status, + statusText: clonedResponse.statusText, + body: + clonedResponse.body === null ? null : await clonedResponse.text(), + headers: Object.fromEntries(clonedResponse.headers.entries()), + redirected: clonedResponse.redirected, + }, + }); + })(); + } + + return response; +} + +// Resolve the main client for the given event. +// Client that issues a request doesn't necessarily equal the client +// that registered the worker. It's with the latter the worker should +// communicate with during the response resolving phase. +async function resolveMainClient(event) { + const client = await self.clients.get(event.clientId); + + if (client?.frameType === "top-level") { + return client; + } + + const allClients = await self.clients.matchAll({ + type: "window", + }); + + return allClients + .filter((client) => { + // Get only those clients that are currently visible. + return client.visibilityState === "visible"; + }) + .find((client) => { + // Find the client ID that's recorded in the + // set of clients that have registered the worker. + return activeClientIds.has(client.id); + }); +} + +async function getResponse(event, client, requestId) { + const { request } = event; + const clonedRequest = request.clone(); + + function passthrough() { + // Clone the request because it might've been already used + // (i.e. its body has been read and sent to the client). + const headers = Object.fromEntries(clonedRequest.headers.entries()); + + // Remove MSW-specific request headers so the bypassed requests + // comply with the server's CORS preflight check. + // Operate with the headers as an object because request "Headers" + // are immutable. + delete headers["x-msw-bypass"]; + + return fetch(clonedRequest, { headers }); + } + + // Bypass mocking when the client is not active. + if (!client) { + return passthrough(); + } + + // Bypass initial page load requests (i.e. static assets). + // The absence of the immediate/parent client in the map of the active clients + // means that MSW hasn't dispatched the "MOCK_ACTIVATE" event yet + // and is not ready to handle requests. + if (!activeClientIds.has(client.id)) { + return passthrough(); + } + + // Bypass requests with the explicit bypass header. + // Such requests can be issued by "ctx.fetch()". + if (request.headers.get("x-msw-bypass") === "true") { + return passthrough(); + } + + // Notify the client that a request has been intercepted. + const clientMessage = await sendToClient(client, { + type: "REQUEST", + payload: { + id: requestId, + url: request.url, + method: request.method, + headers: Object.fromEntries(request.headers.entries()), + cache: request.cache, + mode: request.mode, + credentials: request.credentials, + destination: request.destination, + integrity: request.integrity, + redirect: request.redirect, + referrer: request.referrer, + referrerPolicy: request.referrerPolicy, + body: await request.text(), + bodyUsed: request.bodyUsed, + keepalive: request.keepalive, + }, + }); + + switch (clientMessage.type) { + case "MOCK_RESPONSE": { + return respondWithMock(clientMessage.data); + } + + case "MOCK_NOT_FOUND": { + return passthrough(); + } + + case "NETWORK_ERROR": { + const { name, message } = clientMessage.data; + const networkError = new Error(message); + networkError.name = name; + + // Rejecting a "respondWith" promise emulates a network error. + throw networkError; + } + } + + return passthrough(); +} + +function sendToClient(client, message) { + return new Promise((resolve, reject) => { + const channel = new MessageChannel(); + + channel.port1.onmessage = (event) => { + if (event.data && event.data.error) { + return reject(event.data.error); + } + + resolve(event.data); + }; + + client.postMessage(message, [channel.port2]); + }); +} + +function sleep(timeMs) { + return new Promise((resolve) => { + setTimeout(resolve, timeMs); + }); +} + +async function respondWithMock(response) { + await sleep(response.delay); + return new Response(response.body, response); +} diff --git a/frontend/client/public/robots.txt b/frontend/client/public/robots.txt new file mode 100644 index 000000000..e9e57dc4d --- /dev/null +++ b/frontend/client/public/robots.txt @@ -0,0 +1,3 @@ +# https://www.robotstxt.org/robotstxt.html +User-agent: * +Disallow: diff --git a/frontend/client/src/app/App.css b/frontend/client/src/app/App.css new file mode 100644 index 000000000..50707665f --- /dev/null +++ b/frontend/client/src/app/App.css @@ -0,0 +1,10 @@ +.pf-c-select__toggle:before { + border-top: var(--pf-c-select__toggle--before--BorderTopWidth) solid + var(--pf-c-select__toggle--before--BorderTopColor) !important; + border-right: var(--pf-c-select__toggle--before--BorderRightWidth) solid + var(--pf-c-select__toggle--before--BorderRightColor) !important; + border-bottom: var(--pf-c-select__toggle--before--BorderBottomWidth) solid + var(--pf-c-select__toggle--before--BorderBottomColor) !important; + border-left: var(--pf-c-select__toggle--before--BorderLeftWidth) solid + var(--pf-c-select__toggle--before--BorderLeftColor) !important; +} diff --git a/frontend/client/src/app/App.tsx b/frontend/client/src/app/App.tsx new file mode 100644 index 000000000..5b7088d55 --- /dev/null +++ b/frontend/client/src/app/App.tsx @@ -0,0 +1,27 @@ +import "./App.css"; +import React from "react"; +import { BrowserRouter as Router } from "react-router-dom"; + +import { DefaultLayout } from "./layout"; +import { AppRoutes } from "./Routes"; +import { NotificationsProvider } from "./components/NotificationsContext"; +import { AnalyticsProvider } from "./components/AnalyticsProvider"; + +import "@patternfly/patternfly/patternfly.css"; +import "@patternfly/patternfly/patternfly-addons.css"; + +const App: React.FC = () => { + return ( + + + + + + + + + + ); +}; + +export default App; diff --git a/frontend/client/src/app/Constants.ts b/frontend/client/src/app/Constants.ts new file mode 100644 index 000000000..f75e2c215 --- /dev/null +++ b/frontend/client/src/app/Constants.ts @@ -0,0 +1,27 @@ +import ENV from "./env"; + +export const RENDER_DATE_FORMAT = "MMM DD, YYYY"; +export const FILTER_DATE_FORMAT = "YYYY-MM-DD"; + +export const TablePersistenceKeyPrefixes = { + advisories: "ad", + cves: "cv", + sboms: "sb", + packages: "pk", +}; + +// URL param prefixes: should be short, must be unique for each table that uses one +export enum TableURLParamKeyPrefix { + repositories = "r", + tags = "t", +} + +export const isAuthRequired = ENV.AUTH_REQUIRED !== "false"; +export const isAnalyticsEnabled = ENV.ANALYTICS_ENABLED !== "false"; +export const uploadLimit = "500m"; + +/** + * The name of the client generated id field inserted in a object marked with mixin type + * `WithUiId`. + */ +export const UI_UNIQUE_ID = "_ui_unique_id"; diff --git a/frontend/client/src/app/Routes.tsx b/frontend/client/src/app/Routes.tsx new file mode 100644 index 000000000..5ea9c485d --- /dev/null +++ b/frontend/client/src/app/Routes.tsx @@ -0,0 +1,22 @@ +import React, { Suspense, lazy } from "react"; +import { useRoutes } from "react-router-dom"; + +import { Bullseye, Spinner } from "@patternfly/react-core"; + +const Home = lazy(() => import("./pages/home")); + +export const AppRoutes = () => { + const allRoutes = useRoutes([{ path: "/", element: }]); + + return ( + + + + } + > + {allRoutes} + + ); +}; diff --git a/frontend/client/src/app/analytics.ts b/frontend/client/src/app/analytics.ts new file mode 100644 index 000000000..1d3d7015c --- /dev/null +++ b/frontend/client/src/app/analytics.ts @@ -0,0 +1,6 @@ +import { AnalyticsBrowserSettings } from "@segment/analytics-next"; +import { ENV } from "./env"; + +export const analyticsSettings: AnalyticsBrowserSettings = { + writeKey: ENV.ANALYTICS_WRITE_KEY || "", +}; diff --git a/frontend/client/src/app/api/models.ts b/frontend/client/src/app/api/models.ts new file mode 100644 index 000000000..e26e01397 --- /dev/null +++ b/frontend/client/src/app/api/models.ts @@ -0,0 +1,34 @@ +export type WithUiId = T & { _ui_unique_id: string }; + +/** Mark an object as "New" therefore does not have an `id` field. */ +export type New = Omit; + +export interface HubFilter { + field: string; + operator?: "=" | "!=" | "~" | ">" | ">=" | "<" | "<="; + value: + | string + | number + | { + list: (string | number)[]; + operator?: "AND" | "OR"; + }; +} + +export interface HubRequestParams { + filters?: HubFilter[]; + sort?: { + field: string; + direction: "asc" | "desc"; + }; + page?: { + pageNumber: number; // 1-indexed + itemsPerPage: number; + }; +} + +export interface HubPaginatedResult { + data: T[]; + total: number; + params: HubRequestParams; +} diff --git a/frontend/client/src/app/api/rest.ts b/frontend/client/src/app/api/rest.ts new file mode 100644 index 000000000..b91268d50 --- /dev/null +++ b/frontend/client/src/app/api/rest.ts @@ -0,0 +1,24 @@ +import axios from "axios"; +import { serializeRequestParamsForHub } from "@app/hooks/table-controls"; +import { HubPaginatedResult, HubRequestParams } from "./models"; + +const HUB = "/hub"; + +interface ApiSearchResult { + total: number; + result: T[]; +} + +export const getHubPaginatedResult = ( + url: string, + params: HubRequestParams = {} +): Promise> => + axios + .get>(url, { + params: serializeRequestParamsForHub(params), + }) + .then(({ data }) => ({ + data: data.result, + total: data.total, + params, + })); diff --git a/frontend/client/src/app/axios-config/apiInit.ts b/frontend/client/src/app/axios-config/apiInit.ts new file mode 100644 index 000000000..8c16f7dac --- /dev/null +++ b/frontend/client/src/app/axios-config/apiInit.ts @@ -0,0 +1,30 @@ +import ENV from "@app/env"; +import axios from "axios"; +import { User } from "oidc-client-ts"; + +function getUser() { + const oidcStorage = sessionStorage.getItem( + `oidc.user:${ENV.OIDC_SERVER_URL}:${ENV.OIDC_CLIENT_ID}` + ); + if (!oidcStorage) { + return null; + } + + return User.fromStorageString(oidcStorage); +} + +export const initInterceptors = () => { + axios.interceptors.request.use( + (config) => { + const user = getUser(); + const token = user?.access_token; + if (token) { + config.headers.Authorization = `Bearer ${token}`; + } + return config; + }, + (error) => { + return Promise.reject(error); + } + ); +}; diff --git a/frontend/client/src/app/axios-config/index.ts b/frontend/client/src/app/axios-config/index.ts new file mode 100644 index 000000000..98b6062d1 --- /dev/null +++ b/frontend/client/src/app/axios-config/index.ts @@ -0,0 +1 @@ +export { initInterceptors } from "./apiInit"; diff --git a/frontend/client/src/app/components/AnalyticsProvider.tsx b/frontend/client/src/app/components/AnalyticsProvider.tsx new file mode 100644 index 000000000..d1f22dfd3 --- /dev/null +++ b/frontend/client/src/app/components/AnalyticsProvider.tsx @@ -0,0 +1,55 @@ +import React, { useEffect } from "react"; +import { useLocation } from "react-router"; +import { useAuth } from "react-oidc-context"; +import { AnalyticsBrowser } from "@segment/analytics-next"; +import ENV from "@app/env"; +import { isAuthRequired } from "@app/Constants"; +import { analyticsSettings } from "@app/analytics"; + +const AnalyticsContext = React.createContext(undefined!); + +interface IAnalyticsProviderProps { + children: React.ReactNode; +} + +export const AnalyticsProvider: React.FC = ({ + children, +}) => { + return ENV.ANALYTICS_ENABLED !== "true" ? ( + <>{children} + ) : ( + {children} + ); +}; + +export const AnalyticsContextProvider: React.FC = ({ + children, +}) => { + const auth = (isAuthRequired && useAuth()) || undefined; + const analytics = React.useMemo(() => { + return AnalyticsBrowser.load(analyticsSettings); + }, []); + + // Identify + useEffect(() => { + if (auth) { + const claims = auth.user?.profile; + analytics.identify(claims?.sub, { + organization_id: (claims?.organization as any)?.id, + domain: claims?.email?.split("@")[1], + }); + } + }, [auth, analytics]); + + // Watch navigation + const location = useLocation(); + useEffect(() => { + analytics.page(); + }, [location]); + + return ( + + {children} + + ); +}; diff --git a/frontend/client/src/app/components/AppPlaceholder.tsx b/frontend/client/src/app/components/AppPlaceholder.tsx new file mode 100644 index 000000000..a7850977c --- /dev/null +++ b/frontend/client/src/app/components/AppPlaceholder.tsx @@ -0,0 +1,17 @@ +import React from "react"; +import { Bullseye, Spinner } from "@patternfly/react-core"; + +export const AppPlaceholder: React.FC = () => { + return ( + +
+
+ +
+
+

Loading...

+
+
+
+ ); +}; diff --git a/frontend/client/src/app/components/ConfirmDialog.tsx b/frontend/client/src/app/components/ConfirmDialog.tsx new file mode 100644 index 000000000..f42e046c4 --- /dev/null +++ b/frontend/client/src/app/components/ConfirmDialog.tsx @@ -0,0 +1,85 @@ +import React from "react"; +import { + Button, + Modal, + ButtonVariant, + ModalVariant, +} from "@patternfly/react-core"; + +export interface ConfirmDialogProps { + isOpen: boolean; + + title: string; + titleIconVariant?: + | "success" + | "danger" + | "warning" + | "info" + | React.ComponentType; + message: string | React.ReactNode; + + confirmBtnLabel: string; + cancelBtnLabel: string; + + inProgress?: boolean; + confirmBtnVariant: ButtonVariant; + + onClose: () => void; + onConfirm: () => void; + onCancel?: () => void; +} + +export const ConfirmDialog: React.FC = ({ + isOpen, + title, + titleIconVariant, + message, + confirmBtnLabel, + cancelBtnLabel, + inProgress, + confirmBtnVariant, + onClose, + onConfirm, + onCancel, +}) => { + const confirmBtn = ( + + ); + + const cancelBtn = onCancel ? ( + + ) : undefined; + + return ( + + {message} + + ); +}; diff --git a/frontend/client/src/app/components/Notifications.tsx b/frontend/client/src/app/components/Notifications.tsx new file mode 100644 index 000000000..2d51d05f4 --- /dev/null +++ b/frontend/client/src/app/components/Notifications.tsx @@ -0,0 +1,36 @@ +import React from "react"; +import { + Alert, + AlertActionCloseButton, + AlertGroup, +} from "@patternfly/react-core"; +import { NotificationsContext } from "./NotificationsContext"; + +export const Notifications: React.FunctionComponent = () => { + const appContext = React.useContext(NotificationsContext); + return ( + + {appContext.notifications.map((notification) => { + return ( + { + appContext.dismissNotification(notification.title); + }} + /> + ), + })} + timeout={notification.timeout ? notification.timeout : 4000} + > + {notification.message} + + ); + })} + + ); +}; diff --git a/frontend/client/src/app/components/NotificationsContext.tsx b/frontend/client/src/app/components/NotificationsContext.tsx new file mode 100644 index 000000000..e21a8d35c --- /dev/null +++ b/frontend/client/src/app/components/NotificationsContext.tsx @@ -0,0 +1,66 @@ +import * as React from "react"; +import { AlertProps } from "@patternfly/react-core"; + +export type INotification = { + title: string; + variant: AlertProps["variant"]; + message?: React.ReactNode; + hideCloseButton?: boolean; + timeout?: number | boolean; +}; + +interface INotificationsProvider { + children: React.ReactNode; +} + +interface INotificationsContext { + pushNotification: (notification: INotification) => void; + dismissNotification: (key: string) => void; + notifications: INotification[]; +} + +const appContextDefaultValue = {} as INotificationsContext; + +const notificationDefault: Pick = { + hideCloseButton: false, +}; + +export const NotificationsContext = React.createContext( + appContextDefaultValue +); + +export const NotificationsProvider: React.FunctionComponent< + INotificationsProvider +> = ({ children }: INotificationsProvider) => { + const [notifications, setNotifications] = React.useState([]); + + const pushNotification = ( + notification: INotification, + clearNotificationDelay?: number + ) => { + setNotifications([ + ...notifications, + { ...notificationDefault, ...notification }, + ]); + setTimeout(() => setNotifications([]), clearNotificationDelay || 10000); + }; + + const dismissNotification = (title: string) => { + const remainingNotifications = notifications.filter( + (n) => n.title !== title + ); + setNotifications(remainingNotifications); + }; + + return ( + + {children} + + ); +}; diff --git a/frontend/client/src/app/components/OidcProvider.tsx b/frontend/client/src/app/components/OidcProvider.tsx new file mode 100644 index 000000000..049303757 --- /dev/null +++ b/frontend/client/src/app/components/OidcProvider.tsx @@ -0,0 +1,54 @@ +import React, { Suspense } from "react"; +import { AuthProvider, useAuth } from "react-oidc-context"; +import { oidcClientSettings } from "@app/oidc"; +import ENV from "@app/env"; +import { AppPlaceholder } from "./AppPlaceholder"; +import { initInterceptors } from "@app/axios-config"; + +interface IOidcProviderProps { + children: React.ReactNode; +} + +export const OidcProvider: React.FC = ({ children }) => { + return ENV.AUTH_REQUIRED !== "true" ? ( + <>{children} + ) : ( + + window.history.replaceState( + {}, + document.title, + window.location.pathname + ) + } + > + {children} + + ); +}; + +const AuthEnabledOidcProvider: React.FC = ({ + children, +}) => { + const auth = useAuth(); + + React.useEffect(() => { + if (!auth.isAuthenticated && !auth.isLoading) { + auth.signinRedirect(); + } + }, [auth.isAuthenticated, auth.isLoading]); + + React.useEffect(() => { + initInterceptors(); + }, []); + + if (auth.isAuthenticated) { + return }>{children}; + } else if (auth.isLoading) { + return ; + } else { + return

Login in...

; + } +}; diff --git a/frontend/client/src/app/components/PageDrawerContext.tsx b/frontend/client/src/app/components/PageDrawerContext.tsx new file mode 100644 index 000000000..a7bb01da5 --- /dev/null +++ b/frontend/client/src/app/components/PageDrawerContext.tsx @@ -0,0 +1,200 @@ +import * as React from "react"; +import { + Drawer, + DrawerActions, + DrawerCloseButton, + DrawerContent, + DrawerContentBody, + DrawerHead, + DrawerPanelBody, + DrawerPanelContent, + DrawerPanelContentProps, +} from "@patternfly/react-core"; +import pageStyles from "@patternfly/react-styles/css/components/Page/page"; + +const usePageDrawerState = () => { + const [isDrawerExpanded, setIsDrawerExpanded] = React.useState(false); + const [drawerPanelContent, setDrawerPanelContent] = + React.useState(null); + const [drawerPanelContentProps, setDrawerPanelContentProps] = React.useState< + Partial + >({}); + const [drawerPageKey, setDrawerPageKey] = React.useState(""); + const drawerFocusRef = React.useRef(document.createElement("span")); + return { + isDrawerExpanded, + setIsDrawerExpanded, + drawerPanelContent, + setDrawerPanelContent, + drawerPanelContentProps, + setDrawerPanelContentProps, + drawerPageKey, + setDrawerPageKey, + drawerFocusRef: drawerFocusRef as typeof drawerFocusRef | null, + }; +}; + +type PageDrawerState = ReturnType; + +const PageDrawerContext = React.createContext({ + isDrawerExpanded: false, + setIsDrawerExpanded: () => {}, + drawerPanelContent: null, + setDrawerPanelContent: () => {}, + drawerPanelContentProps: {}, + setDrawerPanelContentProps: () => {}, + drawerPageKey: "", + setDrawerPageKey: () => {}, + drawerFocusRef: null, +}); + +// PageContentWithDrawerProvider should only be rendered as the direct child of a PatternFly Page component. +interface IPageContentWithDrawerProviderProps { + children: React.ReactNode; // The entire content of the page. See usage in client/src/app/layout/DefaultLayout. +} +export const PageContentWithDrawerProvider: React.FC< + IPageContentWithDrawerProviderProps +> = ({ children }) => { + const pageDrawerState = usePageDrawerState(); + const { + isDrawerExpanded, + drawerFocusRef, + drawerPanelContent, + drawerPanelContentProps, + drawerPageKey, + } = pageDrawerState; + return ( + +
+ drawerFocusRef?.current?.focus()} + position="right" + > + + {drawerPanelContent} + + } + > + {children} + + +
+
+ ); +}; + +let numPageDrawerContentInstances = 0; + +// PageDrawerContent can be rendered anywhere, but must have only one instance rendered at a time. +export interface IPageDrawerContentProps { + isExpanded: boolean; + onCloseClick: () => void; // Should be used to update local state such that `isExpanded` becomes false. + header?: React.ReactNode; + children: React.ReactNode; // The content to show in the drawer when `isExpanded` is true. + drawerPanelContentProps?: Partial; // Additional props for the DrawerPanelContent component. + focusKey?: string | number; // A unique key representing the object being described in the drawer. When this changes, the drawer will regain focus. + pageKey: string; // A unique key representing the page where the drawer is used. Causes the drawer to remount when changing pages. +} + +export const PageDrawerContent: React.FC = ({ + isExpanded, + onCloseClick, + header = null, + children, + drawerPanelContentProps, + focusKey, + pageKey: localPageKeyProp, +}) => { + const { + setIsDrawerExpanded, + drawerFocusRef, + setDrawerPanelContent, + setDrawerPanelContentProps, + setDrawerPageKey, + } = React.useContext(PageDrawerContext); + + // Warn if we are trying to render more than one PageDrawerContent (they'll fight over the same state). + React.useEffect(() => { + numPageDrawerContentInstances++; + return () => { + numPageDrawerContentInstances--; + }; + }, []); + if (numPageDrawerContentInstances > 1) { + console.warn( + `${numPageDrawerContentInstances} instances of PageDrawerContent are currently rendered! Only one instance of this component should be rendered at a time.` + ); + } + + // Lift the value of isExpanded out to the context, but derive it from local state such as a selected table row. + // This is the ONLY place where `setIsDrawerExpanded` should be called. + // To expand/collapse the drawer, use the `isExpanded` prop when rendering PageDrawerContent. + React.useEffect(() => { + setIsDrawerExpanded(isExpanded); + return () => { + setIsDrawerExpanded(false); + setDrawerPanelContent(null); + }; + }, [isExpanded, setDrawerPanelContent, setIsDrawerExpanded]); + + // Same with pageKey and drawerPanelContentProps, keep them in sync with the local prop on PageDrawerContent. + React.useEffect(() => { + setDrawerPageKey(localPageKeyProp); + return () => { + setDrawerPageKey(""); + }; + }, [localPageKeyProp, setDrawerPageKey]); + + React.useEffect(() => { + setDrawerPanelContentProps(drawerPanelContentProps || {}); + }, [drawerPanelContentProps, setDrawerPanelContentProps]); + + // If the drawer is already expanded describing app A, then the user clicks app B, we want to send focus back to the drawer. + + // TODO: This introduces a layout issue bug when clicking in between the columns of a table. + // React.useEffect(() => { + // drawerFocusRef?.current?.focus(); + // }, [drawerFocusRef, focusKey]); + + React.useEffect(() => { + const drawerHead = header === null ? children : header; + const drawerPanelBody = header === null ? null : children; + + setDrawerPanelContent( + <> + + + {drawerHead} + + + + + + {drawerPanelBody} + + ); + }, [ + children, + drawerFocusRef, + header, + isExpanded, + onCloseClick, + setDrawerPanelContent, + ]); + + return null; +}; diff --git a/frontend/client/src/app/env.ts b/frontend/client/src/app/env.ts new file mode 100644 index 000000000..189e60b9c --- /dev/null +++ b/frontend/client/src/app/env.ts @@ -0,0 +1,5 @@ +import { decodeEnv, buildTrustificationEnv } from "@trustification-ui/common"; + +export const ENV = buildTrustificationEnv(decodeEnv(window._env)); + +export default ENV; diff --git a/frontend/client/src/app/hooks/table-controls/getFilterHubRequestParams.ts b/frontend/client/src/app/hooks/table-controls/getFilterHubRequestParams.ts new file mode 100644 index 000000000..9a9cdd0a2 --- /dev/null +++ b/frontend/client/src/app/hooks/table-controls/getFilterHubRequestParams.ts @@ -0,0 +1,242 @@ +import { HubFilter, HubRequestParams } from "@app/api/models"; +import { objectKeys } from "@app/utils/utils"; +import { + FilterCategory, + FilterState, + getFilterLogicOperator, +} from "@carlosthe19916-latest/react-table-batteries"; + +/** + * Helper function for getFilterHubRequestParams + * Given a new filter, determines whether there is an existing filter for that hub field and either creates one or merges this filter with the existing one. + * - If we have multiple UI filters using the same hub field, we need to AND them and pass them to the hub as one filter. + */ +const pushOrMergeFilter = ( + existingFilters: HubFilter[], + newFilter: HubFilter +) => { + const existingFilterIndex = existingFilters.findIndex( + (f) => f.field === newFilter.field + ); + const existingFilter = + existingFilterIndex === -1 ? null : existingFilters[existingFilterIndex]; + // We only want to merge filters in specific conditions: + if ( + existingFilter && // There is a filter to merge with + existingFilter.operator === newFilter.operator && // It is using the same comparison operator as the new filter (=, ~) + typeof newFilter.value !== "object" && // The new filter isn't already a list (nested lists are not supported) + (typeof existingFilter.value !== "object" || // The existing filter isn't already a list, or... + existingFilter.value.operator === "AND") // ...it is and it's an AND list (already merged once) + ) { + const mergedFilter: HubFilter = + typeof existingFilter.value === "object" + ? { + ...existingFilter, + value: { + ...existingFilter.value, + list: [...existingFilter.value.list, newFilter.value], + }, + } + : { + ...existingFilter, + value: { + list: [existingFilter.value, newFilter.value], + operator: "AND", + }, + }; + existingFilters[existingFilterIndex] = mergedFilter; + } else { + existingFilters.push(newFilter); + } +}; + +/** + * Args for getFilterHubRequestParams + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + */ +export interface IGetFilterHubRequestParamsArgs< + TItem, + TFilterCategoryKey extends string = string, +> { + /** + * The "source of truth" state for the filter feature (returned by useFilterState) + */ + filter?: FilterState; + /** + * Definitions of the filters to be used (must include `getItemValue` functions for each category when performing filtering locally) + */ + filterCategories?: FilterCategory[]; + implicitFilters?: HubFilter[]; +} + +/** + * Given the state for the filter feature and additional arguments, returns params the hub API needs to apply the current filters. + * - Makes up part of the object returned by getHubRequestParams + * @see getHubRequestParams + */ +export const getFilterHubRequestParams = < + TItem, + TFilterCategoryKey extends string = string, +>({ + filter, + filterCategories, + implicitFilters, +}: IGetFilterHubRequestParamsArgs< + TItem, + TFilterCategoryKey +>): Partial => { + if ( + !implicitFilters?.length && + (!filter || + !filterCategories || + objectKeys(filter.filterValues).length === 0) + ) { + return {}; + } + const filters: HubFilter[] = []; + if (filter) { + const { filterValues } = filter; + objectKeys(filterValues).forEach((categoryKey) => { + const filterCategory = filterCategories?.find( + (category) => category.key === categoryKey + ); + const filterValue = filterValues[categoryKey]; + if (!filterCategory || !filterValue) return; + const serverFilterField = filterCategory.serverFilterField || categoryKey; + const serverFilterValue = + filterCategory.getServerFilterValue?.(filterValue) || filterValue; + // Note: If we need to support more of the logic operators in HubFilter in the future, + // we'll need to figure out how to express those on the FilterCategory objects + // and translate them here. + if (filterCategory.type === "numsearch") { + pushOrMergeFilter(filters, { + field: serverFilterField, + operator: "=", + value: Number(serverFilterValue[0]), + }); + } + if (filterCategory.type === "search") { + pushOrMergeFilter(filters, { + field: serverFilterField, + operator: "~", + value: serverFilterValue[0], + }); + } + if (filterCategory.type === "select") { + pushOrMergeFilter(filters, { + field: serverFilterField, + operator: "=", + value: serverFilterValue[0], + }); + } + if (filterCategory.type === "multiselect") { + pushOrMergeFilter(filters, { + field: serverFilterField, + operator: "=", + value: { + list: serverFilterValue, + operator: getFilterLogicOperator(filterCategory, "OR"), + }, + }); + } + }); + } + if (implicitFilters) { + implicitFilters.forEach((filter) => filters.push(filter)); + } + return { filters }; +}; + +/** + * Helper function for serializeFilterForHub + * - Given a string or number, returns it as a string with quotes (`"`) around it. + * - Adds an escape character before any existing quote (`"`) characters in the string. + */ +export const wrapInQuotesAndEscape = (value: string | number): string => + `"${String(value).replace('"', '\\"')}"`; + +/** + * Converts a single filter object (HubFilter, the higher-level inspectable type) to the query string filter format used by the hub API + */ +export const serializeFilterForHub = (filter: HubFilter): string => { + const { field, operator, value } = filter; + + let sikula: (fieldName: string, fieldValue: string) => string = () => ""; + switch (operator) { + case "=": + sikula = (fieldName, fieldValue) => { + const f = fieldName.split(":"); + if (f.length == 2) { + switch (f[1]) { + case "in": + return `(${fieldValue} in:${f[0]})`; + case "is": + return `(is:${fieldValue})`; + } + } + + return `${fieldName}:${fieldValue}`; + }; + break; + case "!=": + sikula = (fieldName, fieldValue) => { + return `-${fieldName}:${fieldValue}`; + }; + break; + case "~": + sikula = (_, fieldValue) => { + return fieldValue; + }; + break; + case ">": + sikula = (fieldName, fieldValue) => { + return `-${fieldName}:>${fieldValue}`; + }; + break; + case "<": + sikula = (fieldName, fieldValue) => { + return `-${fieldName}:<${fieldValue}`; + }; + break; + case "<=": + sikula = (fieldName, fieldValue) => { + return `-${fieldName}:<=${fieldValue}`; + }; + break; + case ">=": + sikula = (fieldName, fieldValue) => { + return `-${fieldName}:>=${fieldValue}`; + }; + break; + } + + if (typeof value === "string") { + return sikula(field, wrapInQuotesAndEscape(value)); + } else if (typeof value === "number") { + return sikula(field, `"${value}"`); + } else { + return value.list + .map(wrapInQuotesAndEscape) + .map((val) => sikula(field, val)) + .join(` ${value.operator || "AND"} `); + } +}; + +/** + * Converts the values returned by getFilterHubRequestParams into the URL query strings expected by the hub API + * - Appends converted URL params to the given `serializedParams` object for use in the hub API request + * - Constructs part of the object returned by serializeRequestParamsForHub + * @see serializeRequestParamsForHub + */ +export const serializeFilterRequestParamsForHub = ( + deserializedParams: HubRequestParams, + serializedParams: URLSearchParams +) => { + const { filters } = deserializedParams; + if (filters) { + serializedParams.append( + "q", + `(${filters.map(serializeFilterForHub).join(")(")})` + ); + } +}; diff --git a/frontend/client/src/app/hooks/table-controls/getHubRequestParams.ts b/frontend/client/src/app/hooks/table-controls/getHubRequestParams.ts new file mode 100644 index 000000000..062d0cdac --- /dev/null +++ b/frontend/client/src/app/hooks/table-controls/getHubRequestParams.ts @@ -0,0 +1,71 @@ +// Hub filter/sort/pagination utils +// TODO these could use some unit tests! + +import { HubRequestParams } from "@app/api/models"; +import { + serializeFilterRequestParamsForHub, + getFilterHubRequestParams, + IGetFilterHubRequestParamsArgs, +} from "./getFilterHubRequestParams"; +import { + serializeSortRequestParamsForHub, + getSortHubRequestParams, + IGetSortHubRequestParamsArgs, +} from "./getSortHubRequestParams"; +import { + serializePaginationRequestParamsForHub, + getPaginationHubRequestParams, + IGetPaginationHubRequestParamsArgs, +} from "./getPaginationHubRequestParams"; + +/** + * Returns params required to fetch server-filtered/sorted/paginated data from the hub API. + * - NOTE: This is Hub-specific. + * - Takes "source of truth" state for all table features (returned by useTableControlState), + * - Call after useTableControlState and before fetching API data and then calling useTableControlProps. + * - Returns a HubRequestParams object which is structured for easier consumption by other code before the fetch is made. + * @see useTableControlState + * @see useTableControlProps + */ +export const getHubRequestParams = < + TItem, + TSortableColumnKey extends string, + TFilterCategoryKey extends string = string, +>( + args: IGetFilterHubRequestParamsArgs & + IGetSortHubRequestParamsArgs & + IGetPaginationHubRequestParamsArgs +): HubRequestParams => ({ + ...getFilterHubRequestParams(args), + ...getSortHubRequestParams(args), + ...getPaginationHubRequestParams(args), +}); + +/** + * Converts the HubRequestParams object created above into URLSearchParams (the browser API object for URL query parameters). + * - NOTE: This is Hub-specific. + * - Used internally by the application's useFetch[Resource] hooks + */ +export const serializeRequestParamsForHub = ( + deserializedParams: HubRequestParams +): URLSearchParams => { + const serializedParams = new URLSearchParams(); + serializeFilterRequestParamsForHub(deserializedParams, serializedParams); + serializeSortRequestParamsForHub(deserializedParams, serializedParams); + serializePaginationRequestParamsForHub(deserializedParams, serializedParams); + + // Sikula forces sort to have "sorting" data within the query itself + // rather than its own queryParams, therefore: + if (serializedParams.has("q") && serializedParams.has("sort")) { + serializedParams.set( + "q", + `${serializedParams.get("q")} (${serializedParams.get("sort")})` + ); + serializedParams.delete("sort"); + } else if (serializedParams.has("sort")) { + serializedParams.set("q", `(${serializedParams.get("sort")})`); + serializedParams.delete("sort"); + } + + return serializedParams; +}; diff --git a/frontend/client/src/app/hooks/table-controls/getPaginationHubRequestParams.ts b/frontend/client/src/app/hooks/table-controls/getPaginationHubRequestParams.ts new file mode 100644 index 000000000..68e147472 --- /dev/null +++ b/frontend/client/src/app/hooks/table-controls/getPaginationHubRequestParams.ts @@ -0,0 +1,44 @@ +import { HubRequestParams } from "@app/api/models"; +import { PaginationState } from "@carlosthe19916-latest/react-table-batteries"; + +/** + * Args for getPaginationHubRequestParams + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + */ +export interface IGetPaginationHubRequestParamsArgs { + /** + * The "source of truth" state for the pagination feature (returned by usePaginationState) + */ + pagination?: PaginationState; +} + +/** + * Given the state for the pagination feature and additional arguments, returns params the hub API needs to apply the current pagination. + * - Makes up part of the object returned by getHubRequestParams + * @see getHubRequestParams + */ +export const getPaginationHubRequestParams = ({ + pagination: paginationState, +}: IGetPaginationHubRequestParamsArgs): Partial => { + if (!paginationState) return {}; + const { pageNumber, itemsPerPage } = paginationState; + return { page: { pageNumber, itemsPerPage } }; +}; + +/** + * Converts the values returned by getPaginationHubRequestParams into the URL query strings expected by the hub API + * - Appends converted URL params to the given `serializedParams` object for use in the hub API request + * - Constructs part of the object returned by serializeRequestParamsForHub + * @see serializeRequestParamsForHub + */ +export const serializePaginationRequestParamsForHub = ( + deserializedParams: HubRequestParams, + serializedParams: URLSearchParams +) => { + const { page } = deserializedParams; + if (page) { + const { pageNumber, itemsPerPage } = page; + serializedParams.append("limit", String(itemsPerPage)); + serializedParams.append("offset", String((pageNumber - 1) * itemsPerPage)); + } +}; diff --git a/frontend/client/src/app/hooks/table-controls/getSortHubRequestParams.ts b/frontend/client/src/app/hooks/table-controls/getSortHubRequestParams.ts new file mode 100644 index 000000000..79bddc65c --- /dev/null +++ b/frontend/client/src/app/hooks/table-controls/getSortHubRequestParams.ts @@ -0,0 +1,60 @@ +import { HubRequestParams } from "@app/api/models"; +import { SortState } from "@carlosthe19916-latest/react-table-batteries"; + +/** + * Args for getSortHubRequestParams + * - Partially satisfied by the object returned by useTableControlState (ITableControlState) + */ +export interface IGetSortHubRequestParamsArgs< + TSortableColumnKey extends string, +> { + /** + * The "source of truth" state for the sort feature (returned by usePaginationState) + */ + sort?: SortState; + /** + * A map of `columnKey` values (keys of the `columnNames` object passed to useTableControlState) to the field keys used by the hub API for sorting on those columns + * - Keys and values in this object will usually be the same, but sometimes we need to present a hub field with a different name/key or have a column that is a composite of multiple hub fields. + */ + hubSortFieldKeys?: Record; +} + +/** + * Given the state for the sort feature and additional arguments, returns params the hub API needs to apply the current sort. + * - Makes up part of the object returned by getHubRequestParams + * @see getHubRequestParams + */ +export const getSortHubRequestParams = ({ + sort: sortState, + hubSortFieldKeys, +}: IGetSortHubRequestParamsArgs): Partial => { + if (!sortState?.activeSort || !hubSortFieldKeys) return {}; + const { activeSort } = sortState; + return { + sort: { + field: hubSortFieldKeys[activeSort.columnKey], + direction: activeSort.direction, + }, + }; +}; + +/** + * Converts the values returned by getSortHubRequestParams into the URL query strings expected by the hub API + * - Appends converted URL params to the given `serializedParams` object for use in the hub API request + * - Constructs part of the object returned by serializeRequestParamsForHub + * @see serializeRequestParamsForHub + */ +export const serializeSortRequestParamsForHub = ( + deserializedParams: HubRequestParams, + serializedParams: URLSearchParams +) => { + const { sort } = deserializedParams; + if (sort) { + const { field, direction } = sort; + + serializedParams.append( + "sort", + `${direction === "desc" ? "-" : ""}sort:${field}` + ); + } +}; diff --git a/frontend/client/src/app/hooks/table-controls/index.ts b/frontend/client/src/app/hooks/table-controls/index.ts new file mode 100644 index 000000000..eb811eed5 --- /dev/null +++ b/frontend/client/src/app/hooks/table-controls/index.ts @@ -0,0 +1 @@ +export * from "./getHubRequestParams"; diff --git a/frontend/client/src/app/hooks/useBranding.ts b/frontend/client/src/app/hooks/useBranding.ts new file mode 100644 index 000000000..328369b33 --- /dev/null +++ b/frontend/client/src/app/hooks/useBranding.ts @@ -0,0 +1,12 @@ +import { BrandingStrings, brandingStrings } from "@trustification-ui/common"; + +/** + * Wrap the branding strings in a hook so components access it in a standard + * React way instead of a direct import. This allows the branding implementation + * to change in future with a minimal amount of refactoring in existing components. + */ +export const useBranding = (): BrandingStrings => { + return brandingStrings; +}; + +export default useBranding; diff --git a/frontend/client/src/app/hooks/useCreateEditModalState.ts b/frontend/client/src/app/hooks/useCreateEditModalState.ts new file mode 100644 index 000000000..8f723fcad --- /dev/null +++ b/frontend/client/src/app/hooks/useCreateEditModalState.ts @@ -0,0 +1,18 @@ +import React from "react"; + +type ModalState = + | { mode: "create"; resource: null } + | { mode: "create"; resource: T } + | { mode: "edit"; resource: T } + | null; + +export default function useCreateEditModalState() { + const [modalState, setModalState] = React.useState>(null); + const isModalOpen = modalState !== null; + + return { + modalState, + setModalState, + isModalOpen, + }; +} diff --git a/frontend/client/src/app/hooks/useSelectionState.ts b/frontend/client/src/app/hooks/useSelectionState.ts new file mode 100644 index 000000000..b6f41965a --- /dev/null +++ b/frontend/client/src/app/hooks/useSelectionState.ts @@ -0,0 +1,102 @@ +import * as React from "react"; + +export interface ISelectionStateArgs { + items: T[]; + initialSelected?: T[]; + isEqual?: (a: T, b: T) => boolean; + isItemSelectable?: (item: T) => boolean; + externalState?: [T[], React.Dispatch>]; +} + +export interface ISelectionState { + selectedItems: T[]; + isItemSelected: (item: T) => boolean; + isItemSelectable: (item: T) => boolean; + toggleItemSelected: (item: T, isSelecting?: boolean) => void; + selectMultiple: (items: T[], isSelecting: boolean) => void; + areAllSelected: boolean; + selectAll: (isSelecting?: boolean) => void; + setSelectedItems: (items: T[]) => void; +} + +export const useSelectionState = ({ + items, + initialSelected = [], + isEqual = (a, b) => a === b, + isItemSelectable = () => true, + externalState, +}: ISelectionStateArgs): ISelectionState => { + const internalState = React.useState(initialSelected); + const [selectedItems, setSelectedItems] = externalState || internalState; + + const selectableItems = React.useMemo( + () => items.filter(isItemSelectable), + [items, isItemSelectable] + ); + + const isItemSelected = React.useCallback( + (item: T) => selectedItems.some((i) => isEqual(item, i)), + [isEqual, selectedItems] + ); + + // If isItemSelectable changes and a selected item is no longer selectable, deselect it + React.useEffect(() => { + if (!selectedItems.every(isItemSelectable)) { + setSelectedItems(selectedItems.filter(isItemSelectable)); + } + }, [isItemSelectable, selectedItems, setSelectedItems]); + + const toggleItemSelected = React.useCallback( + (item: T, isSelecting = !isItemSelected(item)) => { + if (isSelecting && isItemSelectable(item)) { + setSelectedItems([...selectedItems, item]); + } else { + setSelectedItems(selectedItems.filter((i) => !isEqual(i, item))); + } + }, + [isEqual, isItemSelectable, isItemSelected, selectedItems, setSelectedItems] + ); + + const selectMultiple = React.useCallback( + (itemsSubset: T[], isSelecting: boolean) => { + const otherSelectedItems = selectedItems.filter( + (selected) => !itemsSubset.some((item) => isEqual(selected, item)) + ); + const itemsToSelect = itemsSubset.filter(isItemSelectable); + if (isSelecting) { + setSelectedItems([...otherSelectedItems, ...itemsToSelect]); + } else { + setSelectedItems(otherSelectedItems); + } + }, + [isEqual, isItemSelectable, selectedItems, setSelectedItems] + ); + + const selectAll = React.useCallback( + (isSelecting = true) => + setSelectedItems(isSelecting ? selectableItems : []), + [selectableItems, setSelectedItems] + ); + const areAllSelected = selectedItems.length === selectableItems.length; + + // Preserve original order of items + const selectedItemsInOrder = React.useMemo(() => { + if (areAllSelected) { + return selectableItems; + } else if (selectedItems.length > 0) { + return selectableItems.filter(isItemSelected); + } + return []; + }, [areAllSelected, isItemSelected, selectableItems, selectedItems.length]); + + return { + selectedItems: selectedItemsInOrder, + isItemSelected, + isItemSelectable, + toggleItemSelected, + selectMultiple, + areAllSelected, + selectAll, + setSelectedItems, + }; +}; diff --git a/frontend/client/src/app/hooks/useStorage.ts b/frontend/client/src/app/hooks/useStorage.ts new file mode 100644 index 000000000..e83d8fb90 --- /dev/null +++ b/frontend/client/src/app/hooks/useStorage.ts @@ -0,0 +1,113 @@ +import * as React from "react"; + +type StorageType = "localStorage" | "sessionStorage"; + +const getValueFromStorage = ( + storageType: StorageType, + key: string, + defaultValue: T +): T => { + if (typeof window === "undefined") return defaultValue; + try { + const itemJSON = window[storageType].getItem(key); + return itemJSON ? (JSON.parse(itemJSON) as T) : defaultValue; + } catch (error) { + console.error(error); + return defaultValue; + } +}; + +const setValueInStorage = ( + storageType: StorageType, + key: string, + newValue: T | undefined +) => { + if (typeof window === "undefined") return; + try { + if (newValue !== undefined) { + const newValueJSON = JSON.stringify(newValue); + window[storageType].setItem(key, newValueJSON); + if (storageType === "localStorage") { + // setItem only causes the StorageEvent to be dispatched in other windows. We dispatch it here + // manually so that all instances of useLocalStorage on this window also react to this change. + window.dispatchEvent( + new StorageEvent("storage", { key, newValue: newValueJSON }) + ); + } + } else { + window[storageType].removeItem(key); + if (storageType === "localStorage") { + window.dispatchEvent( + new StorageEvent("storage", { key, newValue: null }) + ); + } + } + } catch (error) { + console.error(error); + } +}; + +interface IUseStorageOptions { + isEnabled?: boolean; + type: StorageType; + key: string; + defaultValue: T; +} + +const useStorage = ({ + isEnabled = true, + type, + key, + defaultValue, +}: IUseStorageOptions): [T, React.Dispatch>] => { + const [cachedValue, setCachedValue] = React.useState( + getValueFromStorage(type, key, defaultValue) + ); + + const usingStorageEvents = + type === "localStorage" && typeof window !== "undefined" && isEnabled; + + const setValue: React.Dispatch> = React.useCallback( + (newValueOrFn: T | ((prevState: T) => T)) => { + const newValue = + newValueOrFn instanceof Function + ? newValueOrFn(getValueFromStorage(type, key, defaultValue)) + : newValueOrFn; + setValueInStorage(type, key, newValue); + if (!usingStorageEvents) { + // The cache won't update automatically if there is no StorageEvent dispatched. + setCachedValue(newValue); + } + }, + [type, key, defaultValue, usingStorageEvents] + ); + + React.useEffect(() => { + if (!usingStorageEvents) return; + const onStorageUpdated = (event: StorageEvent) => { + if (event.key === key) { + setCachedValue( + event.newValue ? JSON.parse(event.newValue) : defaultValue + ); + } + }; + window.addEventListener("storage", onStorageUpdated); + return () => { + window.removeEventListener("storage", onStorageUpdated); + }; + }, [key, defaultValue, usingStorageEvents]); + + return [cachedValue, setValue]; +}; + +export type UseStorageTypeOptions = Omit, "type">; + +export const useLocalStorage = ( + options: UseStorageTypeOptions +): [T, React.Dispatch>] => + useStorage({ ...options, type: "localStorage" }); + +export const useSessionStorage = ( + options: UseStorageTypeOptions +): [T, React.Dispatch>] => + useStorage({ ...options, type: "sessionStorage" }); diff --git a/frontend/client/src/app/hooks/useUpload.ts b/frontend/client/src/app/hooks/useUpload.ts new file mode 100644 index 000000000..2ffe4ed2b --- /dev/null +++ b/frontend/client/src/app/hooks/useUpload.ts @@ -0,0 +1,255 @@ +import React from "react"; +import axios, { + AxiosError, + AxiosPromise, + AxiosRequestConfig, + AxiosResponse, + CancelTokenSource, +} from "axios"; + +const CANCEL_MESSAGE = "cancelled"; + +interface PromiseConfig { + formData: FormData; + config: AxiosRequestConfig; + + thenFn: (response: AxiosResponse) => void; + catchFn: (error: AxiosError) => void; +} + +interface Upload { + progress: number; + status: "queued" | "inProgress" | "complete"; + response?: AxiosResponse; + error?: AxiosError; + wasCancelled: boolean; + cancelFn?: CancelTokenSource; +} + +const defaultUpload = (): Upload => ({ + progress: 0, + status: "queued", + error: undefined, + response: undefined, + wasCancelled: false, + cancelFn: undefined, +}); + +interface Status { + uploads: Map>; +} + +type Action = + | { + type: "queueUpload"; + payload: { + file: File; + cancelFn: CancelTokenSource; + }; + } + | { + type: "updateUploadProgress"; + payload: { + file: File; + progress: number; + }; + } + | { + type: "finishUploadSuccessfully"; + payload: { + file: File; + response: AxiosResponse; + }; + } + | { + type: "finishUploadWithError"; + payload: { + file: File; + error: AxiosError; + }; + } + | { + type: "removeUpload"; + payload: { + file: File; + }; + }; + +const reducer = ( + state: Status, + action: Action +): Status => { + switch (action.type) { + case "queueUpload": { + return { + ...state, + uploads: new Map(state.uploads).set(action.payload.file, { + ...(state.uploads.get(action.payload.file) || defaultUpload()), + status: "queued", + cancelFn: action.payload.cancelFn, + }), + }; + } + case "updateUploadProgress": { + return { + ...state, + uploads: new Map(state.uploads).set(action.payload.file, { + ...(state.uploads.get(action.payload.file) || defaultUpload()), + progress: action.payload.progress || 0, + status: "inProgress", + }), + }; + } + case "finishUploadSuccessfully": { + return { + ...state, + uploads: new Map(state.uploads).set(action.payload.file, { + ...state.uploads.get(action.payload.file)!, + status: "complete", + + response: action.payload.response, + }), + }; + } + case "finishUploadWithError": { + return { + ...state, + uploads: new Map(state.uploads).set(action.payload.file, { + ...state.uploads.get(action.payload.file)!, + status: "complete", + error: action.payload.error, + wasCancelled: action.payload.error?.message === CANCEL_MESSAGE, + }), + }; + } + case "removeUpload": { + const newUploads = new Map(state.uploads); + newUploads.delete(action.payload.file); + return { + ...state, + uploads: newUploads, + }; + } + default: + throw new Error(); + } +}; + +const initialState = (): Status => ({ + uploads: new Map(), +}); + +export const useUpload = ({ + parallel, + uploadFn, + onSuccess, + onError, +}: { + parallel: boolean; + uploadFn: ( + form: FormData, + requestConfig: AxiosRequestConfig + ) => AxiosPromise; + onSuccess?: (response: AxiosResponse, file: File) => void; + onError?: (error: AxiosError, file: File) => void; +}) => { + const [state, dispatch] = React.useReducer(reducer, initialState()); + + const handleUpload = (acceptedFiles: File[]) => { + const queue: PromiseConfig[] = []; + + for (let index = 0; index < acceptedFiles.length; index++) { + const file = acceptedFiles[index]; + + // Upload + const formData = new FormData(); + formData.set("file", file); + + const cancelFn = axios.CancelToken.source(); + + const config: AxiosRequestConfig = { + headers: { + "X-Requested-With": "XMLHttpRequest", + }, + onUploadProgress: (progressEvent: ProgressEvent) => { + const progress = (progressEvent.loaded / progressEvent.total) * 100; + dispatch({ + type: "updateUploadProgress", + payload: { file, progress: Math.round(progress) }, + }); + }, + cancelToken: cancelFn.token, + }; + + dispatch({ + type: "queueUpload", + payload: { + file, + cancelFn, + }, + }); + + const thenFn = (response: AxiosResponse) => { + dispatch({ + type: "finishUploadSuccessfully", + payload: { file, response }, + }); + + if (onSuccess) onSuccess(response, file); + }; + + const catchFn = (error: AxiosError) => { + dispatch({ + type: "finishUploadWithError", + payload: { file, error }, + }); + + if (error.message !== CANCEL_MESSAGE) { + if (onError) onError(error, file); + } + }; + + const promiseConfig: PromiseConfig = { + formData, + config, + thenFn, + catchFn, + }; + + queue.push(promiseConfig); + } + + if (parallel) { + queue.forEach((queue) => { + uploadFn(queue.formData, queue.config) + .then(queue.thenFn) + .catch(queue.catchFn); + }); + } else { + queue.reduce(async (prev, next) => { + await prev; + return uploadFn(next.formData, next.config) + .then(next.thenFn) + .catch(next.catchFn); + }, Promise.resolve()); + } + }; + + const handleCancelUpload = (file: File) => { + state.uploads.get(file)?.cancelFn?.cancel(CANCEL_MESSAGE); + }; + + const handleRemoveUpload = (file: File) => { + dispatch({ + type: "removeUpload", + payload: { file }, + }); + }; + + return { + uploads: state.uploads as Map>, + handleUpload, + handleCancelUpload, + handleRemoveUpload, + }; +}; diff --git a/frontend/client/src/app/images/avatar.svg b/frontend/client/src/app/images/avatar.svg new file mode 100644 index 000000000..73726f9bc --- /dev/null +++ b/frontend/client/src/app/images/avatar.svg @@ -0,0 +1,18 @@ + + + + + + + + + + diff --git a/frontend/client/src/app/images/pfbg-icon.svg b/frontend/client/src/app/images/pfbg-icon.svg new file mode 100644 index 000000000..6625ff2df --- /dev/null +++ b/frontend/client/src/app/images/pfbg-icon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/client/src/app/layout/about.tsx b/frontend/client/src/app/layout/about.tsx new file mode 100644 index 000000000..20925a579 --- /dev/null +++ b/frontend/client/src/app/layout/about.tsx @@ -0,0 +1,72 @@ +import React from "react"; + +import { + AboutModal, + Text, + TextContent, + TextList, + TextListItem, + TextVariants, +} from "@patternfly/react-core"; + +import backgroundImage from "@app/images/pfbg-icon.svg"; + +import ENV from "@app/env"; +import useBranding from "@app/hooks/useBranding"; + +interface IButtonAboutAppProps { + isOpen: boolean; + onClose: () => void; +} + +const TRANSPARENT_1x1_GIF = + "data:image/gif;base64,R0lGODlhAQABAIAAAP///wAAACH5BAEAAAAALAAAAAABAAEAAAICRAEAOw== "; + +export const AboutApp: React.FC = ({ + isOpen, + onClose, +}) => { + const { about } = useBranding(); + + return ( + + + + {about.displayName} is vendor-neutral, thought-leadering, mostly + informational collection of resources devoted to making Software + Supply Chains easier to create, manage, consume and ultimately… to + trust! + + + {about.documentationUrl ? ( + + For more information refer to{" "} + + {about.displayName} documentation + + + ) : null} + + + + + Version + {ENV.VERSION} + + + + + ); +}; diff --git a/frontend/client/src/app/layout/default-layout.tsx b/frontend/client/src/app/layout/default-layout.tsx new file mode 100644 index 000000000..c5db687d8 --- /dev/null +++ b/frontend/client/src/app/layout/default-layout.tsx @@ -0,0 +1,23 @@ +import React from "react"; + +import { Page } from "@patternfly/react-core"; + +import { HeaderApp } from "./header"; +import { SidebarApp } from "./sidebar"; +import { Notifications } from "@app/components/Notifications"; +import { PageContentWithDrawerProvider } from "@app/components/PageDrawerContext"; + +interface DefaultLayoutProps { + children?: React.ReactNode; +} + +export const DefaultLayout: React.FC = ({ children }) => { + return ( + } sidebar={} isManagedSidebar> + + {children} + + + + ); +}; diff --git a/frontend/client/src/app/layout/header.tsx b/frontend/client/src/app/layout/header.tsx new file mode 100644 index 000000000..dc7eaed8e --- /dev/null +++ b/frontend/client/src/app/layout/header.tsx @@ -0,0 +1,193 @@ +import React, { useReducer, useState } from "react"; +import { useAuth } from "react-oidc-context"; +import { useNavigate } from "react-router-dom"; + +import { + Avatar, + Brand, + Button, + ButtonVariant, + Dropdown, + DropdownItem, + DropdownList, + Masthead, + MastheadBrand, + MastheadContent, + MastheadMain, + MastheadToggle, + MenuToggle, + MenuToggleElement, + PageToggleButton, + Toolbar, + ToolbarContent, + ToolbarGroup, + ToolbarItem, +} from "@patternfly/react-core"; + +import BarsIcon from "@patternfly/react-icons/dist/js/icons/bars-icon"; +import QuestionCircleIcon from "@patternfly/react-icons/dist/esm/icons/question-circle-icon"; +import EllipsisVIcon from "@patternfly/react-icons/dist/esm/icons/ellipsis-v-icon"; +import HelpIcon from "@patternfly/react-icons/dist/esm/icons/help-icon"; + +import { useLocalStorage } from "@app/hooks/useStorage"; +import { isAuthRequired } from "@app/Constants"; + +import { AboutApp } from "./about"; +import imgAvatar from "../images/avatar.svg"; +import useBranding from "@app/hooks/useBranding"; + +export const HeaderApp: React.FC = () => { + const { + masthead: { leftBrand, leftTitle, rightBrand }, + } = useBranding(); + + const auth = (isAuthRequired && useAuth()) || undefined; + + const navigate = useNavigate(); + + const [isAboutOpen, toggleIsAboutOpen] = useReducer((state) => !state, false); + const [isLangDropdownOpen, setIsLangDropdownOpen] = useState(false); + const [isKebabDropdownOpen, setIsKebabDropdownOpen] = useState(false); + const [isUserDropdownOpen, setIsUserDropdownOpen] = useState(false); + + const [lang, setLang] = useLocalStorage({ key: "lang", defaultValue: "es" }); + + const kebabDropdownItems = ( + <> + + About + + + ); + + const onKebabDropdownToggle = () => { + setIsKebabDropdownOpen(!isKebabDropdownOpen); + }; + + const onKebabDropdownSelect = () => { + setIsKebabDropdownOpen(!isKebabDropdownOpen); + }; + + return ( + <> + + + + + + + + + + + {leftBrand ? ( + + ) : null} + + + + + + + + +