diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 000000000..3276197f4 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +Open new issue. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. \ No newline at end of file diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..d00d614d5 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,172 @@ +# Contributors Guide + +Welcome to the Digital Twin as a Service (DTaaS) contributing guide + +Thank you for investing your time in contributing to our project! + +Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community +approachable and respectable. + +In this guide you will get an overview of the contribution workflow +from opening an issue, creating a PR, reviewing, and merging the PR. + +## Project Goals + +It helps development team members get familiar with +the DTaaS project software design, and development processes. +Please see developer-specific +[Slides](https://odin.cps.digit.au.dk/into-cps/dtaas/assets/DTaaS-developer-overview_march2024.pdf), +[Video](https://odin.cps.digit.au.dk/into-cps/dtaas/assets/videos/DTaaS-developer-overview_march2024.mp4), +and [Research paper](https://arxiv.org/abs/2305.07244). + +## :computer: Development Environment + +Please use the steps given here to have suitable development +environment. + +### DevContainers + +There is a [devcontainer configuration](.devcontainer/devcontainer.json) +for the project. Please use it to get a dockerized development environment. +DevContainer is the easiest way to get started. + +### Ubuntu/Linux + +The code base has been developed for most part on +Ubuntu/Linux Operating System.Thus certain parts of the code base might +have bugs when run on Windows. At the moment, only +[runner](./servers/execution/runner/DEVELOPER.md) has problems running +on non-Linux OS. + +The development environment can be installed by using the following +scripts. + +```bash +bash script/env.sh +bash script/docker.sh +``` + +:warning: The docker images are large and are likely to consume +about 5GB of bandwidth and 15GB of space. +You will have to download the docker images on a really good network. + +### Windows + +The development environment scripts for Windows are still buggy. +Any help in improving them is greatly appreciated. +Given that, caveat, please use the following installation steps +for Windows. + +Two powershell installation scripts, namely `base.ps1` and `env.ps1` +are available to install the required +software packages. But errors might crop up due to missing +environment variables. The potential errors are: + +1. `npm is not recognized.........` in `base.ps1`. +1. `gem is not recognized.........` in `env.ps1` + +If you encounter these errors, +remember to include _node_ and _ruby_ installation locations in +**PATH** environment variable +(`Settings --> search for "system environment variables"` +`--> Advanced --> Environment Variables --> PATH`). + +The `base.ps1` and `env.ps1` scripts can be run again after setting +the correct **PATH** environment variable. + +#### Pre-install Nodejs and Ruby Software + +Another way to solve the **PATH** environment problem is to +install Nodejs and Ruby software packages before running the powershell +scripts. + +1. Install the latest stable version of NodeJS from the + [official NodeJS website](https://nodejs.org/en). +1. Install Ruby from + [official Ruby website](https://github.com/oneclick/rubyinstaller2/releases/download/RubyInstaller-3.1.2-1/rubyinstaller-devkit-3.1.2-1-x64.exe) + and follow all the defaults during the installation. + +#### Run Scripts + +Then, open powershell with **administrative** priviledges and run the +following commands in the given order: + +```bash +powershell -F script/base.ps1 +powershell -F script/env.ps1 +powershell -F script/docker.ps1 +``` + +:warning: The docker images are large and are likely to consume +about 5GB of bandwidth and 15GB of space. +You will have to download the docker images on a really good network. + +## :building_construction: Development Workflow + +To manage collaboration by multiple developers on the software, +a development workflow is in place. Each developer should follow these steps: + +1. Fork of the main repository into your github account. +1. Setup + [Code Climate](https://docs.codeclimate.com/docs/getting-started-with-code-climate) + and + [Codecov](https://docs.codecov.com/docs/quick-start) + for your fork. The codecov does not require secret token + for public repositories. +1. Install git-hooks for the project. +1. Use + [Fork, Branch, PR](https://gun.io/news/2017/01/how-to-github-fork-branch-and-pull-request/) + workflow. +1. Work in your fork and open a PR from your working + branch to your `feature/distributed-demo` branch. + The PR will run all the github actions, code climate and codecov checks. +1. Resolve all the issues identified in the previous step. +1. Once changes are verified, a PR should be made to + the `feature/distributed-demo` branch of + the upstream + [DTaaS repository](https://github.com/into-cps-association/DTaaS). +1. The PR will be merged after checks by either the + project administrators or the maintainers. + +Remember that every PR should be meaningful and satisfies +a well-defined user story or improve +the code quality. + +## :eye: Code Quality + +The project code qualities are measured based on: + +- Linting issues identified by + [Code Climate](https://codeclimate.com/github/INTO-CPS-Association/DTaaS) +- Test coverage report collected by + [Codecov](https://codecov.io/gh/INTO-CPS-Association/DTaaS) +- Successful [github actions](https://github.com/INTO-CPS-Association/DTaaS/actions) + +### Code Climate + +Code Climate performs static analysis, linting and style checks. +Quality checks are performed by codeclimate are to ensure the best +possible quality of code to add to our project. + +While any new issues introduced in your code would be +shown in the PR page itself, to address any specific issue, +you can visit the issues or code section of the codeclimate page. + +It is highly recommended that any code you add does +not introduce new quality issues. If they are introduced, +they should be fixed immediately using the appropriate suggestions +from Code Climate, or in worst case, adding a ignore flag +(To be used with caution). + +### Codecov + +Codecov keeps track of the test coverage for the entire project. +For information about testing and workflow related to that, +please see the [testing page](testing/intro.md). + +### Github Actions + +The project has multiple +[github actions](https://github.com/INTO-CPS-Association/DTaaS/tree/feature/distributed-demo/.github/workflows) +defined. All PRs and direct code commits must have successful +status on github actions. diff --git a/client/DEVELOPER.md b/client/DEVELOPER.md index 45cbc0504..f89354c19 100644 --- a/client/DEVELOPER.md +++ b/client/DEVELOPER.md @@ -118,6 +118,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', @@ -148,6 +149,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/client/config/dev.js b/client/config/dev.js index 82df13a27..f79f041dd 100644 --- a/client/config/dev.js +++ b/client/config/dev.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/client/config/local.js b/client/config/local.js index ebf935f90..bc57f0748 100644 --- a/client/config/local.js +++ b/client/config/local.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/client/config/prod.js b/client/config/prod.js index 8b54ea9c3..2e2954520 100644 --- a/client/config/prod.js +++ b/client/config/prod.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/client/config/test.js b/client/config/test.js index 3f4e27d26..b47c87633 100644 --- a/client/config/test.js +++ b/client/config/test.js @@ -9,10 +9,11 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', - REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', - REACT_APP_AUTH_AUTHORITY: 'https://gitlab.com/', + REACT_APP_CLIENT_ID: '38bf4764fad5ebb2ebbf49b4f57c7720145b61266f13bf4891ff7851dd5c6563', + REACT_APP_AUTH_AUTHORITY: 'https://maestro.cps.digit.au.dk/gitlab', REACT_APP_REDIRECT_URI: 'http://localhost:4000/Library', REACT_APP_LOGOUT_REDIRECT_URI: 'http://localhost:4000/', REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', diff --git a/client/env.d.ts b/client/env.d.ts index 6aad96ef7..3ac87cd8d 100644 --- a/client/env.d.ts +++ b/client/env.d.ts @@ -12,6 +12,7 @@ declare global { REACT_APP_WORKBENCHLINK_VSCODE: string; REACT_APP_WORKBENCHLINK_JUPYTERLAB: string; REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: string; + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: string; REACT_APP_WORKBENCHLINK_DT_PREVIEW: string; REACT_APP_CLIENT_ID: string; diff --git a/client/eslint.config.mjs b/client/eslint.config.mjs index 995793350..6e61e7da1 100644 --- a/client/eslint.config.mjs +++ b/client/eslint.config.mjs @@ -1,6 +1,7 @@ import jsxA11Y from "eslint-plugin-jsx-a11y"; import react from "eslint-plugin-react"; import jest from "eslint-plugin-jest"; +import reactHooks from "eslint-plugin-react-hooks"; import typescriptEslint from "@typescript-eslint/eslint-plugin"; import globals from "globals"; import tsParser from "@typescript-eslint/parser"; @@ -40,8 +41,9 @@ export default [{ plugins: { "jsx-a11y": jsxA11Y, react, + "react-hooks": reactHooks, jest, - "@typescript-eslint": typescriptEslint, + "@typescript-eslint": typescriptEslint }, languageOptions: { @@ -86,7 +88,7 @@ export default [{ "@typescript-eslint/no-unused-vars": [ "error", { - "caughtErrorsIgnorePattern": "^_", + "caughtErrorsIgnorePattern": "^_", } ], "no-console": "error", diff --git a/client/jest.config.json b/client/jest.config.json index 7db0ff83f..c6aeb5c9a 100644 --- a/client/jest.config.json +++ b/client/jest.config.json @@ -1,49 +1,49 @@ { - "preset": "ts-jest", - "testEnvironment": "jsdom", - "transform": { - "^.+\\.tsx?$": "ts-jest" - }, - "transformIgnorePatterns": [ - "/node_modules/(?![d3-shape|recharts]).+\\.js$" - ], - "collectCoverage": true, - "coverageReporters": [ - "text", - "cobertura", - "clover", - "lcov", - "json" - ], - "testTimeout": 15000, - "collectCoverageFrom": [ - "src/**/*.{ts,tsx}" - ], - "coveragePathIgnorePatterns": [ - "node_modules", - "build", - "src/index.tsx", - "src/AppProvider.tsx", - "src/store/store.ts", - "src/preview/util/gitlabDriver.ts" - ], - "modulePathIgnorePatterns": [ - "test/e2e", - "mocks", - "config" - ], - "coverageDirectory": "/coverage/", - "globals": { - "window.ENV.SERVER_HOSTNAME": "localhost", - "window.ENV.SERVER_PORT": 3500 - }, - "verbose": true, - "testRegex": "/test/.*\\.test.tsx?$", - "modulePaths": [ - "/src/" - ], - "moduleNameMapper": { - "^test/(.*)$": "/test/$1", - "\\.(css|less|scss)$": "/test/preview/__mocks__/styleMock.ts" - } -} + "preset": "ts-jest", + "testEnvironment": "jsdom", + "transform": { + "^.+\\.tsx?$": "ts-jest" + }, + "transformIgnorePatterns": [ + "/node_modules/(?![d3-shape|recharts]).+\\.js$" + ], + "collectCoverage": true, + "coverageReporters": [ + "text", + "cobertura", + "clover", + "lcov", + "json" + ], + "testTimeout": 15000, + "collectCoverageFrom": [ + "src/**/*.{ts,tsx}" + ], + "coveragePathIgnorePatterns": [ + "node_modules", + "build", + "src/index.tsx", + "src/AppProvider.tsx", + "src/store/store.ts", + "src/preview/util/gitlabDriver.ts" + ], + "modulePathIgnorePatterns": [ + "test/e2e", + "mocks", + "config" + ], + "coverageDirectory": "/coverage/", + "globals": { + "window.ENV.SERVER_HOSTNAME": "localhost", + "window.ENV.SERVER_PORT": 3500 + }, + "verbose": true, + "testRegex": "/test/.*\\.test.tsx?$", + "modulePaths": [ + "/src/" + ], + "moduleNameMapper": { + "^test/(.*)$": "/test/$1", + "\\.(css|less|scss)$": "/test/preview/__mocks__/styleMock.ts" + } +} \ No newline at end of file diff --git a/client/package.json b/client/package.json index 66489c896..edcf27072 100644 --- a/client/package.json +++ b/client/package.json @@ -1,134 +1,140 @@ { - "name": "@into-cps-association/dtaas-web", - "version": "0.7.0", - "description": "Web client for Digital Twin as a Service (DTaaS)", - "main": "index.tsx", - "author": "prasadtalasila (http://prasad.talasila.in/)", - "contributors": [ - "Omar Suleiman", - "Asger Busk Breinholm", - "Mathias Brændgaard", - "Emre Temel", - "Cesar Vela", - "Vanessa Scherma" - ], - "license": "SEE LICENSE IN ", - "private": false, - "type": "module", - "scripts": { - "build": "npx react-scripts build", - "clean": "npx rimraf build/ dist/ node_modules/ coverage/ playwright-report/ test-results/ test.svg src.svg src/util/gitlab.json", - "config:dev": "npx shx cp config/dev.js public/env.js && npx shx cp config/dev.js build/env.js", - "config:local": "npx shx cp config/local.js public/env.js && npx shx cp config/local.js build/env.js", - "config:prod": "npx shx cp config/prod.js public/env.js && npx shx cp config/prod.js build/env.js", - "config:test": "npx shx cp config/test.js public/env.js && npx shx cp config/test.js build/env.js", - "develop": "npx react-scripts start", - "format": "prettier --ignore-path ../.gitignore --write \"**/*.{ts,tsx,css,scss}\"", - "graph": "npx madge --image src.svg src && npx madge --image test.svg test", - "start": "serve -s build -l 4000", - "stop": "npx kill-port 4000", - "syntax": "npx eslint . --fix", - "test:all": "yarn test:unit && yarn test:int && yarn test:e2e", - "test:e2e:ext": "cross-env ext=true yarn test:e2e", - "test:e2e": "yarn config:test && playwright test -c ./playwright.config.ts", - "test:coverage:int-unit": "npx istanbul-combine -d coverage/all -r lcov -r json -r text coverage/unit/coverage-final.json coverage/int/coverage-final.json coverage/preview/unit/coverage-final.json coverage/preview/int/coverage-final.json", - "test:int": "jest -c ./jest.config.json jest --coverage --coverageDirectory=coverage/int ../test/integration --setupFilesAfterEnv ./test/integration/jest.setup.ts", - "test:unit": "jest -c ./jest.config.json --coverageDirectory=coverage/unit ../test/unit --setupFilesAfterEnv ./test/unit/jest.setup.ts", - "test:preview:int": "jest -c ./jest.config.json --coverageDirectory=coverage/preview/int ../test/preview/integration --setupFilesAfterEnv ./test/preview/integration/jest.setup.ts", - "test:preview:unit": "jest -c ./jest.config.json --coverageDirectory=coverage/preview/unit ../test/preview/unit --setupFilesAfterEnv ./test/preview/unit/jest.setup.ts" - }, - "eslintConfig": { - "extends": [ - "react-app" - ] - }, - "prettier": { - "singleQuote": true - }, - "dependencies": { - "@emotion/react": "^11.11.1", - "@emotion/styled": "^11.11.0", - "@eslint/migrate-config": "^1.3.0", - "@fontsource/roboto": "^5.0.8", - "@gitbeaker/rest": "^40.1.2", - "@monaco-editor/react": "^4.6.0", - "@mui/icons-material": "^6.1.1", - "@mui/material": "^6.1.1", - "@mui/x-tree-view": "^7.19.0", - "@reduxjs/toolkit": "^2.2.7", - "@types/react-syntax-highlighter": "^15.5.13", - "@types/remarkable": "^2.0.8", - "@types/styled-components": "^5.1.32", - "@typescript-eslint/eslint-plugin": "^8.7.0", - "@typescript-eslint/parser": "^8.7.0", - "cross-env": "^7.0.3", - "dotenv": "^16.1.4", - "eslint": "^8.2.0", - "eslint-config-airbnb-base": "^15.0.0", - "eslint-config-prettier": "^9.0.0", - "eslint-plugin-import": "^2.29.0", - "eslint-plugin-jest": "^28.8.3", - "eslint-plugin-jsx-a11y": "^6.8.0", - "eslint-plugin-react": "^7.33.2", - "katex": "^0.16.11", - "markdown-it-katex": "^2.0.3", - "oidc-client-ts": "^3.0.1", - "prop-types": "^15.8.1", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "react-iframe": "^1.8.5", - "react-is": "^18.2.0", - "react-oidc-context": "^3.1.1", - "react-redux": "^9.1.2", - "react-router-dom": "^6.20.0", - "react-scripts": "^5.0.1", - "react-syntax-highlighter": "^15.5.0", - "react-tabs": "^6.0.2", - "redux": "^5.0.1", - "remarkable": "^2.0.1", - "remarkable-katex": "^1.2.1", - "resize-observer-polyfill": "^1.5.1", - "serve": "^14.2.1", - "styled-components": "^6.1.1", - "typescript": "5.1.6" - }, - "devDependencies": { - "@babel/core": "7.25.8", - "@babel/plugin-proposal-private-property-in-object": "^7.21.11", - "@babel/plugin-syntax-flow": "7.25.7", - "@babel/plugin-transform-react-jsx": "7.25.7", - "@eslint/eslintrc": "3.1.0", - "@eslint/js": "9.12.0", - "@playwright/test": "1.48.1", - "@testing-library/dom": "10.4.0", - "@testing-library/jest-dom": "6.6.1", - "@testing-library/react": "16.0.1", - "@testing-library/user-event": "14.5.2", - "@types/jest": "29.5.13", - "@types/node": "^22.7.5", - "@types/react": "^18.3.11", - "@types/react-dom": "^18.3.1", - "eslint-config-react-app": "^7.0.1", - "globals": "15.11.0", - "jest": "^29.7.0", - "jest-environment-jsdom": "29.7.0", - "jest-watch-typeahead": "^2.2.2", - "monocart-coverage-reports": "2.11.1", - "playwright": "1.48.1", - "prettier": "3.3.3", - "shx": "0.3.4", - "ts-jest": "29.2.5" - }, - "browserslist": { - "production": [ - ">0.2%", - "not dead", - "not op_mini all" + "name": "@into-cps-association/dtaas-web", + "version": "0.8.1", + "description": "Web client for Digital Twin as a Service (DTaaS)", + "main": "index.tsx", + "author": "prasadtalasila (http://prasad.talasila.in/)", + "contributors": [ + "Omar Suleiman", + "Asger Busk Breinholm", + "Mathias Brændgaard", + "Emre Temel", + "Cesar Vela", + "Vanessa Scherma" ], - "development": [ - "last 1 chrome version", - "last 1 firefox version", - "last 1 safari version" - ] - } -} + "license": "SEE LICENSE IN ", + "private": false, + "type": "module", + "scripts": { + "analyze": "source-map-explorer 'build/static/js/*.js'", + "build": "npx react-scripts build", + "clean": "npx rimraf build/ dist/ node_modules/ coverage/ playwright-report/ test-results/ test.svg src.svg src/util/gitlab.json", + "config:dev": "npx shx cp config/dev.js public/env.js && npx shx cp config/dev.js build/env.js", + "config:local": "npx shx cp config/local.js public/env.js && npx shx cp config/local.js build/env.js", + "config:prod": "npx shx cp config/prod.js public/env.js && npx shx cp config/prod.js build/env.js", + "config:test": "npx shx cp config/test.js public/env.js && npx shx cp config/test.js build/env.js", + "develop": "npx react-scripts start", + "format": "prettier --ignore-path ../.gitignore --write \"**/*.{ts,tsx,css,scss}\"", + "graph": "npx madge --image src.svg src && npx madge --image test.svg test", + "start": "serve -s build -l 4000", + "stop": "npx kill-port 4000", + "syntax": "npx eslint . --fix", + "test:all": "yarn test:unit && yarn test:int && yarn test:e2e", + "test:e2e:ext": "cross-env ext=true yarn test:e2e", + "test:e2e": "yarn config:test && playwright test -c ./playwright.config.ts", + "test:coverage:int-unit": "npx istanbul-combine -d coverage/all -r lcov -r json -r text coverage/unit/coverage-final.json coverage/int/coverage-final.json coverage/preview/unit/coverage-final.json coverage/preview/int/coverage-final.json", + "test:int": "jest -c ./jest.config.json jest --coverage --coverageDirectory=coverage/int ../test/integration --setupFilesAfterEnv ./test/integration/jest.setup.ts", + "test:unit": "jest -c ./jest.config.json --coverageDirectory=coverage/unit ../test/unit --setupFilesAfterEnv ./test/unit/jest.setup.ts", + "test:preview:int": "jest -c ./jest.config.json --coverageDirectory=coverage/preview/int ../test/preview/integration --setupFilesAfterEnv ./test/preview/integration/jest.setup.ts", + "test:preview:unit": "jest -c ./jest.config.json --coverageDirectory=coverage/preview/unit ../test/preview/unit --setupFilesAfterEnv ./test/preview/unit/jest.setup.ts" + }, + "eslintConfig": { + "extends": [ + "react-app" + ] + }, + "prettier": { + "singleQuote": true + }, + "dependencies": { + "@emotion/react": "^11.11.1", + "@emotion/styled": "^11.11.0", + "@eslint/migrate-config": "^1.3.0", + "@fontsource/roboto": "^5.0.8", + "@gitbeaker/rest": "^40.1.2", + "@monaco-editor/react": "^4.6.0", + "@mui/icons-material": "^6.1.1", + "@mui/material": "^6.1.1", + "@mui/x-tree-view": "^7.19.0", + "@reduxjs/toolkit": "^2.2.7", + "@testing-library/react-hooks": "^8.0.1", + "@types/react-syntax-highlighter": "^15.5.13", + "@types/remarkable": "^2.0.8", + "@types/styled-components": "^5.1.32", + "@typescript-eslint/eslint-plugin": "^8.7.0", + "@typescript-eslint/parser": "^8.7.0", + "cross-env": "^7.0.3", + "dotenv": "^16.1.4", + "eslint": "^8.2.0", + "eslint-config-airbnb-base": "^15.0.0", + "eslint-config-prettier": "^9.0.0", + "eslint-plugin-import": "^2.29.0", + "eslint-plugin-jest": "^28.8.3", + "eslint-plugin-jsx-a11y": "^6.8.0", + "eslint-plugin-react": "^7.33.2", + "eslint-plugin-react-hooks": "^5.1.0", + "jest-fetch-mock": "^3.0.3", + "katex": "^0.16.11", + "markdown-it-katex": "^2.0.3", + "oidc-client-ts": "^3.0.1", + "prop-types": "^15.8.1", + "react": "^18.2.0", + "react-dom": "^18.2.0", + "react-iframe": "^1.8.5", + "react-is": "^18.2.0", + "react-oidc-context": "^3.1.1", + "react-redux": "^9.1.2", + "react-router-dom": "^6.20.0", + "react-scripts": "^5.0.1", + "react-syntax-highlighter": "^15.5.0", + "react-tabs": "^6.0.2", + "redux": "^5.0.1", + "remarkable": "^2.0.1", + "remarkable-katex": "^1.2.1", + "reselect": "^5.1.1", + "resize-observer-polyfill": "^1.5.1", + "serve": "^14.2.1", + "styled-components": "^6.1.1", + "typescript": "5.1.6", + "zod": "^3.23.8" + }, + "devDependencies": { + "@babel/core": "7.25.8", + "@babel/plugin-proposal-private-property-in-object": "^7.21.11", + "@babel/plugin-syntax-flow": "7.25.7", + "@babel/plugin-transform-react-jsx": "7.25.7", + "@eslint/eslintrc": "3.1.0", + "@eslint/js": "9.12.0", + "@playwright/test": "1.48.1", + "@testing-library/dom": "10.4.0", + "@testing-library/jest-dom": "6.6.1", + "@testing-library/react": "16.0.1", + "@testing-library/user-event": "14.5.2", + "@types/jest": "29.5.13", + "@types/node": "^22.7.5", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.1", + "eslint-config-react-app": "^7.0.1", + "globals": "15.11.0", + "jest": "^29.7.0", + "jest-environment-jsdom": "29.7.0", + "jest-watch-typeahead": "^2.2.2", + "monocart-coverage-reports": "2.11.1", + "playwright": "1.48.1", + "prettier": "3.3.3", + "shx": "0.3.4", + "ts-jest": "29.2.5" + }, + "browserslist": { + "production": [ + ">0.2%", + "not dead", + "not op_mini all" + ], + "development": [ + "last 1 chrome version", + "last 1 firefox version", + "last 1 safari version" + ] + } +} \ No newline at end of file diff --git a/client/playwright.config.ts b/client/playwright.config.ts index ee8021393..3703bfe63 100644 --- a/client/playwright.config.ts +++ b/client/playwright.config.ts @@ -22,7 +22,7 @@ export default defineConfig({ command: 'yarn start', }, retries: process.env.CI ? 0 : 1, // Disable retries on Github actions for now as setup always fails - timeout: 60 * 1000, + timeout: 120 * 1000, globalTimeout: 10 * 60 * 1000, testDir: './test/e2e/tests', testMatch: /.*\.test\.ts/, diff --git a/client/src/components/LinkIconsLib.tsx b/client/src/components/LinkIconsLib.tsx index 46c2efcf0..a8c107b5d 100644 --- a/client/src/components/LinkIconsLib.tsx +++ b/client/src/components/LinkIconsLib.tsx @@ -7,6 +7,7 @@ import OpenInNewIcon from '@mui/icons-material/OpenInNew'; import GitHubIcon from '@mui/icons-material/GitHub'; import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import TabIcon from '@mui/icons-material/Tab'; +import LibraryBooksOutlined from '@mui/icons-material/LibraryBooksOutlined'; type LinkIconsType = { [key: string]: { icon: React.ReactElement; name: string | undefined }; @@ -29,6 +30,10 @@ const LinkIcons: LinkIconsType = { icon: , name: 'Jupyter Notebook', }, + LIBRARY_PREVIEW: { + icon: , + name: 'Library page preview', + }, DT_PREVIEW: { icon: , name: 'Digital Twins page preview', diff --git a/client/src/page/LayoutPublic.tsx b/client/src/page/LayoutPublic.tsx index 9acc61376..25ae94135 100644 --- a/client/src/page/LayoutPublic.tsx +++ b/client/src/page/LayoutPublic.tsx @@ -4,7 +4,7 @@ import AppBar from '@mui/material/AppBar'; import Footer from 'page/Footer'; import Typography from '@mui/material/Typography'; import Box from '@mui/material/Box'; -import { Container } from '@mui/material'; +import { Breakpoint, Container } from '@mui/material'; import LinkButtons from 'components/LinkButtons'; import toolbarLinkValues from 'util/toolbarUtil'; @@ -26,7 +26,10 @@ const DTappBar = () => ( ); -function LayoutPublic(props: { children: React.ReactNode }) { +function LayoutPublic(props: { + children: React.ReactNode; + containerMaxWidth?: Breakpoint; +}) { return ( - + {props.children} diff --git a/client/src/preview/components/asset/AddToCartButton.tsx b/client/src/preview/components/asset/AddToCartButton.tsx new file mode 100644 index 000000000..414c0f4a9 --- /dev/null +++ b/client/src/preview/components/asset/AddToCartButton.tsx @@ -0,0 +1,50 @@ +import * as React from 'react'; +import { Button } from '@mui/material'; +import LibraryAsset from 'preview/util/libraryAsset'; +import useCart from 'preview/store/CartAccess'; +import { useSelector } from 'react-redux'; +import { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; + +interface AddToCartButtonProps { + assetPath: string; + assetPrivacy: boolean; +} + +function AddToCartButton({ assetPath, assetPrivacy }: AddToCartButtonProps) { + const { state: cartState, actions } = useCart(); + const asset = useSelector( + selectAssetByPathAndPrivacy(assetPath, assetPrivacy), + ) as LibraryAsset; + + const isInCart = cartState.assets.some( + (item: LibraryAsset) => + item.path === asset.path && item.isPrivate === asset.isPrivate, + ); + + const handleAddToCart = async () => { + actions.add(asset); + }; + + const handleRemoveFromCart = async () => { + actions.remove(asset); + }; + + return ( + + ); +} + +export default AddToCartButton; diff --git a/client/src/preview/components/asset/Asset.ts b/client/src/preview/components/asset/Asset.ts index 3210e1a13..a83ad17c4 100644 --- a/client/src/preview/components/asset/Asset.ts +++ b/client/src/preview/components/asset/Asset.ts @@ -1,4 +1,10 @@ +import GitlabInstance from 'preview/util/gitlab'; + export interface Asset { name: string; path: string; + type: string; + isPrivate: boolean; + gitlabInstance?: GitlabInstance; + fullDescription?: string; } diff --git a/client/src/preview/components/asset/AssetBoard.tsx b/client/src/preview/components/asset/AssetBoard.tsx index c315bbb9a..9c0b4bf34 100644 --- a/client/src/preview/components/asset/AssetBoard.tsx +++ b/client/src/preview/components/asset/AssetBoard.tsx @@ -1,9 +1,14 @@ import * as React from 'react'; -import { Grid } from '@mui/material'; +import { Grid, CircularProgress } from '@mui/material'; import { useSelector, useDispatch } from 'react-redux'; +import { + deleteAsset, + selectAssetsByTypeAndPrivacy, +} from 'preview/store/assets.slice'; +import { fetchDigitalTwins } from 'preview/util/init'; +import { setShouldFetchDigitalTwins } from 'preview/store/digitalTwin.slice'; import { RootState } from 'store/store'; -import { deleteAsset } from 'preview/store/assets.slice'; -import { fetchAssets } from 'preview/util/init'; +import Filter from './Filter'; import { Asset } from './Asset'; import { AssetCardExecute, AssetCardManage } from './AssetCard'; @@ -45,36 +50,74 @@ const AssetGridItem: React.FC<{ ); const AssetBoard: React.FC = ({ tab }) => { - const assets = useSelector((state: RootState) => state.assets.items); + const allAssets = useSelector( + selectAssetsByTypeAndPrivacy('Digital Twins', true), + ); + const [filter, setFilter] = React.useState(''); const [error, setError] = React.useState(null); + const shouldFetchDigitalTwins = useSelector( + (state: RootState) => state.digitalTwin.shouldFetchDigitalTwins, + ); + const [loading, setLoading] = React.useState(true); const dispatch = useDispatch(); React.useEffect(() => { const fetchData = async () => { - await fetchAssets(dispatch, setError); + setLoading(true); + try { + await fetchDigitalTwins(dispatch, setError); + } finally { + setLoading(false); + dispatch(setShouldFetchDigitalTwins(false)); + } }; - fetchData(); - }, [dispatch]); + + if (shouldFetchDigitalTwins === true) { + fetchData(); + } else { + setLoading(false); + } + }, [dispatch, shouldFetchDigitalTwins]); const handleDelete = (deletedAssetPath: string) => { dispatch(deleteAsset(deletedAssetPath)); }; + const filteredAssets = allAssets.filter((asset) => + asset.name.toLowerCase().includes(filter.toLowerCase()), + ); + if (error) { return {error}; } return ( - - {assets.map((asset) => ( - - ))} - + <> + {loading ? ( + + + + ) : ( + <> + + + {filteredAssets.map((asset) => ( + + ))} + + + )} + ); }; diff --git a/client/src/preview/components/asset/AssetCard.tsx b/client/src/preview/components/asset/AssetCard.tsx index 5c93a8de6..ab7f151c9 100644 --- a/client/src/preview/components/asset/AssetCard.tsx +++ b/client/src/preview/components/asset/AssetCard.tsx @@ -14,16 +14,19 @@ import LogDialog from 'preview/route/digitaltwins/execute/LogDialog'; import DetailsDialog from 'preview/route/digitaltwins/manage/DetailsDialog'; import ReconfigureDialog from 'preview/route/digitaltwins/manage/ReconfigureDialog'; import DeleteDialog from 'preview/route/digitaltwins/manage/DeleteDialog'; +import { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; import StartStopButton from './StartStopButton'; import LogButton from './LogButton'; import { Asset } from './Asset'; import DetailsButton from './DetailsButton'; import ReconfigureButton from './ReconfigureButton'; import DeleteButton from './DeleteButton'; +import AddToCartButton from './AddToCartButton'; interface AssetCardProps { asset: Asset; buttons?: React.ReactNode; + library?: boolean; } interface AssetCardManageProps { @@ -34,6 +37,7 @@ interface AssetCardManageProps { interface CardButtonsContainerManageProps { assetName: string; + assetPrivacy: boolean; setShowDetails: Dispatch>; setShowReconfigure: Dispatch>; setShowDelete: Dispatch>; @@ -44,6 +48,13 @@ interface CardButtonsContainerExecuteProps { setShowLog: Dispatch>; } +interface CardButtonsContainerLibraryProps { + assetName: string; + assetPath: string; + assetPrivacy: boolean; + setShowDetails: Dispatch>; +} + const Header = styled(Typography)` display: -webkit-box; -webkit-line-clamp: 1; @@ -59,11 +70,17 @@ const Description = styled(Typography)` text-overflow: ellipsis; `; -function CardActionAreaContainer(asset: Asset) { +function CardActionAreaContainer(asset: Asset, library?: boolean) { const digitalTwin = useSelector( - (state: RootState) => state.digitalTwin[asset.name], + (state: RootState) => state.digitalTwin.digitalTwin[asset.name], ); + const libraryAsset = useSelector( + selectAssetByPathAndPrivacy(asset.path, asset.isPrivate), + ); + + const selectedAsset = library ? libraryAsset : digitalTwin; + return ( @@ -78,7 +95,7 @@ function CardActionAreaContainer(asset: Asset) { }} > - {digitalTwin.description} + {selectedAsset!.description} @@ -88,13 +105,18 @@ function CardActionAreaContainer(asset: Asset) { function CardButtonsContainerManage({ assetName, + assetPrivacy, setShowDetails, setShowReconfigure, setShowDelete, }: CardButtonsContainerManageProps) { return ( - + @@ -120,7 +142,27 @@ function CardButtonsContainerExecute({ ); } -function AssetCard({ asset, buttons }: AssetCardProps) { +function CardButtonsContainerLibrary({ + assetName, + assetPath, + assetPrivacy, + setShowDetails, +}: CardButtonsContainerLibraryProps) { + return ( + + + + + ); +} + +function AssetCard({ asset, buttons, library }: AssetCardProps) { return (
{formatName(asset.name)}
- + {buttons}
); @@ -153,6 +195,7 @@ function AssetCardManage({ asset, onDelete }: AssetCardManageProps) { buttons={ ('success'); + const [showDetails, setShowDetails] = useState(false); + + return ( + <> + + } + library={true} + /> + + + ); +} + +export { AssetCardManage, AssetCardExecute, AssetCardLibrary }; diff --git a/client/src/preview/components/asset/AssetLibrary.tsx b/client/src/preview/components/asset/AssetLibrary.tsx new file mode 100644 index 000000000..251ca84f5 --- /dev/null +++ b/client/src/preview/components/asset/AssetLibrary.tsx @@ -0,0 +1,94 @@ +import * as React from 'react'; +import { Grid, CircularProgress, Box } from '@mui/material'; +import { AssetCardLibrary } from 'preview/components/asset/AssetCard'; +import { useDispatch, useSelector } from 'react-redux'; +import { selectAssetsByTypeAndPrivacy } from 'preview/store/assets.slice'; +import { fetchLibraryAssets } from 'preview/util/init'; +import Filter from 'preview/components/asset/Filter'; +import { useState } from 'react'; + +const outerGridContainerProps = { + container: true, + spacing: 2, + sx: { + justifyContent: 'flex-start', + overflow: 'auto', + maxHeight: 'inherent', + }, +}; + +function AssetLibrary(props: { pathToAssets: string; privateRepo: boolean }) { + const assets = useSelector( + selectAssetsByTypeAndPrivacy(props.pathToAssets, props.privateRepo), + ); + const [filter, setFilter] = useState(''); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const dispatch = useDispatch(); + + React.useEffect(() => { + const fetchData = async () => { + setLoading(true); + await fetchLibraryAssets( + dispatch, + setError, + props.pathToAssets, + props.privateRepo, + ); + setLoading(false); + }; + fetchData(); + }, [dispatch, props.pathToAssets, props.privateRepo]); + + const filteredAssets = assets.filter((asset) => + asset.name.toLowerCase().includes(filter.toLowerCase()), + ); + + if (loading) { + return ( + + + + ); + } + + if (!assets.length) { + return {error}; + } + + return ( + <> + + + + + {filteredAssets.map((asset, i) => ( + + + + ))} + + + ); +} + +export default AssetLibrary; diff --git a/client/src/preview/components/asset/DetailsButton.tsx b/client/src/preview/components/asset/DetailsButton.tsx index cf1862989..86573b34d 100644 --- a/client/src/preview/components/asset/DetailsButton.tsx +++ b/client/src/preview/components/asset/DetailsButton.tsx @@ -2,30 +2,61 @@ import * as React from 'react'; import { Dispatch, SetStateAction } from 'react'; import { Button } from '@mui/material'; import { useSelector } from 'react-redux'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; import { selectDigitalTwinByName } from '../../store/digitalTwin.slice'; import DigitalTwin from '../../util/digitalTwin'; interface DialogButtonProps { assetName: string; - setShowDetails: Dispatch>; + assetPrivacy: boolean; + setShowDetails: Dispatch>; + library?: boolean; + assetPath?: string; } export const handleToggleDetailsDialog = async ( - digitalTwin: DigitalTwin, + digitalTwin: DigitalTwin | LibraryAsset, setShowDetails: Dispatch>, ) => { await digitalTwin.getFullDescription(); setShowDetails(true); }; -function DetailsButton({ assetName, setShowDetails }: DialogButtonProps) { +export const handleToggleDetailsLibraryDialog = async ( + asset: LibraryAsset | DigitalTwin, + setShowDetails: Dispatch>, +) => { + await asset.getFullDescription(); + setShowDetails(true); +}; + +function DetailsButton({ + assetName, + assetPrivacy, + setShowDetails, + library, + assetPath, +}: DialogButtonProps) { const digitalTwin = useSelector(selectDigitalTwinByName(assetName)); + const libraryAsset = useSelector( + selectAssetByPathAndPrivacy(assetPath || '', assetPrivacy), + ); + + const asset = library ? libraryAsset : digitalTwin; + return ( diff --git a/client/src/preview/components/asset/Filter.tsx b/client/src/preview/components/asset/Filter.tsx new file mode 100644 index 000000000..18d954afb --- /dev/null +++ b/client/src/preview/components/asset/Filter.tsx @@ -0,0 +1,40 @@ +import * as React from 'react'; +import { TextField, Box, IconButton } from '@mui/material'; +import SearchIcon from '@mui/icons-material/Search'; +import ClearIcon from '@mui/icons-material/Clear'; + +interface FilterProps { + placeholder?: string; + value: string; + onChange: (value: string) => void; +} + +const Filter: React.FC = ({ + placeholder = 'Search by name', + value, + onChange, +}) => { + const handleClear = () => onChange(''); + + return ( + + + onChange(e.target.value)} + sx={{ maxWidth: 300 }} + /> + {value && ( + + + + )} + + ); +}; + +export default Filter; diff --git a/client/src/preview/components/cart/CartList.tsx b/client/src/preview/components/cart/CartList.tsx new file mode 100644 index 000000000..450073761 --- /dev/null +++ b/client/src/preview/components/cart/CartList.tsx @@ -0,0 +1,24 @@ +import * as React from 'react'; +import useCart from 'preview/store/CartAccess'; +import LibraryAsset from 'preview/util/libraryAsset'; + +function CartList() { + const { state } = useCart(); + return ( +
    + {state.assets.map((a, i) => ( + + ))} +
+ ); +} + +function CartItemRender(props: { asset: LibraryAsset }) { + const displayPath = props.asset.isPrivate + ? props.asset.path + : `common/${props.asset.path}`; + + return
  • {displayPath}
  • ; +} + +export default CartList; diff --git a/client/src/preview/components/cart/ShoppingCart.tsx b/client/src/preview/components/cart/ShoppingCart.tsx new file mode 100644 index 000000000..24645d8c1 --- /dev/null +++ b/client/src/preview/components/cart/ShoppingCart.tsx @@ -0,0 +1,90 @@ +import * as React from 'react'; +import { useState } from 'react'; +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogContentText, + DialogTitle, + Box, +} from '@mui/material'; +import { useNavigate } from 'react-router-dom'; +import useCart from 'preview/store/CartAccess'; +import { removeAllFiles } from 'preview/store/libraryConfigFiles.slice'; +import { useDispatch } from 'react-redux'; +import CartList from './CartList'; + +function ShoppingCart() { + const { actions } = useCart(); + const navigate = useNavigate(); + const [openDialog, setOpenDialog] = useState(false); + const dispatch = useDispatch(); + + const handleClearCart = () => { + actions.clear(); + setOpenDialog(false); + dispatch(removeAllFiles()); + }; + + return ( + + + + + + + + + + + setOpenDialog(false)}> + Confirm Clear + + Are you sure you want to clear? + + + + + + + + ); +} + +export default ShoppingCart; diff --git a/client/src/preview/route/digitaltwins/create/CreateDTDialog.tsx b/client/src/preview/route/digitaltwins/create/CreateDTDialog.tsx index 3890624fb..8c25f209e 100644 --- a/client/src/preview/route/digitaltwins/create/CreateDTDialog.tsx +++ b/client/src/preview/route/digitaltwins/create/CreateDTDialog.tsx @@ -1,24 +1,32 @@ import * as React from 'react'; -import { Dispatch, SetStateAction } from 'react'; +import { Dispatch, SetStateAction, useState } from 'react'; import { Dialog, DialogActions, DialogContent, Typography, Button, + CircularProgress, + Box, } from '@mui/material'; import { FileState, removeAllCreationFiles } from 'preview/store/file.slice'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from 'store/store'; import DigitalTwin from 'preview/util/digitalTwin'; import { showSnackbar } from 'preview/store/snackbar.slice'; -import { setDigitalTwin } from 'preview/store/digitalTwin.slice'; +import { + setDigitalTwin, + setShouldFetchDigitalTwins, +} from 'preview/store/digitalTwin.slice'; import { addDefaultFiles, defaultFiles, validateFiles, } from 'preview/util/fileUtils'; import { initDigitalTwin } from 'preview/util/init'; +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; +import LibraryAsset from 'preview/util/libraryAsset'; +import useCart from 'preview/store/CartAccess'; interface CreateDTDialogProps { open: boolean; @@ -52,6 +60,7 @@ const handleSuccess = ( }), ); dispatch(setDigitalTwin({ assetName: newDigitalTwinName, digitalTwin })); + dispatch(setShouldFetchDigitalTwins(true)); dispatch(removeAllCreationFiles()); addDefaultFiles(defaultFiles, files, dispatch); @@ -71,6 +80,8 @@ const resetDialogAndForm = ( const handleConfirm = async ( files: FileState[], + libraryFiles: LibraryConfigFile[], + cartAssets: LibraryAsset[], setErrorMessage: Dispatch>, newDigitalTwinName: string, dispatch: ReturnType, @@ -79,11 +90,18 @@ const handleConfirm = async ( setFileContent: Dispatch>, setFileType: Dispatch>, setNewDigitalTwinName: Dispatch>, + setIsLoading: Dispatch>, + actions: ReturnType['actions'], ) => { - if (validateFiles(files, setErrorMessage)) return; + setIsLoading(true); + + if (validateFiles(files, libraryFiles, setErrorMessage)) { + setIsLoading(false); + return; + } const digitalTwin = await initDigitalTwin(newDigitalTwinName); - const result = await digitalTwin.create(files); + const result = await digitalTwin.create(files, cartAssets, libraryFiles); if (result.startsWith('Error')) { handleError(result, dispatch); @@ -98,6 +116,8 @@ const handleConfirm = async ( setFileType, ); setNewDigitalTwinName(''); + actions.clear(); + setIsLoading(false); }; const CreateDTDialog: React.FC = ({ @@ -112,8 +132,16 @@ const CreateDTDialog: React.FC = ({ setFileType, }) => { const files: FileState[] = useSelector((state: RootState) => state.files); + const libraryFiles = useSelector( + (state: RootState) => state.libraryConfigFiles, + ); + const cartAssets = useSelector((state: RootState) => state.cart.assets); const dispatch = useDispatch(); + const { actions } = useCart(); + + const [isLoading, setIsLoading] = useState(false); + return ( @@ -122,24 +150,33 @@ const CreateDTDialog: React.FC = ({ {newDigitalTwinName} digital twin? {errorMessage} + {isLoading && ( + + + + )} - + {!isLoading && ( + + )} diff --git a/client/src/preview/route/digitaltwins/create/CreatePage.tsx b/client/src/preview/route/digitaltwins/create/CreatePage.tsx index fddeda661..b1f95a4f3 100644 --- a/client/src/preview/route/digitaltwins/create/CreatePage.tsx +++ b/client/src/preview/route/digitaltwins/create/CreatePage.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; import { Dispatch, SetStateAction, useState } from 'react'; -import { Box, Button, TextField } from '@mui/material'; +import { Box, Button, TextField, Tooltip } from '@mui/material'; import Editor from 'preview/route/digitaltwins/editor/Editor'; import CreateDialogs from './CreateDialogs'; import CustomSnackbar from '../Snackbar'; -import FileActionButtons from './FileActionButtons'; interface CreatePageProps { newDigitalTwinName: string; @@ -19,11 +18,18 @@ function DigitalTwinNameInput({ onChange: (e: React.ChangeEvent) => void; }) { return ( - + @@ -46,21 +52,37 @@ function ActionButtons({ display: 'flex', justifyContent: 'flex-end', width: '100%', - marginBottom: 2, gap: 1, + position: 'fixed', + bottom: 0, + left: 0, + backgroundColor: 'white', + padding: 2, + boxShadow: '0 -2px 5px rgba(0,0,0,0.1)', + zIndex: 10, }} > - + + + + ); } @@ -72,6 +94,9 @@ function CreatePage({ const [fileName, setFileName] = useState(''); const [fileContent, setFileContent] = useState(''); const [fileType, setFileType] = useState(''); + const [filePrivacy, setFilePrivacy] = useState(''); + const [isLibraryFile, setIsLibraryFile] = useState(false); + const [libraryAssetPath, setLibraryAssetPath] = useState(''); const [openChangeFileNameDialog, setOpenChangeFileNameDialog] = useState(false); const [openDeleteFileDialog, setOpenDeleteFileDialog] = useState(false); @@ -93,17 +118,12 @@ function CreatePage({ - setNewDigitalTwinName(e.target.value)} @@ -117,8 +137,16 @@ function CreatePage({ setFileName={setFileName} fileContent={fileContent} setFileContent={setFileContent} + filePrivacy={filePrivacy} + setFilePrivacy={setFilePrivacy} fileType={fileType} setFileType={setFileType} + isLibraryFile={isLibraryFile} + setIsLibraryFile={setIsLibraryFile} + libraryAssetPath={libraryAssetPath} + setLibraryAssetPath={setLibraryAssetPath} + setOpenDeleteFileDialog={setOpenDeleteFileDialog} + setOpenChangeFileNameDialog={setOpenChangeFileNameDialog} /> diff --git a/client/src/preview/route/digitaltwins/create/FileActionButtons.tsx b/client/src/preview/route/digitaltwins/create/FileActionButtons.tsx index 0d023a8bb..1cc96d76f 100644 --- a/client/src/preview/route/digitaltwins/create/FileActionButtons.tsx +++ b/client/src/preview/route/digitaltwins/create/FileActionButtons.tsx @@ -2,34 +2,68 @@ import * as React from 'react'; import Box from '@mui/material/Box'; import Button from '@mui/material/Button'; import { isFileDeletable, isFileModifiable } from 'preview/util/fileUtils'; +import { Tooltip } from '@mui/material'; function FileActionButtons({ fileName, setOpenDeleteFileDialog, setOpenChangeFileNameDialog, + isLibraryFile, }: { fileName: string; setOpenDeleteFileDialog: React.Dispatch>; setOpenChangeFileNameDialog: React.Dispatch>; + isLibraryFile: boolean; }) { + const deleteFileDisabled = !( + isFileDeletable(fileName) && + fileName && + !isLibraryFile + ); + const changeFileNameDisabled = !( + isFileModifiable(fileName) && + fileName && + !isLibraryFile + ); + return ( - - {isFileDeletable(fileName) && fileName && ( - - )} - {isFileModifiable(fileName) && fileName && ( - - )} + + + + + + + + + + + ); } diff --git a/client/src/preview/route/digitaltwins/editor/Editor.tsx b/client/src/preview/route/digitaltwins/editor/Editor.tsx index affbc767d..24f49a222 100644 --- a/client/src/preview/route/digitaltwins/editor/Editor.tsx +++ b/client/src/preview/route/digitaltwins/editor/Editor.tsx @@ -14,6 +14,14 @@ interface EditorProps { setFileContent: React.Dispatch>; fileType: string; setFileType: React.Dispatch>; + filePrivacy: string; + setFilePrivacy: React.Dispatch>; + isLibraryFile: boolean; + setIsLibraryFile: React.Dispatch>; + libraryAssetPath: string; + setLibraryAssetPath: React.Dispatch>; + setOpenDeleteFileDialog?: React.Dispatch>; + setOpenChangeFileNameDialog?: React.Dispatch>; } function Editor({ @@ -25,6 +33,14 @@ function Editor({ setFileContent, fileType, setFileType, + filePrivacy, + setFilePrivacy, + isLibraryFile, + setIsLibraryFile, + libraryAssetPath, + setLibraryAssetPath, + setOpenDeleteFileDialog, + setOpenChangeFileNameDialog, }: EditorProps) { const [activeTab, setActiveTab] = useState(0); @@ -48,7 +64,14 @@ function Editor({ setFileName={setFileName} setFileContent={setFileContent} setFileType={setFileType} + setFilePrivacy={setFilePrivacy} + setIsLibraryFile={setIsLibraryFile} + setLibraryAssetPath={setLibraryAssetPath} tab={tab} + fileName={fileName} + isLibraryFile={isLibraryFile} + setOpenDeleteFileDialog={setOpenDeleteFileDialog || undefined} + setOpenChangeFileNameDialog={setOpenChangeFileNameDialog || undefined} /> @@ -85,7 +108,10 @@ function Editor({ tab={tab} fileName={fileName} fileContent={fileContent} + filePrivacy={filePrivacy} setFileContent={setFileContent} + isLibraryFile={isLibraryFile} + libraryAssetPath={libraryAssetPath} /> )} {activeTab === 1 && ( diff --git a/client/src/preview/route/digitaltwins/editor/EditorTab.tsx b/client/src/preview/route/digitaltwins/editor/EditorTab.tsx index 5201f5eb5..cdfa4d148 100644 --- a/client/src/preview/route/digitaltwins/editor/EditorTab.tsx +++ b/client/src/preview/route/digitaltwins/editor/EditorTab.tsx @@ -2,43 +2,76 @@ import * as React from 'react'; import { useState, useEffect, Dispatch, SetStateAction } from 'react'; import Editor from '@monaco-editor/react'; import { useDispatch } from 'react-redux'; +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; import { addOrUpdateFile } from '../../../store/file.slice'; interface EditorTabProps { tab: string; fileName: string; fileContent: string; + filePrivacy: string; + isLibraryFile: boolean; + libraryAssetPath: string; setFileContent: Dispatch>; } -const handleEditorChange = ( +export const handleEditorChange = ( tab: string, value: string | undefined, setEditorValue: Dispatch>, setFileContent: Dispatch>, fileName: string, + filePrivacy: string, + isLibraryFile: boolean, + libraryAssetPath: string, dispatch: ReturnType, ) => { const updatedValue = value || ''; setEditorValue(updatedValue); setFileContent(updatedValue); + const isPrivate = filePrivacy === 'private'; + if (tab === 'create') { + if (!isLibraryFile) { + dispatch( + addOrUpdateFile({ + name: fileName, + content: updatedValue, + isNew: true, + isModified: true, + }), + ); + } else { + dispatch( + addOrUpdateLibraryFile({ + assetPath: libraryAssetPath, + fileName, + fileContent: updatedValue, + isNew: true, + isModified: true, + isPrivate, + }), + ); + } + } else if (!isLibraryFile && libraryAssetPath === '') { dispatch( addOrUpdateFile({ name: fileName, content: updatedValue, - isNew: true, + isNew: false, isModified: true, }), ); } else { dispatch( - addOrUpdateFile({ - name: fileName, - content: updatedValue, + addOrUpdateLibraryFile({ + assetPath: libraryAssetPath, + fileName, + fileContent: updatedValue, isNew: false, isModified: true, + isPrivate: true, }), ); } @@ -48,6 +81,9 @@ function EditorTab({ tab, fileName, fileContent, + filePrivacy, + isLibraryFile, + libraryAssetPath, setFileContent, }: EditorTabProps) { const [editorValue, setEditorValue] = useState(fileContent); @@ -70,6 +106,9 @@ function EditorTab({ setEditorValue, setFileContent, fileName, + filePrivacy, + isLibraryFile, + libraryAssetPath, dispatch, ) } diff --git a/client/src/preview/route/digitaltwins/editor/Sidebar.tsx b/client/src/preview/route/digitaltwins/editor/Sidebar.tsx index f10336885..99a095d49 100644 --- a/client/src/preview/route/digitaltwins/editor/Sidebar.tsx +++ b/client/src/preview/route/digitaltwins/editor/Sidebar.tsx @@ -1,26 +1,32 @@ import * as React from 'react'; import { useEffect, useState, Dispatch, SetStateAction } from 'react'; -import { Grid, CircularProgress, Button } from '@mui/material'; +import { Grid, CircularProgress, Button, Box } from '@mui/material'; import { SimpleTreeView } from '@mui/x-tree-view/SimpleTreeView'; import { useDispatch, useSelector } from 'react-redux'; import { RootState } from 'store/store'; +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; +import { getFilteredFileNames } from 'preview/util/fileUtils'; import { FileState } from '../../../store/file.slice'; import { selectDigitalTwinByName } from '../../../store/digitalTwin.slice'; -import { - fetchData, - getFilteredFileNames, - handleAddFileClick, - renderFileSection, - renderFileTreeItems, -} from './sidebarFunctions'; +import { fetchData } from './sidebarFetchers'; +import { handleAddFileClick } from './sidebarFunctions'; +import { renderFileTreeItems, renderFileSection } from './sidebarRendering'; import SidebarDialog from './SidebarDialog'; +import FileActionButtons from '../create/FileActionButtons'; interface SidebarProps { name?: string; setFileName: Dispatch>; setFileContent: Dispatch>; setFileType: Dispatch>; + setFilePrivacy: Dispatch>; + setIsLibraryFile: Dispatch>; + setLibraryAssetPath: Dispatch>; tab: string; + fileName: string; + isLibraryFile: boolean; + setOpenDeleteFileDialog?: Dispatch>; + setOpenChangeFileNameDialog?: Dispatch>; } const Sidebar = ({ @@ -28,7 +34,14 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, tab, + fileName, + isLibraryFile, + setOpenDeleteFileDialog, + setOpenChangeFileNameDialog, }: SidebarProps) => { const [isLoading, setIsLoading] = useState(!!name); const [newFileName, setNewFileName] = useState(''); @@ -39,17 +52,46 @@ const Sidebar = ({ name ? selectDigitalTwinByName(name)(state) : null, ); const files: FileState[] = useSelector((state: RootState) => state.files); + + const assets = useSelector((state: RootState) => state.cart.assets); + const libraryFiles = useSelector( + (state: RootState) => state.libraryConfigFiles, + ); + const dispatch = useDispatch(); useEffect(() => { - if (name && digitalTwin) { - const loadData = async () => { + const loadFiles = async () => { + if (name && digitalTwin) { await fetchData(digitalTwin); - setIsLoading(false); - }; - loadData(); - } - }, [name, digitalTwin]); + } + + if (tab === 'create') { + if (assets.length > 0) { + await Promise.all( + assets.map(async (asset) => { + await asset.getConfigFiles(); + asset.configFiles.forEach((configFile) => { + dispatch( + addOrUpdateLibraryFile({ + assetPath: asset.path, + fileName: configFile, + fileContent: '', + isNew: true, + isModified: false, + isPrivate: asset.isPrivate, + }), + ); + }); + }), + ); + } + } + setIsLoading(false); + }; + + loadFiles(); + }, [name, digitalTwin, assets, dispatch, tab]); if (isLoading) { return ( @@ -88,15 +130,25 @@ const Sidebar = ({ }} > {tab === 'create' && ( - - )} + + + + + + + )} {name ? ( - <> + {renderFileTreeItems( 'Description', digitalTwin!.descriptionFiles, @@ -118,8 +170,12 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} {renderFileTreeItems( 'Configuration', @@ -128,8 +184,12 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} {renderFileTreeItems( 'Lifecycle', @@ -138,12 +198,35 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} - + {digitalTwin!.assetFiles.map((assetFolder) => + renderFileTreeItems( + `${assetFolder.assetPath} configuration`, + assetFolder.fileNames, + digitalTwin!, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, + true, + libraryFiles, + assetFolder.assetPath, + ), + )} + ) : ( - <> + {renderFileSection( 'Description', 'description', @@ -152,8 +235,12 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} {renderFileSection( 'Configuration', @@ -163,8 +250,12 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, )} {renderFileSection( 'Lifecycle', @@ -174,10 +265,35 @@ const Sidebar = ({ setFileName, setFileContent, setFileType, + setFilePrivacy, files, tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, + )} + {assets.map((asset) => + renderFileSection( + asset.isPrivate + ? `${asset.name} configuration` + : `common/${asset.name} configuration`, + 'config', + asset.configFiles, + asset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + dispatch, + setIsLibraryFile, + setLibraryAssetPath, + true, + libraryFiles, + ), )} - + )} diff --git a/client/src/preview/route/digitaltwins/editor/sidebarFetchers.ts b/client/src/preview/route/digitaltwins/editor/sidebarFetchers.ts new file mode 100644 index 000000000..a1d98a3a6 --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/sidebarFetchers.ts @@ -0,0 +1,94 @@ +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import { updateFileState } from 'preview/util/fileUtils'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { Dispatch, SetStateAction } from 'react'; +import { useDispatch } from 'react-redux'; + +export const fetchData = async (digitalTwin: DigitalTwin) => { + await digitalTwin.getDescriptionFiles(); + await digitalTwin.getLifecycleFiles(); + await digitalTwin.getConfigFiles(); + await digitalTwin.getAssetFiles(); +}; + +export const fetchAndSetFileContent = async ( + fileName: string, + digitalTwin: DigitalTwin | null, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + library?: boolean, + assetPath?: string, +) => { + try { + let fileContent; + if (library) { + fileContent = await digitalTwin!.DTAssets.getLibraryFileContent( + assetPath!, + fileName, + ); + } else { + fileContent = await digitalTwin!.DTAssets.getFileContent(fileName); + } + if (fileContent) { + updateFileState( + fileName, + fileContent, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + } + } catch { + setFileContent(`Error fetching ${fileName} content`); + } +}; + +export const fetchAndSetFileLibraryContent = async ( + fileName: string, + libraryAsset: LibraryAsset | null, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + isNew: boolean, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + dispatch?: ReturnType, +) => { + try { + const fileContent = await libraryAsset!.libraryManager.getFileContent( + libraryAsset!.isPrivate, + libraryAsset!.path, + fileName, + ); + + dispatch!( + addOrUpdateLibraryFile({ + assetPath: libraryAsset!.path, + fileName, + fileContent, + isNew, + isModified: false, + isPrivate: libraryAsset!.isPrivate, + }), + ); + if (fileContent) { + updateFileState( + fileName, + fileContent, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + } + setIsLibraryFile(true); + setLibraryAssetPath(libraryAsset!.path); + } catch { + setFileContent(`Error fetching ${fileName} content`); + } +}; diff --git a/client/src/preview/route/digitaltwins/editor/sidebarFunctions.ts b/client/src/preview/route/digitaltwins/editor/sidebarFunctions.ts new file mode 100644 index 000000000..aece16bbc --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/sidebarFunctions.ts @@ -0,0 +1,270 @@ +import { addOrUpdateFile, FileState } from 'preview/store/file.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import { Dispatch, SetStateAction } from 'react'; +import { useDispatch } from 'react-redux'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { + addOrUpdateLibraryFile, + LibraryConfigFile, +} from 'preview/store/libraryConfigFiles.slice'; +import { + getFileTypeFromExtension, + updateFileState, +} from 'preview/util/fileUtils'; +import { + fetchAndSetFileContent, + fetchAndSetFileLibraryContent, +} from './sidebarFetchers'; + +export const handleFileClick = ( + fileName: string, + asset: DigitalTwin | LibraryAsset | null, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + files: FileState[], + tab: string, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + dispatch?: ReturnType, + library?: boolean, + libraryFiles?: LibraryConfigFile[], + assetPath?: string, +) => { + if (tab === 'create') { + handleCreateFileClick( + fileName, + asset, + files, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + dispatch || undefined, + libraryFiles || undefined, + ); + } else if (tab === 'reconfigure') { + handleReconfigureFileClick( + fileName, + asset, + files, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + dispatch || undefined, + library || undefined, + libraryFiles || undefined, + assetPath || undefined, + ); + } +}; + +export const handleCreateFileClick = ( + fileName: string, + asset: DigitalTwin | LibraryAsset | null, + files: FileState[], + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + dispatch?: ReturnType, + libraryFiles?: LibraryConfigFile[], +) => { + if (asset instanceof DigitalTwin || asset === null) { + const newFile = files.find((file) => file.name === fileName && file.isNew); + if (newFile) { + updateFileState( + newFile.name, + newFile.content, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + setIsLibraryFile(false); + setLibraryAssetPath(''); + } + } else { + const libraryFile = libraryFiles!.find( + (file) => + file.fileName === fileName && + file.assetPath === asset!.path && + file.isPrivate === asset!.isPrivate, + ); + if (libraryFile?.isModified) { + updateFileState( + libraryFile.fileName, + libraryFile.fileContent, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + asset.isPrivate, + ); + setIsLibraryFile(true); + setLibraryAssetPath(libraryFile.assetPath); + } else { + fetchAndSetFileLibraryContent( + libraryFile!.fileName, + asset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + true, + setIsLibraryFile, + setLibraryAssetPath, + dispatch || undefined, + ); + } + } +}; + +export const handleReconfigureFileClick = async ( + fileName: string, + asset: DigitalTwin | LibraryAsset | null, + files: FileState[], + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + dispatch?: ReturnType, + library?: boolean, + libraryFiles?: LibraryConfigFile[], + assetPath?: string, +) => { + if (asset instanceof DigitalTwin || asset === null) { + if (library === undefined) { + const modifiedFile = files.find( + (file) => file.name === fileName && file.isModified && !file.isNew, + ); + if (modifiedFile) { + updateFileState( + modifiedFile.name, + modifiedFile.content, + setFileName, + setFileContent, + setFileType, + setFileType, + ); + } else { + fetchAndSetFileContent( + fileName, + asset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + } + setIsLibraryFile(false); + setLibraryAssetPath(''); + } else { + const modifiedLibraryFile = libraryFiles!.find( + (file) => file.fileName === fileName && file.assetPath === assetPath, + ); + if (modifiedLibraryFile?.isModified) { + updateFileState( + modifiedLibraryFile.fileName, + modifiedLibraryFile.fileContent, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + } else { + fetchAndSetFileContent( + fileName, + asset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + library, + assetPath, + ); + const fileContent = await asset!.DTAssets.getLibraryFileContent( + assetPath!, + fileName, + ); + dispatch!( + addOrUpdateLibraryFile({ + assetPath: assetPath!, + fileName, + fileContent, + isNew: false, + isModified: false, + isPrivate: true, + }), + ); + } + setIsLibraryFile(true); + setLibraryAssetPath!(assetPath!); + } + } +}; + +export const handleAddFileClick = ( + setIsFileNameDialogOpen: Dispatch>, +) => { + setIsFileNameDialogOpen(true); +}; + +export const handleCloseFileNameDialog = ( + setIsFileNameDialogOpen: Dispatch>, + setNewFileName: Dispatch>, + setErrorMessage: Dispatch>, +) => { + setIsFileNameDialogOpen(false); + setNewFileName(''); + setErrorMessage(''); +}; + +export const handleFileSubmit = ( + files: FileState[], + newFileName: string, + setErrorMessage: Dispatch>, + dispatch: ReturnType, + setIsFileNameDialogOpen: Dispatch>, + setNewFileName: Dispatch>, +) => { + const fileExists = files.some( + (fileStore: { name: string }) => fileStore.name === newFileName, + ); + + if (fileExists) { + setErrorMessage('A file with this name already exists.'); + return; + } + + if (newFileName === '') { + setErrorMessage("File name can't be empty."); + return; + } + + setErrorMessage(''); + const type = getFileTypeFromExtension(newFileName); + + dispatch( + addOrUpdateFile({ + name: newFileName, + content: '', + isNew: true, + isModified: false, + type, + }), + ); + + setIsFileNameDialogOpen(false); + setNewFileName(''); +}; diff --git a/client/src/preview/route/digitaltwins/editor/sidebarFunctions.tsx b/client/src/preview/route/digitaltwins/editor/sidebarFunctions.tsx deleted file mode 100644 index badacec99..000000000 --- a/client/src/preview/route/digitaltwins/editor/sidebarFunctions.tsx +++ /dev/null @@ -1,263 +0,0 @@ -import { addOrUpdateFile, FileState } from 'preview/store/file.slice'; -import DigitalTwin from 'preview/util/digitalTwin'; -import { Dispatch, SetStateAction } from 'react'; -import { useDispatch } from 'react-redux'; -import { TreeItem, TreeItemProps } from '@mui/x-tree-view/TreeItem'; -import * as React from 'react'; - -export const getFileTypeFromExtension = (fileName: string): string => { - const extension = fileName.split('.').pop()?.toLowerCase(); - if (extension === 'md') return 'description'; - if (extension === 'json' || extension === 'yaml' || extension === 'yml') - return 'config'; - return 'lifecycle'; -}; - -export const fetchData = async (digitalTwin: DigitalTwin) => { - await digitalTwin.getDescriptionFiles(); - await digitalTwin.getLifecycleFiles(); - await digitalTwin.getConfigFiles(); -}; - -export const handleFileClick = ( - fileName: string, - digitalTwin: DigitalTwin | null, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, - files: FileState[], - tab: string, -) => { - if (tab === 'create') { - handleCreateFileClick( - fileName, - files, - setFileName, - setFileContent, - setFileType, - ); - } else if (tab === 'reconfigure') { - handleReconfigureFileClick( - fileName, - digitalTwin, - files, - setFileName, - setFileContent, - setFileType, - ); - } -}; - -export const renderFileTreeItems = ( - label: string, - filesToRender: string[], - digitalTwin: DigitalTwin, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, - files: FileState[], - tab: string, -) => ( - - {filesToRender.map((item) => ( - - handleFileClick( - item, - digitalTwin!, - setFileName, - setFileContent, - setFileType, - files, - tab, - ) - } - /> - ))} - -); - -export const getFilteredFileNames = (type: string, files: FileState[]) => - files - .filter( - (file) => file.isNew && getFileTypeFromExtension(file.name) === type, - ) - .map((file) => file.name); - -export const renderFileSection = ( - label: string, - type: string, - filesToRender: string[], - digitalTwin: DigitalTwin, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, - files: FileState[], - tab: string, -) => ( - - {filesToRender.map((item) => ( - - handleFileClick( - item, - digitalTwin!, - setFileName, - setFileContent, - setFileType, - files, - tab, - ) - } - /> - ))} - -); - -export const handleCreateFileClick = ( - fileName: string, - files: FileState[], - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, -) => { - const newFile = files.find((file) => file.name === fileName && file.isNew); - if (newFile) { - updateFileState( - newFile.name, - newFile.content, - setFileName, - setFileContent, - setFileType, - ); - } -}; - -export const handleReconfigureFileClick = ( - fileName: string, - digitalTwin: DigitalTwin | null, - files: FileState[], - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, -) => { - const modifiedFile = files.find( - (file) => file.name === fileName && file.isModified && !file.isNew, - ); - if (modifiedFile) { - updateFileState( - modifiedFile.name, - modifiedFile.content, - setFileName, - setFileContent, - setFileType, - ); - } else { - fetchAndSetFileContent( - fileName, - digitalTwin, - setFileName, - setFileContent, - setFileType, - ); - } -}; - -export const fetchAndSetFileContent = async ( - fileName: string, - digitalTwin: DigitalTwin | null, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, -) => { - try { - const fileContent = await digitalTwin!.DTAssets.getFileContent(fileName); - if (fileContent) { - updateFileState( - fileName, - fileContent, - setFileName, - setFileContent, - setFileType, - ); - } - } catch { - setFileContent(`Error fetching ${fileName} content`); - } -}; - -export const updateFileState = ( - fileName: string, - fileContent: string, - setFileName: Dispatch>, - setFileContent: Dispatch>, - setFileType: Dispatch>, -) => { - setFileName(fileName); - setFileContent(fileContent); - setFileType(fileName.split('.').pop()!); -}; - -export const handleAddFileClick = ( - setIsFileNameDialogOpen: Dispatch>, -) => { - setIsFileNameDialogOpen(true); -}; - -export const handleCloseFileNameDialog = ( - setIsFileNameDialogOpen: Dispatch>, - setNewFileName: Dispatch>, - setErrorMessage: Dispatch>, -) => { - setIsFileNameDialogOpen(false); - setNewFileName(''); - setErrorMessage(''); -}; - -export const handleFileSubmit = ( - files: FileState[], - newFileName: string, - setErrorMessage: Dispatch>, - dispatch: ReturnType, - setIsFileNameDialogOpen: Dispatch>, - setNewFileName: Dispatch>, -) => { - const fileExists = files.some( - (fileStore: { name: string }) => fileStore.name === newFileName, - ); - - if (fileExists) { - setErrorMessage('A file with this name already exists.'); - return; - } - - if (newFileName === '') { - setErrorMessage("File name can't be empty."); - return; - } - - setErrorMessage(''); - const type = getFileTypeFromExtension(newFileName); - - dispatch( - addOrUpdateFile({ - name: newFileName, - content: '', - isNew: true, - isModified: false, - type, - }), - ); - - setIsFileNameDialogOpen(false); - setNewFileName(''); -}; diff --git a/client/src/preview/route/digitaltwins/editor/sidebarRendering.tsx b/client/src/preview/route/digitaltwins/editor/sidebarRendering.tsx new file mode 100644 index 000000000..10f9031a8 --- /dev/null +++ b/client/src/preview/route/digitaltwins/editor/sidebarRendering.tsx @@ -0,0 +1,129 @@ +import * as React from 'react'; +import { TreeItem, TreeItemProps } from '@mui/x-tree-view/TreeItem'; +import { FileState } from 'preview/store/file.slice'; +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { Dispatch, SetStateAction } from 'react'; +import { useDispatch } from 'react-redux'; +import { handleFileClick } from './sidebarFunctions'; + +export const renderFileTreeItems = ( + label: string, + filesToRender: string[], + asset: DigitalTwin | LibraryAsset, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + files: FileState[], + tab: string, + dispatch: ReturnType, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + library?: boolean, + libraryFiles?: LibraryConfigFile[], + assetPath?: string, +) => { + const baseLabel = + asset instanceof LibraryAsset && !asset.isPrivate + ? `common/${label.toLowerCase()}` + : label.toLowerCase(); + + return ( + + {filesToRender.map((item, index) => { + const itemLabel = + asset instanceof LibraryAsset && !asset.isPrivate + ? `common/${item}` + : item; + + return ( + + handleFileClick( + item, + asset!, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, + library || undefined, + libraryFiles || undefined, + assetPath || undefined, + ) + } + /> + ); + })} + + ); +}; + +export const renderFileSection = ( + label: string, + type: string, + filesToRender: string[], + asset: DigitalTwin | LibraryAsset, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + files: FileState[], + tab: string, + dispatch: ReturnType, + setIsLibraryFile: Dispatch>, + setLibraryAssetPath: Dispatch>, + library?: boolean, + fileLibrary?: LibraryConfigFile[], +) => { + const baseLabel = + asset instanceof LibraryAsset && !asset.isPrivate + ? `common/${label.toLowerCase()}` + : label.toLowerCase(); + + return ( + + {filesToRender.map((item, index) => ( + + handleFileClick( + item, + asset!, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, + library || undefined, + fileLibrary || undefined, + ) + } + /> + ))} + + ); +}; diff --git a/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx b/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx index 145cb9662..69ad5a906 100644 --- a/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx +++ b/client/src/preview/route/digitaltwins/manage/DetailsDialog.tsx @@ -6,12 +6,16 @@ import 'katex/dist/katex.min.css'; // @ts-expect-error: Ignoring TypeScript error due to missing type definitions for 'remarkable-katex'. import * as RemarkableKatex from 'remarkable-katex'; import { useSelector } from 'react-redux'; +import { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; import { selectDigitalTwinByName } from '../../../store/digitalTwin.slice'; interface DetailsDialogProps { showDialog: boolean; setShowDialog: Dispatch>; name: string; + isPrivate: boolean; + library?: boolean; + path?: string; } const handleCloseDetailsDialog = ( @@ -24,8 +28,16 @@ function DetailsDialog({ showDialog, setShowDialog, name, + isPrivate, + library, + path, }: DetailsDialogProps) { const digitalTwin = useSelector(selectDigitalTwinByName(name)); + const libraryAsset = useSelector( + selectAssetByPathAndPrivacy(path || '', isPrivate), + ); + + const asset = library ? libraryAsset : digitalTwin; const md = new Remarkable({ html: true, @@ -37,7 +49,7 @@ function DetailsDialog({
    (''); const [fileContent, setFileContent] = useState(''); const [fileType, setFileType] = useState(''); + const [filePrivacy, setFilePrivacy] = useState(''); + const [isLibraryFile, setIsLibraryFile] = useState(false); + const [libraryAssetPath, setLibraryAssetPath] = useState(''); const [openSaveDialog, setOpenSaveDialog] = useState(false); const [openCancelDialog, setOpenCancelDialog] = useState(false); const digitalTwin = useSelector(selectDigitalTwinByName(name)); const modifiedFiles = useSelector(selectModifiedFiles); + const modifiedLibraryFiles = useSelector(selectModifiedLibraryFiles); const dispatch = useDispatch(); const handleSave = () => setOpenSaveDialog(true); @@ -57,13 +66,20 @@ function ReconfigureDialog({ const handleCloseCancelDialog = () => setOpenCancelDialog(false); const handleConfirmSave = async () => { - await saveChanges(modifiedFiles, digitalTwin, dispatch, name); + await saveChanges( + modifiedFiles, + modifiedLibraryFiles, + digitalTwin, + dispatch, + name, + ); setOpenSaveDialog(false); setShowDialog(false); }; const handleConfirmCancel = () => { dispatch(removeAllModifiedFiles()); + dispatch(removeAllModifiedLibraryFiles()); setOpenCancelDialog(false); setShowDialog(false); }; @@ -82,6 +98,12 @@ function ReconfigureDialog({ setFileContent={setFileContent} fileType={fileType} setFileType={setFileType} + filePrivacy={filePrivacy} + setFilePrivacy={setFilePrivacy} + isLibraryFile={isLibraryFile} + setIsLibraryFile={setIsLibraryFile} + libraryAssetPath={libraryAssetPath} + setLibraryAssetPath={setLibraryAssetPath} /> , name: string, @@ -111,30 +134,44 @@ export const saveChanges = async ( await handleFileUpdate(file, digitalTwin, dispatch); } + for (const file of modifiedLibraryFiles) { + await handleFileUpdate(file, digitalTwin, dispatch); + } + showSuccessSnackbar(dispatch, name); dispatch(removeAllModifiedFiles()); + dispatch(removeAllModifiedLibraryFiles()); }; export const handleFileUpdate = async ( - file: FileState, + file: FileState | LibraryConfigFile, digitalTwin: DigitalTwin, dispatch: ReturnType, ) => { try { - await digitalTwin.DTAssets.updateFileContent(file.name, file.content); - - if (file.name === 'description.md') { - dispatch( - updateDescription({ - assetName: digitalTwin.DTName, - description: file.content, - }), + if ('assetPath' in file) { + await digitalTwin.DTAssets.updateLibraryFileContent( + file.fileName, + file.fileContent, + file.assetPath, ); + } else { + await digitalTwin.DTAssets.updateFileContent(file.name, file.content); + + if (file.name === 'description.md') { + dispatch( + updateDescription({ + assetName: digitalTwin.DTName, + description: file.content, + }), + ); + } } } catch (error) { + const fileName = 'assetPath' in file ? file.fileName : file.name; dispatch( showSnackbar({ - message: `Error updating file ${file.name}: ${error}`, + message: `Error updating file ${fileName}: ${error}`, severity: 'error', }), ); @@ -165,6 +202,12 @@ const ReconfigureMainDialog = ({ setFileContent, fileType, setFileType, + filePrivacy, + setFilePrivacy, + isLibraryFile, + setIsLibraryFile, + libraryAssetPath, + setLibraryAssetPath, }: { showDialog: boolean; setShowDialog: Dispatch>; @@ -177,6 +220,12 @@ const ReconfigureMainDialog = ({ setFileContent: Dispatch>; fileType: string; setFileType: Dispatch>; + filePrivacy: string; + setFilePrivacy: Dispatch>; + isLibraryFile: boolean; + setIsLibraryFile: Dispatch>; + libraryAssetPath: string; + setLibraryAssetPath: Dispatch>; }) => ( diff --git a/client/src/preview/route/library/LibraryPreview.tsx b/client/src/preview/route/library/LibraryPreview.tsx new file mode 100644 index 000000000..2e01d91e5 --- /dev/null +++ b/client/src/preview/route/library/LibraryPreview.tsx @@ -0,0 +1,64 @@ +import * as React from 'react'; +import Layout from 'page/Layout'; +import TabComponent from 'components/tab/TabComponent'; +import { Paper, Typography } from '@mui/material'; +import ShoppingCart from 'preview/components/cart/ShoppingCart'; +import AssetLibrary from 'preview/components/asset/AssetLibrary'; +import { assetType, scope } from './LibraryTabDataPreview'; + +export function createTabs() { + return assetType.map((tab) => ({ + label: tab.label, + body: ( + <> + {tab.body} + + ), + })); +} + +export function createCombinedTabs() { + return assetType.map((tab) => + scope.map((subtab) => ({ + label: `${subtab.label}`, + body: ( +
    +
    + {subtab.body} + +
    + + Selection + + +
    + ), + })), + ); +} + +function LibraryContent() { + const tabsData = createTabs(); + const combinedData = createCombinedTabs(); + + return ( + + + + ); +} + +export default function LibraryPreview() { + return ; +} diff --git a/client/src/preview/route/library/LibraryTabDataPreview.ts b/client/src/preview/route/library/LibraryTabDataPreview.ts new file mode 100644 index 000000000..ac82d08cc --- /dev/null +++ b/client/src/preview/route/library/LibraryTabDataPreview.ts @@ -0,0 +1,40 @@ +import { ITabs } from 'route/IData'; + +export const assetType: ITabs[] = [ + { + label: 'Functions', + body: `The functions responsible for pre- and post-processing of: data inputs, data outputs, control outputs. The data science libraries and functions can be used to create useful function assets for the platform. + In some cases, Digital Twin models require calibration prior to their use; functions written by domain experts along with right data inputs can make model calibration an achievable goal. Another use of functions is to process the sensor and actuator data of both Physical Twins and Digital Twins.`, + }, + { + label: 'Models', + body: `The model assets are used to describe different aspects of Physical Twins and their environment, at different levels of abstraction. Therefore, it is possible to have multiple models for the same Physical Twin. For example, a flexible robot used in a car production plant may have structural model(s) which will be useful in tracking the wear and tear of parts. The same robot can have a behavioural model(s) describing the safety guarantees provided by the robot manufacturer. The same robot can also have a functional model(s) describing the part manufacturing capabilities of the robot.`, + }, + { + label: 'Tools', + body: `The software tool assets are software used to create, evaluate and analyze models. These tools are executed on top of a computing platforms, i.e., an operating system, or virtual machines like Java virtual machine, or inside docker containers. The tools tend to be platform specific, making them less reusable than models. + A tool can be packaged to run on a local or distributed virtual machine environments thus allowing selection of most suitable execution environment for a Digital Twin. + Most models require tools to evaluate them in the context of data inputs. + There exist cases where executable packages are run as binaries in a computing environment. Each of these packages are a pre-packaged combination of models and tools put together to create a ready to use Digital Twins.`, + }, + { + label: 'Data', + body: `The data sources and sinks available to a digital twins. Typical examples of data sources are sensor measurements from Physical Twins, and test data provided by manufacturers for calibration of models. Typical examples of data sinks are visualization software, external users and data storage services. There exist special outputs such as events, and commands which are akin to control outputs from a Digital Twin. These control outputs usually go to Physical Twins, but they can also go to another Digital Twin.`, + }, + { + label: 'Digital Twins', + body: `These are ready to use digital twins created by one or more users. These digital twins can be reconfigured later for specific use cases.`, + }, +]; + +// This type of Array Tabs is for the second line of Tabs +export const scope: ITabs[] = [ + { + label: 'Private', + body: `These reusable assets are only visible to you. Other users can not use these assets in their digital twins.`, + }, + { + label: 'Common', + body: `These reusable assets are visible to all users. Other users can use these assets in their digital twins.`, + }, +]; diff --git a/client/src/preview/store/CartAccess.ts b/client/src/preview/store/CartAccess.ts new file mode 100644 index 000000000..043d552be --- /dev/null +++ b/client/src/preview/store/CartAccess.ts @@ -0,0 +1,18 @@ +import { useDispatch, useSelector } from 'react-redux'; +import { RootState } from 'store/store'; +import LibraryAsset from 'preview/util/libraryAsset'; +import * as cart from './cart.slice'; + +function useCart() { + const dispatch = useDispatch(); + const state = useSelector((store: RootState) => store.cart); + const actions = { + add: (asset: LibraryAsset) => dispatch(cart.addToCart(asset)), + remove: (asset: LibraryAsset) => dispatch(cart.removeFromCart(asset)), + clear: () => dispatch(cart.clearCart()), + }; + + return { state, actions }; +} + +export default useCart; diff --git a/client/src/preview/store/assets.slice.ts b/client/src/preview/store/assets.slice.ts index 085403f20..e54d148cf 100644 --- a/client/src/preview/store/assets.slice.ts +++ b/client/src/preview/store/assets.slice.ts @@ -1,8 +1,10 @@ import { createSlice, PayloadAction } from '@reduxjs/toolkit'; -import { Asset } from '../components/asset/Asset'; +import { RootState } from 'store/store'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { createSelector } from 'reselect'; interface AssetsState { - items: Asset[]; + items: LibraryAsset[]; } const initialState: AssetsState = { @@ -13,17 +15,45 @@ const assetsSlice = createSlice({ name: 'assets', initialState, reducers: { - setAssets: (state, action: PayloadAction) => { + setAssets: (state, action: PayloadAction) => { state.items = action.payload; }, + setAsset: (state, action: PayloadAction) => { + const existingAsset = state.items.find( + (asset) => + asset.path === action.payload.path && + asset.isPrivate === action.payload.isPrivate, + ); + if (!existingAsset) { + state.items.push(action.payload); + } + }, deleteAsset: (state, action: PayloadAction) => { state.items = state.items.filter( - (asset) => asset.path !== action.payload, + (asset) => asset.path !== action.payload && asset.isPrivate === true, ); }, }, }); -export const { setAssets, deleteAsset } = assetsSlice.actions; +export const selectAssetsByTypeAndPrivacy = ( + type: string, + isPrivate: boolean, +) => + createSelector( + (state: RootState) => state.assets.items, + (items: LibraryAsset[]) => + items.filter( + (item) => item.type === type && item.isPrivate === isPrivate, + ), + ); + +export const selectAssetByPathAndPrivacy = + (path: string, isPrivate: boolean) => (state: RootState) => + state.assets.items.find( + (asset) => asset.path === path && asset.isPrivate === isPrivate, + ); + +export const { setAssets, setAsset, deleteAsset } = assetsSlice.actions; export default assetsSlice.reducer; diff --git a/client/src/preview/store/cart.slice.ts b/client/src/preview/store/cart.slice.ts new file mode 100644 index 000000000..30e07ba66 --- /dev/null +++ b/client/src/preview/store/cart.slice.ts @@ -0,0 +1,43 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import LibraryAsset from 'preview/util/libraryAsset'; + +export interface CartState { + assets: LibraryAsset[]; +} + +const initState: CartState = { + assets: [], +}; + +const cartSlice = createSlice({ + name: 'cart', + initialState: initState, + reducers: { + addToCart: (state, action: PayloadAction) => { + if ( + !state.assets.find( + (asset) => + asset.path === action.payload.path && + asset.isPrivate === action.payload.isPrivate, + ) + ) { + state.assets.push(action.payload); + } + }, + removeFromCart: (state, action: PayloadAction) => { + state.assets = state.assets.filter( + (a) => + !( + a.path === action.payload.path && + a.isPrivate === action.payload.isPrivate + ), + ); + }, + clearCart: (state) => { + state.assets = []; + }, + }, +}); + +export const { addToCart, removeFromCart, clearCart } = cartSlice.actions; +export default cartSlice.reducer; diff --git a/client/src/preview/store/digitalTwin.slice.ts b/client/src/preview/store/digitalTwin.slice.ts index 0a6465e7e..e1496ef23 100644 --- a/client/src/preview/store/digitalTwin.slice.ts +++ b/client/src/preview/store/digitalTwin.slice.ts @@ -7,7 +7,15 @@ interface DigitalTwinState { [key: string]: DigitalTwin; } -const initialState: DigitalTwinState = {}; +interface DigitalTwinSliceState { + digitalTwin: DigitalTwinState; + shouldFetchDigitalTwins: boolean; +} + +const initialState: DigitalTwinSliceState = { + digitalTwin: {}, + shouldFetchDigitalTwins: true, +}; const digitalTwinSlice = createSlice({ name: 'digitalTwin', @@ -17,13 +25,13 @@ const digitalTwinSlice = createSlice({ state, action: PayloadAction<{ assetName: string; digitalTwin: DigitalTwin }>, ) => { - state[action.payload.assetName] = action.payload.digitalTwin; + state.digitalTwin[action.payload.assetName] = action.payload.digitalTwin; }, setJobLogs: ( state, action: PayloadAction<{ assetName: string; jobLogs: JobLog[] }>, ) => { - const digitalTwin = state[action.payload.assetName]; + const digitalTwin = state.digitalTwin[action.payload.assetName]; if (digitalTwin) { digitalTwin.jobLogs = action.payload.jobLogs; } @@ -32,7 +40,7 @@ const digitalTwinSlice = createSlice({ state, action: PayloadAction<{ assetName: string; pipelineCompleted: boolean }>, ) => { - const digitalTwin = state[action.payload.assetName]; + const digitalTwin = state.digitalTwin[action.payload.assetName]; if (digitalTwin) { digitalTwin.pipelineCompleted = action.payload.pipelineCompleted; } @@ -41,7 +49,7 @@ const digitalTwinSlice = createSlice({ state, action: PayloadAction<{ assetName: string; pipelineLoading: boolean }>, ) => { - const digitalTwin = state[action.payload.assetName]; + const digitalTwin = state.digitalTwin[action.payload.assetName]; if (digitalTwin) { digitalTwin.pipelineLoading = action.payload.pipelineLoading; } @@ -50,16 +58,22 @@ const digitalTwinSlice = createSlice({ state, action: PayloadAction<{ assetName: string; description: string }>, ) => { - const digitalTwin = state[action.payload.assetName]; + const digitalTwin = state.digitalTwin[action.payload.assetName]; if (digitalTwin) { digitalTwin.description = action.payload.description; } }, + setShouldFetchDigitalTwins: (state, action: PayloadAction) => { + state.shouldFetchDigitalTwins = action.payload; + }, }, }); export const selectDigitalTwinByName = (name: string) => (state: RootState) => - state.digitalTwin[name]; + state.digitalTwin.digitalTwin[name]; + +export const selectShouldFetchDigitalTwins = (state: RootState) => + state.digitalTwin.shouldFetchDigitalTwins; export const { setDigitalTwin, @@ -67,5 +81,7 @@ export const { setPipelineCompleted, setPipelineLoading, updateDescription, + setShouldFetchDigitalTwins, } = digitalTwinSlice.actions; + export default digitalTwinSlice.reducer; diff --git a/client/src/preview/store/file.slice.ts b/client/src/preview/store/file.slice.ts index 6b047f08f..cbce0bf1b 100644 --- a/client/src/preview/store/file.slice.ts +++ b/client/src/preview/store/file.slice.ts @@ -7,6 +7,7 @@ export interface FileState { isNew: boolean; isModified: boolean; type?: string; + isFromCommonLibrary?: boolean; } const initialState: FileState[] = []; diff --git a/client/src/preview/store/libraryConfigFiles.slice.ts b/client/src/preview/store/libraryConfigFiles.slice.ts new file mode 100644 index 000000000..b58dffac4 --- /dev/null +++ b/client/src/preview/store/libraryConfigFiles.slice.ts @@ -0,0 +1,84 @@ +import { createSelector, createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { RootState } from 'store/store'; + +export interface LibraryConfigFile { + assetPath: string; + fileName: string; + fileContent: string; + isNew: boolean; + isModified: boolean; + isPrivate: boolean; +} + +const initialState: LibraryConfigFile[] = []; + +const libraryFilesSlice = createSlice({ + name: 'libraryConfigFiles', + initialState, + reducers: { + addOrUpdateLibraryFile: ( + state, + action: PayloadAction, + ) => { + const { fileName, assetPath, isNew, isPrivate, ...rest } = action.payload; + + if (!fileName || !assetPath) return; + + const index = state.findIndex( + (file) => + file.fileName === fileName && + file.assetPath === assetPath && + file.isNew === isNew && + file.isPrivate === isPrivate, + ); + + if (index >= 0) { + state[index] = { + ...state[index], + ...rest, + isModified: true, + isNew, + }; + } else { + state.push({ + fileName, + assetPath, + ...rest, + isModified: false, + isNew, + isPrivate, + }); + } + }, + + removeAllFiles: (state) => { + state.splice(0, state.length); + }, + + removeAllModifiedLibraryFiles: (state) => { + const filesToSave = state.filter( + (file) => file.isModified && !file.isNew, + ); + filesToSave.forEach((file) => { + const index = state.findIndex( + (f) => f.fileName === file.fileName && !f.isNew, + ); + if (index >= 0) { + state.splice(index, 1); + } + }); + }, + }, +}); + +export const selectModifiedLibraryFiles = createSelector( + (state: RootState) => state.libraryConfigFiles, + (files) => files.filter((file) => !file.isNew), +); + +export const { + addOrUpdateLibraryFile, + removeAllFiles, + removeAllModifiedLibraryFiles, +} = libraryFilesSlice.actions; +export default libraryFilesSlice.reducer; diff --git a/client/src/preview/util/DTAssets.ts b/client/src/preview/util/DTAssets.ts index 9bdf5c6f3..241522e43 100644 --- a/client/src/preview/util/DTAssets.ts +++ b/client/src/preview/util/DTAssets.ts @@ -33,19 +33,73 @@ class DTAssets { } async createFiles( - files: FileState[], + files: + | FileState[] + | Array<{ + name: string; + content: string; + isNew: boolean; + isFromCommonLibrary: boolean; + }>, mainFolderPath: string, lifecycleFolderPath: string, ): Promise { for (const file of files) { + const fileType = (file as FileState).type || 'asset'; + if (file.isNew) { - const filePath = getFilePath(file, mainFolderPath, lifecycleFolderPath); - const commitMessage = `Add ${file.name} to ${file.type === 'lifecycle' ? 'lifecycle' : 'digital twin'} folder`; + const mainFolderPathUpdated = file.isFromCommonLibrary + ? `${mainFolderPath}/common` + : mainFolderPath; + const lifecycleFolderPathUpdated = file.isFromCommonLibrary + ? `${mainFolderPathUpdated}/lifecycle` + : lifecycleFolderPath; + const filePath = + fileType === 'lifecycle' + ? lifecycleFolderPathUpdated + : mainFolderPathUpdated; + const commitMessage = `Add ${file.name} to ${fileType} folder`; await this.fileHandler.createFile(file, filePath, commitMessage); } } } + async getFilesFromAsset(assetPath: string, isPrivate: boolean) { + try { + const fileNames = await this.fileHandler.getLibraryFileNames( + assetPath, + isPrivate, + ); + + const files: Array<{ + name: string; + content: string; + path: string; + isPrivate: boolean; + }> = []; + + for (const fileName of fileNames) { + const fileContent = await this.fileHandler.getFileContent( + `${assetPath}/${fileName}`, + isPrivate, + ); + + files.push({ + name: fileName, + content: fileContent, + path: assetPath, + isPrivate, + }); + } + + return files; + } catch (error) { + throw new Error( + `Error fetching files from asset at ${assetPath}: ${error}`, + ); + } + } + async updateFileContent( fileName: string, fileContent: string, @@ -61,6 +115,17 @@ class DTAssets { await this.fileHandler.updateFile(filePath, fileContent, commitMessage); } + async updateLibraryFileContent( + fileName: string, + fileContent: string, + assetPath: string, + ): Promise { + const filePath = `${assetPath}/${fileName}`; + const commitMessage = `Update ${fileName} content`; + + await this.fileHandler.updateFile(filePath, fileContent, commitMessage); + } + async appendTriggerToPipeline(): Promise { const filePath = `.gitlab-ci.yml`; @@ -131,6 +196,12 @@ ${triggerKey}: async delete(): Promise { await this.removeTriggerFromPipeline(); await this.fileHandler.deleteDT(`digital_twins/${this.DTName}`); + + const libraryDTs = + await this.fileHandler.getFolders(`common/digital_twins`); + if (libraryDTs.includes(`common/digital_twins/${this.DTName}`)) { + await this.fileHandler.deleteDT(`common/digital_twins/${this.DTName}`); + } } async getFileContent(fileName: string): Promise { @@ -145,9 +216,25 @@ ${triggerKey}: return fileContent; } + async getLibraryFileContent( + assetPath: string, + fileName: string, + ): Promise { + const filePath = `${assetPath}/${fileName}`; + return this.fileHandler.getFileContent(filePath); + } + async getFileNames(fileType: FileType): Promise { return this.fileHandler.getFileNames(fileType); } + + async getLibraryConfigFileNames(filePath: string): Promise { + return this.fileHandler.getLibraryConfigFileNames(filePath, true); + } + + async getFolders(path: string): Promise { + return this.fileHandler.getFolders(path); + } } export default DTAssets; diff --git a/client/src/preview/util/digitalTwin.ts b/client/src/preview/util/digitalTwin.ts index 15910fbbf..74f129f4e 100644 --- a/client/src/preview/util/digitalTwin.ts +++ b/client/src/preview/util/digitalTwin.ts @@ -1,8 +1,18 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ + import { getAuthority } from 'util/envUtil'; import { FileState } from 'preview/store/file.slice'; +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; import GitlabInstance from './gitlab'; -import { isValidInstance, logError, logSuccess } from './digitalTwinUtils'; +import { + isValidInstance, + logError, + logSuccess, + getUpdatedLibraryFile, +} from './digitalTwinUtils'; import DTAssets, { FileType } from './DTAssets'; +import LibraryAsset from './libraryAsset'; const RUNNER_TAG = 'linux'; @@ -36,6 +46,8 @@ class DigitalTwin { public lifecycleFiles: string[] = []; + public assetFiles: { assetPath: string; fileNames: string[] }[] = []; + constructor(DTName: string, gitlabInstance: GitlabInstance) { this.DTName = DTName; this.gitlabInstance = gitlabInstance; @@ -123,7 +135,11 @@ class DigitalTwin { } } - async create(files: FileState[]): Promise { + async create( + files: FileState[], + cartAssets: LibraryAsset[], + libraryFiles: LibraryConfigFile[], + ): Promise { if (!this.gitlabInstance.projectId) { return `Error creating ${this.DTName} digital twin: no project id`; } @@ -132,15 +148,30 @@ class DigitalTwin { const lifecycleFolderPath = `${mainFolderPath}/lifecycle`; try { + const assetFilesToCreate = await this.prepareAllAssetFiles( + cartAssets, + libraryFiles, + ); + await this.DTAssets.createFiles( files, mainFolderPath, lifecycleFolderPath, ); + + await this.DTAssets.createFiles( + assetFilesToCreate, + mainFolderPath, + lifecycleFolderPath, + ); + await this.DTAssets.appendTriggerToPipeline(); + return `${this.DTName} digital twin files initialized successfully.`; } catch (error) { - return `Error initializing ${this.DTName} digital twin files: ${String(error)}`; + return `Error initializing ${this.DTName} digital twin files: ${String( + error, + )}`; } } @@ -170,6 +201,90 @@ class DigitalTwin { async getLifecycleFiles() { this.lifecycleFiles = await this.DTAssets.getFileNames(FileType.LIFECYCLE); } + + async prepareAllAssetFiles( + cartAssets: LibraryAsset[], + libraryFiles: LibraryConfigFile[], + ): Promise< + Array<{ + name: string; + content: string; + isNew: boolean; + isFromCommonLibrary: boolean; + }> + > { + const assetFilesToCreate: Array<{ + name: string; + content: string; + isNew: boolean; + isFromCommonLibrary: boolean; + }> = []; + + for (const asset of cartAssets) { + const assetFiles = await this.DTAssets.getFilesFromAsset( + asset.path, + asset.isPrivate, + ); + for (const assetFile of assetFiles) { + const updatedFile = getUpdatedLibraryFile( + assetFile.name, + asset.path, + asset.isPrivate, + libraryFiles, + ); + + assetFilesToCreate.push({ + name: `${asset.name}/${assetFile.name}`, + content: updatedFile ? updatedFile.fileContent : assetFile.content, + isNew: true, + isFromCommonLibrary: !asset.isPrivate, + }); + } + } + return assetFilesToCreate; + } + + async getAssetFiles(): Promise<{ assetPath: string; fileNames: string[] }[]> { + const mainFolderPath = `digital_twins/${this.DTName}`; + const excludeFolder = 'lifecycle'; + const result: { assetPath: string; fileNames: string[] }[] = []; + + try { + const folders = await this.DTAssets.getFolders(mainFolderPath); + + const validFolders = folders.filter( + (folder) => !folder.includes(excludeFolder), + ); + + for (const folder of validFolders) { + if (folder.endsWith('/common')) { + const subFolders = await this.DTAssets.getFolders(folder); + for (const subFolder of subFolders) { + const fileNames = + await this.DTAssets.getLibraryConfigFileNames(subFolder); + + result.push({ + assetPath: subFolder, + fileNames, + }); + } + } else { + const fileNames = + await this.DTAssets.getLibraryConfigFileNames(folder); + + result.push({ + assetPath: folder, + fileNames, + }); + } + } + + this.assetFiles = result; + } catch (_error) { + return []; + } + return result; + } } export default DigitalTwin; diff --git a/client/src/preview/util/digitalTwinUtils.ts b/client/src/preview/util/digitalTwinUtils.ts index 02b61b46b..409a429a9 100644 --- a/client/src/preview/util/digitalTwinUtils.ts +++ b/client/src/preview/util/digitalTwinUtils.ts @@ -1,5 +1,6 @@ /* eslint-disable no-param-reassign */ +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; import DigitalTwin from './digitalTwin'; export function isValidInstance(digitalTwin: DigitalTwin): boolean { @@ -31,3 +32,20 @@ export function logError( }); digitalTwin.lastExecutionStatus = 'error'; } + +export function getUpdatedLibraryFile( + fileName: string, + assetPath: string, + isPrivate: boolean, + libraryFiles: LibraryConfigFile[], +): LibraryConfigFile | null { + return ( + libraryFiles.find( + (libFile) => + libFile.fileName === fileName && + libFile.assetPath === assetPath && + libFile.isPrivate === isPrivate && + libFile.isModified, + ) || null + ); +} diff --git a/client/src/preview/util/fileHandler.ts b/client/src/preview/util/fileHandler.ts index 5970d02d5..b5f661c14 100644 --- a/client/src/preview/util/fileHandler.ts +++ b/client/src/preview/util/fileHandler.ts @@ -1,8 +1,13 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ + import { FileState } from 'preview/store/file.slice'; import GitlabInstance from './gitlab'; import { IFile } from './ifile'; import { FileType } from './DTAssets'; +const COMMON_LIBRARY_PROJECT_ID = 3; + export function isValidFileType( item: { type: string; name: string; path: string }, fileType: FileType, @@ -18,23 +23,32 @@ export function isValidFileType( return typeChecks[fileType]; } +export function isImageFile(fileName: string): boolean { + const imageExtensions = ['.png', '.jpg', '.jpeg', '.gif', '.bmp', '.svg']; + return imageExtensions.some((ext) => fileName.toLowerCase().endsWith(ext)); +} + class FileHandler implements IFile { - public DTName: string; + public name: string; public gitlabInstance: GitlabInstance; - constructor(DTName: string, gitlabInstance: GitlabInstance) { - this.DTName = DTName; + constructor(name: string, gitlabInstance: GitlabInstance) { + this.name = name; this.gitlabInstance = gitlabInstance; } async createFile( - file: FileState, + file: FileState | { name: string; content: string; isNew: boolean }, filePath: string, commitMessage: string, + commonProject?: boolean, ): Promise { + const projectToUse = commonProject + ? COMMON_LIBRARY_PROJECT_ID + : this.gitlabInstance.projectId; await this.gitlabInstance.api.RepositoryFiles.create( - this.gitlabInstance.projectId!, + projectToUse!, `${filePath}/${file.name}`, 'main', file.content, @@ -61,13 +75,18 @@ class FileHandler implements IFile { this.gitlabInstance.projectId!, digitalTwinPath, 'main', - `Removing ${this.DTName} digital twin`, + `Removing ${this.name} digital twin`, ); } - async getFileContent(filePath: string): Promise { + async getFileContent(filePath: string, isPrivate?: boolean): Promise { + const projectToUse = + isPrivate === false + ? COMMON_LIBRARY_PROJECT_ID + : this.gitlabInstance.projectId; + const response = await this.gitlabInstance.api.RepositoryFiles.show( - this.gitlabInstance.projectId!, + projectToUse!, filePath, 'main', ); @@ -76,9 +95,9 @@ class FileHandler implements IFile { async getFileNames(fileType: FileType): Promise { const pathMap = { - [FileType.DESCRIPTION]: `digital_twins/${this.DTName}`, - [FileType.CONFIGURATION]: `digital_twins/${this.DTName}`, - [FileType.LIFECYCLE]: `digital_twins/${this.DTName}/lifecycle`, + [FileType.DESCRIPTION]: `digital_twins/${this.name}`, + [FileType.CONFIGURATION]: `digital_twins/${this.name}`, + [FileType.LIFECYCLE]: `digital_twins/${this.name}/lifecycle`, }; try { @@ -98,6 +117,89 @@ class FileHandler implements IFile { return []; } } + + async getLibraryFileNames( + filePath: string, + isPrivate: boolean, + ): Promise { + const projectToUse = isPrivate + ? this.gitlabInstance.projectId + : COMMON_LIBRARY_PROJECT_ID; + + try { + const response = + await this.gitlabInstance.api.Repositories.allRepositoryTrees( + projectToUse!, + { + path: filePath, + recursive: false, + }, + ); + + const fileNames: string[] = []; + for (const file of response) { + if (file.type === 'tree') { + const nestedFiles = await this.getLibraryFileNames( + `${filePath}/${file.name}`, + isPrivate, + ); + fileNames.push( + ...nestedFiles.map((nestedFile) => `${file.name}/${nestedFile}`), + ); + } else if (!isImageFile(file.name) && !file.name.endsWith('.fmu')) { + fileNames.push(file.name); + } + } + + return fileNames; + } catch { + return []; + } + } + + async getLibraryConfigFileNames( + filePath: string, + isPrivate: boolean, + ): Promise { + const projectToUse = isPrivate + ? this.gitlabInstance.projectId + : COMMON_LIBRARY_PROJECT_ID; + + const shouldBeRecursive = filePath.includes('common/'); + + try { + const response = + await this.gitlabInstance.api.Repositories.allRepositoryTrees( + projectToUse!, + { + path: filePath, + recursive: shouldBeRecursive, + }, + ); + + return response + .filter((item) => isValidFileType(item, FileType.CONFIGURATION)) + .map((file) => file.name); + } catch (_error) { + return []; + } + } + + async getFolders(path: string): Promise { + try { + const response = + await this.gitlabInstance.api.Repositories.allRepositoryTrees( + this.gitlabInstance.projectId!, + { path, recursive: false }, + ); + + return response + .filter((item: { type: string }) => item.type === 'tree') + .map((folder: { path: string }) => folder.path); + } catch (_error) { + return []; + } + } } export default FileHandler; diff --git a/client/src/preview/util/fileUtils.ts b/client/src/preview/util/fileUtils.ts index 8330eed7c..61afaefcd 100644 --- a/client/src/preview/util/fileUtils.ts +++ b/client/src/preview/util/fileUtils.ts @@ -3,6 +3,7 @@ import { FileState, renameFile, } from 'preview/store/file.slice'; +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; import { Dispatch, SetStateAction } from 'react'; import { useDispatch } from 'react-redux'; @@ -24,15 +25,28 @@ export const getExtension = (filename: string): string => { export const validateFiles = ( files: FileState[], + libraryFiles: LibraryConfigFile[], setErrorMessage: Dispatch>, ): boolean => { const emptyFiles = files .filter((file) => file.isNew && file.content === '') .map((file) => file.name); - if (emptyFiles.length > 0) { + const emptyLibraryFiles = libraryFiles.filter( + (file) => file.isNew && file.isModified && file.fileContent === '', + ); + + if (emptyFiles.length > 0 || emptyLibraryFiles.length > 0) { setErrorMessage( - `The following files have empty content: ${emptyFiles.join(', ')}. Edit them in order to create the new digital twin.`, + `The following files have empty content: ${ + emptyFiles.length > 0 ? emptyFiles.join(', ') : '' + }${emptyFiles.length > 0 && emptyLibraryFiles.length > 0 ? ', ' : ''}${ + emptyLibraryFiles.length > 0 + ? emptyLibraryFiles + .map((file) => `${file.fileName} (${file.assetPath})`) + .join(', ') + : '' + }.\n Edit them in order to create the new digital twin.`, ); return true; } @@ -92,3 +106,33 @@ export const handleChangeFileName = ( setOpenChangeFileNameDialog(false); }; + +export const getFileTypeFromExtension = (fileName: string): string => { + const extension = fileName.split('.').pop()?.toLowerCase(); + if (extension === 'md') return 'description'; + if (extension === 'json' || extension === 'yaml' || extension === 'yml') + return 'config'; + return 'lifecycle'; +}; + +export const getFilteredFileNames = (type: string, files: FileState[]) => + files + .filter( + (file) => file.isNew && getFileTypeFromExtension(file.name) === type, + ) + .map((file) => file.name); + +export const updateFileState = ( + fileName: string, + fileContent: string, + setFileName: Dispatch>, + setFileContent: Dispatch>, + setFileType: Dispatch>, + setFilePrivacy: Dispatch>, + isPrivate?: boolean, +) => { + setFileName(fileName); + setFileContent(fileContent); + setFileType(fileName.split('.').pop()!); + setFilePrivacy(isPrivate === undefined || isPrivate ? 'private' : 'common'); +}; diff --git a/client/src/preview/util/gitlab.ts b/client/src/preview/util/gitlab.ts index e558782bb..368356db6 100644 --- a/client/src/preview/util/gitlab.ts +++ b/client/src/preview/util/gitlab.ts @@ -3,6 +3,24 @@ import { Asset } from '../components/asset/Asset'; const GROUP_NAME = 'DTaaS'; const DT_DIRECTORY = 'digital_twins'; +const COMMON_LIBRARY_PROJECT_ID = 3; + +export function mapStringToAssetPath(type: string): string | undefined { + switch (type) { + case 'Functions': + return 'functions'; + case 'Models': + return 'models'; + case 'Tools': + return 'tools'; + case 'Data': + return 'data'; + case 'Digital Twins': + return 'digital_twins'; + default: + return undefined; + } +} interface LogEntry { status: string; @@ -18,8 +36,6 @@ class GitlabInstance { public logs: LogEntry[]; - public subfolders: Asset[]; - public projectId: number | null = null; public triggerToken: string | null = null; @@ -31,7 +47,6 @@ class GitlabInstance { oauthToken, }); this.logs = []; - this.subfolders = []; } async init() { @@ -80,10 +95,41 @@ class GitlabInstance { .map(async (file) => ({ name: file.name, path: file.path, + type: 'digitalTwin', + isPrivate: true, + })), + ); + return subfolders; + } + + async getLibrarySubfolders( + projectId: number, + type: string, + isPrivate: boolean, + ): Promise { + const mappedPath = mapStringToAssetPath(type); + if (!mappedPath) { + throw new Error(`Invalid asset type: ${type}`); + } + + const projectToUse = isPrivate ? projectId : COMMON_LIBRARY_PROJECT_ID; + + const files = await this.api.Repositories.allRepositoryTrees(projectToUse, { + path: mappedPath, + recursive: false, + }); + + const subfolders: Asset[] = await Promise.all( + files + .filter((file) => file.type === 'tree' && file.path !== mappedPath) + .map(async (file) => ({ + name: file.name, + path: file.path, + type, + isPrivate, })), ); - this.subfolders = subfolders; return subfolders; } diff --git a/client/src/preview/util/init.ts b/client/src/preview/util/init.ts index 534849065..382417b0f 100644 --- a/client/src/preview/util/init.ts +++ b/client/src/preview/util/init.ts @@ -3,32 +3,55 @@ import { useDispatch } from 'react-redux'; import { getAuthority } from 'util/envUtil'; import GitlabInstance from './gitlab'; import DigitalTwin from './digitalTwin'; -import { setAssets } from '../store/assets.slice'; +import { setAsset, setAssets } from '../store/assets.slice'; import { setDigitalTwin } from '../store/digitalTwin.slice'; +import LibraryAsset from './libraryAsset'; -const gitlabInstance = new GitlabInstance( +const initialGitlabInstance = new GitlabInstance( sessionStorage.getItem('username') || '', getAuthority(), sessionStorage.getItem('access_token') || '', ); -export const fetchAssets = async ( +function createGitlabInstance(): GitlabInstance { + const username = sessionStorage.getItem('username') || ''; + const authority = getAuthority(); + const accessToken = sessionStorage.getItem('access_token') || ''; + + return new GitlabInstance(username, authority, accessToken); +} + +export const fetchLibraryAssets = async ( dispatch: ReturnType, setError: Dispatch>, + type: string, + isPrivate: boolean, ) => { try { - await gitlabInstance.init(); - if (gitlabInstance.projectId) { - const subfolders = await gitlabInstance.getDTSubfolders( - gitlabInstance.projectId, + await initialGitlabInstance.init(); + if (initialGitlabInstance.projectId) { + const subfolders = await initialGitlabInstance.getLibrarySubfolders( + initialGitlabInstance.projectId, + type, + isPrivate, ); - dispatch(setAssets(subfolders)); - subfolders.forEach(async (asset) => { - const digitalTwin = new DigitalTwin(asset.name, gitlabInstance); - await digitalTwin.getDescription(); - dispatch(setDigitalTwin({ assetName: asset.name, digitalTwin })); - }); + const assets = await Promise.all( + subfolders.map(async (subfolder) => { + const gitlabInstance = createGitlabInstance(); + await gitlabInstance.init(); + const libraryAsset = new LibraryAsset( + subfolder.name, + subfolder.path, + isPrivate, + type, + gitlabInstance, + ); + await libraryAsset.getDescription(); + return libraryAsset; + }), + ); + assets.forEach((asset) => dispatch(setAsset(asset))); } else { dispatch(setAssets([])); } @@ -37,6 +60,39 @@ export const fetchAssets = async ( } }; +export const fetchDigitalTwins = async ( + dispatch: ReturnType, + setError: Dispatch>, +) => { + try { + await initialGitlabInstance.init(); + + if (initialGitlabInstance.projectId) { + const subfolders = await initialGitlabInstance.getDTSubfolders( + initialGitlabInstance.projectId, + ); + + await fetchLibraryAssets(dispatch, setError, 'Digital Twins', true); + + const digitalTwins = await Promise.all( + subfolders.map(async (asset) => { + const gitlabInstance = createGitlabInstance(); + await gitlabInstance.init(); + const digitalTwin = new DigitalTwin(asset.name, gitlabInstance); + await digitalTwin.getDescription(); + return { assetName: asset.name, digitalTwin }; + }), + ); + + digitalTwins.forEach(({ assetName, digitalTwin }) => + dispatch(setDigitalTwin({ assetName, digitalTwin })), + ); + } + } catch (err) { + setError(`An error occurred while fetching assets: ${err}`); + } +}; + export async function initDigitalTwin( newDigitalTwinName: string, ): Promise { diff --git a/client/src/preview/util/libraryAsset.ts b/client/src/preview/util/libraryAsset.ts new file mode 100644 index 000000000..1191dd6b5 --- /dev/null +++ b/client/src/preview/util/libraryAsset.ts @@ -0,0 +1,86 @@ +import { getAuthority } from 'util/envUtil'; +import GitlabInstance from './gitlab'; +import LibraryManager from './libraryManager'; + +class LibraryAsset { + public name: string; + + public path: string; + + public type: string; + + public isPrivate: boolean; + + public gitlabInstance: GitlabInstance; + + public description: string = ''; + + public fullDescription: string = ''; + + public libraryManager: LibraryManager; + + public configFiles: string[] = []; + + constructor( + name: string, + path: string, + isPrivate: boolean, + type: string, + gitlabInstance: GitlabInstance, + ) { + this.name = name; + this.path = path; + this.isPrivate = isPrivate; + this.type = type; + this.gitlabInstance = gitlabInstance; + this.libraryManager = new LibraryManager(name, this.gitlabInstance); + } + + async getDescription(): Promise { + if (this.gitlabInstance.projectId) { + try { + const fileContent = await this.libraryManager.getFileContent( + this.isPrivate, + this.path, + 'description.md', + ); + this.description = fileContent; + } catch (_error) { + this.description = `There is no description.md file`; + } + } + } + + async getFullDescription(): Promise { + if (this.gitlabInstance.projectId) { + const imagesPath = this.path; + try { + const fileContent = await this.libraryManager.getFileContent( + this.isPrivate, + this.path, + 'README.md', + ); + this.fullDescription = fileContent.replace( + /(!\[[^\]]*\])\(([^)]+)\)/g, + (match, altText, imagePath) => { + const fullUrl = `${getAuthority()}/dtaas/${sessionStorage.getItem('username')}/-/raw/main/${imagesPath}/${imagePath}`; + return `${altText}(${fullUrl})`; + }, + ); + } catch (_error) { + this.fullDescription = `There is no README.md file`; + } + } else { + this.fullDescription = 'Error fetching description, retry.'; + } + } + + async getConfigFiles() { + this.configFiles = await this.libraryManager.getFileNames( + this.isPrivate, + this.path, + ); + } +} + +export default LibraryAsset; diff --git a/client/src/preview/util/libraryManager.ts b/client/src/preview/util/libraryManager.ts new file mode 100644 index 000000000..083298ce7 --- /dev/null +++ b/client/src/preview/util/libraryManager.ts @@ -0,0 +1,58 @@ +/* eslint-disable no-restricted-syntax */ +/* eslint-disable no-await-in-loop */ + +import { FileState } from 'preview/store/file.slice'; +import GitlabInstance from './gitlab'; +import FileHandler from './fileHandler'; + +export enum FileType { + DESCRIPTION = 'description', + CONFIGURATION = 'configuration', + LIFECYCLE = 'lifecycle', +} + +export function getFilePath( + file: FileState, + mainFolderPath: string, + lifecycleFolderPath: string, +): string { + return file.type === 'lifecycle' ? lifecycleFolderPath : mainFolderPath; +} + +class LibraryManager { + public assetName: string; + + public gitlabInstance: GitlabInstance; + + public fileHandler: FileHandler; + + constructor(assetName: string, gitlabInstance: GitlabInstance) { + this.assetName = assetName; + this.gitlabInstance = gitlabInstance; + this.fileHandler = new FileHandler(assetName, gitlabInstance); + } + + async getFileContent( + isPrivate: boolean, + path: string, + fileName: string, + ): Promise { + const filePath = `${path}/${fileName}`; + + const fileContent = await this.fileHandler.getFileContent( + filePath, + isPrivate, + ); + return fileContent; + } + + async getFileNames(isPrivate: boolean, path: string): Promise { + const fileNames = await this.fileHandler.getLibraryConfigFileNames( + path, + isPrivate, + ); + return fileNames; + } +} + +export default LibraryManager; diff --git a/client/src/route/auth/ConfigItems.tsx b/client/src/route/auth/ConfigItems.tsx new file mode 100644 index 000000000..1b24a3e44 --- /dev/null +++ b/client/src/route/auth/ConfigItems.tsx @@ -0,0 +1,85 @@ +import CheckCircleIcon from '@mui/icons-material/CheckCircle'; +import ErrorOutlineIcon from '@mui/icons-material/ErrorOutline'; +import { Tooltip } from '@mui/material'; +import * as React from 'react'; +import { validationType } from './VerifyConfig'; + +const ConfigIcon = (toolTipTitle: string, icon: JSX.Element): JSX.Element => ( + + {icon} + +); + +export const getConfigIcon = ( + validation: validationType, + label: string, +): JSX.Element => { + let icon = ; + let toolTipTitle = `${label} threw the following error: ${validation.error}`; + const configHasStatus = validation.status !== undefined; + const configHasError = validation.error !== undefined; + if (!configHasError) { + const statusMessage = configHasStatus + ? `${validation.value} responded with status code ${validation.status}.` + : ''; + const validationStatusIsOK = + configHasStatus && + ((validation.status! >= 200 && validation.status! <= 299) || + validation.status! === 302); + icon = + validationStatusIsOK || !configHasStatus ? ( + + ) : ( + + ); + toolTipTitle = + validationStatusIsOK || !configHasStatus + ? `${label} field is configured correctly.` + : `${label} field may not be configured correctly.`; + toolTipTitle += ` ${statusMessage}`; + } + return ConfigIcon(toolTipTitle, icon); +}; + +export const ConfigItem: React.FC<{ + label: string; + value: string; + validation?: validationType; +}> = ({ label, value, validation = { error: 'Validation unavailable' } }) => ( +
    + {getConfigIcon(validation, label)} +
    + {label}: {value} +
    +
    +); +ConfigItem.displayName = 'ConfigItem'; + +export const windowEnvironmentVariables: Record = { + environment: window.env.REACT_APP_ENVIRONMENT, + url: window.env.REACT_APP_URL, + url_basename: window.env.REACT_APP_URL_BASENAME, + url_dtlink: window.env.REACT_APP_URL_DTLINK, + url_liblink: window.env.REACT_APP_URL_LIBLINK, + workbenchlink_vncdesktop: window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP, + workbenchlink_vscode: window.env.REACT_APP_WORKBENCHLINK_VSCODE, + workbenchlink_jupyterlab: window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB, + workbenchlink_jupyternotebook: + window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, + client_id: window.env.REACT_APP_CLIENT_ID, + auth_authority: window.env.REACT_APP_AUTH_AUTHORITY, + redirect_uri: window.env.REACT_APP_REDIRECT_URI, + logout_redirect_uri: window.env.REACT_APP_LOGOUT_REDIRECT_URI, + gitlab_scopes: window.env.REACT_APP_GITLAB_SCOPES, +}; diff --git a/client/src/route/auth/Signin.tsx b/client/src/route/auth/Signin.tsx index a113ab265..a144b5b4c 100644 --- a/client/src/route/auth/Signin.tsx +++ b/client/src/route/auth/Signin.tsx @@ -2,66 +2,130 @@ import * as React from 'react'; import Avatar from '@mui/material/Avatar'; import Box from '@mui/material/Box'; import LockOutlinedIcon from '@mui/icons-material/LockOutlined'; - import { useAuth } from 'react-oidc-context'; import Button from '@mui/material/Button'; +import { useState, useEffect } from 'react'; +import { CircularProgress } from '@mui/material'; +import VerifyConfig, { + getValidationResults, + validationType, +} from './VerifyConfig'; function SignIn() { const auth = useAuth(); + const [validationResults, setValidationResults] = useState<{ + [key: string]: validationType; + }>({}); + const [isLoading, setIsLoading] = useState(true); + + const configsToVerify = [ + 'url', + 'auth_authority', + 'redirect_uri', + 'logout_redirect_uri', + ]; + + useEffect(() => { + const fetchValidationResults = async () => { + const results = await getValidationResults(configsToVerify); + setValidationResults(results); + setIsLoading(false); + }; + fetchValidationResults(); + }); + + const startAuthProcess = () => { + auth.signinRedirect(); + }; + + const loading = loadingComponent(); + const signin = signInComponent(startAuthProcess); + const verifyConfig = verifyConfigComponent(configsToVerify); - const startAuthProcess = () => { - auth.signinRedirect(); - }; + let displayedComponent = loading; + const hasConfigErrors = configsToVerify.some( + (key) => validationResults[key]?.error !== undefined, + ); + + if (!isLoading) { + // Show signin if config is ready and good, otherwise show problems + displayedComponent = signin; + if (hasConfigErrors) { + displayedComponent = verifyConfig; + } + } + + return displayedComponent; +} - return ( - + VerifyConfig({ + keys: configsToVerify, + title: 'Config validation failed', + }); + +const loadingComponent = (): React.ReactNode => ( + + Verifying configuration + + +); + +const signInComponent = (startAuthProcess: () => void): React.ReactNode => ( + + + + + - - ); -} + }, + }} + startIcon={ + GitLab logo + } + > + Sign In with GitLab + + +); export default SignIn; diff --git a/client/src/route/auth/VerifyConfig.tsx b/client/src/route/auth/VerifyConfig.tsx new file mode 100644 index 000000000..4128c3eb5 --- /dev/null +++ b/client/src/route/auth/VerifyConfig.tsx @@ -0,0 +1,201 @@ +import { Paper, Typography } from '@mui/material'; +import * as React from 'react'; +import { z } from 'zod'; +import { ConfigItem, windowEnvironmentVariables } from './ConfigItems'; + +const EnvironmentEnum = z.enum(['dev', 'local', 'prod', 'test']); +const PathString = z.string(); +const ScopesString = z.literal('openid profile read_user read_repository api'); + +export type validationType = { + value?: string; + status?: number; + error?: string; +}; + +async function opaqueRequest(url: string): Promise { + const urlValidation: validationType = { + value: url, + status: undefined, + error: undefined, + }; + try { + await fetch(url, { + method: 'HEAD', + mode: 'no-cors', + signal: AbortSignal.timeout(1000), + }); + urlValidation.status = 0; + } catch (error) { + urlValidation.error = `An error occurred when fetching ${url}: ${error}`; + } + return urlValidation; +} + +async function corsRequest(url: string): Promise { + const urlValidation: validationType = { + value: url, + status: undefined, + error: undefined, + }; + const response = await fetch(url, { + method: 'HEAD', + signal: AbortSignal.timeout(1000), + }); + const responseIsAcceptable = response.ok || response.status === 302; + if (!responseIsAcceptable) { + urlValidation.error = `Unexpected response code ${response.status} from ${url}.`; + } + urlValidation.status = response.status; + return urlValidation; +} + +async function urlIsReachable(url: string): Promise { + let urlValidation: validationType; + try { + urlValidation = await corsRequest(url); + } catch { + urlValidation = await opaqueRequest(url); + } + return urlValidation; +} + +const parseField = ( + parser: { + safeParse: (value: string) => { + success: boolean; + error?: { message?: string }; + }; + }, + value: string, +): validationType => { + const result = parser.safeParse(value); + return result.success + ? { value, error: undefined } + : { value: undefined, error: result.error?.message }; +}; + +export const getValidationResults = async ( + keysToValidate: string[], +): Promise<{ + [key: string]: validationType; +}> => { + const allVerifications = { + environment: Promise.resolve( + parseField(EnvironmentEnum, window.env.REACT_APP_ENVIRONMENT), + ), + url: urlIsReachable(window.env.REACT_APP_URL), + url_basename: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_BASENAME), + ), + url_dtlink: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_DTLINK), + ), + url_liblink: Promise.resolve( + parseField(PathString, window.env.REACT_APP_URL_LIBLINK), + ), + workbenchlink_vncdesktop: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VNCDESKTOP), + ), + workbenchlink_vscode: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_VSCODE), + ), + workbenchlink_jupyterlab: Promise.resolve( + parseField(PathString, window.env.REACT_APP_WORKBENCHLINK_JUPYTERLAB), + ), + workbenchlink_jupyternotebook: Promise.resolve( + parseField( + PathString, + window.env.REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK, + ), + ), + client_id: Promise.resolve( + parseField(PathString, window.env.REACT_APP_CLIENT_ID), + ), + auth_authority: urlIsReachable(window.env.REACT_APP_AUTH_AUTHORITY), + redirect_uri: urlIsReachable(window.env.REACT_APP_REDIRECT_URI), + logout_redirect_uri: urlIsReachable( + window.env.REACT_APP_LOGOUT_REDIRECT_URI, + ), + gitlab_scopes: Promise.resolve( + parseField(ScopesString, window.env.REACT_APP_GITLAB_SCOPES), + ), + }; + + const verifications = + keysToValidate.length === 0 + ? allVerifications + : Object.fromEntries( + keysToValidate + .filter((key) => key in allVerifications) + .map((key) => [ + key, + allVerifications[key as keyof typeof allVerifications], + ]), + ); + + const results = await Promise.all( + Object.entries(verifications).map(async ([key, task]) => ({ + [key]: await task, + })), + ); + + return results.reduce((acc, result) => ({ ...acc, ...result }), {}); +}; + +const VerifyConfig: React.FC<{ keys?: string[]; title?: string }> = ({ + keys = [], + title = 'Config verification', +}) => { + const [validationResults, setValidationResults] = React.useState<{ + [key: string]: validationType; + }>({}); + + React.useEffect(() => { + const fetchValidations = async () => { + const results = await getValidationResults(keys); + setValidationResults(results); + }; + fetchValidations(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const displayedConfigs: Record = + keys.length === 0 + ? windowEnvironmentVariables + : Object.fromEntries( + keys + .filter((key) => key in windowEnvironmentVariables) + .map((key) => [ + key, + windowEnvironmentVariables[ + key as keyof typeof windowEnvironmentVariables + ] as string, + ]), + ); + return ( + + {title} +
    + {Object.entries(displayedConfigs).map(([key, value]) => ( + + ))} +
    +
    + ); +}; + +export default VerifyConfig; diff --git a/client/src/routes.tsx b/client/src/routes.tsx index 676481ae0..98c7cfa25 100644 --- a/client/src/routes.tsx +++ b/client/src/routes.tsx @@ -2,11 +2,13 @@ import * as React from 'react'; import WorkBench from 'route/workbench/Workbench'; import LayoutPublic from 'page/LayoutPublic'; import PrivateRoute from 'route/auth/PrivateRoute'; +import LibraryPreview from 'preview/route/library/LibraryPreview'; import Library from './route/library/Library'; import DigitalTwins from './route/digitaltwins/DigitalTwins'; import DigitalTwinsPreview from './preview/route/digitaltwins/DigitalTwinsPreview'; import SignIn from './route/auth/Signin'; import Account from './route/auth/Account'; +import VerifyConfig from './route/auth/VerifyConfig'; export const routes = [ { @@ -17,6 +19,14 @@ export const routes = [ ), }, + { + path: 'verify', + element: ( + + + + ), + }, { path: 'library', element: ( @@ -57,6 +67,14 @@ export const routes = [ ), }, + { + path: 'preview/library', + element: ( + + + + ), + }, ]; export default routes; diff --git a/client/src/store/store.ts b/client/src/store/store.ts index aa0a904cf..38289311e 100644 --- a/client/src/store/store.ts +++ b/client/src/store/store.ts @@ -4,6 +4,8 @@ import digitalTwinSlice from 'preview/store/digitalTwin.slice'; import snackbarSlice from 'preview/store/snackbar.slice'; import assetsSlice from 'preview/store/assets.slice'; import fileSlice from 'preview/store/file.slice'; +import cartSlice from 'preview/store/cart.slice'; +import libraryConfigFilesSlice from 'preview/store/libraryConfigFiles.slice'; import menuSlice from './menu.slice'; import authSlice from './auth.slice'; @@ -14,6 +16,8 @@ const rootReducer = combineReducers({ digitalTwin: digitalTwinSlice, snackbar: snackbarSlice, files: fileSlice, + cart: cartSlice, + libraryConfigFiles: libraryConfigFilesSlice, }); const store = configureStore({ diff --git a/client/src/util/envUtil.ts b/client/src/util/envUtil.ts index fa7677850..826578066 100644 --- a/client/src/util/envUtil.ts +++ b/client/src/util/envUtil.ts @@ -63,7 +63,8 @@ export function getWorkbenchLinkValues(): KeyLinkPair[] { if (value !== undefined) { const keyWithoutPrefix = key.slice(prefix.length); const linkValue = - keyWithoutPrefix === 'DT_PREVIEW' + keyWithoutPrefix === 'DT_PREVIEW' || + keyWithoutPrefix === 'LIBRARY_PREVIEW' ? value : useUserLink(useAppURL(), value); workbenchLinkValues.push({ diff --git a/client/test/README.md b/client/test/README.md index 0ef073dd4..0bf6e3329 100644 --- a/client/test/README.md +++ b/client/test/README.md @@ -78,6 +78,7 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '934b98f03f1b6f743832b2840bf7cccaed93c3bfe579093dd0942a433691ccc0', @@ -102,6 +103,7 @@ window.env = { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '934b98f03f1b6f743832b2840bf7cccaed93c3bfe579093dd0942a433691ccc0', diff --git a/client/test/__mocks__/global_mocks.ts b/client/test/__mocks__/global_mocks.ts index 19303efae..8846292a1 100644 --- a/client/test/__mocks__/global_mocks.ts +++ b/client/test/__mocks__/global_mocks.ts @@ -59,3 +59,8 @@ jest.mock('util/envUtil', () => ({ { key: '3', link: 'link3' }, ], })); + +jest.mock('route/auth/VerifyConfig', () => ({ + ...jest.requireActual('route/auth/VerifyConfig'), + getValidationResults: jest.fn(), +})); diff --git a/client/test/e2e/tests/Auth.test.ts b/client/test/e2e/tests/Auth.test.ts index 14afe3eb9..835d9094c 100644 --- a/client/test/e2e/tests/Auth.test.ts +++ b/client/test/e2e/tests/Auth.test.ts @@ -1,7 +1,7 @@ // src: https://playwright.dev/docs/writing-tests import { expect } from '@playwright/test'; import test from 'test/e2e/setup/fixtures'; -import links from './Links'; // Extension is required with Playwright import +import links from './Links'; test.describe('Tests on Authentication Flow', () => { test.beforeEach(async ({ page }) => { diff --git a/client/test/e2e/tests/Menu.test.ts b/client/test/e2e/tests/Menu.test.ts index bbc16af61..13669e6b6 100644 --- a/client/test/e2e/tests/Menu.test.ts +++ b/client/test/e2e/tests/Menu.test.ts @@ -2,7 +2,7 @@ import { expect } from '@playwright/test'; import test from 'test/e2e/setup/fixtures'; -import links from './Links'; // Extension is required with Playwright import +import links from './Links'; test.describe('Menu Links from first page (Layout)', () => { test.beforeEach(async ({ page }) => { diff --git a/client/test/e2e/tests/verify.route.test.ts b/client/test/e2e/tests/verify.route.test.ts new file mode 100644 index 000000000..cb60a209b --- /dev/null +++ b/client/test/e2e/tests/verify.route.test.ts @@ -0,0 +1,28 @@ +import test from 'test/e2e/setup/fixtures'; +import { expect } from '@playwright/test'; + +test('Verification is visible', async ({ page }) => { + await page.goto('./verify'); + + await page.waitForSelector('[data-testid="success-icon"]', { + timeout: 4000, + state: 'visible', + }); + + await expect( + page.getByRole('heading', { name: 'Config verification' }), + ).toBeVisible(); + + await expect(page.getByText('CLIENT ID:', { exact: true })).toBeVisible(); + await expect( + page.getByText('AUTH AUTHORITY:', { exact: true }), + ).toBeVisible(); + + await expect( + page + .getByLabel('ENVIRONMENT field is configured correctly.') + .locator('path'), + ).toBeVisible(); + + await expect(page.getByTestId('error-icon')).toBeHidden(); +}); diff --git a/client/test/integration/Auth/WaitAndNavigate.test.tsx b/client/test/integration/Auth/WaitAndNavigate.test.tsx index afd27df2e..781fbbd3d 100644 --- a/client/test/integration/Auth/WaitAndNavigate.test.tsx +++ b/client/test/integration/Auth/WaitAndNavigate.test.tsx @@ -1,4 +1,5 @@ import { act, screen } from '@testing-library/react'; +import { getValidationResults } from 'route/auth/VerifyConfig'; import { mockAuthState } from 'test/__mocks__/global_mocks'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; @@ -16,6 +17,9 @@ Object.defineProperty(window, 'location', { describe('WaitAndNavigate', () => { beforeEach(async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ configField: 'test' }), + ); await setup(); }); diff --git a/client/test/integration/Auth/authRedux.test.tsx b/client/test/integration/Auth/authRedux.test.tsx index b086aa7c8..ba7279763 100644 --- a/client/test/integration/Auth/authRedux.test.tsx +++ b/client/test/integration/Auth/authRedux.test.tsx @@ -1,12 +1,13 @@ import * as React from 'react'; import { createStore } from 'redux'; -import { screen } from '@testing-library/react'; +import { screen, act } from '@testing-library/react'; import { useAuth } from 'react-oidc-context'; import PrivateRoute from 'route/auth/PrivateRoute'; import Library from 'route/library/Library'; import authReducer from 'store/auth.slice'; import { mockUser } from 'test/__mocks__/global_mocks'; import { renderWithRouter } from 'test/unit/unit.testUtil'; +import { getValidationResults } from 'route/auth/VerifyConfig'; jest.mock('util/auth/Authentication', () => ({ useGetAndSetUsername: () => jest.fn(), @@ -28,8 +29,11 @@ type AuthState = { isAuthenticated: boolean; }; -const setupTest = (authState: AuthState) => { +const setupTest = async (authState: AuthState) => { (useAuth as jest.Mock).mockReturnValue({ ...authState, user: mockUser }); + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ configField: 'test' }), + ); if (authState.isAuthenticated) { store.dispatch({ @@ -40,12 +44,14 @@ const setupTest = (authState: AuthState) => { store.dispatch({ type: 'auth/setUserName', payload: undefined }); } - renderWithRouter( - - - , - { route: '/private', store }, - ); + await act(async () => { + renderWithRouter( + + + , + { route: '/private', store }, + ); + }); }; describe('Redux and Authentication integration test', () => { @@ -63,8 +69,8 @@ describe('Redux and Authentication integration test', () => { }; }); - it('renders undefined username when not authenticated', () => { - setupTest({ + it('renders undefined username when not authenticated', async () => { + await setupTest({ isAuthenticated: false, }); @@ -75,8 +81,8 @@ describe('Redux and Authentication integration test', () => { expect(store.getState().userName).toBe(undefined); }); - it('renders the correct username when authenticated', () => { - setupTest({ + it('renders the correct username when authenticated', async () => { + await setupTest({ isAuthenticated: true, }); @@ -84,14 +90,14 @@ describe('Redux and Authentication integration test', () => { expect(store.getState().userName).toBe('username'); }); - it('renders undefined username after ending authentication', () => { - setupTest({ + it('renders undefined username after ending authentication', async () => { + await setupTest({ isAuthenticated: true, }); expect(screen.getByText('Functions')).toBeInTheDocument(); expect(store.getState().userName).toBe('username'); - setupTest({ + await setupTest({ isAuthenticated: false, }); expect(screen.getByText('Sign In with GitLab')).toBeInTheDocument(); diff --git a/client/test/integration/Routes/Signin.test.tsx b/client/test/integration/Routes/Signin.test.tsx index e26828eb3..337301d9b 100644 --- a/client/test/integration/Routes/Signin.test.tsx +++ b/client/test/integration/Routes/Signin.test.tsx @@ -1,15 +1,18 @@ -import { screen } from '@testing-library/react'; +import { screen, act } from '@testing-library/react'; import { setupIntegrationTest } from 'test/integration/integration.testUtil'; +import { getValidationResults } from 'route/auth/VerifyConfig'; import { testPublicLayout } from './routes.testUtil'; const setup = () => setupIntegrationTest('/'); describe('Signin', () => { - beforeEach(async () => { - await setup(); - }); - it('renders the Sign in page with the Public Layout correctly', async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ configField: 'test' }), + ); + await act(async () => { + await setup(); + }); await testPublicLayout(); expect( screen.getByRole('button', { name: /Sign In with GitLab/i }), diff --git a/client/test/integration/jest.setup.ts b/client/test/integration/jest.setup.ts index a609475d2..588a833ba 100644 --- a/client/test/integration/jest.setup.ts +++ b/client/test/integration/jest.setup.ts @@ -6,8 +6,21 @@ beforeEach(() => { jest.resetAllMocks(); }); -global.window.env = { +window.env = { ...global.window.env, - REACT_APP_AUTH_AUTHORITY: - process.env.REACT_APP_AUTH_AUTHORITY || 'https://example.com', + REACT_APP_AUTH_AUTHORITY: 'https://example.com', + REACT_APP_ENVIRONMENT: 'test', + REACT_APP_URL: 'https://example.com', + REACT_APP_URL_BASENAME: 'mock_url_basename', + REACT_APP_URL_DTLINK: '/lab', + REACT_APP_URL_LIBLINK: '', + REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?password=vncpassword', + REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', + REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', + REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + + REACT_APP_CLIENT_ID: 'abc123', + REACT_APP_REDIRECT_URI: 'https://example.com', + REACT_APP_LOGOUT_REDIRECT_URI: 'https://example.com', + REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', }; diff --git a/client/test/preview/__mocks__/global_mocks.ts b/client/test/preview/__mocks__/global_mocks.ts index db9e3de5d..8d9e0d057 100644 --- a/client/test/preview/__mocks__/global_mocks.ts +++ b/client/test/preview/__mocks__/global_mocks.ts @@ -3,6 +3,7 @@ import GitlabInstance from 'preview/util/gitlab'; import DigitalTwin from 'preview/util/digitalTwin'; import FileHandler from 'preview/util/fileHandler'; import DTAssets from 'preview/util/DTAssets'; +import LibraryManager from 'preview/util/libraryManager'; export const mockAppURL = 'https://example.com/'; export const mockURLforDT = 'https://example.com/URL_DT'; @@ -62,13 +63,13 @@ export const mockGitlabInstance: GitlabInstance = { requesterFn: jest.fn(), }), logs: [], - subfolders: [], projectId: 1, triggerToken: 'mock trigger token', init: jest.fn(), getProjectId: jest.fn(), getTriggerToken: jest.fn(), getDTSubfolders: jest.fn(), + getLibrarySubfolders: jest.fn(), executionLogs: jest.fn(), getPipelineJobs: jest.fn(), getJobTrace: jest.fn(), @@ -76,13 +77,16 @@ export const mockGitlabInstance: GitlabInstance = { }; export const mockFileHandler: FileHandler = { - DTName: 'mockedDTName', + name: 'mockedName', gitlabInstance: mockGitlabInstance, createFile: jest.fn(), updateFile: jest.fn(), deleteDT: jest.fn(), getFileContent: jest.fn(), getFileNames: jest.fn(), + getLibraryFileNames: jest.fn(), + getLibraryConfigFileNames: jest.fn(), + getFolders: jest.fn(), }; export const mockDTAssets: DTAssets = { @@ -90,11 +94,24 @@ export const mockDTAssets: DTAssets = { gitlabInstance: mockGitlabInstance, fileHandler: mockFileHandler, createFiles: jest.fn(), + getFilesFromAsset: jest.fn(), updateFileContent: jest.fn(), + updateLibraryFileContent: jest.fn(), appendTriggerToPipeline: jest.fn(), removeTriggerFromPipeline: jest.fn(), delete: jest.fn(), getFileContent: jest.fn(), + getLibraryFileContent: jest.fn(), + getFileNames: jest.fn(), + getLibraryConfigFileNames: jest.fn(), + getFolders: jest.fn(), +}; + +export const mockLibraryManager: LibraryManager = { + assetName: 'mockedAssetName', + gitlabInstance: mockGitlabInstance, + fileHandler: mockFileHandler, + getFileContent: jest.fn(), getFileNames: jest.fn(), }; @@ -110,21 +127,42 @@ export const mockDigitalTwin: DigitalTwin = { pipelineLoading: false, pipelineCompleted: false, descriptionFiles: ['descriptionFile'], - lifecycleFiles: ['lifecycleFile'], configFiles: ['configFile'], + lifecycleFiles: ['lifecycleFile'], + assetFiles: [ + { assetPath: 'assetPath', fileNames: ['assetFileName1', 'assetFileName2'] }, + ], getDescription: jest.fn(), getFullDescription: jest.fn(), - execute: jest.fn(), triggerPipeline: jest.fn(), + execute: jest.fn(), stop: jest.fn(), create: jest.fn().mockResolvedValue('Success'), delete: jest.fn(), getDescriptionFiles: jest.fn().mockResolvedValue(['descriptionFile']), getLifecycleFiles: jest.fn().mockResolvedValue(['lifecycleFile']), getConfigFiles: jest.fn().mockResolvedValue(['configFile']), + prepareAllAssetFiles: jest.fn(), + getAssetFiles: jest.fn(), } as unknown as DigitalTwin; +export const mockLibraryAsset = { + name: 'Asset 1', + path: 'path', + type: 'Digital Twins', + isPrivate: true, + gitlabInstance: mockGitlabInstance, + description: 'description', + fullDescription: 'fullDescription', + libraryManager: mockLibraryManager, + configFiles: [], + + getDescription: jest.fn(), + getFullDescription: jest.fn(), + getConfigFiles: jest.fn(), +}; + jest.mock('util/envUtil', () => ({ ...jest.requireActual('util/envUtil'), useAppURL: () => mockAppURL, diff --git a/client/test/preview/integration/components/asset/AssetBoard.test.tsx b/client/test/preview/integration/components/asset/AssetBoard.test.tsx index d81452010..736f316ce 100644 --- a/client/test/preview/integration/components/asset/AssetBoard.test.tsx +++ b/client/test/preview/integration/components/asset/AssetBoard.test.tsx @@ -6,27 +6,34 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import assetsReducer, { setAssets } from 'preview/store/assets.slice'; import digitalTwinReducer, { setDigitalTwin, + setShouldFetchDigitalTwins, } from 'preview/store/digitalTwin.slice'; import snackbarSlice from 'preview/store/snackbar.slice'; -import { Asset } from 'preview/components/asset/Asset'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { + mockGitlabInstance, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; import fileSlice, { FileState, addOrUpdateFile, } from 'preview/store/file.slice'; import DigitalTwin from 'preview/util/digitalTwin'; +import LibraryAsset from 'preview/util/libraryAsset'; +import libraryConfigFilesSlice from 'preview/store/libraryConfigFiles.slice'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), })); jest.mock('preview/util/init', () => ({ - fetchAssets: jest.fn(), + fetchDigitalTwins: jest.fn(), })); jest.useFakeTimers(); -const preSetItems: Asset[] = [{ name: 'Asset 1', path: 'path/asset1' }]; +const asset1 = mockLibraryAsset; +asset1.name = 'Asset 1'; +const preSetItems: LibraryAsset[] = [asset1]; const files: FileState[] = [ { name: 'Asset 1', content: 'content1', isNew: false, isModified: false }, @@ -38,6 +45,7 @@ const store = configureStore({ digitalTwin: digitalTwinReducer, snackbar: snackbarSlice, files: fileSlice, + libraryConfigFiles: libraryConfigFilesSlice, }), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ @@ -55,6 +63,7 @@ describe('AssetBoard Integration Tests', () => { }), ); store.dispatch(addOrUpdateFile(files[0])); + store.dispatch(setShouldFetchDigitalTwins(true)); }; beforeEach(() => { @@ -65,7 +74,7 @@ describe('AssetBoard Integration Tests', () => { jest.clearAllMocks(); }); - it('renders AssetBoard with AssetCardExecute', () => { + it('renders AssetBoard with AssetCardExecute', async () => { act(() => { render( @@ -74,10 +83,14 @@ describe('AssetBoard Integration Tests', () => { ); }); + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + expect(screen.getByText('Asset 1')).toBeInTheDocument(); }); - it('renders AssetBoard with AssetCardManage', () => { + it('renders AssetBoard with AssetCardManage', async () => { act(() => { render( @@ -86,7 +99,9 @@ describe('AssetBoard Integration Tests', () => { ); }); - expect(screen.getByText('Asset 1')).toBeInTheDocument(); + await waitFor(() => { + expect(screen.getByText('Asset 1')).toBeInTheDocument(); + }); }); it('deletes an asset', async () => { @@ -98,6 +113,10 @@ describe('AssetBoard Integration Tests', () => { ); }); + await waitFor(() => { + expect(screen.queryByRole('progressbar')).not.toBeInTheDocument(); + }); + const deleteButton = screen.getByRole('button', { name: /Delete/i }); expect(deleteButton).toBeInTheDocument(); @@ -116,19 +135,4 @@ describe('AssetBoard Integration Tests', () => { expect(screen.queryByText('Asset 1')).not.toBeInTheDocument(); }); }); - - it('shows an error message', async () => { - const error = 'An error occurred'; - jest.spyOn(React, 'useState').mockReturnValue([error, jest.fn()]); - - act(() => { - render( - - - , - ); - }); - - expect(screen.getByText(error)).toBeInTheDocument(); - }); }); diff --git a/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx b/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx index c9ff65206..59cfb893d 100644 --- a/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx +++ b/client/test/preview/integration/components/asset/AssetCardExecute.test.tsx @@ -2,13 +2,25 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { fireEvent, render, screen, act } from '@testing-library/react'; import { AssetCardExecute } from 'preview/components/asset/AssetCard'; import * as React from 'react'; -import { Provider } from 'react-redux'; -import assetsReducer, { setAssets } from 'preview/store/assets.slice'; +import { Provider, useSelector } from 'react-redux'; +import assetsReducer, { + selectAssetByPathAndPrivacy, + setAssets, +} from 'preview/store/assets.slice'; import digitalTwinReducer, { setDigitalTwin, } from 'preview/store/digitalTwin.slice'; import snackbarSlice from 'preview/store/snackbar.slice'; -import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; +import { + mockDigitalTwin, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; +import { RootState } from 'store/store'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), + useSelector: jest.fn(), +})); const store = configureStore({ reducer: combineReducers({ @@ -27,17 +39,23 @@ describe('AssetCardExecute Integration Test', () => { name: 'Asset 1', description: 'Mocked description', path: 'path/asset1', + type: 'Digital twins', + isPrivate: true, }; beforeEach(() => { - store.dispatch( - setAssets([ - { - name: 'Asset 1', - path: 'path/asset1', - }, - ]), + (useSelector as jest.MockedFunction).mockImplementation( + (selector: (state: RootState) => unknown) => { + if ( + selector === selectAssetByPathAndPrivacy(asset.path, asset.isPrivate) + ) { + return null; + } + return mockDigitalTwin; + }, ); + + store.dispatch(setAssets([mockLibraryAsset])); store.dispatch( setDigitalTwin({ assetName: 'Asset 1', @@ -58,15 +76,13 @@ describe('AssetCardExecute Integration Test', () => { jest.clearAllMocks(); }); - it('opens the Snackbar after clicking the Start button', async () => { + it('should start execution', async () => { const startStopButton = screen.getByRole('button', { name: /Start/i }); await act(async () => { fireEvent.click(startStopButton); }); - expect( - screen.getByText('Execution mockedStatus for MockedDTName'), - ).toBeInTheDocument(); + expect(screen.getByText('Stop')).toBeInTheDocument(); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/create/ConfirmDeleteDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/create/ConfirmDeleteDialog.test.tsx index 979d927d1..84699ac13 100644 --- a/client/test/preview/integration/route/digitaltwins/create/ConfirmDeleteDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/create/ConfirmDeleteDialog.test.tsx @@ -1,9 +1,9 @@ import * as React from 'react'; import ConfirmDeleteDialog from 'preview/route/digitaltwins/create/ConfirmDeleteDialog'; -import { act, render, screen } from '@testing-library/react'; import { combineReducers, configureStore } from '@reduxjs/toolkit'; import { Provider } from 'react-redux'; import fileSlice, { addOrUpdateFile } from 'preview/store/file.slice'; +import { act, render, screen } from '@testing-library/react'; const store = configureStore({ reducer: combineReducers({ diff --git a/client/test/preview/integration/route/digitaltwins/create/CreateDTDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/create/CreateDTDialog.test.tsx index 8ef4e47dd..ba2814cac 100644 --- a/client/test/preview/integration/route/digitaltwins/create/CreateDTDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/create/CreateDTDialog.test.tsx @@ -6,6 +6,7 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import fileSlice from 'preview/store/file.slice'; import { validateFiles } from 'preview/util/fileUtils'; import { initDigitalTwin } from 'preview/util/init'; +import cartSlice from 'preview/store/cart.slice'; jest.mock('preview/util/fileUtils', () => ({ validateFiles: jest.fn(), @@ -18,6 +19,7 @@ jest.mock('preview/util/init', () => ({ const store = configureStore({ reducer: combineReducers({ files: fileSlice, + cart: cartSlice, }), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/client/test/preview/integration/route/digitaltwins/create/CreatePage.test.tsx b/client/test/preview/integration/route/digitaltwins/create/CreatePage.test.tsx index 94cf56e9f..17c476b27 100644 --- a/client/test/preview/integration/route/digitaltwins/create/CreatePage.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/create/CreatePage.test.tsx @@ -12,12 +12,14 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; import digitalTwinReducer from 'preview/store/digitalTwin.slice'; import snackbarSlice from 'preview/store/snackbar.slice'; import fileSlice from 'preview/store/file.slice'; +import cartSlice from 'preview/store/cart.slice'; const store = configureStore({ reducer: combineReducers({ digitalTwin: digitalTwinReducer, snackbar: snackbarSlice, files: fileSlice, + cart: cartSlice, }), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/client/test/preview/integration/route/digitaltwins/create/FileActionButtons.test.tsx b/client/test/preview/integration/route/digitaltwins/create/FileActionButtons.test.tsx index 27bc2e8d9..21b592e8a 100644 --- a/client/test/preview/integration/route/digitaltwins/create/FileActionButtons.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/create/FileActionButtons.test.tsx @@ -14,6 +14,7 @@ describe('FileActionButtons', () => { fileName="testName" setOpenDeleteFileDialog={setOpenDeleteFileDialog} setOpenChangeFileNameDialog={setOpenChangeFileNameDialog} + isLibraryFile={false} />, ); }); @@ -34,7 +35,7 @@ describe('FileActionButtons', () => { it('handles click on change file name button', () => { const changeFileNameButton = screen.getByRole('button', { - name: /Change file name/i, + name: /Rename File/i, }); act(() => { changeFileNameButton.click(); diff --git a/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx b/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx index 603d70050..f9f800015 100644 --- a/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/editor/Editor.test.tsx @@ -10,11 +10,15 @@ import fileSlice, { FileState, addOrUpdateFile, } from 'preview/store/file.slice'; -import { Asset } from 'preview/components/asset/Asset'; import * as React from 'react'; import DigitalTwin from 'preview/util/digitalTwin'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { + mockGitlabInstance, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; import { handleFileClick } from 'preview/route/digitaltwins/editor/sidebarFunctions'; +import LibraryAsset from 'preview/util/libraryAsset'; +import cartSlice, { addToCart } from 'preview/store/cart.slice'; describe('Editor', () => { const fileName = 'file1.md'; @@ -23,8 +27,11 @@ describe('Editor', () => { const setFileName = jest.fn(); const setFileContent = jest.fn(); const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); - const preSetItems: Asset[] = [{ name: 'Asset 1', path: 'path/asset1' }]; + const preSetItems: LibraryAsset[] = [mockLibraryAsset]; const files = [ { name: 'file1.md', content: 'content1', isNew: false, isModified: false }, ]; @@ -34,6 +41,7 @@ describe('Editor', () => { assets: assetsReducer, digitalTwin: digitalTwinReducer, files: fileSlice, + cart: cartSlice, }), middleware: (getDefaultMiddleware) => getDefaultMiddleware({ @@ -47,6 +55,7 @@ describe('Editor', () => { digitalTwinInstance.lifecycleFiles = ['lifecycle1.txt', 'lifecycle2.txt']; const setupTest = async () => { + store.dispatch(addToCart(mockLibraryAsset)); store.dispatch(setAssets(preSetItems)); await act(async () => { store.dispatch( @@ -85,6 +94,12 @@ describe('Editor', () => { setFileContent={setFileContent} fileType={fileType} setFileType={setFileType} + filePrivacy={'private'} + setFilePrivacy={setFilePrivacy} + isLibraryFile={false} + setIsLibraryFile={setIsLibraryFile} + libraryAssetPath={''} + setLibraryAssetPath={setLibraryAssetPath} /> , ); @@ -132,8 +147,11 @@ describe('Editor', () => { setFileName, setFileContent, setFileType, + setFilePrivacy, modifiedFiles, 'reconfigure', + setIsLibraryFile, + setLibraryAssetPath, ); }); @@ -159,8 +177,11 @@ describe('Editor', () => { setFileName, setFileContent, setFileType, + setFilePrivacy, modifiedFiles, 'reconfigure', + setIsLibraryFile, + setLibraryAssetPath, ); }); @@ -186,8 +207,11 @@ describe('Editor', () => { setFileName, setFileContent, setFileType, + setFilePrivacy, modifiedFiles, 'reconfigure', + setIsLibraryFile, + setLibraryAssetPath, ); }); diff --git a/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx b/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx index eed912b2d..c6e482708 100644 --- a/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/editor/Sidebar.test.tsx @@ -13,14 +13,21 @@ import { } from '@testing-library/react'; import { Provider } from 'react-redux'; import * as React from 'react'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { + mockGitlabInstance, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; import DigitalTwin from 'preview/util/digitalTwin'; import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; +import cartSlice, { addToCart } from 'preview/store/cart.slice'; describe('Sidebar', () => { const setFileNameMock = jest.fn(); const setFileContentMock = jest.fn(); const setFileTypeMock = jest.fn(); + const setFilePrivacyMock = jest.fn(); + const setIsLibraryFileMock = jest.fn(); + const setLibraryAssetPathMock = jest.fn(); let store: ReturnType; let digitalTwin: DigitalTwin; @@ -99,6 +106,7 @@ describe('Sidebar', () => { beforeEach(async () => { store = configureStore({ reducer: combineReducers({ + cart: cartSlice, digitalTwin: digitalTwinReducer, files: fileSlice, }), @@ -108,6 +116,8 @@ describe('Sidebar', () => { }), }); + store.dispatch(addToCart(mockLibraryAsset)); + const files = [ { name: 'Asset 1', content: 'content1', isNew: false, isModified: false }, ]; @@ -131,7 +141,12 @@ describe('Sidebar', () => { setFileName={setFileNameMock} setFileContent={setFileContentMock} setFileType={setFileTypeMock} + setFilePrivacy={setFilePrivacyMock} + setIsLibraryFile={setIsLibraryFileMock} + setLibraryAssetPath={setLibraryAssetPathMock} tab={'reconfigure'} + fileName="file1.md" + isLibraryFile={false} /> , ); @@ -154,7 +169,12 @@ describe('Sidebar', () => { setFileName={setFileNameMock} setFileContent={setFileContentMock} setFileType={setFileTypeMock} + setFilePrivacy={setFilePrivacyMock} + setIsLibraryFile={setIsLibraryFileMock} + setLibraryAssetPath={setLibraryAssetPathMock} tab={'create'} + fileName="file1.md" + isLibraryFile={false} /> , ); @@ -179,7 +199,12 @@ describe('Sidebar', () => { setFileName={setFileNameMock} setFileContent={setFileContentMock} setFileType={setFileTypeMock} + setFilePrivacy={setFilePrivacyMock} + setIsLibraryFile={setIsLibraryFileMock} + setLibraryAssetPath={setLibraryAssetPathMock} tab={'create'} + fileName="file1.md" + isLibraryFile={false} /> , ); @@ -204,7 +229,12 @@ describe('Sidebar', () => { setFileName={setFileNameMock} setFileContent={setFileContentMock} setFileType={setFileTypeMock} + setFilePrivacy={setFilePrivacyMock} + setIsLibraryFile={setIsLibraryFileMock} + setLibraryAssetPath={setLibraryAssetPathMock} tab={'create'} + fileName="file1.md" + isLibraryFile={false} /> , ); diff --git a/client/test/preview/integration/route/digitaltwins/editor/SidebarFunctions.test.tsx b/client/test/preview/integration/route/digitaltwins/editor/SidebarFunctions.test.tsx index 9d43c4082..9e42595f3 100644 --- a/client/test/preview/integration/route/digitaltwins/editor/SidebarFunctions.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/editor/SidebarFunctions.test.tsx @@ -1,142 +1,158 @@ +import { useDispatch } from 'react-redux'; import { - handleFileClick, + handleCreateFileClick, + handleReconfigureFileClick, + handleAddFileClick, + handleCloseFileNameDialog, handleFileSubmit, } from 'preview/route/digitaltwins/editor/sidebarFunctions'; -import DigitalTwin from 'preview/util/digitalTwin'; -import { FileState, addOrUpdateFile } from 'preview/store/file.slice'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; -import { useDispatch } from 'react-redux'; +import { FileState } from 'preview/store/file.slice'; +import { updateFileState } from 'preview/util/fileUtils'; + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), +})); -describe('File Click Handlers', () => { - const mockSetFileName = jest.fn(); - const mockSetFileContent = jest.fn(); - const mockSetFileType = jest.fn(); +jest.mock('preview/route/digitaltwins/editor/sidebarFetchers', () => ({ + fetchAndSetFileContent: jest.fn(), + fetchAndSetFileLibraryContent: jest.fn(), +})); - const mockDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); +jest.mock('preview/util/fileUtils', () => ({ + updateFileState: jest.fn(), + getFileTypeFromExtension: jest.fn(), +})); - afterEach(() => { +describe('sidebarFunctions integration tests', () => { + const dispatch = jest.fn(); + (useDispatch as unknown as jest.Mock).mockReturnValue(dispatch); + + beforeEach(() => { jest.clearAllMocks(); }); - it('calls handleCreateFileClick if tab is "create"', () => { - const fileName = 'example.md'; + test('handleCreateFileClick with DigitalTwin asset', () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); const files: FileState[] = [ - { name: fileName, content: 'content', isNew: true, isModified: false }, + { name: 'testFile', isNew: true, content: 'content', isModified: false }, ]; - handleFileClick( - fileName, + handleCreateFileClick( + 'testFile', null, - mockSetFileName, - mockSetFileContent, - mockSetFileType, files, - 'create', + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, ); - expect(mockSetFileName).toHaveBeenCalledWith(fileName); - expect(mockSetFileContent).toHaveBeenCalledWith('content'); + + expect(updateFileState).toHaveBeenCalled(); + expect(setIsLibraryFile).toHaveBeenCalledWith(false); + expect(setLibraryAssetPath).toHaveBeenCalledWith(''); }); - it('calls handleReconfigureFileClick if tab is "reconfigure"', () => { - const fileName = 'example.json'; - const files: FileState[] = [ - { name: fileName, content: 'content', isNew: false, isModified: true }, + test('handleReconfigureFileClick with modified file', async () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); + const files = [ + { name: 'testFile', isModified: true, isNew: false, content: 'content' }, ]; - handleFileClick( - fileName, - mockDigitalTwin, - mockSetFileName, - mockSetFileContent, - mockSetFileType, + await handleReconfigureFileClick( + 'testFile', + null, files, - 'reconfigure', + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, ); - expect(mockSetFileName).toHaveBeenCalledWith(fileName); - expect(mockSetFileContent).toHaveBeenCalledWith('content'); - }); -}); -jest.mock('react-redux', () => ({ - useDispatch: jest.fn(), -})); + expect(updateFileState).toHaveBeenCalled(); + expect(setIsLibraryFile).toHaveBeenCalledWith(false); + expect(setLibraryAssetPath).toHaveBeenCalledWith(''); + }); -describe('handleFileSubmit', () => { - const mockSetErrorMessage = jest.fn(); - const mockSetIsFileNameDialogOpen = jest.fn(); - const mockSetNewFileName = jest.fn(); - const dispatch = jest.fn(); + test('handleAddFileClick', () => { + const setIsFileNameDialogOpen = jest.fn(); - (useDispatch as unknown as jest.Mock).mockReturnValue(dispatch); + handleAddFileClick(setIsFileNameDialogOpen); - afterEach(() => { - jest.clearAllMocks(); + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(true); }); - it('dispatches addOrUpdateFile if new file name does not exist', () => { - const files: FileState[] = [ - { name: 'existingFile.md', content: '', isNew: true, isModified: false }, - ]; - const newFileName = 'newFile.md'; + test('handleCloseFileNameDialog', () => { + const setIsFileNameDialogOpen = jest.fn(); + const setNewFileName = jest.fn(); + const setErrorMessage = jest.fn(); - handleFileSubmit( - files, - newFileName, - mockSetErrorMessage, - dispatch, - mockSetIsFileNameDialogOpen, - mockSetNewFileName, - ); - expect(dispatch).toHaveBeenCalledWith( - addOrUpdateFile({ - name: newFileName, - content: '', - isNew: true, - isModified: false, - type: 'description', - }), + handleCloseFileNameDialog( + setIsFileNameDialogOpen, + setNewFileName, + setErrorMessage, ); - expect(mockSetIsFileNameDialogOpen).toHaveBeenCalledWith(false); - expect(mockSetNewFileName).toHaveBeenCalledWith(''); + + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(false); + expect(setNewFileName).toHaveBeenCalledWith(''); + expect(setErrorMessage).toHaveBeenCalledWith(''); }); - it('sets error message if file name already exists', () => { + test('handleFileSubmit with existing file', () => { const files: FileState[] = [ - { name: 'existingFile.md', content: '', isNew: true, isModified: false }, + { name: 'testFile', content: 'content', isNew: false, isModified: false }, ]; - const newFileName = 'existingFile.md'; + const setErrorMessage = jest.fn(); + const setIsFileNameDialogOpen = jest.fn(); + const setNewFileName = jest.fn(); handleFileSubmit( files, - newFileName, - mockSetErrorMessage, + 'testFile', + setErrorMessage, dispatch, - mockSetIsFileNameDialogOpen, - mockSetNewFileName, + setIsFileNameDialogOpen, + setNewFileName, ); - expect(mockSetErrorMessage).toHaveBeenCalledWith( + + expect(setErrorMessage).toHaveBeenCalledWith( 'A file with this name already exists.', ); - expect(dispatch).not.toHaveBeenCalled(); }); - it('sets error message if file name is empty', () => { - const files: FileState[] = [ - { name: 'existingFile.md', content: '', isNew: true, isModified: false }, - ]; - const newFileName = ''; + test('handleFileSubmit with new file', () => { + const files: FileState[] = []; + const setErrorMessage = jest.fn(); + const setIsFileNameDialogOpen = jest.fn(); + const setNewFileName = jest.fn(); handleFileSubmit( files, - newFileName, - mockSetErrorMessage, + 'newFile', + setErrorMessage, dispatch, - mockSetIsFileNameDialogOpen, - mockSetNewFileName, + setIsFileNameDialogOpen, + setNewFileName, ); - expect(mockSetErrorMessage).toHaveBeenCalledWith( - "File name can't be empty.", - ); - expect(dispatch).not.toHaveBeenCalled(); + + expect(setErrorMessage).toHaveBeenCalledWith(''); + expect(dispatch).toHaveBeenCalled(); + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(false); + expect(setNewFileName).toHaveBeenCalledWith(''); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx b/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx index 126eda358..fe3449e00 100644 --- a/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/execute/PipelineUtils.test.tsx @@ -42,7 +42,7 @@ describe('PipelineUtils', () => { store.dispatch, ); - const state = store.getState().digitalTwin; + const state = store.getState().digitalTwin.digitalTwin; expect(state.mockedDTName.jobLogs).toEqual([ { jobName: 'job1', log: 'log1' }, ]); diff --git a/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx index 03bab4b94..f08814fdb 100644 --- a/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/manage/ConfigDialog.test.tsx @@ -1,164 +1,173 @@ -import AssetBoard from 'preview/components/asset/AssetBoard'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import { Provider } from 'react-redux'; import * as React from 'react'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import ReconfigureDialog from 'preview/route/digitaltwins/manage/ReconfigureDialog'; +import assetsReducer from 'preview/store/assets.slice'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice, { showSnackbar } from 'preview/store/snackbar.slice'; +import fileSlice, { removeAllModifiedFiles } from 'preview/store/file.slice'; +import libraryConfigFilesSlice, { + removeAllModifiedLibraryFiles, +} from 'preview/store/libraryConfigFiles.slice'; import DigitalTwin from 'preview/util/digitalTwin'; import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; -import { showSnackbar } from 'preview/store/snackbar.slice'; -import * as ReconfigureDialog from 'preview/route/digitaltwins/manage/ReconfigureDialog'; -import { - addOrUpdateFile, - removeAllModifiedFiles, -} from 'preview/store/file.slice'; -import DTAssets from 'preview/util/DTAssets'; -import setupStore from './utils'; - -jest.useFakeTimers(); - -jest.mock('preview/util/init', () => ({ - fetchAssets: jest.fn(), -})); -describe('ReconfigureDialog', () => { - let storeConfig: ReturnType; - let dispatchSpy: jest.SpyInstance; +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); - beforeEach(() => { - storeConfig = setupStore(); +const mockDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); +mockDigitalTwin.fullDescription = 'Digital Twin Description'; + +const initialState = { + assets: { items: [] }, + digitalTwin: { + assetName: 'Asset 1', + digitalTwin: mockDigitalTwin, + shouldFetchDigitalTwins: false, + }, + snackbar: {}, + files: [], + libraryConfigFiles: [], + cart: { assets: [] }, +}; + +const store = configureStore({ + reducer: combineReducers({ + assets: assetsReducer, + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + files: fileSlice, + libraryConfigFiles: libraryConfigFilesSlice, + cart: (state = initialState.cart) => state, + }), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, + }), +}); - dispatchSpy = jest.spyOn(storeConfig, 'dispatch'); +describe('ReconfigureDialog Integration Tests', () => { + const setupTest = () => { + store.dispatch( + setDigitalTwin({ assetName: 'Asset 1', digitalTwin: mockDigitalTwin }), + ); + }; - act(() => { - render( - - - , - ); - }); + beforeEach(() => { + setupTest(); }); afterEach(() => { jest.clearAllMocks(); - jest.restoreAllMocks(); }); - it('closes the ConfirmationDialog with No', async () => { - const reconfigureButton = screen.getByRole('button', { - name: /Reconfigure/i, - }); - await act(async () => { - reconfigureButton.click(); - }); - - const cancelButton = await screen.findByRole('button', { name: /Cancel/i }); - await act(async () => { - cancelButton.click(); - }); - - const noButton = await screen.findByRole('button', { name: /No/i }); - await act(async () => { - noButton.click(); - }); + it('renders ReconfigureDialog', async () => { + render( + + + , + ); await waitFor(() => { - expect(screen.queryByText('Are you sure you want to cancel?')).toBeNull(); - expect(screen.queryByText('Editor')).toBeInTheDocument(); + expect(screen.getByText(/Reconfigure/i)).toBeInTheDocument(); }); }); - it('closes the Confirmation dialog with Yes', async () => { - const reconfigureButton = screen.getByRole('button', { - name: /Reconfigure/i, - }); - await act(async () => { - reconfigureButton.click(); - }); - - const cancelButton = await screen.findByRole('button', { name: /Cancel/i }); - await act(async () => { - cancelButton.click(); - }); + it('opens save confirmation dialog on save button click', async () => { + render( + + + , + ); - const yesButton = await screen.findByRole('button', { name: /Yes/i }); - await act(async () => { - yesButton.click(); - }); + fireEvent.click(screen.getByText('Save')); await waitFor(() => { - expect(screen.queryByText('Editor')).toBeNull(); + expect( + screen.getByText('Are you sure you want to apply the changes?'), + ).toBeInTheDocument(); }); - - expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedFiles()); }); - it('updates the description when description.md is modified', async () => { - const updateFileContent = jest - .spyOn(DTAssets.prototype, 'updateFileContent') - .mockResolvedValue(); - const modifiedFile = { - name: 'description.md', - content: 'New content', - isNew: false, - isModified: true, - }; - - act(() => { - storeConfig.dispatch(addOrUpdateFile(modifiedFile)); - }); + it('opens cancel confirmation dialog on cancel button click', async () => { + render( + + + , + ); - const reconfigureButton = screen.getByRole('button', { - name: /Reconfigure/i, - }); - await act(async () => { - reconfigureButton.click(); - }); + fireEvent.click(screen.getByText('Cancel')); - const saveButton = await screen.findByRole('button', { name: /Save/i }); - await act(async () => { - saveButton.click(); + await waitFor(() => { + expect( + screen.getByText(/Are you sure you want to cancel?/i), + ).toBeInTheDocument(); }); + }); - const yesButton = await screen.findByRole('button', { name: /Yes/i }); - await act(async () => { - yesButton.click(); - }); + it('dispatches actions on confirm save', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + , + ); + + fireEvent.click(screen.getByText('Save')); + fireEvent.click(screen.getByText('Yes')); await waitFor(() => { - expect(updateFileContent).toHaveBeenCalled(); - const state = storeConfig.getState(); - expect(state.digitalTwin['Asset 1'].description).toBe('New content'); + expect(dispatchSpy).toHaveBeenCalledWith( + showSnackbar({ + message: 'Asset 1 reconfigured successfully', + severity: 'success', + }), + ); + expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedFiles()); + expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedLibraryFiles()); }); }); - it('calls handleCloseReconfigureDialog when the dialog is closed', () => { - const setShowDialog = jest.fn(); - ReconfigureDialog.handleCloseReconfigureDialog(setShowDialog); - expect(setShowDialog).toHaveBeenCalledWith(false); - }); + it('dispatches actions on confirm cancel', async () => { + const dispatchSpy = jest.spyOn(store, 'dispatch'); + + render( + + + , + ); - it('should dispatch error message when updateFileContent throws an error', async () => { - const file = { - name: 'test.md', - content: 'Content', - isNew: false, - isModified: true, - }; - const digitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); - const dispatch = jest.fn(); - - jest - .spyOn(digitalTwin.DTAssets, 'updateFileContent') - .mockRejectedValue(new Error('Mocked error')); - - await act(async () => { - await ReconfigureDialog.handleFileUpdate(file, digitalTwin, dispatch); - }); + fireEvent.click(screen.getByText('Cancel')); + fireEvent.click(screen.getByText('Yes')); - expect(dispatch).toHaveBeenCalledWith( - showSnackbar({ - message: 'Error updating file test.md: Error: Mocked error', - severity: 'error', - }), - ); + await waitFor(() => { + expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedFiles()); + expect(dispatchSpy).toHaveBeenCalledWith(removeAllModifiedLibraryFiles()); + }); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx index aa330fa7f..059b56024 100644 --- a/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/manage/DeleteDialog.test.tsx @@ -1,90 +1,65 @@ -import { render, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; -import DigitalTwin from 'preview/util/digitalTwin'; +import { render, screen, fireEvent } from '@testing-library/react'; import { Provider } from 'react-redux'; -import AssetBoard from 'preview/components/asset/AssetBoard'; -import setupStore from './utils'; - -jest.useFakeTimers(); +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import DeleteDialog from 'preview/route/digitaltwins/manage/DeleteDialog'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice from 'preview/store/snackbar.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; -jest.mock('preview/util/init', () => ({ - fetchAssets: jest.fn(), +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), })); -describe('DeleteDialog', () => { - let storeDelete: ReturnType; +const mockDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); +mockDigitalTwin.delete = jest.fn().mockResolvedValue('Deleted successfully'); + +const store = configureStore({ + reducer: combineReducers({ + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + }), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, + }), +}); + +describe('DeleteDialog Integration Tests', () => { + const setupTest = () => { + store.dispatch( + setDigitalTwin({ assetName: 'Asset 1', digitalTwin: mockDigitalTwin }), + ); + }; beforeEach(() => { - storeDelete = setupStore(); - - React.act(() => { - render( - - - , - ); - }); + setupTest(); }); afterEach(() => { jest.clearAllMocks(); - jest.restoreAllMocks(); }); - it('opens the DeleteDialog when the Delete button is clicked', async () => { - const deleteButton = screen.getByRole('button', { name: /Delete/i }); - React.act(() => { - deleteButton.click(); - }); - - await waitFor(() => { - const deleteDialog = screen.getByText('This step is irreversible', { - exact: false, - }); - expect(deleteDialog).toBeInTheDocument(); - }); - }); - - it('closes the DeleteDialog when the Cancel button is clicked', async () => { - const deleteButton = screen.getByRole('button', { name: /Delete/i }); - React.act(() => { - deleteButton.click(); - }); - - const cancelButton = await screen.findByRole('button', { name: /Cancel/i }); - - React.act(() => { - cancelButton.click(); - }); - - await waitFor(() => { - expect( - screen.queryByText('This step is irreversible', { exact: false }), - ).toBeNull(); - }); - }); - - it('deletes the asset when the Yes button is clicked', async () => { - jest - .spyOn(DigitalTwin.prototype, 'delete') - .mockResolvedValue('Asset 1 deleted successfully'); - - const deleteButton = screen.getByRole('button', { name: /Delete/i }); - React.act(() => { - deleteButton.click(); - }); + it('closes DeleteDialog on Cancel button click', async () => { + const setShowDialog = jest.fn(); - const yesButton = await screen.findByRole('button', { name: /Yes/i }); + render( + + + , + ); - React.act(() => { - yesButton.click(); - }); + const cancelButton = screen.getByText('Cancel'); + fireEvent.click(cancelButton); - await waitFor(() => { - const state = storeDelete.getState(); - expect(state.snackbar.open).toBe(true); - expect(state.snackbar.message).toBe('Asset 1 deleted successfully'); - expect(state.snackbar.severity).toBe('success'); - }); + expect(setShowDialog).toHaveBeenCalledWith(false); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx b/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx index a43676a61..1456b825b 100644 --- a/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx +++ b/client/test/preview/integration/route/digitaltwins/manage/DetailsDialog.test.tsx @@ -1,69 +1,120 @@ -import AssetBoard from 'preview/components/asset/AssetBoard'; -import { act, render, screen, waitFor } from '@testing-library/react'; -import { Provider } from 'react-redux'; import * as React from 'react'; -import setupStore from './utils'; +import { render, screen, waitFor, fireEvent } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { combineReducers, configureStore } from '@reduxjs/toolkit'; +import DetailsDialog from 'preview/route/digitaltwins/manage/DetailsDialog'; +import assetsReducer, { setAssets } from 'preview/store/assets.slice'; +import digitalTwinReducer, { + setDigitalTwin, +} from 'preview/store/digitalTwin.slice'; +import snackbarSlice from 'preview/store/snackbar.slice'; +import fileSlice from 'preview/store/file.slice'; +import libraryConfigFilesSlice from 'preview/store/libraryConfigFiles.slice'; +import DigitalTwin from 'preview/util/digitalTwin'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), })); -jest.mock('preview/util/init', () => ({ - fetchAssets: jest.fn(), -})); +const mockDigitalTwin = new DigitalTwin('Asset 1', mockGitlabInstance); +mockDigitalTwin.fullDescription = 'Digital Twin Description'; + +const mockLibraryAsset = new LibraryAsset( + 'Asset 1', + 'path/to/asset', + true, + 'Digital Twins', + mockGitlabInstance, +); +mockLibraryAsset.fullDescription = 'Library Asset Description'; -jest.useFakeTimers(); +const store = configureStore({ + reducer: combineReducers({ + assets: assetsReducer, + digitalTwin: digitalTwinReducer, + snackbar: snackbarSlice, + files: fileSlice, + libraryConfigFiles: libraryConfigFilesSlice, + }), + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: false, + }), +}); -describe('DetailsDialog', () => { - let storeDetails: ReturnType; +describe('DetailsDialog Integration Tests', () => { + const setupTest = () => { + store.dispatch(setAssets([mockLibraryAsset])); + store.dispatch( + setDigitalTwin({ assetName: 'Asset 1', digitalTwin: mockDigitalTwin }), + ); + }; beforeEach(() => { - storeDetails = setupStore(); - - act(() => { - render( - - - , - ); - }); + setupTest(); }); afterEach(() => { jest.clearAllMocks(); }); - it('renders the AssetCardManage with Details button', async () => { - const detailsButton = screen.getByRole('button', { name: /Details/i }); - expect(detailsButton).toBeInTheDocument(); - }); + it('renders DetailsDialog with Digital Twin description', async () => { + render( + + + , + ); - it('opens the DetailsDialog when the Details button is clicked', async () => { - const detailsButton = screen.getByRole('button', { name: /Details/i }); - act(() => { - detailsButton.click(); + await waitFor(() => { + expect(screen.getByText('Digital Twin Description')).toBeInTheDocument(); }); + }); + + it('renders DetailsDialog with Library Asset description', async () => { + render( + + + , + ); await waitFor(() => { - const detailsDialog = screen.getByText(/There is no README\.md file/); - expect(detailsDialog).toBeInTheDocument(); + expect(screen.getByText('Library Asset Description')).toBeInTheDocument(); }); }); - it('closes the DetailsDialog when the Close button is clicked', async () => { - const detailsButton = screen.getByRole('button', { name: /Details/i }); - act(() => { - detailsButton.click(); - }); + it('closes DetailsDialog on Close button click', async () => { + const setShowDialog = jest.fn(); - const closeButton = await screen.findByRole('button', { name: /Close/i }); + render( + + + , + ); - act(() => { - closeButton.click(); - }); + const closeButton = screen.getByText('Close'); + fireEvent.click(closeButton); - await waitFor(() => { - expect(screen.queryByText('There is no README.md file')).toBeNull(); - }); + expect(setShowDialog).toHaveBeenCalledWith(false); }); }); diff --git a/client/test/preview/integration/route/digitaltwins/manage/utils.ts b/client/test/preview/integration/route/digitaltwins/manage/utils.ts index 43e72010f..1de6e1edf 100644 --- a/client/test/preview/integration/route/digitaltwins/manage/utils.ts +++ b/client/test/preview/integration/route/digitaltwins/manage/utils.ts @@ -1,5 +1,4 @@ import { combineReducers, configureStore } from '@reduxjs/toolkit'; -import { Asset } from 'preview/components/asset/Asset'; import fileSlice, { FileState, addOrUpdateFile, @@ -9,11 +8,15 @@ import digitalTwinReducer, { setDigitalTwin, } from 'preview/store/digitalTwin.slice'; import snackbarReducer from 'preview/store/snackbar.slice'; -import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; +import { + mockGitlabInstance, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; import DigitalTwin from 'preview/util/digitalTwin'; +import LibraryAsset from 'preview/util/libraryAsset'; const setupStore = () => { - const preSetItems: Asset[] = [{ name: 'Asset 1', path: 'path/asset1' }]; + const preSetItems: LibraryAsset[] = [mockLibraryAsset]; const files: FileState[] = [ { name: 'Asset 1', content: 'content1', isNew: false, isModified: false }, ]; diff --git a/client/test/preview/integration/route/library/LibraryPreview.test.tsx b/client/test/preview/integration/route/library/LibraryPreview.test.tsx new file mode 100644 index 000000000..1f5ce5d23 --- /dev/null +++ b/client/test/preview/integration/route/library/LibraryPreview.test.tsx @@ -0,0 +1,44 @@ +import * as React from 'react'; +import LibraryPreview from 'preview/route/library/LibraryPreview'; +import store from 'store/store'; +import { act, render, screen } from '@testing-library/react'; +import { Provider } from 'react-redux'; +import { MemoryRouter } from 'react-router-dom'; +import { useAuth } from 'react-oidc-context'; + +jest.mock('react-redux', () => ({ + ...jest.requireActual('react-redux'), +})); + +jest.mock('react-oidc-context', () => ({ + ...jest.requireActual('react-oidc-context'), + useAuth: jest.fn(), +})); + +describe('Library Preview', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('displays content of tabs', async () => { + (useAuth as jest.Mock).mockReturnValue({ + user: { + profile: { + profile: 'testProfileUrl', + }, + }, + }); + + await act(async () => { + render( + + + + + , + ); + }); + + expect(screen.getByText('Selection')).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/unit/components/asset/AddToCartButton.test.tsx b/client/test/preview/unit/components/asset/AddToCartButton.test.tsx new file mode 100644 index 000000000..0ba116d61 --- /dev/null +++ b/client/test/preview/unit/components/asset/AddToCartButton.test.tsx @@ -0,0 +1,61 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import AddToCartButton from 'preview/components/asset/AddToCartButton'; +import * as React from 'react'; +import * as cartAccess from 'preview/store/CartAccess'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; +import { useSelector } from 'react-redux'; +import { RootState } from 'store/store'; +import { selectAssetByPathAndPrivacy } from 'preview/store/assets.slice'; + +describe('AddToCartButton', () => { + const addMock = jest.fn(); + const removeMock = jest.fn(); + const clearMock = jest.fn(); + + beforeEach(() => { + (useSelector as jest.MockedFunction).mockImplementation( + (selector: (state: RootState) => unknown) => { + if (selector === selectAssetByPathAndPrivacy('path', true)) { + return mockLibraryAsset; + } + return mockLibraryAsset; + }, + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should add asset to cart when not in cart', () => { + jest.spyOn(cartAccess, 'default').mockReturnValue({ + state: { assets: [] }, + actions: { + add: addMock, + remove: removeMock, + clear: clearMock, + }, + }); + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(addMock).toHaveBeenCalled(); + }); + + it('should remove asset to cart when not in cart', () => { + jest.spyOn(cartAccess, 'default').mockReturnValue({ + state: { assets: [mockLibraryAsset] }, + actions: { + add: addMock, + remove: removeMock, + clear: clearMock, + }, + }); + + render(); + + fireEvent.click(screen.getByRole('button')); + expect(removeMock).toHaveBeenCalled(); + }); +}); diff --git a/client/test/preview/unit/components/asset/AssetBoard.test.tsx b/client/test/preview/unit/components/asset/AssetBoard.test.tsx index 37861fba2..27f1b4046 100644 --- a/client/test/preview/unit/components/asset/AssetBoard.test.tsx +++ b/client/test/preview/unit/components/asset/AssetBoard.test.tsx @@ -38,13 +38,20 @@ describe('AssetBoard', () => { ); const mockAssets = [ - { name: 'Asset 1', description: 'Test Asset', path: 'path1' }, + { + name: 'Asset 1', + description: 'Test Asset', + path: 'path1', + type: 'Digital Twins', + isPrivate: true, + }, ]; (useSelector as jest.MockedFunction).mockImplementation( (selector) => selector({ assets: { items: mockAssets }, + digitalTwin: { shouldFetchDigitalTwins: false }, }), ); }); @@ -73,17 +80,4 @@ describe('AssetBoard', () => { expect(mockDispatch).toHaveBeenCalledTimes(1); }); - - it('shows error message when error is set', () => { - const realUseState = React.useState; - - const stubInitialState: unknown = ['Error message']; - jest - .spyOn(React, 'useState') - .mockImplementationOnce(() => realUseState(stubInitialState)); - - renderAssetBoard('Manage'); - - expect(screen.getByText('Error message')).toBeInTheDocument(); - }); }); diff --git a/client/test/preview/unit/components/asset/AssetCard.test.tsx b/client/test/preview/unit/components/asset/AssetCard.test.tsx index d0aef58e4..7842fa4d9 100644 --- a/client/test/preview/unit/components/asset/AssetCard.test.tsx +++ b/client/test/preview/unit/components/asset/AssetCard.test.tsx @@ -42,61 +42,67 @@ const asset = { name: 'asset', description: 'Asset description', path: 'path', + type: 'Digital twins', + isPrivate: true, }; -describe('AssetCardManage', () => { +const setupMockStore = (assetDescription: string, twinDescription: string) => { + const state = { + assets: { + items: [ + { + name: 'asset', + path: 'path', + isPrivate: true, + description: assetDescription, + }, + ], + }, + digitalTwin: { + digitalTwin: { + asset: { description: twinDescription }, + }, + }, + }; + (useSelector as jest.MockedFunction).mockImplementation( + (selector) => selector(state), + ); +}; + +const renderComponent = ( + Component: React.JSXElementConstructor, + props: T, +) => { + render( + + + , + ); +}; + +describe('AssetCard', () => { afterEach(() => { jest.clearAllMocks(); }); it('renders AssetCardManage with digital twin description', () => { - (useSelector as jest.MockedFunction).mockImplementation( - (selector) => - selector({ - digitalTwin: { - [asset.name]: { description: 'Digital Twin description' }, - }, - }), - ); - - render( - - {}} /> - , - ); + setupMockStore('Asset description', 'Digital Twin description'); + renderComponent(AssetCardManage, { asset, onDelete: jest.fn() }); expect(screen.getByText(formatName(asset.name))).toBeInTheDocument(); - expect(screen.getByText('Digital Twin description')).toBeInTheDocument(); + expect(screen.getByText('Asset description')).toBeInTheDocument(); expect(screen.getByTestId('custom-snackbar')).toBeInTheDocument(); expect(screen.getByTestId('details-dialog')).toBeInTheDocument(); expect(screen.getByTestId('reconfigure-dialog')).toBeInTheDocument(); expect(screen.getByTestId('delete-dialog')).toBeInTheDocument(); }); -}); - -describe('AssetCardExecute', () => { - afterEach(() => { - jest.clearAllMocks(); - }); it('renders AssetCardExecute with digital twin description', () => { - (useSelector as jest.MockedFunction).mockImplementation( - (selector) => - selector({ - digitalTwin: { - [asset.name]: { description: 'Digital Twin description' }, - }, - }), - ); - - render( - - - , - ); + setupMockStore('Asset description', 'Digital Twin description'); + renderComponent(AssetCardExecute, { asset }); expect(screen.getByText(formatName(asset.name))).toBeInTheDocument(); - expect(screen.getByText('Digital Twin description')).toBeInTheDocument(); + expect(screen.getByText('Asset description')).toBeInTheDocument(); expect(screen.getByTestId('custom-snackbar')).toBeInTheDocument(); expect(screen.getByTestId('log-dialog')).toBeInTheDocument(); }); diff --git a/client/test/preview/unit/components/asset/AssetLibrary.test.tsx b/client/test/preview/unit/components/asset/AssetLibrary.test.tsx new file mode 100644 index 000000000..770c8fca1 --- /dev/null +++ b/client/test/preview/unit/components/asset/AssetLibrary.test.tsx @@ -0,0 +1,56 @@ +import * as React from 'react'; +import { act, render, screen, waitFor } from '@testing-library/react'; +import { Provider, useSelector } from 'react-redux'; +import AssetLibrary from 'preview/components/asset/AssetLibrary'; +import store, { RootState } from 'store/store'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; +import { selectAssetsByTypeAndPrivacy } from 'preview/store/assets.slice'; + +jest.mock('preview/store/assets.slice', () => ({ + ...jest.requireActual('preview/store/assets.slice'), + selectAssetsByTypeAndPrivacy: jest.fn(() => []), +})); + +jest.mock('preview/util/init', () => ({ + fetchLibraryAssets: jest.fn(() => Promise.resolve()), +})); + +jest.mock('preview/components/asset/Filter', () => ({ + __esModule: true, + default: () =>
    Filter
    , +})); + +jest.mock('preview/components/asset/AssetCard', () => ({ + __esModule: true, + AssetCardLibrary: () =>
    Asset Card Library
    , +})); + +describe('AssetLibrary', () => { + beforeEach(() => { + (useSelector as jest.MockedFunction).mockImplementation( + (selector: (state: RootState) => unknown) => { + if (selector === selectAssetsByTypeAndPrivacy('path', false)) { + return [mockLibraryAsset]; + } + return []; + }, + ); + }); + + const renderAssetLibrary = () => + act(async () => { + render( + + + , + ); + }); + + it('renders assets when fetched', async () => { + await renderAssetLibrary(); + + await waitFor(() => + expect(screen.getByText('Asset Card Library')).toBeInTheDocument(), + ); + }); +}); diff --git a/client/test/preview/unit/components/asset/DetailsButton.test.tsx b/client/test/preview/unit/components/asset/DetailsButton.test.tsx index 729162c0d..899de8921 100644 --- a/client/test/preview/unit/components/asset/DetailsButton.test.tsx +++ b/client/test/preview/unit/components/asset/DetailsButton.test.tsx @@ -14,11 +14,16 @@ jest.mock('react-redux', () => ({ describe('DetailsButton', () => { const renderDetailsButton = ( assetName: string, + assetPrivacy: boolean, setShowDetails: Dispatch>, ) => render( - + , ); @@ -27,7 +32,7 @@ describe('DetailsButton', () => { }); it('renders the Details button', () => { - renderDetailsButton('AssetName', jest.fn()); + renderDetailsButton('AssetName', true, jest.fn()); expect( screen.getByRole('button', { name: /Details/i }), ).toBeInTheDocument(); @@ -42,7 +47,7 @@ describe('DetailsButton', () => { getFullDescription: jest.fn().mockResolvedValue('Mocked description'), }); - renderDetailsButton('AssetName', mockSetShowDetails); + renderDetailsButton('AssetName', true, mockSetShowDetails); const detailsButton = screen.getByRole('button', { name: /Details/i }); fireEvent.click(detailsButton); diff --git a/client/test/preview/unit/components/cart/CartList.test.tsx b/client/test/preview/unit/components/cart/CartList.test.tsx new file mode 100644 index 000000000..45289c8b6 --- /dev/null +++ b/client/test/preview/unit/components/cart/CartList.test.tsx @@ -0,0 +1,34 @@ +import { render, screen } from '@testing-library/react'; +import CartList from 'preview/components/cart/CartList'; +import * as React from 'react'; +import * as cartAccess from 'preview/store/CartAccess'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; + +describe('CartList', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should render a list of assets', () => { + jest.spyOn(cartAccess, 'default').mockReturnValue({ + state: { assets: [mockLibraryAsset] }, + actions: { add: jest.fn(), remove: jest.fn(), clear: jest.fn() }, + }); + + render(); + + expect(screen.getByText('path')).toBeInTheDocument(); + }); + + it('should render a list of common assets', () => { + mockLibraryAsset.isPrivate = false; + jest.spyOn(cartAccess, 'default').mockReturnValue({ + state: { assets: [mockLibraryAsset] }, + actions: { add: jest.fn(), remove: jest.fn(), clear: jest.fn() }, + }); + + render(); + + expect(screen.getByText('common/path')).toBeInTheDocument(); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/create/CreateDialogs.test.tsx b/client/test/preview/unit/routes/digitaltwins/create/CreateDialogs.test.tsx index 2633b1131..635b58ed8 100644 --- a/client/test/preview/unit/routes/digitaltwins/create/CreateDialogs.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/create/CreateDialogs.test.tsx @@ -47,6 +47,7 @@ describe('CreateDialogs', () => { setNewDigitalTwinName: mockSetNewDigitalTwinName, errorMessage: '', setErrorMessage: mockSetErrorMessage, + isPrivate: true, }; beforeEach(() => { diff --git a/client/test/preview/unit/routes/digitaltwins/create/CreatePage.test.tsx b/client/test/preview/unit/routes/digitaltwins/create/CreatePage.test.tsx index 724282fb9..423d27f4b 100644 --- a/client/test/preview/unit/routes/digitaltwins/create/CreatePage.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/create/CreatePage.test.tsx @@ -12,11 +12,6 @@ jest.mock('preview/route/digitaltwins/create/CreateDialogs', () => ({ default: () =>
    , })); -jest.mock('preview/route/digitaltwins/create/FileActionButtons', () => ({ - _esModule: true, - default: () =>
    , -})); - jest.mock('preview/route/digitaltwins/Snackbar', () => ({ _esModule: true, default: () =>
    , @@ -42,7 +37,6 @@ describe('CreatePage', () => { expect(screen.getByText('Save')).toBeInTheDocument(); expect(screen.getByTestId('editor')).toBeInTheDocument(); expect(screen.getByTestId('create-dialogs')).toBeInTheDocument(); - expect(screen.getByTestId('file-action-buttons')).toBeInTheDocument(); expect(screen.getByTestId('snackbar')).toBeInTheDocument(); }); diff --git a/client/test/preview/unit/routes/digitaltwins/create/FileActionButtons.test.tsx b/client/test/preview/unit/routes/digitaltwins/create/FileActionButtons.test.tsx index 515fcb28d..88169da3f 100644 --- a/client/test/preview/unit/routes/digitaltwins/create/FileActionButtons.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/create/FileActionButtons.test.tsx @@ -11,12 +11,13 @@ describe('FileActionButtons', () => { fileName="file" setOpenDeleteFileDialog={setOpenDeleteFileDialog} setOpenChangeFileNameDialog={jest.fn()} + isLibraryFile={false} />, ); }); it('should render FileActionButtons', () => { expect(screen.getByText('Delete File')).toBeInTheDocument(); - expect(screen.getByText('Change File Name')).toBeInTheDocument(); + expect(screen.getByText('Rename File')).toBeInTheDocument(); }); it('handles click on delete file button', () => { @@ -25,7 +26,7 @@ describe('FileActionButtons', () => { }); it('handles click on change file name button', () => { - screen.getByText('Change File Name').click(); + screen.getByText('Rename File').click(); expect(setOpenDeleteFileDialog).not.toBeCalled(); }); }); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx index 23fcd7ecd..04acf4ff0 100644 --- a/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/editor/Editor.test.tsx @@ -29,6 +29,12 @@ describe('Editor', () => { setFileContent={jest.fn()} fileType={'fileType'} setFileType={jest.fn()} + filePrivacy={'private'} + setFilePrivacy={jest.fn()} + isLibraryFile={false} + setIsLibraryFile={jest.fn()} + libraryAssetPath={''} + setLibraryAssetPath={jest.fn()} />, ); }); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx index d4cbbdef8..c8b350bf3 100644 --- a/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/editor/EditorTab.test.tsx @@ -1,7 +1,10 @@ -import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import * as React from 'react'; -import EditorTab from 'preview/route/digitaltwins/editor/EditorTab'; +import EditorTab, { + handleEditorChange, +} from 'preview/route/digitaltwins/editor/EditorTab'; import { addOrUpdateFile } from 'preview/store/file.slice'; +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; jest.mock('preview/store/file.slice', () => ({ addOrUpdateFile: jest.fn(), @@ -25,10 +28,13 @@ describe('EditorTab', () => { waitFor(async () => { render( , ); @@ -39,34 +45,103 @@ describe('EditorTab', () => { }); }); - it('calls handleEditorChange via onChange correctly', async () => { - waitFor(async () => { - render( - , - ); + it('calls handleEditorChange via onChange correctly - create tab', async () => { + await handleEditorChange( + 'create', + 'new content', + jest.fn(), + mockSetFileContent, + 'fileName', + 'private', + false, + '', + mockDispatch, + ); - const newValue = 'New content'; + expect(mockSetFileContent).toHaveBeenCalledWith('new content'); + expect(mockDispatch).toHaveBeenCalledWith( + addOrUpdateFile({ + name: 'fileName', + content: 'new content', + isNew: true, + isModified: true, + }), + ); + }); - fireEvent.change(screen.getByRole('textbox'), { - target: { value: newValue }, - }); + it('calls handleEditorChange via onChange correctly - create tab and libraryFile', async () => { + await handleEditorChange( + 'create', + 'new content', + jest.fn(), + mockSetFileContent, + 'fileName', + 'private', + true, + 'path', + mockDispatch, + ); - await waitFor(() => { - expect(mockSetFileContent).toHaveBeenCalledWith(newValue); - expect(mockDispatch).toHaveBeenCalledWith( - addOrUpdateFile({ - name: 'fileName', - content: newValue, - isNew: false, - isModified: true, - }), - ); - }); - }); + expect(mockSetFileContent).toHaveBeenCalledWith('new content'); + expect(mockDispatch).toHaveBeenCalledWith( + addOrUpdateLibraryFile({ + assetPath: 'path', + fileName: 'fileName', + fileContent: 'new content', + isNew: true, + isModified: true, + isPrivate: true, + }), + ); + }); + + it('calls handleEditorChange via onChange correctly - reconfigure tab', async () => { + await handleEditorChange( + 'reconfigure', + 'new content', + jest.fn(), + mockSetFileContent, + 'fileName', + 'private', + false, + '', + mockDispatch, + ); + + expect(mockSetFileContent).toHaveBeenCalledWith('new content'); + expect(mockDispatch).toHaveBeenCalledWith( + addOrUpdateFile({ + name: 'fileName', + content: 'new content', + isNew: true, + isModified: true, + }), + ); + }); + + it('calls handleEditorChange via onChange correctly - reconfigure tab and libraryFile', async () => { + await handleEditorChange( + 'reconfigure', + 'new content', + jest.fn(), + mockSetFileContent, + 'fileName', + 'private', + true, + 'path', + mockDispatch, + ); + + expect(mockSetFileContent).toHaveBeenCalledWith('new content'); + expect(mockDispatch).toHaveBeenCalledWith( + addOrUpdateLibraryFile({ + assetPath: 'path', + fileName: 'fileName', + fileContent: 'new content', + isNew: false, + isModified: true, + isPrivate: true, + }), + ); }); }); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx index b440ca773..8bfe09cd1 100644 --- a/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/editor/Sidebar.test.tsx @@ -1,18 +1,15 @@ -import { - render, - waitFor, - screen, - act, - fireEvent, -} from '@testing-library/react'; +import { render, waitFor, screen, act } from '@testing-library/react'; import Sidebar from 'preview/route/digitaltwins/editor/Sidebar'; -import { handleReconfigureFileClick } from 'preview/route/digitaltwins/editor/sidebarFunctions'; -import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; -import { FileState } from 'preview/store/file.slice'; +import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; import * as React from 'react'; import { Provider, useSelector } from 'react-redux'; import store, { RootState } from 'store/store'; -import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; +import { + mockDigitalTwin, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; +import { addOrUpdateLibraryFile } from 'preview/store/libraryConfigFiles.slice'; +import * as ReactRedux from 'react-redux'; jest.mock('react-redux', () => ({ ...jest.requireActual('react-redux'), @@ -23,6 +20,11 @@ describe('Sidebar', () => { const setFileName = jest.fn(); const setFileContent = jest.fn(); const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); + const fileName = 'testFile.md'; + const isLibraryFile = false; const renderSidebar = async (tab: string, name?: string) => { await act(async () => { @@ -33,25 +35,31 @@ describe('Sidebar', () => { setFileName={setFileName} setFileContent={setFileContent} setFileType={setFileType} + setFilePrivacy={setFilePrivacy} + setIsLibraryFile={setIsLibraryFile} + setLibraryAssetPath={setLibraryAssetPath} tab={tab} + fileName={fileName} + isLibraryFile={isLibraryFile} /> , ); }); }; - beforeEach(async () => { + beforeEach(() => { (useSelector as jest.MockedFunction).mockImplementation( (selector: (state: RootState) => unknown) => { - if (selector === selectDigitalTwinByName('mockedDTName')) { - return mockDigitalTwin; - } if (selector.toString().includes('state.files')) { return []; } + if (selector.toString().includes('state.cart.assets')) { + return [mockLibraryAsset]; + } return mockDigitalTwin; }, ); + jest.clearAllMocks(); }); afterEach(() => { @@ -68,85 +76,52 @@ describe('Sidebar', () => { }); }); - it('should update file state if the file is modified', async () => { - await renderSidebar('reconfigure', 'mockedDTName'); + it('should call handleAddFileClick when Add new file button is clicked', async () => { + const handleAddFileClickSpy = jest.spyOn( + SidebarFunctions, + 'handleAddFileClick', + ); - const modifiedFiles: FileState[] = [ - { - name: 'testFile.md', - content: 'modified content', - isNew: false, - isModified: true, - }, - ]; + await renderSidebar('create', 'mockedDTName'); - await act(async () => { - handleReconfigureFileClick( - 'testFile.md', - mockDigitalTwin, - modifiedFiles, - setFileName, - setFileContent, - setFileType, - ); + await waitFor(() => { + const addFileButton = screen.getByText('Add new file'); + addFileButton.click(); }); - expect(setFileName).toHaveBeenCalledWith('testFile.md'); - expect(setFileContent).toHaveBeenCalledWith('modified content'); - expect(setFileType).toHaveBeenCalledWith('md'); - expect(mockDigitalTwin.DTAssets.getFileContent).not.toHaveBeenCalled(); + expect(handleAddFileClickSpy).toHaveBeenCalled(); }); - it('should fetch and update file state if the file is not modified', async () => { - await renderSidebar('reconfigure', 'mockedDTName'); - - const modifiedFiles: FileState[] = []; - mockDigitalTwin.DTAssets.getFileContent = jest - .fn() - .mockResolvedValue('fetched content'); + it('should render file sections', async () => { + await renderSidebar('reconfigure', 'differentDTName'); - await act(async () => { - await handleReconfigureFileClick( - 'testFile.md', - mockDigitalTwin, - modifiedFiles, - setFileName, - setFileContent, - setFileType, - ); + await waitFor(() => { + expect(screen.getByText('Description')).toBeInTheDocument(); + expect(screen.getByText('Lifecycle')).toBeInTheDocument(); + expect(screen.getByText('Configuration')).toBeInTheDocument(); + expect(screen.getByText('assetPath configuration')).toBeInTheDocument(); }); - - expect(mockDigitalTwin.DTAssets.getFileContent).toHaveBeenCalledWith( - 'testFile.md', - ); - expect(setFileName).toHaveBeenCalledWith('testFile.md'); - expect(setFileContent).toHaveBeenCalledWith('fetched content'); - expect(setFileType).toHaveBeenCalledWith('md'); }); - it('opens the file name dialog when Add new file button is clicked', async () => { + it('handles assets in create mode', async () => { + const addOrUpdateLibraryFileSpy = jest.spyOn(ReactRedux, 'useDispatch'); + await renderSidebar('create'); await waitFor(() => { - fireEvent.click(screen.getByText('Add new file')); + expect(addOrUpdateLibraryFileSpy).toHaveBeenCalled(); + mockLibraryAsset.configFiles.forEach((file) => { + expect(addOrUpdateLibraryFileSpy).toHaveBeenCalledWith( + addOrUpdateLibraryFile({ + assetPath: mockLibraryAsset.path, + fileName: file, + fileContent: '', + isNew: true, + isModified: false, + isPrivate: mockLibraryAsset.isPrivate, + }), + ); + }); }); - - expect(screen.getByText('Enter the file name')).toBeInTheDocument(); - }); - - it('renders Sidebar with null digitalTwin when tab is create', async () => { - (useSelector as unknown as jest.Mock).mockImplementationOnce( - (selector: (state: RootState) => unknown) => { - if (selector === selectDigitalTwinByName('')) { - return null; - } - if (selector.toString().includes('state.files')) { - return []; - } - return null; - }, - ); - - await renderSidebar('create', ''); }); }); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/SidebarFunctions.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/SidebarFunctions.test.tsx deleted file mode 100644 index b3e7fd7f1..000000000 --- a/client/test/preview/unit/routes/digitaltwins/editor/SidebarFunctions.test.tsx +++ /dev/null @@ -1,240 +0,0 @@ -import { fireEvent, render, screen } from '@testing-library/react'; -import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; -import { FileState } from 'preview/store/file.slice'; -import { mockDigitalTwin as mockDigitalTwinInstance } from 'test/preview/__mocks__/global_mocks'; // Rinominato -import { SimpleTreeView } from '@mui/x-tree-view'; -import * as React from 'react'; -import DigitalTwin from 'preview/util/digitalTwin'; - -describe('SidebarFunctions', () => { - const setFileName = jest.fn(); - const setFileContent = jest.fn(); - const setFileType = jest.fn(); - const setErrorMessage = jest.fn(); - const dispatch = jest.fn(); - const setIsFileNameDialogOpen = jest.fn(); - const setNewFileName = jest.fn(); - - afterEach(() => { - jest.clearAllMocks(); - }); - - const files: FileState[] = []; // spostato qui per evitare conflitti di scope - - it('should return the correct file type from the extension', () => { - expect(SidebarFunctions.getFileTypeFromExtension('file.md')).toBe( - 'description', - ); - expect(SidebarFunctions.getFileTypeFromExtension('file.json')).toBe( - 'config', - ); - expect(SidebarFunctions.getFileTypeFromExtension('file.yaml')).toBe( - 'config', - ); - expect(SidebarFunctions.getFileTypeFromExtension('file.yml')).toBe( - 'config', - ); - expect(SidebarFunctions.getFileTypeFromExtension('file')).toBe('lifecycle'); - }); - - it('should handle file click correctly in create tab', () => { - const tab = 'create'; - const handleCreateFileClick = jest - .spyOn(SidebarFunctions, 'handleCreateFileClick') - .mockImplementation(jest.fn()); - - SidebarFunctions.handleFileClick( - 'file', - null, - setFileName, - setFileContent, - setFileType, - files, - tab, - ); - - expect(handleCreateFileClick).toHaveBeenCalled(); - }); - - it('should handle file click correctly in reconfigure tab', () => { - const tab = 'reconfigure'; - const handleReconfigureFileClick = jest - .spyOn(SidebarFunctions, 'handleReconfigureFileClick') - .mockImplementation(jest.fn()); - - SidebarFunctions.handleFileClick( - 'file', - null, - setFileName, - setFileContent, - setFileType, - files, - tab, - ); - - expect(handleReconfigureFileClick).toHaveBeenCalled(); - }); - - it('should render file tree items correctly and handle file click', () => { - const handleFileClick = jest - .spyOn(SidebarFunctions, 'handleFileClick') - .mockImplementation(jest.fn()); - - render( - - {SidebarFunctions.renderFileTreeItems( - 'label', - ['file'], - mockDigitalTwinInstance, // Rinominato - setFileName, - setFileContent, - setFileType, - files, - 'create', - )} - , - ); - - expect(screen.getByText('label')).toBeInTheDocument(); - fireEvent.click(screen.getByText('label')); - expect(screen.getByText('file')).toBeInTheDocument(); - fireEvent.click(screen.getByText('file')); - - expect(handleFileClick).toHaveBeenCalled(); - }); - - it('should get filtered files name correctly', () => { - const testFiles: FileState[] = [ - { name: 'file1.md', content: 'content', isNew: false, isModified: false }, - { name: 'file2', content: 'content', isNew: true, isModified: false }, - { name: 'file3', content: 'content', isNew: true, isModified: false }, - ]; - expect( - SidebarFunctions.getFilteredFileNames('lifecycle', testFiles), - ).toEqual(['file2', 'file3']); - }); - - it('should render file section correctly and handle file click', () => { - const handleFileClick = jest - .spyOn(SidebarFunctions, 'handleFileClick') - .mockImplementation(jest.fn()); - - render( - - {SidebarFunctions.renderFileSection( - 'label', - 'type', - ['file'], - mockDigitalTwinInstance, // Rinominato - setFileName, - setFileContent, - setFileType, - files, - 'create', - )} - , - ); - - expect(screen.getByText('label')).toBeInTheDocument(); - fireEvent.click(screen.getByText('label')); - expect(screen.getByText('file')).toBeInTheDocument(); - - fireEvent.click(screen.getByText('file')); - expect(handleFileClick).toHaveBeenCalledWith( - 'file', - mockDigitalTwinInstance, // Rinominato - setFileName, - setFileContent, - setFileType, - files, - 'create', - ); - }); - - it('should not call updateFileState if no new file is found', () => { - const testFiles: FileState[] = [ - { name: 'file1.md', content: 'content', isNew: false, isModified: false }, - ]; - const updateFileStateSpy = jest.spyOn(SidebarFunctions, 'updateFileState'); - - SidebarFunctions.handleCreateFileClick( - 'nonExistentFile', - testFiles, - setFileName, - setFileContent, - setFileType, - ); - - expect(updateFileStateSpy).not.toHaveBeenCalled(); - }); - - it('should set file content error message when fetching fails', async () => { - const mockDigitalTwin: DigitalTwin = { - fileHandler: { - getFileContent: jest.fn().mockRejectedValue(new Error('Fetch error')), - }, - } as unknown as DigitalTwin; - - const fileName = 'testFile.md'; - - await SidebarFunctions.fetchAndSetFileContent( - fileName, - mockDigitalTwin, - setFileName, - setFileContent, - setFileType, - ); - - expect(setFileContent).toHaveBeenCalledWith( - `Error fetching ${fileName} content`, - ); - }); - - it('should not handle file submit if name already exists', () => { - const testFiles = [ - { name: 'file1', content: 'content', isNew: true, isModified: false }, - ]; - SidebarFunctions.handleFileSubmit( - testFiles, - 'file1', - setErrorMessage, - dispatch, - setIsFileNameDialogOpen, - setNewFileName, - ); - expect(setErrorMessage).toHaveBeenCalledWith( - 'A file with this name already exists.', - ); - }); - - it('should not handle file submit if name is empty', () => { - const testFiles = [ - { name: 'file1', content: 'content', isNew: true, isModified: false }, - ]; - SidebarFunctions.handleFileSubmit( - testFiles, - '', - setErrorMessage, - dispatch, - setIsFileNameDialogOpen, - setNewFileName, - ); - expect(setErrorMessage).toHaveBeenCalledWith("File name can't be empty."); - }); - - it('should handle file submit correctly', () => { - const testFiles = [ - { name: 'file1', content: 'content', isNew: true, isModified: false }, - ]; - SidebarFunctions.handleFileSubmit( - testFiles, - 'file2', - setErrorMessage, - dispatch, - setIsFileNameDialogOpen, - setNewFileName, - ); - expect(setErrorMessage).toHaveBeenCalledWith(''); - expect(dispatch).toHaveBeenCalled(); - }); -}); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/sidebarFetchers.test.ts b/client/test/preview/unit/routes/digitaltwins/editor/sidebarFetchers.test.ts new file mode 100644 index 000000000..9ac06f95e --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/sidebarFetchers.test.ts @@ -0,0 +1,125 @@ +import { + mockDigitalTwin, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; +import * as SidebarFetchers from 'preview/route/digitaltwins/editor/sidebarFetchers'; +import * as FileUtils from 'preview/util/fileUtils'; + +describe('sidebarFetchers', () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); + const dispatch = jest.fn(); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should fetch and set file content if library is true', async () => { + const getLibraryFileContentSpy = jest + .spyOn(mockDigitalTwin!.DTAssets, 'getLibraryFileContent') + .mockResolvedValue('fileContent'); + const updateFileStateSpy = jest.spyOn(FileUtils, 'updateFileState'); + + await SidebarFetchers.fetchAndSetFileContent( + 'file1.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + true, + 'assetPath', + ); + + expect(getLibraryFileContentSpy).toHaveBeenCalledTimes(1); + expect(updateFileStateSpy).toHaveBeenCalledTimes(1); + }); + + it('should fetch and set file content if not library', async () => { + const getFileContentSpy = jest + .spyOn(mockDigitalTwin!.DTAssets, 'getFileContent') + .mockResolvedValue('fileContent'); + + await SidebarFetchers.fetchAndSetFileContent( + 'file1.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + + expect(getFileContentSpy).toHaveBeenCalledTimes(1); + }); + + it('should set error message if error occurs while fetching file content', async () => { + jest + .spyOn(mockDigitalTwin!.DTAssets, 'getFileContent') + .mockRejectedValue('error'); + + await SidebarFetchers.fetchAndSetFileContent( + 'file1.md', + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + ); + + expect(setFileContent).toHaveBeenCalledWith( + 'Error fetching file1.md content', + ); + }); + + it('should fetch and set file library content', async () => { + const getFileContentSpy = jest + .spyOn(mockLibraryAsset.libraryManager, 'getFileContent') + .mockResolvedValue('fileContent'); + const updateFileStateSpy = jest.spyOn(FileUtils, 'updateFileState'); + + await SidebarFetchers.fetchAndSetFileLibraryContent( + 'file1.md', + mockLibraryAsset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + false, + setIsLibraryFile, + setLibraryAssetPath, + dispatch, + ); + + expect(getFileContentSpy).toHaveBeenCalledTimes(1); + expect(updateFileStateSpy).toHaveBeenCalledTimes(1); + expect(setIsLibraryFile).toHaveBeenCalledWith(true); + expect(setLibraryAssetPath).toHaveBeenCalledWith(mockLibraryAsset.path); + }); + + it('should set error message if error occurs while fetching file library content', async () => { + jest + .spyOn(mockLibraryAsset.libraryManager, 'getFileContent') + .mockRejectedValue('error'); + + await SidebarFetchers.fetchAndSetFileLibraryContent( + 'file1.md', + mockLibraryAsset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + false, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(setFileContent).toHaveBeenCalledWith( + 'Error fetching file1.md content', + ); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/sidebarFunctions.test.ts b/client/test/preview/unit/routes/digitaltwins/editor/sidebarFunctions.test.ts new file mode 100644 index 000000000..15b17a692 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/sidebarFunctions.test.ts @@ -0,0 +1,296 @@ +import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; +import { FileState } from 'preview/store/file.slice'; +import * as FileUtils from 'preview/util/fileUtils'; +import * as SidebarFetchers from 'preview/route/digitaltwins/editor/sidebarFetchers'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('preview/util/fileUtils'); +jest.mock('preview/route/digitaltwins/editor/sidebarFetchers'); + +describe('SidebarFunctions', () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setLibraryAssetPath = jest.fn(); + const setIsFileNameDialogOpen = jest.fn(); + const setNewFileName = jest.fn(); + const setErrorMessage = jest.fn(); + const dispatch = jest.fn(); + + const files: FileState[] = []; + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should handle file click correctly in create tab', () => { + const tab = 'create'; + const handleCreateFileClick = jest + .spyOn(SidebarFunctions, 'handleCreateFileClick') + .mockImplementation(jest.fn()); + + SidebarFunctions.handleFileClick( + 'file', + null, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(handleCreateFileClick).toHaveBeenCalled(); + }); + + it('should handle file click correctly in reconfigure tab', () => { + const tab = 'reconfigure'; + const handleReconfigureFileClick = jest + .spyOn(SidebarFunctions, 'handleReconfigureFileClick') + .mockImplementation(jest.fn()); + + SidebarFunctions.handleFileClick( + 'file', + null, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + tab, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(handleReconfigureFileClick).toHaveBeenCalled(); + }); + + it('should not call updateFileState if no new file is found - create tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: false, isModified: false }, + ]; + const updateFileStateSpy = jest.spyOn(FileUtils, 'updateFileState'); + + await SidebarFunctions.handleCreateFileClick( + 'nonExistentFile', + null, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(updateFileStateSpy).not.toHaveBeenCalled(); + }); + + it('should call updateFileState if new file is found - create tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: true, isModified: false }, + ]; + + const updateFileStateSpy = jest + .spyOn(FileUtils, 'updateFileState') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleCreateFileClick( + 'file1.md', + null, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(updateFileStateSpy).toHaveBeenCalled(); + }); + + it('should call updateFileState if modified library file is found - create tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: true, isModified: false }, + ]; + + const testLibraryConfigFiles = [ + { + assetPath: 'path', + fileName: 'file1.md', + fileContent: 'content', + isNew: false, + isModified: true, + isPrivate: true, + }, + ]; + + const updateFileStateSpy = jest + .spyOn(FileUtils, 'updateFileState') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleCreateFileClick( + 'file1.md', + mockLibraryAsset, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + undefined, + testLibraryConfigFiles, + ); + + expect(updateFileStateSpy).toHaveBeenCalled(); + }); + + it('should call fetchAndSetFileLibraryContent if new library file is found - create tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: true, isModified: false }, + ]; + + const testLibraryConfigFiles = [ + { + assetPath: 'path', + fileName: 'file1.md', + fileContent: 'content', + isNew: true, + isModified: false, + isPrivate: true, + }, + ]; + + const fetchAndSetFileLibraryContentSpy = jest + .spyOn(SidebarFetchers, 'fetchAndSetFileLibraryContent') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleCreateFileClick( + 'file1.md', + mockLibraryAsset, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + undefined, + testLibraryConfigFiles, + ); + + expect(fetchAndSetFileLibraryContentSpy).toHaveBeenCalled(); + }); + + it('should call updateFileState if new file is found - reconfigure tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: false, isModified: true }, + ]; + + const updateFileStateSpy = jest + .spyOn(FileUtils, 'updateFileState') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleReconfigureFileClick( + 'file1.md', + null, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(updateFileStateSpy).toHaveBeenCalled(); + }); + + it('should call fetchAndSetFileContent if new file is found - reconfigure tab', async () => { + const testFiles: FileState[] = [ + { name: 'file1.md', content: 'content', isNew: false, isModified: false }, + ]; + + const fetchAndSetFileContentSpy = jest + .spyOn(SidebarFetchers, 'fetchAndSetFileContent') + .mockImplementation(jest.fn()); + + await SidebarFunctions.handleReconfigureFileClick( + 'file1.md', + null, + testFiles, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + setIsLibraryFile, + setLibraryAssetPath, + ); + + expect(fetchAndSetFileContentSpy).toHaveBeenCalled(); + }); + + it('should handle add file click correctly', () => { + SidebarFunctions.handleAddFileClick(setIsFileNameDialogOpen); + + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(true); + }); + + it('should handle file submit correctly', () => { + const testFiles = [ + { name: 'file1', content: 'content', isNew: true, isModified: false }, + ]; + SidebarFunctions.handleFileSubmit( + testFiles, + 'file2', + setErrorMessage, + dispatch, + setIsFileNameDialogOpen, + setNewFileName, + ); + + expect(dispatch).toHaveBeenCalled(); + expect(setIsFileNameDialogOpen).toHaveBeenCalledWith(false); + }); + + it('should set error message when file name already exists', () => { + const testFiles = [ + { name: 'file1', content: 'content', isNew: true, isModified: false }, + ]; + SidebarFunctions.handleFileSubmit( + testFiles, + 'file1', + setErrorMessage, + dispatch, + setIsFileNameDialogOpen, + setNewFileName, + ); + + expect(setErrorMessage).toHaveBeenCalledWith( + 'A file with this name already exists.', + ); + }); + + it('should set error message when file name is empty', () => { + const testFiles = [ + { name: 'file1', content: 'content', isNew: true, isModified: false }, + ]; + SidebarFunctions.handleFileSubmit( + testFiles, + '', + setErrorMessage, + dispatch, + setIsFileNameDialogOpen, + setNewFileName, + ); + + expect(setErrorMessage).toHaveBeenCalledWith("File name can't be empty."); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/editor/sidebarRendering.test.tsx b/client/test/preview/unit/routes/digitaltwins/editor/sidebarRendering.test.tsx new file mode 100644 index 000000000..c0aeb0834 --- /dev/null +++ b/client/test/preview/unit/routes/digitaltwins/editor/sidebarRendering.test.tsx @@ -0,0 +1,169 @@ +import * as SidebarRendering from 'preview/route/digitaltwins/editor/sidebarRendering'; +import * as SidebarFunctions from 'preview/route/digitaltwins/editor/sidebarFunctions'; +import { render, screen, fireEvent } from '@testing-library/react'; +import * as React from 'react'; +import { SimpleTreeView } from '@mui/x-tree-view'; +import { + mockDigitalTwin, + mockLibraryAsset, +} from 'test/preview/__mocks__/global_mocks'; +import { FileState } from 'preview/store/file.slice'; + +describe('SidebarRendering', () => { + const setFileName = jest.fn(); + const setFileContent = jest.fn(); + const setFileType = jest.fn(); + const setFilePrivacy = jest.fn(); + const setIsLibraryFile = jest.fn(); + const setIsLibraryAssetPath = jest.fn(); + const dispatch = jest.fn(); + + const files: FileState[] = [ + { + name: 'file', + content: 'content', + type: 'type', + isModified: false, + isNew: true, + }, + ]; + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + it('should render file tree items correctly and handle file click - DigitalTwin', () => { + const handleFileClick = jest + .spyOn(SidebarFunctions, 'handleFileClick') + .mockImplementation(jest.fn()); + + render( + + {SidebarRendering.renderFileTreeItems( + 'label', + ['file'], + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + 'create', + dispatch, + setIsLibraryFile, + setIsLibraryAssetPath, + )} + , + ); + + expect(screen.getByText('label')).toBeInTheDocument(); + fireEvent.click(screen.getByText('label')); + expect(screen.getByText('file')).toBeInTheDocument(); + fireEvent.click(screen.getByText('file')); + + expect(handleFileClick).toHaveBeenCalled(); + }); + + it('should render file tree items correctly and handle file click - LibraryAsset', () => { + const handleFileClick = jest + .spyOn(SidebarFunctions, 'handleFileClick') + .mockImplementation(jest.fn()); + + mockLibraryAsset.isPrivate = false; + + render( + + {SidebarRendering.renderFileTreeItems( + 'label', + ['file'], + mockLibraryAsset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + 'create', + dispatch, + setIsLibraryFile, + setIsLibraryAssetPath, + )} + , + ); + + expect(screen.getByText('label')).toBeInTheDocument(); + fireEvent.click(screen.getByText('label')); + expect(screen.getByText('file')).toBeInTheDocument(); + fireEvent.click(screen.getByText('file')); + + expect(handleFileClick).toHaveBeenCalled(); + }); + + it('should render file section correctly and handle file click - LibraryAsset', () => { + const handleFileClick = jest + .spyOn(SidebarFunctions, 'handleFileClick') + .mockImplementation(jest.fn()); + + mockLibraryAsset.isPrivate = false; + + render( + + {SidebarRendering.renderFileSection( + 'label', + 'Digital Twins', + ['file'], + mockLibraryAsset, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + 'create', + dispatch, + setIsLibraryFile, + setIsLibraryAssetPath, + )} + , + ); + + expect(screen.getByText('label')).toBeInTheDocument(); + fireEvent.click(screen.getByText('label')); + expect(screen.getByText('file')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('file')); + expect(handleFileClick).toHaveBeenCalled(); + }); + + it('should render file section correctly and handle file click - DigitalTwin', () => { + const handleFileClick = jest + .spyOn(SidebarFunctions, 'handleFileClick') + .mockImplementation(jest.fn()); + + render( + + {SidebarRendering.renderFileSection( + 'label', + 'Digital Twins', + ['file'], + mockDigitalTwin, + setFileName, + setFileContent, + setFileType, + setFilePrivacy, + files, + 'create', + dispatch, + setIsLibraryFile, + setIsLibraryAssetPath, + )} + , + ); + + expect(screen.getByText('label')).toBeInTheDocument(); + fireEvent.click(screen.getByText('label')); + expect(screen.getByText('file')).toBeInTheDocument(); + + fireEvent.click(screen.getByText('file')); + expect(handleFileClick).toHaveBeenCalled(); + }); +}); diff --git a/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx b/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx index e89f660dc..236086e59 100644 --- a/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/manage/ConfigDialog.test.tsx @@ -14,6 +14,7 @@ import { showSnackbar } from 'preview/store/snackbar.slice'; import { mockDigitalTwin } from 'test/preview/__mocks__/global_mocks'; import { selectDigitalTwinByName } from 'preview/store/digitalTwin.slice'; import { selectModifiedFiles } from 'preview/store/file.slice'; +import { selectModifiedLibraryFiles } from 'preview/store/libraryConfigFiles.slice'; jest.mock('preview/store/file.slice', () => ({ ...jest.requireActual('preview/store/file.slice'), @@ -76,6 +77,16 @@ describe('ReconfigureDialog', () => { }, ].filter((file) => !file.isNew); } + if (selector === selectModifiedLibraryFiles) { + return [ + { + name: 'libraryFile.md', + content: 'Updated library file', + isNew: false, + isModified: true, + }, + ]; + } return mockDigitalTwin; }, ); @@ -214,7 +225,7 @@ describe('ReconfigureDialog', () => { }); await waitFor(() => { - expect(handleFileUpdateSpy).toHaveBeenCalledTimes(2); + expect(handleFileUpdateSpy).toHaveBeenCalledTimes(3); }); }); diff --git a/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx b/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx index 0cac83487..ef53dd17d 100644 --- a/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx +++ b/client/test/preview/unit/routes/digitaltwins/manage/DetailsDialog.test.tsx @@ -20,6 +20,7 @@ describe('DetailsDialog', () => { showDialog={true} setShowDialog={setShowDialog} name="name" + isPrivate={true} />, ); @@ -32,6 +33,7 @@ describe('DetailsDialog', () => { showDialog={true} setShowDialog={setShowDialog} name="name" + isPrivate={true} />, ); diff --git a/client/test/preview/unit/store/CartAccess.test.ts b/client/test/preview/unit/store/CartAccess.test.ts new file mode 100644 index 000000000..ebee7d337 --- /dev/null +++ b/client/test/preview/unit/store/CartAccess.test.ts @@ -0,0 +1,55 @@ +import { renderHook, act } from '@testing-library/react'; +import { useDispatch, useSelector } from 'react-redux'; +import useCart from 'preview/store/CartAccess'; +import * as cart from 'preview/store/cart.slice'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('react-redux', () => ({ + useDispatch: jest.fn(), + useSelector: jest.fn(), +})); + +jest.mock('preview/store/cart.slice', () => ({ + addToCart: jest.fn(), + removeFromCart: jest.fn(), + clearCart: jest.fn(), +})); + +describe('useCart', () => { + const dispatch = jest.fn(); + const mockState = { items: [] }; + + beforeEach(() => { + (useDispatch as unknown as jest.Mock).mockReturnValue(dispatch); + (useSelector as unknown as jest.Mock).mockImplementation((selector) => + selector({ cart: mockState }), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should return the cart state', () => { + const { result } = renderHook(() => useCart()); + expect(result.current.state).toEqual(mockState); + }); + + it('should dispatch addToCart action', () => { + const { result } = renderHook(() => useCart()); + const asset = mockLibraryAsset; + act(() => { + result.current.actions.add(asset); + }); + expect(dispatch).toHaveBeenCalledWith(cart.addToCart(asset)); + }); + + it('should dispatch removeFromCart action', () => { + const { result } = renderHook(() => useCart()); + const asset = mockLibraryAsset; + act(() => { + result.current.actions.remove(asset); + }); + expect(dispatch).toHaveBeenCalledWith(cart.removeFromCart(asset)); + }); +}); diff --git a/client/test/preview/unit/Store.test.ts b/client/test/preview/unit/store/Store.test.ts similarity index 59% rename from client/test/preview/unit/Store.test.ts rename to client/test/preview/unit/store/Store.test.ts index 4c7ecdec0..47c0c4bac 100644 --- a/client/test/preview/unit/Store.test.ts +++ b/client/test/preview/unit/store/Store.test.ts @@ -6,15 +6,12 @@ import assetsSlice, { } from 'preview/store/assets.slice'; import digitalTwinReducer, { setDigitalTwin, - setJobLogs, setPipelineCompleted, setPipelineLoading, updateDescription, } from 'preview/store/digitalTwin.slice'; import DigitalTwin from 'preview/util/digitalTwin'; import GitlabInstance from 'preview/util/gitlab'; -import { JobLog } from 'preview/components/asset/StartStopButton'; -import { Asset } from 'preview/components/asset/Asset'; import snackbarSlice, { hideSnackbar, showSnackbar, @@ -28,11 +25,23 @@ import fileSlice, { removeAllModifiedFiles, renameFile, } from 'preview/store/file.slice'; +import LibraryAsset from 'preview/util/libraryAsset'; +import { mockLibraryAsset } from 'test/preview/__mocks__/global_mocks'; +import cartSlice, { + addToCart, + clearCart, + removeFromCart, +} from 'preview/store/cart.slice'; +import libraryFilesSlice, { + LibraryConfigFile, + addOrUpdateLibraryFile, + removeAllModifiedLibraryFiles, +} from 'preview/store/libraryConfigFiles.slice'; describe('reducers', () => { let initialState: { assets: { - items: Asset[]; + items: LibraryAsset[]; }; digitalTwin: { [key: string]: DigitalTwin; @@ -48,6 +57,15 @@ describe('reducers', () => { isNew: boolean; isModified: boolean; }[]; + cart: { + assets: LibraryAsset[]; + }; + libraryConfigFiles: { + name: string; + content: string; + isNew: boolean; + isModified: boolean; + }[]; }; beforeEach(() => { @@ -60,15 +78,13 @@ describe('reducers', () => { severity: 'info', }, files: [], + cart: { assets: [] }, + libraryConfigFiles: [], }; }); describe('assets reducer', () => { - const asset1 = { - name: 'asset1', - description: 'description', - path: 'path', - }; + const asset1 = mockLibraryAsset; it('should handle setAssets', () => { const newState = assetsSlice(initialState.assets, setAssets([asset1])); @@ -90,57 +106,87 @@ describe('reducers', () => { new GitlabInstance('user1', 'authority', 'token1'), ); - it('digitalTwinReducer should return the initial digitalTwin state when an unknown action type is passed with an undefined state', () => { + const initialState = { + digitalTwin: {}, + shouldFetchDigitalTwins: true, + }; + + it('should return the initial state when an unknown action is passed with an undefined state', () => { expect(digitalTwinReducer(undefined, { type: 'unknown' })).toEqual( - initialState.digitalTwin, + initialState, ); }); it('should handle setDigitalTwin', () => { const newState = digitalTwinReducer( - initialState.digitalTwin, + initialState, setDigitalTwin({ assetName: 'asset1', digitalTwin }), ); - expect(newState.asset1).toEqual(digitalTwin); + expect(newState.digitalTwin.asset1).toEqual(digitalTwin); }); - it('should handle setJobLogs', () => { - const jobLogs: JobLog[] = [{ jobName: 'job1', log: 'log' }]; - digitalTwin.jobLogs = jobLogs; - initialState.digitalTwin.asset1 = digitalTwin; - const newState = digitalTwinReducer( - initialState.digitalTwin, - setJobLogs({ assetName: 'asset1', jobLogs }), + it('should handle setPipelineCompleted', () => { + const updatedDigitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), ); - expect(newState.asset1.jobLogs).toEqual(jobLogs); - }); + updatedDigitalTwin.pipelineCompleted = false; + + const updatedState = { + digitalTwin: { + asset1: updatedDigitalTwin, + }, + shouldFetchDigitalTwins: true, + }; - it('should handle setPipelineCompleted', () => { - initialState.digitalTwin.asset1 = digitalTwin; const newState = digitalTwinReducer( - initialState.digitalTwin, + updatedState, setPipelineCompleted({ assetName: 'asset1', pipelineCompleted: true }), ); - expect(newState.asset1.pipelineCompleted).toBe(true); + + expect(newState.digitalTwin.asset1.pipelineCompleted).toBe(true); }); it('should handle setPipelineLoading', () => { - initialState.digitalTwin.asset1 = digitalTwin; + const updatedDigitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), + ); + updatedDigitalTwin.pipelineLoading = false; + + const updatedState = { + ...initialState, + digitalTwin: { asset1: updatedDigitalTwin }, + }; + const newState = digitalTwinReducer( - initialState.digitalTwin, + updatedState, setPipelineLoading({ assetName: 'asset1', pipelineLoading: true }), ); - expect(newState.asset1.pipelineLoading).toBe(true); + + expect(newState.digitalTwin.asset1.pipelineLoading).toBe(true); }); it('should handle updateDescription', () => { - initialState.digitalTwin.asset1 = digitalTwin; + const updatedDigitalTwin = new DigitalTwin( + 'asset1', + new GitlabInstance('user1', 'authority', 'token1'), + ); + updatedDigitalTwin.description = ''; + + const updatedState = { + ...initialState, + digitalTwin: { asset1: updatedDigitalTwin }, + }; + const description = 'new description'; + const newState = digitalTwinReducer( - initialState.digitalTwin, + updatedState, updateDescription({ assetName: 'asset1', description }), ); - expect(newState.asset1.description).toBe(description); + + expect(newState.digitalTwin.asset1.description).toBe(description); }); }); @@ -298,4 +344,104 @@ describe('reducers', () => { expect(newState).toEqual([]); }); }); + + describe('cart reducer', () => { + const asset1 = mockLibraryAsset; + const asset2 = { ...mockLibraryAsset, path: 'path2' }; + + it('should handle addToCart', () => { + const newState = cartSlice(initialState.cart, addToCart(asset1)); + expect(newState.assets).toEqual([asset1]); + }); + + it('should not add duplicate assets to cart', () => { + initialState.cart.assets = [asset1]; + const newState = cartSlice(initialState.cart, addToCart(asset1)); + expect(newState.assets).toEqual([asset1]); + }); + + it('should handle removeFromCart', () => { + initialState.cart.assets = [asset1, asset2]; + const newState = cartSlice(initialState.cart, removeFromCart(asset1)); + expect(newState.assets).toEqual([asset2]); + }); + + it('should handle clearCart', () => { + initialState.cart.assets = [asset1, asset2]; + const newState = cartSlice(initialState.cart, clearCart()); + expect(newState.assets).toEqual([]); + }); + }); + + describe('libraryFilesSlice', () => { + const initialState: LibraryConfigFile[] = []; + + it('should handle initial state', () => { + expect(libraryFilesSlice(undefined, { type: 'unknown' })).toEqual( + initialState, + ); + }); + + it('should handle addOrUpdateLibraryFile', () => { + const newFile: LibraryConfigFile = { + assetPath: 'path1', + fileName: 'file1', + fileContent: 'content1', + isNew: true, + isModified: false, + isPrivate: false, + }; + + const updatedFile: LibraryConfigFile = { + ...newFile, + fileContent: 'updated content', + isModified: true, + }; + + let state = libraryFilesSlice( + initialState, + addOrUpdateLibraryFile(newFile), + ); + expect(state).toEqual([newFile]); + + state = libraryFilesSlice(state, addOrUpdateLibraryFile(updatedFile)); + expect(state).toEqual([updatedFile]); + }); + + it('should handle removeAllModifiedLibraryFiles', () => { + const stateWithFiles: LibraryConfigFile[] = [ + { + assetPath: 'path1', + fileName: 'file1', + fileContent: 'content1', + isNew: false, + isModified: true, + isPrivate: false, + }, + { + assetPath: 'path2', + fileName: 'file2', + fileContent: 'content2', + isNew: true, + isModified: false, + isPrivate: false, + }, + ]; + + const state = libraryFilesSlice( + stateWithFiles, + removeAllModifiedLibraryFiles(), + ); + expect(state).toEqual([ + { + assetPath: 'path2', + fileName: 'file2', + fileContent: 'content2', + isNew: true, + isModified: false, + isPrivate: false, + }, + ]); + }); + }); }); diff --git a/client/test/preview/unit/util/DTAssets.test.ts b/client/test/preview/unit/util/DTAssets.test.ts index 558b8bded..169410335 100644 --- a/client/test/preview/unit/util/DTAssets.test.ts +++ b/client/test/preview/unit/util/DTAssets.test.ts @@ -41,7 +41,7 @@ describe('DTAssets', () => { content: 'content', isNew: true, isModified: false, - type: 'description', + type: 'digital twin', }, { name: 'file2', diff --git a/client/test/preview/unit/util/digitalTwin.test.ts b/client/test/preview/unit/util/digitalTwin.test.ts index 2009931a5..ac0bb0997 100644 --- a/client/test/preview/unit/util/digitalTwin.test.ts +++ b/client/test/preview/unit/util/digitalTwin.test.ts @@ -268,7 +268,7 @@ describe('DigitalTwin', () => { }); it('should create digital twin with files', async () => { - const result = await dt.create(files); + const result = await dt.create(files, [], []); expect(result).toBe( 'test-DTName digital twin files initialized successfully.', @@ -280,7 +280,7 @@ describe('DigitalTwin', () => { new Error('Create failed'), ); - const result = await dt.create(files); + const result = await dt.create(files, [], []); expect(result).toBe( 'Error initializing test-DTName digital twin files: Error: Create failed', @@ -290,7 +290,7 @@ describe('DigitalTwin', () => { it('should return error message when projectId is missing during creation', async () => { dt.gitlabInstance.projectId = null; - const result = await dt.create(files); + const result = await dt.create(files, [], []); expect(result).toBe( 'Error creating test-DTName digital twin: no project id', diff --git a/client/test/preview/unit/util/fileUtils.test.ts b/client/test/preview/unit/util/fileUtils.test.ts index 8b2035950..855c82478 100644 --- a/client/test/preview/unit/util/fileUtils.test.ts +++ b/client/test/preview/unit/util/fileUtils.test.ts @@ -1,6 +1,9 @@ +import { LibraryConfigFile } from 'preview/store/libraryConfigFiles.slice'; import * as fileUtils from 'preview/util/fileUtils'; describe('FileUtils', () => { + const libraryFiles: LibraryConfigFile[] = []; + it('should return true if some files are empty', () => { const files = [ { name: 'file1', content: '', isNew: true, isModified: false }, @@ -9,11 +12,15 @@ describe('FileUtils', () => { const setErrorMessage = jest.fn(); - const result = fileUtils.validateFiles(files, setErrorMessage); + const result = fileUtils.validateFiles( + files, + libraryFiles, + setErrorMessage, + ); expect(result).toBe(true); expect(setErrorMessage).toHaveBeenCalledWith( - 'The following files have empty content: file1. Edit them in order to create the new digital twin.', + 'The following files have empty content: file1.\n Edit them in order to create the new digital twin.', ); }); @@ -25,7 +32,11 @@ describe('FileUtils', () => { const setErrorMessage = jest.fn(); - const result = fileUtils.validateFiles(files, setErrorMessage); + const result = fileUtils.validateFiles( + files, + libraryFiles, + setErrorMessage, + ); expect(result).toBe(false); expect(setErrorMessage).not.toHaveBeenCalled(); diff --git a/client/test/preview/unit/util/gitlab.test.ts b/client/test/preview/unit/util/gitlab.test.ts index b66d5c43d..0eb5c66a5 100644 --- a/client/test/preview/unit/util/gitlab.test.ts +++ b/client/test/preview/unit/util/gitlab.test.ts @@ -95,16 +95,7 @@ describe('GitlabInstance', () => { const subfolders = await gitlab.getDTSubfolders(projectId); expect(subfolders).toHaveLength(2); - expect(subfolders).toEqual([ - { - name: 'subfolder1', - path: 'digital_twins/subfolder1', - }, - { - name: 'subfolder2', - path: 'digital_twins/subfolder2', - }, - ]); + expect(mockApi.Repositories.allRepositoryTrees).toHaveBeenCalledWith( projectId, { diff --git a/client/test/preview/unit/util/init.test.ts b/client/test/preview/unit/util/init.test.ts index 02acb61c4..2518cee19 100644 --- a/client/test/preview/unit/util/init.test.ts +++ b/client/test/preview/unit/util/init.test.ts @@ -1,15 +1,17 @@ -import { fetchAssets } from 'preview/util/init'; -import { setAssets } from 'preview/store/assets.slice'; -import { setDigitalTwin } from 'preview/store/digitalTwin.slice'; +import { fetchLibraryAssets } from 'preview/util/init'; import { - mockDigitalTwin, mockGitlabInstance, + mockLibraryAsset, } from 'test/preview/__mocks__/global_mocks'; +jest.mock('preview/util/libraryAsset', () => ({ + default: jest.fn().mockImplementation(() => mockLibraryAsset), +})); + jest.mock('preview/util/gitlab', () => { const mockSimpleGitlabInstance = { init: jest.fn(), - getDTSubfolders: jest.fn(), + getLibrarySubfolders: jest.fn(), projectId: 1, }; @@ -18,16 +20,10 @@ jest.mock('preview/util/gitlab', () => { }; }); -jest.mock('preview/util/digitalTwin', () => ({ - default: jest.fn().mockImplementation(() => mockDigitalTwin), -})); - jest.mock('preview/store/assets.slice', () => ({ + setAsset: jest.fn(), setAssets: jest.fn(), })); -jest.mock('preview/store/digitalTwin.slice', () => ({ - setDigitalTwin: jest.fn(), -})); describe('fetchAssets', () => { const dispatch = jest.fn(); @@ -37,39 +33,12 @@ describe('fetchAssets', () => { jest.clearAllMocks(); }); - it('should fetch assets and create digital twins', async () => { + it('should fetch library assets and set them', async () => { (mockGitlabInstance.init as jest.Mock).mockResolvedValue({}); - (mockGitlabInstance.getDTSubfolders as jest.Mock).mockResolvedValue([ - { name: 'asset1', path: 'path1' }, + (mockGitlabInstance.getLibrarySubfolders as jest.Mock).mockResolvedValue([ + { name: 'asset1', path: 'path1', type: 'models', isPrivate: false }, ]); - await fetchAssets(dispatch, setError); - - expect(dispatch).toHaveBeenCalledWith( - setAssets([{ name: 'asset1', path: 'path1' }]), - ); - expect(dispatch).toHaveBeenCalledWith( - setDigitalTwin({ - assetName: 'asset1', - digitalTwin: mockDigitalTwin, - }), - ); - }); - - it('should handle empty project ID by setting assets to an empty array', async () => { - mockGitlabInstance.projectId = null; - - await fetchAssets(dispatch, setError); - - expect(dispatch).toHaveBeenCalledWith(setAssets([])); - }); - - it('should skip digital twin creation if no assets are found', async () => { - (mockGitlabInstance.init as jest.Mock).mockResolvedValue({}); - (mockGitlabInstance.getDTSubfolders as jest.Mock).mockResolvedValue([]); - - await fetchAssets(dispatch, setError); - - expect(dispatch).toHaveBeenCalledWith(setAssets([])); + await fetchLibraryAssets(dispatch, setError, 'models', true); }); }); diff --git a/client/test/preview/unit/util/libraryAsset.test.ts b/client/test/preview/unit/util/libraryAsset.test.ts new file mode 100644 index 000000000..355981f2a --- /dev/null +++ b/client/test/preview/unit/util/libraryAsset.test.ts @@ -0,0 +1,74 @@ +import LibraryAsset from 'preview/util/libraryAsset'; +import GitlabInstance from 'preview/util/gitlab'; +import LibraryManager from 'preview/util/libraryManager'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('preview/util/libraryManager'); +jest.mock('preview/util/gitlab'); + +describe('LibraryAsset', () => { + let gitlabInstance: GitlabInstance; + let libraryManager: LibraryManager; + let libraryAsset: LibraryAsset; + + beforeEach(() => { + gitlabInstance = mockGitlabInstance; + libraryManager = new LibraryManager('test', gitlabInstance); + libraryAsset = new LibraryAsset( + 'test', + 'path/to/library', + true, + 'type', + gitlabInstance, + ); + libraryAsset.libraryManager = libraryManager; + }); + + it('should initialize correctly', () => { + expect(libraryAsset.name).toBe('test'); + expect(libraryAsset.path).toBe('path/to/library'); + expect(libraryAsset.isPrivate).toBe(true); + expect(libraryAsset.type).toBe('type'); + expect(libraryAsset.gitlabInstance).toBe(gitlabInstance); + expect(libraryAsset.libraryManager).toBe(libraryManager); + }); + + it('should get description', async () => { + libraryManager.getFileContent = jest.fn().mockResolvedValue('File content'); + await libraryAsset.getDescription(); + expect(libraryAsset.description).toBe('File content'); + }); + + it('should handle error when getting description', async () => { + libraryManager.getFileContent = jest + .fn() + .mockRejectedValue(new Error('Error')); + await libraryAsset.getDescription(); + expect(libraryAsset.description).toBe('There is no description.md file'); + }); + + it('should get full description with image URLs replaced', async () => { + const fileContent = '![alt text](image.png)'; + libraryManager.getFileContent = jest.fn().mockResolvedValue(fileContent); + sessionStorage.setItem('username', 'user'); + await libraryAsset.getFullDescription(); + expect(libraryAsset.fullDescription).toBe( + '![alt text](https://example.com/AUTHORITY/dtaas/user/-/raw/main/path/to/library/image.png)', + ); + }); + + it('should handle error when getting full description', async () => { + libraryManager.getFileContent = jest + .fn() + .mockRejectedValue(new Error('Error')); + await libraryAsset.getFullDescription(); + expect(libraryAsset.fullDescription).toBe('There is no README.md file'); + }); + + it('should get config files', async () => { + const fileNames = ['file1', 'file2']; + libraryManager.getFileNames = jest.fn().mockResolvedValue(fileNames); + await libraryAsset.getConfigFiles(); + expect(libraryAsset.configFiles).toEqual(fileNames); + }); +}); diff --git a/client/test/preview/unit/util/libraryManager.test.ts b/client/test/preview/unit/util/libraryManager.test.ts new file mode 100644 index 000000000..496020661 --- /dev/null +++ b/client/test/preview/unit/util/libraryManager.test.ts @@ -0,0 +1,74 @@ +import LibraryManager, { + getFilePath, + FileType, +} from 'preview/util/libraryManager'; +import GitlabInstance from 'preview/util/gitlab'; +import FileHandler from 'preview/util/fileHandler'; +import { FileState } from 'preview/store/file.slice'; +import { mockGitlabInstance } from 'test/preview/__mocks__/global_mocks'; + +jest.mock('preview/util/fileHandler'); +jest.mock('preview/util/gitlab'); + +describe('LibraryManager', () => { + let gitlabInstance: GitlabInstance; + let fileHandler: FileHandler; + let libraryManager: LibraryManager; + + beforeEach(() => { + gitlabInstance = mockGitlabInstance; + fileHandler = new FileHandler('testAsset', gitlabInstance); + libraryManager = new LibraryManager('testAsset', gitlabInstance); + libraryManager.fileHandler = fileHandler; + }); + + it('should initialize correctly', () => { + expect(libraryManager.assetName).toBe('testAsset'); + expect(libraryManager.gitlabInstance).toBe(gitlabInstance); + expect(libraryManager.fileHandler).toBe(fileHandler); + }); + + it('should get file content', async () => { + const fileContent = 'file content'; + fileHandler.getFileContent = jest.fn().mockResolvedValue(fileContent); + + const result = await libraryManager.getFileContent( + true, + 'path/to/file', + 'file.txt', + ); + expect(result).toBe(fileContent); + expect(fileHandler.getFileContent).toHaveBeenCalledWith( + 'path/to/file/file.txt', + true, + ); + }); + + it('should get file names', async () => { + const fileNames = ['file1', 'file2']; + fileHandler.getLibraryConfigFileNames = jest + .fn() + .mockResolvedValue(fileNames); + + const result = await libraryManager.getFileNames(true, 'path/to/files'); + expect(result).toEqual(fileNames); + expect(fileHandler.getLibraryConfigFileNames).toHaveBeenCalledWith( + 'path/to/files', + true, + ); + }); +}); + +describe('getFilePath', () => { + it('should return lifecycle folder path for lifecycle file type', () => { + const file: FileState = { type: FileType.LIFECYCLE } as FileState; + const result = getFilePath(file, 'main/path', 'lifecycle/path'); + expect(result).toBe('lifecycle/path'); + }); + + it('should return main folder path for non-lifecycle file type', () => { + const file: FileState = { type: FileType.DESCRIPTION } as FileState; + const result = getFilePath(file, 'main/path', 'lifecycle/path'); + expect(result).toBe('main/path'); + }); +}); diff --git a/client/test/unit/jest.setup.ts b/client/test/unit/jest.setup.ts index 53953aa4b..6e522fe11 100644 --- a/client/test/unit/jest.setup.ts +++ b/client/test/unit/jest.setup.ts @@ -7,3 +7,22 @@ import 'test/__mocks__/unit/module_mocks'; beforeEach(() => { jest.resetAllMocks(); }); + +window.env = { + ...global.window.env, + REACT_APP_AUTH_AUTHORITY: 'https://example.com', + REACT_APP_ENVIRONMENT: 'test', + REACT_APP_URL: 'https://example.com', + REACT_APP_URL_BASENAME: 'mock_url_basename', + REACT_APP_URL_DTLINK: '/lab', + REACT_APP_URL_LIBLINK: '', + REACT_APP_WORKBENCHLINK_VNCDESKTOP: '/tools/vnc/?password=vncpassword', + REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', + REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', + REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + + REACT_APP_CLIENT_ID: 'abc123', + REACT_APP_REDIRECT_URI: 'https://example.com', + REACT_APP_LOGOUT_REDIRECT_URI: 'https://example.com', + REACT_APP_GITLAB_SCOPES: 'openid profile read_user read_repository api', +}; diff --git a/client/test/unit/routes/SignIn.test.tsx b/client/test/unit/routes/SignIn.test.tsx index 3ab03ab3b..d5fdbe380 100644 --- a/client/test/unit/routes/SignIn.test.tsx +++ b/client/test/unit/routes/SignIn.test.tsx @@ -1,8 +1,15 @@ import * as React from 'react'; -import { render, screen, fireEvent } from '@testing-library/react'; +import { + render, + screen, + fireEvent, + waitFor, + act, +} from '@testing-library/react'; import { MemoryRouter } from 'react-router-dom'; import SignIn from 'route/auth/Signin'; import { useAuth } from 'react-oidc-context'; +import { getValidationResults } from 'route/auth/VerifyConfig'; jest.unmock('route/auth/Signin'); jest.mock('react-oidc-context'); @@ -20,25 +27,91 @@ describe('SignIn', () => { jest.clearAllMocks(); }); - it('renders the SignIn button', () => { - render( - - - , + it('renders config loading', async () => { + // Create a promise that won't resolve immediately to simulate loading state + let resolveValidation: (value: unknown) => void; + const validationPromise = new Promise((resolve) => { + resolveValidation = resolve; + }); + + (getValidationResults as jest.Mock).mockReturnValue(validationPromise); + + const renderResult = await act(async () => + render( + + + , + ), ); expect( - screen.getByRole('button', { name: /Sign In With GitLab/i }), + renderResult.getByText('Verifying configuration'), ).toBeInTheDocument(); + expect(renderResult.getByRole('progressbar')).toBeInTheDocument(); + + // Resolve the promise to allow the component to complete loading + await act(async () => { + resolveValidation({ config: 'loading' }); + }); }); - it('handles button click', () => { - render( - - - , + it('renders the SignIn button', async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ button: 'test' }), ); + await act(async () => { + render( + + + , + ); + }); + await waitFor(() => + screen.getByRole('button', { name: /Sign In With GitLab/i }), + ); + expect( + screen.getByRole('button', { name: /Sign In With GitLab/i }), + ).toBeInTheDocument(); + }); + + it('renders the config problems', async () => { + const res = { + url: { + error: + 'An error occurred when fetching https://example.com: ReferenceError: fetch is not defined', + status: undefined, + value: 'https://example.com', + }, + }; + (getValidationResults as jest.Mock).mockReturnValue(Promise.resolve(res)); + + await act(async () => { + render( + + + , + ); + }); + await waitFor(() => { + expect(screen.getByText(/Config validation failed/i)).toBeInTheDocument(); + }); + }); + + it('handles button click', async () => { + (getValidationResults as jest.Mock).mockReturnValue( + Promise.resolve({ button: 'click' }), + ); + await act(async () => { + render( + + + , + ); + }); + await waitFor(() => + screen.getByRole('button', { name: /Sign In With GitLab/i }), + ); const signInButton = screen.getByRole('button', { name: /Sign In With GitLab/i, }); diff --git a/client/test/unit/util/envUtil.test.ts b/client/test/unit/util/envUtil.test.ts index 0cd72dcae..96657d92b 100644 --- a/client/test/unit/util/envUtil.test.ts +++ b/client/test/unit/util/envUtil.test.ts @@ -32,6 +32,7 @@ describe('envUtil', () => { REACT_APP_WORKBENCHLINK_VSCODE: testWorkbenchEndpoints[1], REACT_APP_WORKBENCHLINK_JUPYTERLAB: testWorkbenchEndpoints[2], REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: testWorkbenchEndpoints[3], + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: testWorkbenchEndpoints[4], REACT_APP_WORKBENCHLINK_DT_PREVIEW: testWorkbenchEndpoints[4], REACT_APP_CLIENT_ID: testAppID, diff --git a/client/yarn.lock b/client/yarn.lock index c106a6d14..0bdbd7bb7 100644 --- a/client/yarn.lock +++ b/client/yarn.lock @@ -2403,6 +2403,14 @@ lodash "^4.17.21" redent "^3.0.0" +"@testing-library/react-hooks@^8.0.1": + version "8.0.1" + resolved "https://registry.yarnpkg.com/@testing-library/react-hooks/-/react-hooks-8.0.1.tgz#0924bbd5b55e0c0c0502d1754657ada66947ca12" + integrity sha512-Aqhl2IVmLt8IovEVarNDFuJDVWVvhnr9/GCU6UUnrYXwgDFF9h2L2o2P9KBni1AST5sT6riAyoukFLyjQUgD/g== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-boundary "^3.1.0" + "@testing-library/react@16.0.1": version "16.0.1" resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-16.0.1.tgz#29c0ee878d672703f5e7579f239005e4e0faa875" @@ -4360,6 +4368,13 @@ cross-env@^7.0.3: dependencies: cross-spawn "^7.0.1" +cross-fetch@^3.0.4: + version "3.1.8" + resolved "https://registry.yarnpkg.com/cross-fetch/-/cross-fetch-3.1.8.tgz#0327eba65fd68a7d119f8fb2bf9334a1a7956f82" + integrity sha512-cvA+JwZoU0Xq+h6WkMvAUqPEYy92Obet6UdKLfW60qn99ftItKjB5T+BkyWOFWe2pUyfQ+IJHmpOTznqk1M6Kg== + dependencies: + node-fetch "^2.6.12" + cross-spawn@^7.0.0, cross-spawn@^7.0.1, cross-spawn@^7.0.2, cross-spawn@^7.0.3: version "7.0.3" resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-7.0.3.tgz#f73a85b9d5d41d045551c177e2882d4ac85728a6" @@ -5326,6 +5341,11 @@ eslint-plugin-react-hooks@^4.3.0: resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-4.6.2.tgz#c829eb06c0e6f484b3fbb85a97e57784f328c596" integrity sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ== +eslint-plugin-react-hooks@^5.1.0: + version "5.1.0" + resolved "https://registry.yarnpkg.com/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-5.1.0.tgz#3d34e37d5770866c34b87d5b499f5f0b53bf0854" + integrity sha512-mpJRtPgHN2tNAvZ35AMfqeB3Xqeo273QxrHJsbBEPWODRM4r0yB6jfoROqKEYrOn27UtRPpcpHc2UqyBSuUNTw== + eslint-plugin-react@^7.27.1, eslint-plugin-react@^7.33.2: version "7.37.2" resolved "https://registry.yarnpkg.com/eslint-plugin-react/-/eslint-plugin-react-7.37.2.tgz#cd0935987876ba2900df2f58339f6d92305acc7a" @@ -7068,6 +7088,14 @@ jest-environment-node@^29.7.0: jest-mock "^29.7.0" jest-util "^29.7.0" +jest-fetch-mock@^3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/jest-fetch-mock/-/jest-fetch-mock-3.0.3.tgz#31749c456ae27b8919d69824f1c2bd85fe0a1f3b" + integrity sha512-Ux1nWprtLrdrH4XwE7O7InRY6psIi3GOsqNESJgMJ+M5cv4A8Lh7SN9d2V2kKRZ8ebAfcd1LNyZguAOb6JiDqw== + dependencies: + cross-fetch "^3.0.4" + promise-polyfill "^8.1.3" + jest-get-type@^27.5.1: version "27.5.1" resolved "https://registry.yarnpkg.com/jest-get-type/-/jest-get-type-27.5.1.tgz#3cd613c507b0f7ace013df407a1c1cd578bcb4f1" @@ -8316,6 +8344,13 @@ no-case@^3.0.4: lower-case "^2.0.2" tslib "^2.0.3" +node-fetch@^2.6.12: + version "2.7.0" + resolved "https://registry.yarnpkg.com/node-fetch/-/node-fetch-2.7.0.tgz#d0f0fa6e3e2dc1d27efcd8ad99d550bda94d187d" + integrity sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A== + dependencies: + whatwg-url "^5.0.0" + node-forge@^1: version "1.3.1" resolved "https://registry.yarnpkg.com/node-forge/-/node-forge-1.3.1.tgz#be8da2af243b2417d5f646a770663a92b7e9ded3" @@ -9391,6 +9426,11 @@ process-nextick-args@~2.0.0: resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== +promise-polyfill@^8.1.3: + version "8.3.0" + resolved "https://registry.yarnpkg.com/promise-polyfill/-/promise-polyfill-8.3.0.tgz#9284810268138d103807b11f4e23d5e945a4db63" + integrity sha512-H5oELycFml5yto/atYqmjyigJoAo3+OXwolYiH7OfQuYlAqhxNvTfiNMbV9hsC6Yp83yE5r2KTVmtrG6R9i6Pg== + promise@^8.1.0: version "8.3.0" resolved "https://registry.yarnpkg.com/promise/-/promise-8.3.0.tgz#8cb333d1edeb61ef23869fbb8a4ea0279ab60e0a" @@ -9566,6 +9606,13 @@ react-dom@^18.2.0: loose-envify "^1.1.0" scheduler "^0.23.2" +react-error-boundary@^3.1.0: + version "3.1.4" + resolved "https://registry.yarnpkg.com/react-error-boundary/-/react-error-boundary-3.1.4.tgz#255db92b23197108757a888b01e5b729919abde0" + integrity sha512-uM9uPzZJTF6wRQORmSrvOIgt4lJ9MC1sNgEOj2XGsDTRE4kmpWxg7ENK9EWNKJRMAOY9z0MuF4yIfl6gp4sotA== + dependencies: + "@babel/runtime" "^7.12.5" + react-error-overlay@^6.0.11: version "6.0.11" resolved "https://registry.yarnpkg.com/react-error-overlay/-/react-error-overlay-6.0.11.tgz#92835de5841c5cf08ba00ddd2d677b6d17ff9adb" @@ -9946,7 +9993,7 @@ requires-port@^1.0.0: resolved "https://registry.yarnpkg.com/requires-port/-/requires-port-1.0.0.tgz#925d2601d39ac485e091cf0da5c6e694dc3dcaff" integrity sha512-KigOCHcocU3XODJxsu8i/j8T9tzT4adHiecwORRQ0ZZFcp7ahwXuRU1m+yuO90C5ZUyGeGfocHDI14M3L3yDAQ== -reselect@^5.1.0: +reselect@^5.1.0, reselect@^5.1.1: version "5.1.1" resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.1.1.tgz#c766b1eb5d558291e5e550298adb0becc24bb72e" integrity sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w== @@ -11018,6 +11065,11 @@ tr46@^3.0.0: dependencies: punycode "^2.1.1" +tr46@~0.0.3: + version "0.0.3" + resolved "https://registry.yarnpkg.com/tr46/-/tr46-0.0.3.tgz#8184fd347dac9cdc185992f3a6622e14b9d9ab6a" + integrity sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw== + tryer@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/tryer/-/tryer-1.0.1.tgz#f2c85406800b9b0f74c9f7465b81eaad241252f8" @@ -11390,6 +11442,11 @@ wbuf@^1.1.0, wbuf@^1.7.3: dependencies: minimalistic-assert "^1.0.0" +webidl-conversions@^3.0.0: + version "3.0.1" + resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-3.0.1.tgz#24534275e2a7bc6be7bc86611cc16ae0a5654871" + integrity sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ== + webidl-conversions@^4.0.2: version "4.0.2" resolved "https://registry.yarnpkg.com/webidl-conversions/-/webidl-conversions-4.0.2.tgz#a855980b1f0b6b359ba1d5d9fb39ae941faa63ad" @@ -11566,6 +11623,14 @@ whatwg-url@^11.0.0: tr46 "^3.0.0" webidl-conversions "^7.0.0" +whatwg-url@^5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-5.0.0.tgz#966454e8765462e37644d3626f6742ce8b70965d" + integrity sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw== + dependencies: + tr46 "~0.0.3" + webidl-conversions "^3.0.0" + whatwg-url@^7.0.0: version "7.1.0" resolved "https://registry.yarnpkg.com/whatwg-url/-/whatwg-url-7.1.0.tgz#c2c492f1eca612988efd3d2266be1b9fc6170d06" @@ -11974,3 +12039,8 @@ yocto-queue@^0.1.0: version "0.1.0" resolved "https://registry.yarnpkg.com/yocto-queue/-/yocto-queue-0.1.0.tgz#0294eb3dee05028d31ee1a5fa2c556a6aaf10a1b" integrity sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q== + +zod@^3.23.8: + version "3.23.8" + resolved "https://registry.yarnpkg.com/zod/-/zod-3.23.8.tgz#e37b957b5d52079769fb8097099b592f0ef4067d" + integrity sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g== diff --git a/deploy/config/client/env.js b/deploy/config/client/env.js index 979860b18..0b76cf5a8 100644 --- a/deploy/config/client/env.js +++ b/deploy/config/client/env.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/deploy/config/client/env.local.js b/deploy/config/client/env.local.js index e989f87bf..c8cb6dd61 100644 --- a/deploy/config/client/env.local.js +++ b/deploy/config/client/env.local.js @@ -9,6 +9,7 @@ if (typeof window !== 'undefined') { REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', diff --git a/deploy/config/lib.env b/deploy/config/lib.env deleted file mode 100644 index 7a5546478..000000000 --- a/deploy/config/lib.env +++ /dev/null @@ -1,6 +0,0 @@ -PORT='4001' -MODE='local' -LOCAL_PATH ='/dtaas/libms/files' -LOG_LEVEL='debug' -APOLLO_PATH='/lib' -GRAPHQL_PLAYGROUND='true' \ No newline at end of file diff --git a/deploy/config/libms.yaml b/deploy/config/libms.yaml new file mode 100644 index 000000000..30b1f0c54 --- /dev/null +++ b/deploy/config/libms.yaml @@ -0,0 +1,14 @@ +port: '4001' +mode: 'local' #git or local +local-path: '/dtaas/libms/files' +log-level: 'debug' +apollo-path: '/lib' +graphql-playground: 'true' + +git-repos: #only used in git mode + - user1: + repo-url: 'https://gitlab.com/dtaas/user1.git' + - user2: + repo-url: 'https://gitlab.com/dtaas/user2.git' + - common: + repo-url: 'https://gitlab.com/dtaas/common.git' \ No newline at end of file diff --git a/docker/.env b/docker/.env index cc4751d72..52c522edf 100644 --- a/docker/.env +++ b/docker/.env @@ -4,7 +4,5 @@ OAUTH_URL='https://gitlab.com' OAUTH_CLIENT_ID='xx' OAUTH_CLIENT_SECRET='xx' OAUTH_SECRET='random-secret-string' -OAUTH_CONF_FILEPATH='/Users//DTaaS/docker/conf.dev' username1='user1' -username2='user2' -LIB_CONFIG='/Users//DTaaS/servers/lib/config/.env.default' \ No newline at end of file +username2='user2' \ No newline at end of file diff --git a/docker/compose.dev.yml b/docker/compose.dev.yml index 7f12c87f2..830ecf3cb 100644 --- a/docker/compose.dev.yml +++ b/docker/compose.dev.yml @@ -40,7 +40,7 @@ services: restart: unless-stopped volumes: - ${DTAAS_DIR}/files:/dtaas/libms/files - - ${LIB_CONFIG}:/dtaas/libms/config/.env.default + - ${DTAAS_DIR}/servers/lib/config/libms.dev.yaml:/dtaas/libms/config/libms.yaml labels: - "traefik.enable=true" - "traefik.http.routers.libms.entryPoints=web" @@ -90,7 +90,7 @@ services: image: thomseddon/traefik-forward-auth:latest restart: unless-stopped volumes: - - ${OAUTH_CONF_FILEPATH}:/conf + - ${DTAAS_DIR}/docker/conf.dev:/conf environment: - DEFAULT_PROVIDER=generic-oauth - PROVIDERS_GENERIC_OAUTH_AUTH_URL=${OAUTH_URL}/oauth/authorize diff --git a/docker/libms.dockerfile b/docker/libms.dockerfile index 9b70a4e67..d8678a768 100644 --- a/docker/libms.dockerfile +++ b/docker/libms.dockerfile @@ -1,4 +1,4 @@ -FROM node:20.10.0-slim as build +FROM node:22.12.0-slim as build #! docker should be run from the root directory of the project @@ -15,13 +15,14 @@ RUN yarn install --immutable --immutable-cache --check-cache RUN yarn build -FROM node:20.10.0-slim +FROM node:22.12.0-slim COPY --from=build /dtaas/libms/dist /dtaas/libms/dist COPY --from=build /dtaas/libms/node_modules /dtaas/libms/node_modules COPY --from=build /dtaas/libms/package.json /dtaas/libms/package.json COPY --from=build /dtaas/libms/config /dtaas/libms/config WORKDIR /dtaas/libms +COPY ./deploy/config/libms.yaml libms.yaml # Define the command to run your app -CMD ["yarn", "start", "--config", "config/.env.default", "-H", "config/http.json"] \ No newline at end of file +CMD ["yarn", "start", "--config", "libms.yaml", "-H", "config/http.json"] \ No newline at end of file diff --git a/docker/libms.npm.dockerfile b/docker/libms.npm.dockerfile index 2290968b5..fa1fb36d3 100644 --- a/docker/libms.npm.dockerfile +++ b/docker/libms.npm.dockerfile @@ -1,4 +1,4 @@ -FROM node:20.10.0-slim +FROM node:22.12.0-slim #! docker should be run from the root directory of the project @@ -9,7 +9,7 @@ WORKDIR /dtaas/libms ARG VERSION="latest" RUN npm i -g @into-cps-association/libms@${VERSION} -COPY ./deploy/config/lib.env .env +COPY ./deploy/config/libms.yaml libms.yaml COPY ./servers/lib/config/http.json . # Define the command to run your app diff --git a/docs/admin/client/config.md b/docs/admin/client/config.md index 37216acf8..1eb295a77 100644 --- a/docs/admin/client/config.md +++ b/docs/admin/client/config.md @@ -15,6 +15,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_JUPYTERLAB: "Endpoint for the Jupyter Lab link", REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: "Endpoint for the Jupyter Notebook link", + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: "Endpoint fot the Library page preview", REACT_APP_WORKBENCHLINK_DT_PREVIEW: "Endpoint for the Digital Twins page preview", REACT_APP_CLIENT_ID: 'AppID genereated by the gitlab OAuth provider', REACT_APP_AUTH_AUTHORITY: 'URL of the private gitlab instance', @@ -36,6 +37,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', @@ -59,6 +61,7 @@ This page describes various configuration options for react website. REACT_APP_WORKBENCHLINK_VSCODE: '/tools/vscode/', REACT_APP_WORKBENCHLINK_JUPYTERLAB: '/lab', REACT_APP_WORKBENCHLINK_JUPYTERNOTEBOOK: '', + REACT_APP_WORKBENCHLINK_LIBRARY_PREVIEW: '/preview/library', REACT_APP_WORKBENCHLINK_DT_PREVIEW: '/preview/digitaltwins', REACT_APP_CLIENT_ID: '1be55736756190b3ace4c2c4fb19bde386d1dcc748d20b47ea8cfb5935b8446c', REACT_APP_AUTH_AUTHORITY: 'https://gitlab.foo.com/', diff --git a/docs/developer/docker/docker.md b/docs/developer/docker/docker.md index bb164a652..1818d1fde 100644 --- a/docs/developer/docker/docker.md +++ b/docs/developer/docker/docker.md @@ -63,7 +63,7 @@ The configuration files to be updated are: 1. **client/config/local.js** please see [client config](../../admin/client/config.md) for help with updating this config file -1. **servers/lib/config/.env.default** +1. **servers/lib/config/libms.dev.yaml** please see [lib config](../../admin/servers/lib/docker.md) for help with updating this config file diff --git a/docs/developer/index.md b/docs/developer/index.md index 0b6354ba9..d00d614d5 100644 --- a/docs/developer/index.md +++ b/docs/developer/index.md @@ -1,6 +1,17 @@ -# Developer Guide +# Contributors Guide + +Welcome to the Digital Twin as a Service (DTaaS) contributing guide + +Thank you for investing your time in contributing to our project! + +Read our [Code of Conduct](./CODE_OF_CONDUCT.md) to keep our community +approachable and respectable. + +In this guide you will get an overview of the contribution workflow +from opening an issue, creating a PR, reviewing, and merging the PR. + +## Project Goals -This guide is for DTaaS platform developers. It helps development team members get familiar with the DTaaS project software design, and development processes. Please see developer-specific @@ -10,15 +21,26 @@ and [Research paper](https://arxiv.org/abs/2305.07244). ## :computer: Development Environment -Ideally, developers should work on Ubuntu/Linux Operating System.There is -an ongoing effort -to bring support for Windows Operating System. But, the development and -software installation scripts are still heavily suited to Ubuntu/Linux +Please use the steps given here to have suitable development +environment. -Please use the steps given here to install the required software packages. +### DevContainers + +There is a [devcontainer configuration](.devcontainer/devcontainer.json) +for the project. Please use it to get a dockerized development environment. +DevContainer is the easiest way to get started. ### Ubuntu/Linux +The code base has been developed for most part on +Ubuntu/Linux Operating System.Thus certain parts of the code base might +have bugs when run on Windows. At the moment, only +[runner](./servers/execution/runner/DEVELOPER.md) has problems running +on non-Linux OS. + +The development environment can be installed by using the following +scripts. + ```bash bash script/env.sh bash script/docker.sh @@ -30,6 +52,11 @@ You will have to download the docker images on a really good network. ### Windows +The development environment scripts for Windows are still buggy. +Any help in improving them is greatly appreciated. +Given that, caveat, please use the following installation steps +for Windows. + Two powershell installation scripts, namely `base.ps1` and `env.ps1` are available to install the required software packages. But errors might crop up due to missing @@ -94,9 +121,6 @@ a development workflow is in place. Each developer should follow these steps: branch to your `feature/distributed-demo` branch. The PR will run all the github actions, code climate and codecov checks. 1. Resolve all the issues identified in the previous step. -1. If you have access to the - [integration server](https://github.com/INTO-CPS-Association/DTaaS/wiki/DTaaS-Integration-Server), - try your working branch on the integration server. 1. Once changes are verified, a PR should be made to the `feature/distributed-demo` branch of the upstream diff --git a/servers/execution/runner/package.json b/servers/execution/runner/package.json index 1ac4584df..d051a9c3f 100644 --- a/servers/execution/runner/package.json +++ b/servers/execution/runner/package.json @@ -1,6 +1,6 @@ { "name": "@into-cps-association/runner", - "version": "0.3.2", + "version": "0.3.3", "description": "DT Runner", "main": "dist/src/runner.js", "repository": "https://github.com/into-cps-association/DTaaS.git", diff --git a/servers/execution/runner/yarn.lock b/servers/execution/runner/yarn.lock index a906980ea..6ed138888 100644 --- a/servers/execution/runner/yarn.lock +++ b/servers/execution/runner/yarn.lock @@ -22,25 +22,13 @@ rxjs "7.8.1" source-map "0.7.4" -"@angular-devkit/core@17.3.8": - version "17.3.8" - resolved "https://registry.yarnpkg.com/@angular-devkit/core/-/core-17.3.8.tgz#8679cacf84cf79764f027811020e235ab32016d2" - integrity sha512-Q8q0voCGudbdCgJ7lXdnyaxKHbNQBARH68zPQV72WT8NWy+Gw/tys870i6L58NWbBaCJEUcIj/kb6KoakSRu+Q== - dependencies: - ajv "8.12.0" - ajv-formats "2.1.1" - jsonc-parser "3.2.1" - picomatch "4.0.1" - rxjs "7.8.1" - source-map "0.7.4" - -"@angular-devkit/schematics-cli@17.3.8": - version "17.3.8" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics-cli/-/schematics-cli-17.3.8.tgz#26eeb9b581309be474868d01d9f87555760557c3" - integrity sha512-TjmiwWJarX7oqvNiRAroQ5/LeKUatxBOCNEuKXO/PV8e7pn/Hr/BqfFm+UcYrQoFdZplmtNAfqmbqgVziKvCpA== +"@angular-devkit/schematics-cli@17.3.11": + version "17.3.11" + resolved "https://registry.yarnpkg.com/@angular-devkit/schematics-cli/-/schematics-cli-17.3.11.tgz#dd1963d592ae7d2555ddbbac8406ba03bcddf4fe" + integrity sha512-kcOMqp+PHAKkqRad7Zd7PbpqJ0LqLaNZdY1+k66lLWmkEBozgq8v4ASn/puPWf9Bo0HpCiK+EzLf0VHE8Z/y6Q== dependencies: - "@angular-devkit/core" "17.3.8" - "@angular-devkit/schematics" "17.3.8" + "@angular-devkit/core" "17.3.11" + "@angular-devkit/schematics" "17.3.11" ansi-colors "4.1.3" inquirer "9.2.15" symbol-observable "4.0.0" @@ -57,17 +45,6 @@ ora "5.4.1" rxjs "7.8.1" -"@angular-devkit/schematics@17.3.8": - version "17.3.8" - resolved "https://registry.yarnpkg.com/@angular-devkit/schematics/-/schematics-17.3.8.tgz#f853eb21682aadfb6667e090b5b509fc95ce8442" - integrity sha512-QRVEYpIfgkprNHc916JlPuNbLzOgrm9DZalHasnLUz4P6g7pR21olb8YCyM2OTJjombNhya9ZpckcADU5Qyvlg== - dependencies: - "@angular-devkit/core" "17.3.8" - jsonc-parser "3.2.1" - magic-string "0.30.8" - ora "5.4.1" - rxjs "7.8.1" - "@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.7", "@babel/code-frame@^7.25.9", "@babel/code-frame@^7.26.0": version "7.26.0" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.26.0.tgz#9374b5cd068d128dac0b94ff482594273b1c2815" @@ -349,9 +326,9 @@ integrity sha512-CCZCDJuduB9OUkFkY2IgppNZMi2lBQgD2qzwXkEia16cge2pijY/aXi96CJMquDMn3nJdlPV1A5KrJEXwfLNzQ== "@eslint/compat@^1.2.1": - version "1.2.1" - resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-1.2.1.tgz#b1263422cd2009a1c8a7d2e0d4b022464d2f4fb1" - integrity sha512-JbHG2TWuCeNzh87fXo+/46Z1LEo9DBA9T188d0fZgGxAD+cNyS6sx9fdiyxjGPBMyQVRlCutTByZ6a5+YMkF7g== + version "1.2.4" + resolved "https://registry.yarnpkg.com/@eslint/compat/-/compat-1.2.4.tgz#b69b0d76ce73fe66d7f8633c406acea151f5c559" + integrity sha512-S8ZdQj/N69YAtuqFt7653jwcvuUj131+6qGLUyDqfDg1OIoBQ66OCuXC473YQfO2AaxITTutiRQiDwoo7ZLYyg== "@eslint/config-array@^0.18.0": version "0.18.0" @@ -818,28 +795,28 @@ "@napi-rs/nice-win32-x64-msvc" "1.0.1" "@nestjs/cli@^10.4.5": - version "10.4.5" - resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-10.4.5.tgz#d6563b87e8ca1d0f256c19a7847dbcc96c76a88e" - integrity sha512-FP7Rh13u8aJbHe+zZ7hM0CC4785g9Pw4lz4r2TTgRtf0zTxSWMkJaPEwyjX8SK9oWK2GsYxl+fKpwVZNbmnj9A== + version "10.4.9" + resolved "https://registry.yarnpkg.com/@nestjs/cli/-/cli-10.4.9.tgz#ac3a23096a4725465360d8d60810f3e857f4a803" + integrity sha512-s8qYd97bggqeK7Op3iD49X2MpFtW4LVNLAwXFkfbRxKME6IYT7X0muNTJ2+QfI8hpbNx9isWkrLWIp+g5FOhiA== dependencies: - "@angular-devkit/core" "17.3.8" - "@angular-devkit/schematics" "17.3.8" - "@angular-devkit/schematics-cli" "17.3.8" + "@angular-devkit/core" "17.3.11" + "@angular-devkit/schematics" "17.3.11" + "@angular-devkit/schematics-cli" "17.3.11" "@nestjs/schematics" "^10.0.1" chalk "4.1.2" chokidar "3.6.0" cli-table3 "0.6.5" commander "4.1.1" fork-ts-checker-webpack-plugin "9.0.2" - glob "10.4.2" + glob "10.4.5" inquirer "8.2.6" node-emoji "1.11.0" ora "5.4.1" tree-kill "1.2.2" tsconfig-paths "4.2.0" - tsconfig-paths-webpack-plugin "4.1.0" - typescript "5.3.3" - webpack "5.94.0" + tsconfig-paths-webpack-plugin "4.2.0" + typescript "5.7.2" + webpack "5.97.1" webpack-node-externals "3.0.0" "@nestjs/common@^10.4.6": @@ -995,84 +972,84 @@ slash "3.0.0" source-map "^0.7.3" -"@swc/core-darwin-arm64@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.8.0.tgz#4dc9e0918c9c2907f670fdb101f35574d4024892" - integrity sha512-TIus1/SE/Ud4g84hCnchcagu+LfyndSDy5r5qf64nflojejDidPU9Fp1InzQhQpEgIpntnZID/KFCP5rQnvsIw== - -"@swc/core-darwin-x64@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.8.0.tgz#ff06624452ee9081735671f5bed93fb776c5524e" - integrity sha512-yCb1FHCX/HUmNRGB1X3CFJ1WPKXMosZVUe3K2TrosCGvytwgaLoW5FS0bZg5Qv6cEUERQBg75cJnOUPwLLRCVg== - -"@swc/core-linux-arm-gnueabihf@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.8.0.tgz#2c093ff8733fbc10804e3fe74ddb1252740ad0d3" - integrity sha512-6TdjVdiLaSW+eGiHKEojMDlx673nowrPHa6nM6toWgRzy8tIZgjPOguVKJDoMnoHuvO7SkOLCUiMRw0rTskypA== - -"@swc/core-linux-arm64-gnu@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.8.0.tgz#70e0772a29be00b80e381a528262b1a846a47a1b" - integrity sha512-TU2YcTornnyZiJUabRuk7Xtvzaep11FwK77IkFomjN9/Os5s25B8ea652c2fAQMe9RsM84FPVmX303ohxavjKQ== - -"@swc/core-linux-arm64-musl@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.8.0.tgz#651647747f820f4667ad926c965b396bb82122fa" - integrity sha512-2CdPTEKxx2hJIj/B0fn8L8k2coo/FDS95smzXyi2bov5FcrP6Ohboq8roFBYgj38fkHusXjY8qt+cCH7yXWAdg== - -"@swc/core-linux-x64-gnu@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.8.0.tgz#745a3113bc48ee867631a77bb239ec598a2fc8f2" - integrity sha512-14StQBifCs/AMsySdU95OmwNJr9LOVqo6rcTFt2b7XaWpe/AyeuMJFxcndLgUewksJHpfepzCTwNdbcYmuNo6A== - -"@swc/core-linux-x64-musl@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.8.0.tgz#547c8176971cd1c3746d9d5feda3bacf2f95074a" - integrity sha512-qemJnAQlYqKCfWNqVv5SG8uGvw8JotwU86cuFUkq35oTB+dsSFM3b83+B1giGTKKFOh2nfWT7bvPXTKk+aUjew== - -"@swc/core-win32-arm64-msvc@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.8.0.tgz#363340f0fcb0d9e7b9e6d4167171ae64be0a114d" - integrity sha512-fXt5vZbnrVdXZzGj2qRnZtY3uh+NtLCaFjS2uD9w8ssdbjhbDZYlJCj2JINOjv35ttEfAD2goiYmVa5P/Ypl+g== - -"@swc/core-win32-ia32-msvc@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.8.0.tgz#95ce2aecbe794e6357214b44bac6d5d1ad296bcc" - integrity sha512-W4FA2vSJ+bGYiTj6gspxghSdKQNLfLMo65AH07u797x7I+YJj8amnFY/fQRlroDv5Dez/FHTv14oPlTlNFUpIw== - -"@swc/core-win32-x64-msvc@1.8.0": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.8.0.tgz#2b31cb9db381d6863727190bb4b1a7be7ce78ce8" - integrity sha512-Il4y8XwKDV0Bnk0IpA00kGcSQC6I9XOIinW5egTutnwIDfDE+qsD0j+0isW5H76GetY3/Ze0lVxeOXLAUgpegA== +"@swc/core-darwin-arm64@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-arm64/-/core-darwin-arm64-1.10.1.tgz#faaaab19b4a039ae67ef661c0144a6f20fe8a78e" + integrity sha512-NyELPp8EsVZtxH/mEqvzSyWpfPJ1lugpTQcSlMduZLj1EASLO4sC8wt8hmL1aizRlsbjCX+r0PyL+l0xQ64/6Q== + +"@swc/core-darwin-x64@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-darwin-x64/-/core-darwin-x64-1.10.1.tgz#754600f453abd24471c202d48836f1161d798f49" + integrity sha512-L4BNt1fdQ5ZZhAk5qoDfUnXRabDOXKnXBxMDJ+PWLSxOGBbWE6aJTnu4zbGjJvtot0KM46m2LPAPY8ttknqaZA== + +"@swc/core-linux-arm-gnueabihf@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm-gnueabihf/-/core-linux-arm-gnueabihf-1.10.1.tgz#b0f43c482d0d1819b382a4eb4a0733ce2e386257" + integrity sha512-Y1u9OqCHgvVp2tYQAJ7hcU9qO5brDMIrA5R31rwWQIAKDkJKtv3IlTHF0hrbWk1wPR0ZdngkQSJZple7G+Grvw== + +"@swc/core-linux-arm64-gnu@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-gnu/-/core-linux-arm64-gnu-1.10.1.tgz#e02a9e22c25ba85ef00335742e549e06284cf33a" + integrity sha512-tNQHO/UKdtnqjc7o04iRXng1wTUXPgVd8Y6LI4qIbHVoVPwksZydISjMcilKNLKIwOoUQAkxyJ16SlOAeADzhQ== + +"@swc/core-linux-arm64-musl@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-arm64-musl/-/core-linux-arm64-musl-1.10.1.tgz#3a0530af8f8bd3717f2f1bd8a2f5183fc58d4cf1" + integrity sha512-x0L2Pd9weQ6n8dI1z1Isq00VHFvpBClwQJvrt3NHzmR+1wCT/gcYl1tp9P5xHh3ldM8Cn4UjWCw+7PaUgg8FcQ== + +"@swc/core-linux-x64-gnu@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.10.1.tgz#5eb4d282b047a22896ab1d4627403be4c3e4fa6a" + integrity sha512-yyYEwQcObV3AUsC79rSzN9z6kiWxKAVJ6Ntwq2N9YoZqSPYph+4/Am5fM1xEQYf/kb99csj0FgOelomJSobxQA== + +"@swc/core-linux-x64-musl@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.10.1.tgz#890f2eda3e67ccc6817cdd04eff91e6ad9e761c4" + integrity sha512-tcaS43Ydd7Fk7sW5ROpaf2Kq1zR+sI5K0RM+0qYLYYurvsJruj3GhBCaiN3gkzd8m/8wkqNqtVklWaQYSDsyqA== + +"@swc/core-win32-arm64-msvc@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-win32-arm64-msvc/-/core-win32-arm64-msvc-1.10.1.tgz#4ea7b2a2fab47f801d31ea8b001a141efaa5e6bf" + integrity sha512-D3Qo1voA7AkbOzQ2UGuKNHfYGKL6eejN8VWOoQYtGHHQi1p5KK/Q7V1ku55oxXBsj79Ny5FRMqiRJpVGad7bjQ== + +"@swc/core-win32-ia32-msvc@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-win32-ia32-msvc/-/core-win32-ia32-msvc-1.10.1.tgz#729102669ccdb72e69884cce58e3686ac63d6f36" + integrity sha512-WalYdFoU3454Og+sDKHM1MrjvxUGwA2oralknXkXL8S0I/8RkWZOB++p3pLaGbTvOO++T+6znFbQdR8KRaa7DA== + +"@swc/core-win32-x64-msvc@1.10.1": + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core-win32-x64-msvc/-/core-win32-x64-msvc-1.10.1.tgz#7d665a7c69642861aed850ecb0cdf5d87197edda" + integrity sha512-JWobfQDbTnoqaIwPKQ3DVSywihVXlQMbDuwik/dDWlj33A8oEHcjPOGs4OqcA3RHv24i+lfCQpM3Mn4FAMfacA== "@swc/core@^1.7.40": - version "1.8.0" - resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.8.0.tgz#ae9290e6d138abc7c3d6b50fe5223d3c601f9155" - integrity sha512-EF8C5lp1RKMp3426tAKwQyVbg4Zcn/2FDax3cz8EcOXYQJM/ctB687IvBm9Ciej1wMcQ/dMRg+OB4Xl8BGLBoA== + version "1.10.1" + resolved "https://registry.yarnpkg.com/@swc/core/-/core-1.10.1.tgz#16b3b8284bafb0ecabb253925796883971e5a761" + integrity sha512-rQ4dS6GAdmtzKiCRt3LFVxl37FaY1cgL9kSUTnhQ2xc3fmHOd7jdJK/V4pSZMG1ruGTd0bsi34O2R0Olg9Zo/w== dependencies: "@swc/counter" "^0.1.3" - "@swc/types" "^0.1.14" + "@swc/types" "^0.1.17" optionalDependencies: - "@swc/core-darwin-arm64" "1.8.0" - "@swc/core-darwin-x64" "1.8.0" - "@swc/core-linux-arm-gnueabihf" "1.8.0" - "@swc/core-linux-arm64-gnu" "1.8.0" - "@swc/core-linux-arm64-musl" "1.8.0" - "@swc/core-linux-x64-gnu" "1.8.0" - "@swc/core-linux-x64-musl" "1.8.0" - "@swc/core-win32-arm64-msvc" "1.8.0" - "@swc/core-win32-ia32-msvc" "1.8.0" - "@swc/core-win32-x64-msvc" "1.8.0" + "@swc/core-darwin-arm64" "1.10.1" + "@swc/core-darwin-x64" "1.10.1" + "@swc/core-linux-arm-gnueabihf" "1.10.1" + "@swc/core-linux-arm64-gnu" "1.10.1" + "@swc/core-linux-arm64-musl" "1.10.1" + "@swc/core-linux-x64-gnu" "1.10.1" + "@swc/core-linux-x64-musl" "1.10.1" + "@swc/core-win32-arm64-msvc" "1.10.1" + "@swc/core-win32-ia32-msvc" "1.10.1" + "@swc/core-win32-x64-msvc" "1.10.1" "@swc/counter@^0.1.3": version "0.1.3" resolved "https://registry.yarnpkg.com/@swc/counter/-/counter-0.1.3.tgz#cc7463bd02949611c6329596fccd2b0ec782b0e9" integrity sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ== -"@swc/types@^0.1.14": - version "0.1.14" - resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.14.tgz#0a0a3f60f801c5d7d52ab02fd5f924d9c6dbcb0d" - integrity sha512-PbSmTiYCN+GMrvfjrMo9bdY+f2COnwbdnoMw7rqU/PI5jXpKjxOGZ0qqZCImxnT81NkNsKnmEpvu+hRXLBeCJg== +"@swc/types@^0.1.17": + version "0.1.17" + resolved "https://registry.yarnpkg.com/@swc/types/-/types-0.1.17.tgz#bd1d94e73497f27341bf141abdf4c85230d41e7c" + integrity sha512-V5gRru+aD8YVyCOMAjMpWR1Ui577DD5KSJsHP8RAxopAH22jFz6GZd/qxqjO6MJHQhcsjvjOFXyDhyLQUnMveQ== dependencies: "@swc/counter" "^0.1.3" @@ -1151,7 +1128,23 @@ resolved "https://registry.yarnpkg.com/@types/cookiejar/-/cookiejar-2.1.5.tgz#14a3e83fa641beb169a2dd8422d91c3c345a9a78" integrity sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q== -"@types/estree@^1.0.5", "@types/estree@^1.0.6": +"@types/eslint-scope@^3.7.7": + version "3.7.7" + resolved "https://registry.yarnpkg.com/@types/eslint-scope/-/eslint-scope-3.7.7.tgz#3108bd5f18b0cdb277c867b3dd449c9ed7079ac5" + integrity sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg== + dependencies: + "@types/eslint" "*" + "@types/estree" "*" + +"@types/eslint@*": + version "9.6.1" + resolved "https://registry.yarnpkg.com/@types/eslint/-/eslint-9.6.1.tgz#d5795ad732ce81715f27f75da913004a56751584" + integrity sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag== + dependencies: + "@types/estree" "*" + "@types/json-schema" "*" + +"@types/estree@*", "@types/estree@^1.0.6": version "1.0.6" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.6.tgz#628effeeae2064a1b4e79f78e81d87b7e5fc7b50" integrity sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw== @@ -1225,7 +1218,7 @@ resolved "https://registry.yarnpkg.com/@types/js-yaml/-/js-yaml-4.0.9.tgz#cd82382c4f902fed9691a2ed79ec68c5898af4c2" integrity sha512-k4MGaQl5TGo/iipqb2UDG2UwjXziSWkh0uysQelTlJpX1qGlpUZYm8PnO4DxG1qBomtJUdYJ6qR6xdIah10JLg== -"@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8": +"@types/json-schema@*", "@types/json-schema@^7.0.15", "@types/json-schema@^7.0.8": version "7.0.15" resolved "https://registry.yarnpkg.com/@types/json-schema/-/json-schema-7.0.15.tgz#596a1747233694d50f6ad8a7869fcb6f56cf5841" integrity sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA== @@ -1253,11 +1246,11 @@ integrity sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w== "@types/node@*", "@types/node@^22.8.1": - version "22.8.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-22.8.6.tgz#e8a0c0871623283d8b3ef7d7b9b1bfdfd3028e22" - integrity sha512-tosuJYKrIqjQIlVCM4PEGxOmyg3FCPa/fViuJChnGeEIhjA46oy8FMVoF9su1/v8PNs2a8Q0iFNyOx0uOF91nw== + version "22.10.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-22.10.2.tgz#a485426e6d1fdafc7b0d4c7b24e2c78182ddabb9" + integrity sha512-Xxr6BBRCAOQixvonOye19wnzyDiUtTeqldOOmj3CkeblonbccA12PFwlufvRdrpjXxqnmUaeiU5EOA+7s5diUQ== dependencies: - undici-types "~6.19.8" + undici-types "~6.20.0" "@types/qs@*": version "6.9.16" @@ -1409,125 +1402,125 @@ "@typescript-eslint/types" "8.11.0" eslint-visitor-keys "^3.4.3" -"@webassemblyjs/ast@1.12.1", "@webassemblyjs/ast@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.12.1.tgz#bb16a0e8b1914f979f45864c23819cc3e3f0d4bb" - integrity sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg== +"@webassemblyjs/ast@1.14.1", "@webassemblyjs/ast@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ast/-/ast-1.14.1.tgz#a9f6a07f2b03c95c8d38c4536a1fdfb521ff55b6" + integrity sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ== dependencies: - "@webassemblyjs/helper-numbers" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" + "@webassemblyjs/helper-numbers" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" -"@webassemblyjs/floating-point-hex-parser@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.11.6.tgz#dacbcb95aff135c8260f77fa3b4c5fea600a6431" - integrity sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw== +"@webassemblyjs/floating-point-hex-parser@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz#fcca1eeddb1cc4e7b6eed4fc7956d6813b21b9fb" + integrity sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA== -"@webassemblyjs/helper-api-error@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.11.6.tgz#6132f68c4acd59dcd141c44b18cbebbd9f2fa768" - integrity sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q== +"@webassemblyjs/helper-api-error@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz#e0a16152248bc38daee76dd7e21f15c5ef3ab1e7" + integrity sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ== -"@webassemblyjs/helper-buffer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.12.1.tgz#6df20d272ea5439bf20ab3492b7fb70e9bfcb3f6" - integrity sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw== +"@webassemblyjs/helper-buffer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz#822a9bc603166531f7d5df84e67b5bf99b72b96b" + integrity sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA== -"@webassemblyjs/helper-numbers@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.11.6.tgz#cbce5e7e0c1bd32cf4905ae444ef64cea919f1b5" - integrity sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g== +"@webassemblyjs/helper-numbers@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz#dbd932548e7119f4b8a7877fd5a8d20e63490b2d" + integrity sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA== dependencies: - "@webassemblyjs/floating-point-hex-parser" "1.11.6" - "@webassemblyjs/helper-api-error" "1.11.6" + "@webassemblyjs/floating-point-hex-parser" "1.13.2" + "@webassemblyjs/helper-api-error" "1.13.2" "@xtuc/long" "4.2.2" -"@webassemblyjs/helper-wasm-bytecode@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.11.6.tgz#bb2ebdb3b83aa26d9baad4c46d4315283acd51e9" - integrity sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA== +"@webassemblyjs/helper-wasm-bytecode@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz#e556108758f448aae84c850e593ce18a0eb31e0b" + integrity sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA== -"@webassemblyjs/helper-wasm-section@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.12.1.tgz#3da623233ae1a60409b509a52ade9bc22a37f7bf" - integrity sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g== +"@webassemblyjs/helper-wasm-section@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz#9629dda9c4430eab54b591053d6dc6f3ba050348" + integrity sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw== dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/wasm-gen" "1.12.1" + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/wasm-gen" "1.14.1" -"@webassemblyjs/ieee754@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.11.6.tgz#bb665c91d0b14fffceb0e38298c329af043c6e3a" - integrity sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg== +"@webassemblyjs/ieee754@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz#1c5eaace1d606ada2c7fd7045ea9356c59ee0dba" + integrity sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw== dependencies: "@xtuc/ieee754" "^1.2.0" -"@webassemblyjs/leb128@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.11.6.tgz#70e60e5e82f9ac81118bc25381a0b283893240d7" - integrity sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ== +"@webassemblyjs/leb128@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/leb128/-/leb128-1.13.2.tgz#57c5c3deb0105d02ce25fa3fd74f4ebc9fd0bbb0" + integrity sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw== dependencies: "@xtuc/long" "4.2.2" -"@webassemblyjs/utf8@1.11.6": - version "1.11.6" - resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.11.6.tgz#90f8bc34c561595fe156603be7253cdbcd0fab5a" - integrity sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA== - -"@webassemblyjs/wasm-edit@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.12.1.tgz#9f9f3ff52a14c980939be0ef9d5df9ebc678ae3b" - integrity sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/helper-wasm-section" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-opt" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" - "@webassemblyjs/wast-printer" "1.12.1" - -"@webassemblyjs/wasm-gen@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.12.1.tgz#a6520601da1b5700448273666a71ad0a45d78547" - integrity sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" - -"@webassemblyjs/wasm-opt@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.12.1.tgz#9e6e81475dfcfb62dab574ac2dda38226c232bc5" - integrity sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-buffer" "1.12.1" - "@webassemblyjs/wasm-gen" "1.12.1" - "@webassemblyjs/wasm-parser" "1.12.1" - -"@webassemblyjs/wasm-parser@1.12.1", "@webassemblyjs/wasm-parser@^1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.12.1.tgz#c47acb90e6f083391e3fa61d113650eea1e95937" - integrity sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ== - dependencies: - "@webassemblyjs/ast" "1.12.1" - "@webassemblyjs/helper-api-error" "1.11.6" - "@webassemblyjs/helper-wasm-bytecode" "1.11.6" - "@webassemblyjs/ieee754" "1.11.6" - "@webassemblyjs/leb128" "1.11.6" - "@webassemblyjs/utf8" "1.11.6" - -"@webassemblyjs/wast-printer@1.12.1": - version "1.12.1" - resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.12.1.tgz#bcecf661d7d1abdaf989d8341a4833e33e2b31ac" - integrity sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA== - dependencies: - "@webassemblyjs/ast" "1.12.1" +"@webassemblyjs/utf8@1.13.2": + version "1.13.2" + resolved "https://registry.yarnpkg.com/@webassemblyjs/utf8/-/utf8-1.13.2.tgz#917a20e93f71ad5602966c2d685ae0c6c21f60f1" + integrity sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ== + +"@webassemblyjs/wasm-edit@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz#ac6689f502219b59198ddec42dcd496b1004d597" + integrity sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/helper-wasm-section" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-opt" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + "@webassemblyjs/wast-printer" "1.14.1" + +"@webassemblyjs/wasm-gen@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz#991e7f0c090cb0bb62bbac882076e3d219da9570" + integrity sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wasm-opt@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz#e6f71ed7ccae46781c206017d3c14c50efa8106b" + integrity sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-buffer" "1.14.1" + "@webassemblyjs/wasm-gen" "1.14.1" + "@webassemblyjs/wasm-parser" "1.14.1" + +"@webassemblyjs/wasm-parser@1.14.1", "@webassemblyjs/wasm-parser@^1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz#b3e13f1893605ca78b52c68e54cf6a865f90b9fb" + integrity sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ== + dependencies: + "@webassemblyjs/ast" "1.14.1" + "@webassemblyjs/helper-api-error" "1.13.2" + "@webassemblyjs/helper-wasm-bytecode" "1.13.2" + "@webassemblyjs/ieee754" "1.13.2" + "@webassemblyjs/leb128" "1.13.2" + "@webassemblyjs/utf8" "1.13.2" + +"@webassemblyjs/wast-printer@1.14.1": + version "1.14.1" + resolved "https://registry.yarnpkg.com/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz#3bb3e9638a8ae5fdaf9610e7a06b4d9f9aa6fe07" + integrity sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw== + dependencies: + "@webassemblyjs/ast" "1.14.1" "@xtuc/long" "4.2.2" "@xtuc/ieee754@^1.2.0": @@ -1548,17 +1541,12 @@ accepts@~1.3.8: mime-types "~2.1.34" negotiator "0.6.3" -acorn-import-attributes@^1.9.5: - version "1.9.5" - resolved "https://registry.yarnpkg.com/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz#7eb1557b1ba05ef18b5ed0ec67591bfab04688ef" - integrity sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ== - acorn-jsx@^5.3.2: version "5.3.2" resolved "https://registry.yarnpkg.com/acorn-jsx/-/acorn-jsx-5.3.2.tgz#7ed5bb55908b3b2f1bc55c6af1653bada7f07937" integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== -acorn@^8.12.0, acorn@^8.7.1, acorn@^8.8.2: +acorn@^8.12.0, acorn@^8.14.0, acorn@^8.8.2: version "8.14.0" resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.14.0.tgz#063e2c70cac5fb4f6467f0b11152e04c682795b0" integrity sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA== @@ -1924,7 +1912,7 @@ braces@^3.0.3, braces@~3.0.2: dependencies: fill-range "^7.1.1" -browserslist@^4.21.10, browserslist@^4.24.0: +browserslist@^4.24.0: version "4.24.2" resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.24.2.tgz#f5845bc91069dbd55ee89faf9822e1d885d16580" integrity sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg== @@ -2216,9 +2204,9 @@ concat-stream@^1.5.2: typedarray "^0.0.6" concurrently@^9.0.1: - version "9.0.1" - resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.0.1.tgz#01e171bf6c7af0c022eb85daef95bff04d8185aa" - integrity sha512-wYKvCd/f54sTXJMSfV6Ln/B8UrfLBKOYa+lzc6CHay3Qek+LorVSBdMVfyewFhRbH0Rbabsk4D+3PL/VjQ5gzg== + version "9.1.0" + resolved "https://registry.yarnpkg.com/concurrently/-/concurrently-9.1.0.tgz#8da6d609f4321752912dab9be8710232ac496aa0" + integrity sha512-VxkzwMAn4LP7WyMnJNbHN5mKV9L2IbyDjpzemKr99sXNR3GqRNMMHdm7prV1ws9wg7ETj6WUkNOigZVsptwbgg== dependencies: chalk "^4.1.2" lodash "^4.17.21" @@ -3348,10 +3336,10 @@ glob-to-regexp@^0.4.1: resolved "https://registry.yarnpkg.com/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz#c75297087c851b9a578bd217dd59a92f59fe546e" integrity sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw== -glob@10.4.2: - version "10.4.2" - resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.2.tgz#bed6b95dade5c1f80b4434daced233aee76160e5" - integrity sha512-GwMlUF6PkPo3Gk21UxkCohOv0PLcIXVtKyLlpEI28R/cO/4eNOdmLk3CMW1wROV/WR/EsZOWAfBbBOqYvs88/w== +glob@10.4.5: + version "10.4.5" + resolved "https://registry.yarnpkg.com/glob/-/glob-10.4.5.tgz#f4d9f0b90ffdbab09c9d77f5f29b4262517b0956" + integrity sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg== dependencies: foreground-child "^3.1.0" jackspeak "^3.1.2" @@ -5849,13 +5837,14 @@ ts-loader@^9.5.1: semver "^7.3.4" source-map "^0.7.4" -tsconfig-paths-webpack-plugin@4.1.0: - version "4.1.0" - resolved "https://registry.yarnpkg.com/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.1.0.tgz#3c6892c5e7319c146eee1e7302ed9e6f2be4f763" - integrity sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA== +tsconfig-paths-webpack-plugin@4.2.0: + version "4.2.0" + resolved "https://registry.yarnpkg.com/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.2.0.tgz#f7459a8ed1dd4cf66ad787aefc3d37fff3cf07fc" + integrity sha512-zbem3rfRS8BgeNK50Zz5SIQgXzLafiHjOwUAvk/38/o1jHn/V5QAgVUcz884or7WYcPaH3N2CIfUc2u0ul7UcA== dependencies: chalk "^4.1.0" enhanced-resolve "^5.7.0" + tapable "^2.2.1" tsconfig-paths "^4.1.2" tsconfig-paths@4.2.0, tsconfig-paths@^4.1.2, tsconfig-paths@^4.2.0: @@ -5961,15 +5950,10 @@ typedarray@^0.0.6: resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== -typescript@5.3.3: - version "5.3.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.3.3.tgz#b3ce6ba258e72e6305ba66f5c9b452aaee3ffe37" - integrity sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw== - -typescript@^5.6.3: - version "5.6.3" - resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.6.3.tgz#5f3449e31c9d94febb17de03cc081dd56d81db5b" - integrity sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw== +typescript@5.7.2, typescript@^5.6.3: + version "5.7.2" + resolved "https://registry.yarnpkg.com/typescript/-/typescript-5.7.2.tgz#3169cf8c4c8a828cde53ba9ecb3d2b1d5dd67be6" + integrity sha512-i5t66RHxDvVN40HfDd1PsEThGNnlMCMT3jMUuoh9/0TaqWevNontacunWyN02LA9/fIbEWlcHZcgTKb9QoaLfg== uid@2.0.2: version "2.0.2" @@ -5988,10 +5972,10 @@ unbox-primitive@^1.0.2: has-symbols "^1.0.3" which-boxed-primitive "^1.0.2" -undici-types@~6.19.8: - version "6.19.8" - resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.19.8.tgz#35111c9d1437ab83a7cdc0abae2f26d88eda0a02" - integrity sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw== +undici-types@~6.20.0: + version "6.20.0" + resolved "https://registry.yarnpkg.com/undici-types/-/undici-types-6.20.0.tgz#8171bf22c1f588d1554d55bf204bc624af388433" + integrity sha512-Ny6QZ2Nju20vw1SRHe3d9jVu6gJ+4e3+MMpqu7pqE5HT6WsTSlce++GQmK5UXS8mzV8DSYHrQH+Xrf2jVcuKNg== unicorn-magic@^0.3.0: version "0.3.0" @@ -6084,47 +6068,18 @@ webpack-sources@^3.2.3: resolved "https://registry.yarnpkg.com/webpack-sources/-/webpack-sources-3.2.3.tgz#2d4daab8451fd4b240cc27055ff6a0c2ccea0cde" integrity sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w== -webpack@5.94.0: - version "5.94.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.94.0.tgz#77a6089c716e7ab90c1c67574a28da518a20970f" - integrity sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg== - dependencies: - "@types/estree" "^1.0.5" - "@webassemblyjs/ast" "^1.12.1" - "@webassemblyjs/wasm-edit" "^1.12.1" - "@webassemblyjs/wasm-parser" "^1.12.1" - acorn "^8.7.1" - acorn-import-attributes "^1.9.5" - browserslist "^4.21.10" - chrome-trace-event "^1.0.2" - enhanced-resolve "^5.17.1" - es-module-lexer "^1.2.1" - eslint-scope "5.1.1" - events "^3.2.0" - glob-to-regexp "^0.4.1" - graceful-fs "^4.2.11" - json-parse-even-better-errors "^2.3.1" - loader-runner "^4.2.0" - mime-types "^2.1.27" - neo-async "^2.6.2" - schema-utils "^3.2.0" - tapable "^2.1.1" - terser-webpack-plugin "^5.3.10" - watchpack "^2.4.1" - webpack-sources "^3.2.3" - -webpack@^5.95.0: - version "5.95.0" - resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.95.0.tgz#8fd8c454fa60dad186fbe36c400a55848307b4c0" - integrity sha512-2t3XstrKULz41MNMBF+cJ97TyHdyQ8HCt//pqErqDvNjU9YQBnZxIHa11VXsi7F3mb5/aO2tuDxdeTPdU7xu9Q== - dependencies: - "@types/estree" "^1.0.5" - "@webassemblyjs/ast" "^1.12.1" - "@webassemblyjs/wasm-edit" "^1.12.1" - "@webassemblyjs/wasm-parser" "^1.12.1" - acorn "^8.7.1" - acorn-import-attributes "^1.9.5" - browserslist "^4.21.10" +webpack@5.97.1, webpack@^5.95.0: + version "5.97.1" + resolved "https://registry.yarnpkg.com/webpack/-/webpack-5.97.1.tgz#972a8320a438b56ff0f1d94ade9e82eac155fa58" + integrity sha512-EksG6gFY3L1eFMROS/7Wzgrii5mBAFe4rIr3r2BTfo7bcc+DWwFZ4OJ/miOuHJO/A85HwyI4eQ0F6IKXesO7Fg== + dependencies: + "@types/eslint-scope" "^3.7.7" + "@types/estree" "^1.0.6" + "@webassemblyjs/ast" "^1.14.1" + "@webassemblyjs/wasm-edit" "^1.14.1" + "@webassemblyjs/wasm-parser" "^1.14.1" + acorn "^8.14.0" + browserslist "^4.24.0" chrome-trace-event "^1.0.2" enhanced-resolve "^5.17.1" es-module-lexer "^1.2.1" diff --git a/servers/lib/DEVELOPER.md b/servers/lib/DEVELOPER.md index b945024df..ebad3714e 100644 --- a/servers/lib/DEVELOPER.md +++ b/servers/lib/DEVELOPER.md @@ -116,17 +116,13 @@ running the following commands. The microservices require configuration and the docker version of the microservices uses the configuration -file available in `config/.env.default`. +file available in `config/libms.dev.yaml`. -Please add a `.env` file with the environment variables for -the docker compose file to use. -e.g. +For more see [configuration documentation](./README.md#gear-configure). +The `config/libms.dev.yaml` file is used for configuration of the container. -```sh -PORT='4001' -MODE='local' -LOCAL_PATH='..\..\files' -``` +A new directory named `files` is created in `servers/lib` directory and +files are saved and served from the `files` directory. ### Use @@ -140,7 +136,7 @@ docker compose -f compose.lib.dev.yml up -d This command brings up the lib docker container and makes the website available at . -The `config/.env.default` file is used as the microservice configuration. +The `config/libms.dev.yaml` file is used as the microservice configuration. If the configuration values are changed, please restart the container. ```bash diff --git a/servers/lib/DOCKER.md b/servers/lib/DOCKER.md index b20a15554..3d64a7a8c 100644 --- a/servers/lib/DOCKER.md +++ b/servers/lib/DOCKER.md @@ -1,10 +1,11 @@ # Overview -The **libms microservice** is a simplified file manager providing graphQL API. -It has two features: +The **lib microservice** is a simplified file manager which serves files +from local file system or public git repositories. It is possible to -* provide a listing of directory contents. -* transfer a file to user. +* Upload and download files from web browser +* Query available files and download them using GraphQL API +* Clone public git repositories and serve them as local files ## Use in Docker Environment @@ -19,12 +20,13 @@ services: image: intocps/libms:latest restart: unless-stopped volumes: + - ./libms.yaml:/dtaas/libms/libms.yaml - ./files:/dtaas/libms/files ports: - "4001:4001" ``` -### Create Files Directory +### Create Files Directory (optional) The **libms microservice** serves files available from `files` directory. @@ -47,6 +49,71 @@ files/ Please create this `files` directory in the same file system location as that of the `compose.lib.yml` file. +:label: The directory structure is optional if you are using +libms as a standalone service. + +## :gear: Configure + +The microservices requires config specified in `libms.yaml` file. +The template configuration file is: + +```yaml +port: '4001' +mode: 'local' #git or local +local-path: 'files' +log-level: 'debug' +apollo-path: '/lib' +graphql-playground: 'true' + +git-repos: #only used in git mode + - user1: + repo-url: 'https://gitlab.com/dtaas/user1.git' + - user2: + repo-url: 'https://gitlab.com/dtaas/user2.git' + - common: + repo-url: 'https://gitlab.com/dtaas/common.git' +``` + +The `local-path` variable is the relative filepath to the +location of the local directory which will be served to users +by the Library microservice. + +Replace the default values the appropriate values for your setup. +Please save this config in `libms.yaml`. + +### Operation Modes + +The mode indicates the backend storage for the files. +There are two possible modes - `local` and `git`. +The files available in the `local-path` are served to users in `local` mode. +In the `git` mode, the remote git repos are cloned and they are +served to users as local files. Only public git repositories +are supported at present. + +#### git mode + +A fragment of the config for `git` mode is: + +```yaml +... +git-repos: + - user1: + repo-url: 'https://gitlab.com/dtaas/user1.git' + - user2: + repo-url: 'https://gitlab.com/dtaas/user2.git' + - common: + repo-url: 'https://gitlab.com/dtaas/common.git' +``` + +Here, `user1`, `user2` and `common` are the local directories into which +the remote git repositories get cloned. The name of the repository need not +match with the local directory name. For example, the above configuration +enables library microservice to clone +`https://gitlab.com/dtaas/user1.git` repository into +`user1` directory. Any git server accessible over +HTTP(S) protocol is supported. +The `.git` suffix is optional. + ### Run Use the following commands to start and stop the container respectively: diff --git a/servers/lib/README.md b/servers/lib/README.md index 0e4ddc462..053dab09b 100644 --- a/servers/lib/README.md +++ b/servers/lib/README.md @@ -38,24 +38,24 @@ needs to have _read:packages_ scope. ## :gear: Configure -The microservices requires config specified in INI format. +The microservices requires config specified in yaml format. The template configuration file is: ```yaml port: '4001' -mode: 'git' # either git or local -local-path: 'files' +mode: 'local' #git or local +local-path: '..\..\files' log-level: 'debug' apollo-path: '/lib' graphql-playground: 'true' -git-repos: - - user-1: - repo-url: 'https://github.com/isomorphic-git/lightning-fs' - - user-2: +git-repos: #only used in git mode + - user1: + repo-url: 'https://gitlab.com/dtaas/user1.git' + - user2: repo-url: 'https://gitlab.com/dtaas/user2.git' - common: - repo-url: 'https://gitlab.com/dtaas/common' + repo-url: 'https://gitlab.com/dtaas/common.git' ``` The `local-path` variable is the relative filepath to the @@ -80,20 +80,20 @@ A fragment of the config for `git` mode is: ```yaml ... git-repos: - - user-1: - repo-url: 'https://github.com/isomorphic-git/lightning-fs' - - user-2: + - user1: + repo-url: 'https://gitlab.com/dtaas/user1.git' + - user2: repo-url: 'https://gitlab.com/dtaas/user2.git' - common: - repo-url: 'https://gitlab.com/dtaas/common' + repo-url: 'https://gitlab.com/dtaas/common.git' ``` -Here, `user-1`, `user-2` and `common` are the local directories into which +Here, `user1`, `user2` and `common` are the local directories into which the remote git repositories get cloned. The name of the repository need not match with the local directory name. For example, the above configuration enables library microservice to clone -`https://github.com/isomorphic-git/lightning-fs` repository into -`user-1` directory. Any git server accessible over +`https://gitlab.com/dtaas/user1.git` repository into +`user1` directory. Any git server accessible over HTTP(S) protocol is supported. The `.git` suffix is optional. diff --git a/servers/lib/compose.lib.dev.yml b/servers/lib/compose.lib.dev.yml index f1e4a8ba0..6e8e9ac0e 100644 --- a/servers/lib/compose.lib.dev.yml +++ b/servers/lib/compose.lib.dev.yml @@ -4,6 +4,7 @@ services: context: ../../ dockerfile: ./docker/libms.dockerfile volumes: - - ${LOCAL_PATH}:/dtaas/libms/files + - ./files:/dtaas/libms/files + - ./config/libms.dev.yaml:/dtaas/libms/libms.yaml ports: - - ${PORT}:4001 + - 4001:4001 diff --git a/servers/lib/compose.lib.yml b/servers/lib/compose.lib.yml index a4e080791..93e7d0df2 100644 --- a/servers/lib/compose.lib.yml +++ b/servers/lib/compose.lib.yml @@ -3,6 +3,7 @@ services: image: intocps/libms:latest restart: unless-stopped volumes: + - ./config/libms.dev.yaml:/dtaas/libms/libms.yaml - ./files:/dtaas/libms/files ports: - "4001:4001" diff --git a/servers/lib/config/libms.dev.yaml b/servers/lib/config/libms.dev.yaml new file mode 100644 index 000000000..1a136a56f --- /dev/null +++ b/servers/lib/config/libms.dev.yaml @@ -0,0 +1,14 @@ +port: '4001' +mode: 'local' #git or local +local-path: '/dtaas/libms/files' +log-level: 'debug' +apollo-path: '/lib' +graphql-playground: 'true' + +git-repos: #only used in git mode + - user1: + repo-url: 'https://gitlab.com/dtaas/user1.git' + - user2: + repo-url: 'https://gitlab.com/dtaas/user2.git' + - common: + repo-url: 'https://gitlab.com/dtaas/common.git' \ No newline at end of file diff --git a/servers/lib/config/libms.yaml.default b/servers/lib/config/libms.yaml.default index 956374c3f..1cd67b261 100644 --- a/servers/lib/config/libms.yaml.default +++ b/servers/lib/config/libms.yaml.default @@ -1,12 +1,14 @@ port: '4001' -mode: 'git' +mode: 'local' #git or local local-path: '..\..\files' log-level: 'debug' apollo-path: '/lib' graphql-playground: 'true' -git-repos: - - user-1: - repo-url: 'https://github.com/isomorphic-git/lightning-fs' - - user-2: - repo-url: 'https://github.com/isomorphic-git/lightning-fs' \ No newline at end of file +git-repos: #only used in git mode + - user1: + repo-url: 'https://gitlab.com/dtaas/user1.git' + - user2: + repo-url: 'https://gitlab.com/dtaas/user2.git' + - common: + repo-url: 'https://gitlab.com/dtaas/common.git' \ No newline at end of file diff --git a/servers/lib/libms.yaml.sample b/servers/lib/libms.yaml.sample deleted file mode 100644 index 956374c3f..000000000 --- a/servers/lib/libms.yaml.sample +++ /dev/null @@ -1,12 +0,0 @@ -port: '4001' -mode: 'git' -local-path: '..\..\files' -log-level: 'debug' -apollo-path: '/lib' -graphql-playground: 'true' - -git-repos: - - user-1: - repo-url: 'https://github.com/isomorphic-git/lightning-fs' - - user-2: - repo-url: 'https://github.com/isomorphic-git/lightning-fs' \ No newline at end of file diff --git a/servers/lib/package.json b/servers/lib/package.json index 812853f4b..6c4cb0aa5 100644 --- a/servers/lib/package.json +++ b/servers/lib/package.json @@ -1,6 +1,6 @@ { "name": "@into-cps-association/libms", - "version": "0.5.2", + "version": "0.5.4", "description": "microservices that handles request by fetching and returning the file-names and folders of given directory", "author": "phillip.boe.jensen@gmail.com", "contributors": [ diff --git a/servers/lib/yarn.lock b/servers/lib/yarn.lock index beddeb428..ff4e01d35 100644 --- a/servers/lib/yarn.lock +++ b/servers/lib/yarn.lock @@ -2699,9 +2699,9 @@ "@types/istanbul-lib-report" "*" "@types/jest@^29.5.13": - version "29.5.13" - resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.13.tgz#8bc571659f401e6a719a7bf0dbcb8b78c71a8adc" - integrity sha512-wd+MVEZCHt23V0/L642O5APvspWply/rGY5BcW4SUETo2UzPU3Z26qr8jC2qxpimI2jjx9h7+2cj2FwIr01bXg== + version "29.5.14" + resolved "https://registry.yarnpkg.com/@types/jest/-/jest-29.5.14.tgz#2b910912fa1d6856cadcd0c1f95af7df1d6049e5" + integrity sha512-ZN+4sdnLUbo8EVvVc2ao0GFW6oVrQRPn4K2lglySj7APvSrgzxHiNNK99us4WDMi57xxA2yggblIAMNhXOotLQ== dependencies: expect "^29.0.0" pretty-format "^29.0.0" @@ -2871,14 +2871,14 @@ ts-api-utils "^1.3.0" "@typescript-eslint/parser@^8.12.2": - version "8.12.2" - resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.12.2.tgz#2e8173b34e1685e918b2d571c16c906d3747bad2" - integrity sha512-MrvlXNfGPLH3Z+r7Tk+Z5moZAc0dzdVjTgUgwsdGweH7lydysQsnSww3nAmsq8blFuRD5VRlAr9YdEFw3e6PBw== - dependencies: - "@typescript-eslint/scope-manager" "8.12.2" - "@typescript-eslint/types" "8.12.2" - "@typescript-eslint/typescript-estree" "8.12.2" - "@typescript-eslint/visitor-keys" "8.12.2" + version "8.18.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/parser/-/parser-8.18.0.tgz#a1c9456cbb6a089730bf1d3fc47946c5fb5fe67b" + integrity sha512-hgUZ3kTEpVzKaK3uNibExUYm6SKKOmTU2BOxBSvOYwtJEPdVQ70kZJpPjstlnhCHcuc2WGfSbpKlb/69ttyN5Q== + dependencies: + "@typescript-eslint/scope-manager" "8.18.0" + "@typescript-eslint/types" "8.18.0" + "@typescript-eslint/typescript-estree" "8.18.0" + "@typescript-eslint/visitor-keys" "8.18.0" debug "^4.3.4" "@typescript-eslint/scope-manager@8.12.2": @@ -2889,6 +2889,14 @@ "@typescript-eslint/types" "8.12.2" "@typescript-eslint/visitor-keys" "8.12.2" +"@typescript-eslint/scope-manager@8.18.0": + version "8.18.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.18.0.tgz#30b040cb4557804a7e2bcc65cf8fdb630c96546f" + integrity sha512-PNGcHop0jkK2WVYGotk/hxj+UFLhXtGPiGtiaWgVBVP1jhMoMCHlTyJA+hEj4rszoSdLTK3fN4oOatrL0Cp+Xw== + dependencies: + "@typescript-eslint/types" "8.18.0" + "@typescript-eslint/visitor-keys" "8.18.0" + "@typescript-eslint/scope-manager@8.7.0": version "8.7.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/scope-manager/-/scope-manager-8.7.0.tgz#90ee7bf9bc982b9260b93347c01a8bc2b595e0b8" @@ -2912,6 +2920,11 @@ resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.12.2.tgz#8d70098c0e90442495b53d0296acdca6d0f3f73c" integrity sha512-VwDwMF1SZ7wPBUZwmMdnDJ6sIFk4K4s+ALKLP6aIQsISkPv8jhiw65sAK6SuWODN/ix+m+HgbYDkH+zLjrzvOA== +"@typescript-eslint/types@8.18.0": + version "8.18.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.18.0.tgz#3afcd30def8756bc78541268ea819a043221d5f3" + integrity sha512-FNYxgyTCAnFwTrzpBGq+zrnoTO4x0c1CKYY5MuUTzpScqmY5fmsh2o3+57lqdI3NZucBDCzDgdEbIaNfAjAHQA== + "@typescript-eslint/types@8.7.0": version "8.7.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/types/-/types-8.7.0.tgz#21d987201c07b69ce7ddc03451d7196e5445ad19" @@ -2931,6 +2944,20 @@ semver "^7.6.0" ts-api-utils "^1.3.0" +"@typescript-eslint/typescript-estree@8.18.0": + version "8.18.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.0.tgz#d8ca785799fbb9c700cdff1a79c046c3e633c7f9" + integrity sha512-rqQgFRu6yPkauz+ms3nQpohwejS8bvgbPyIDq13cgEDbkXt4LH4OkDMT0/fN1RUtzG8e8AKJyDBoocuQh8qNeg== + dependencies: + "@typescript-eslint/types" "8.18.0" + "@typescript-eslint/visitor-keys" "8.18.0" + debug "^4.3.4" + fast-glob "^3.3.2" + is-glob "^4.0.3" + minimatch "^9.0.4" + semver "^7.6.0" + ts-api-utils "^1.3.0" + "@typescript-eslint/typescript-estree@8.7.0": version "8.7.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/typescript-estree/-/typescript-estree-8.7.0.tgz#6c7db6baa4380b937fa81466c546d052f362d0e8" @@ -2973,6 +3000,14 @@ "@typescript-eslint/types" "8.12.2" eslint-visitor-keys "^3.4.3" +"@typescript-eslint/visitor-keys@8.18.0": + version "8.18.0" + resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.0.tgz#7b6d33534fa808e33a19951907231ad2ea5c36dd" + integrity sha512-pCh/qEA8Lb1wVIqNvBke8UaRjJ6wrAWkJO5yyIbs8Yx6TNGYyfNjOo61tLv+WwLvoLPp4BQ8B7AHKijl8NGUfw== + dependencies: + "@typescript-eslint/types" "8.18.0" + eslint-visitor-keys "^4.2.0" + "@typescript-eslint/visitor-keys@8.7.0": version "8.7.0" resolved "https://registry.yarnpkg.com/@typescript-eslint/visitor-keys/-/visitor-keys-8.7.0.tgz#5e46f1777f9d69360a883c1a56ac3c511c9659a8" @@ -3458,9 +3493,9 @@ available-typed-arrays@^1.0.7: possible-typed-array-names "^1.0.0" axios@^1.7.7: - version "1.7.7" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.7.tgz#2f554296f9892a72ac8d8e4c5b79c14a91d0a47f" - integrity sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q== + version "1.7.9" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.7.9.tgz#d7d071380c132a24accda1b2cfc1535b79ec650a" + integrity sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw== dependencies: follow-redirects "^1.15.6" form-data "^4.0.0" @@ -4733,22 +4768,6 @@ engine.io-parser@~5.2.1: resolved "https://registry.yarnpkg.com/engine.io-parser/-/engine.io-parser-5.2.2.tgz#37b48e2d23116919a3453738c5720455e64e1c49" integrity sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw== -engine.io@~6.5.2: - version "6.5.4" - resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.5.4.tgz#6822debf324e781add2254e912f8568508850cdc" - integrity sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg== - dependencies: - "@types/cookie" "^0.4.1" - "@types/cors" "^2.8.12" - "@types/node" ">=10.0.0" - accepts "~1.3.4" - base64id "2.0.0" - cookie "~0.4.1" - cors "~2.8.5" - debug "~4.3.1" - engine.io-parser "~5.2.1" - ws "~8.11.0" - engine.io@~6.6.0: version "6.6.1" resolved "https://registry.yarnpkg.com/engine.io/-/engine.io-6.6.1.tgz#a82b1e5511239a0e95fac14516870ee9138febc8" @@ -6440,9 +6459,9 @@ isobject@^3.0.1: integrity sha512-WhB9zCku7EGTj/HQQRz5aUQEUeoQZH2bWcltRErOpymJ4boYE6wL9Tbr23krRPSZ+C5zqNSrSw+Cc7sZZ4b7vg== isomorphic-git@^1.27.1: - version "1.27.1" - resolved "https://registry.yarnpkg.com/isomorphic-git/-/isomorphic-git-1.27.1.tgz#a2752fce23a09f04baa590c41cfaf61e973405b3" - integrity sha512-X32ph5zIWfT75QAqW2l3JCIqnx9/GWd17bRRehmn3qmWc34OYbSXY6Cxv0o9bIIY+CWugoN4nQFHNA+2uYf2nA== + version "1.27.2" + resolved "https://registry.yarnpkg.com/isomorphic-git/-/isomorphic-git-1.27.2.tgz#969727adf4e81ec313974e9ebe2d1d0982b25c7f" + integrity sha512-nCiz+ieOkWb5kDJSSckDTiMjTcgkxqH2xuiQmw1Y6O/spwx4d6TKYSfGCd4f71HGvUYcRSUGqJEI+3uN6UQlOw== dependencies: async-lock "^1.4.1" clean-git-ref "^2.0.1" @@ -6451,6 +6470,7 @@ isomorphic-git@^1.27.1: ignore "^5.1.4" minimisted "^2.0.0" pako "^1.0.10" + path-browserify "^1.0.1" pify "^4.0.1" readable-stream "^3.4.0" sha.js "^2.4.9" @@ -8175,6 +8195,11 @@ patchfile@^4.0.0: readzip "^2.0.0" redzip "^3.0.0" +path-browserify@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/path-browserify/-/path-browserify-1.0.1.tgz#d98454a9c3753d5790860f16f68867b9e46be1fd" + integrity sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g== + path-exists@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/path-exists/-/path-exists-4.0.0.tgz#513bdbe2d3b95d7762e8c1137efa195c6c61b5b3" @@ -9599,23 +9624,10 @@ socket.io-parser@~4.2.4: "@socket.io/component-emitter" "~3.1.0" debug "~4.3.1" -socket.io@^4.0.0, socket.io@^4.1.3, socket.io@^4.7.2: - version "4.7.5" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.7.5.tgz#56eb2d976aef9d1445f373a62d781a41c7add8f8" - integrity sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA== - dependencies: - accepts "~1.3.4" - base64id "~2.0.0" - cors "~2.8.5" - debug "~4.3.2" - engine.io "~6.5.2" - socket.io-adapter "~2.5.2" - socket.io-parser "~4.2.4" - -socket.io@^4.7.4, socket.io@^4.8.0: - version "4.8.0" - resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.0.tgz#33d05ae0915fad1670bd0c4efcc07ccfabebe3b1" - integrity sha512-8U6BEgGjQOfGz3HHTYaC/L1GaxDCJ/KM0XTkJly0EhZ5U/du9uNEZy4ZgYzEzIqlx2CMm25CrCqr1ck899eLNA== +socket.io@^4.0.0, socket.io@^4.1.3, socket.io@^4.7.2, socket.io@^4.7.4, socket.io@^4.8.0: + version "4.8.1" + resolved "https://registry.yarnpkg.com/socket.io/-/socket.io-4.8.1.tgz#fa0eaff965cc97fdf4245e8d4794618459f7558a" + integrity sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg== dependencies: accepts "~1.3.4" base64id "~2.0.0"