diff --git a/frontend/.eslintrc b/frontend/.eslintrc index 8d9e726c..6a358f5a 100644 --- a/frontend/.eslintrc +++ b/frontend/.eslintrc @@ -8,23 +8,22 @@ "extends": ["eslint:recommended", "plugin:import/errors", "plugin:import/warnings", "plugin:react-hooks/recommended", "plugin:react/recommended"], "settings": { "import/resolver": { - "node": { - "extensions": [".js", ".jsx", ".ts", ".tsx"] - } + "typescript": {} }, "react": { "pragma": "React", "version": "detect" } }, - "parser": "@babel/eslint-parser", + "parser": "@typescript-eslint/parser", "parserOptions": { - "sourceType": "module" + "project": "./tsconfig.json", + "tsconfigRootDir": "./" }, "globals": { "mender_environment": "readonly" }, - "plugins": ["react", "prettier", "sonarjs"], + "plugins": ["react", "prettier", "sonarjs", "@typescript-eslint", "import"], "rules": { "prettier/prettier": "error", "consistent-this": ["error", "self"], diff --git a/frontend/babel.config.json b/frontend/babel.config.json index 97d499cf..07a0ccf1 100644 --- a/frontend/babel.config.json +++ b/frontend/babel.config.json @@ -10,6 +10,5 @@ ], "@babel/preset-typescript", ["@babel/preset-react", { "runtime": "automatic" }] - ], - "plugins": ["@babel/plugin-syntax-dynamic-import", "@babel/plugin-transform-runtime"] + ] } diff --git a/frontend/jest.config.json b/frontend/jest.config.json new file mode 100644 index 00000000..f6de7b60 --- /dev/null +++ b/frontend/jest.config.json @@ -0,0 +1,57 @@ +{ + "collectCoverage": true, + "collectCoverageFrom": [ + "src/**/*.{js,ts,tsx}" + ], + "coveragePathIgnorePatterns": [ + "/node_modules/", + "/themes/" + ], + "coverageReporters": [ + [ + "lcov", + { + "projectRoot": "../" + } + ], + "text" + ], + "setupFiles": [ + "/tests/jest.polyfills.js" + ], + "setupFilesAfterEnv": [ + "/tests/setupTests.js" + ], + "snapshotSerializers": [ + "@emotion/jest/serializer" + ], + "testEnvironment": "jest-environment-jsdom", + "testMatch": [ + "/src/**/__tests__/**/*.{js,ts,tsx}", + "/src/**/*.{spec,test}.{js,ts,tsx}" + ], + "fakeTimers": { + "enableGlobally": true + }, + "preset": "ts-jest/presets/js-with-babel", + "testEnvironmentOptions": { + "customExportConditions": [ + "" + ] + }, + "transform": { + "\\.[j|t]sx?$": "babel-jest", + "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/tests/assetsTransformer.js" + }, + "transformIgnorePatterns": [ + "/node_modules/(?!xterm-for-react|node-fetch|jsdom-worker|data-uri-to-buffer|fetch-blob|formdata-polyfill)" + ], + "moduleNameMapper": { + "\\.(css|less)$": "/tests/cssTransform.js", + "^@northern.tech/store/(.*)$": "/src/js/store/$1" + }, + "watchPlugins": [ + "jest-watch-typeahead/filename", + "jest-watch-typeahead/testname" + ] +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json index bcd99e6f..7ea06574 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -47,7 +47,7 @@ "react-router-dom": "6.26.2", "redux-thunk": "^3.1.0", "tss-react": "4.9.13", - "universal-cookie": "7.2.0", + "universal-cookie": "7.1.4", "uuid": "10.0.0", "validator": "13.12.0", "victory": "37.1.1", @@ -55,9 +55,6 @@ }, "devDependencies": { "@babel/core": "7.25.2", - "@babel/eslint-parser": "7.25.1", - "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/plugin-transform-runtime": "7.25.4", "@babel/preset-env": "7.25.4", "@babel/preset-react": "7.24.7", "@babel/preset-typescript": "^7.24.7", @@ -85,6 +82,7 @@ "css-loader": "7.1.2", "esbuild-loader": "4.2.2", "eslint": "8.57.0", + "eslint-import-resolver-typescript": "3.6.1", "eslint-plugin-import": "2.30.0", "eslint-plugin-prettier": "5.2.1", "eslint-plugin-react": "7.36.1", @@ -112,6 +110,8 @@ "process": "0.11.10", "redux-mock-store": "1.5.4", "stream-browserify": "3.0.0", + "ts-jest": "^29.2.5", + "tsconfig-paths-webpack-plugin": "^4.1.0", "typescript": "^5.6.2", "undici": "^6.19.8", "util": "0.12.5", @@ -203,24 +203,6 @@ "url": "https://opencollective.com/babel" } }, - "node_modules/@babel/eslint-parser": { - "version": "7.25.1", - "resolved": "https://registry.npmjs.org/@babel/eslint-parser/-/eslint-parser-7.25.1.tgz", - "integrity": "sha512-Y956ghgTT4j7rKesabkh5WeqgSFZVFwaPR0IWFm7KFHFmmJ4afbG49SmfW4S+GyRPx0Dy5jxEWA5t0rpxfElWg==", - "dev": true, - "dependencies": { - "@nicolo-ribaudo/eslint-scope-5-internals": "5.1.1-v1", - "eslint-visitor-keys": "^2.1.0", - "semver": "^6.3.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || >=14.0.0" - }, - "peerDependencies": { - "@babel/core": "^7.11.0", - "eslint": "^7.5.0 || ^8.0.0 || ^9.0.0" - } - }, "node_modules/@babel/generator": { "version": "7.25.6", "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.6.tgz", @@ -1740,26 +1722,6 @@ "@babel/core": "^7.0.0-0" } }, - "node_modules/@babel/plugin-transform-runtime": { - "version": "7.25.4", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.25.4.tgz", - "integrity": "sha512-8hsyG+KUYGY0coX6KUCDancA0Vw225KJ2HJO0yCNr1vq5r+lJTleDaJf0K7iOhjw4SWhu03TMBzYTJ9krmzULQ==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.8", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.10.6", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "node_modules/@babel/plugin-transform-shorthand-properties": { "version": "7.24.7", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.24.7.tgz", @@ -2430,70 +2392,6 @@ "resolved": "https://registry.npmjs.org/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz", "integrity": "sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==" }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@esbuild/darwin-arm64": { "version": "0.21.5", "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", @@ -2510,294 +2408,6 @@ "node": ">=12" } }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -4536,6 +4146,7 @@ "version": "1.19.2", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.19.2.tgz", "integrity": "sha512-baiMx18+IMuD1yyvOGaHM9QrVUPGGG0jC+z+IPHnRJWUAUvaKuWKyE8gjDj2rzv3sz9zOGoRSPgeBVHRhZnBlA==", + "license": "MIT", "engines": { "node": ">=14.0.0" } @@ -4597,6 +4208,7 @@ "version": "2.8.0", "resolved": "https://registry.npmjs.org/@stripe/react-stripe-js/-/react-stripe-js-2.8.0.tgz", "integrity": "sha512-Vf1gNEuBxA9EtxiLghm2ZWmgbADNMJw4HW6eolUu0DON/6mZvWZgk0KHolN0sozNJwYp0i/8hBsDBcBUWcvnbw==", + "license": "MIT", "dependencies": { "prop-types": "^15.7.2" }, @@ -4610,6 +4222,7 @@ "version": "4.4.0", "resolved": "https://registry.npmjs.org/@stripe/stripe-js/-/stripe-js-4.4.0.tgz", "integrity": "sha512-p1WeTOwnAyXQ9I5/YC3+JXoUB6NKMR4qGjBobie2+rgYa3ftUTRS2L5qRluw/tGACty5SxqnfORCdsaymD1XjQ==", + "license": "MIT", "engines": { "node": ">=12.16" } @@ -5274,22 +4887,26 @@ "node_modules/@types/d3-array": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.1.tgz", - "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==" + "integrity": "sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==", + "license": "MIT" }, "node_modules/@types/d3-color": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", - "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==" + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" }, "node_modules/@types/d3-ease": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", - "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==" + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", "dependencies": { "@types/d3-color": "*" } @@ -5297,12 +4914,14 @@ "node_modules/@types/d3-path": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.0.tgz", - "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==" + "integrity": "sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==", + "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.8.tgz", "integrity": "sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==", + "license": "MIT", "dependencies": { "@types/d3-time": "*" } @@ -5311,6 +4930,7 @@ "version": "3.1.6", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.6.tgz", "integrity": "sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==", + "license": "MIT", "dependencies": { "@types/d3-path": "*" } @@ -5318,12 +4938,14 @@ "node_modules/@types/d3-time": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.3.tgz", - "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==" + "integrity": "sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==", + "license": "MIT" }, "node_modules/@types/d3-timer": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", - "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==" + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" }, "node_modules/@types/eslint": { "version": "8.56.10", @@ -5493,9 +5115,10 @@ "integrity": "sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==" }, "node_modules/@types/react": { - "version": "18.3.6", - "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.6.tgz", - "integrity": "sha512-CnGaRYNu2iZlkGXGrOYtdg5mLK8neySj0woZ4e2wF/eli2E6Sazmq5X+Nrj6OBrrFVQfJWTUFeqAzoRhWQXYvg==", + "version": "18.3.7", + "resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.7.tgz", + "integrity": "sha512-KUnDCJF5+AiZd8owLIeVHqmW9yM4sqmDVf2JRJiBMFkGvkoZ4/WyV2lL4zVsoinmRS/W3FeEdZLEWFRofnT2FQ==", + "license": "MIT", "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -6457,6 +6080,13 @@ "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", "dev": true }, + "node_modules/async": { + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==", + "dev": true, + "license": "MIT" + }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", @@ -6535,6 +6165,7 @@ "version": "1.7.7", "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.7.tgz", "integrity": "sha512-S4kL7XrjgBmvdGut0sN3yJxqYzrDOnivkBiN0OFs6hLiUam3UPvswUo0kqGyhqUZGEOytHyumEdXsAkgCOUf3Q==", + "license": "MIT", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -6970,6 +6601,19 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bs-logger": { + "version": "0.2.6", + "resolved": "https://registry.npmjs.org/bs-logger/-/bs-logger-0.2.6.tgz", + "integrity": "sha512-pd8DCoxmbgc7hyPKOvxtqNcjYoOsABPQdcCUjGp3d42VR2CX1ORhk2A87oqqu5R1kk+76nsxZupkmyd+MVtCog==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-json-stable-stringify": "2.x" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/bser": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/bser/-/bser-2.1.1.tgz", @@ -8002,6 +7646,7 @@ "version": "3.2.4", "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", "dependencies": { "internmap": "1 - 2" }, @@ -8013,6 +7658,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", "engines": { "node": ">=12" } @@ -8021,6 +7667,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", "engines": { "node": ">=12" } @@ -8029,6 +7676,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.0.tgz", "integrity": "sha512-YyUI6AEuY/Wpt8KWLgZHsIU86atmikuoOmCfommt0LYHiQSPjvX2AcFc38PX0CBpr2RCyZhjex+NS/LPOv6YqA==", + "license": "ISC", "engines": { "node": ">=12" } @@ -8037,6 +7685,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", "dependencies": { "d3-color": "1 - 3" }, @@ -8048,6 +7697,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", "engines": { "node": ">=12" } @@ -8056,6 +7706,7 @@ "version": "4.0.2", "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", @@ -8071,6 +7722,7 @@ "version": "3.2.0", "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", "dependencies": { "d3-path": "^3.1.0" }, @@ -8082,6 +7734,7 @@ "version": "3.1.0", "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", "dependencies": { "d3-array": "2 - 3" }, @@ -8093,6 +7746,7 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", "dependencies": { "d3-time": "1 - 3" }, @@ -8104,6 +7758,7 @@ "version": "3.0.1", "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", "engines": { "node": ">=12" } @@ -8111,7 +7766,8 @@ "node_modules/d3-voronoi": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", - "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==" + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==", + "license": "BSD-3-Clause" }, "node_modules/damerau-levenshtein": { "version": "1.0.8", @@ -8379,12 +8035,14 @@ "node_modules/delaunator": { "version": "4.0.1", "resolved": "https://registry.npmjs.org/delaunator/-/delaunator-4.0.1.tgz", - "integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==" + "integrity": "sha512-WNPWi1IRKZfCt/qIDMfERkDp93+iZEmOxN2yy4Jg+Xhv8SLk2UTqqbe1sfiipn0and9QrE914/ihdx82Y/Giag==", + "license": "ISC" }, "node_modules/delaunay-find": { "version": "0.0.6", "resolved": "https://registry.npmjs.org/delaunay-find/-/delaunay-find-0.0.6.tgz", "integrity": "sha512-1+almjfrnR7ZamBk0q3Nhg6lqSe6Le4vL0WJDSMx4IDbQwTpUTXPjxC00lqLBT8MYsJpPCbI16sIkw9cPsbi7Q==", + "license": "ISC", "dependencies": { "delaunator": "^4.0.0" } @@ -8587,6 +8245,22 @@ "tslib": "^2.0.3" } }, + "node_modules/ejs": { + "version": "3.1.10", + "resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz", + "integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "jake": "^10.8.5" + }, + "bin": { + "ejs": "bin/cli.js" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.4", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.4.tgz", @@ -9087,6 +8761,32 @@ "ms": "^2.1.1" } }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.6.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.6.1.tgz", + "integrity": "sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==", + "dev": true, + "license": "ISC", + "dependencies": { + "debug": "^4.3.4", + "enhanced-resolve": "^5.12.0", + "eslint-module-utils": "^2.7.4", + "fast-glob": "^3.3.1", + "get-tsconfig": "^4.5.0", + "is-core-module": "^2.11.0", + "is-glob": "^4.0.3" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/unts/projects/eslint-import-resolver-ts" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*" + } + }, "node_modules/eslint-module-utils": { "version": "2.11.0", "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.11.0.tgz", @@ -10640,6 +10340,39 @@ "node": ">= 12" } }, + "node_modules/filelist": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/filelist/-/filelist-1.0.4.tgz", + "integrity": "sha512-w1cEuf3S+DrLCQL7ET6kz+gmlJdbq9J7yXCSjK/OZCPA+qEN1WyF4ZAf0YYJa4/shHJra2t/d/r8SV4Ji+x+8Q==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "minimatch": "^5.0.1" + } + }, + "node_modules/filelist/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/filelist/node_modules/minimatch": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", + "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -11521,6 +11254,7 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", "engines": { "node": ">=12" } @@ -12103,59 +11837,154 @@ "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, "dependencies": { - "has-flag": "^4.0.0" + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-source-maps": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", + "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "dev": true, + "dependencies": { + "debug": "^4.1.1", + "istanbul-lib-coverage": "^3.0.0", + "source-map": "^0.6.1" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-lib-source-maps/node_modules/source-map": { + "version": "0.6.1", + "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", + "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/istanbul-reports": { + "version": "3.1.6", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", + "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "dev": true, + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/iterator.prototype": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", + "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "dev": true, + "dependencies": { + "define-properties": "^1.2.1", + "get-intrinsic": "^1.2.1", + "has-symbols": "^1.0.3", + "reflect.getprototypeof": "^1.0.4", + "set-function-name": "^2.0.1" + } + }, + "node_modules/jake": { + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "async": "^3.2.3", + "chalk": "^4.0.2", + "filelist": "^1.0.4", + "minimatch": "^3.1.2" + }, + "bin": { + "jake": "bin/cli.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/jake/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" }, "engines": { "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/istanbul-lib-source-maps": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/istanbul-lib-source-maps/-/istanbul-lib-source-maps-4.0.1.tgz", - "integrity": "sha512-n3s8EwkdFIJCG3BPKBYvskgXGoy88ARzvegkitk60NxRdwltLOTaH7CUiMRXvwYorl0Q712iEjcWB+fK/MrWVw==", + "node_modules/jake/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", "dev": true, + "license": "MIT", "dependencies": { - "debug": "^4.1.1", - "istanbul-lib-coverage": "^3.0.0", - "source-map": "^0.6.1" + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" }, "engines": { "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" } }, - "node_modules/istanbul-lib-source-maps/node_modules/source-map": { - "version": "0.6.1", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", - "integrity": "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==", + "node_modules/jake/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, "engines": { - "node": ">=0.10.0" + "node": ">=7.0.0" } }, - "node_modules/istanbul-reports": { - "version": "3.1.6", - "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.1.6.tgz", - "integrity": "sha512-TLgnMkKg3iTDsQ9PbPTdpfAK2DzjF9mqUG7RMgcQl8oFjad8ob4laGxv5XV5U9MAfx8D6tSJiUyuAwzLicaxlg==", + "node_modules/jake/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", "dev": true, - "dependencies": { - "html-escaper": "^2.0.0", - "istanbul-lib-report": "^3.0.0" - }, + "license": "MIT" + }, + "node_modules/jake/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", "engines": { "node": ">=8" } }, - "node_modules/iterator.prototype": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.2.tgz", - "integrity": "sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==", + "node_modules/jake/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", "dev": true, + "license": "MIT", "dependencies": { - "define-properties": "^1.2.1", - "get-intrinsic": "^1.2.1", - "has-symbols": "^1.0.3", - "reflect.getprototypeof": "^1.0.4", - "set-function-name": "^2.0.1" + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" } }, "node_modules/javascript-natural-sort": { @@ -14261,7 +14090,8 @@ "node_modules/json-stringify-safe": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", - "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==" + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "license": "ISC" }, "node_modules/json5": { "version": "2.2.3", @@ -14733,6 +14563,13 @@ "integrity": "sha512-oSXzaWypCMHkPC3NvBEaPHf0KsA5mvPrOPgQWDsbg8n7orZ290M0BmC/jgRZ4vcJ6DTAhjrsSYgdsW/F+MFOBA==", "dev": true }, + "node_modules/lodash.memoize": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/lodash.memoize/-/lodash.memoize-4.1.2.tgz", + "integrity": "sha512-t7j+NzmgnQzTAYXcsHYLgimltOV1MXHtlOWf6GjL9Kj8GK5FInw5JotxvbOs+IvV1/Dzo04/fCGfLVs7aXb4Ag==", + "dev": true, + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -14938,6 +14775,13 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "dev": true }, + "node_modules/make-error": { + "version": "1.3.6", + "resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz", + "integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==", + "dev": true, + "license": "ISC" + }, "node_modules/makeerror": { "version": "1.0.12", "resolved": "https://registry.npmjs.org/makeerror/-/makeerror-1.0.12.tgz", @@ -16435,6 +16279,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" }, @@ -16513,6 +16358,7 @@ "version": "1.14.1", "resolved": "https://registry.npmjs.org/react-big-calendar/-/react-big-calendar-1.14.1.tgz", "integrity": "sha512-6Le0kV/4yiV/mlqv5YYBBS+FaBeYBPNGjcYitLoVdPCiXsc0xzSHyX8+2FRqX9AM16XZYIjjomouK3wcnq6+XQ==", + "license": "MIT", "dependencies": { "@babel/runtime": "^7.20.7", "clsx": "^1.2.1", @@ -16565,6 +16411,7 @@ "version": "18.3.1", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -16592,7 +16439,8 @@ "node_modules/react-fast-compare": { "version": "3.2.2", "resolved": "https://registry.npmjs.org/react-fast-compare/-/react-fast-compare-3.2.2.tgz", - "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==" + "integrity": "sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==", + "license": "MIT" }, "node_modules/react-ga4": { "version": "2.1.0", @@ -16615,6 +16463,7 @@ "version": "7.53.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.53.0.tgz", "integrity": "sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==", + "license": "MIT", "engines": { "node": ">=18.0.0" }, @@ -16699,6 +16548,7 @@ "version": "6.26.2", "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.26.2.tgz", "integrity": "sha512-tvN1iuT03kHgOFnLPfLJ8V95eijteveqdOSk+srqfePtQvqCExB8eHOYnlilbOcyJyKnYkr1vJvf7YqotAJu1A==", + "license": "MIT", "dependencies": { "@remix-run/router": "1.19.2" }, @@ -16713,6 +16563,7 @@ "version": "6.26.2", "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.26.2.tgz", "integrity": "sha512-z7YkaEW0Dy35T3/QKPYB1LjMK2R1fxnHO8kWpUMTBdfVzZrWOiY9a7CtN8HqdWtDUWd5FY6Dl8HFsqVwH4uOtQ==", + "license": "MIT", "dependencies": { "@remix-run/router": "1.19.2", "react-router": "6.26.2" @@ -17236,6 +17087,7 @@ "version": "0.23.2", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", "dependencies": { "loose-envify": "^1.1.0" } @@ -18216,6 +18068,68 @@ "typescript": ">=4.2.0" } }, + "node_modules/ts-jest": { + "version": "29.2.5", + "resolved": "https://registry.npmjs.org/ts-jest/-/ts-jest-29.2.5.tgz", + "integrity": "sha512-KD8zB2aAZrcKIdGk4OwpJggeLcH1FgrICqDSROWqlnJXGCXK4Mn6FcdK2B6670Xr73lHMG1kHw8R87A0ecZ+vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "bs-logger": "^0.2.6", + "ejs": "^3.1.10", + "fast-json-stable-stringify": "^2.1.0", + "jest-util": "^29.0.0", + "json5": "^2.2.3", + "lodash.memoize": "^4.1.2", + "make-error": "^1.3.6", + "semver": "^7.6.3", + "yargs-parser": "^21.1.1" + }, + "bin": { + "ts-jest": "cli.js" + }, + "engines": { + "node": "^14.15.0 || ^16.10.0 || ^18.0.0 || >=20.0.0" + }, + "peerDependencies": { + "@babel/core": ">=7.0.0-beta.0 <8", + "@jest/transform": "^29.0.0", + "@jest/types": "^29.0.0", + "babel-jest": "^29.0.0", + "jest": "^29.0.0", + "typescript": ">=4.3 <6" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "@jest/transform": { + "optional": true + }, + "@jest/types": { + "optional": true + }, + "babel-jest": { + "optional": true + }, + "esbuild": { + "optional": true + } + } + }, + "node_modules/ts-jest/node_modules/semver": { + "version": "7.6.3", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz", + "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/tsconfig-paths": { "version": "3.15.0", "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", @@ -18228,6 +18142,122 @@ "strip-bom": "^3.0.0" } }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths-webpack-plugin/-/tsconfig-paths-webpack-plugin-4.1.0.tgz", + "integrity": "sha512-xWFISjviPydmtmgeUAuXp4N1fky+VCtfhOkDUFIv5ea7p4wuTomI4QTrXvFBX2S4jZsmyTSrStQl+E+4w+RzxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/tsconfig-paths-webpack-plugin/node_modules/tsconfig-paths": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", + "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/tsconfig-paths/node_modules/json5": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", @@ -18258,6 +18288,7 @@ "version": "4.9.13", "resolved": "https://registry.npmjs.org/tss-react/-/tss-react-4.9.13.tgz", "integrity": "sha512-Gu19qqPH8/SAyKVIgDE5qHygirEDnNIQcXhiEc+l4Q9T7C1sfvUnbVWs+yBpmN26/wyk4FTOupjYS2wq4vH0yA==", + "license": "MIT", "dependencies": { "@emotion/cache": "*", "@emotion/serialize": "*", @@ -18499,9 +18530,10 @@ } }, "node_modules/universal-cookie": { - "version": "7.2.0", - "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.2.0.tgz", - "integrity": "sha512-PvcyflJAYACJKr28HABxkGemML5vafHmiL4ICe3e+BEKXRMt0GaFLZhAwgv637kFFnnfiSJ8e6jknrKkMrU+PQ==", + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/universal-cookie/-/universal-cookie-7.1.4.tgz", + "integrity": "sha512-Q+DVJsdykStWRMtXr2Pdj3EF98qZHUH/fXv/gwFz/unyToy1Ek1w5GsWt53Pf38tT8Gbcy5QNsj61Xe9TggP4g==", + "license": "MIT", "dependencies": { "@types/cookie": "^0.6.0", "cookie": "^0.6.0" @@ -18660,6 +18692,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory/-/victory-37.1.1.tgz", "integrity": "sha512-3tyIZ79YVd9bxS3KocGa6UuQdCA4Kenqzh3Th7QBB7Am96MHXVyePsYwhg0KorOmKqocQxYgLShGIjEHT1Qv+w==", + "license": "MIT", "dependencies": { "victory-area": "37.1.1", "victory-axis": "37.1.1", @@ -18697,6 +18730,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-area/-/victory-area-37.1.1.tgz", "integrity": "sha512-9OVILTIT5DW/BsMksZ1xCjmNrT0iIhsHnumeNJDvvfzWUeqLyYPwmqp8e2wRraj1VRhRAAgZGXAHi7XA3rJkgQ==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1", @@ -18710,6 +18744,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-axis/-/victory-axis-37.1.1.tgz", "integrity": "sha512-LqlXoAHNxvS/GdAKR6YSHZf0I9egMZf84kqUb7dG3NNLE8M1XnaEkYlfIOJsL+vsZJqm4kqoe67yI56eqIY5Hw==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" @@ -18722,6 +18757,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-bar/-/victory-bar-37.1.1.tgz", "integrity": "sha512-1e1QtVDMgFRwXZDrt9nT1Fqv57yHL9Z9ssA2mgyzV/wi/HRneuUXE958Q/t59z4cTEkRYwNrUE3dODBCpxXMKw==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1", @@ -18735,6 +18771,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-box-plot/-/victory-box-plot-37.1.1.tgz", "integrity": "sha512-cdmAxg1Sqt/c2lbPJdD8+4qBNj8UMav8fLtsGd/uCNHWYzv52+0g9B8ToE6ImsKyBFRGnW+c0BD5vKbtyW6tJw==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1", @@ -18748,6 +18785,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-brush-container/-/victory-brush-container-37.1.1.tgz", "integrity": "sha512-iZkp/r7uzkc7UN3EgAWe4aDDEFHe7BQs0nv/mmyFeFYIXG5e2uiKs28OsZnfgp6CDIHDqUoV8DAGOccotUbUaQ==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "react-fast-compare": "^3.2.0", @@ -18761,6 +18799,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-brush-line/-/victory-brush-line-37.1.1.tgz", "integrity": "sha512-nsuJW7VFYFO2R+i0wveC4nizOhLj/UcTHwv98J6PYt3c0LQXa04YMFOfrRuKV/+Qsrj4DOVO3/GU6/PSUwozlQ==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "react-fast-compare": "^3.2.0", @@ -18774,6 +18813,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-candlestick/-/victory-candlestick-37.1.1.tgz", "integrity": "sha512-M5ftMbFi8HM9QYLrPb1DfrHOYKCwnDkxe8ct8MjE2ibsnKNCxUrwjJbkh0QXPa4ndk5y4jl98T9FmJS1Q14nPg==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" @@ -18786,6 +18826,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-canvas/-/victory-canvas-37.1.1.tgz", "integrity": "sha512-nq+du3x2D8sdRfNNg2idieElJbwq7vI2DO5FoFyFyowX6plXjOXoJZAOX/+7GTBQ4FP7tktNka5AQ9z8u5Sxbw==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-bar": "37.1.1", @@ -18799,6 +18840,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-chart/-/victory-chart-37.1.1.tgz", "integrity": "sha512-p//04lKzUX1ocXmp9RWmQMOsQUcP7m1CsrYkBOvqzD1sjgMhDzTqZdn38rMUzW0bpbCs0Tl6wbOzxMN+/PA8fQ==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "react-fast-compare": "^3.2.0", @@ -18815,6 +18857,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-core/-/victory-core-37.1.1.tgz", "integrity": "sha512-4UK1S1+9CFBn1Nwu18JsOf2EtaTI/DOE4Eoi5byLd6kFO8/luSbaLvc7BDPxiLpSj0BGiX/Hbqs12T2gPaEnAA==", + "license": "MIT", "dependencies": { "lodash": "^4.17.21", "react-fast-compare": "^3.2.0", @@ -18828,6 +18871,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-create-container/-/victory-create-container-37.1.1.tgz", "integrity": "sha512-t/soXK97TcP4yxHYwvfCWJW9jGlRyYS4zdhjLe9Q2iETY0ngiVk+bpETZVPMgubPxq3JPaogMQKgd+1hDWjBMg==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-brush-container": "37.1.1", @@ -18845,6 +18889,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-cursor-container/-/victory-cursor-container-37.1.1.tgz", "integrity": "sha512-m2YS7nmAcGHatVhuqjuJW7jXRXutI0e1pBz9PbHm692HNAJbMfFTJAKtgPXUj5wYVae4OAr6f0551/ekkcL7xQ==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" @@ -18857,6 +18902,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-errorbar/-/victory-errorbar-37.1.1.tgz", "integrity": "sha512-1nDaa6zT/OaA99DYwznwEwbD3lHfsnBV0UbUlQn91Hv99sg0Rvyk9cZinQWTZ0nNf8cNBYOzZlpFxY35XbQVSA==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" @@ -18869,6 +18915,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-group/-/victory-group-37.1.1.tgz", "integrity": "sha512-170CnQ6+doT8VUPZzcq6IIluSMSYqactT9J0ANSDEwHsO/+r0tFwez44FtA4/DgdDh5ObWQ6VfQx330urMG5bA==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "react-fast-compare": "^3.2.0", @@ -18883,6 +18930,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-histogram/-/victory-histogram-37.1.1.tgz", "integrity": "sha512-2KnfdQYaO+MELM/PB3saPHcUf+tHg0SwbaLHKRk+Im7+aQRUlprlHH7sHZJM/TYXCkJdqbQQNoW6R9VK/kQiGg==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "react-fast-compare": "^3.2.0", @@ -18898,6 +18946,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-legend/-/victory-legend-37.1.1.tgz", "integrity": "sha512-8F51DbYzG+jkMJoGp2Ulqqxgoq00TWgvQcBTZptdrN2PFlc2b1Ug7z3lbK1ziUCunrVbHQpAhge0onDoRyn1Vg==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" @@ -18910,6 +18959,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-line/-/victory-line-37.1.1.tgz", "integrity": "sha512-YLR9/i7BwN3taBvHCfmc5hA0po16QFQuFnO61NPNCBZtv8kNf39m3BpDTDYMeuEgEBCnMw0znR0C1NASZcJDWQ==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1", @@ -18923,6 +18973,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-pie/-/victory-pie-37.1.1.tgz", "integrity": "sha512-GWHR4prUq6ZNeMd0IEywHvvWn3dkn7vS3fkLMVTKitpbMIRPGlFxo5gLTkAQv3nnA/762GLSyELbcFgFQXOQUA==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1", @@ -18936,6 +18987,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-polar-axis/-/victory-polar-axis-37.1.1.tgz", "integrity": "sha512-I9okmw1MauiucV6WxylHDOZtW5mgrozYmfglOSR6fnQ9gcxPoXSgBNxo801kyV2/pu8BP6dD07Uz1QLbCh3KSA==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" @@ -18948,6 +19000,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-scatter/-/victory-scatter-37.1.1.tgz", "integrity": "sha512-2jt0HgYnLngw8oVAY5Tcq2MEHVc3FDo47gMQf7LysFvsuCtBLvgkaDuRPnF+8Ty3hP/7qwjV9tgM7Ui2cSfZSg==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" @@ -18960,6 +19013,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-selection-container/-/victory-selection-container-37.1.1.tgz", "integrity": "sha512-5FYlMQNt7uV+EfndtCTYkE5/yjnHo243ZnBiUzXmvXU+IBCjzXmcOeyqyn7IY7+p1fvA2Hc698mDLGydd8QJrA==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" @@ -18972,6 +19026,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-shared-events/-/victory-shared-events-37.1.1.tgz", "integrity": "sha512-hMZI4GMLNWoIQ/Yso/tiTKpx5wUgNi2iwozrxWDesr11I5uqwutkBeHpIBMBwsGRWy6plkMyBp9lCf2Etkxm4A==", + "license": "MIT", "dependencies": { "json-stringify-safe": "^5.0.1", "lodash": "^4.17.19", @@ -18986,6 +19041,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-stack/-/victory-stack-37.1.1.tgz", "integrity": "sha512-jIHV7xRZW8jEuOGjrEreIh/u1mddDix98NmIJnd2+qMk1EuWIHngC2neCKQ0iF3wc8eAMuaK8gGr6ksSkpsqPA==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "react-fast-compare": "^3.2.0", @@ -19000,6 +19056,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-tooltip/-/victory-tooltip-37.1.1.tgz", "integrity": "sha512-n5TTR92jIDaeXSADV+edevcMcNLz1iPwzQr7CNX38vWU6RWf/FRcdiBlBNg3v4rNh41+sO8jjMQhjOpDti6Rvw==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" @@ -19012,6 +19069,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.1.1.tgz", "integrity": "sha512-WDnoGOSqmgyFgY/+7v4i40Vc/I/iOqc9JpUniWO9TvLCWAVEmwAjKxrorBlxEv+vQxQuhxGKOf3PcJqfjZqA9g==", + "license": "MIT AND ISC", "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", @@ -19033,6 +19091,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-voronoi/-/victory-voronoi-37.1.1.tgz", "integrity": "sha512-LIGT4JLP+9GxzvA1rka3W8iHXx8TXvGDzcgDhj3E14dSjkDkYaX0/tyBBirHo7T3IFHThAO6GNPsfMrCzz8Z9w==", + "license": "MIT", "dependencies": { "d3-voronoi": "^1.1.4", "lodash": "^4.17.19", @@ -19046,6 +19105,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-voronoi-container/-/victory-voronoi-container-37.1.1.tgz", "integrity": "sha512-OIiT/KroQCvPaITEGcZfPd7B5Byw2vjo52RiUfzdg5WfCvqxuOURnvXsv6lh8nTNS/VI9uWaxHYdATXqXtNgfA==", + "license": "MIT", "dependencies": { "delaunay-find": "0.0.6", "lodash": "^4.17.19", @@ -19061,6 +19121,7 @@ "version": "37.1.1", "resolved": "https://registry.npmjs.org/victory-zoom-container/-/victory-zoom-container-37.1.1.tgz", "integrity": "sha512-pBW64iT9zlFqmo468+MXkqNwJuuM+Q/+5/llFCKBoMA6wE1SwpkgHQ8RITWQUDCY9dR3y/bJFLEQg2aqoFB8/g==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.1.1" diff --git a/frontend/package.json b/frontend/package.json index 018651af..ccc2720d 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -14,6 +14,10 @@ "@reduxjs/toolkit": "2.2.7", "@stripe/react-stripe-js": "2.8.0", "@stripe/stripe-js": "4.4.0", + "@xterm/addon-fit": "0.10.0", + "@xterm/addon-search": "0.15.0", + "@xterm/addon-web-links": "0.11.0", + "@xterm/xterm": "5.5.0", "axios": "1.7.7", "copy-to-clipboard": "3.3.3", "dayjs": "^1.11.13", @@ -37,36 +41,29 @@ "react-router-dom": "6.26.2", "redux-thunk": "^3.1.0", "tss-react": "4.9.13", - "universal-cookie": "7.2.0", + "universal-cookie": "7.1.4", "uuid": "10.0.0", "validator": "13.12.0", "victory": "37.1.1", - "@xterm/xterm": "5.5.0", - "@xterm/addon-fit": "0.10.0", - "@xterm/addon-search": "0.15.0", - "@xterm/addon-web-links": "0.11.0", "zxcvbn": "4.4.2" }, "devDependencies": { "@babel/core": "7.25.2", - "@babel/eslint-parser": "7.25.1", - "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/plugin-transform-runtime": "7.25.4", "@babel/preset-env": "7.25.4", "@babel/preset-react": "7.24.7", + "@babel/preset-typescript": "^7.24.7", "@emotion/jest": "11.13.0", "@svgr/webpack": "8.1.0", "@testing-library/jest-dom": "6.5.0", "@testing-library/react": "^16.0.1", "@testing-library/user-event": "14.5.2", "@trivago/prettier-plugin-sort-imports": "4.3.0", - "@typescript-eslint/eslint-plugin": "8.5.0", - "@typescript-eslint/parser": "8.5.0", - "@babel/preset-typescript": "^7.24.7", "@types/jest": "^29.5.13", "@types/node": "^22.5.5", "@types/react": "^18.3.6", "@types/react-dom": "^18.3.0", + "@typescript-eslint/eslint-plugin": "8.5.0", + "@typescript-eslint/parser": "8.5.0", "assert": "2.1.0", "autoprefixer": "10.4.20", "babel-jest": "~29.7.0", @@ -79,6 +76,7 @@ "css-loader": "7.1.2", "esbuild-loader": "4.2.2", "eslint": "8.57.0", + "eslint-import-resolver-typescript": "3.6.1", "eslint-plugin-import": "2.30.0", "eslint-plugin-prettier": "5.2.1", "eslint-plugin-react": "7.36.1", @@ -106,6 +104,8 @@ "process": "0.11.10", "redux-mock-store": "1.5.4", "stream-browserify": "3.0.0", + "ts-jest": "^29.2.5", + "tsconfig-paths-webpack-plugin": "^4.1.0", "typescript": "^5.6.2", "undici": "^6.19.8", "util": "0.12.5", @@ -134,61 +134,6 @@ "post-commit": "sh ${MENDER_TESTING}/check_commits.sh" } }, - "jest": { - "collectCoverage": true, - "collectCoverageFrom": [ - "src/**/*.{js,jsx}" - ], - "coveragePathIgnorePatterns": [ - "/node_modules/", - "/themes/" - ], - "coverageReporters": [ - [ - "lcov", - { - "projectRoot": "../" - } - ], - "text" - ], - "setupFiles": [ - "/tests/jest.polyfills.js" - ], - "setupFilesAfterEnv": [ - "/tests/setupTests.js" - ], - "snapshotSerializers": [ - "@emotion/jest/serializer" - ], - "testEnvironment": "jest-environment-jsdom", - "testMatch": [ - "/src/**/__tests__/**/*.js", - "/src/**/*.{spec,test}.js" - ], - "fakeTimers": { - "enableGlobally": true - }, - "testEnvironmentOptions": { - "customExportConditions": [ - "" - ] - }, - "transform": { - "\\.[j|t]sx?$": "babel-jest", - "\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "/tests/assetsTransformer.js" - }, - "transformIgnorePatterns": [ - "/node_modules/(?!xterm-for-react|node-fetch|jsdom-worker|data-uri-to-buffer|fetch-blob|formdata-polyfill)" - ], - "moduleNameMapper": { - "\\.(css|less)$": "/tests/cssTransform.js" - }, - "watchPlugins": [ - "jest-watch-typeahead/filename", - "jest-watch-typeahead/testname" - ] - }, "scripts": { "build": "webpack --mode production", "disclaim": "yarn licenses generate-disclaimer > disclaimer.txt", diff --git a/frontend/src/js/actions/appActions.js b/frontend/src/js/actions/appActions.js deleted file mode 100644 index 56d8f408..00000000 --- a/frontend/src/js/actions/appActions.js +++ /dev/null @@ -1,389 +0,0 @@ -// Copyright 2019 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import dayjs from 'dayjs'; -import durationDayJs from 'dayjs/plugin/duration'; -import Cookies from 'universal-cookie'; - -import GeneralApi from '../api/general-api'; -import { getSessionInfo, getToken } from '../auth'; -import { - SET_ENVIRONMENT_DATA, - SET_FEATURES, - SET_FIRST_LOGIN_AFTER_SIGNUP, - SET_OFFLINE_THRESHOLD, - SET_SEARCH_STATE, - SET_SNACKBAR, - SET_VERSION_INFORMATION, - TIMEOUTS -} from '../constants/appConstants'; -import { DEPLOYMENT_STATES } from '../constants/deploymentConstants'; -import { DEVICE_STATES, timeUnits } from '../constants/deviceConstants'; -import { onboardingSteps } from '../constants/onboardingConstants'; -import { SET_TOOLTIPS_STATE, SUCCESSFULLY_LOGGED_IN } from '../constants/userConstants'; -import { deepCompare, extractErrorMessage, preformatWithRequestID, stringToBoolean } from '../helpers'; -import { getFeatures, getIsEnterprise, getOfflineThresholdSettings, getUserCapabilities, getUserSettings as getUserSettingsSelector } from '../selectors'; -import { getOnboardingComponentFor } from '../utils/onboardingmanager'; -import { getDeploymentsByStatus } from './deploymentActions'; -import { - getDeviceAttributes, - getDeviceById, - getDeviceLimit, - getDevicesByStatus, - getDynamicGroups, - getGroups, - searchDevices, - setDeviceListState -} from './deviceActions'; -import { getOnboardingState, setDemoArtifactPort, setOnboardingComplete } from './onboardingActions'; -import { getIntegrations, getUserOrganization } from './organizationActions'; -import { getReleases } from './releaseActions'; -import { getGlobalSettings, getRoles, getUserSettings, saveGlobalSettings, saveUserSettings, setShowStartupNotification } from './userActions'; - -const cookies = new Cookies(); -dayjs.extend(durationDayJs); - -export const commonErrorFallback = 'Please check your connection.'; -export const commonErrorHandler = (err, errorContext, dispatch, fallback, mightBeAuthRelated = false) => { - const errMsg = extractErrorMessage(err, fallback); - if (mightBeAuthRelated || getToken()) { - dispatch(setSnackbar(preformatWithRequestID(err.response, `${errorContext} ${errMsg}`), null, 'Copy to clipboard')); - } - return Promise.reject(err); -}; - -const getComparisonCompatibleVersion = version => (isNaN(version.charAt(0)) && version !== 'next' ? 'master' : version); - -const featureFlags = [ - 'hasAuditlogs', - 'hasMultitenancy', - 'hasDeltaProgress', - 'hasDeviceConfig', - 'hasDeviceConnect', - 'hasReporting', - 'hasMonitor', - 'isEnterprise' -]; -export const parseEnvironmentInfo = () => (dispatch, getState) => { - const state = getState(); - let onboardingComplete = state.onboarding.complete || !!JSON.parse(window.localStorage.getItem('onboardingComplete') ?? 'false'); - let demoArtifactPort = 85; - let environmentData = {}; - let environmentFeatures = {}; - let versionInfo = {}; - if (mender_environment) { - const { - features = {}, - demoArtifactPort: port, - disableOnboarding, - hostAddress, - hostedAnnouncement, - integrationVersion, - isDemoMode, - menderVersion, - menderArtifactVersion, - metaMenderVersion, - recaptchaSiteKey, - services = {}, - stripeAPIKey, - trackerCode - } = mender_environment; - onboardingComplete = stringToBoolean(features.isEnterprise) || stringToBoolean(disableOnboarding) || onboardingComplete; - demoArtifactPort = port || demoArtifactPort; - environmentData = { - hostedAnnouncement: hostedAnnouncement || state.app.hostedAnnouncement, - hostAddress: hostAddress || state.app.hostAddress, - recaptchaSiteKey: recaptchaSiteKey || state.app.recaptchaSiteKey, - stripeAPIKey: stripeAPIKey || state.app.stripeAPIKey, - trackerCode: trackerCode || state.app.trackerCode - }; - environmentFeatures = { - ...featureFlags.reduce((accu, flag) => ({ ...accu, [flag]: stringToBoolean(features[flag]) }), {}), - // the check in features is purely kept as a local override, it shouldn't become relevant for production again - isHosted: features.isHosted || window.location.hostname.includes('hosted.mender.io'), - isDemoMode: stringToBoolean(isDemoMode || features.isDemoMode) - }; - versionInfo = { - docs: isNaN(integrationVersion.charAt(0)) ? '' : integrationVersion.split('.').slice(0, 2).join('.'), - remainder: { - Integration: getComparisonCompatibleVersion(integrationVersion), - 'Mender-Client': getComparisonCompatibleVersion(menderVersion), - 'Mender-Artifact': menderArtifactVersion, - 'Meta-Mender': metaMenderVersion, - Deployments: services.deploymentsVersion, - Deviceauth: services.deviceauthVersion, - Inventory: services.inventoryVersion, - GUI: services.guiVersion - } - }; - } - return Promise.all([ - dispatch({ type: SUCCESSFULLY_LOGGED_IN, value: getSessionInfo() }), - dispatch(setOnboardingComplete(onboardingComplete)), - dispatch(setDemoArtifactPort(demoArtifactPort)), - dispatch({ type: SET_FEATURES, value: environmentFeatures }), - dispatch({ type: SET_VERSION_INFORMATION, docsVersion: versionInfo.docs, value: versionInfo.remainder }), - dispatch({ type: SET_ENVIRONMENT_DATA, value: environmentData }), - dispatch(getLatestReleaseInfo()) - ]); -}; - -const maybeAddOnboardingTasks = ({ devicesByStatus, dispatch, onboardingState, tasks }) => { - if (!onboardingState.showTips || onboardingState.complete) { - return tasks; - } - const welcomeTip = getOnboardingComponentFor(onboardingSteps.ONBOARDING_START, { - progress: onboardingState.progress, - complete: onboardingState.complete, - showTips: onboardingState.showTips - }); - if (welcomeTip) { - tasks.push(dispatch(setSnackbar('open', TIMEOUTS.refreshDefault, '', welcomeTip, () => {}, true))); - } - // try to retrieve full device details for onboarding devices to ensure ips etc. are available - // we only load the first few/ 20 devices, as it is possible the onboarding is left dangling - // and a lot of devices are present and we don't want to flood the backend for this - return devicesByStatus[DEVICE_STATES.accepted].deviceIds.reduce((accu, id) => { - accu.push(dispatch(getDeviceById(id))); - return accu; - }, tasks); -}; - -const interpretAppData = () => (dispatch, getState) => { - const state = getState(); - let { columnSelection = [], trackingConsentGiven: hasTrackingEnabled, tooltips = {} } = getUserSettingsSelector(state); - let settings = {}; - if (cookies.get('_ga') && typeof hasTrackingEnabled === 'undefined') { - settings.trackingConsentGiven = true; - } - let tasks = [ - dispatch(setDeviceListState({ selectedAttributes: columnSelection.map(column => ({ attribute: column.key, scope: column.scope })) })), - dispatch({ type: SET_TOOLTIPS_STATE, value: tooltips }), // tooltips read state is primarily trusted from the redux store, except on app init - here user settings are the reference - dispatch(saveUserSettings(settings)) - ]; - tasks = maybeAddOnboardingTasks({ devicesByStatus: state.devices.byStatus, dispatch, tasks, onboardingState: state.onboarding }); - - const { canManageUsers } = getUserCapabilities(getState()); - const { interval, intervalUnit } = getOfflineThresholdSettings(getState()); - if (canManageUsers && intervalUnit && intervalUnit !== timeUnits.days) { - const duration = dayjs.duration(interval, intervalUnit); - const days = duration.asDays(); - if (days < 1) { - tasks.push(Promise.resolve(setTimeout(() => dispatch(setShowStartupNotification(true)), TIMEOUTS.fiveSeconds))); - } else { - const roundedDays = Math.max(1, Math.round(days)); - tasks.push(dispatch(saveGlobalSettings({ offlineThreshold: { interval: roundedDays, intervalUnit: timeUnits.days } }))); - } - } - - // the following is used as a migration and initialization of the stored identity attribute - // changing the default device attribute to the first non-deviceId attribute, unless a stored - // id attribute setting exists - const identityOptions = state.devices.filteringAttributes.identityAttributes.filter(attribute => !['id', 'Device ID', 'status'].includes(attribute)); - const { id_attribute } = state.users.globalSettings; - if (!id_attribute && identityOptions.length) { - tasks.push(dispatch(saveGlobalSettings({ id_attribute: { attribute: identityOptions[0], scope: 'identity' } }))); - } else if (typeof id_attribute === 'string') { - let attribute = id_attribute; - if (attribute === 'Device ID') { - attribute = 'id'; - } - tasks.push(dispatch(saveGlobalSettings({ id_attribute: { attribute, scope: 'identity' } }))); - } - return Promise.all(tasks); -}; - -const retrieveAppData = () => (dispatch, getState) => { - let tasks = [ - dispatch(parseEnvironmentInfo()), - dispatch(getUserSettings()), - dispatch(getGlobalSettings()), - dispatch(getDeviceAttributes()), - dispatch(getDeploymentsByStatus(DEPLOYMENT_STATES.finished, undefined, undefined, undefined, undefined, undefined, undefined, false)), - dispatch(getDeploymentsByStatus(DEPLOYMENT_STATES.inprogress)), - dispatch(getDevicesByStatus(DEVICE_STATES.accepted)), - dispatch(getDevicesByStatus(DEVICE_STATES.pending)), - dispatch(getDevicesByStatus(DEVICE_STATES.preauth)), - dispatch(getDevicesByStatus(DEVICE_STATES.rejected)), - dispatch(getDynamicGroups()), - dispatch(getGroups()), - dispatch(getIntegrations()), - dispatch(getReleases()), - dispatch(getDeviceLimit()), - dispatch(getRoles()), - dispatch(setFirstLoginAfterSignup(stringToBoolean(cookies.get('firstLoginAfterSignup')))) - ]; - const { hasMultitenancy, isHosted } = getFeatures(getState()); - const multitenancy = hasMultitenancy || isHosted || getIsEnterprise(getState()); - if (multitenancy) { - tasks.push(dispatch(getUserOrganization())); - } - return Promise.all(tasks); -}; - -export const initializeAppData = () => dispatch => - dispatch(retrieveAppData()) - .then(() => dispatch(interpretAppData())) - // this is allowed to fail if no user information are available - .catch(err => console.log(extractErrorMessage(err))) - .then(() => dispatch(getOnboardingState())); - -/* - General -*/ -export const setSnackbar = (message, autoHideDuration, action, children, onClick, onClose) => dispatch => - dispatch({ - type: SET_SNACKBAR, - snackbar: { - open: message ? true : false, - message, - maxWidth: '900px', - autoHideDuration, - action, - children, - onClick, - onClose - } - }); - -export const setFirstLoginAfterSignup = firstLoginAfterSignup => dispatch => { - cookies.set('firstLoginAfterSignup', !!firstLoginAfterSignup, { maxAge: 60, path: '/', domain: '.mender.io', sameSite: false }); - dispatch({ type: SET_FIRST_LOGIN_AFTER_SIGNUP, firstLoginAfterSignup: !!firstLoginAfterSignup }); -}; - -const dateFunctionMap = { - getDays: 'getDate', - setDays: 'setDate' -}; -export const setOfflineThreshold = () => (dispatch, getState) => { - const { interval, intervalUnit } = getOfflineThresholdSettings(getState()); - const today = new Date(); - const intervalName = `${intervalUnit.charAt(0).toUpperCase()}${intervalUnit.substring(1)}`; - const setter = dateFunctionMap[`set${intervalName}`] ?? `set${intervalName}`; - const getter = dateFunctionMap[`get${intervalName}`] ?? `get${intervalName}`; - today[setter](today[getter]() - interval); - let value; - try { - value = today.toISOString(); - } catch { - return Promise.resolve(dispatch(setSnackbar('There was an error saving the offline threshold, please check your settings.'))); - } - return Promise.resolve(dispatch({ type: SET_OFFLINE_THRESHOLD, value })); -}; - -export const setVersionInfo = info => (dispatch, getState) => - Promise.resolve( - dispatch({ - type: SET_VERSION_INFORMATION, - docsVersion: getState().app.docsVersion, - value: { - ...getState().app.versionInformation, - ...info - } - }) - ); - -const versionRegex = new RegExp(/\d+\.\d+/); -const getLatestRelease = thing => { - const latestKey = Object.keys(thing) - .filter(key => versionRegex.test(key)) - .sort() - .reverse()[0]; - return thing[latestKey]; -}; - -const repoKeyMap = { - integration: 'Integration', - mender: 'Mender-Client', - 'mender-artifact': 'Mender-Artifact' -}; - -const deductSaasState = (latestRelease, guiTags, saasReleases) => { - const latestGuiTag = guiTags.length ? guiTags[0].name : ''; - const latestSaasRelease = latestGuiTag.startsWith('saas-v') ? { date: latestGuiTag.split('-v')[1].replaceAll('.', '-'), tag: latestGuiTag } : saasReleases[0]; - return latestSaasRelease.date > latestRelease.release_date ? latestSaasRelease.tag : latestRelease.release; -}; - -export const getLatestReleaseInfo = () => (dispatch, getState) => { - if (!getState().app.features.isHosted) { - return Promise.resolve(); - } - return Promise.all([GeneralApi.get('/versions.json'), GeneralApi.get('/tags.json')]) - .catch(err => { - console.log('init error:', extractErrorMessage(err)); - return Promise.resolve([{ data: {} }, { data: [] }]); - }) - .then(([{ data }, { data: guiTags }]) => { - if (!guiTags.length) { - return Promise.resolve(); - } - const { releases, saas } = data; - const latestRelease = getLatestRelease(getLatestRelease(releases)); - const { latestRepos, latestVersions } = latestRelease.repos.reduce( - (accu, item) => { - if (repoKeyMap[item.name]) { - accu.latestVersions[repoKeyMap[item.name]] = getComparisonCompatibleVersion(item.version); - } - accu.latestRepos[item.name] = getComparisonCompatibleVersion(item.version); - return accu; - }, - { latestVersions: { ...getState().app.versionInformation }, latestRepos: {} } - ); - const info = deductSaasState(latestRelease, guiTags, saas); - return Promise.resolve( - dispatch({ - type: SET_VERSION_INFORMATION, - docsVersion: getState().app.docsVersion, - value: { - ...latestVersions, - backend: info, - GUI: info, - latestRelease: { - releaseDate: latestRelease.release_date, - repos: latestRepos - } - } - }) - ); - }); -}; - -export const setSearchState = searchState => (dispatch, getState) => { - const currentState = getState().app.searchState; - let nextState = { - ...currentState, - ...searchState, - sort: { - ...currentState.sort, - ...searchState.sort - } - }; - let tasks = []; - // eslint-disable-next-line no-unused-vars - const { isSearching: currentSearching, deviceIds: currentDevices, searchTotal: currentTotal, ...currentRequestState } = currentState; - // eslint-disable-next-line no-unused-vars - const { isSearching: nextSearching, deviceIds: nextDevices, searchTotal: nextTotal, ...nextRequestState } = nextState; - if (nextRequestState.searchTerm && !deepCompare(currentRequestState, nextRequestState)) { - nextState.isSearching = true; - tasks.push( - dispatch(searchDevices(nextState)) - .then(results => { - const searchResult = results[results.length - 1]; - return dispatch(setSearchState({ ...searchResult, isSearching: false })); - }) - .catch(() => dispatch(setSearchState({ isSearching: false, searchTotal: 0 }))) - ); - } - tasks.push(dispatch({ type: SET_SEARCH_STATE, state: nextState })); - return Promise.all(tasks); -}; diff --git a/frontend/src/js/actions/appActions.test.js b/frontend/src/js/actions/appActions.test.js deleted file mode 100644 index baf0bee6..00000000 --- a/frontend/src/js/actions/appActions.test.js +++ /dev/null @@ -1,548 +0,0 @@ -// Copyright 2020 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { act } from '@testing-library/react'; -import configureMockStore from 'redux-mock-store'; -import { thunk } from 'redux-thunk'; - -import { inventoryDevice } from '../../../tests/__mocks__/deviceHandlers'; -import { defaultState, receivedPermissionSets, receivedRoles, token } from '../../../tests/mockData'; -import { getSessionInfo } from '../auth'; -import { - SET_ANNOUNCEMENT, - SET_ENVIRONMENT_DATA, - SET_FEATURES, - SET_FIRST_LOGIN_AFTER_SIGNUP, - SET_OFFLINE_THRESHOLD, - SET_SEARCH_STATE, - SET_SNACKBAR, - SET_VERSION_INFORMATION, - SORTING_OPTIONS, - TIMEOUTS -} from '../constants/appConstants'; -import { - RECEIVE_DEPLOYMENTS, - RECEIVE_FINISHED_DEPLOYMENTS, - RECEIVE_INPROGRESS_DEPLOYMENTS, - SELECT_INPROGRESS_DEPLOYMENTS -} from '../constants/deploymentConstants'; -import { - ADD_DYNAMIC_GROUP, - DEVICE_LIST_DEFAULTS, - DEVICE_STATES, - EXTERNAL_PROVIDER, - RECEIVE_DEVICES, - RECEIVE_DYNAMIC_GROUPS, - RECEIVE_GROUPS, - SET_ACCEPTED_DEVICES, - SET_DEVICE_LIMIT, - SET_DEVICE_LIST_STATE, - SET_FILTER_ATTRIBUTES, - SET_PENDING_DEVICES, - SET_PREAUTHORIZED_DEVICES, - SET_REJECTED_DEVICES, - UNGROUPED_GROUP, - timeUnits -} from '../constants/deviceConstants'; -import { SET_DEMO_ARTIFACT_PORT, SET_ONBOARDING_COMPLETE } from '../constants/onboardingConstants'; -import { RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, SET_ORGANIZATION } from '../constants/organizationConstants'; -import { RECEIVE_RELEASES, SET_RELEASES_LIST_STATE } from '../constants/releaseConstants'; -import { - RECEIVED_PERMISSION_SETS, - RECEIVED_ROLES, - SET_GLOBAL_SETTINGS, - SET_SHOW_STARTUP_NOTIFICATION, - SET_TOOLTIPS_STATE, - SET_USER_SETTINGS, - SUCCESSFULLY_LOGGED_IN -} from '../constants/userConstants'; -import { - commonErrorHandler, - getLatestReleaseInfo, - initializeAppData, - setFirstLoginAfterSignup, - setOfflineThreshold, - setSearchState, - setSnackbar, - setVersionInfo -} from './appActions'; -import { defaultOnboardingState, expectedOnboardingActions } from './onboardingActions.test'; -import { tenantDataDivergedMessage } from './organizationActions'; - -export const attributeReducer = (accu, item) => { - if (item.scope === 'inventory') { - accu[item.name] = item.value; - if (item.name === 'device_type') { - accu[item.name] = [].concat(item.value); - } - } - return accu; -}; -// eslint-disable-next-line no-unused-vars -const { attributes, ...expectedDevice } = defaultState.devices.byId.a1; -export const receivedInventoryDevice = { - ...defaultState.devices.byId.a1, - attributes: inventoryDevice.attributes.reduce(attributeReducer, {}), - identity_data: { ...defaultState.devices.byId.a1.identity_data, status: DEVICE_STATES.accepted }, - isNew: false, - isOffline: true, - monitor: {}, - tags: {}, - updated_ts: inventoryDevice.updated_ts -}; -const latestSaasReleaseTag = 'saas-v2023.05.02'; - -export const commonAppInitActions = [ - { type: SET_ONBOARDING_COMPLETE, complete: false }, - { type: SET_DEMO_ARTIFACT_PORT, value: 85 }, - { type: SET_FEATURES, value: { ...defaultState.app.features, hasMultitenancy: true } }, - { - type: SET_VERSION_INFORMATION, - docsVersion: '', - value: { - Deployments: '1.2.3', - Deviceauth: null, - GUI: undefined, - Integration: 'master', - Inventory: null, - 'Mender-Artifact': undefined, - 'Mender-Client': 'next', - 'Meta-Mender': 'saas-123.34' - } - }, - { type: SET_ENVIRONMENT_DATA, value: { hostAddress: null, hostedAnnouncement: '', recaptchaSiteKey: '', stripeAPIKey: '', trackerCode: '' } }, - { type: SET_FIRST_LOGIN_AFTER_SIGNUP, firstLoginAfterSignup: false }, - - { type: RECEIVE_DEPLOYMENTS, deployments: defaultState.deployments.byId }, - { - type: RECEIVE_FINISHED_DEPLOYMENTS, - deploymentIds: Object.keys(defaultState.deployments.byId), - status: 'finished', - total: Object.keys(defaultState.deployments.byId).length - }, - { type: RECEIVE_DEPLOYMENTS, deployments: defaultState.deployments.byId }, - { - type: RECEIVE_INPROGRESS_DEPLOYMENTS, - deploymentIds: Object.keys(defaultState.deployments.byId), - status: 'inprogress', - total: Object.keys(defaultState.deployments.byId).length - }, - { - type: SELECT_INPROGRESS_DEPLOYMENTS, - deploymentIds: Object.keys(defaultState.deployments.byId), - status: 'inprogress' - }, - { type: SET_DEVICE_LIMIT, limit: 500 }, - { - type: RECEIVE_GROUPS, - groups: { - testGroup: defaultState.devices.groups.byId.testGroup, - testGroupDynamic: { - filters: [{ key: 'group', operator: '$eq', scope: 'system', value: 'things' }], - id: 'filter1' - } - } - }, - { - type: SET_FILTER_ATTRIBUTES, - attributes: { - identityAttributes: ['status', 'mac'], - inventoryAttributes: [ - 'artifact_name', - 'cpu_model', - 'device_type', - 'hostname', - 'ipv4_wlan0', - 'ipv6_wlan0', - 'kernel', - 'mac_eth0', - 'mac_wlan0', - 'mem_total_kB', - 'mender_bootloader_integration', - 'mender_client_version', - 'network_interfaces', - 'os', - 'rootfs_type' - ], - systemAttributes: ['created_ts', 'updated_ts', 'group'], - tagAttributes: [] - } - }, - { - type: RECEIVE_DYNAMIC_GROUPS, - groups: { - testGroup: defaultState.devices.groups.byId.testGroup, - testGroupDynamic: { - deviceIds: [], - filters: [ - { key: 'id', operator: '$in', scope: 'identity', value: [defaultState.devices.byId.a1.id] }, - { key: 'mac', operator: '$nexists', scope: 'identity', value: false }, - { key: 'kernel', operator: '$exists', scope: 'identity', value: true } - ], - id: 'filter1', - total: 0 - } - } - } -]; - -const appInitActions = [ - { type: SUCCESSFULLY_LOGGED_IN, value: { token } }, - ...commonAppInitActions, - { - type: RECEIVE_DEVICES, - devicesById: { - [defaultState.devices.byId.a1.id]: { ...receivedInventoryDevice, group: 'test' }, - [defaultState.devices.byId.b1.id]: { ...receivedInventoryDevice, id: defaultState.devices.byId.b1.id, group: 'test' } - } - }, - { - type: SET_ACCEPTED_DEVICES, - deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], - status: DEVICE_STATES.accepted, - total: defaultState.devices.byStatus.accepted.deviceIds.length - }, - { - type: RECEIVE_DEVICES, - devicesById: { [expectedDevice.id]: { ...receivedInventoryDevice, group: 'test', status: 'pending' } } - }, - { - type: SET_PENDING_DEVICES, - deviceIds: Array.from({ length: defaultState.devices.byStatus.pending.total }, () => defaultState.devices.byId.a1.id), - status: 'pending', - total: defaultState.devices.byStatus.pending.deviceIds.length - }, - { type: RECEIVE_DEVICES, devicesById: {} }, - { type: SET_PREAUTHORIZED_DEVICES, deviceIds: [], status: 'preauthorized', total: 0 }, - { type: RECEIVE_DEVICES, devicesById: {} }, - { type: SET_REJECTED_DEVICES, deviceIds: [], status: 'rejected', total: 0 }, - { - type: SET_VERSION_INFORMATION, - docsVersion: '', - value: { - GUI: latestSaasReleaseTag, - Integration: '1.2.3', - 'Mender-Artifact': '1.3.7', - 'Mender-Client': '3.2.1', - backend: latestSaasReleaseTag, - latestRelease: { - releaseDate: '2022-02-02', - repos: { - integration: '1.2.3', - mender: '3.2.1', - 'mender-artifact': '1.3.7', - 'other-service': '1.1.0', - service: '3.0.0' - } - } - } - }, - { type: SET_ORGANIZATION, organization: defaultState.organization.organization }, - { type: SET_ANNOUNCEMENT, announcement: tenantDataDivergedMessage }, - { - type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, - value: [ - { connection_string: 'something_else', id: 1, provider: EXTERNAL_PROVIDER['iot-hub'].provider }, - { id: 2, provider: 'iot-core', something: 'new' } - ] - }, - { type: RECEIVE_RELEASES, releases: defaultState.releases.byId }, - { - type: SET_RELEASES_LIST_STATE, - value: { ...defaultState.releases.releasesList, releaseIds: [defaultState.releases.byId.r1.name], page: 42 } - }, - { - type: RECEIVE_DEVICES, - devicesById: { - [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} }, - [defaultState.devices.byId.b1.id]: { ...defaultState.devices.byId.b1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} } - } - }, - { - type: RECEIVE_DEVICES, - devicesById: { - [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} } - } - }, - { - type: RECEIVE_DEVICES, - devicesById: { - [defaultState.devices.byId.a1.id]: { ...receivedInventoryDevice, group: 'test' }, - [defaultState.devices.byId.b1.id]: { - ...receivedInventoryDevice, - id: defaultState.devices.byId.b1.id, - group: 'test', - identity_data: { ...defaultState.devices.byId.b1.identity_data, status: DEVICE_STATES.accepted } - } - } - }, - { type: SET_GLOBAL_SETTINGS, settings: { ...defaultState.users.globalSettings } }, - { type: SET_OFFLINE_THRESHOLD, value: '2019-01-12T13:00:06.900Z' }, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, - { type: RECEIVED_PERMISSION_SETS, value: receivedPermissionSets }, - { type: RECEIVED_ROLES, value: receivedRoles }, - { - type: RECEIVE_DEVICES, - devicesById: { - [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} }, - [defaultState.devices.byId.b1.id]: { ...defaultState.devices.byId.b1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} } - } - }, - { - type: ADD_DYNAMIC_GROUP, - groupName: UNGROUPED_GROUP.id, - group: { deviceIds: [], total: 0, filters: [{ key: 'group', value: ['testGroup'], operator: '$nin', scope: 'system' }] } - }, - { - type: SET_DEVICE_LIST_STATE, - state: { - ...DEVICE_LIST_DEFAULTS, - deviceIds: [], - isLoading: true, - selectedAttributes: [], - selectedIssues: [], - selection: [], - setOnly: false, - sort: { direction: SORTING_OPTIONS.desc }, - state: DEVICE_STATES.accepted, - total: 0 - } - }, - { type: SET_TOOLTIPS_STATE, value: {} }, - { - type: RECEIVE_DEVICES, - devicesById: { - [expectedDevice.id]: { ...receivedInventoryDevice, group: 'test' }, - [defaultState.devices.byId.b1.id]: { ...receivedInventoryDevice, id: defaultState.devices.byId.b1.id, group: 'test' } - } - }, - { - type: SET_ACCEPTED_DEVICES, - deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], - status: DEVICE_STATES.accepted, - total: defaultState.devices.byStatus.accepted.total - }, - { - type: RECEIVE_DEVICES, - devicesById: { - [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} }, - [defaultState.devices.byId.b1.id]: { ...defaultState.devices.byId.b1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} } - } - }, - { - type: SET_DEVICE_LIST_STATE, - state: { - ...DEVICE_LIST_DEFAULTS, - deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], - isLoading: false, - selectedAttributes: [], - selectedIssues: [], - selection: [], - sort: { direction: SORTING_OPTIONS.desc }, - state: DEVICE_STATES.accepted, - total: 2 - } - }, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings, onboarding: defaultOnboardingState } }, - ...expectedOnboardingActions -]; - -const middlewares = [thunk]; -const mockStore = configureMockStore(middlewares); - -/* eslint-disable sonarjs/no-identical-functions */ -describe('app actions', () => { - it('should handle different error message formats', async () => { - const store = mockStore({ ...defaultState }); - const err = { response: { data: { error: { message: 'test' } } }, id: '123' }; - await expect(commonErrorHandler(err, 'testContext', store.dispatch)).rejects.toEqual(err); - const expectedActions = [ - { - type: SET_SNACKBAR, - snackbar: { - open: true, - message: `testContext ${err.response.data.error.message}`, - maxWidth: '900px', - autoHideDuration: null, - action: 'Copy to clipboard', - children: undefined, - onClick: undefined, - onClose: undefined - } - } - ]; - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should try to get all required app information', async () => { - const store = mockStore({ - ...defaultState, - app: { ...defaultState.app, features: { ...defaultState.app.features, isHosted: true } }, - users: { - ...defaultState.users, - currentSession: getSessionInfo(), - globalSettings: { ...defaultState.users.globalSettings, id_attribute: { attribute: 'mac', scope: 'identity' } } - }, - releases: { ...defaultState.releases, releasesList: { ...defaultState.releases.releasesList, page: 42 } } - }); - - await store.dispatch(initializeAppData()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(appInitActions.length); - appInitActions.map((action, index) => Object.keys(action).map(key => expect(storeActions[index][key]).toEqual(action[key]))); - }); - it('should execute the offline threshold migration for multi day thresholds', async () => { - const store = mockStore({ - ...defaultState, - app: { ...defaultState.app, features: { ...defaultState.app.features, isHosted: true } }, - users: { - ...defaultState.users, - currentSession: getSessionInfo(), - globalSettings: { - ...defaultState.users.globalSettings, - id_attribute: { attribute: 'mac', scope: 'identity' }, - offlineThreshold: { interval: 48, intervalUnit: timeUnits.hours } - } - }, - releases: { ...defaultState.releases, releasesList: { ...defaultState.releases.releasesList, page: 42 } } - }); - await store.dispatch(initializeAppData()); - - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(appInitActions.length + 3); // 3 = get settings + set settings + set offline threshold - const settingStorageAction = storeActions.find(action => action.type === SET_GLOBAL_SETTINGS && action.settings.offlineThreshold); - expect(settingStorageAction.settings.offlineThreshold.interval).toEqual(2); - expect(settingStorageAction.settings.offlineThreshold.intervalUnit).toEqual(timeUnits.days); - }); - it('should trigger the offline threshold migration dialog', async () => { - const store = mockStore({ - ...defaultState, - app: { ...defaultState.app, features: { ...defaultState.app.features, isHosted: true } }, - users: { - ...defaultState.users, - currentSession: getSessionInfo(), - globalSettings: { - ...defaultState.users.globalSettings, - id_attribute: { attribute: 'mac', scope: 'identity' }, - offlineThreshold: { interval: 15, intervalUnit: 'minutes' } - } - }, - releases: { ...defaultState.releases, releasesList: { ...defaultState.releases.releasesList, page: 42 } } - }); - await store.dispatch(initializeAppData()); - await act(async () => { - jest.advanceTimersByTime(TIMEOUTS.fiveSeconds + TIMEOUTS.oneSecond); - jest.runOnlyPendingTimers(); - jest.runAllTicks(); - }); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(appInitActions.length + 1); - const notificationAction = storeActions.find(action => action.type === SET_SHOW_STARTUP_NOTIFICATION); - expect(notificationAction.value).toBeTruthy(); - }); - - it('should pass snackbar information', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { - type: SET_SNACKBAR, - snackbar: { - open: true, - message: 'test', - maxWidth: '900px', - autoHideDuration: 20, - action: undefined, - children: undefined, - onClick: undefined, - onClose: undefined - } - } - ]; - await store.dispatch(setSnackbar('test', 20)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should set version information', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: SET_VERSION_INFORMATION, value: { Integration: 'next' } }]; - await store.dispatch(setVersionInfo({ Integration: 'next' })); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should not get the latest release info when not hosted', async () => { - const store = mockStore({ ...defaultState }); - await store.dispatch(getLatestReleaseInfo()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(0); - }); - it('should get the latest release info when hosted', async () => { - const store = mockStore({ - ...defaultState, - app: { - ...defaultState.app, - features: { - ...defaultState.app.features, - isHosted: true - } - } - }); - const expectedActions = [ - { - type: SET_VERSION_INFORMATION, - value: { backend: latestSaasReleaseTag, GUI: latestSaasReleaseTag, Integration: '1.2.3', 'Mender-Client': '3.2.1', 'Mender-Artifact': '1.3.7' } - } - ]; - await store.dispatch(getLatestReleaseInfo()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - - it('should store first login after Signup', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { - type: SET_FIRST_LOGIN_AFTER_SIGNUP, - firstLoginAfterSignup: true - } - ]; - await store.dispatch(setFirstLoginAfterSignup(true)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should calculate yesterdays timestamp', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: SET_OFFLINE_THRESHOLD, value: '2019-01-12T13:00:06.900Z' }]; - await store.dispatch(setOfflineThreshold()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should handle searching', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: SET_SEARCH_STATE, state: { ...defaultState.app.searchState, isSearching: true, searchTerm: 'next!' } }, - { type: RECEIVE_DEVICES, devicesById: {} }, - { type: SET_SEARCH_STATE, state: { ...defaultState.app.searchState, isSearching: false, searchTerm: '' } } - ]; - await store.dispatch(setSearchState({ searchTerm: 'next!' })); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); -}); diff --git a/frontend/src/js/actions/deploymentActions.js b/frontend/src/js/actions/deploymentActions.js deleted file mode 100644 index 5038615a..00000000 --- a/frontend/src/js/actions/deploymentActions.js +++ /dev/null @@ -1,440 +0,0 @@ -/*eslint import/namespace: ['error', { allowComputed: true }]*/ -import isUUID from 'validator/lib/isUUID'; - -import { commonErrorHandler, setSnackbar } from '../actions/appActions'; -import GeneralApi, { apiUrl, headerNames } from '../api/general-api'; -import { SORTING_OPTIONS, TIMEOUTS } from '../constants/appConstants'; -import * as DeploymentConstants from '../constants/deploymentConstants'; -import { DEVICE_LIST_DEFAULTS, RECEIVE_DEVICE } from '../constants/deviceConstants'; -import { deepCompare, isEmpty, standardizePhases, startTimeSort } from '../helpers'; -import { - getDeploymentsByStatus as getDeploymentsByStatusSelector, - getDevicesById, - getGlobalSettings, - getOrganization, - getUserCapabilities -} from '../selectors'; -import Tracking from '../tracking'; -import { getDeviceAuth, getDeviceById, mapTermsToFilters } from './deviceActions'; -import { saveGlobalSettings } from './userActions'; - -export const deploymentsApiUrl = `${apiUrl.v1}/deployments`; -export const deploymentsApiUrlV2 = `${apiUrl.v2}/deployments`; - -const { - CREATE_DEPLOYMENT, - DEPLOYMENT_ROUTES, - DEPLOYMENT_STATES, - DEPLOYMENT_TYPES, - deploymentPrototype, - RECEIVE_DEPLOYMENT_DEVICE_LOG, - RECEIVE_DEPLOYMENT_DEVICES, - RECEIVE_DEPLOYMENT, - RECEIVE_DEPLOYMENTS, - REMOVE_DEPLOYMENT, - SET_DEPLOYMENTS_STATE -} = DeploymentConstants; - -// default per page until pagination and counting integrated -const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS; - -export const deriveDeploymentGroup = ({ filter = {}, group, groups = [], name }) => (group || (groups.length === 1 && !isUUID(name)) ? groups[0] : filter.name); - -const transformDeployments = (deployments, deploymentsById) => - deployments.sort(startTimeSort).reduce( - (accu, item) => { - const filter = item.filter ?? {}; - let deployment = { - ...deploymentPrototype, - ...deploymentsById[item.id], - ...item, - filter: item.filter ? { ...filter, name: filter.name ?? filter.id, filters: mapTermsToFilters(filter.terms) } : undefined, - name: decodeURIComponent(item.name) - }; - // deriving the group in a second step to potentially make use of the merged data from the existing group state + the decoded name - deployment = { ...deployment, group: deriveDeploymentGroup(deployment) }; - accu.deployments[item.id] = deployment; - accu.deploymentIds.push(item.id); - return accu; - }, - { deployments: {}, deploymentIds: [] } - ); - -/*Deployments */ -export const getDeploymentsByStatus = - (status, page = defaultPage, per_page = defaultPerPage, startDate, endDate, group, type, shouldSelect = true, sort = SORTING_OPTIONS.desc) => - (dispatch, getState) => { - const created_after = startDate ? `&created_after=${startDate}` : ''; - const created_before = endDate ? `&created_before=${endDate}` : ''; - const search = group ? `&search=${group}` : ''; - const typeFilter = type ? `&type=${type}` : ''; - return GeneralApi.get( - `${deploymentsApiUrl}/deployments?status=${status}&per_page=${per_page}&page=${page}${created_after}${created_before}${search}${typeFilter}&sort=${sort}` - ).then(res => { - const { deployments, deploymentIds } = transformDeployments(res.data, getState().deployments.byId); - const total = Number(res.headers[headerNames.total]); - let tasks = [ - dispatch({ type: RECEIVE_DEPLOYMENTS, deployments }), - dispatch({ - type: DeploymentConstants[`RECEIVE_${status.toUpperCase()}_DEPLOYMENTS`], - deploymentIds, - status, - total: !(startDate || endDate || group || type) ? total : getState().deployments.byStatus[status].total - }) - ]; - tasks = deploymentIds.reduce((accu, deploymentId) => { - if (deployments[deploymentId].type === DEPLOYMENT_TYPES.configuration) { - accu.push(dispatch(getSingleDeployment(deploymentId))); - } - return accu; - }, tasks); - if (shouldSelect) { - tasks.push(dispatch({ type: DeploymentConstants[`SELECT_${status.toUpperCase()}_DEPLOYMENTS`], deploymentIds, status, total })); - } - tasks.push({ deploymentIds, total }); - return Promise.all(tasks); - }); - }; - -const isWithinFirstMonth = expirationDate => { - if (!expirationDate) { - return false; - } - const endOfFirstMonth = new Date(expirationDate); - endOfFirstMonth.setMonth(endOfFirstMonth.getMonth() - 11); - return endOfFirstMonth > new Date(); -}; - -const trackDeploymentCreation = (totalDeploymentCount, hasDeployments, trial_expiration) => { - Tracking.event({ category: 'deployments', action: 'create' }); - if (!totalDeploymentCount) { - if (!hasDeployments) { - Tracking.event({ category: 'deployments', action: 'create_initial_deployment' }); - if (isWithinFirstMonth(trial_expiration)) { - Tracking.event({ category: 'deployments', action: 'create_initial_deployment_first_month' }); - } - } - Tracking.event({ category: 'deployments', action: 'create_initial_deployment_user' }); - } -}; - -const MAX_PREVIOUS_PHASES_COUNT = 5; -export const createDeployment = - (newDeployment, hasNewRetryDefault = false) => - (dispatch, getState) => { - let request; - if (newDeployment.filter_id) { - request = GeneralApi.post(`${deploymentsApiUrlV2}/deployments`, newDeployment); - } else if (newDeployment.group) { - request = GeneralApi.post(`${deploymentsApiUrl}/deployments/group/${newDeployment.group}`, newDeployment); - } else { - request = GeneralApi.post(`${deploymentsApiUrl}/deployments`, newDeployment); - } - const totalDeploymentCount = Object.values(getDeploymentsByStatusSelector(getState())).reduce((accu, item) => accu + item.total, 0); - const { hasDeployments } = getGlobalSettings(getState()); - const { trial_expiration } = getOrganization(getState()); - return request - .catch(err => commonErrorHandler(err, 'Error creating deployment.', dispatch)) - .then(data => { - const lastslashindex = data.headers.location.lastIndexOf('/'); - const deploymentId = data.headers.location.substring(lastslashindex + 1); - const deployment = { - ...newDeployment, - devices: newDeployment.devices ? newDeployment.devices.map(id => ({ id, status: 'pending' })) : [], - statistics: { status: {} } - }; - let tasks = [ - dispatch({ type: CREATE_DEPLOYMENT, deployment, deploymentId }), - dispatch(getSingleDeployment(deploymentId)), - dispatch(setSnackbar('Deployment created successfully', TIMEOUTS.fiveSeconds)) - ]; - // track in GA - trackDeploymentCreation(totalDeploymentCount, hasDeployments, trial_expiration); - - const { canManageUsers } = getUserCapabilities(getState()); - if (canManageUsers) { - const { phases, retries } = newDeployment; - const { previousPhases = [], retries: previousRetries = 0 } = getGlobalSettings(getState()); - let newSettings = { retries: hasNewRetryDefault ? retries : previousRetries, hasDeployments: true }; - if (phases) { - const standardPhases = standardizePhases(phases); - let prevPhases = previousPhases.map(standardizePhases); - if (!prevPhases.find(previousPhaseList => previousPhaseList.every(oldPhase => standardPhases.find(phase => deepCompare(phase, oldPhase))))) { - prevPhases.push(standardPhases); - } - newSettings.previousPhases = prevPhases.slice(-1 * MAX_PREVIOUS_PHASES_COUNT); - } - tasks.push(dispatch(saveGlobalSettings(newSettings))); - } - return Promise.all(tasks); - }); - }; - -export const getDeploymentDevices = - (id, options = {}) => - (dispatch, getState) => { - const { page = defaultPage, perPage = defaultPerPage } = options; - return GeneralApi.get(`${deploymentsApiUrl}/deployments/${id}/devices/list?deployment_id=${id}&page=${page}&per_page=${perPage}`).then(response => { - const { devices: deploymentDevices = {} } = getState().deployments.byId[id] || {}; - const devices = response.data.reduce((accu, item) => { - accu[item.id] = item; - const log = (deploymentDevices[item.id] || {}).log; - if (log) { - accu[item.id].log = log; - } - return accu; - }, {}); - const selectedDeviceIds = Object.keys(devices); - let tasks = [ - dispatch({ - type: RECEIVE_DEPLOYMENT_DEVICES, - deploymentId: id, - devices, - selectedDeviceIds, - totalDeviceCount: Number(response.headers[headerNames.total]) - }) - ]; - const devicesById = getDevicesById(getState()); - // only update those that have changed & lack data - const lackingData = selectedDeviceIds.reduce((accu, deviceId) => { - const device = devicesById[deviceId]; - if (!device || !device.identity_data || !device.attributes || Object.keys(device.attributes).length === 0) { - accu.push(deviceId); - } - return accu; - }, []); - // get device artifact, inventory and identity details not listed in schedule data - tasks = lackingData.reduce((accu, deviceId) => [...accu, dispatch(getDeviceById(deviceId)), dispatch(getDeviceAuth(deviceId))], tasks); - return Promise.all(tasks); - }); - }; - -const parseDeviceDeployment = ({ - deployment: { id, artifact_name: release, status: deploymentStatus }, - device: { created, deleted, id: deviceId, finished, status, log } -}) => ({ - id, - release, - created, - deleted, - deviceId, - finished, - status, - log, - route: Object.values(DEPLOYMENT_ROUTES).reduce((accu, { key, states }) => { - if (!accu) { - return states.includes(deploymentStatus) ? key : accu; - } - return accu; - }, '') -}); - -export const getDeviceDeployments = - (deviceId, options = {}) => - (dispatch, getState) => { - const { filterSelection = [], page = defaultPage, perPage = defaultPerPage } = options; - const filters = filterSelection.map(item => `&status=${item}`).join(''); - return GeneralApi.get(`${deploymentsApiUrl}/deployments/devices/${deviceId}?page=${page}&per_page=${perPage}${filters}`) - .then(({ data, headers }) => - Promise.resolve( - dispatch({ - type: RECEIVE_DEVICE, - device: { - ...getState().devices.byId[deviceId], - deviceDeployments: data.map(parseDeviceDeployment), - deploymentsCount: Number(headers[headerNames.total]) - } - }) - ) - ) - .catch(err => commonErrorHandler(err, 'There was an error retrieving the device deployment history:', dispatch)); - }; - -export const resetDeviceDeployments = deviceId => dispatch => - GeneralApi.delete(`${deploymentsApiUrl}/deployments/devices/${deviceId}/history`) - .then(() => Promise.resolve(dispatch(getDeviceDeployments(deviceId)))) - .catch(err => commonErrorHandler(err, 'There was an error resetting the device deployment history:', dispatch)); - -export const getSingleDeployment = id => (dispatch, getState) => - GeneralApi.get(`${deploymentsApiUrl}/deployments/${id}`).then(({ data }) => { - const { deployments } = transformDeployments([data], getState().deployments.byId); - return Promise.resolve(dispatch({ type: RECEIVE_DEPLOYMENT, deployment: deployments[id] })); - }); - -export const getDeviceLog = (deploymentId, deviceId) => (dispatch, getState) => - GeneralApi.get(`${deploymentsApiUrl}/deployments/${deploymentId}/devices/${deviceId}/log`) - .catch(e => { - console.log('no log here', e); - return Promise.reject(); - }) - .then(({ data: log }) => { - const stateDeployment = getState().deployments.byId[deploymentId]; - const deployment = { - ...stateDeployment, - devices: { - ...stateDeployment.devices, - [deviceId]: { - ...stateDeployment.devices[deviceId], - log - } - } - }; - return Promise.all([ - Promise.resolve( - dispatch({ - type: RECEIVE_DEPLOYMENT_DEVICE_LOG, - deployment - }) - ), - Promise.resolve(log) - ]); - }); - -export const abortDeployment = deploymentId => (dispatch, getState) => - GeneralApi.put(`${deploymentsApiUrl}/deployments/${deploymentId}/status`, { status: 'aborted' }) - .then(() => { - const state = getState(); - let status = DEPLOYMENT_STATES.pending; - let index = state.deployments.byStatus.pending.deploymentIds.findIndex(id => id === deploymentId); - if (index < 0) { - status = DEPLOYMENT_STATES.inprogress; - index = state.deployments.byStatus.inprogress.deploymentIds.findIndex(id => id === deploymentId); - } - const deploymentIds = [ - ...state.deployments.byStatus[status].deploymentIds.slice(0, index), - ...state.deployments.byStatus[status].deploymentIds.slice(index + 1) - ]; - const deployments = deploymentIds.reduce((accu, id) => { - accu[id] = state.deployments.byId[id]; - return accu; - }, {}); - const total = Math.max(state.deployments.byStatus[status].total - 1, 0); - return Promise.all([ - dispatch({ type: RECEIVE_DEPLOYMENTS, deployments }), - dispatch({ type: DeploymentConstants[`RECEIVE_${status.toUpperCase()}_DEPLOYMENTS`], deploymentIds, status, total }), - dispatch({ - type: REMOVE_DEPLOYMENT, - deploymentId - }), - dispatch(setSnackbar('The deployment was successfully aborted')) - ]); - }) - .catch(err => commonErrorHandler(err, 'There was an error while aborting the deployment:', dispatch)); - -export const updateDeploymentControlMap = (deploymentId, update_control_map) => dispatch => - GeneralApi.patch(`${deploymentsApiUrl}/deployments/${deploymentId}`, { update_control_map }) - .catch(err => commonErrorHandler(err, 'There was an error while updating the deployment status:', dispatch)) - .then(() => Promise.resolve(dispatch(getSingleDeployment(deploymentId)))); - -export const setDeploymentsState = selection => (dispatch, getState) => { - // eslint-disable-next-line no-unused-vars - const { page, perPage, ...selectionState } = selection; - const currentState = getState().deployments.selectionState; - let nextState = { - ...currentState, - ...selectionState, - ...Object.keys(DEPLOYMENT_STATES).reduce((accu, item) => { - accu[item] = { - ...currentState[item], - ...selectionState[item] - }; - return accu; - }, {}), - general: { - ...currentState.general, - ...selectionState.general - } - }; - let tasks = [dispatch({ type: SET_DEPLOYMENTS_STATE, state: nextState })]; - if (nextState.selectedId && currentState.selectedId !== nextState.selectedId) { - tasks.push(dispatch(getSingleDeployment(nextState.selectedId))); - } - return Promise.all(tasks); -}; - -const deltaAttributeMappings = [ - { here: 'compressionLevel', there: 'compression_level' }, - { here: 'disableChecksum', there: 'disable_checksum' }, - { here: 'disableDecompression', there: 'disable_external_decompression' }, - { here: 'sourceWindow', there: 'source_window_size' }, - { here: 'inputWindow', there: 'input_window_size' }, - { here: 'duplicatesWindow', there: 'compression_duplicates_window' }, - { here: 'instructionBuffer', there: 'instruction_buffer_size' } -]; - -const mapExternalDeltaConfig = (config = {}) => - deltaAttributeMappings.reduce((accu, { here, there }) => { - if (config[there] !== undefined) { - accu[here] = config[there]; - } - return accu; - }, {}); - -export const getDeploymentsConfig = () => (dispatch, getState) => - GeneralApi.get(`${deploymentsApiUrl}/config`).then(({ data }) => { - const oldConfig = getState().deployments.config; - const { delta = {} } = data; - const { binary_delta = {}, binary_delta_limits = {} } = delta; - const { xdelta_args = {}, timeout: timeoutConfig = oldConfig.binaryDelta.timeout } = binary_delta; - const { xdelta_args_limits = {}, timeout: timeoutLimit = oldConfig.binaryDeltaLimits.timeout } = binary_delta_limits; - const config = { - ...oldConfig, - hasDelta: Boolean(delta.enabled), - binaryDelta: { - ...oldConfig.binaryDelta, - timeout: timeoutConfig, - ...mapExternalDeltaConfig(xdelta_args) - }, - binaryDeltaLimits: { - ...oldConfig.binaryDeltaLimits, - timeout: timeoutLimit, - ...mapExternalDeltaConfig(xdelta_args_limits) - } - }; - return Promise.resolve(dispatch({ type: DeploymentConstants.SET_DEPLOYMENTS_CONFIG, config })); - }); - -// traverse a source object and remove undefined & empty object properties to only return an attribute if there really is content worth sending -const deepClean = source => - Object.entries(source).reduce((accu, [key, value]) => { - if (value !== undefined) { - let cleanedValue = typeof value === 'object' ? deepClean(value) : value; - if (cleanedValue === undefined || (typeof cleanedValue === 'object' && isEmpty(cleanedValue))) { - return accu; - } - accu = { ...(accu ?? {}), [key]: cleanedValue }; - } - return accu; - }, undefined); - -export const saveDeltaDeploymentsConfig = config => (dispatch, getState) => { - const configChange = { - timeout: config.timeout, - xdelta_args: deltaAttributeMappings.reduce((accu, { here, there }) => { - if (config[here] !== undefined) { - accu[there] = config[here]; - } - return accu; - }, {}) - }; - const result = deepClean(configChange); - if (!result) { - return Promise.resolve(); - } - return GeneralApi.put(`${deploymentsApiUrl}/config/binary_delta`, result) - .catch(err => commonErrorHandler(err, 'There was a problem storing your delta artifact generation configuration.', dispatch)) - .then(() => { - const oldConfig = getState().deployments.config; - const newConfig = { - ...oldConfig, - binaryDelta: { - ...oldConfig.binaryDelta, - ...config - } - }; - return Promise.all([ - dispatch({ type: DeploymentConstants.SET_DEPLOYMENTS_CONFIG, config: newConfig }), - dispatch(setSnackbar('Settings saved successfully')) - ]); - }); -}; diff --git a/frontend/src/js/actions/deviceActions.js b/frontend/src/js/actions/deviceActions.js deleted file mode 100644 index 4ae4b296..00000000 --- a/frontend/src/js/actions/deviceActions.js +++ /dev/null @@ -1,1264 +0,0 @@ -/*eslint import/namespace: ['error', { allowComputed: true }]*/ -import React from 'react'; -import { Link } from 'react-router-dom'; - -import { isCancel } from 'axios'; -import pluralize from 'pluralize'; -import { v4 as uuid } from 'uuid'; - -import { commonErrorFallback, commonErrorHandler, setSnackbar } from '../actions/appActions'; -import { getSingleDeployment } from '../actions/deploymentActions'; -import { auditLogsApiUrl } from '../actions/organizationActions'; -import { cleanUpUpload, progress } from '../actions/releaseActions'; -import { saveGlobalSettings } from '../actions/userActions'; -import GeneralApi, { MAX_PAGE_SIZE, apiUrl, headerNames } from '../api/general-api'; -import { routes, sortingAlternatives } from '../components/devices/base-devices'; -import { filtersFilter } from '../components/devices/widgets/filters'; -import { SORTING_OPTIONS, TIMEOUTS, UPLOAD_PROGRESS, emptyChartSelection, yes } from '../constants/appConstants'; -import * as DeviceConstants from '../constants/deviceConstants'; -import { rootfsImageVersion } from '../constants/releaseConstants'; -import { attributeDuplicateFilter, deepCompare, extractErrorMessage, getSnackbarMessage, mapDeviceAttributes } from '../helpers'; -import { - getDeviceById as getDeviceByIdSelector, - getDeviceFilters, - getDeviceTwinIntegrations, - getGroups as getGroupsSelector, - getIdAttribute, - getTenantCapabilities, - getUserCapabilities, - getUserSettings -} from '../selectors'; -import { chartColorPalette } from '../themes/Mender'; -import { getDeviceMonitorConfig, getLatestDeviceAlerts } from './monitorActions'; - -const { DEVICE_FILTERING_OPTIONS, DEVICE_STATES, DEVICE_LIST_DEFAULTS, UNGROUPED_GROUP, emptyFilter } = DeviceConstants; -const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS; - -export const deviceAuthV2 = `${apiUrl.v2}/devauth`; -export const deviceConnect = `${apiUrl.v1}/deviceconnect`; -export const inventoryApiUrl = `${apiUrl.v1}/inventory`; -export const inventoryApiUrlV2 = `${apiUrl.v2}/inventory`; -export const deviceConfig = `${apiUrl.v1}/deviceconfig/configurations/device`; -export const reportingApiUrl = `${apiUrl.v1}/reporting`; -export const iotManagerBaseURL = `${apiUrl.v1}/iot-manager`; - -const defaultAttributes = [ - { scope: 'identity', attribute: 'status' }, - { scope: 'inventory', attribute: 'artifact_name' }, - { scope: 'inventory', attribute: 'device_type' }, - { scope: 'inventory', attribute: 'mender_is_gateway' }, - { scope: 'inventory', attribute: 'mender_gateway_system_id' }, - { scope: 'inventory', attribute: rootfsImageVersion }, - { scope: 'monitor', attribute: 'alerts' }, - { scope: 'system', attribute: 'created_ts' }, - { scope: 'system', attribute: 'updated_ts' }, - { scope: 'system', attribute: 'check_in_time' }, - { scope: 'system', attribute: 'group' }, - { scope: 'tags', attribute: 'name' } -]; - -export const getSearchEndpoint = hasReporting => (hasReporting ? `${reportingApiUrl}/devices/search` : `${inventoryApiUrlV2}/filters/search`); - -const getAttrsEndpoint = hasReporting => (hasReporting ? `${reportingApiUrl}/devices/search/attributes` : `${inventoryApiUrlV2}/filters/attributes`); - -export const getGroups = () => (dispatch, getState) => - GeneralApi.get(`${inventoryApiUrl}/groups`).then(res => { - const state = getState().devices.groups.byId; - const dynamicGroups = Object.entries(state).reduce((accu, [id, group]) => { - if (group.id || (group.filters?.length && id !== UNGROUPED_GROUP.id)) { - accu[id] = group; - } - return accu; - }, {}); - const groups = res.data.reduce((accu, group) => { - accu[group] = { deviceIds: [], filters: [], total: 0, ...state[group] }; - return accu; - }, dynamicGroups); - const filters = [{ key: 'group', value: res.data, operator: DEVICE_FILTERING_OPTIONS.$nin.key, scope: 'system' }]; - return Promise.all([ - dispatch({ type: DeviceConstants.RECEIVE_GROUPS, groups }), - dispatch(getDevicesByStatus(undefined, { filterSelection: filters, group: 0, page: 1, perPage: 1 })) - ]).then(promises => { - const ungroupedDevices = promises[promises.length - 1] || []; - const result = ungroupedDevices[ungroupedDevices.length - 1] || {}; - if (!result.total) { - return Promise.resolve(); - } - return Promise.resolve( - dispatch({ - type: DeviceConstants.ADD_DYNAMIC_GROUP, - groupName: UNGROUPED_GROUP.id, - group: { - deviceIds: [], - total: 0, - ...getState().devices.groups.byId[UNGROUPED_GROUP.id], - filters: [{ key: 'group', value: res.data, operator: DEVICE_FILTERING_OPTIONS.$nin.key, scope: 'system' }] - } - }) - ); - }); - }); - -export const addDevicesToGroup = (group, deviceIds, isCreation) => dispatch => - GeneralApi.patch(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds) - .then(() => dispatch({ type: DeviceConstants.ADD_TO_GROUP, group, deviceIds })) - .finally(() => (isCreation ? Promise.resolve(dispatch(getGroups())) : {})); - -export const removeDevicesFromGroup = (group, deviceIds) => dispatch => - GeneralApi.delete(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds).then(() => - Promise.all([ - dispatch({ - type: DeviceConstants.REMOVE_FROM_GROUP, - group, - deviceIds - }), - dispatch(setSnackbar(`The ${pluralize('devices', deviceIds.length)} ${pluralize('were', deviceIds.length)} removed from the group`, TIMEOUTS.fiveSeconds)) - ]) - ); - -const getGroupNotification = (newGroup, selectedGroup) => { - const successMessage = 'The group was updated successfully'; - if (newGroup === selectedGroup) { - return [successMessage, TIMEOUTS.fiveSeconds]; - } - return [ - <> - {successMessage} - click here to see it. - , - 5000, - undefined, - undefined, - () => {} - ]; -}; - -export const addStaticGroup = (group, devices) => (dispatch, getState) => - Promise.resolve( - dispatch( - addDevicesToGroup( - group, - devices.map(({ id }) => id), - true - ) - ) - ) - .then(() => - Promise.resolve( - dispatch({ - type: DeviceConstants.ADD_STATIC_GROUP, - group: { deviceIds: [], total: 0, filters: [], ...getState().devices.groups.byId[group] }, - groupName: group - }) - ).then(() => - Promise.all([ - dispatch(setDeviceListState({ setOnly: true })), - dispatch(getGroups()), - dispatch(setSnackbar(...getGroupNotification(group, getState().devices.groups.selectedGroup))) - ]) - ) - ) - .catch(err => commonErrorHandler(err, `Group could not be updated:`, dispatch)); - -export const removeStaticGroup = groupName => (dispatch, getState) => { - return GeneralApi.delete(`${inventoryApiUrl}/groups/${groupName}`).then(() => { - const selectedGroup = getState().devices.groups.selectedGroup === groupName ? undefined : getState().devices.groups.selectedGroup; - // eslint-disable-next-line no-unused-vars - const { [groupName]: removal, ...groups } = getState().devices.groups.byId; - return Promise.all([ - dispatch({ - type: DeviceConstants.REMOVE_STATIC_GROUP, - groups - }), - dispatch(getGroups()), - dispatch(selectGroup(selectedGroup)), - dispatch(setSnackbar('Group was removed successfully', TIMEOUTS.fiveSeconds)) - ]); - }); -}; - -// for some reason these functions can not be stored in the deviceConstants... -const filterProcessors = { - $gt: val => Number(val) || val, - $gte: val => Number(val) || val, - $lt: val => Number(val) || val, - $lte: val => Number(val) || val, - $in: val => ('' + val).split(',').map(i => i.trim()), - $nin: val => ('' + val).split(',').map(i => i.trim()), - $exists: yes, - $nexists: () => false -}; -const filterAliases = { - $nexists: { alias: DEVICE_FILTERING_OPTIONS.$exists.key, value: false } -}; -export const mapFiltersToTerms = (filters = []) => - filters.map(filter => ({ - scope: filter.scope, - attribute: filter.key, - type: filterAliases[filter.operator]?.alias || filter.operator, - value: filterProcessors.hasOwnProperty(filter.operator) ? filterProcessors[filter.operator](filter.value) : filter.value - })); -export const mapTermsToFilters = (terms = []) => - terms.map(term => { - const aliasedFilter = Object.entries(filterAliases).find( - aliasDefinition => aliasDefinition[1].alias === term.type && aliasDefinition[1].value === term.value - ); - const operator = aliasedFilter ? aliasedFilter[0] : term.type; - return { scope: term.scope, key: term.attribute, operator, value: term.value }; - }); - -export const getDynamicGroups = () => (dispatch, getState) => - GeneralApi.get(`${inventoryApiUrlV2}/filters?per_page=${MAX_PAGE_SIZE}`) - .then(({ data: filters }) => { - const state = getState().devices.groups.byId; - const staticGroups = Object.entries(state).reduce((accu, [id, group]) => { - if (!(group.id || group.filters?.length)) { - accu[id] = group; - } - return accu; - }, {}); - const groups = (filters || []).reduce((accu, filter) => { - accu[filter.name] = { - deviceIds: [], - total: 0, - ...state[filter.name], - id: filter.id, - filters: mapTermsToFilters(filter.terms) - }; - return accu; - }, staticGroups); - return Promise.resolve(dispatch({ type: DeviceConstants.RECEIVE_DYNAMIC_GROUPS, groups })); - }) - .catch(() => console.log('Dynamic group retrieval failed - likely accessing a non-enterprise backend')); - -export const addDynamicGroup = (groupName, filterPredicates) => (dispatch, getState) => - GeneralApi.post(`${inventoryApiUrlV2}/filters`, { name: groupName, terms: mapFiltersToTerms(filterPredicates) }) - .then(res => - Promise.resolve( - dispatch({ - type: DeviceConstants.ADD_DYNAMIC_GROUP, - groupName, - group: { - deviceIds: [], - total: 0, - ...getState().devices.groups.byId[groupName], - id: res.headers[headerNames.location].substring(res.headers[headerNames.location].lastIndexOf('/') + 1), - filters: filterPredicates - } - }) - ).then(() => { - const { cleanedFilters } = getGroupFilters(groupName, getState().devices.groups); - return Promise.all([ - dispatch(setDeviceFilters(cleanedFilters)), - dispatch(setSnackbar(...getGroupNotification(groupName, getState().devices.groups.selectedGroup))), - dispatch(getDynamicGroups()) - ]); - }) - ) - .catch(err => commonErrorHandler(err, `Group could not be updated:`, dispatch)); - -export const updateDynamicGroup = (groupName, filterPredicates) => (dispatch, getState) => { - const filterId = getState().devices.groups.byId[groupName].id; - return GeneralApi.delete(`${inventoryApiUrlV2}/filters/${filterId}`).then(() => Promise.resolve(dispatch(addDynamicGroup(groupName, filterPredicates)))); -}; - -export const removeDynamicGroup = groupName => (dispatch, getState) => { - let groups = { ...getState().devices.groups.byId }; - const filterId = groups[groupName].id; - const selectedGroup = getState().devices.groups.selectedGroup === groupName ? undefined : getState().devices.groups.selectedGroup; - return Promise.all([GeneralApi.delete(`${inventoryApiUrlV2}/filters/${filterId}`), dispatch(selectGroup(selectedGroup))]).then(() => { - delete groups[groupName]; - return Promise.all([ - dispatch({ - type: DeviceConstants.REMOVE_DYNAMIC_GROUP, - groups - }), - dispatch(setSnackbar('Group was removed successfully', TIMEOUTS.fiveSeconds)) - ]); - }); -}; -/* - * Device inventory functions - */ -const getGroupFilters = (group, groupsState, filters = []) => { - const groupName = group === UNGROUPED_GROUP.id || group === UNGROUPED_GROUP.name ? UNGROUPED_GROUP.id : group; - const selectedGroup = groupsState.byId[groupName]; - const groupFilterLength = selectedGroup?.filters?.length || 0; - const cleanedFilters = groupFilterLength ? [...filters, ...selectedGroup.filters].filter(filtersFilter) : filters; - return { cleanedFilters, groupName, selectedGroup, groupFilterLength }; -}; - -export const selectGroup = - (group, filters = []) => - (dispatch, getState) => { - const { cleanedFilters, groupName, selectedGroup, groupFilterLength } = getGroupFilters(group, getState().devices.groups, filters); - const state = getState(); - if (state.devices.groups.selectedGroup === groupName && ((filters.length === 0 && !groupFilterLength) || filters.length === cleanedFilters.length)) { - return Promise.resolve(); - } - let tasks = []; - if (groupFilterLength) { - tasks.push(dispatch(setDeviceFilters(cleanedFilters))); - } else { - tasks.push(dispatch(setDeviceFilters(filters))); - tasks.push(dispatch(getGroupDevices(groupName, { perPage: 1, shouldIncludeAllStates: true }))); - } - const selectedGroupName = selectedGroup || !Object.keys(state.devices.groups.byId).length ? groupName : undefined; - tasks.push(dispatch({ type: DeviceConstants.SELECT_GROUP, group: selectedGroupName })); - return Promise.all(tasks); - }; - -const getEarliestTs = (dateA = '', dateB = '') => (!dateA || !dateB ? dateA || dateB : dateA < dateB ? dateA : dateB); - -const reduceReceivedDevices = (devices, ids, state, status) => - devices.reduce( - (accu, device) => { - const stateDevice = getDeviceByIdSelector(state, device.id); - const { - attributes: storedAttributes = {}, - identity_data: storedIdentity = {}, - monitor: storedMonitor = {}, - tags: storedTags = {}, - group: storedGroup - } = stateDevice; - const { identity, inventory, monitor, system = {}, tags } = mapDeviceAttributes(device.attributes); - device.tags = { ...storedTags, ...tags }; - device.group = system.group ?? storedGroup; - device.monitor = { ...storedMonitor, ...monitor }; - device.identity_data = { ...storedIdentity, ...identity, ...(device.identity_data ? device.identity_data : {}) }; - device.status = status ? status : device.status || identity.status; - device.check_in_time_rounded = system.check_in_time ?? stateDevice.check_in_time_rounded; - device.check_in_time_exact = device.check_in_time ?? stateDevice.check_in_time_exact; - device.created_ts = getEarliestTs(getEarliestTs(system.created_ts, device.created_ts), stateDevice.created_ts); - device.updated_ts = device.attributes ? device.updated_ts : stateDevice.updated_ts; - device.isNew = new Date(device.created_ts) > new Date(state.app.newThreshold); - device.isOffline = new Date(device.check_in_time_rounded) < new Date(state.app.offlineThreshold) || device.check_in_time_rounded === undefined; - // all the other mapped attributes return as empty objects if there are no attributes to map, but identity will be initialized with an empty state - // for device_type and artifact_name, potentially overwriting existing info, so rely on stored information instead if there are no attributes - device.attributes = device.attributes ? { ...storedAttributes, ...inventory } : storedAttributes; - accu.devicesById[device.id] = { ...stateDevice, ...device }; - accu.ids.push(device.id); - return accu; - }, - { ids, devicesById: {} } - ); - -export const getGroupDevices = - (group, options = {}) => - (dispatch, getState) => { - const { shouldIncludeAllStates, ...remainder } = options; - const { cleanedFilters: filterSelection } = getGroupFilters(group, getState().devices.groups); - return Promise.resolve( - dispatch(getDevicesByStatus(shouldIncludeAllStates ? undefined : DEVICE_STATES.accepted, { ...remainder, filterSelection, group })) - ).then(results => { - if (!group) { - return Promise.resolve(); - } - const { deviceAccu, total } = results[results.length - 1]; - const stateGroup = getState().devices.groups.byId[group]; - if (!stateGroup && !total && !deviceAccu.ids.length) { - return Promise.resolve(); - } - return Promise.resolve( - dispatch({ - type: DeviceConstants.RECEIVE_GROUP_DEVICES, - group: { - filters: [], - ...stateGroup, - deviceIds: deviceAccu.ids.length === total || deviceAccu.ids.length > stateGroup?.deviceIds ? deviceAccu.ids : stateGroup.deviceIds, - total - }, - groupName: group - }) - ); - }); - }; - -export const getAllGroupDevices = (group, shouldIncludeAllStates) => (dispatch, getState) => { - if (!group || (!!group && (!getState().devices.groups.byId[group] || getState().devices.groups.byId[group].filters.length))) { - return Promise.resolve(); - } - const { attributes, filterTerms } = prepareSearchArguments({ - filters: [], - group, - state: getState(), - status: shouldIncludeAllStates ? undefined : DEVICE_STATES.accepted - }); - const getAllDevices = (perPage = MAX_PAGE_SIZE, page = defaultPage, devices = []) => - GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), { - page, - per_page: perPage, - filters: filterTerms, - attributes - }).then(res => { - const state = getState(); - const deviceAccu = reduceReceivedDevices(res.data, devices, state); - dispatch({ - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: deviceAccu.devicesById - }); - const total = Number(res.headers[headerNames.total]); - if (total > perPage * page) { - return getAllDevices(perPage, page + 1, deviceAccu.ids); - } - return Promise.resolve( - dispatch({ - type: DeviceConstants.RECEIVE_GROUP_DEVICES, - group: { - filters: [], - ...state.devices.groups.byId[group], - deviceIds: deviceAccu.ids, - total: deviceAccu.ids.length - }, - groupName: group - }) - ); - }); - return getAllDevices(); -}; - -export const getAllDynamicGroupDevices = group => (dispatch, getState) => { - if (!!group && (!getState().devices.groups.byId[group] || !getState().devices.groups.byId[group].filters.length)) { - return Promise.resolve(); - } - const { attributes, filterTerms: filters } = prepareSearchArguments({ - filters: getState().devices.groups.byId[group].filters, - state: getState(), - status: DEVICE_STATES.accepted - }); - const getAllDevices = (perPage = MAX_PAGE_SIZE, page = defaultPage, devices = []) => - GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), { page, per_page: perPage, filters, attributes }).then(res => { - const state = getState(); - const deviceAccu = reduceReceivedDevices(res.data, devices, state); - dispatch({ - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: deviceAccu.devicesById - }); - const total = Number(res.headers[headerNames.total]); - if (total > deviceAccu.ids.length) { - return getAllDevices(perPage, page + 1, deviceAccu.ids); - } - return Promise.resolve( - dispatch({ - type: DeviceConstants.RECEIVE_GROUP_DEVICES, - group: { - ...state.devices.groups.byId[group], - deviceIds: deviceAccu.ids, - total - }, - groupName: group - }) - ); - }); - return getAllDevices(); -}; - -export const setDeviceFilters = filters => (dispatch, getState) => { - if (deepCompare(filters, getDeviceFilters(getState()))) { - return Promise.resolve(); - } - return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_FILTERS, filters })); -}; - -export const getDeviceById = id => (dispatch, getState) => - GeneralApi.get(`${inventoryApiUrl}/devices/${id}`) - .then(res => { - const device = reduceReceivedDevices([res.data], [], getState()).devicesById[id]; - device.etag = res.headers.etag; - dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device }); - return Promise.resolve(device); - }) - .catch(err => { - const errMsg = extractErrorMessage(err); - if (errMsg.includes('Not Found')) { - console.log(`${id} does not have any inventory information`); - const device = reduceReceivedDevices( - [ - { - id, - attributes: [ - { name: 'status', value: 'decomissioned', scope: 'identity' }, - { name: 'decomissioned', value: 'true', scope: 'inventory' } - ] - } - ], - [], - getState() - ).devicesById[id]; - dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device }); - } - }); - -export const getDeviceInfo = deviceId => (dispatch, getState) => { - const device = getState().devices.byId[deviceId] || {}; - const { hasDeviceConfig, hasDeviceConnect, hasMonitor } = getTenantCapabilities(getState()); - const { canConfigure } = getUserCapabilities(getState()); - const integrations = getDeviceTwinIntegrations(getState()); - let tasks = [dispatch(getDeviceAuth(deviceId)), ...integrations.map(integration => dispatch(getDeviceTwin(deviceId, integration)))]; - if (hasDeviceConfig && canConfigure && [DEVICE_STATES.accepted, DEVICE_STATES.preauth].includes(device.status)) { - tasks.push(dispatch(getDeviceConfig(deviceId))); - } - if (device.status === DEVICE_STATES.accepted) { - // Get full device identity details for single selected device - tasks.push(dispatch(getDeviceById(deviceId))); - if (hasDeviceConnect) { - tasks.push(dispatch(getDeviceConnect(deviceId))); - } - if (hasMonitor) { - tasks.push(dispatch(getLatestDeviceAlerts(deviceId))); - tasks.push(dispatch(getDeviceMonitorConfig(deviceId))); - } - } - return Promise.all(tasks); -}; - -const deriveInactiveDevices = deviceIds => (dispatch, getState) => { - const yesterday = new Date(); - yesterday.setDate(yesterday.getDate() - 1); - const yesterdaysIsoString = yesterday.toISOString(); - const state = getState().devices; - // now boil the list down to the ones that were not updated since yesterday - const devices = deviceIds.reduce( - (accu, id) => { - const device = state.byId[id]; - if (device && device.updated_ts > yesterdaysIsoString) { - accu.active.push(id); - } else { - accu.inactive.push(id); - } - return accu; - }, - { active: [], inactive: [] } - ); - return dispatch({ - type: DeviceConstants.SET_INACTIVE_DEVICES, - activeDeviceTotal: devices.active.length, - inactiveDeviceTotal: devices.inactive.length - }); -}; - -/* - Device Auth + admission - */ -export const getDeviceCount = status => (dispatch, getState) => - GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), { - page: 1, - per_page: 1, - filters: mapFiltersToTerms([{ key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]), - attributes: defaultAttributes - }).then(response => { - const count = Number(response.headers[headerNames.total]); - switch (status) { - case DEVICE_STATES.accepted: - case DEVICE_STATES.pending: - case DEVICE_STATES.preauth: - case DEVICE_STATES.rejected: - return dispatch({ type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES_COUNT`], count, status }); - default: - return dispatch({ type: DeviceConstants.SET_TOTAL_DEVICES, count }); - } - }); - -export const getAllDeviceCounts = () => dispatch => - Promise.all([DEVICE_STATES.accepted, DEVICE_STATES.pending].map(status => dispatch(getDeviceCount(status)))); - -export const getDeviceLimit = () => dispatch => - GeneralApi.get(`${deviceAuthV2}/limits/max_devices`).then(res => - dispatch({ - type: DeviceConstants.SET_DEVICE_LIMIT, - limit: res.data.limit - }) - ); - -export const setDeviceListState = - (selectionState, shouldSelectDevices = true, forceRefresh, fetchAuth = true) => - (dispatch, getState) => { - const currentState = getState().devices.deviceList; - const refreshTrigger = forceRefresh ? !currentState.refreshTrigger : selectionState.refreshTrigger; - let nextState = { - ...currentState, - setOnly: false, - refreshTrigger, - ...selectionState, - sort: { ...currentState.sort, ...selectionState.sort } - }; - let tasks = []; - // eslint-disable-next-line no-unused-vars - const { isLoading: currentLoading, deviceIds: currentDevices, selection: currentSelection, ...currentRequestState } = currentState; - // eslint-disable-next-line no-unused-vars - const { isLoading: nextLoading, deviceIds: nextDevices, selection: nextSelection, ...nextRequestState } = nextState; - if (!nextState.setOnly && !deepCompare(currentRequestState, nextRequestState)) { - const { direction: sortDown = SORTING_OPTIONS.desc, key: sortCol, scope: sortScope } = nextState.sort ?? {}; - const sortBy = sortCol ? [{ attribute: sortCol, order: sortDown, scope: sortScope }] : undefined; - if (sortCol && sortingAlternatives[sortCol]) { - sortBy.push({ ...sortBy[0], attribute: sortingAlternatives[sortCol] }); - } - const applicableSelectedState = nextState.state === routes.allDevices.key ? undefined : nextState.state; - nextState.isLoading = true; - tasks.push( - dispatch(getDevicesByStatus(applicableSelectedState, { ...nextState, sortOptions: sortBy }, fetchAuth)) - .then(results => { - const { deviceAccu, total } = results[results.length - 1]; - const devicesState = shouldSelectDevices - ? { ...getState().devices.deviceList, deviceIds: deviceAccu.ids, total, isLoading: false } - : { ...getState().devices.deviceList, isLoading: false }; - return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: devicesState })); - }) - // whatever happens, change "loading" back to null - .catch(() => - Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: { ...getState().devices.deviceList, isLoading: false } })) - ) - ); - } - tasks.push(dispatch({ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: nextState })); - return Promise.all(tasks); - }; - -const convertIssueOptionsToFilters = (issuesSelection, filtersState = {}) => - issuesSelection.map(item => { - if (typeof DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule.value === 'function') { - return { ...DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule, value: DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule.value(filtersState) }; - } - return DeviceConstants.DEVICE_ISSUE_OPTIONS[item].filterRule; - }); - -export const convertDeviceListStateToFilters = ({ filters = [], group, groups = { byId: {} }, offlineThreshold, selectedIssues = [], status }) => { - let applicableFilters = [...filters]; - if (typeof group === 'string' && !(groups.byId[group]?.filters || applicableFilters).length) { - applicableFilters.push({ key: 'group', value: group, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'system' }); - } - const nonMonitorFilters = applicableFilters.filter( - filter => - !Object.values(DeviceConstants.DEVICE_ISSUE_OPTIONS).some( - ({ filterRule }) => filter.scope !== 'inventory' && filterRule.scope === filter.scope && filterRule.key === filter.key - ) - ); - const deviceIssueFilters = convertIssueOptionsToFilters(selectedIssues, { offlineThreshold }); - applicableFilters = [...nonMonitorFilters, ...deviceIssueFilters]; - const effectiveFilters = status - ? [...applicableFilters, { key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }] - : applicableFilters; - return { applicableFilters: nonMonitorFilters, filterTerms: mapFiltersToTerms(effectiveFilters) }; -}; - -// get devices from inventory -export const getDevicesByStatus = - (status, options = {}, fetchAuth = true) => - (dispatch, getState) => { - const { filterSelection, group, selectedIssues = [], page = defaultPage, perPage = defaultPerPage, sortOptions = [], selectedAttributes = [] } = options; - const state = getState(); - const { applicableFilters, filterTerms } = convertDeviceListStateToFilters({ - filters: filterSelection ?? getDeviceFilters(state), - group: group ?? state.devices.groups.selectedGroup, - groups: state.devices.groups, - offlineThreshold: state.app.offlineThreshold, - selectedIssues, - status - }); - const attributes = [...defaultAttributes, getIdAttribute(state), ...selectedAttributes]; - return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), { - page, - per_page: perPage, - filters: filterTerms, - sort: sortOptions, - attributes - }) - .then(response => { - const state = getState(); - const deviceAccu = reduceReceivedDevices(response.data, [], state, status); - let total = !applicableFilters.length ? Number(response.headers[headerNames.total]) : null; - if (status && state.devices.byStatus[status].total === deviceAccu.ids.length) { - total = deviceAccu.ids.length; - } - let tasks = [ - dispatch({ - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: deviceAccu.devicesById - }) - ]; - if (status) { - tasks.push( - dispatch({ - type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES`], - deviceIds: deviceAccu.ids, - status, - total - }) - ); - } - // for each device, get device identity info - const receivedDevices = Object.values(deviceAccu.devicesById); - if (receivedDevices.length && fetchAuth) { - tasks.push(dispatch(getDevicesWithAuth(receivedDevices))); - } - tasks.push(Promise.resolve({ deviceAccu, total: Number(response.headers[headerNames.total]) })); - return Promise.all(tasks); - }) - .catch(err => commonErrorHandler(err, `${status} devices couldn't be loaded.`, dispatch, commonErrorFallback)); - }; - -export const getAllDevicesByStatus = status => (dispatch, getState) => { - const attributes = [...defaultAttributes, getIdAttribute(getState())]; - const getAllDevices = (perPage = MAX_PAGE_SIZE, page = 1, devices = []) => - GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), { - page, - per_page: perPage, - filters: mapFiltersToTerms([{ key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]), - attributes - }).then(res => { - const state = getState(); - const deviceAccu = reduceReceivedDevices(res.data, devices, state, status); - dispatch({ - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: deviceAccu.devicesById - }); - const total = Number(res.headers[headerNames.total]); - if (total > state.deployments.deploymentDeviceLimit) { - return Promise.resolve(); - } - if (total > perPage * page) { - return getAllDevices(perPage, page + 1, deviceAccu.ids); - } - let tasks = [ - dispatch({ - type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES`], - deviceIds: deviceAccu.ids, - forceUpdate: true, - status, - total: deviceAccu.ids.length - }) - ]; - if (status === DEVICE_STATES.accepted && deviceAccu.ids.length === total) { - tasks.push(dispatch(deriveInactiveDevices(deviceAccu.ids))); - tasks.push(dispatch(deriveReportsData())); - } - return Promise.all(tasks); - }); - return getAllDevices(); -}; - -export const searchDevices = - (passedOptions = {}) => - (dispatch, getState) => { - const state = getState(); - let options = { ...state.app.searchState, ...passedOptions }; - const { page = defaultPage, searchTerm, sortOptions = [] } = options; - const { columnSelection = [] } = getUserSettings(state); - const selectedAttributes = columnSelection.map(column => ({ attribute: column.key, scope: column.scope })); - const attributes = attributeDuplicateFilter([...defaultAttributes, getIdAttribute(state), ...selectedAttributes], 'attribute'); - return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), { - page, - per_page: 10, - filters: [], - sort: sortOptions, - text: searchTerm, - attributes - }) - .then(response => { - const deviceAccu = reduceReceivedDevices(response.data, [], getState()); - return Promise.all([ - dispatch({ type: DeviceConstants.RECEIVE_DEVICES, devicesById: deviceAccu.devicesById }), - Promise.resolve({ deviceIds: deviceAccu.ids, searchTotal: Number(response.headers[headerNames.total]) }) - ]); - }) - .catch(err => commonErrorHandler(err, `devices couldn't be searched.`, dispatch, commonErrorFallback)); - }; - -const ATTRIBUTE_LIST_CUTOFF = 100; -const attributeReducer = (attributes = []) => - attributes.slice(0, ATTRIBUTE_LIST_CUTOFF).reduce( - (accu, { name, scope }) => { - if (!accu[scope]) { - accu[scope] = []; - } - accu[scope].push(name); - return accu; - }, - { identity: [], inventory: [], system: [], tags: [] } - ); - -export const getDeviceAttributes = () => (dispatch, getState) => - GeneralApi.get(getAttrsEndpoint(getState().app.features.hasReporting)).then(({ data }) => { - // TODO: remove the array fallback once the inventory attributes endpoint is fixed - const { identity: identityAttributes, inventory: inventoryAttributes, system: systemAttributes, tags: tagAttributes } = attributeReducer(data || []); - return dispatch({ - type: DeviceConstants.SET_FILTER_ATTRIBUTES, - attributes: { identityAttributes, inventoryAttributes, systemAttributes, tagAttributes } - }); - }); - -export const getReportingLimits = () => dispatch => - GeneralApi.get(`${reportingApiUrl}/devices/attributes`) - .catch(err => commonErrorHandler(err, `filterable attributes limit & usage could not be retrieved.`, dispatch, commonErrorFallback)) - .then(({ data }) => { - const { attributes, count, limit } = data; - const groupedAttributes = attributeReducer(attributes); - return Promise.resolve(dispatch({ type: DeviceConstants.SET_FILTERABLES_CONFIG, count, limit, attributes: groupedAttributes })); - }); - -export const ensureVersionString = (software, fallback) => - software.length && software !== 'artifact_name' ? (software.endsWith('.version') ? software : `${software}.version`) : fallback; - -const getSingleReportData = (reportConfig, groups) => { - const { attribute, group, software = '' } = reportConfig; - const filters = [{ key: 'status', scope: 'identity', operator: DEVICE_FILTERING_OPTIONS.$eq.key, value: 'accepted' }]; - if (group) { - const staticGroupFilter = { key: 'group', scope: 'system', operator: DEVICE_FILTERING_OPTIONS.$eq.key, value: group }; - const { cleanedFilters: groupFilters } = getGroupFilters(group, groups); - filters.push(...(groupFilters.length ? groupFilters : [staticGroupFilter])); - } - const aggregationAttribute = ensureVersionString(software, attribute); - return GeneralApi.post(`${reportingApiUrl}/devices/aggregate`, { - aggregations: [{ attribute: aggregationAttribute, name: '*', scope: 'inventory', size: chartColorPalette.length }], - filters: mapFiltersToTerms(filters) - }).then(({ data }) => ({ data, reportConfig })); -}; - -export const defaultReportType = 'distribution'; -export const defaultReports = [{ ...emptyChartSelection, group: null, attribute: 'artifact_name', type: defaultReportType }]; - -export const getReportsData = () => (dispatch, getState) => { - const state = getState(); - const reports = - getUserSettings(state).reports || - state.users.globalSettings[`${state.users.currentUser}-reports`] || - (Object.keys(state.devices.byId).length ? defaultReports : []); - return Promise.all(reports.map(report => getSingleReportData(report, getState().devices.groups))).then(results => { - const devicesState = getState().devices; - const totalDeviceCount = devicesState.byStatus.accepted.total; - const newReports = results.map(({ data, reportConfig }) => { - let { items, other_count } = data[0]; - const { attribute, group, software = '' } = reportConfig; - const dataCount = items.reduce((accu, item) => accu + item.count, 0); - // the following is needed to show reports including both old (artifact_name) & current style (rootfs-image.version) device software - const otherCount = !group && (software === rootfsImageVersion || attribute === 'artifact_name') ? totalDeviceCount - dataCount : other_count; - return { items, otherCount, total: otherCount + dataCount }; - }); - return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_REPORTS, reports: newReports })); - }); -}; - -const initializeDistributionData = (report, groups, devices, totalDeviceCount) => { - const { attribute, group = '', software = '' } = report; - const effectiveAttribute = software ? software : attribute; - const { deviceIds, total = 0 } = groups[group] || {}; - const relevantDevices = groups[group] ? deviceIds.map(id => devices[id]) : Object.values(devices); - const distributionByAttribute = relevantDevices.reduce((accu, item) => { - if (!item.attributes || item.status !== DEVICE_STATES.accepted) return accu; - if (!accu[item.attributes[effectiveAttribute]]) { - accu[item.attributes[effectiveAttribute]] = 0; - } - accu[item.attributes[effectiveAttribute]] = accu[item.attributes[effectiveAttribute]] + 1; - return accu; - }, {}); - const distributionByAttributeSorted = Object.entries(distributionByAttribute).sort((pairA, pairB) => pairB[1] - pairA[1]); - const items = distributionByAttributeSorted.map(([key, count]) => ({ key, count })); - const dataCount = items.reduce((accu, item) => accu + item.count, 0); - // the following is needed to show reports including both old (artifact_name) & current style (rootfs-image.version) device software - const otherCount = (groups[group] ? total : totalDeviceCount) - dataCount; - return { items, otherCount, total: otherCount + dataCount }; -}; - -const deriveReportsData = () => (dispatch, getState) => { - const state = getState(); - const { - groups: { byId: groupsById }, - byId, - byStatus: { - accepted: { total } - } - } = state.devices; - const reports = - getUserSettings(state).reports || state.users.globalSettings[`${state.users.currentUser}-reports`] || (Object.keys(byId).length ? defaultReports : []); - const newReports = reports.map(report => initializeDistributionData(report, groupsById, byId, total)); - return Promise.resolve(dispatch({ type: DeviceConstants.SET_DEVICE_REPORTS, reports: newReports })); -}; - -export const getReportsDataWithoutBackendSupport = () => (dispatch, getState) => - Promise.all([dispatch(getAllDevicesByStatus(DEVICE_STATES.accepted)), dispatch(getGroups()), dispatch(getDynamicGroups())]).then(() => { - const { dynamic: dynamicGroups, static: staticGroups } = getGroupsSelector(getState()); - return Promise.all([ - ...staticGroups.map(({ groupId }) => dispatch(getAllGroupDevices(groupId))), - ...dynamicGroups.map(({ groupId }) => dispatch(getAllDynamicGroupDevices(groupId))) - ]).then(() => dispatch(deriveReportsData())); - }); - -export const getDeviceConnect = id => dispatch => - GeneralApi.get(`${deviceConnect}/devices/${id}`).then(({ data }) => { - let tasks = [ - dispatch({ - type: DeviceConstants.RECEIVE_DEVICE_CONNECT, - device: { connect_status: data.status, connect_updated_ts: data.updated_ts, id } - }) - ]; - tasks.push(Promise.resolve(data)); - return Promise.all(tasks); - }); - -export const getSessionDetails = (sessionId, deviceId, userId, startDate, endDate) => () => { - const createdAfter = startDate ? `&created_after=${Math.round(Date.parse(startDate) / 1000)}` : ''; - const createdBefore = endDate ? `&created_before=${Math.round(Date.parse(endDate) / 1000)}` : ''; - const objectSearch = `&object_id=${deviceId}`; - return GeneralApi.get(`${auditLogsApiUrl}/logs?per_page=500${createdAfter}${createdBefore}&actor_id=${userId}${objectSearch}`).then( - ({ data: auditLogEntries }) => { - const { start, end } = auditLogEntries.reduce( - (accu, item) => { - if (item.meta?.session_id?.includes(sessionId)) { - accu.start = new Date(item.action.startsWith('open') ? item.time : accu.start); - accu.end = new Date(item.action.startsWith('close') ? item.time : accu.end); - } - return accu; - }, - { start: startDate || endDate, end: endDate || startDate } - ); - return Promise.resolve({ start, end }); - } - ); -}; - -export const getDeviceFileDownloadLink = (deviceId, path) => () => - Promise.resolve(`${deviceConnect}/devices/${deviceId}/download?path=${encodeURIComponent(path)}`); - -export const deviceFileUpload = (deviceId, path, file) => (dispatch, getState) => { - var formData = new FormData(); - formData.append('path', path); - formData.append('file', file); - const uploadId = uuid(); - const cancelSource = new AbortController(); - const uploads = { ...getState().app.uploads, [uploadId]: { inprogress: true, uploadProgress: 0, cancelSource } }; - return Promise.all([ - dispatch(setSnackbar('Uploading file')), - dispatch({ type: UPLOAD_PROGRESS, uploads }), - GeneralApi.uploadPut(`${deviceConnect}/devices/${deviceId}/upload`, formData, e => dispatch(progress(e, uploadId)), cancelSource.signal) - ]) - .then(() => Promise.resolve(dispatch(setSnackbar('Upload successful', TIMEOUTS.fiveSeconds)))) - .catch(err => { - if (isCancel(err)) { - return dispatch(setSnackbar('The upload has been cancelled', TIMEOUTS.fiveSeconds)); - } - return commonErrorHandler(err, `Error uploading file to device.`, dispatch); - }) - .finally(() => dispatch(cleanUpUpload(uploadId))); -}; - -export const getDeviceAuth = id => dispatch => - Promise.resolve(dispatch(getDevicesWithAuth([{ id }]))).then(results => { - if (results[results.length - 1]) { - return Promise.resolve(results[results.length - 1][0]); - } - return Promise.resolve(); - }); - -export const getDevicesWithAuth = devices => (dispatch, getState) => - devices.length - ? GeneralApi.get(`${deviceAuthV2}/devices?id=${devices.map(device => device.id).join('&id=')}`) - .then(({ data: receivedDevices }) => { - const { devicesById } = reduceReceivedDevices(receivedDevices, [], getState()); - return Promise.all([dispatch({ type: DeviceConstants.RECEIVE_DEVICES, devicesById }), Promise.resolve(receivedDevices)]); - }) - .catch(err => commonErrorHandler(err, `Error: ${err}`, dispatch)) - : Promise.resolve([[], []]); - -const maybeUpdateDevicesByStatus = (deviceId, authId) => (dispatch, getState) => { - const devicesState = getState().devices; - const device = devicesState.byId[deviceId]; - const hasMultipleAuthSets = authId ? device.auth_sets.filter(authset => authset.id !== authId).length > 0 : false; - if (!hasMultipleAuthSets && Object.values(DEVICE_STATES).includes(device.status)) { - const deviceIds = devicesState.byStatus[device.status].deviceIds.filter(id => id !== deviceId); - return Promise.resolve( - dispatch({ - type: DeviceConstants[`SET_${device.status.toUpperCase()}_DEVICES`], - deviceIds, - forceUpdate: true, - status: device.status, - total: Math.max(0, devicesState.byStatus[device.status].total - 1) - }) - ); - } - return Promise.resolve(); -}; - -export const updateDeviceAuth = (deviceId, authId, status) => (dispatch, getState) => - GeneralApi.put(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}/status`, { status }) - .then(() => Promise.all([dispatch(getDeviceAuth(deviceId)), dispatch(setSnackbar('Device authorization status was updated successfully'))])) - .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch)) - .then(() => Promise.resolve(dispatch(maybeUpdateDevicesByStatus(deviceId, authId)))) - .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger }))); - -export const updateDevicesAuth = (deviceIds, status) => (dispatch, getState) => { - let devices = getState().devices.byId; - const deviceIdsWithoutAuth = deviceIds.reduce((accu, id) => (devices[id].auth_sets ? accu : [...accu, { id }]), []); - return dispatch(getDevicesWithAuth(deviceIdsWithoutAuth)).then(() => { - devices = getState().devices.byId; - // for each device, get id and id of authset & make api call to accept - // if >1 authset, skip instead - const deviceAuthUpdates = deviceIds.map(id => { - const device = devices[id]; - if (device.auth_sets.length !== 1) { - return Promise.reject(); - } - // api call device.id and device.authsets[0].id - return dispatch(updateDeviceAuth(device.id, device.auth_sets[0].id, status)).catch(err => - commonErrorHandler(err, 'The action was stopped as there was a problem updating a device authorization status: ', dispatch) - ); - }); - return Promise.allSettled(deviceAuthUpdates).then(results => { - const { skipped, count } = results.reduce( - (accu, item) => { - if (item.status === 'rejected') { - accu.skipped = accu.skipped + 1; - } else { - accu.count = accu.count + 1; - } - return accu; - }, - { skipped: 0, count: 0 } - ); - const message = getSnackbarMessage(skipped, count); - // break if an error occurs, display status up til this point before error message - return dispatch(setSnackbar(message)); - }); - }); -}; - -export const deleteAuthset = (deviceId, authId) => (dispatch, getState) => - GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}`) - .then(() => Promise.all([dispatch(setSnackbar('Device authorization status was updated successfully'))])) - .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch)) - .then(() => Promise.resolve(dispatch(maybeUpdateDevicesByStatus(deviceId, authId)))) - .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger }))); - -export const preauthDevice = authset => dispatch => - GeneralApi.post(`${deviceAuthV2}/devices`, authset) - .catch(err => { - if (err.response.status === 409) { - return Promise.reject('A device with a matching identity data set already exists'); - } - commonErrorHandler(err, 'The device could not be added:', dispatch); - return Promise.reject(); - }) - .then(() => Promise.resolve(dispatch(setSnackbar('Device was successfully added to the preauthorization list', TIMEOUTS.fiveSeconds)))); - -export const decommissionDevice = (deviceId, authId) => (dispatch, getState) => - GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}`) - .then(() => Promise.resolve(dispatch(setSnackbar('Device was decommissioned successfully')))) - .catch(err => commonErrorHandler(err, 'There was a problem decommissioning the device:', dispatch)) - .then(() => Promise.resolve(dispatch(maybeUpdateDevicesByStatus(deviceId, authId)))) - // trigger reset of device list list! - .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger }))); - -export const getDeviceConfig = deviceId => dispatch => - GeneralApi.get(`${deviceConfig}/${deviceId}`) - .then(({ data }) => { - let tasks = [ - dispatch({ - type: DeviceConstants.RECEIVE_DEVICE_CONFIG, - device: { id: deviceId, config: data } - }) - ]; - tasks.push(Promise.resolve(data)); - return Promise.all(tasks); - }) - .catch(err => { - // if we get a proper error response we most likely queried a device without an existing config check-in and we can just ignore the call - if (err.response?.data?.error.status_code !== 404) { - return commonErrorHandler(err, `There was an error retrieving the configuration for device ${deviceId}.`, dispatch, commonErrorFallback); - } - }); - -export const setDeviceConfig = (deviceId, config) => dispatch => - GeneralApi.put(`${deviceConfig}/${deviceId}`, config) - .catch(err => commonErrorHandler(err, `There was an error setting the configuration for device ${deviceId}.`, dispatch, commonErrorFallback)) - .then(() => Promise.resolve(dispatch(getDeviceConfig(deviceId)))); - -export const applyDeviceConfig = (deviceId, configDeploymentConfiguration, isDefault, config) => (dispatch, getState) => - GeneralApi.post(`${deviceConfig}/${deviceId}/deploy`, configDeploymentConfiguration) - .catch(err => commonErrorHandler(err, `There was an error deploying the configuration to device ${deviceId}.`, dispatch, commonErrorFallback)) - .then(({ data }) => { - const device = getDeviceByIdSelector(getState(), deviceId); - const { canManageUsers } = getUserCapabilities(getState()); - let tasks = [ - dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device: { ...device, config: { ...device.config, deployment_id: '' } } }), - new Promise(resolve => setTimeout(() => resolve(dispatch(getSingleDeployment(data.deployment_id))), TIMEOUTS.oneSecond)) - ]; - if (isDefault && canManageUsers) { - const { previous } = getState().users.globalSettings.defaultDeviceConfig ?? {}; - tasks.push(dispatch(saveGlobalSettings({ defaultDeviceConfig: { current: config, previous } }))); - } - return Promise.all(tasks); - }); - -export const setDeviceTags = (deviceId, tags) => dispatch => - // to prevent tag set failures, retrieve the device & use the freshest etag we can get - Promise.resolve(dispatch(getDeviceById(deviceId))).then(device => { - const headers = device.etag ? { 'If-Match': device.etag } : {}; - return GeneralApi.put( - `${inventoryApiUrl}/devices/${deviceId}/tags`, - Object.entries(tags).map(([name, value]) => ({ name, value })), - { headers } - ) - .catch(err => commonErrorHandler(err, `There was an error setting tags for device ${deviceId}.`, dispatch, 'Please check your connection.')) - .then(() => Promise.all([dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device: { ...device, tags } }), dispatch(setSnackbar('Device name changed'))])); - }); - -export const getDeviceTwin = (deviceId, integration) => (dispatch, getState) => { - let providerResult = {}; - return GeneralApi.get(`${iotManagerBaseURL}/devices/${deviceId}/state`) - .then(({ data }) => { - providerResult = { ...data, twinError: '' }; - }) - .catch(err => { - providerResult = { - twinError: `There was an error getting the ${DeviceConstants.EXTERNAL_PROVIDER[ - integration.provider - ].twinTitle.toLowerCase()} for device ${deviceId}. ${err}` - }; - }) - .finally(() => - Promise.resolve( - dispatch({ - type: DeviceConstants.RECEIVE_DEVICE, - device: { - ...getState().devices.byId[deviceId], - twinsByIntegration: { - ...getState().devices.byId[deviceId].twinsByIntegration, - ...providerResult - } - } - }) - ) - ); -}; - -export const setDeviceTwin = (deviceId, integration, settings) => (dispatch, getState) => - GeneralApi.put(`${iotManagerBaseURL}/devices/${deviceId}/state/${integration.id}`, { desired: settings }) - .catch(err => - commonErrorHandler( - err, - `There was an error updating the ${DeviceConstants.EXTERNAL_PROVIDER[integration.provider].twinTitle.toLowerCase()} for device ${deviceId}.`, - dispatch - ) - ) - .then(() => { - const { twinsByIntegration = {} } = getState().devices.byId[deviceId]; - const { [integration.id]: currentState = {} } = twinsByIntegration; - return Promise.resolve( - dispatch({ - type: DeviceConstants.RECEIVE_DEVICE, - device: { - ...getState().devices.byId[deviceId], - twinsByIntegration: { - ...twinsByIntegration, - [integration.id]: { - ...currentState, - desired: settings - } - } - } - }) - ); - }); - -const prepareSearchArguments = ({ filters, group, state, status }) => { - const { filterTerms } = convertDeviceListStateToFilters({ filters, group, offlineThreshold: state.app.offlineThreshold, selectedIssues: [], status }); - const { columnSelection = [] } = getUserSettings(state); - const selectedAttributes = columnSelection.map(column => ({ attribute: column.key, scope: column.scope })); - const attributes = [...defaultAttributes, getIdAttribute(state), ...selectedAttributes]; - return { attributes, filterTerms }; -}; - -export const getSystemDevices = - (id, options = {}) => - (dispatch, getState) => { - const { page = defaultPage, perPage = defaultPerPage, sortOptions = [] } = options; - const state = getState(); - let device = getDeviceByIdSelector(state, id); - const { attributes: deviceAttributes = {} } = device; - const { mender_gateway_system_id = '' } = deviceAttributes; - const { hasFullFiltering } = getTenantCapabilities(state); - if (!hasFullFiltering) { - return Promise.resolve(); - } - const filters = [ - { ...emptyFilter, key: 'mender_is_gateway', operator: DEVICE_FILTERING_OPTIONS.$ne.key, value: 'true', scope: 'inventory' }, - { ...emptyFilter, key: 'mender_gateway_system_id', value: mender_gateway_system_id, scope: 'inventory' } - ]; - const { attributes, filterTerms } = prepareSearchArguments({ filters, state }); - - return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), { - page, - per_page: perPage, - filters: filterTerms, - sort: sortOptions, - attributes - }) - .catch(err => commonErrorHandler(err, `There was an error getting system devices device ${id}.`, dispatch, 'Please check your connection.')) - .then(({ data, headers }) => { - const state = getState(); - const { devicesById, ids } = reduceReceivedDevices(data, [], state); - const device = { - ...state.devices.byId[id], - systemDeviceIds: ids, - systemDeviceTotal: Number(headers[headerNames.total]) - }; - return Promise.resolve( - dispatch({ - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: { - ...devicesById, - [id]: device - } - }) - ); - }); - }; - -export const getGatewayDevices = deviceId => (dispatch, getState) => { - const state = getState(); - let device = getDeviceByIdSelector(state, deviceId); - const { attributes = {} } = device; - const { mender_gateway_system_id = '' } = attributes; - const filters = [ - { ...emptyFilter, key: 'id', operator: DEVICE_FILTERING_OPTIONS.$ne.key, value: deviceId, scope: 'identity' }, - { ...emptyFilter, key: 'mender_is_gateway', value: 'true', scope: 'inventory' }, - { ...emptyFilter, key: 'mender_gateway_system_id', value: mender_gateway_system_id, scope: 'inventory' } - ]; - const { attributes: attributeSelection, filterTerms } = prepareSearchArguments({ filters, state }); - return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), { - page: 1, - per_page: MAX_PAGE_SIZE, - filters: filterTerms, - attributes: attributeSelection - }).then(({ data }) => { - const { ids } = reduceReceivedDevices(data, [], getState()); - let tasks = ids.map(deviceId => dispatch(getDeviceInfo(deviceId))); - tasks.push(dispatch({ type: DeviceConstants.RECEIVE_DEVICE, device: { ...getState().devices.byId[deviceId], gatewayIds: ids } })); - return Promise.all(tasks); - }); -}; - -export const geoAttributes = ['geo-lat', 'geo-lon'].map(attribute => ({ attribute, scope: 'inventory' })); -export const getDevicesInBounds = (bounds, group) => (dispatch, getState) => { - const state = getState(); - const { filterTerms } = convertDeviceListStateToFilters({ - group: group === DeviceConstants.ALL_DEVICES ? undefined : group, - groups: state.devices.groups, - status: DEVICE_STATES.accepted - }); - return GeneralApi.post(getSearchEndpoint(state.app.features.hasReporting), { - page: 1, - per_page: MAX_PAGE_SIZE, - filters: filterTerms, - attributes: geoAttributes, - geo_bounding_box_filter: { - geo_bounding_box: { - location: { - top_left: { lat: bounds._northEast.lat, lon: bounds._southWest.lng }, - bottom_right: { lat: bounds._southWest.lat, lon: bounds._northEast.lng } - } - } - } - }).then(({ data }) => { - const { devicesById } = reduceReceivedDevices(data, [], getState()); - return Promise.resolve(dispatch({ type: DeviceConstants.RECEIVE_DEVICES, devicesById })); - }); -}; diff --git a/frontend/src/js/actions/deviceActions.test.js b/frontend/src/js/actions/deviceActions.test.js deleted file mode 100644 index bb0e22a6..00000000 --- a/frontend/src/js/actions/deviceActions.test.js +++ /dev/null @@ -1,1011 +0,0 @@ -/*eslint import/namespace: ['error', { allowComputed: true }]*/ -import React from 'react'; -import { act } from 'react-dom/test-utils'; -import { Link } from 'react-router-dom'; - -import configureMockStore from 'redux-mock-store'; -import { thunk } from 'redux-thunk'; - -import { inventoryDevice } from '../../../tests/__mocks__/deviceHandlers'; -import { defaultState } from '../../../tests/mockData'; -import { mockAbortController, waitFor } from '../../../tests/setupTests'; -import { SET_SNACKBAR, TIMEOUTS, UPLOAD_PROGRESS } from '../constants/appConstants'; -import * as DeploymentConstants from '../constants/deploymentConstants'; -import * as DeviceConstants from '../constants/deviceConstants'; -import * as DeploymentActions from './deploymentActions'; -import { - addDevicesToGroup, - addDynamicGroup, - addStaticGroup, - applyDeviceConfig, - decommissionDevice, - deleteAuthset, - deviceFileUpload, - getAllDeviceCounts, - getAllDevicesByStatus, - getAllDynamicGroupDevices, - getAllGroupDevices, - getDeviceAttributes, - getDeviceAuth, - getDeviceById, - getDeviceConfig, - getDeviceCount, - getDeviceFileDownloadLink, - getDeviceInfo, - getDeviceLimit, - getDeviceTwin, - getDevicesByStatus, - getDevicesWithAuth, - getDynamicGroups, - getGatewayDevices, - getGroupDevices, - getGroups, - getReportingLimits, - getReportsData, - getReportsDataWithoutBackendSupport, - getSessionDetails, - getSystemDevices, - preauthDevice, - removeDevicesFromGroup, - removeDynamicGroup, - removeStaticGroup, - selectGroup, - setDeviceConfig, - setDeviceFilters, - setDeviceListState, - setDeviceTags, - setDeviceTwin, - updateDeviceAuth, - updateDevicesAuth, - updateDynamicGroup -} from './deviceActions'; - -const deploymentsSpy = jest.spyOn(DeploymentActions, 'getSingleDeployment'); - -const middlewares = [thunk]; -const mockStore = configureMockStore(middlewares); - -const groupUpdateSuccessMessage = 'The group was updated successfully'; -const getGroupSuccessNotification = groupName => ( - <> - {groupUpdateSuccessMessage} - click here to see it. - -); - -// eslint-disable-next-line no-unused-vars -const { attributes, check_in_time, updated_ts, ...expectedDevice } = defaultState.devices.byId.a1; -const receivedExpectedDevice = { type: DeviceConstants.RECEIVE_DEVICES, devicesById: { [defaultState.devices.byId.a1.id]: expectedDevice } }; -const defaultDeviceListState = { - type: DeviceConstants.SET_DEVICE_LIST_STATE, - state: { - ...defaultState.devices.deviceList, - perPage: 20, - deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], - isLoading: false, - total: 2 - } -}; -const acceptedDevices = { - type: DeviceConstants.SET_ACCEPTED_DEVICES, - deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], - status: DeviceConstants.DEVICE_STATES.accepted, - total: defaultState.devices.byStatus.accepted.total -}; - -const defaultResults = { - receivedDynamicGroups: { - type: DeviceConstants.RECEIVE_DYNAMIC_GROUPS, - groups: { - testGroupDynamic: { - deviceIds: [], - filters: [ - { key: 'id', operator: '$in', scope: 'identity', value: ['a1'] }, - { key: 'mac', operator: '$nexists', scope: 'identity', value: false }, - { key: 'kernel', operator: '$exists', scope: 'identity', value: true } - ], - id: 'filter1', - total: 0 - } - } - }, - receiveDefaultDevice: { type: DeviceConstants.RECEIVE_DEVICES, devicesById: { [defaultState.devices.byId.a1.id]: defaultState.devices.byId.a1 } }, - acceptedDevices, - receivedExpectedDevice, - defaultDeviceListState, - postDeviceAuthActions: [ - { type: DeviceConstants.SET_DEVICE_LIST_STATE, state: { deviceIds: [], isLoading: true, refreshTrigger: true } }, - { - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } - }, - acceptedDevices, - receivedExpectedDevice, - defaultDeviceListState - ] -}; - -/* eslint-disable sonarjs/no-identical-functions */ -describe('selecting things', () => { - it('should allow device list selections', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: DeviceConstants.SET_DEVICE_LIST_STATE, state: { deviceIds: ['a1'], isLoading: true } }, - defaultResults.receivedExpectedDevice, - defaultResults.acceptedDevices, - defaultResults.receivedExpectedDevice, - defaultResults.defaultDeviceListState - ]; - await store.dispatch(setDeviceListState({ deviceIds: ['a1'] })); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow device list selections without device retrieval', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: DeviceConstants.SET_DEVICE_LIST_STATE, state: { deviceIds: ['a1'], isLoading: false } }]; - await store.dispatch(setDeviceListState({ deviceIds: ['a1'], setOnly: true })); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow static group selection', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'testGroup'; - await store.dispatch(selectGroup(groupName)); - // eslint-disable-next-line no-unused-vars - const { attributes, updated_ts, ...expectedDevice } = defaultState.devices.byId.a1; - const expectedActions = [ - { type: DeviceConstants.SELECT_GROUP, group: groupName }, - { type: DeviceConstants.RECEIVE_DEVICES, devicesById: { [defaultState.devices.byId.a1.id]: { ...expectedDevice, attributes } } }, - defaultResults.receivedExpectedDevice, - { - type: DeviceConstants.RECEIVE_GROUP_DEVICES, - group: { filters: [], deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], total: 2 }, - groupName - } - ]; - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow dynamic group selection', async () => { - const store = mockStore({ ...defaultState }); - await store.dispatch(selectGroup('testGroupDynamic')); - const expectedActions = [ - { type: DeviceConstants.SET_DEVICE_FILTERS, filters: [{ scope: 'system', key: 'group', operator: '$eq', value: 'things' }] }, - { type: DeviceConstants.SELECT_GROUP, group: 'testGroupDynamic' } - ]; - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow dynamic group selection with extra filters', async () => { - const store = mockStore({ ...defaultState }); - await store.dispatch( - selectGroup('testGroupDynamic', [ - ...defaultState.devices.groups.byId.testGroupDynamic.filters, - { scope: 'system', key: 'group2', operator: '$eq', value: 'things2' } - ]) - ); - const expectedActions = [ - { - type: DeviceConstants.SET_DEVICE_FILTERS, - filters: [ - { scope: 'system', key: 'group', operator: '$eq', value: 'things' }, - { scope: 'system', key: 'group2', operator: '$eq', value: 'things2' } - ] - }, - { type: DeviceConstants.SELECT_GROUP, group: 'testGroupDynamic' } - ]; - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow setting filters independently', async () => { - const store = mockStore({ ...defaultState }); - await store.dispatch(setDeviceFilters([{ scope: 'system', key: 'group2', operator: '$eq', value: 'things2' }])); - const expectedActions = [{ type: DeviceConstants.SET_DEVICE_FILTERS, filters: [{ scope: 'system', key: 'group2', operator: '$eq', value: 'things2' }] }]; - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); -}); - -describe('overall device information retrieval', () => { - it('should allow count retrieval', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - ...Object.values(DeviceConstants.DEVICE_STATES).map(status => ({ - type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES_COUNT`], - count: defaultState.devices.byStatus[status].total, - status - })) - ]; - await Promise.all(Object.values(DeviceConstants.DEVICE_STATES).map(status => store.dispatch(getDeviceCount(status)))).then(() => { - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - }); - it('should allow count retrieval for all state counts', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - ...[DeviceConstants.DEVICE_STATES.accepted, DeviceConstants.DEVICE_STATES.pending].map(status => ({ - type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES_COUNT`], - count: defaultState.devices.byStatus[status].total, - status - })) - ]; - await store.dispatch(getAllDeviceCounts()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - - it('should allow limit retrieval', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: DeviceConstants.SET_DEVICE_LIMIT, limit: defaultState.devices.limit }]; - await store.dispatch(getDeviceLimit()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow attribute retrieval and group results', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: DeviceConstants.SET_FILTER_ATTRIBUTES, attributes: {} }]; - await store.dispatch(getDeviceAttributes()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - const receivedAttributes = storeActions.find(item => item.type === DeviceConstants.SET_FILTER_ATTRIBUTES).attributes; - expect(Object.keys(receivedAttributes)).toHaveLength(4); - Object.entries(receivedAttributes).forEach(([key, value]) => { - expect(key).toBeTruthy(); - expect(value).toBeTruthy(); - }); - }); - it('should allow attribute config + limit retrieval and group results', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { - type: DeviceConstants.SET_FILTERABLES_CONFIG, - attributes: { - identity: ['status', 'mac'], - inventory: [ - 'artifact_name', - 'cpu_model', - 'device_type', - 'hostname', - 'ipv4_wlan0', - 'ipv6_wlan0', - 'kernel', - 'mac_eth0', - 'mac_wlan0', - 'mem_total_kB', - 'mender_bootloader_integration', - 'mender_client_version', - 'network_interfaces', - 'os', - 'rootfs_type' - ], - system: ['created_ts', 'updated_ts', 'group'] - }, - count: 20, - limit: 100 - } - ]; - await store.dispatch(getReportingLimits()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - - it('should allow getting device aggregation data for use in the dashboard/ reports', async () => { - const store = mockStore({ - ...defaultState, - devices: { ...defaultState.devices, byStatus: { ...defaultState.devices.byStatus, accepted: { ...defaultState.devices.byStatus.accepted, total: 50 } } } - }); - const expectedActions = [ - { - type: DeviceConstants.SET_DEVICE_REPORTS, - reports: [ - { - items: [ - { count: 6, key: 'test' }, - { count: 1, key: 'original' } - ], - otherCount: 43, - total: 50 - } - ] - } - ]; - await store.dispatch(getReportsData()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow getting device aggregation data for use in the dashboard/ reports even if the reporting service is not ready', async () => { - const groupName = 'testGroup'; - const groupNameDynamic = 'testGroupDynamic'; - const store = mockStore({ - ...defaultState, - users: { - ...defaultState.users, - userSettings: { - ...defaultState.users.userSettings, - reports: [{ attribute: 'ipv4_wlan0', chartType: 'bar', group: groupName, type: 'distribution' }] - } - } - }); - const expectedActions = [ - { type: DeviceConstants.RECEIVE_GROUPS, groups: { testGroup: defaultState.devices.groups.byId.testGroup } }, - defaultResults.receivedDynamicGroups, - defaultResults.receivedExpectedDevice, - defaultResults.acceptedDevices, - { type: DeviceConstants.SET_INACTIVE_DEVICES, activeDeviceTotal: 0, inactiveDeviceTotal: 2 }, - { - type: DeviceConstants.SET_DEVICE_REPORTS, - reports: [{ items: [{ count: 2, key: '192.168.10.141/24' }], otherCount: 0, total: 2 }] - }, - defaultResults.receiveDefaultDevice, - defaultResults.receivedExpectedDevice, - { - type: DeviceConstants.ADD_DYNAMIC_GROUP, - groupName: DeviceConstants.UNGROUPED_GROUP.id, - group: { - deviceIds: [], - total: 0, - filters: [{ key: 'group', operator: '$nin', scope: 'system', value: [Object.keys(defaultState.devices.groups.byId)[0]] }] - } - }, - defaultResults.receivedExpectedDevice, - { - type: DeviceConstants.RECEIVE_GROUP_DEVICES, - group: { filters: [], deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], total: 2 }, - groupName - }, - { type: DeviceConstants.RECEIVE_DEVICES, devicesById: {} }, - { type: DeviceConstants.RECEIVE_GROUP_DEVICES, group: defaultState.devices.groups.byId.testGroupDynamic, groupName: groupNameDynamic }, - { - type: DeviceConstants.SET_DEVICE_REPORTS, - reports: [{ items: [{ count: 2, key: '192.168.10.141/24' }], otherCount: 0, total: 2 }] - } - ]; - await store.dispatch(getReportsDataWithoutBackendSupport()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow system devices retrieval', async () => { - const store = mockStore({ - ...defaultState, - app: { - ...defaultState.app, - features: { - ...defaultState.app.features, - isEnterprise: true - } - } - }); - const expectedActions = [ - { - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: { - [defaultState.devices.byId.a1.id]: { - ...defaultState.devices.byId.a1, - systemDeviceIds: [], - systemDeviceTotal: 0 - } - } - } - ]; - await store.dispatch(getSystemDevices(defaultState.devices.byId.a1.id)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow system devices retrieval', async () => { - const gatewayDevice = defaultState.devices.byId.a1; - const store = mockStore({ - ...defaultState, - app: { - ...defaultState.app, - features: { - ...defaultState.app.features, - isEnterprise: true - } - }, - devices: { - ...defaultState.devices, - byId: { - ...defaultState.devices.byId, - [gatewayDevice.id]: { - ...gatewayDevice, - attributes: { - ...gatewayDevice.attributes, - mender_gateway_system_id: 'gatewaySystem' - } - } - } - } - }); - const expectedActions = [{ type: DeviceConstants.RECEIVE_DEVICE, device: { ...gatewayDevice, gatewayIds: [] } }]; - await store.dispatch(getGatewayDevices(defaultState.devices.byId.a1.id)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); -}); - -describe('device auth handling', () => { - const deviceUpdateSuccessMessage = 'Device authorization status was updated successfully'; - it('should allow device auth information retrieval', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [defaultResults.receivedExpectedDevice]; - await store.dispatch(getDeviceAuth(defaultState.devices.byId.a1.id)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should return device auth device as a promise result', async () => { - const store = mockStore({ ...defaultState }); - const device = await store.dispatch(getDeviceAuth(defaultState.devices.byId.a1.id)); - expect(device).toBeDefined(); - }); - it('should allow single device auth updates', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: deviceUpdateSuccessMessage } }, - defaultResults.receivedExpectedDevice, - { - ...defaultResults.acceptedDevices, - deviceIds: defaultState.devices.byStatus.accepted.deviceIds.filter(id => id !== defaultState.devices.byId.a1.id), - total: defaultState.devices.byStatus.accepted.deviceIds.filter(id => id !== defaultState.devices.byId.a1.id).length - }, - ...defaultResults.postDeviceAuthActions - ]; - await store.dispatch( - updateDeviceAuth(defaultState.devices.byId.a1.id, defaultState.devices.byId.a1.auth_sets[0].id, DeviceConstants.DEVICE_STATES.pending) - ); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow multiple device auth updates', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: deviceUpdateSuccessMessage } }, - defaultResults.receivedExpectedDevice, - { - ...defaultResults.acceptedDevices, - deviceIds: [defaultState.devices.byId.b1.id], - total: defaultState.devices.byStatus.accepted.deviceIds.filter(id => id !== defaultState.devices.byId.a1.id).length - }, - ...defaultResults.postDeviceAuthActions, - { - type: SET_SNACKBAR, - snackbar: { - message: - '1 device was updated successfully. 1 device has more than one pending authset. Expand this device to individually adjust its authorization status. ' - } - } - ]; - await store.dispatch(updateDevicesAuth([defaultState.devices.byId.a1.id, defaultState.devices.byId.c1.id], DeviceConstants.DEVICE_STATES.pending)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow preauthorizing devices', async () => { - const store = mockStore({ ...defaultState }); - // eslint-disable-next-line no-unused-vars - const expectedActions = [{ type: SET_SNACKBAR, snackbar: { message: 'Device was successfully added to the preauthorization list' } }]; - await store.dispatch( - preauthDevice({ - ...defaultState.devices.byId.a1.auth_sets[0], - identity_data: { ...defaultState.devices.byId.a1.auth_sets[0].identity_data, mac: '12:34:56' }, - pubkey: 'test' - }) - ); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should notify about duplicate device preauthorization attempts', async () => { - const store = mockStore({ ...defaultState }); - await store - .dispatch(preauthDevice(defaultState.devices.byId.a1.auth_sets[0])) - .catch(message => expect(message).toContain('identity data set already exists')); - }); - it('should allow single device auth set deletion', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: deviceUpdateSuccessMessage } }, - { - ...defaultResults.acceptedDevices, - deviceIds: defaultState.devices.byStatus.accepted.deviceIds.filter(id => id !== defaultState.devices.byId.a1.id), - total: defaultState.devices.byStatus.accepted.deviceIds.filter(id => id !== defaultState.devices.byId.a1.id).length - }, - ...defaultResults.postDeviceAuthActions - ]; - await store.dispatch(deleteAuthset(defaultState.devices.byId.a1.id, defaultState.devices.byId.a1.auth_sets[0].id)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow single device decomissioning', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'Device was decommissioned successfully' } }, - { - ...defaultResults.acceptedDevices, - deviceIds: defaultState.devices.byStatus.accepted.deviceIds.filter(id => id !== defaultState.devices.byId.a1.id), - total: defaultState.devices.byStatus.accepted.deviceIds.filter(id => id !== defaultState.devices.byId.a1.id).length - }, - ...defaultResults.postDeviceAuthActions - ]; - await store.dispatch(decommissionDevice(defaultState.devices.byId.a1.id)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); -}); - -describe('static grouping related actions', () => { - it('should allow retrieving static groups', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: DeviceConstants.RECEIVE_GROUPS, groups: { testGroup: defaultState.devices.groups.byId.testGroup } }, - { - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } - }, - defaultResults.receiveDefaultDevice, - { - type: DeviceConstants.ADD_DYNAMIC_GROUP, - groupName: DeviceConstants.UNGROUPED_GROUP.id, - group: { - deviceIds: [], - total: 0, - filters: [{ key: 'group', operator: '$nin', scope: 'system', value: [Object.keys(defaultState.devices.groups.byId)[0]] }] - } - } - ]; - await store.dispatch(getGroups()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow creating static groups', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'createdTestGroup'; - const expectedActions = [ - { type: DeviceConstants.ADD_TO_GROUP, group: groupName, deviceIds: [defaultState.devices.byId.a1.id] }, - { type: DeviceConstants.RECEIVE_GROUPS, groups: { testGroup: defaultState.devices.groups.byId.testGroup } }, - { - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } - }, - defaultResults.receiveDefaultDevice, - { - type: DeviceConstants.ADD_DYNAMIC_GROUP, - groupName: DeviceConstants.UNGROUPED_GROUP.id, - group: { - deviceIds: [], - total: 0, - filters: [{ key: 'group', operator: '$nin', scope: 'system', value: [Object.keys(defaultState.devices.groups.byId)[0]] }] - } - }, - { type: DeviceConstants.ADD_STATIC_GROUP, group: { deviceIds: [], total: 0, filters: [] }, groupName }, - { type: DeviceConstants.SET_DEVICE_LIST_STATE, state: { ...defaultState.devices.deviceList, deviceIds: [], setOnly: true } }, - { type: SET_SNACKBAR, snackbar: { message: getGroupSuccessNotification(groupName) } }, - { type: DeviceConstants.RECEIVE_GROUPS, groups: { testGroup: defaultState.devices.groups.byId.testGroup } }, - { - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } - }, - defaultResults.receiveDefaultDevice, - { - type: DeviceConstants.ADD_DYNAMIC_GROUP, - groupName: DeviceConstants.UNGROUPED_GROUP.id, - group: { - deviceIds: [], - total: 0, - filters: [{ key: 'group', operator: '$nin', scope: 'system', value: [Object.keys(defaultState.devices.groups.byId)[0]] }] - } - } - ]; - await store.dispatch(addStaticGroup(groupName, [defaultState.devices.byId.a1])); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow extending static groups', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'createdTestGroup'; - const expectedActions = [{ type: DeviceConstants.ADD_TO_GROUP, group: groupName, deviceIds: [defaultState.devices.byId.b1.id] }]; - await store.dispatch(addDevicesToGroup(groupName, [defaultState.devices.byId.b1.id])); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow shrinking static groups', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'testGroup'; - const expectedActions = [ - { type: DeviceConstants.REMOVE_FROM_GROUP, group: groupName, deviceIds: [defaultState.devices.byId.b1.id] }, - { type: SET_SNACKBAR, snackbar: { message: 'The device was removed from the group' } } - ]; - await store.dispatch(removeDevicesFromGroup(groupName, [defaultState.devices.byId.b1.id])); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow removing static groups', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'testGroup'; - const expectedActions = [ - { type: DeviceConstants.REMOVE_STATIC_GROUP, groups: {} }, - { type: SET_SNACKBAR, snackbar: { message: 'Group was removed successfully' } }, - { type: DeviceConstants.RECEIVE_GROUPS, groups: { testGroup: defaultState.devices.groups.byId.testGroup } }, - { - type: DeviceConstants.RECEIVE_DEVICES, - devicesById: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } - }, - defaultResults.receiveDefaultDevice, - { - type: DeviceConstants.ADD_DYNAMIC_GROUP, - groupName: DeviceConstants.UNGROUPED_GROUP.id, - group: { - deviceIds: [], - total: 0, - filters: [{ key: 'group', operator: '$nin', scope: 'system', value: [Object.keys(defaultState.devices.groups.byId)[0]] }] - } - } - ]; - await store.dispatch(removeStaticGroup(groupName)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - const remainingGroups = storeActions.find(item => item.type === DeviceConstants.REMOVE_STATIC_GROUP).groups; - expect(Object.keys(remainingGroups).length).toBeLessThan(Object.keys(defaultState.devices.groups.byId).length); - expect(Object.keys(remainingGroups).some(key => key === groupName)).toBeFalsy(); - }); - it('should allow device retrieval for static groups', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'testGroup'; - // eslint-disable-next-line no-unused-vars - const { attributes, updated_ts, ...expectedDevice } = defaultState.devices.byId.a1; - const expectedActions = [ - { type: DeviceConstants.RECEIVE_DEVICES, devicesById: { [defaultState.devices.byId.a1.id]: { ...expectedDevice, attributes } } }, - { - type: DeviceConstants.SET_ACCEPTED_DEVICES, - deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], - status: DeviceConstants.DEVICE_STATES.accepted, - total: 2 - }, - { type: DeviceConstants.RECEIVE_DEVICES, devicesById: { [expectedDevice.id]: { ...expectedDevice, updated_ts } } }, - { - type: DeviceConstants.RECEIVE_GROUP_DEVICES, - group: { filters: [], deviceIds: defaultState.devices.groups.byId[groupName].deviceIds, total: defaultState.devices.groups.byId[groupName].total }, - groupName - } - ]; - await store.dispatch(getGroupDevices(groupName)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - const devicesById = storeActions.find(item => item.type === DeviceConstants.RECEIVE_DEVICES).devicesById; - expect(devicesById[defaultState.devices.byId.a1.id]).toBeTruthy(); - expect(new Date(devicesById[defaultState.devices.byId.a1.id].updated_ts).getTime()).toBeGreaterThanOrEqual(new Date(updated_ts).getTime()); - }); - it('should allow complete device retrieval for static groups', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'testGroup'; - const expectedActions = [ - defaultResults.receivedExpectedDevice, - { - type: DeviceConstants.RECEIVE_GROUP_DEVICES, - group: { filters: [], deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], total: 2 }, - groupName - } - ]; - await store.dispatch(getAllGroupDevices(groupName)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); -}); - -describe('dynamic grouping related actions', () => { - it('should allow retrieving dynamic groups', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [defaultResults.receivedDynamicGroups]; - await store.dispatch(getDynamicGroups()); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - - it('should allow creating dynamic groups', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'createdTestGroup'; - const expectedActions = [ - { - type: DeviceConstants.ADD_DYNAMIC_GROUP, - groupName, - group: { deviceIds: [], total: 0, filters: [{ key: 'group', operator: '$nin', scope: 'system', value: ['testGroup'] }] } - }, - { type: SET_SNACKBAR, snackbar: { message: getGroupSuccessNotification(groupName) } }, - defaultResults.receivedDynamicGroups - ]; - await store.dispatch(addDynamicGroup(groupName, [{ key: 'group', operator: '$nin', scope: 'system', value: ['testGroup'] }])); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow complete device retrieval for dynamic groups', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'testGroupDynamic'; - const expectedActions = [ - { type: DeviceConstants.RECEIVE_DEVICES, devicesById: {} }, - { type: DeviceConstants.RECEIVE_GROUP_DEVICES, group: defaultState.devices.groups.byId.testGroupDynamic, groupName } - ]; - await store.dispatch(getAllDynamicGroupDevices(groupName)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow dynamic group updates', async () => { - const groupName = 'testGroupDynamic'; - const store = mockStore({ - ...defaultState, - devices: { - ...defaultState.devices, - groups: { - ...defaultState.devices.groups, - selectedGroup: groupName - } - } - }); - const expectedActions = [ - { type: DeviceConstants.ADD_DYNAMIC_GROUP, groupName, group: { deviceIds: [], total: 0, filters: [] } }, - { type: DeviceConstants.SET_DEVICE_FILTERS, filters: defaultState.devices.groups.byId.testGroupDynamic.filters }, - { type: SET_SNACKBAR, snackbar: { message: groupUpdateSuccessMessage } }, - defaultResults.receivedDynamicGroups - ]; - await store.dispatch(updateDynamicGroup(groupName, [])); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow removing dynamic groups', async () => { - const store = mockStore({ ...defaultState }); - const groupName = 'testGroupDynamic'; - const { testGroup } = defaultState.devices.groups.byId; - const expectedActions = [ - { type: DeviceConstants.REMOVE_DYNAMIC_GROUP, groups: { testGroup } }, - { type: SET_SNACKBAR, snackbar: { message: 'Group was removed successfully' } } - ]; - await store.dispatch(removeDynamicGroup(groupName)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - const remainingGroups = storeActions.find(item => item.type === DeviceConstants.REMOVE_DYNAMIC_GROUP).groups; - expect(Object.keys(remainingGroups).length).toBeLessThan(Object.keys(defaultState.devices.groups.byId).length); - expect(Object.keys(remainingGroups).some(key => key === groupName)).toBeFalsy(); - }); -}); - -describe('device retrieval ', () => { - it('should allow single device retrieval from inventory', async () => { - const store = mockStore({ - ...defaultState - }); - const { attributes, id } = defaultState.devices.byId.a1; - const expectedActions = [{ type: DeviceConstants.RECEIVE_DEVICE, device: { attributes, id } }]; - await store.dispatch(getDeviceById(defaultState.devices.byId.a1.id)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow single device retrieval from detailed sources', async () => { - const store = mockStore({ - ...defaultState, - app: { ...defaultState.app, features: { ...defaultState.app.features, hasDeviceConnect: true } }, - organization: { ...defaultState.organization, addons: [], externalDeviceIntegrations: [{ ...DeviceConstants.EXTERNAL_PROVIDER['iot-hub'], id: 'test' }] } - }); - const { attributes, updated_ts, id, ...expectedDevice } = defaultState.devices.byId.a1; - const expectedActions = [ - { type: DeviceConstants.RECEIVE_DEVICES, devicesById: { [id]: { ...expectedDevice, id } } }, - { type: DeviceConstants.RECEIVE_DEVICE, device: { attributes, id } }, - { type: DeviceConstants.RECEIVE_DEVICE_CONNECT, device: { connect_status: 'connected', connect_updated_ts: updated_ts, id } }, - { type: DeviceConstants.RECEIVE_DEVICE, device: expectedDevice } - ]; - await store.dispatch(getDeviceInfo(defaultState.devices.byId.a1.id)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow retrieving multiple devices by status', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [defaultResults.receivedExpectedDevice, defaultResults.acceptedDevices, defaultResults.receivedExpectedDevice]; - await store.dispatch(getDevicesByStatus(DeviceConstants.DEVICE_STATES.accepted)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow retrieving multiple devices by status and select if requested', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - defaultResults.receivedExpectedDevice, - { - type: DeviceConstants.SET_ACCEPTED_DEVICES, - deviceIds: [defaultState.devices.byId.a1.id], - status: DeviceConstants.DEVICE_STATES.accepted, - total: defaultState.devices.byStatus.accepted.total - }, - defaultResults.receivedExpectedDevice - ]; - await store.dispatch(getDevicesByStatus(DeviceConstants.DEVICE_STATES.accepted, { perPage: 1, shouldSelectDevices: true })); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow retrieving devices based on devicelist state', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: DeviceConstants.SET_DEVICE_LIST_STATE, state: { ...defaultState.devices.deviceList, perPage: 2, deviceIds: [], isLoading: true } }, - defaultResults.receivedExpectedDevice, - defaultResults.acceptedDevices, - defaultResults.receivedExpectedDevice, - // the following perPage setting should be 2 as well, but the test backend seems to respond too fast for the state change to propagate - defaultResults.defaultDeviceListState - ]; - await store.dispatch(setDeviceListState({ page: 1, perPage: 2, refreshTrigger: true })); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow retrieving all devices per status', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - defaultResults.receivedExpectedDevice, - defaultResults.acceptedDevices, - { type: DeviceConstants.SET_INACTIVE_DEVICES, activeDeviceTotal: 0, inactiveDeviceTotal: 2 }, - { type: DeviceConstants.SET_DEVICE_REPORTS, reports: [{ items: [{ count: 2, key: 'undefined' }], otherCount: 0, total: 2 }] } - ]; - await store.dispatch(getAllDevicesByStatus(DeviceConstants.DEVICE_STATES.accepted)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow retrieving devices per status and their auth data', async () => { - const store = mockStore({ ...defaultState }); - const { - a1: { attributes: attributes1, ...expectedDevice1 }, // eslint-disable-line no-unused-vars - b1: { attributes: attributes2, auth_sets, ...expectedDevice2 } // eslint-disable-line no-unused-vars - } = defaultState.devices.byId; - const expectedActions = [ - { type: DeviceConstants.RECEIVE_DEVICES, devicesById: { [expectedDevice1.id]: expectedDevice1, [expectedDevice2.id]: expectedDevice2 } } - ]; - await store.dispatch(getDevicesWithAuth([defaultState.devices.byId.a1, defaultState.devices.byId.b1])); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); -}); - -const deviceConfig = { - configured: { aNumber: 42, something: 'else', test: true }, - reported: { aNumber: 42, something: 'else', test: true }, - updated_ts: defaultState.devices.byId.a1.updated_ts, - reported_ts: '2019-01-01T09:25:01.000Z' -}; - -describe('device config ', () => { - it('should allow single device config retrieval', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: DeviceConstants.RECEIVE_DEVICE_CONFIG, device: { config: deviceConfig, id: defaultState.devices.byId.a1.id } }]; - await store.dispatch(getDeviceConfig(defaultState.devices.byId.a1.id)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should not have a problem with unknown devices on config retrieval', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = []; - await store.dispatch(getDeviceConfig('testId')); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - - it('should allow single device config update', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: DeviceConstants.RECEIVE_DEVICE_CONFIG, device: { config: deviceConfig, id: defaultState.devices.byId.a1.id } }]; - await store.dispatch(setDeviceConfig(defaultState.devices.byId.a1.id), { something: 'asdl' }); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow single device config deployment', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: DeviceConstants.RECEIVE_DEVICE, device: { ...defaultState.devices.byId.a1, config: { deployment_id: '' } } }, - { type: DeploymentConstants.RECEIVE_DEPLOYMENT, deployment: { ...defaultState.deployments.byId.d1, id: 'config1', created: '2019-01-01T09:25:01.000Z' } } - ]; - const result = store.dispatch(applyDeviceConfig(defaultState.devices.byId.a1.id), { something: 'asdl' }); - await act(async () => jest.runAllTicks()); - result.then(() => { - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - await waitFor(async () => expect(deploymentsSpy).toHaveBeenCalled(), { timeout: TIMEOUTS.threeSeconds }); - deploymentsSpy.mockClear(); - }); - it('should allow setting device tags', async () => { - const store = mockStore({ ...defaultState }); - const { attributes, id } = defaultState.devices.byId.a1; - const expectedActions = [ - { type: DeviceConstants.RECEIVE_DEVICE, device: { attributes, id } }, - { type: DeviceConstants.RECEIVE_DEVICE, device: { attributes, id, tags: { something: 'asdl' } } }, - { type: SET_SNACKBAR, snackbar: { message: 'Device name changed' } } - ]; - await store.dispatch(setDeviceTags(defaultState.devices.byId.a1.id, { something: 'asdl' })); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); -}); - -describe('troubleshooting related actions', () => { - it('should allow session info retrieval', async () => { - const store = mockStore({ ...defaultState }); - const endDate = '2019-01-01T12:16:22.667Z'; - const sessionId = 'abd313a8-ee88-48ab-9c99-fbcd80048e6e'; - const result = await store.dispatch(getSessionDetails(sessionId, defaultState.devices.byId.a1.id, defaultState.users.currentUser, undefined, endDate)); - - expect(result).toMatchObject({ start: new Date(endDate), end: new Date(endDate) }); - }); - - it('should allow device file transfers', async () => { - const link = await getDeviceFileDownloadLink('aDeviceId', '/tmp/file')(); - expect(link).toBe('/api/management/v1/deviceconnect/devices/aDeviceId/download?path=%2Ftmp%2Ffile'); - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'Uploading file' } }, - { - type: UPLOAD_PROGRESS, - uploads: { 'mock-uuid': { cancelSource: mockAbortController, uploadProgress: 0 } } - }, - { type: UPLOAD_PROGRESS, uploads: {} }, - { type: SET_SNACKBAR, snackbar: { message: 'Upload successful' } }, - { type: UPLOAD_PROGRESS, uploads: {} } - ]; - await store.dispatch(deviceFileUpload(defaultState.devices.byId.a1.id, '/tmp/file', 'file')); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); -}); - -describe('device twin related actions', () => { - it('should allow retrieving twin data from azure', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: DeviceConstants.RECEIVE_DEVICE, device: defaultState.devices.byId.a1 }]; - await store.dispatch(getDeviceTwin(defaultState.devices.byId.a1.id, DeviceConstants.EXTERNAL_PROVIDER['iot-hub'])); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); - it('should allow configuring twin data on azure', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: DeviceConstants.RECEIVE_DEVICE, device: defaultState.devices.byId.a1 }]; - await store.dispatch(setDeviceTwin(defaultState.devices.byId.a1.id, DeviceConstants.EXTERNAL_PROVIDER['iot-hub'], { something: 'asdl' })); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); -}); diff --git a/frontend/src/js/actions/monitorActions.js b/frontend/src/js/actions/monitorActions.js deleted file mode 100644 index 73abfcc6..00000000 --- a/frontend/src/js/actions/monitorActions.js +++ /dev/null @@ -1,143 +0,0 @@ -// Copyright 2021 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import Api, { apiUrl, headerNames } from '../api/general-api'; -import { TIMEOUTS } from '../constants/appConstants'; -import * as DeviceConstants from '../constants/deviceConstants'; -import * as MonitorConstants from '../constants/monitorConstants'; -import { getDeviceFilters } from '../selectors'; -import { commonErrorFallback, commonErrorHandler, setSnackbar } from './appActions'; -import { convertDeviceListStateToFilters, getSearchEndpoint } from './deviceActions'; - -export const monitorApiUrlv1 = `${apiUrl.v1}/devicemonitor`; - -const { page: defaultPage, perPage: defaultPerPage } = DeviceConstants.DEVICE_LIST_DEFAULTS; - -const cutoffLength = 75; -const ellipsis = '...'; - -const longTextTrimmer = text => (text.length >= cutoffLength + ellipsis.length ? `${text.substring(0, cutoffLength + ellipsis.length)}${ellipsis}` : text); - -const sanitizeDeviceAlerts = alerts => alerts.map(alert => ({ ...alert, fullName: alert.name, name: longTextTrimmer(alert.name) })); - -export const setAlertListState = selectionState => (dispatch, getState) => - Promise.resolve( - dispatch({ - type: MonitorConstants.SET_ALERT_LIST_STATE, - value: { ...getState().monitor.alerts.alertList, ...selectionState } - }) - ); - -export const getDeviceAlerts = - (id, config = {}) => - dispatch => { - const { page = defaultPage, perPage = defaultPerPage, issuedBefore, issuedAfter, sortAscending = false } = config; - const issued_after = issuedAfter ? `&issued_after=${issuedAfter}` : ''; - const issued_before = issuedBefore ? `&issued_before=${issuedBefore}` : ''; - return Api.get(`${monitorApiUrlv1}/devices/${id}/alerts?page=${page}&per_page=${perPage}${issued_after}${issued_before}&sort_ascending=${sortAscending}`) - .catch(err => commonErrorHandler(err, `Retrieving device alerts for device ${id} failed:`, dispatch)) - .then(res => - Promise.all([ - dispatch({ - type: MonitorConstants.RECEIVE_DEVICE_ALERTS, - deviceId: id, - alerts: sanitizeDeviceAlerts(res.data) - }), - dispatch(setAlertListState({ total: Number(res.headers[headerNames.total]) })) - ]) - ); - }; - -export const getLatestDeviceAlerts = - (id, config = {}) => - dispatch => { - const { page = defaultPage, perPage = 10 } = config; - return Api.get(`${monitorApiUrlv1}/devices/${id}/alerts/latest?page=${page}&per_page=${perPage}`) - .catch(err => commonErrorHandler(err, `Retrieving device alerts for device ${id} failed:`, dispatch)) - .then(res => - Promise.resolve( - dispatch({ - type: MonitorConstants.RECEIVE_LATEST_DEVICE_ALERTS, - deviceId: id, - alerts: sanitizeDeviceAlerts(res.data) - }) - ) - ); - }; - -export const getIssueCountsByType = - (type, options = {}) => - (dispatch, getState) => { - const state = getState(); - const { filters = getDeviceFilters(state), group, status, ...remainder } = options; - const { applicableFilters: nonMonitorFilters, filterTerms } = convertDeviceListStateToFilters({ - ...remainder, - filters, - group, - offlineThreshold: getState().app.offlineThreshold, - selectedIssues: [type], - status - }); - return Api.post(getSearchEndpoint(state.app.features.hasReporting), { - page: 1, - per_page: 1, - filters: filterTerms, - attributes: [{ scope: 'identity', attribute: 'status' }] - }) - .catch(err => commonErrorHandler(err, `Retrieving issue counts failed:`, dispatch, commonErrorFallback)) - .then(res => { - const total = nonMonitorFilters.length ? state.monitor.issueCounts.byType[type].total : Number(res.headers[headerNames.total]); - const filtered = nonMonitorFilters.length ? Number(res.headers[headerNames.total]) : total; - if (total === state.monitor.issueCounts.byType[type].total && filtered === state.monitor.issueCounts.byType[type].filtered) { - return Promise.resolve(); - } - return Promise.resolve( - dispatch({ - counts: { filtered, total }, - issueType: type, - type: MonitorConstants.RECEIVE_DEVICE_ISSUE_COUNTS - }) - ); - }); - }; - -export const getDeviceMonitorConfig = id => dispatch => - Api.get(`${monitorApiUrlv1}/devices/${id}/config`) - .catch(err => commonErrorHandler(err, `Retrieving device monitor config for device ${id} failed:`, dispatch)) - .then(({ data }) => { - let tasks = [ - dispatch({ - type: MonitorConstants.RECEIVE_DEVICE_MONITOR_CONFIG, - device: { id, monitors: data } - }) - ]; - tasks.push(Promise.resolve(data)); - return Promise.all(tasks); - }); - -export const changeNotificationSetting = - (enabled, channel = MonitorConstants.alertChannels.email) => - dispatch => { - return Api.put(`${monitorApiUrlv1}/settings/global/channel/alerts/${channel}/status`, { enabled }) - .catch(err => commonErrorHandler(err, `${enabled ? 'En' : 'Dis'}abling ${channel} alerts failed:`, dispatch)) - .then(() => - Promise.all([ - dispatch({ - type: MonitorConstants.CHANGE_ALERT_CHANNEL, - channel, - enabled - }), - dispatch(setSnackbar(`Successfully ${enabled ? 'en' : 'dis'}abled ${channel} alerts`, TIMEOUTS.fiveSeconds)) - ]) - ); - }; diff --git a/frontend/src/js/components/__snapshots__/app.test.js.snap b/frontend/src/js/components/__snapshots__/app.test.js.snap index 932f77b1..1a6c71bc 100644 --- a/frontend/src/js/components/__snapshots__/app.test.js.snap +++ b/frontend/src/js/components/__snapshots__/app.test.js.snap @@ -1097,9 +1097,7 @@ label[data-shrink=false]+.MuiInputBase-formControl .emotion-5:focus::-ms-input-p
- Version: next -
+ />

{ const showStartupNotification = useSelector(state => state.users.showStartupNotification); const snackbar = useSelector(state => state.app.snackbar); const trackingCode = useSelector(state => state.app.trackerCode); - const { mode } = useSelector(getUserSettings); + const isDarkMode = useSelector(getIsDarkMode); const { token: storedToken } = getSessionInfo(); const { expiresAt, token = storedToken } = useSelector(getCurrentSession); @@ -119,7 +122,7 @@ export const AppRoot = () => { const keyOnlyFilters = filters.split('&').reduce((accu, item) => `${accu}:${item.split('=')[0]}&`, ''); // assume the keys to filter by are not as revealing as the values things are filtered by page = `${page.substring(0, splitter)}?${keyOnlyFilters.substring(0, keyOnlyFilters.length - 1)}`; // cut off the last & of the reduced filters string } else if (page.startsWith(activationPath)) { - dispatch(setAccountActivationCode(page.substring(activationPath.length + 1))); + dispatch(receivedActivationCode(page.substring(activationPath.length + 1))); navigate('/settings/my-profile', { replace: true }); } else if (trackingBlacklist.some(item => !!page.match(item))) { return; @@ -180,11 +183,13 @@ export const AppRoot = () => { const onToggleSearchResult = () => setShowSearchResult(toggle); - const theme = createTheme(isDarkMode(mode) ? darkTheme : lightTheme); + const theme = createTheme(isDarkMode ? darkTheme : lightTheme); const { classes } = useStyles(); const globalCssVars = cssVariables({ theme })['@global']; + const dispatchedSetSnackbar = useCallback(message => dispatch(setSnackbar(message)), [dispatch]); + return ( @@ -192,7 +197,7 @@ export const AppRoot = () => { <> {token ? (

-
+
@@ -210,7 +215,7 @@ export const AppRoot = () => {
)} - dispatch(setSnackbar(message))} /> + diff --git a/frontend/src/js/components/app.test.js b/frontend/src/js/components/app.test.js index ea6c5a5d..747a4046 100644 --- a/frontend/src/js/components/app.test.js +++ b/frontend/src/js/components/app.test.js @@ -14,16 +14,16 @@ import React from 'react'; import Linkify from 'react-linkify'; +import GeneralApi from '@northern.tech/store/api/general-api'; +import { getSessionInfo, maxSessionAge } from '@northern.tech/store/auth'; +import { TIMEOUTS } from '@northern.tech/store/constants'; +import * as DeviceActions from '@northern.tech/store/devicesSlice/thunks'; import { act, screen, render as testLibRender, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import 'jsdom-worker'; import { defaultState, mockDate, token, undefineds } from '../../../tests/mockData'; import { render } from '../../../tests/setupTests'; -import * as DeviceActions from '../actions/deviceActions'; -import GeneralApi from '../api/general-api'; -import { getSessionInfo, maxSessionAge } from '../auth'; -import { TIMEOUTS } from '../constants/appConstants'; import App, { AppProviders } from './app'; const preloadedState = { diff --git a/frontend/src/js/components/auditlogs/auditlogs.js b/frontend/src/js/components/auditlogs/auditlogs.js index 16af9e77..92b2322f 100644 --- a/frontend/src/js/components/auditlogs/auditlogs.js +++ b/frontend/src/js/components/auditlogs/auditlogs.js @@ -17,14 +17,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { Button, TextField } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import dayjs from 'dayjs'; - -import historyImage from '../../../assets/img/history.png'; -import { getAuditLogs, getAuditLogsCsvLink, setAuditlogsState } from '../../actions/organizationActions'; -import { getUserList } from '../../actions/userActions'; -import { BEGINNING_OF_TIME, BENEFITS, SORTING_OPTIONS, TIMEOUTS } from '../../constants/appConstants'; -import { AUDIT_LOGS_TYPES } from '../../constants/organizationConstants'; -import { createDownload, getISOStringBoundaries } from '../../helpers'; +import { AUDIT_LOGS_TYPES, BEGINNING_OF_TIME, BENEFITS, SORTING_OPTIONS, TIMEOUTS } from '@northern.tech/store/constants'; import { getAuditLog, getAuditLogEntry, @@ -33,7 +26,12 @@ import { getGroupNames, getTenantCapabilities, getUserCapabilities -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { getAuditLogs, getAuditLogsCsvLink, getUserList, setAuditlogsState } from '@northern.tech/store/thunks'; +import dayjs from 'dayjs'; + +import historyImage from '../../../assets/img/history.png'; +import { createDownload, getISOStringBoundaries } from '../../helpers'; import { useLocationParams } from '../../utils/liststatehook'; import EnterpriseNotification, { DefaultUpgradeNotification } from '../common/enterpriseNotification'; import { ControlledAutoComplete } from '../common/forms/autocomplete'; @@ -172,18 +170,20 @@ export const AuditLogs = props => { dispatch(setAuditlogsState(state)).then(() => setTimeout(() => (isInitialized.current = true), TIMEOUTS.oneSecond + TIMEOUTS.debounceDefault)); return; } - dispatch( - getAuditLogs({ page: state.page ?? 1, perPage: 50, startDate: startDate !== today ? startDate : BEGINNING_OF_TIME, endDate, user, type, detail }) - ).then(result => initAuditlogState(result, state)); + dispatch(getAuditLogs({ page: state.page ?? 1, perPage: 50, startDate: startDate !== today ? startDate : BEGINNING_OF_TIME, endDate, user, type, detail })) + .unwrap() + .then(({ payload: result }) => initAuditlogState(result, state)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, hasAuditlogs, JSON.stringify(events), JSON.stringify(locationParams), initAuditlogState, today, tonight]); const createCsvDownload = () => { setCsvLoading(true); - dispatch(getAuditLogsCsvLink()).then(address => { - createDownload(encodeURI(address), `Mender-AuditLog-${dayjs(startDate).format('YYYY-MM-DD')}-${dayjs(endDate).format('YYYY-MM-DD')}.csv`, token); - setCsvLoading(false); - }); + dispatch(getAuditLogsCsvLink()) + .unwrap() + .then(address => { + createDownload(encodeURI(address), `Mender-AuditLog-${dayjs(startDate).format('YYYY-MM-DD')}-${dayjs(endDate).format('YYYY-MM-DD')}.csv`, token); + setCsvLoading(false); + }); }; const onChangeSorting = () => { diff --git a/frontend/src/js/components/auditlogs/auditlogs.test.js b/frontend/src/js/components/auditlogs/auditlogs.test.js index d6f172e2..a6289695 100644 --- a/frontend/src/js/components/auditlogs/auditlogs.test.js +++ b/frontend/src/js/components/auditlogs/auditlogs.test.js @@ -19,14 +19,14 @@ import { ThemeProvider, createTheme } from '@mui/material/styles'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import { TIMEOUTS } from '@northern.tech/store/constants'; +import { getConfiguredStore } from '@northern.tech/store/store'; import { prettyDOM, screen, render as testingLibRender, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { light as lightTheme } from '../../../../src/js/themes/Mender'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render, selectMaterialUiSelectOption } from '../../../../tests/setupTests'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { getConfiguredStore } from '../../reducers'; import AuditLogs from './auditlogs'; const preloadedState = { diff --git a/frontend/src/js/components/auditlogs/auditlogslist.js b/frontend/src/js/components/auditlogs/auditlogslist.js index bf8cb8d1..ebd89793 100644 --- a/frontend/src/js/components/auditlogs/auditlogslist.js +++ b/frontend/src/js/components/auditlogs/auditlogslist.js @@ -16,8 +16,8 @@ import { Link } from 'react-router-dom'; import { ArrowRightAlt as ArrowRightAltIcon, Sort as SortIcon } from '@mui/icons-material'; -import { SORTING_OPTIONS, canAccess } from '../../constants/appConstants'; -import { DEPLOYMENT_ROUTES } from '../../constants/deploymentConstants'; +import { DEPLOYMENT_ROUTES, SORTING_OPTIONS, canAccess } from '@northern.tech/store/constants'; + import DeviceIdentityDisplay from '../common/deviceidentity'; import Loader from '../common/loader'; import Pagination from '../common/pagination'; diff --git a/frontend/src/js/components/auditlogs/eventdetails/__snapshots__/portforward.test.js.snap b/frontend/src/js/components/auditlogs/eventdetails/__snapshots__/portforward.test.js.snap index 373bdd81..8f615e87 100644 --- a/frontend/src/js/components/auditlogs/eventdetails/__snapshots__/portforward.test.js.snap +++ b/frontend/src/js/components/auditlogs/eventdetails/__snapshots__/portforward.test.js.snap @@ -183,9 +183,9 @@ exports[`PortForward Component renders correctly 1`] = ` class="flexbox " >
- 00:05:59:998 + 00:00:00:000
{ dispatch(getDeviceById(object.id)); } dispatch( - getSessionDetails(meta.session_id[0], object.id, actor.id, action.startsWith('open') ? time : undefined, action.startsWith('close') ? time : undefined) + getSessionDetails({ + sessionId: meta.session_id[0], + deviceId: object.id, + userId: actor.id, + startDate: action.startsWith('open') ? time : undefined, + endDate: action.startsWith('close') ? time : undefined + }) ).then(setSessionDetails); }, [action, actor.id, canReadDevices, dispatch, meta.session_id, object.id, time]); diff --git a/frontend/src/js/components/auditlogs/eventdetails/portforward.test.js b/frontend/src/js/components/auditlogs/eventdetails/portforward.test.js index 210818ab..ca2db367 100644 --- a/frontend/src/js/components/auditlogs/eventdetails/portforward.test.js +++ b/frontend/src/js/components/auditlogs/eventdetails/portforward.test.js @@ -13,11 +13,11 @@ // limitations under the License. import React from 'react'; +import * as DeviceActions from '@northern.tech/store/devicesSlice/thunks'; import { act, waitFor } from '@testing-library/react'; import { defaultState, undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import * as DeviceActions from '../../../actions/deviceActions'; import PortForward from './portforward'; describe('PortForward Component', () => { diff --git a/frontend/src/js/components/auditlogs/eventdetails/terminalplayer.js b/frontend/src/js/components/auditlogs/eventdetails/terminalplayer.js index 8e1b40e8..186a12e4 100644 --- a/frontend/src/js/components/auditlogs/eventdetails/terminalplayer.js +++ b/frontend/src/js/components/auditlogs/eventdetails/terminalplayer.js @@ -17,12 +17,10 @@ import { CloudDownload, Pause, PlayArrow, Refresh } from '@mui/icons-material'; import { Button } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { DEVICE_MESSAGE_PROTOCOLS as MessageProtocols, DEVICE_MESSAGE_TYPES as MessageTypes, TIMEOUTS, deviceConnect } from '@northern.tech/store/constants'; import msgpack5 from 'msgpack5'; import Cookies from 'universal-cookie'; -import { deviceConnect } from '../../../actions/deviceActions'; -import { TIMEOUTS } from '../../../constants/appConstants'; -import { DEVICE_MESSAGE_PROTOCOLS as MessageProtocols, DEVICE_MESSAGE_TYPES as MessageTypes } from '../../../constants/deviceConstants'; import { createFileDownload, toggle } from '../../../helpers'; import { blobToString, byteArrayToString } from '../../../utils/sockethook'; import XTerm from '../../common/xterm'; diff --git a/frontend/src/js/components/auditlogs/eventdetails/terminalsession.js b/frontend/src/js/components/auditlogs/eventdetails/terminalsession.js index d5cbf741..1f9484e9 100644 --- a/frontend/src/js/components/auditlogs/eventdetails/terminalsession.js +++ b/frontend/src/js/components/auditlogs/eventdetails/terminalsession.js @@ -16,11 +16,11 @@ import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from '@mui/material/styles'; +import { getAuditlogDevice, getCurrentSession, getIdAttribute, getUserCapabilities } from '@northern.tech/store/selectors'; +import { getDeviceById, getSessionDetails } from '@northern.tech/store/thunks'; import dayjs from 'dayjs'; import durationDayJs from 'dayjs/plugin/duration'; -import { getDeviceById, getSessionDetails } from '../../../actions/deviceActions'; -import { getAuditlogDevice, getCurrentSession, getIdAttribute, getUserCapabilities } from '../../../selectors'; import Loader from '../../common/loader'; import Time from '../../common/time'; import DeviceDetails, { DetailInformation } from './devicedetails'; @@ -43,8 +43,16 @@ export const TerminalSession = ({ item, onClose }) => { dispatch(getDeviceById(object.id)); } dispatch( - getSessionDetails(meta.session_id[0], object.id, actor.id, action.startsWith('open') ? time : undefined, action.startsWith('close') ? time : undefined) - ).then(setSessionDetails); + getSessionDetails({ + sessionId: meta.session_id[0], + deviceId: object.id, + userId: actor.id, + startDate: action.startsWith('open') ? time : undefined, + endDate: action.startsWith('close') ? time : undefined + }) + ) + .unwrap() + .then(setSessionDetails); }, [action, actor.id, canReadDevices, dispatch, meta.session_id, object.id, time]); if (!sessionDetails || (canReadDevices && !device)) { diff --git a/frontend/src/js/components/auditlogs/eventdetails/terminalsession.test.js b/frontend/src/js/components/auditlogs/eventdetails/terminalsession.test.js index 3d232efe..a59623b9 100644 --- a/frontend/src/js/components/auditlogs/eventdetails/terminalsession.test.js +++ b/frontend/src/js/components/auditlogs/eventdetails/terminalsession.test.js @@ -13,11 +13,11 @@ // limitations under the License. import React from 'react'; +import * as DeviceActions from '@northern.tech/store/devicesSlice/thunks'; import { act, screen, waitFor } from '@testing-library/react'; import { defaultState, undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import * as DeviceActions from '../../../actions/deviceActions'; import TerminalSession from './terminalsession'; describe('TerminalSession Component', () => { diff --git a/frontend/src/js/components/common/asyncautocomplete.js b/frontend/src/js/components/common/asyncautocomplete.js index 1cfddfe9..cd886d52 100644 --- a/frontend/src/js/components/common/asyncautocomplete.js +++ b/frontend/src/js/components/common/asyncautocomplete.js @@ -16,7 +16,8 @@ import React, { useEffect, useState } from 'react'; import { Autocomplete, TextField } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import { TIMEOUTS } from '../../constants/appConstants'; +import { TIMEOUTS } from '@northern.tech/store/constants'; + import { useDebounce } from '../../utils/debouncehook'; import Loader from './loader'; diff --git a/frontend/src/js/components/common/copy-code.js b/frontend/src/js/components/common/copy-code.js index f96ff9a7..390e858e 100644 --- a/frontend/src/js/components/common/copy-code.js +++ b/frontend/src/js/components/common/copy-code.js @@ -18,7 +18,7 @@ import { FileCopy as CopyPasteIcon } from '@mui/icons-material'; import { Button, IconButton } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { TIMEOUTS } from '../../constants/appConstants'; +import { TIMEOUTS } from '@northern.tech/store/constants'; const buttonStyle = { float: 'right', margin: '-20px 0 0 10px' }; diff --git a/frontend/src/js/components/common/copy-code.test.js b/frontend/src/js/components/common/copy-code.test.js index 5ccef5a6..4adc96c7 100644 --- a/frontend/src/js/components/common/copy-code.test.js +++ b/frontend/src/js/components/common/copy-code.test.js @@ -13,12 +13,12 @@ // limitations under the License. import React from 'react'; +import { yes } from '@northern.tech/store/constants'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { yes } from '../../constants/appConstants'; import CopyCode from './copy-code'; describe('CopyCode Component', () => { diff --git a/frontend/src/js/components/common/detailstable.js b/frontend/src/js/components/common/detailstable.js index 9d21f495..40557675 100644 --- a/frontend/src/js/components/common/detailstable.js +++ b/frontend/src/js/components/common/detailstable.js @@ -18,7 +18,7 @@ import { Sort as SortIcon } from '@mui/icons-material'; import { Checkbox, Table, TableBody, TableCell, TableHead, TableRow } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { SORTING_OPTIONS } from '../../constants/appConstants'; +import { SORTING_OPTIONS } from '@northern.tech/store/constants'; const useStyles = makeStyles()(() => ({ header: { diff --git a/frontend/src/js/components/common/deviceidentity.js b/frontend/src/js/components/common/deviceidentity.js index 00e2ce3a..769d3805 100644 --- a/frontend/src/js/components/common/deviceidentity.js +++ b/frontend/src/js/components/common/deviceidentity.js @@ -16,10 +16,11 @@ import { useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; +import { getDeviceById as getDeviceByIdSelector, getIdAttribute } from '@northern.tech/store/selectors'; + import GatewayConnectionIcon from '../../../assets/img/gateway-connection.svg'; import GatewayIcon from '../../../assets/img/gateway.svg'; import { stringToBoolean } from '../../helpers'; -import { getDeviceById as getDeviceByIdSelector, getIdAttribute } from '../../selectors'; import { getDeviceIdentityText } from '../devices/base-devices'; import DeviceNameInput from './devicenameinput'; diff --git a/frontend/src/js/components/common/deviceidentity.test.js b/frontend/src/js/components/common/deviceidentity.test.js index 4449657a..97e24105 100644 --- a/frontend/src/js/components/common/deviceidentity.test.js +++ b/frontend/src/js/components/common/deviceidentity.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { ATTRIBUTE_SCOPES } from '@northern.tech/store/constants'; + import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { ATTRIBUTE_SCOPES } from '../../constants/deviceConstants'; import DeviceIdentityDisplay from './deviceidentity'; describe('DeviceIdentityDisplay Component', () => { diff --git a/frontend/src/js/components/common/devicenameinput.js b/frontend/src/js/components/common/devicenameinput.js index 3bb6e87a..a79e768a 100644 --- a/frontend/src/js/components/common/devicenameinput.js +++ b/frontend/src/js/components/common/devicenameinput.js @@ -18,7 +18,8 @@ import { useDispatch } from 'react-redux'; import { Input, InputAdornment } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { setDeviceTags } from '../../actions/deviceActions'; +import { setDeviceTags } from '@northern.tech/store/thunks'; + import { ConfirmationButtons, EditButton } from './confirm'; const useStyles = makeStyles()(theme => ({ @@ -55,7 +56,7 @@ export const DeviceNameInput = ({ device, isHovered }) => { inputRef.current.focus(); }, [isEditing]); - const onSubmit = () => dispatch(setDeviceTags(id, { ...tags, name: value })).then(() => setIsEditing(false)); + const onSubmit = () => dispatch(setDeviceTags({ deviceId: id, tags: { ...tags, name: value } })).then(() => setIsEditing(false)); const onCancel = () => { setValue(name); diff --git a/frontend/src/js/components/common/devicenameinput.test.js b/frontend/src/js/components/common/devicenameinput.test.js index 3ea061d7..65ae33f2 100644 --- a/frontend/src/js/components/common/devicenameinput.test.js +++ b/frontend/src/js/components/common/devicenameinput.test.js @@ -18,8 +18,6 @@ import userEvent from '@testing-library/user-event'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import * as AppActions from '../../actions/appActions'; -import * as DeviceActions from '../../actions/deviceActions'; import DeviceNameInput from './devicenameinput'; describe('DeviceNameInput Component', () => { @@ -32,17 +30,15 @@ describe('DeviceNameInput Component', () => { it('works as intended', async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - const deviceTagsSpy = jest.spyOn(DeviceActions, 'setDeviceTags'); - const snackbarSpy = jest.spyOn(AppActions, 'setSnackbar'); const ui = ; - const { rerender } = render(ui); + const { rerender, store } = render(ui); expect(screen.queryByDisplayValue(/testname/i)).toBeInTheDocument(); await user.click(screen.getByRole('button')); await waitFor(() => rerender(ui)); await user.type(screen.getByDisplayValue(/testname/i), 'something'); await user.click(screen.getAllByRole('button')[0]); await act(async () => jest.runAllTicks()); - await waitFor(() => expect(snackbarSpy).toHaveBeenCalledWith('Device name changed')); - expect(deviceTagsSpy).toHaveBeenCalledWith(defaultState.devices.byId.a1.id, { name: 'testnamesomething' }); + await waitFor(() => expect(store.getState().app.snackbar.message).toBe('Device name changed')); + await waitFor(() => expect(store.getState().devices.byId.a1.tags).toStrictEqual({ name: 'testnamesomething' })); }); }); diff --git a/frontend/src/js/components/common/dialogs/confirmdismisshelptips.js b/frontend/src/js/components/common/dialogs/confirmdismisshelptips.js index 4b5dee8f..dccab19c 100644 --- a/frontend/src/js/components/common/dialogs/confirmdismisshelptips.js +++ b/frontend/src/js/components/common/dialogs/confirmdismisshelptips.js @@ -16,7 +16,10 @@ import { useDispatch } from 'react-redux'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; -import { setOnboardingCanceled, setShowDismissOnboardingTipsDialog } from '../../../actions/onboardingActions'; +import storeActions from '@northern.tech/store/actions'; +import { setOnboardingCanceled } from '@northern.tech/store/thunks'; + +const { setShowDismissOnboardingTipsDialog } = storeActions; export const ConfirmDismissHelptips = () => { const dispatch = useDispatch(); diff --git a/frontend/src/js/components/common/dialogs/deviceconnectiondialog.js b/frontend/src/js/components/common/dialogs/deviceconnectiondialog.js index ad59974d..2a02d267 100644 --- a/frontend/src/js/components/common/dialogs/deviceconnectiondialog.js +++ b/frontend/src/js/components/common/dialogs/deviceconnectiondialog.js @@ -18,20 +18,17 @@ import { useNavigate } from 'react-router-dom'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { DEVICE_STATES, TIMEOUTS, onboardingSteps } from '@northern.tech/store/constants'; +import { getDeviceCountsByStatus, getOnboardingState, getTenantCapabilities } from '@northern.tech/store/selectors'; +import { advanceOnboarding, saveUserSettings, setDeviceListState } from '@northern.tech/store/thunks'; + import docker from '../../../../assets/img/docker.png'; import raspberryPi4 from '../../../../assets/img/raspberrypi4.png'; import raspberryPi from '../../../../assets/img/raspberrypi.png'; -import { setDeviceListState } from '../../../actions/deviceActions'; -import { advanceOnboarding } from '../../../actions/onboardingActions'; -import { saveUserSettings } from '../../../actions/userActions'; -import { TIMEOUTS } from '../../../constants/appConstants'; -import { DEVICE_STATES } from '../../../constants/deviceConstants'; -import { onboardingSteps } from '../../../constants/onboardingConstants'; -import { getDeviceCountsByStatus, getOnboardingState, getTenantCapabilities } from '../../../selectors'; import InfoText from '../../common/infotext'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; import DocsLink from '../docslink'; -import Loader from '../loader.js'; +import Loader from '../loader'; import PhysicalDeviceOnboarding from './physicaldeviceonboarding'; import VirtualDeviceOnboarding from './virtualdeviceonboarding'; diff --git a/frontend/src/js/components/common/dialogs/physicaldeviceonboarding.js b/frontend/src/js/components/common/dialogs/physicaldeviceonboarding.js index 1edab511..4245bda4 100644 --- a/frontend/src/js/components/common/dialogs/physicaldeviceonboarding.js +++ b/frontend/src/js/components/common/dialogs/physicaldeviceonboarding.js @@ -19,10 +19,7 @@ import { InfoOutlined as InfoIcon } from '@mui/icons-material'; import { Autocomplete, TextField } from '@mui/material'; import { createFilterOptions } from '@mui/material/useAutocomplete'; -import { advanceOnboarding, setOnboardingApproach, setOnboardingDeviceType } from '../../../actions/onboardingActions'; -import { EXTERNAL_PROVIDER } from '../../../constants/deviceConstants'; -import { onboardingSteps } from '../../../constants/onboardingConstants'; -import { getDebConfigurationCode, versionCompare } from '../../../helpers'; +import { EXTERNAL_PROVIDER, onboardingSteps } from '@northern.tech/store/constants'; import { getCurrentSession, getFeatures, @@ -32,7 +29,10 @@ import { getOnboardingState, getOrganization, getTenantCapabilities -} from '../../../selectors'; +} from '@northern.tech/store/selectors'; +import { advanceOnboarding, setOnboardingApproach, setOnboardingDeviceType } from '@northern.tech/store/thunks'; + +import { getDebConfigurationCode, versionCompare } from '../../../helpers'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; import CopyCode from '../copy-code'; import DocsLink from '../docslink'; diff --git a/frontend/src/js/components/common/dialogs/physicaldeviceonboarding.test.js b/frontend/src/js/components/common/dialogs/physicaldeviceonboarding.test.js index 69b1770e..b399aff6 100644 --- a/frontend/src/js/components/common/dialogs/physicaldeviceonboarding.test.js +++ b/frontend/src/js/components/common/dialogs/physicaldeviceonboarding.test.js @@ -13,11 +13,11 @@ // limitations under the License. import React from 'react'; +import { EXTERNAL_PROVIDER } from '@northern.tech/store/constants'; import { act, waitFor } from '@testing-library/react'; import { token, undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import { EXTERNAL_PROVIDER } from '../../../constants/deviceConstants'; import PhysicalDeviceOnboarding, { ConvertedImageNote, DeviceTypeSelectionStep, ExternalProviderTip, InstallationStep } from './physicaldeviceonboarding'; const oldHostname = window.location.hostname; diff --git a/frontend/src/js/components/common/dialogs/startupnotification.js b/frontend/src/js/components/common/dialogs/startupnotification.js index bcf269f8..29aca229 100644 --- a/frontend/src/js/components/common/dialogs/startupnotification.js +++ b/frontend/src/js/components/common/dialogs/startupnotification.js @@ -16,15 +16,17 @@ import { useDispatch, useSelector } from 'react-redux'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Divider } from '@mui/material'; +import storeActions from '@northern.tech/store/actions'; +import { DEVICE_ONLINE_CUTOFF, TIMEOUTS } from '@northern.tech/store/constants'; +import { getIsDarkMode } from '@northern.tech/store/selectors'; +import { saveGlobalSettings } from '@northern.tech/store/thunks'; + import logo from '../../../../assets/img/headerlogo.png'; import whiteLogo from '../../../../assets/img/whiteheaderlogo.png'; -import { saveGlobalSettings, setShowStartupNotification } from '../../../actions/userActions'; -import { TIMEOUTS } from '../../../constants/appConstants'; -import { DEVICE_ONLINE_CUTOFF } from '../../../constants/deviceConstants'; -import { isDarkMode } from '../../../helpers'; -import { getUserSettings } from '../../../selectors'; import { useDebounce } from '../../../utils/debouncehook'; +const { setShowStartupNotification } = storeActions; + const OfflineThresholdContent = () => ( <> In our continuous efforts to enhance performance and to ensure the stability of our service, we have made adjustments to how granular device connectivity @@ -66,7 +68,7 @@ const notifications = { export const StartupNotificationDialog = () => { const [isAllowedToClose] = useState(false); const dispatch = useDispatch(); - const { mode } = useSelector(getUserSettings); + const isDarkMode = useSelector(getIsDarkMode); const { action, Content } = notifications.offlineThreshold; @@ -76,7 +78,7 @@ export const StartupNotificationDialog = () => { action({ dispatch }); dispatch(setShowStartupNotification(false)); }; - const headerLogo = isDarkMode(mode) ? whiteLogo : logo; + const headerLogo = isDarkMode ? whiteLogo : logo; return ( diff --git a/frontend/src/js/components/common/dialogs/virtualdeviceonboarding.js b/frontend/src/js/components/common/dialogs/virtualdeviceonboarding.js index af4b8784..cfcb0cce 100644 --- a/frontend/src/js/components/common/dialogs/virtualdeviceonboarding.js +++ b/frontend/src/js/components/common/dialogs/virtualdeviceonboarding.js @@ -14,27 +14,28 @@ import React, { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { setOnboardingApproach } from '../../../actions/onboardingActions'; -import { initialState as onboardingReducerState } from '../../../reducers/onboardingReducer'; -import { getFeatures, getOrganization } from '../../../selectors'; +import { getFeatures, getOrganization } from '@northern.tech/store/selectors'; +import { setOnboardingApproach } from '@northern.tech/store/thunks'; + import CopyCode from '../copy-code'; import DocsLink from '../docslink'; -export const getDemoDeviceCreationCommand = tenantToken => +export const getDemoDeviceCreationCommand = (tenantToken, demoArtifactPort) => tenantToken - ? `TENANT_TOKEN='${tenantToken}'\ndocker run -it -p ${onboardingReducerState.demoArtifactPort}:${onboardingReducerState.demoArtifactPort} -e SERVER_URL='https://${window.location.hostname}' \\\n-e TENANT_TOKEN=$TENANT_TOKEN --pull=always mendersoftware/mender-client-qemu` + ? `TENANT_TOKEN='${tenantToken}'\ndocker run -it -p ${demoArtifactPort}:${demoArtifactPort} -e SERVER_URL='https://${window.location.hostname}' \\\n-e TENANT_TOKEN=$TENANT_TOKEN --pull=always mendersoftware/mender-client-qemu` : './demo --client up'; export const VirtualDeviceOnboarding = () => { const dispatch = useDispatch(); const { isHosted } = useSelector(getFeatures); const { tenant_token: tenantToken } = useSelector(getOrganization); + const demoArtifactPort = useSelector(state => state.onboarding.demoArtifactPort); useEffect(() => { dispatch(setOnboardingApproach('virtual')); }, [dispatch]); - const codeToCopy = getDemoDeviceCreationCommand(tenantToken); + const codeToCopy = getDemoDeviceCreationCommand(tenantToken, demoArtifactPort); return (
diff --git a/frontend/src/js/components/common/dialogs/virtualdeviceonboarding.test.js b/frontend/src/js/components/common/dialogs/virtualdeviceonboarding.test.js index 56738235..6143dd77 100644 --- a/frontend/src/js/components/common/dialogs/virtualdeviceonboarding.test.js +++ b/frontend/src/js/components/common/dialogs/virtualdeviceonboarding.test.js @@ -34,13 +34,13 @@ describe('getDemoDeviceCreationCommand function', () => { it('should not contain any template string leftovers', async () => { let code = getDemoDeviceCreationCommand(); expect(code).not.toMatch(/\$\{([^}]+)\}/); - code = getDemoDeviceCreationCommand(token); + code = getDemoDeviceCreationCommand(token, 85); expect(code).not.toMatch(/\$\{([^}]+)\}/); }); it('should return a sane result', async () => { let code = getDemoDeviceCreationCommand(); expect(code).toMatch('./demo --client up'); - code = getDemoDeviceCreationCommand(token); + code = getDemoDeviceCreationCommand(token, 85); expect(code).toMatch( `TENANT_TOKEN='${token}'\ndocker run -it -p 85:85 -e SERVER_URL='https://localhost' \\\n-e TENANT_TOKEN=$TENANT_TOKEN --pull=always mendersoftware/mender-client-qemu` ); diff --git a/frontend/src/js/components/common/docslink.js b/frontend/src/js/components/common/docslink.js index d20bf833..61e88ece 100644 --- a/frontend/src/js/components/common/docslink.js +++ b/frontend/src/js/components/common/docslink.js @@ -18,8 +18,9 @@ import { Description as DescriptionIcon } from '@mui/icons-material'; import { Chip, Collapse, chipClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { getDocsVersion, getFeatures } from '../../selectors'; +import { TIMEOUTS } from '@northern.tech/store/constants'; +import { getDocsVersion, getFeatures } from '@northern.tech/store/selectors'; + import { useDebounce } from '../../utils/debouncehook'; import { MenderTooltipClickable } from './mendertooltip'; diff --git a/frontend/src/js/components/common/editablelongtext.js b/frontend/src/js/components/common/editablelongtext.js new file mode 100644 index 00000000..40e83d32 --- /dev/null +++ b/frontend/src/js/components/common/editablelongtext.js @@ -0,0 +1,94 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import React, { useCallback, useEffect, useState } from 'react'; + +// material ui +import { TextField } from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; + +import { toggle } from '../../helpers'; +import { ConfirmationButtons, EditButton } from '../common/confirm'; +import ExpandableAttribute from '../common/expandable-attribute'; + +const useStyles = makeStyles()(theme => ({ + notes: { display: 'block', whiteSpace: 'pre-wrap' }, + notesWrapper: { minWidth: theme.components?.MuiFormControl?.styleOverrides?.root?.minWidth } +})); + +export const EditableLongText = ({ contentFallback = '', fullWidth, original, onChange, placeholder = '-' }) => { + const [isEditing, setIsEditing] = useState(false); + const [value, setValue] = useState(original); + const { classes } = useStyles(); + + useEffect(() => { + setValue(original); + }, [original]); + + const onCancelClick = () => { + setValue(original); + setIsEditing(false); + }; + + const onEdit = ({ target: { value } }) => setValue(value); + + const onEditClick = () => setIsEditing(true); + + const onToggleEditing = useCallback( + event => { + event.stopPropagation(); + if (event.key && (event.key !== 'Enter' || event.shiftKey)) { + return; + } + if (isEditing) { + // save change + onChange(value); + } + setIsEditing(toggle); + }, + [isEditing, onChange, value] + ); + + const fullWidthClass = fullWidth ? 'full-width' : ''; + + return ( +
+ {isEditing ? ( + <> + + + + ) : ( + <> + + + + )} +
+ ); +}; diff --git a/frontend/src/js/components/common/enterpriseNotification.js b/frontend/src/js/components/common/enterpriseNotification.js index 885788a8..a8595fb5 100644 --- a/frontend/src/js/components/common/enterpriseNotification.js +++ b/frontend/src/js/components/common/enterpriseNotification.js @@ -18,8 +18,9 @@ import { Link } from 'react-router-dom'; import { Chip } from '@mui/material'; import { withStyles } from 'tss-react/mui'; -import { ADDONS, BENEFITS, PLANS } from '../../constants/appConstants'; -import { getTenantCapabilities } from '../../selectors'; +import { ADDONS, BENEFITS, PLANS } from '@northern.tech/store/constants'; +import { getTenantCapabilities } from '@northern.tech/store/selectors'; + import MenderTooltip, { MenderTooltipClickable } from './mendertooltip'; const PlansTooltip = withStyles(MenderTooltip, ({ palette }) => ({ diff --git a/frontend/src/js/components/common/forms/fileupload.js b/frontend/src/js/components/common/forms/fileupload.js index da109492..9bc0603e 100644 --- a/frontend/src/js/components/common/forms/fileupload.js +++ b/frontend/src/js/components/common/forms/fileupload.js @@ -19,7 +19,9 @@ import { useDispatch } from 'react-redux'; import { Clear as ClearIcon, CloudUploadOutlined as FileIcon } from '@mui/icons-material'; import { IconButton, TextField } from '@mui/material'; -import { setSnackbar } from '../../../actions/appActions'; +import storeActions from '@northern.tech/store/actions'; + +const { setSnackbar } = storeActions; export const FileUpload = ({ enableContentReading = true, fileNameSelection, onFileChange, onFileSelect = () => undefined, placeholder, style = {} }) => { const [filename, setFilename] = useState(fileNameSelection); diff --git a/frontend/src/js/components/common/forms/filters.js b/frontend/src/js/components/common/forms/filters.js index d571f69c..05988860 100644 --- a/frontend/src/js/components/common/forms/filters.js +++ b/frontend/src/js/components/common/forms/filters.js @@ -16,7 +16,8 @@ import { FormProvider, useForm } from 'react-hook-form'; import { makeStyles } from 'tss-react/mui'; -import { TIMEOUTS } from '../../../constants/appConstants'; +import { TIMEOUTS } from '@northern.tech/store/constants'; + import { useDebounce } from '../../../utils/debouncehook'; const useStyles = makeStyles()(theme => ({ diff --git a/frontend/src/js/components/common/forms/passwordinput.js b/frontend/src/js/components/common/forms/passwordinput.js index 08f99603..d598e39b 100644 --- a/frontend/src/js/components/common/forms/passwordinput.js +++ b/frontend/src/js/components/common/forms/passwordinput.js @@ -17,10 +17,10 @@ import { Controller, useFormContext, useWatch } from 'react-hook-form'; import { CheckCircle as CheckIcon, Visibility as VisibilityIcon, VisibilityOff as VisibilityOffIcon } from '@mui/icons-material'; import { Button, FormControl, FormHelperText, IconButton, Input, InputAdornment, InputLabel } from '@mui/material'; +import { TIMEOUTS } from '@northern.tech/store/constants'; import copy from 'copy-to-clipboard'; import generator from 'generate-password'; -import { TIMEOUTS } from '../../../constants/appConstants'; import { toggle } from '../../../helpers'; import { runValidations } from './form'; diff --git a/frontend/src/js/components/common/left-nav.js b/frontend/src/js/components/common/left-nav.js index 25eadf7a..76d0b11f 100644 --- a/frontend/src/js/components/common/left-nav.js +++ b/frontend/src/js/components/common/left-nav.js @@ -19,7 +19,7 @@ import { List, ListItem, ListItemIcon, ListItemText, ListSubheader } from '@mui/ import { listItemTextClasses } from '@mui/material/ListItemText'; import { makeStyles } from 'tss-react/mui'; -import { isDarkMode } from '../../helpers.js'; +import { isDarkMode } from '@northern.tech/store/utils'; const useStyles = makeStyles()(theme => ({ list: { diff --git a/frontend/src/js/components/common/mendertooltip.js b/frontend/src/js/components/common/mendertooltip.js index 0c3fee1e..ace33ff1 100644 --- a/frontend/src/js/components/common/mendertooltip.js +++ b/frontend/src/js/components/common/mendertooltip.js @@ -17,8 +17,8 @@ import { Help as HelpIcon } from '@mui/icons-material'; import { ClickAwayListener, Tooltip } from '@mui/material'; import { makeStyles, withStyles } from 'tss-react/mui'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { READ_STATES } from '../../constants/userConstants'; +import { READ_STATES, TIMEOUTS } from '@northern.tech/store/constants'; + import { toggle } from '../../helpers'; import { useDebounce } from '../../utils/debouncehook'; @@ -177,7 +177,7 @@ export const HelpTooltip = ({ icon = undefined, id, contentProps = {}, tooltip, if (!debouncedIsOpen) { return; } - setTooltipReadState(id, READ_STATES.read, true); + setTooltipReadState({ id, persist: true, readState: READ_STATES.read }); }, [debouncedIsOpen, id, setTooltipReadState]); const onReadAllClick = () => setAllTooltipsReadState(READ_STATES.read); diff --git a/frontend/src/js/components/common/pagination.js b/frontend/src/js/components/common/pagination.js index 1f9756b8..02aa8656 100644 --- a/frontend/src/js/components/common/pagination.js +++ b/frontend/src/js/components/common/pagination.js @@ -16,8 +16,8 @@ import React, { useEffect, useState } from 'react'; import { KeyboardArrowLeft, KeyboardArrowRight } from '@mui/icons-material'; import { IconButton, TablePagination } from '@mui/material'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { DEVICE_LIST_DEFAULTS, DEVICE_LIST_MAXIMUM_LENGTH } from '../../constants/deviceConstants'; +import { DEVICE_LIST_DEFAULTS, DEVICE_LIST_MAXIMUM_LENGTH, TIMEOUTS } from '@northern.tech/store/constants'; + import { useDebounce } from '../../utils/debouncehook'; import MenderTooltip from '../common/mendertooltip'; diff --git a/frontend/src/js/components/common/search.js b/frontend/src/js/components/common/search.js index ab795530..592d15b6 100644 --- a/frontend/src/js/components/common/search.js +++ b/frontend/src/js/components/common/search.js @@ -18,7 +18,8 @@ import { Search as SearchIcon } from '@mui/icons-material'; import { InputAdornment, TextField } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { TIMEOUTS } from '../../constants/appConstants'; +import { TIMEOUTS } from '@northern.tech/store/constants'; + import { useDebounce } from '../../utils/debouncehook'; import Loader from './loader'; diff --git a/frontend/src/js/components/common/sharedsnackbar.test.js b/frontend/src/js/components/common/sharedsnackbar.test.js index f7174176..c264dfc1 100644 --- a/frontend/src/js/components/common/sharedsnackbar.test.js +++ b/frontend/src/js/components/common/sharedsnackbar.test.js @@ -13,12 +13,12 @@ // limitations under the License. import React from 'react'; +import { yes } from '@northern.tech/store/constants'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { yes } from '../../constants/appConstants'; import SharedSnackbar from './sharedsnackbar'; describe('SharedSnackbar Component', () => { diff --git a/frontend/src/js/components/dashboard/dashboard.js b/frontend/src/js/components/dashboard/dashboard.js index 7c483079..947fab93 100644 --- a/frontend/src/js/components/dashboard/dashboard.js +++ b/frontend/src/js/components/dashboard/dashboard.js @@ -17,17 +17,18 @@ import { useNavigate } from 'react-router-dom'; import { makeStyles } from 'tss-react/mui'; -import { setSnackbar } from '../../actions/appActions'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { DEPLOYMENT_ROUTES } from '../../constants/deploymentConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; -import { getCurrentUser, getOnboardingState } from '../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { DEPLOYMENT_ROUTES, TIMEOUTS, onboardingSteps } from '@northern.tech/store/constants'; +import { getCurrentUser, getOnboardingState } from '@northern.tech/store/selectors'; + import { getOnboardingComponentFor } from '../../utils/onboardingmanager'; import Loader from '../common/loader'; import Deployments from './deployments'; import Devices from './devices'; import SoftwareDistribution from './software-distribution'; +const { setSnackbar } = storeActions; + const useStyles = makeStyles()(theme => ({ board: { columnGap: theme.spacing(6), diff --git a/frontend/src/js/components/dashboard/dashboard.test.js b/frontend/src/js/components/dashboard/dashboard.test.js index f64141b7..f456f03b 100644 --- a/frontend/src/js/components/dashboard/dashboard.test.js +++ b/frontend/src/js/components/dashboard/dashboard.test.js @@ -14,13 +14,13 @@ import React from 'react'; import { Route, Routes } from 'react-router-dom'; +import { actions as deviceActions } from '@northern.tech/store/devicesSlice'; +import * as DeviceActions from '@northern.tech/store/devicesSlice/thunks'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import * as DeviceActions from '../../actions/deviceActions'; -import { SET_ACCEPTED_DEVICES_COUNT } from '../../constants/deviceConstants'; import Dashboard from './dashboard'; const reportsSpy = jest.spyOn(DeviceActions, 'getReportsDataWithoutBackendSupport'); @@ -77,7 +77,7 @@ describe('Dashboard Component', () => { const { rerender, store } = render(ui, { preloadedState }); await waitFor(() => expect(reportsSpy).toHaveBeenCalled()); await waitFor(() => rerender(ui)); - await act(() => store.dispatch({ type: SET_ACCEPTED_DEVICES_COUNT, status: 'accepted', count: 0 })); + await act(() => store.dispatch({ type: deviceActions.setDevicesCountByStatus.type, payload: { status: 'accepted', count: 0 } })); await user.click(screen.getByText(/pending devices/i)); await waitFor(() => screen.queryByText(/pendings route/i)); expect(screen.getByText(/pendings route/i)).toBeVisible(); diff --git a/frontend/src/js/components/dashboard/deployments.js b/frontend/src/js/components/dashboard/deployments.js index 7de8b217..d7e8e79b 100644 --- a/frontend/src/js/components/dashboard/deployments.js +++ b/frontend/src/js/components/dashboard/deployments.js @@ -15,11 +15,18 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; -import { setSnackbar } from '../../actions/appActions'; -import { getDeploymentsByStatus } from '../../actions/deploymentActions'; -import { DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, deploymentDisplayStates } from '../../constants/deploymentConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; -import { DEPLOYMENT_CUTOFF, getDevicesById, getIdAttribute, getOnboardingState, getRecentDeployments, getUserCapabilities } from '../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, deploymentDisplayStates, onboardingSteps } from '@northern.tech/store/constants'; +import { + DEPLOYMENT_CUTOFF, + getDevicesById, + getIdAttribute, + getOnboardingState, + getRecentDeployments, + getUserCapabilities +} from '@northern.tech/store/selectors'; +import { getDeploymentsByStatus } from '@northern.tech/store/thunks'; + import { getOnboardingComponentFor } from '../../utils/onboardingmanager'; import useWindowSize from '../../utils/resizehook'; import { clearAllRetryTimers, setRetryTimer } from '../../utils/retrytimer'; @@ -27,6 +34,8 @@ import Loader from '../common/loader'; import { BaseDeploymentsWidget, CompletedDeployments } from './widgets/deployments'; import RedirectionWidget from './widgets/redirectionwidget'; +const { setSnackbar } = storeActions; + const refreshDeploymentsLength = 30000; // we need to exclude the scheduled state here as the os version is not able to process these and would prevent the dashboard from loading @@ -52,7 +61,7 @@ export const Deployments = ({ className = '', clickHandle }) => { const getDeployments = useCallback( () => - Promise.all(Object.keys(stateMap).map(status => dispatch(getDeploymentsByStatus(status, 1, DEPLOYMENT_CUTOFF)))) + Promise.all(Object.keys(stateMap).map(status => dispatch(getDeploymentsByStatus({ status, page: 1, perPage: DEPLOYMENT_CUTOFF })))) .catch(err => setRetryTimer(err, 'deployments', `Couldn't load deployments.`, refreshDeploymentsLength, setSnackbarDispatched)) .finally(() => setLoading(false)), [dispatch, setSnackbarDispatched] diff --git a/frontend/src/js/components/dashboard/devices.js b/frontend/src/js/components/dashboard/devices.js index cde17ed9..b45bc837 100644 --- a/frontend/src/js/components/dashboard/devices.js +++ b/frontend/src/js/components/dashboard/devices.js @@ -15,12 +15,8 @@ import React, { useCallback, useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; -import { getDeviceCount } from '../../actions/deviceActions'; -import { getIssueCountsByType } from '../../actions/monitorActions'; -import { advanceOnboarding } from '../../actions/onboardingActions'; -import { setShowConnectingDialog } from '../../actions/userActions'; -import { DEVICE_STATES } from '../../constants/deviceConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; +import storeActions from '@northern.tech/store/actions'; +import { DEVICE_STATES, onboardingSteps } from '@northern.tech/store/constants'; import { getAcceptedDevices, getAvailableIssueOptionsByType, @@ -28,7 +24,9 @@ import { getOnboardingState, getTenantCapabilities, getUserCapabilities -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { advanceOnboarding, getDeviceCount, getIssueCountsByType } from '@northern.tech/store/thunks'; + import { getOnboardingComponentFor } from '../../utils/onboardingmanager'; import useWindowSize from '../../utils/resizehook'; import AcceptedDevices from './widgets/accepteddevices'; @@ -36,6 +34,8 @@ import ActionableDevices from './widgets/actionabledevices'; import PendingDevices from './widgets/pendingdevices'; import RedirectionWidget from './widgets/redirectionwidget'; +const { setShowConnectingDialog } = storeActions; + export const Devices = ({ clickHandle }) => { // eslint-disable-next-line no-unused-vars const size = useWindowSize(); @@ -51,7 +51,9 @@ export const Devices = ({ clickHandle }) => { const { pending: pendingDevicesCount } = useSelector(getDeviceCountsByStatus); const refreshDevices = useCallback(() => { - const issueRequests = Object.keys(availableIssueOptions).map(key => dispatch(getIssueCountsByType(key, { filters: [], selectedIssues: [key] }))); + const issueRequests = Object.keys(availableIssueOptions).map(key => + dispatch(getIssueCountsByType({ type: key, options: { filters: [], selectedIssues: [key] } })) + ); return Promise.all([dispatch(getDeviceCount(DEVICE_STATES.accepted)), dispatch(getDeviceCount(DEVICE_STATES.pending)), ...issueRequests]); // eslint-disable-next-line react-hooks/exhaustive-deps }, [JSON.stringify(availableIssueOptions), dispatch]); diff --git a/frontend/src/js/components/dashboard/software-distribution.js b/frontend/src/js/components/dashboard/software-distribution.js index 609c11b4..5803c53b 100644 --- a/frontend/src/js/components/dashboard/software-distribution.js +++ b/frontend/src/js/components/dashboard/software-distribution.js @@ -16,18 +16,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { BarChart as BarChartIcon } from '@mui/icons-material'; -import { - defaultReportType, - defaultReports, - getDeviceAttributes, - getGroupDevices, - getReportingLimits, - getReportsData, - getReportsDataWithoutBackendSupport -} from '../../actions/deviceActions'; -import { saveUserSettings } from '../../actions/userActions'; -import { rootfsImageVersion, softwareTitleMap } from '../../constants/releaseConstants'; -import { isEmpty } from '../../helpers'; +import { defaultReportType, defaultReports, rootfsImageVersion, softwareTitleMap } from '@northern.tech/store/constants'; import { getAcceptedDevices, getAttributesList, @@ -36,7 +25,17 @@ import { getFeatures, getGroupsByIdWithoutUngrouped, getIsEnterprise -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { + getDeviceAttributes, + getGroupDevices, + getReportingLimits, + getReportsData, + getReportsDataWithoutBackendSupport, + saveUserSettings +} from '@northern.tech/store/thunks'; + +import { isEmpty } from '../../helpers'; import { extractSoftwareInformation } from '../devices/device-details/installedsoftware'; import ChartAdditionWidget from './widgets/chart-addition'; import DistributionReport from './widgets/distribution'; diff --git a/frontend/src/js/components/dashboard/software-distribution.test.js b/frontend/src/js/components/dashboard/software-distribution.test.js index 00dc18dd..3bff6d0b 100644 --- a/frontend/src/js/components/dashboard/software-distribution.test.js +++ b/frontend/src/js/components/dashboard/software-distribution.test.js @@ -13,13 +13,12 @@ // limitations under the License. import React from 'react'; +import { TIMEOUTS, chartTypes, rootfsImageVersion } from '@northern.tech/store/constants'; +import * as DeviceActions from '@northern.tech/store/devicesSlice/thunks'; import { act, waitFor } from '@testing-library/react'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import * as DeviceActions from '../../actions/deviceActions'; -import { TIMEOUTS, chartTypes } from '../../constants/appConstants'; -import { rootfsImageVersion } from '../../constants/releaseConstants'; import SoftwareDistribution from './software-distribution'; const preloadedState = { diff --git a/frontend/src/js/components/dashboard/widgets/actionabledevices.js b/frontend/src/js/components/dashboard/widgets/actionabledevices.js index 29643ace..9a1d7c5d 100644 --- a/frontend/src/js/components/dashboard/widgets/actionabledevices.js +++ b/frontend/src/js/components/dashboard/widgets/actionabledevices.js @@ -18,7 +18,8 @@ import { Link } from 'react-router-dom'; import { CancelOutlined as FailureIcon, VpnKeyOutlined as KeyIcon, WifiOff as OfflineIcon, WarningAmber as WarningIcon } from '@mui/icons-material'; import { makeStyles } from 'tss-react/mui'; -import { DEVICE_ISSUE_OPTIONS } from '../../../constants/deviceConstants'; +import { DEVICE_ISSUE_OPTIONS } from '@northern.tech/store/constants'; + import { BaseWidget } from './baseWidget'; const issueTypes = [ diff --git a/frontend/src/js/components/dashboard/widgets/chart-addition.js b/frontend/src/js/components/dashboard/widgets/chart-addition.js index 9bdd6920..fbb2c958 100644 --- a/frontend/src/js/components/dashboard/widgets/chart-addition.js +++ b/frontend/src/js/components/dashboard/widgets/chart-addition.js @@ -14,16 +14,16 @@ import React, { useCallback, useState } from 'react'; import { Add as AddIcon } from '@mui/icons-material'; -import { Button, FormControl, IconButton, InputLabel, ListSubheader, MenuItem, Select, iconButtonClasses, selectClasses } from '@mui/material'; +import { Button, FormControl, IconButton, InputLabel, ListSubheader, MenuItem, Select, iconButtonClasses, selectClasses, svgIconClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { BENEFITS, chartTypes, emptyChartSelection } from '../../../constants/appConstants'; +import { BENEFITS, chartTypes, emptyChartSelection } from '@northern.tech/store/constants'; + import { toggle } from '../../../helpers'; import Confirm from '../../common/confirm'; import EnterpriseNotification from '../../common/enterpriseNotification'; import { InfoHintContainer } from '../../common/info-hint'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; -import { Header } from './distribution'; const fontSize = 'smaller'; @@ -31,6 +31,7 @@ const useStyles = makeStyles()(theme => ({ additionButton: { fontSize: '1rem', cursor: 'pointer' }, button: { marginLeft: theme.spacing(2), padding: '6px 8px', fontSize }, buttonWrapper: { display: 'flex', justifyContent: 'flex-end', alignContent: 'center' }, + header: { minHeight: 30, [`.${svgIconClasses.root}`]: { marginLeft: theme.spacing() } }, iconButton: { [`&.${iconButtonClasses.root}`]: { borderRadius: 5, @@ -55,6 +56,17 @@ const useStyles = makeStyles()(theme => ({ } })); +export const Header = ({ chartType }) => { + const { classes } = useStyles(); + const { Icon } = chartTypes[chartType]; + return ( +
+ Software distribution + +
+ ); +}; + const GroupSelect = ({ groups, selection, setSelection }) => ( Device group diff --git a/frontend/src/js/components/dashboard/widgets/deployments.js b/frontend/src/js/components/dashboard/widgets/deployments.js index c0be0bc3..7128ba23 100644 --- a/frontend/src/js/components/dashboard/widgets/deployments.js +++ b/frontend/src/js/components/dashboard/widgets/deployments.js @@ -15,7 +15,8 @@ import React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { DEPLOYMENT_STATES } from '../../../constants/deploymentConstants'; +import { DEPLOYMENT_STATES } from '@northern.tech/store/constants'; + import { useDeploymentDevice } from '../../../utils/deploymentdevicehook'; import Time from '../../common/time'; import { DeploymentDeviceGroup, DeploymentProgress } from '../../deployments/deploymentitem'; diff --git a/frontend/src/js/components/dashboard/widgets/distribution.js b/frontend/src/js/components/dashboard/widgets/distribution.js index 8aaa1493..d17bdcb4 100644 --- a/frontend/src/js/components/dashboard/widgets/distribution.js +++ b/frontend/src/js/components/dashboard/widgets/distribution.js @@ -15,26 +15,25 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { useNavigate } from 'react-router-dom'; import { Clear as ClearIcon, Settings, Square } from '@mui/icons-material'; -import { IconButton, LinearProgress, linearProgressClasses, svgIconClasses } from '@mui/material'; +import { IconButton, LinearProgress, linearProgressClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import storeActions from '@northern.tech/store/actions'; +import { ALL_DEVICES, TIMEOUTS, chartTypes, rootfsImageVersion, softwareTitleMap } from '@northern.tech/store/constants'; import { VictoryBar, VictoryContainer, VictoryPie, VictoryStack } from 'victory'; -import { ensureVersionString } from '../../../actions/deviceActions'; -import { TIMEOUTS, chartTypes } from '../../../constants/appConstants'; -import { ALL_DEVICES } from '../../../constants/deviceConstants'; -import { rootfsImageVersion, softwareTitleMap } from '../../../constants/releaseConstants'; import { isEmpty, toggle } from '../../../helpers'; import { chartColorPalette } from '../../../themes/Mender'; import Loader from '../../common/loader'; -import { ChartEditWidget, RemovalWidget } from './chart-addition'; +import { ChartEditWidget, Header, RemovalWidget } from './chart-addition'; + +const { ensureVersionString } = storeActions; const seriesOther = '__OTHER__'; const createColorClassName = hexColor => `color-${hexColor.slice(1)}`; const useStyles = makeStyles()(theme => ({ - header: { minHeight: 30, [`.${svgIconClasses.root}`]: { marginLeft: theme.spacing() } }, indicator: { fontSize: 10, minWidth: 'initial', marginLeft: 4 }, legendItem: { alignItems: 'center', @@ -204,17 +203,6 @@ const initDistribution = ({ data, theme }) => { return { distribution, totals }; }; -export const Header = ({ chartType }) => { - const { classes } = useStyles(); - const { Icon } = chartTypes[chartType]; - return ( -
- Software distribution - -
- ); -}; - export const DistributionReport = ({ data, getGroupDevices, groups, onClick, onSave, selection = {}, software: softwareTree }) => { const { attribute: attributeSelection, @@ -235,7 +223,7 @@ export const DistributionReport = ({ data, getGroupDevices, groups, onClick, onS setGroup(groupSelection); setChartType(chartTypeSelection); setRemoving(false); - getGroupDevices(groupSelection, { page: 1, perPage: 1 }); + getGroupDevices({ group: groupSelection, page: 1, perPage: 1 }); }, [attributeSelection, groupSelection, chartTypeSelection, softwareSelection, getGroupDevices]); const { distribution, totals } = useMemo(() => { diff --git a/frontend/src/js/components/deployments/createdeployment.js b/frontend/src/js/components/deployments/createdeployment.js index f11d6be2..09ce22fb 100644 --- a/frontend/src/js/components/deployments/createdeployment.js +++ b/frontend/src/js/components/deployments/createdeployment.js @@ -32,17 +32,7 @@ import { } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import dayjs from 'dayjs'; -import pluralize from 'pluralize'; - -import DeltaIcon from '../../../assets/img/deltaicon.svg'; -import { createDeployment, getDeploymentsConfig } from '../../actions/deploymentActions'; -import { getGroupDevices } from '../../actions/deviceActions'; -import { advanceOnboarding } from '../../actions/onboardingActions'; -import { getRelease, getReleases } from '../../actions/releaseActions'; -import { ALL_DEVICES } from '../../constants/deviceConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; -import { toggle, validatePhases } from '../../helpers'; +import { ALL_DEVICES, onboardingSteps } from '@northern.tech/store/constants'; import { getAcceptedDevices, getDeviceCountsByStatus, @@ -57,11 +47,16 @@ import { getReleaseListState, getReleasesById, getTenantCapabilities -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { advanceOnboarding, createDeployment, getDeploymentsConfig, getGroupDevices, getRelease, getReleases } from '@northern.tech/store/thunks'; +import pluralize from 'pluralize'; + +import DeltaIcon from '../../../assets/img/deltaicon.svg'; +import { toggle, validatePhases } from '../../helpers'; import { getOnboardingComponentFor } from '../../utils/onboardingmanager'; import Confirm from '../common/confirm'; import DeviceLimit from './deployment-wizard/devicelimit'; -import { RolloutPatternSelection } from './deployment-wizard/phasesettings'; +import { RolloutPatternSelection, getPhaseStartTime } from './deployment-wizard/phasesettings'; import { ForceDeploy, Retries, RolloutOptions } from './deployment-wizard/rolloutoptions'; import { ScheduleRollout } from './deployment-wizard/schedulerollout'; import { Devices, ReleasesWarning, Software } from './deployment-wizard/softwaredevices'; @@ -94,17 +89,6 @@ const getAnchor = (element, heightAdjustment = 3) => ({ left: element.offsetLeft + element.offsetWidth }); -export const getPhaseStartTime = (phases, index, startDate) => { - if (index < 1) { - return startDate?.toISOString ? startDate.toISOString() : startDate; - } - // since we don't want to get stale phase start times when the creation dialog is open for a long time - // we have to ensure start times are based on delay from previous phases - // since there likely won't be 1000s of phases this should still be fine to recalculate - const newStartTime = phases.slice(0, index).reduce((accu, phase) => dayjs(accu).add(phase.delay, phase.delayUnit), startDate); - return newStartTime.toISOString(); -}; - export const CreateDeployment = props => { const { deploymentObject = {}, onDismiss, onScheduleSubmit, setDeploymentSettings } = props; @@ -159,7 +143,15 @@ export const CreateDeployment = props => { nextDeploymentObject.deploymentDeviceCount = acceptedDeviceCount; } if (groups[group]) { - dispatch(getGroupDevices(group, { perPage: 1 })).then(({ group: { total: deploymentDeviceCount } }) => setDeploymentSettings({ deploymentDeviceCount })); + dispatch(getGroupDevices({ group, perPage: 1 })) + .unwrap() + .then( + ({ + payload: { + group: { total: deploymentDeviceCount } + } + }) => setDeploymentSettings({ deploymentDeviceCount }) + ); } setDeploymentSettings(nextDeploymentObject); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -225,7 +217,7 @@ export const CreateDeployment = props => { if (!isOnboardingComplete) { dispatch(advanceOnboarding(onboardingSteps.SCHEDULING_RELEASE_TO_DEVICES)); } - return dispatch(createDeployment(newDeployment, hasNewRetryDefault)) + return dispatch(createDeployment({ newDeployment, hasNewRetryDefault })) .then(() => { // successfully retrieved new deployment cleanUpDeploymentsStatus(); diff --git a/frontend/src/js/components/deployments/deployment-report/deploymentstatus.js b/frontend/src/js/components/deployments/deployment-report/deploymentstatus.js index 95d3c38b..d855fadc 100644 --- a/frontend/src/js/components/deployments/deployment-report/deploymentstatus.js +++ b/frontend/src/js/components/deployments/deployment-report/deploymentstatus.js @@ -16,8 +16,9 @@ import React from 'react'; import { Pause as PauseIcon, ArrowDropDownCircleOutlined as ScrollDownIcon } from '@mui/icons-material'; import { makeStyles } from 'tss-react/mui'; -import { deploymentDisplayStates, pauseMap } from '../../../constants/deploymentConstants'; -import { groupDeploymentStats } from '../../../helpers'; +import { deploymentDisplayStates, pauseMap } from '@northern.tech/store/constants'; +import { groupDeploymentStats } from '@northern.tech/store/utils'; + import { TwoColumnData } from '../../common/configurationobject'; import { defaultColumnDataProps } from '../report'; diff --git a/frontend/src/js/components/deployments/deployment-report/devicelist.js b/frontend/src/js/components/deployments/deployment-report/devicelist.js index a8c318db..69424786 100644 --- a/frontend/src/js/components/deployments/deployment-report/devicelist.js +++ b/frontend/src/js/components/deployments/deployment-report/devicelist.js @@ -18,11 +18,14 @@ import { Link } from 'react-router-dom'; import { Button, LinearProgress } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { + DEVICE_LIST_DEFAULTS, + canAccess as canShow, + deploymentSubstates, + rootfsImageVersion as rootfsImageVersionAttribute +} from '@northern.tech/store/constants'; + import DeltaIcon from '../../../../assets/img/deltaicon.svg'; -import { canAccess as canShow } from '../../../constants/appConstants'; -import { deploymentSubstates } from '../../../constants/deploymentConstants'; -import { DEVICE_LIST_DEFAULTS } from '../../../constants/deviceConstants'; -import { rootfsImageVersion as rootfsImageVersionAttribute } from '../../../constants/releaseConstants'; import { FileSize, formatTime } from '../../../helpers'; import { TwoColumns } from '../../common/configurationobject'; import DetailsTable from '../../common/detailstable'; @@ -212,7 +215,7 @@ export const DeploymentDeviceList = ({ deployment, getDeploymentDevices, idAttri return; } setIsLoading(true); - getDeploymentDevices(deployment.id, { page: currentPage, perPage }).then(() => setIsLoading(false)); + getDeploymentDevices({ id: deployment.id, page: currentPage, perPage }).then(() => setIsLoading(false)); // eslint-disable-next-line react-hooks/exhaustive-deps }, [currentPage, deployment.id, deployment.status, getDeploymentDevices, JSON.stringify(statistics.status), perPage]); diff --git a/frontend/src/js/components/deployments/deployment-report/overview.js b/frontend/src/js/components/deployments/deployment-report/overview.js index 744c374e..22dcfe8b 100644 --- a/frontend/src/js/components/deployments/deployment-report/overview.js +++ b/frontend/src/js/components/deployments/deployment-report/overview.js @@ -18,13 +18,14 @@ import { Launch as LaunchIcon, ArrowDropDownCircleOutlined as ScrollDownIcon } f import { Chip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { DEPLOYMENT_STATES, DEPLOYMENT_TYPES } from '@northern.tech/store/constants'; +import { groupDeploymentStats } from '@northern.tech/store/utils'; import pluralize from 'pluralize'; import isUUID from 'validator/lib/isUUID'; import failImage from '../../../../assets/img/largeFail.png'; import successImage from '../../../../assets/img/largeSuccess.png'; -import { DEPLOYMENT_STATES, DEPLOYMENT_TYPES } from '../../../constants/deploymentConstants'; -import { groupDeploymentStats, isEmpty } from '../../../helpers'; +import { isEmpty } from '../../../helpers'; import { TwoColumnData } from '../../common/configurationobject'; import DeviceIdentityDisplay from '../../common/deviceidentity'; import Time from '../../common/time'; diff --git a/frontend/src/js/components/deployments/deployment-report/phaseprogress.js b/frontend/src/js/components/deployments/deployment-report/phaseprogress.js index 13ac3e35..7b43dd26 100644 --- a/frontend/src/js/components/deployments/deployment-report/phaseprogress.js +++ b/frontend/src/js/components/deployments/deployment-report/phaseprogress.js @@ -17,11 +17,11 @@ import { CheckCircle, ErrorRounded, Pause, PlayArrow, Warning as WarningIcon } f import { Button } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { deploymentDisplayStates, deploymentSubstates, installationSubstatesMap, pauseMap } from '@northern.tech/store/constants'; +import { getDeploymentState, groupDeploymentStats, statCollector } from '@northern.tech/store/utils'; import pluralize from 'pluralize'; import inprogressImage from '../../../../assets/img/pending_status.png'; -import { deploymentDisplayStates, deploymentSubstates, installationSubstatesMap, pauseMap } from '../../../constants/deploymentConstants'; -import { getDeploymentState, groupDeploymentStats, statCollector } from '../../../helpers'; import Confirm from '../../common/confirm'; import { ProgressChartComponent } from '../progressChart'; diff --git a/frontend/src/js/components/deployments/deployment-report/rolloutschedule.js b/frontend/src/js/components/deployments/deployment-report/rolloutschedule.js index f154db47..946d0c6b 100644 --- a/frontend/src/js/components/deployments/deployment-report/rolloutschedule.js +++ b/frontend/src/js/components/deployments/deployment-report/rolloutschedule.js @@ -17,17 +17,17 @@ import { ArrowForward } from '@mui/icons-material'; import { Chip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { DEPLOYMENT_STATES } from '@northern.tech/store/constants'; import dayjs from 'dayjs'; import durationDayJs from 'dayjs/plugin/duration'; import isSameOrAfter from 'dayjs/plugin/isSameOrAfter'; import pluralize from 'pluralize'; -import { DEPLOYMENT_STATES } from '../../../constants/deploymentConstants'; import { formatTime, getPhaseDeviceCount, getRemainderPercent } from '../../../helpers'; import { TwoColumnData } from '../../common/configurationobject'; import LinedHeader from '../../common/lined-header'; import Time from '../../common/time'; -import { getPhaseStartTime } from '../createdeployment'; +import { getPhaseStartTime } from '../deployment-wizard/phasesettings'; import { ProgressChartComponent, getDeploymentPhasesInfo, getDisplayablePhases } from '../progressChart'; import { defaultColumnDataProps } from '../report'; import PhaseProgress from './phaseprogress'; diff --git a/frontend/src/js/components/deployments/deployment-wizard/devicelimit.js b/frontend/src/js/components/deployments/deployment-wizard/devicelimit.js index f50c8c99..3dc17619 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/devicelimit.js +++ b/frontend/src/js/components/deployments/deployment-wizard/devicelimit.js @@ -16,9 +16,9 @@ import React, { useEffect, useState } from 'react'; import { Checkbox, Collapse, FormControl, FormControlLabel, FormHelperText, Input, formControlClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { TIMEOUTS } from '@northern.tech/store/commonConstants'; import validator from 'validator'; -import { TIMEOUTS } from '../../../constants/appConstants'; import { useDebounce } from '../../../utils/debouncehook'; import { DOCSTIPS, DocsTooltip } from '../../common/docslink'; import { InfoHintContainer } from '../../common/info-hint'; diff --git a/frontend/src/js/components/deployments/deployment-wizard/phasesettings.js b/frontend/src/js/components/deployments/deployment-wizard/phasesettings.js index 5143c290..500b86e9 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/phasesettings.js +++ b/frontend/src/js/components/deployments/deployment-wizard/phasesettings.js @@ -34,15 +34,15 @@ import { } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { BENEFITS } from '@northern.tech/store/constants'; +import dayjs from 'dayjs'; import pluralize from 'pluralize'; -import { BENEFITS } from '../../../constants/appConstants'; import { getPhaseDeviceCount, getRemainderPercent } from '../../../helpers'; import { DOCSTIPS, DocsTooltip } from '../../common/docslink'; import EnterpriseNotification from '../../common/enterpriseNotification'; import { InfoHintContainer } from '../../common/info-hint'; import Time from '../../common/time'; -import { getPhaseStartTime } from '../createdeployment'; const useStyles = makeStyles()(theme => ({ chip: { marginTop: theme.spacing(2) }, @@ -55,6 +55,17 @@ const useStyles = makeStyles()(theme => ({ const timeframes = ['minutes', 'hours', 'days']; const tableHeaders = ['', 'Batch size', 'Phase begins', 'Delay before next phase', '']; +export const getPhaseStartTime = (phases, index, startDate) => { + if (index < 1) { + return startDate?.toISOString ? startDate.toISOString() : startDate; + } + // since we don't want to get stale phase start times when the creation dialog is open for a long time + // we have to ensure start times are based on delay from previous phases + // since there likely won't be 1000s of phases this should still be fine to recalculate + const newStartTime = phases.slice(0, index).reduce((accu, phase) => dayjs(accu).add(phase.delay, phase.delayUnit), startDate); + return newStartTime.toISOString(); +}; + export const PhaseSettings = ({ classNames, deploymentObject, disabled, numberDevices, setDeploymentSettings }) => { const { classes } = useStyles(); diff --git a/frontend/src/js/components/deployments/deployment-wizard/rolloutoptions.js b/frontend/src/js/components/deployments/deployment-wizard/rolloutoptions.js index ef464419..f6957cc2 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/rolloutoptions.js +++ b/frontend/src/js/components/deployments/deployment-wizard/rolloutoptions.js @@ -16,7 +16,8 @@ import React, { useEffect, useState } from 'react'; import { Autocomplete, Checkbox, Collapse, FormControl, FormControlLabel, FormGroup, TextField } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { BENEFITS, TIMEOUTS } from '../../../constants/appConstants'; +import { BENEFITS, TIMEOUTS } from '@northern.tech/store/constants'; + import { toggle } from '../../../helpers'; import { useDebounce } from '../../../utils/debouncehook'; import { DOCSTIPS, DocsTooltip } from '../../common/docslink'; diff --git a/frontend/src/js/components/deployments/deployment-wizard/rolloutsteps.js b/frontend/src/js/components/deployments/deployment-wizard/rolloutsteps.js index c158b9fb..53621abb 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/rolloutsteps.js +++ b/frontend/src/js/components/deployments/deployment-wizard/rolloutsteps.js @@ -17,7 +17,8 @@ import { Add as AddIcon, ArrowRight as ArrowRightIcon, PauseCircleOutline as Pau import { Chip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { TIMEOUTS } from '../../../constants/appConstants'; +import { TIMEOUTS } from '@northern.tech/store/constants'; + import DocsLink from '../../common/docslink'; import InfoText from '../../common/infotext'; import MenderTooltip from '../../common/mendertooltip'; diff --git a/frontend/src/js/components/deployments/deployment-wizard/schedulerollout.js b/frontend/src/js/components/deployments/deployment-wizard/schedulerollout.js index 6272001e..d03c2a95 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/schedulerollout.js +++ b/frontend/src/js/components/deployments/deployment-wizard/schedulerollout.js @@ -17,9 +17,9 @@ import { FormControl, MenuItem, Select } from '@mui/material'; import { DateTimePicker } from '@mui/x-date-pickers'; import { makeStyles } from 'tss-react/mui'; +import { BENEFITS } from '@northern.tech/store/constants'; import dayjs from 'dayjs'; -import { BENEFITS } from '../../../constants/appConstants'; import EnterpriseNotification from '../../common/enterpriseNotification'; import { InfoHintContainer } from '../../common/info-hint'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; diff --git a/frontend/src/js/components/deployments/deployment-wizard/softwaredevices.js b/frontend/src/js/components/deployments/deployment-wizard/softwaredevices.js index e20592fd..9034a4a4 100644 --- a/frontend/src/js/components/deployments/deployment-wizard/softwaredevices.js +++ b/frontend/src/js/components/deployments/deployment-wizard/softwaredevices.js @@ -19,13 +19,11 @@ import { ErrorOutline as ErrorOutlineIcon } from '@mui/icons-material'; import { Autocomplete, TextField, Tooltip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { ALL_DEVICES, DEPLOYMENT_TYPES } from '@northern.tech/store/constants'; +import { getReleases, getSystemDevices } from '@northern.tech/store/thunks'; import pluralize from 'pluralize'; import isUUID from 'validator/lib/isUUID'; -import { getSystemDevices } from '../../../actions/deviceActions'; -import { getReleases } from '../../../actions/releaseActions'; -import { DEPLOYMENT_TYPES } from '../../../constants/deploymentConstants'; -import { ALL_DEVICES } from '../../../constants/deviceConstants'; import { stringToBoolean } from '../../../helpers'; import { formatDeviceSearch } from '../../../utils/locationutils'; import useWindowSize from '../../../utils/resizehook'; @@ -113,7 +111,7 @@ export const Devices = ({ if (!device.id || !stringToBoolean(mender_is_gateway)) { return; } - dispatch(getSystemDevices(device.id, { perPage: 500 })); + dispatch(getSystemDevices({ id: device.id, perPage: 500 })); // eslint-disable-next-line react-hooks/exhaustive-deps }, [device.id, device.attributes?.mender_is_gateway, dispatch]); diff --git a/frontend/src/js/components/deployments/deploymentitem.js b/frontend/src/js/components/deployments/deploymentitem.js index 1e9ae00e..2a4117d3 100644 --- a/frontend/src/js/components/deployments/deploymentitem.js +++ b/frontend/src/js/components/deployments/deploymentitem.js @@ -18,8 +18,10 @@ import { CancelOutlined as CancelOutlinedIcon } from '@mui/icons-material'; import { Button, IconButton, Tooltip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { DEPLOYMENT_STATES, DEPLOYMENT_TYPES } from '../../constants/deploymentConstants'; -import { FileSize, getDeploymentState } from '../../helpers'; +import { DEPLOYMENT_STATES, DEPLOYMENT_TYPES } from '@northern.tech/store/constants'; +import { getDeploymentState } from '@northern.tech/store/utils'; + +import { FileSize } from '../../helpers'; import { useDeploymentDevice } from '../../utils/deploymentdevicehook'; import Confirm from '../common/confirm'; import { RelativeTime } from '../common/time'; diff --git a/frontend/src/js/components/deployments/deployments.js b/frontend/src/js/components/deployments/deployments.js index 587847c2..4a12231f 100644 --- a/frontend/src/js/components/deployments/deployments.js +++ b/frontend/src/js/components/deployments/deployments.js @@ -19,17 +19,20 @@ import { Link, useNavigate } from 'react-router-dom'; import { Button, Tab, Tabs } from '@mui/material'; +import storeActions from '@northern.tech/store/actions'; +import { ALL_DEVICES, DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, listDefaultsByState, onboardingSteps } from '@northern.tech/store/constants'; +import { + getDevicesById, + getGroupsByIdWithoutUngrouped, + getIsEnterprise, + getOnboardingState, + getReleasesById, + getUserCapabilities +} from '@northern.tech/store/selectors'; +import { abortDeployment, advanceOnboarding, getDynamicGroups, getGroups, setDeploymentsState } from '@northern.tech/store/thunks'; import { isUUID } from 'validator'; -import { setSnackbar } from '../../actions/appActions'; -import { abortDeployment, setDeploymentsState } from '../../actions/deploymentActions'; -import { getDynamicGroups, getGroups } from '../../actions/deviceActions'; -import { advanceOnboarding } from '../../actions/onboardingActions'; -import { DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, listDefaultsByState } from '../../constants/deploymentConstants'; -import { ALL_DEVICES } from '../../constants/deviceConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; import { getISOStringBoundaries } from '../../helpers'; -import { getDevicesById, getGroupsByIdWithoutUngrouped, getIsEnterprise, getOnboardingState, getReleasesById, getUserCapabilities } from '../../selectors'; import { useLocationParams } from '../../utils/liststatehook'; import { getOnboardingComponentFor } from '../../utils/onboardingmanager'; import useWindowSize from '../../utils/resizehook'; @@ -39,6 +42,8 @@ import Past from './pastdeployments'; import Report from './report'; import Scheduled from './scheduleddeployments'; +const { setSnackbar } = storeActions; + const routes = { [DEPLOYMENT_ROUTES.active.key]: { ...DEPLOYMENT_ROUTES.active, diff --git a/frontend/src/js/components/deployments/deployments.test.js b/frontend/src/js/components/deployments/deployments.test.js index ae614f2b..52ed66c8 100644 --- a/frontend/src/js/components/deployments/deployments.test.js +++ b/frontend/src/js/components/deployments/deployments.test.js @@ -16,13 +16,13 @@ import React from 'react'; import { LocalizationProvider } from '@mui/x-date-pickers'; import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'; +import GeneralApi from '@northern.tech/store/api/general-api'; +import { ALL_DEVICES } from '@northern.tech/store/constants'; import { act, fireEvent, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { defaultState, mockDate, undefineds } from '../../../../tests/mockData'; import { render, selectMaterialUiSelectOption } from '../../../../tests/setupTests'; -import GeneralApi from '../../api/general-api'; -import { ALL_DEVICES } from '../../constants/deviceConstants'; import Deployments from './deployments'; const defaultLocationProps = { location: { search: 'startDate=2019-01-01' }, match: {} }; @@ -137,7 +137,7 @@ describe('Deployments Component', () => { const inprogressDeployments = screen.getByText(/in progress now/i).parentElement.parentElement; const deployment = within(inprogressDeployments).getAllByText(/test deployment/i)[0].parentElement.parentElement; await user.click(within(deployment).getByRole('button', { name: /Abort/i })); - act(() => jest.advanceTimersByTime(200)); + await waitFor(() => rerender(ui)); await waitFor(() => expect(screen.getByText(/Confirm abort/i)).toBeInTheDocument()); await user.click(document.querySelector('#confirmAbort').nextElementSibling); await waitFor(() => expect(within(deployment).getByRole('button', { name: /View details/i })).toBeVisible()); diff --git a/frontend/src/js/components/deployments/deploymentstatus.js b/frontend/src/js/components/deployments/deploymentstatus.js index aae08eb4..52528041 100644 --- a/frontend/src/js/components/deployments/deploymentstatus.js +++ b/frontend/src/js/components/deployments/deploymentstatus.js @@ -16,12 +16,13 @@ import React from 'react'; import { Tooltip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { groupDeploymentStats } from '@northern.tech/store/utils'; + import errorImage from '../../../assets/img/error_status.png'; import pendingImage from '../../../assets/img/pending_status.png'; import inprogressImage from '../../../assets/img/progress_status.png'; import skippedImage from '../../../assets/img/skipped_status.png'; import successImage from '../../../assets/img/success_status.png'; -import { groupDeploymentStats } from '../../helpers'; const phases = { skipped: { title: 'Skipped', image: skippedImage }, diff --git a/frontend/src/js/components/deployments/inprogressdeployments.js b/frontend/src/js/components/deployments/inprogressdeployments.js index 944a8a1e..71ca7031 100644 --- a/frontend/src/js/components/deployments/inprogressdeployments.js +++ b/frontend/src/js/components/deployments/inprogressdeployments.js @@ -17,10 +17,8 @@ import { useDispatch, useSelector } from 'react-redux'; import { Refresh as RefreshIcon } from '@mui/icons-material'; import { makeStyles } from 'tss-react/mui'; -import { setSnackbar } from '../../actions/appActions'; -import { getDeploymentsByStatus, setDeploymentsState } from '../../actions/deploymentActions'; -import { DEPLOYMENT_STATES } from '../../constants/deploymentConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; +import storeActions from '@northern.tech/store/actions'; +import { DEPLOYMENT_STATES, onboardingSteps } from '@northern.tech/store/constants'; import { getDeploymentsByStatus as getDeploymentsByStatusSelector, getDeploymentsSelectionState, @@ -30,7 +28,9 @@ import { getMappedDeploymentSelection, getOnboardingState, getUserCapabilities -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { getDeploymentsByStatus, setDeploymentsState } from '@northern.tech/store/thunks'; + import { getOnboardingComponentFor } from '../../utils/onboardingmanager'; import useWindowSize from '../../utils/resizehook'; import { clearAllRetryTimers, clearRetryTimer, setRetryTimer } from '../../utils/retrytimer'; @@ -39,6 +39,8 @@ import Loader from '../common/loader'; import { defaultRefreshDeploymentsLength as refreshDeploymentsLength } from './deployments'; import DeploymentsList from './deploymentslist'; +const { setSnackbar } = storeActions; + export const minimalRefreshDeploymentsLength = 2000; const useStyles = makeStyles()(theme => ({ @@ -90,10 +92,10 @@ export const Progress = ({ abort, createClick, ...remainder }) => { const refreshDeployments = useCallback( deploymentStatus => { const { page, perPage } = selectionState[deploymentStatus]; - return dispatch(getDeploymentsByStatus(deploymentStatus, page, perPage)) - .then(deploymentsAction => { + return dispatch(getDeploymentsByStatus({ status: deploymentStatus, page, perPage })) + .then(({ payload }) => { clearRetryTimer(deploymentStatus, dispatchedSetSnackbar); - const { total, deploymentIds } = deploymentsAction[deploymentsAction.length - 1]; + const { total, deploymentIds } = payload[payload.length - 1]; if (total && !deploymentIds.length) { return refreshDeployments(deploymentStatus); } @@ -110,7 +112,7 @@ export const Progress = ({ abort, createClick, ...remainder }) => { let tasks = [refreshDeployments(DEPLOYMENT_STATES.inprogress), refreshDeployments(DEPLOYMENT_STATES.pending)]; if (!onboardingState.complete && !pastDeploymentsCount) { // retrieve past deployments outside of the regular refresh cycle to not change the selection state for past deployments - dispatch(getDeploymentsByStatus(DEPLOYMENT_STATES.finished, 1, 1, undefined, undefined, undefined, undefined, false)); + dispatch(getDeploymentsByStatus({ status: DEPLOYMENT_STATES.finished, page: 1, perPage: 1, shouldSelect: false })); } return Promise.all(tasks) .then(() => { diff --git a/frontend/src/js/components/deployments/pastdeployments.js b/frontend/src/js/components/deployments/pastdeployments.js index 9dae5d13..55bd9ff1 100644 --- a/frontend/src/js/components/deployments/pastdeployments.js +++ b/frontend/src/js/components/deployments/pastdeployments.js @@ -17,14 +17,8 @@ import { useDispatch, useSelector } from 'react-redux'; // material ui import { TextField } from '@mui/material'; -import historyImage from '../../../assets/img/history.png'; -import { setSnackbar } from '../../actions/appActions'; -import { getDeploymentsByStatus, setDeploymentsState } from '../../actions/deploymentActions'; -import { advanceOnboarding } from '../../actions/onboardingActions'; -import { BEGINNING_OF_TIME, SORTING_OPTIONS } from '../../constants/appConstants'; -import { DEPLOYMENT_STATES, DEPLOYMENT_TYPES } from '../../constants/deploymentConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; -import { getISOStringBoundaries } from '../../helpers'; +import storeActions from '@northern.tech/store/actions'; +import { BEGINNING_OF_TIME, DEPLOYMENT_STATES, DEPLOYMENT_TYPES, onboardingSteps } from '@northern.tech/store/constants'; import { getDeploymentsSelectionState, getDevicesById, @@ -33,7 +27,11 @@ import { getMappedDeploymentSelection, getOnboardingState, getUserCapabilities -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { advanceOnboarding, getDeploymentsByStatus, setDeploymentsState } from '@northern.tech/store/thunks'; + +import historyImage from '../../../assets/img/history.png'; +import { getISOStringBoundaries } from '../../helpers'; import { getOnboardingComponentFor } from '../../utils/onboardingmanager'; import useWindowSize from '../../utils/resizehook'; import { clearAllRetryTimers, clearRetryTimer, setRetryTimer } from '../../utils/retrytimer'; @@ -44,6 +42,8 @@ import { DeploymentSize, DeploymentStatus } from './deploymentitem'; import { defaultRefreshDeploymentsLength as refreshDeploymentsLength } from './deployments'; import DeploymentsList, { defaultHeaders } from './deploymentslist'; +const { setSnackbar } = storeActions; + const headers = [ ...defaultHeaders.slice(0, defaultHeaders.length - 1), { title: 'Status', renderer: DeploymentStatus }, @@ -90,11 +90,21 @@ export const Past = props => { const roundedStartDate = Math.round(Date.parse(currentStartDate) / 1000); const roundedEndDate = Math.round(Date.parse(currentEndDate) / 1000); setLoading(true); - return dispatch(getDeploymentsByStatus(type, currentPage, currentPerPage, roundedStartDate, roundedEndDate, currentDeviceGroup, currentType)) - .then(deploymentsAction => { + return dispatch( + getDeploymentsByStatus({ + status: type, + page: currentPage, + perPage: currentPerPage, + startDate: roundedStartDate, + endDate: roundedEndDate, + group: currentDeviceGroup, + type: currentType + }) + ) + .then(({ payload }) => { setLoading(false); clearRetryTimer(type, dispatchedSetSnackbar); - const { total, deploymentIds } = deploymentsAction[deploymentsAction.length - 1]; + const { total, deploymentIds } = payload[payload.length - 1]; if (total && !deploymentIds.length) { return refreshPast(currentPage, currentPerPage, currentStartDate, currentEndDate, currentDeviceGroup); } @@ -108,9 +118,11 @@ export const Past = props => { const roundedStartDate = Math.round(Date.parse(startDate || BEGINNING_OF_TIME) / 1000); const roundedEndDate = Math.round(Date.parse(endDate) / 1000); setLoading(true); - dispatch(getDeploymentsByStatus(type, page, perPage, roundedStartDate, roundedEndDate, deviceGroup, deploymentType, true, SORTING_OPTIONS.desc)) + dispatch( + getDeploymentsByStatus({ status: type, page, perPage, startDate: roundedStartDate, endDate: roundedEndDate, group: deviceGroup, type: deploymentType }) + ) .then(deploymentsAction => { - const deploymentsList = deploymentsAction ? Object.values(deploymentsAction[0].deployments) : []; + const deploymentsList = deploymentsAction ? Object.values(deploymentsAction.payload[0]) : []; if (deploymentsList.length) { let newStartDate = new Date(deploymentsList[deploymentsList.length - 1].created); const { start } = getISOStringBoundaries(newStartDate); diff --git a/frontend/src/js/components/deployments/progressChart.js b/frontend/src/js/components/deployments/progressChart.js index 57e01264..6d982684 100644 --- a/frontend/src/js/components/deployments/progressChart.js +++ b/frontend/src/js/components/deployments/progressChart.js @@ -18,12 +18,12 @@ import { Tooltip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; import { mdiDotsHorizontalCircleOutline as QueuedIcon, mdiSleep as SleepIcon } from '@mdi/js'; +import { TIMEOUTS } from '@northern.tech/store/constants'; +import { groupDeploymentStats } from '@northern.tech/store/utils'; import dayjs from 'dayjs'; import durationDayJs from 'dayjs/plugin/duration'; import pluralize from 'pluralize'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { groupDeploymentStats } from '../../helpers'; import MaterialDesignIcon from '../common/materialdesignicon'; import Time from '../common/time'; diff --git a/frontend/src/js/components/deployments/report.js b/frontend/src/js/components/deployments/report.js index 0efedee4..c9433623 100644 --- a/frontend/src/js/components/deployments/report.js +++ b/frontend/src/js/components/deployments/report.js @@ -25,16 +25,8 @@ import { import { Button, Divider, Drawer, IconButton, Tooltip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import copy from 'copy-to-clipboard'; - -import { setSnackbar } from '../../actions/appActions'; -import { getDeploymentDevices, getDeviceLog, getSingleDeployment, updateDeploymentControlMap } from '../../actions/deploymentActions'; -import { getAuditLogs } from '../../actions/organizationActions'; -import { getRelease } from '../../actions/releaseActions'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { DEPLOYMENT_STATES, DEPLOYMENT_TYPES, deploymentStatesToSubstates } from '../../constants/deploymentConstants'; -import { AUDIT_LOGS_TYPES } from '../../constants/organizationConstants'; -import { statCollector, toggle } from '../../helpers'; +import storeActions from '@northern.tech/store/actions'; +import { AUDIT_LOGS_TYPES, DEPLOYMENT_STATES, DEPLOYMENT_TYPES, TIMEOUTS, deploymentStatesToSubstates } from '@northern.tech/store/constants'; import { getDeploymentRelease, getDevicesById, @@ -43,18 +35,25 @@ import { getSelectedDeploymentData, getTenantCapabilities, getUserCapabilities -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { getAuditLogs, getDeploymentDevices, getDeviceLog, getRelease, getSingleDeployment, updateDeploymentControlMap } from '@northern.tech/store/thunks'; +import { statCollector } from '@northern.tech/store/utils'; +import copy from 'copy-to-clipboard'; + +import { toggle } from '../../helpers'; import ConfigurationObject from '../common/configurationobject'; import Confirm from '../common/confirm'; import LogDialog from '../common/dialogs/log'; import LinedHeader from '../common/lined-header'; -import BaseOnboardingTip from '../helptips/baseonboardingtip.js'; -import { DeploymentUploadFinished } from '../helptips/onboardingtips.js'; +import BaseOnboardingTip from '../helptips/baseonboardingtip'; +import { DeploymentUploadFinished } from '../helptips/onboardingtips'; import DeploymentStatus, { DeploymentPhaseNotification } from './deployment-report/deploymentstatus'; import DeviceList from './deployment-report/devicelist'; import DeploymentOverview from './deployment-report/overview'; import RolloutSchedule from './deployment-report/rolloutschedule'; +const { setSnackbar } = storeActions; + const useStyles = makeStyles()(theme => ({ divider: { marginTop: theme.spacing(2) }, header: { @@ -189,7 +188,10 @@ export const DeploymentReport = ({ abort, onClose, past, retry, type }) => { const scrollToBottom = () => rolloutSchedule.current?.scrollIntoView({ behavior: 'smooth' }); - const viewLog = useCallback(id => dispatch(getDeviceLog(deployment.id, id)).then(() => setDeviceId(id)), [deployment.id, dispatch]); + const viewLog = useCallback( + id => dispatch(getDeviceLog({ deploymentId: deployment.id, deviceId: id })).then(() => setDeviceId(id)), + [deployment.id, dispatch] + ); const copyLinkToClipboard = () => { const location = window.location.href.substring(0, window.location.href.indexOf('/deployments') + '/deployments'.length); @@ -213,12 +215,12 @@ export const DeploymentReport = ({ abort, onClose, past, retry, type }) => { const { id, update_control_map = {} } = deployment; const { states } = update_control_map; const { states: updatedStates } = updatedMap; - dispatch(updateDeploymentControlMap(id, { states: { ...states, ...updatedStates } })); + dispatch(updateDeploymentControlMap({ deploymentId: id, updateControlMap: { states: { ...states, ...updatedStates } } })); }; const props = { deployment, - getDeploymentDevices: useCallback((id, options) => dispatch(getDeploymentDevices(id, options)), [dispatch]), + getDeploymentDevices: useCallback((...args) => dispatch(getDeploymentDevices(...args)), [dispatch]), idAttribute, selectedDevices, userCapabilities, diff --git a/frontend/src/js/components/deployments/scheduleddeployments.js b/frontend/src/js/components/deployments/scheduleddeployments.js index 61e07fea..1da9e9f3 100644 --- a/frontend/src/js/components/deployments/scheduleddeployments.js +++ b/frontend/src/js/components/deployments/scheduleddeployments.js @@ -20,11 +20,8 @@ import { CalendarToday as CalendarTodayIcon, List as ListIcon, Refresh as Refres import { Button } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import dayjs from 'dayjs'; - -import { setSnackbar } from '../../actions/appActions'; -import { getDeploymentsByStatus, setDeploymentsState } from '../../actions/deploymentActions'; -import { DEPLOYMENT_STATES } from '../../constants/deploymentConstants'; +import storeActions from '@northern.tech/store/actions'; +import { DEPLOYMENT_STATES } from '@northern.tech/store/constants'; import { getDeploymentsByStatus as getDeploymentsByStatusSelector, getDeploymentsSelectionState, @@ -33,13 +30,18 @@ import { getMappedDeploymentSelection, getTenantCapabilities, getUserCapabilities -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { getDeploymentsByStatus, setDeploymentsState } from '@northern.tech/store/thunks'; +import dayjs from 'dayjs'; + import { clearAllRetryTimers, clearRetryTimer, setRetryTimer } from '../../utils/retrytimer'; import { DefaultUpgradeNotification } from '../common/enterpriseNotification'; import { DeploymentDeviceCount, DeploymentEndTime, DeploymentPhases, DeploymentStartTime } from './deploymentitem'; import { defaultRefreshDeploymentsLength as refreshDeploymentsLength } from './deployments'; import DeploymentsList, { defaultHeaders } from './deploymentslist'; +const { setSnackbar } = storeActions; + const useStyles = makeStyles()(theme => ({ inactive: { color: theme.palette.text.disabled }, refreshIcon: { fill: theme.palette.grey[400], width: 111, height: 111 }, @@ -92,10 +94,10 @@ export const Scheduled = ({ abort, createClick, openReport, ...remainder }) => { const { page, perPage } = scheduledState; const refreshDeployments = useCallback(() => { - return dispatch(getDeploymentsByStatus(DEPLOYMENT_STATES.scheduled, page, perPage)) - .then(deploymentsAction => { + return dispatch(getDeploymentsByStatus({ status: DEPLOYMENT_STATES.scheduled, page, perPage })) + .then(({ payload }) => { clearRetryTimer(type, dispatchedSetSnackbar); - const { total, deploymentIds } = deploymentsAction[deploymentsAction.length - 1]; + const { total, deploymentIds } = payload[payload.length - 1]; if (total && !deploymentIds.length) { return refreshDeployments(); } diff --git a/frontend/src/js/components/devices/authorized-devices.js b/frontend/src/js/components/devices/authorized-devices.js index ef2970cc..534e7f92 100644 --- a/frontend/src/js/components/devices/authorized-devices.js +++ b/frontend/src/js/components/devices/authorized-devices.js @@ -11,24 +11,17 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; // material ui import { Autorenew as AutorenewIcon, Delete as DeleteIcon, FilterList as FilterListIcon, LockOutlined } from '@mui/icons-material'; -import { Button, MenuItem, Select } from '@mui/material'; +import { Button } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { setSnackbar } from '../../actions/appActions'; -import { deleteAuthset, setDeviceFilters, setDeviceListState, updateDevicesAuth } from '../../actions/deviceActions'; -import { getIssueCountsByType } from '../../actions/monitorActions'; -import { advanceOnboarding } from '../../actions/onboardingActions'; -import { saveUserSettings, updateUserColumnSettings } from '../../actions/userActions'; -import { SORTING_OPTIONS, TIMEOUTS } from '../../constants/appConstants'; -import { ALL_DEVICES, DEVICE_ISSUE_OPTIONS, DEVICE_STATES, UNGROUPED_GROUP } from '../../constants/deviceConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; -import { duplicateFilter, toggle } from '../../helpers'; +import storeActions from '@northern.tech/store/actions'; +import { ALL_DEVICES, DEVICE_ISSUE_OPTIONS, DEVICE_STATES, SORTING_OPTIONS, TIMEOUTS, UNGROUPED_GROUP, onboardingSteps } from '@northern.tech/store/constants'; import { getAvailableIssueOptionsByType, getDeviceCountsByStatus, @@ -42,7 +35,18 @@ import { getTenantCapabilities, getUserCapabilities, getUserSettings -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { + advanceOnboarding, + deleteAuthset, + getIssueCountsByType, + saveUserSettings, + setDeviceListState, + updateDevicesAuth, + updateUserColumnSettings +} from '@northern.tech/store/thunks'; + +import { toggle } from '../../helpers'; import { useDebounce } from '../../utils/debouncehook'; import { getOnboardingComponentFor } from '../../utils/onboardingmanager'; import useWindowSize from '../../utils/resizehook'; @@ -53,10 +57,13 @@ import DeviceList, { minCellWidth } from './devicelist'; import ColumnCustomizationDialog from './dialogs/custom-columns-dialog'; import ExpandedDevice from './expanded-device'; import DeviceQuickActions from './widgets/devicequickactions'; +import { DeviceStateSelection } from './widgets/devicestateselection'; import Filters from './widgets/filters'; import DeviceIssuesSelection from './widgets/issueselection'; import ListOptions from './widgets/listoptions'; +const { setDeviceFilters, setSnackbar } = storeActions; + const deviceRefreshTimes = { [DEVICE_STATES.accepted]: TIMEOUTS.refreshLong, [DEVICE_STATES.pending]: TIMEOUTS.refreshDefault, @@ -71,7 +78,7 @@ const idAttributeTitleMap = { }; const headersReducer = (accu, header) => { - if (header.attribute.scope === accu.column.scope && (header.attribute.name === accu.column.name || header.attribute.alternative === accu.column.name)) { + if (header.attribute.scope === accu.column.scope && header.attribute.name === accu.column.name) { accu.header = { ...accu.header, ...header }; } return accu; @@ -105,14 +112,6 @@ const useStyles = makeStyles()(theme => ({ padding: 20, borderTopLeftRadius: 0 } - }, - selection: { - fontSize: 13, - marginLeft: theme.spacing(0.5), - marginTop: 2, - '>div': { - paddingLeft: theme.spacing(0.5) - } } })); @@ -251,7 +250,10 @@ export const Authorized = ({ }, [settingsInitialized, devicesInitialized, pageLoading]); const onDeviceStateSelectionChange = useCallback( - newState => dispatch(setDeviceListState({ state: newState, page: 1, refreshTrigger: !refreshTrigger }, true, false, false)), + newState => + dispatch( + setDeviceListState({ state: newState, page: 1, refreshTrigger: !refreshTrigger, shouldSelectDevices: true, forceRefresh: false, fetchAuth: false }) + ), [dispatch, refreshTrigger] ); @@ -279,7 +281,7 @@ export const Authorized = ({ }, [selectedGroup]); const dispatchDeviceListState = useCallback( (options, shouldSelectDevices = true, forceRefresh = false, fetchAuth = false) => { - return dispatch(setDeviceListState(options, shouldSelectDevices, forceRefresh, fetchAuth)); + return dispatch(setDeviceListState({ ...options, shouldSelectDevices, forceRefresh, fetchAuth })); }, [dispatch] ); @@ -303,7 +305,7 @@ export const Authorized = ({ useEffect(() => { Object.keys(availableIssueOptions).map(key => dispatch(getIssueCountsByType(key, { filters, group: selectedGroup, state: selectedState }))); availableIssueOptions[DEVICE_ISSUE_OPTIONS.authRequests.key] - ? dispatch(getIssueCountsByType(DEVICE_ISSUE_OPTIONS.authRequests.key, { filters: [] })) + ? dispatch(getIssueCountsByType({ type: DEVICE_ISSUE_OPTIONS.authRequests.key, options: { filters: [] } })) : undefined; // eslint-disable-next-line react-hooks/exhaustive-deps }, [selectedIssues.join(''), JSON.stringify(availableIssueOptions), selectedState, selectedGroup, dispatch, JSON.stringify(filters)]); @@ -326,7 +328,7 @@ export const Authorized = ({ const onAuthorizationChange = (devices, changedState) => { const deviceIds = devicesToIds(devices); return dispatchDeviceListState({ isLoading: true }) - .then(() => dispatch(updateDevicesAuth(deviceIds, changedState))) + .then(() => dispatch(updateDevicesAuth({ deviceIds, status: changedState }))) .then(() => onSelectionChange([])); }; @@ -335,7 +337,7 @@ export const Authorized = ({ .then(() => { const deleteRequests = devices.reduce((accu, device) => { if (device.auth_sets?.length) { - accu.push(dispatch(deleteAuthset(device.id, device.auth_sets[0].id))); + accu.push(dispatch(deleteAuthset({ deviceId: device.id, authId: device.auth_sets[0].id }))); } return accu; }, []); @@ -376,7 +378,7 @@ export const Authorized = ({ const onChangeColumns = useCallback( (changedColumns, customColumnSizes) => { const { columnSizes, selectedAttributes } = calculateColumnSelectionSize(changedColumns, customColumnSizes); - dispatch(updateUserColumnSettings(columnSizes)); + dispatch(updateUserColumnSettings({ columns: columnSizes })); dispatch(saveUserSettings({ columnSelection: changedColumns })); // we don't need an explicit refresh trigger here, since the selectedAttributes will be different anyway & otherwise the shown list should still be valid dispatchDeviceListState({ selectedAttributes }); @@ -398,7 +400,7 @@ export const Authorized = ({ const onCloseExpandedDevice = useCallback(() => dispatchDeviceListState({ selectedId: undefined, detailsTab: '' }), [dispatchDeviceListState]); - const onResizeColumns = useCallback(columns => dispatch(updateUserColumnSettings(columns)), [dispatch]); + const onResizeColumns = useCallback(columns => dispatch(updateUserColumnSettings({ columns })), [dispatch]); const actionCallbacks = { onAddDevicesToGroup: addDevicesToGroup, @@ -512,21 +514,3 @@ export const Authorized = ({ }; export default Authorized; - -export const DeviceStateSelection = ({ onStateChange, selectedState = '', states }) => { - const { classes } = useStyles(); - const availableStates = useMemo(() => Object.values(states).filter(duplicateFilter), [states]); - - return ( -
- Status: - -
- ); -}; diff --git a/frontend/src/js/components/devices/authorized-devices.test.js b/frontend/src/js/components/devices/authorized-devices.test.js index d385d308..70ffc8f8 100644 --- a/frontend/src/js/components/devices/authorized-devices.test.js +++ b/frontend/src/js/components/devices/authorized-devices.test.js @@ -13,13 +13,13 @@ // limitations under the License. import React from 'react'; +import * as DeviceActions from '@northern.tech/store/devicesSlice/thunks'; +import * as UserActions from '@northern.tech/store/usersSlice/thunks'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import * as DeviceActions from '../../actions/deviceActions'; -import * as UserActions from '../../actions/userActions'; import Authorized from './authorized-devices'; import { routes } from './base-devices'; @@ -57,16 +57,13 @@ describe('AuthorizedDevices Component', () => { deviceType: 'device_type', checkInTime: 'check_in_time' }; - // const devices = defaultState.devices.byStatus.accepted.deviceIds.map(id => defaultState.devices.byId[id]); const pageTotal = defaultState.devices.byStatus.accepted.deviceIds.length; - // const deviceListState = { isLoading: false, selectedState: DEVICE_STATES.accepted, selection: [], sort: {} }; const preloadedState = { ...defaultState, app: { ...defaultState.app, features: { ...defaultState.app.features, - // hasReporting: true, hasMonitor: true, isEnterprise: true } @@ -108,12 +105,19 @@ describe('AuthorizedDevices Component', () => { const { rerender } = render(ui, { preloadedState }); await waitFor(() => expect(screen.getAllByRole('checkbox').length).toBeTruthy()); await user.click(screen.getAllByRole('checkbox')[0]); - expect(setListStateSpy).toHaveBeenCalledWith({ selection: [0, 1], setOnly: true }, true, false, false); + expect(setListStateSpy).toHaveBeenCalledWith({ selection: [0, 1], setOnly: true, fetchAuth: false, forceRefresh: false, shouldSelectDevices: true }); const combo = screen.getAllByRole('combobox').find(item => item.textContent?.includes('all')); await user.click(combo); await user.click(screen.getByRole('option', { name: /devices with issues/i })); await user.keyboard('{Escape}'); - expect(setListStateSpy).toHaveBeenCalledWith({ page: 1, refreshTrigger: true, selectedIssues: ['offline', 'monitoring'] }, true, false, false); + expect(setListStateSpy).toHaveBeenCalledWith({ + page: 1, + refreshTrigger: true, + selectedIssues: ['offline', 'monitoring'], + fetchAuth: false, + forceRefresh: false, + shouldSelectDevices: true + }); await waitFor(() => rerender(ui)); await user.click(screen.getByRole('button', { name: /table options/i })); await waitFor(() => rerender(ui)); @@ -129,25 +133,25 @@ describe('AuthorizedDevices Component', () => { expect(button).not.toBeDisabled(); await user.click(button); - expect(setColumnsSpy).toHaveBeenCalledWith([ - { attribute: { name: attributeNames.deviceType, scope: 'inventory' }, size: 150 }, - { attribute: { name: attributeNames.artifact, scope: 'inventory' }, size: 150 }, - { attribute: { name: attributeNames.checkInTime, scope: 'system' }, size: 220 }, - { attribute: { name: testKey, scope: 'inventory' }, size: 150 } - ]); - expect(setListStateSpy).toHaveBeenCalledWith( - { - selectedAttributes: [ - { attribute: attributeNames.deviceType, scope: 'inventory' }, - { attribute: attributeNames.artifact, scope: 'inventory' }, - { attribute: attributeNames.checkInTime, scope: 'system' }, - { attribute: testKey, scope: 'inventory' } - ] - }, - true, - false, - false - ); + expect(setColumnsSpy).toHaveBeenCalledWith({ + columns: [ + { attribute: { name: attributeNames.deviceType, scope: 'inventory' }, size: 150 }, + { attribute: { name: attributeNames.artifact, scope: 'inventory' }, size: 150 }, + { attribute: { name: attributeNames.checkInTime, scope: 'system' }, size: 220 }, + { attribute: { name: testKey, scope: 'inventory' }, size: 150 } + ] + }); + expect(setListStateSpy).toHaveBeenCalledWith({ + selectedAttributes: [ + { attribute: attributeNames.deviceType, scope: 'inventory' }, + { attribute: attributeNames.artifact, scope: 'inventory' }, + { attribute: attributeNames.checkInTime, scope: 'system' }, + { attribute: testKey, scope: 'inventory' } + ], + fetchAuth: false, + forceRefresh: false, + shouldSelectDevices: true + }); expect(setUserSettingsSpy).toHaveBeenCalledWith({ columnSelection: [ { id: 'inventory-device_type', key: attributeNames.deviceType, name: attributeNames.deviceType, scope: 'inventory', title: 'Device type' }, diff --git a/frontend/src/js/components/devices/base-devices.js b/frontend/src/js/components/devices/base-devices.js index c3dcc667..77793968 100644 --- a/frontend/src/js/components/devices/base-devices.js +++ b/frontend/src/js/components/devices/base-devices.js @@ -14,11 +14,10 @@ import React from 'react'; import { Link } from 'react-router-dom'; +import { DEVICE_STATES, currentArtifact, rootfsImageVersion } from '@northern.tech/store/constants'; import pluralize from 'pluralize'; import preauthImage from '../../../assets/img/preauthorize.png'; -import { DEVICE_STATES } from '../../constants/deviceConstants'; -import { currentArtifact, rootfsImageVersion } from '../../constants/releaseConstants'; import Time, { ApproximateRelativeDate } from '../common/time'; import DeviceStatus from './device-status'; @@ -33,7 +32,7 @@ const propertyNameMap = { export const defaultTextRender = ({ column, device }) => { const propertyName = propertyNameMap[column.attribute.scope] ?? column.attribute.scope; const accessorTarget = device[propertyName] ?? device; - const attributeValue = accessorTarget[column.attribute.name] || accessorTarget[column.attribute.alternative] || device[column.attribute.name]; + const attributeValue = accessorTarget[column.attribute.name] || device[column.attribute.name]; return (typeof attributeValue === 'object' ? JSON.stringify(attributeValue) : attributeValue) ?? device.id; }; @@ -247,13 +246,3 @@ export const routes = { defaultHeaders: [defaultHeaders.deviceCreationTime, defaultHeaders.lastCheckIn] } }; - -export const sortingAlternatives = Object.values(routes) - .reduce((accu, item) => [...accu, ...item.defaultHeaders], []) - .reduce((accu, item) => { - if (item.attribute.alternative) { - accu[item.attribute.name] = item.attribute.alternative; - accu[item.attribute.alternative] = item.attribute.name; - } - return accu; - }, {}); diff --git a/frontend/src/js/components/devices/device-details/authsets/authsetlist.js b/frontend/src/js/components/devices/device-details/authsets/authsetlist.js index b1ac9ad1..e56cbc7c 100644 --- a/frontend/src/js/components/devices/device-details/authsets/authsetlist.js +++ b/frontend/src/js/components/devices/device-details/authsets/authsetlist.js @@ -16,8 +16,8 @@ import React, { useState } from 'react'; import { accordionClasses, accordionDetailsClasses, accordionSummaryClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { canAccess } from '../../../../constants/appConstants'; -import { DEVICE_STATES } from '../../../../constants/deviceConstants'; +import { DEVICE_STATES, canAccess } from '@northern.tech/store/constants'; + import { customSort } from '../../../../helpers'; import AuthsetListItem from './authsetlistitem'; diff --git a/frontend/src/js/components/devices/device-details/authsets/authsetlist.test.js b/frontend/src/js/components/devices/device-details/authsets/authsetlist.test.js index bb1127c3..e99f83cb 100644 --- a/frontend/src/js/components/devices/device-details/authsets/authsetlist.test.js +++ b/frontend/src/js/components/devices/device-details/authsets/authsetlist.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { DEVICE_STATES } from '@northern.tech/store/constants'; + import { adminUserCapabilities, undefineds } from '../../../../../../tests/mockData'; import { render } from '../../../../../../tests/setupTests'; -import { DEVICE_STATES } from '../../../../constants/deviceConstants'; import AuthsetList from './authsetlist'; describe('AuthsetList Component', () => { diff --git a/frontend/src/js/components/devices/device-details/authsets/authsetlistitem.js b/frontend/src/js/components/devices/device-details/authsets/authsetlistitem.js index f863d223..f60e7a21 100644 --- a/frontend/src/js/components/devices/device-details/authsets/authsetlistitem.js +++ b/frontend/src/js/components/devices/device-details/authsets/authsetlistitem.js @@ -18,8 +18,8 @@ import { FileCopy as CopyPasteIcon } from '@mui/icons-material'; // material ui import { Accordion, AccordionActions, AccordionDetails, AccordionSummary, Button, Chip, Divider, IconButton } from '@mui/material'; -import { TIMEOUTS } from '../../../../constants/appConstants'; -import { DEVICE_DISMISSAL_STATE, DEVICE_STATES } from '../../../../constants/deviceConstants'; +import { DEVICE_DISMISSAL_STATE, DEVICE_STATES, TIMEOUTS } from '@northern.tech/store/constants'; + import { formatTime } from '../../../../helpers'; import Loader from '../../../common/loader'; import Time from '../../../common/time'; diff --git a/frontend/src/js/components/devices/device-details/authsets/authsetlistitem.test.js b/frontend/src/js/components/devices/device-details/authsets/authsetlistitem.test.js index 09701da8..638fdfd2 100644 --- a/frontend/src/js/components/devices/device-details/authsets/authsetlistitem.test.js +++ b/frontend/src/js/components/devices/device-details/authsets/authsetlistitem.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { DEVICE_STATES } from '@northern.tech/store/constants'; + import { adminUserCapabilities, undefineds } from '../../../../../../tests/mockData'; import { render } from '../../../../../../tests/setupTests'; -import { DEVICE_STATES } from '../../../../constants/deviceConstants'; import { defaultColumns } from './authsetlist'; import AuthsetListItem, { getConfirmationMessage } from './authsetlistitem'; diff --git a/frontend/src/js/components/devices/device-details/authsets/authsets.js b/frontend/src/js/components/devices/device-details/authsets/authsets.js index ebb95177..27e17e66 100644 --- a/frontend/src/js/components/devices/device-details/authsets/authsets.js +++ b/frontend/src/js/components/devices/device-details/authsets/authsets.js @@ -18,13 +18,11 @@ import { useDispatch, useSelector } from 'react-redux'; import { Button } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { DEVICE_DISMISSAL_STATE, DEVICE_STATES, onboardingSteps } from '@northern.tech/store/constants'; +import { getAcceptedDevices, getDeviceLimit, getLimitMaxed, getUserCapabilities } from '@northern.tech/store/selectors'; +import { advanceOnboarding, deleteAuthset, updateDeviceAuth } from '@northern.tech/store/thunks'; import pluralize from 'pluralize'; -import { deleteAuthset, updateDeviceAuth } from '../../../../actions/deviceActions'; -import { advanceOnboarding } from '../../../../actions/onboardingActions'; -import { DEVICE_DISMISSAL_STATE, DEVICE_STATES } from '../../../../constants/deviceConstants'; -import { onboardingSteps } from '../../../../constants/onboardingConstants'; -import { getAcceptedDevices, getDeviceLimit, getLimitMaxed, getUserCapabilities } from '../../../../selectors'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../../helptips/helptooltips'; import { DeviceLimitWarning } from '../../dialogs/preauth-dialog'; import Confirm from './../../../common/confirm'; @@ -55,10 +53,11 @@ export const Authsets = ({ decommission, device, listRef }) => { const { auth_sets = [], status = DEVICE_STATES.accepted } = device; const { canManageDevices } = userCapabilities; - const updateDeviceAuthStatus = (device_id, auth_id, status) => { - setLoading(auth_id); + const updateDeviceAuthStatus = (deviceId, authId, status) => { + setLoading(authId); // call API to update authset - const request = status === DEVICE_DISMISSAL_STATE ? dispatch(deleteAuthset(device_id, auth_id)) : dispatch(updateDeviceAuth(device_id, auth_id, status)); + const request = + status === DEVICE_DISMISSAL_STATE ? dispatch(deleteAuthset({ deviceId, authId })) : dispatch(updateDeviceAuth({ deviceId, authId, status })); // on finish, change "loading" back to null return request.then(() => dispatch(advanceOnboarding(onboardingSteps.DEVICES_PENDING_ACCEPTING_ONBOARDING))).finally(() => setLoading(null)); }; diff --git a/frontend/src/js/components/devices/device-details/authstatus.js b/frontend/src/js/components/devices/device-details/authstatus.js index 59a60017..fceea551 100644 --- a/frontend/src/js/components/devices/device-details/authstatus.js +++ b/frontend/src/js/components/devices/device-details/authstatus.js @@ -17,10 +17,10 @@ import { useSelector } from 'react-redux'; import { Block as BlockIcon, CheckCircle as CheckCircleIcon, Check as CheckIcon } from '@mui/icons-material'; import { Chip, Icon } from '@mui/material'; +import { DEVICE_STATES, onboardingSteps } from '@northern.tech/store/constants'; +import { getOnboardingState } from '@northern.tech/store/selectors'; + import pendingIcon from '../../../../assets/img/pending_status.png'; -import { DEVICE_STATES } from '../../../constants/deviceConstants'; -import { onboardingSteps } from '../../../constants/onboardingConstants'; -import { getOnboardingState } from '../../../selectors'; import { getOnboardingComponentFor } from '../../../utils/onboardingmanager'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; import Authsets from './authsets/authsets'; diff --git a/frontend/src/js/components/devices/device-details/configuration.js b/frontend/src/js/components/devices/device-details/configuration.js index 175b09a2..ac24e8ac 100644 --- a/frontend/src/js/components/devices/device-details/configuration.js +++ b/frontend/src/js/components/devices/device-details/configuration.js @@ -18,15 +18,21 @@ import { Link } from 'react-router-dom'; import { Block as BlockIcon, CheckCircle as CheckCircleIcon, Error as ErrorIcon, Refresh as RefreshIcon, SaveAlt as SaveAltIcon } from '@mui/icons-material'; import { Button, Checkbox, FormControlLabel, Typography } from '@mui/material'; -import { setSnackbar } from '../../../actions/appActions'; -import { abortDeployment, getDeviceLog, getSingleDeployment } from '../../../actions/deploymentActions'; -import { applyDeviceConfig, setDeviceConfig } from '../../../actions/deviceActions'; -import { saveGlobalSettings } from '../../../actions/userActions'; -import { BENEFITS, TIMEOUTS } from '../../../constants/appConstants'; -import { DEPLOYMENT_ROUTES, DEPLOYMENT_STATES } from '../../../constants/deploymentConstants'; -import { DEVICE_STATES } from '../../../constants/deviceConstants'; -import { deepCompare, groupDeploymentDevicesStats, groupDeploymentStats, isEmpty, toggle } from '../../../helpers'; -import { getDeviceConfigDeployment, getTenantCapabilities, getUserCapabilities } from '../../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { BENEFITS, DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, DEVICE_STATES, TIMEOUTS } from '@northern.tech/store/constants'; +import { getDeviceConfigDeployment, getTenantCapabilities, getUserCapabilities } from '@northern.tech/store/selectors'; +import { + abortDeployment, + applyDeviceConfig, + getDeviceConfig, + getDeviceLog, + getSingleDeployment, + saveGlobalSettings, + setDeviceConfig +} from '@northern.tech/store/thunks'; +import { groupDeploymentDevicesStats, groupDeploymentStats } from '@northern.tech/store/utils'; + +import { deepCompare, isEmpty, toggle } from '../../../helpers'; import Tracking from '../../../tracking'; import ConfigurationObject from '../../common/configurationobject'; import Confirm, { EditButton } from '../../common/confirm'; @@ -41,6 +47,8 @@ import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; import ConfigImportDialog from './configimportdialog'; import DeviceDataCollapse from './devicedatacollapse'; +const { setSnackbar } = storeActions; + const buttonStyle = { marginLeft: 30 }; const iconStyle = { margin: 12 }; const textStyle = { textTransform: 'capitalize', textAlign: 'left' }; @@ -195,6 +203,7 @@ export const DeviceConfiguration = ({ defaultConfig = {}, device: { id: deviceId setUpdateFailed(updateFailed); setIsEditingConfig(updateFailed); setIsUpdatingConfig(false); + dispatch(getDeviceConfig(device.id)); } else if (deployment.status) { setChangedConfig(configured); setEditableConfig(configured); @@ -235,10 +244,12 @@ export const DeviceConfiguration = ({ defaultConfig = {}, device: { id: deviceId const onSetAsDefaultChange = () => setIsSetAsDefault(toggle); const onShowLog = () => - dispatch(getDeviceLog(deployment_id, device.id)).then(result => { - setShowLog(true); - setUpdateLog(result[1]); - }); + dispatch(getDeviceLog({ deploymentId: deployment_id, deviceId: device.id })) + .unwrap() + .then(result => { + setShowLog(true); + setUpdateLog(result[1]); + }); const onCancel = () => { if (!isEmpty(reported)) { @@ -252,7 +263,7 @@ export const DeviceConfiguration = ({ defaultConfig = {}, device: { id: deviceId if (deepCompare(reported, changedConfig)) { requests.push(Promise.resolve()); } else { - requests.push(dispatch(setDeviceConfig(device.id, reported))); + requests.push(dispatch(setDeviceConfig({ deviceId: device.id, config: reported }))); if (isSetAsDefault && canManageUsers) { requests.push(dispatch(saveGlobalSettings({ defaultDeviceConfig: { current: defaultConfig.previous } }))); } @@ -268,8 +279,10 @@ export const DeviceConfiguration = ({ defaultConfig = {}, device: { id: deviceId Tracking.event({ category: 'devices', action: 'apply_configuration' }); setIsUpdatingConfig(true); setUpdateFailed(false); - return dispatch(setDeviceConfig(device.id, changedConfig)) - .then(() => dispatch(applyDeviceConfig(device.id, { retries: 0 }, isSetAsDefault, changedConfig))) + return dispatch(setDeviceConfig({ deviceId: device.id, config: changedConfig })) + .then(() => + dispatch(applyDeviceConfig({ deviceId: device.id, configDeploymentConfiguration: { retries: 0 }, isDefault: isSetAsDefault, config: changedConfig })) + ) .catch(() => { setIsEditingConfig(true); setUpdateFailed(true); diff --git a/frontend/src/js/components/devices/device-details/configuration.test.js b/frontend/src/js/components/devices/device-details/configuration.test.js index c2ab032c..6fc3499b 100644 --- a/frontend/src/js/components/devices/device-details/configuration.test.js +++ b/frontend/src/js/components/devices/device-details/configuration.test.js @@ -18,7 +18,6 @@ import userEvent from '@testing-library/user-event'; import { defaultState, undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import { TIMEOUTS } from '../../../constants/appConstants'; import Configuration, { ConfigEditingActions, ConfigEmptyNote, ConfigUpToDateNote, ConfigUpdateFailureActions, ConfigUpdateNote } from './configuration'; describe('tiny components', () => { @@ -133,24 +132,10 @@ describe('Configuration Component', () => { await user.type(screen.getByPlaceholderText(/key/i), 'testKey'); await user.type(screen.getByPlaceholderText(/value/i), 'evilValue'); expect(fabButton).not.toBeDisabled(); + await expect(screen.queryByText(/Configuration up-to-date on the device/i)).not.toBeInTheDocument(); await user.click(screen.getByRole('checkbox', { name: /save/i })); await user.click(screen.getByRole('button', { name: /save/i })); await act(async () => jest.runOnlyPendingTimers()); - expect(screen.getByText(/Configuration could not be updated on device/i)).toBeInTheDocument(); - await user.click(screen.getByRole('button', { name: /Retry/i })); - await waitFor(() => rerender(ui)); - ui = ; - act(() => jest.advanceTimersByTime(TIMEOUTS.twoSeconds)); - await act(async () => { - jest.runAllTimers(); - jest.runAllTicks(); - }); - await waitFor(() => rerender(ui)); - await waitFor(() => expect(document.querySelector('.loaderContainer')).not.toBeInTheDocument()); - const valueInput = screen.getByDisplayValue('evilValue'); - await user.clear(valueInput); - await user.type(valueInput, 'testValue'); - await user.click(screen.getByRole('button', { name: /Retry/i })); - await waitFor(() => expect(screen.queryByText(/Updating configuration/i)).toBeInTheDocument()); + expect(screen.getByText(/Configuration up-to-date on the device/i)).toBeInTheDocument(); }); }); diff --git a/frontend/src/js/components/devices/device-details/connection.js b/frontend/src/js/components/devices/device-details/connection.js index 312bc7e5..be262397 100644 --- a/frontend/src/js/components/devices/device-details/connection.js +++ b/frontend/src/js/components/devices/device-details/connection.js @@ -19,14 +19,21 @@ import { InfoOutlined as InfoIcon, Launch as LaunchIcon } from '@mui/icons-mater import { Button, Typography } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { setSnackbar } from '../../../actions/appActions'; -import { getDeviceFileDownloadLink } from '../../../actions/deviceActions'; -import { BEGINNING_OF_TIME, BENEFITS, TIMEOUTS } from '../../../constants/appConstants'; -import { ALL_DEVICES, DEVICE_CONNECT_STATES } from '../../../constants/deviceConstants'; -import { AUDIT_LOGS_TYPES } from '../../../constants/organizationConstants'; -import { checkPermissionsObject, uiPermissionsById } from '../../../constants/userConstants'; +import storeActions from '@northern.tech/store/actions'; +import { + ALL_DEVICES, + AUDIT_LOGS_TYPES, + BEGINNING_OF_TIME, + BENEFITS, + DEVICE_CONNECT_STATES, + TIMEOUTS, + checkPermissionsObject, + uiPermissionsById +} from '@northern.tech/store/constants'; +import { getCurrentSession, getTenantCapabilities, getUserCapabilities } from '@northern.tech/store/selectors'; +import { getDeviceFileDownloadLink } from '@northern.tech/store/thunks'; + import { createDownload } from '../../../helpers'; -import { getCurrentSession, getTenantCapabilities, getUserCapabilities } from '../../../selectors'; import { formatAuditlogs } from '../../../utils/locationutils'; import DocsLink from '../../common/docslink'; import EnterpriseNotification from '../../common/enterpriseNotification'; @@ -37,6 +44,8 @@ import FileTransfer from '../troubleshoot/filetransfer'; import TroubleshootContent from '../troubleshoot/terminal-wrapper'; import DeviceDataCollapse from './devicedatacollapse'; +const { setSnackbar } = storeActions; + const useStyles = makeStyles()(theme => ({ buttonStyle: { textTransform: 'none', textAlign: 'left' }, connectionIcon: { marginRight: theme.spacing() }, @@ -174,7 +183,7 @@ export const DeviceConnection = ({ className = '', device }) => { path => { setDownloadPath(path); dispatch(setSnackbar('Downloading file')); - dispatch(getDeviceFileDownloadLink(device.id, path)).then(address => { + dispatch(getDeviceFileDownloadLink({ deviceId: device.id, path })).then(address => { const filename = path.substring(path.lastIndexOf('/') + 1) || 'file'; createDownload(address, filename, token); }); diff --git a/frontend/src/js/components/devices/device-details/connection.test.js b/frontend/src/js/components/devices/device-details/connection.test.js index ed747a49..dbd00605 100644 --- a/frontend/src/js/components/devices/device-details/connection.test.js +++ b/frontend/src/js/components/devices/device-details/connection.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { DEVICE_CONNECT_STATES } from '@northern.tech/store/constants'; + import { defaultState, undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import { DEVICE_CONNECT_STATES } from '../../../constants/deviceConstants'; import DeviceConnection, { DeviceConnectionMissingNote, DeviceDisconnectedNote, PortForwardLink } from './connection'; describe('tiny DeviceConnection components', () => { diff --git a/frontend/src/js/components/devices/device-details/deployments.js b/frontend/src/js/components/devices/device-details/deployments.js index d6c7daba..ca1dc4e1 100644 --- a/frontend/src/js/components/devices/device-details/deployments.js +++ b/frontend/src/js/components/devices/device-details/deployments.js @@ -18,17 +18,17 @@ import { Link } from 'react-router-dom'; import { Button, Table, TableBody, TableCell, TableHead, TableRow, buttonClasses, tableCellClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { deploymentsApiUrl, getDeviceDeployments, resetDeviceDeployments } from '../../../actions/deploymentActions'; -import { getToken } from '../../../auth.js'; -import { deploymentStatesToSubstates } from '../../../constants/deploymentConstants'; -import { DEVICE_LIST_DEFAULTS } from '../../../constants/deviceConstants'; -import { createDownload } from '../../../helpers.js'; +import { getToken } from '@northern.tech/store/auth'; +import { DEVICE_LIST_DEFAULTS, deploymentStatesToSubstates, deploymentsApiUrl } from '@northern.tech/store/constants'; +import { getDeviceDeployments, resetDeviceDeployments } from '@northern.tech/store/thunks'; + +import { createDownload } from '../../../helpers'; import Confirm from '../../common/confirm'; import InfoHint from '../../common/info-hint'; import Pagination from '../../common/pagination'; import { MaybeTime } from '../../common/time'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; -import { DeviceStateSelection } from '../authorized-devices'; +import { DeviceStateSelection } from '../widgets/devicestateselection'; const useStyles = makeStyles()(theme => ({ deletion: { justifyContent: 'flex-end' }, @@ -156,7 +156,7 @@ export const Deployments = ({ device }) => { return; } const filterSelection = deploymentStates[filters[0]].values; - dispatch(getDeviceDeployments(device.id, { filterSelection, page, perPage })); + dispatch(getDeviceDeployments({ deviceId: device.id, filterSelection, page, perPage })); }, [device.id, dispatch, filters, page, perPage]); const onSelectStatus = status => setFilters([status]); diff --git a/frontend/src/js/components/devices/device-details/deployments.test.js b/frontend/src/js/components/devices/device-details/deployments.test.js index 96eb6858..4a4f0628 100644 --- a/frontend/src/js/components/devices/device-details/deployments.test.js +++ b/frontend/src/js/components/devices/device-details/deployments.test.js @@ -14,6 +14,7 @@ import React from 'react'; import { Provider } from 'react-redux'; +import * as DeploymentActions from '@northern.tech/store/deploymentsSlice/thunks'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import configureStore from 'redux-mock-store'; @@ -21,7 +22,6 @@ import { thunk } from 'redux-thunk'; import { defaultState, undefineds } from '../../../../../tests/mockData'; import { render, selectMaterialUiSelectOption } from '../../../../../tests/setupTests'; -import * as DeploymentActions from '../../../actions/deploymentActions'; import Deployments from './deployments'; const mockStore = configureStore([thunk]); @@ -59,6 +59,6 @@ describe('Deployments Component', () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); await selectMaterialUiSelectOption(screen.getByText(/any/i), /in progress/i, user); - expect(getDeploymentsSpy).toHaveBeenLastCalledWith('a1', { filterSelection: ['downloading', 'installing', 'rebooting'], page: 1, perPage: 10 }); + expect(getDeploymentsSpy).toHaveBeenLastCalledWith({ deviceId: 'a1', filterSelection: ['downloading', 'installing', 'rebooting'], page: 1, perPage: 10 }); }); }); diff --git a/frontend/src/js/components/devices/device-details/devicesystem.js b/frontend/src/js/components/devices/device-details/devicesystem.js index a625b892..a378090e 100644 --- a/frontend/src/js/components/devices/device-details/devicesystem.js +++ b/frontend/src/js/components/devices/device-details/devicesystem.js @@ -18,12 +18,12 @@ import { Link, useNavigate } from 'react-router-dom'; import { Button } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { setSnackbar } from '../../../actions/appActions'; -import { getSystemDevices } from '../../../actions/deviceActions'; -import { BENEFITS, SORTING_OPTIONS } from '../../../constants/appConstants'; -import { DEVICE_LIST_DEFAULTS } from '../../../constants/deviceConstants'; +import storeActions from '@northern.tech/store/actions'; +import { BENEFITS, DEVICE_LIST_DEFAULTS, SORTING_OPTIONS } from '@northern.tech/store/constants'; +import { getCurrentSession, getDevicesById, getIdAttribute, getIsPreview, getOrganization } from '@northern.tech/store/selectors'; +import { getSystemDevices } from '@northern.tech/store/thunks'; + import { getDemoDeviceAddress, toggle } from '../../../helpers'; -import { getCurrentSession, getDevicesById, getIdAttribute, getIsPreview, getOrganization } from '../../../selectors'; import { TwoColumnData } from '../../common/configurationobject'; import DocsLink from '../../common/docslink'; import EnterpriseNotification from '../../common/enterpriseNotification'; @@ -33,6 +33,8 @@ import Devicelist from '../devicelist'; import ConnectToGatewayDialog from '../dialogs/connecttogatewaydialog'; import DeviceDataCollapse from './devicedatacollapse'; +const { setSnackbar } = storeActions; + const useStyles = makeStyles()(theme => ({ container: { maxWidth: 600, marginTop: theme.spacing(), marginBottom: theme.spacing() } })); export const DeviceSystem = ({ columnSelection, device, onConnectToGatewayClick, openSettingsDialog }) => { @@ -68,7 +70,7 @@ export const DeviceSystem = ({ columnSelection, device, onConnectToGatewayClick, useEffect(() => { if (device.attributes) { - dispatch(getSystemDevices(device.id, { page, perPage, sortOptions })); + dispatch(getSystemDevices({ id: device.id, page, perPage, sortOptions })); } // eslint-disable-next-line react-hooks/exhaustive-deps }, [dispatch, device.id, device.attributes?.mender_is_gateway, page, perPage, sortOptions]); diff --git a/frontend/src/js/components/devices/device-details/devicetags.js b/frontend/src/js/components/devices/device-details/devicetags.js index 1dcfdef2..1badb346 100644 --- a/frontend/src/js/components/devices/device-details/devicetags.js +++ b/frontend/src/js/components/devices/device-details/devicetags.js @@ -17,7 +17,8 @@ import { useDispatch } from 'react-redux'; import { Button } from '@mui/material'; import { useTheme } from '@mui/material/styles'; -import { getDeviceAttributes, setDeviceTags } from '../../../actions/deviceActions'; +import { getDeviceAttributes, setDeviceTags } from '@northern.tech/store/thunks'; + import { toggle } from '../../../helpers'; import Tracking from '../../../tracking'; import ConfigurationObject from '../../common/configurationobject'; @@ -70,7 +71,7 @@ export const DeviceTags = ({ device, setSnackbar, userCapabilities }) => { const onSubmit = () => { Tracking.event({ category: 'devices', action: 'modify_tags' }); setIsEditDisabled(true); - return dispatch(setDeviceTags(device.id, changedTags)) + return dispatch(setDeviceTags({ deviceId: device.id, tags: changedTags })) .then(() => { dispatch(getDeviceAttributes()); setIsEditing(false); diff --git a/frontend/src/js/components/devices/device-details/devicetwin.js b/frontend/src/js/components/devices/device-details/devicetwin.js index 58c1da8c..084c14e3 100644 --- a/frontend/src/js/components/devices/device-details/devicetwin.js +++ b/frontend/src/js/components/devices/device-details/devicetwin.js @@ -20,11 +20,10 @@ import { Button } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; import Editor, { DiffEditor, loader } from '@monaco-editor/react'; +import { EXTERNAL_PROVIDER, TIMEOUTS } from '@northern.tech/store/constants'; +import { getDeviceTwin, setDeviceTwin } from '@northern.tech/store/thunks'; import pluralize from 'pluralize'; -import { getDeviceTwin, setDeviceTwin } from '../../../actions/deviceActions'; -import { TIMEOUTS } from '../../../constants/appConstants'; -import { EXTERNAL_PROVIDER } from '../../../constants/deviceConstants'; import { deepCompare, isEmpty } from '../../../helpers'; import InfoHint from '../../common/info-hint'; import Loader from '../../common/loader'; @@ -207,7 +206,7 @@ export const DeviceTwin = ({ device, integration }) => { editorRef.current.modifiedEditor.getAction('editor.action.formatDocument').run(); setUpdated(stringifyTwin(update)); setErrorMessage(''); - dispatch(setDeviceTwin(device.id, integration, update)).then(() => setIsEditing(false)); + dispatch(setDeviceTwin({ deviceId: device.id, integration, settings: update })).then(() => setIsEditing(false)); }; const onCancelClick = () => { @@ -219,7 +218,7 @@ export const DeviceTwin = ({ device, integration }) => { const onRefreshClick = () => { setIsRefreshing(true); - dispatch(getDeviceTwin(device.id, integration)).finally(() => setTimeout(() => setIsRefreshing(false), TIMEOUTS.halfASecond)); + dispatch(getDeviceTwin({ deviceId: device.id, integration })).finally(() => setTimeout(() => setIsRefreshing(false), TIMEOUTS.halfASecond)); }; const onEditClick = () => setIsEditing(true); diff --git a/frontend/src/js/components/devices/device-details/identity.js b/frontend/src/js/components/devices/device-details/identity.js index c3ee6a07..036d7929 100644 --- a/frontend/src/js/components/devices/device-details/identity.js +++ b/frontend/src/js/components/devices/device-details/identity.js @@ -13,7 +13,8 @@ // limitations under the License. import React from 'react'; -import { DEVICE_STATES } from '../../../constants/deviceConstants'; +import { DEVICE_STATES } from '@northern.tech/store/constants'; + import { TwoColumnData } from '../../common/configurationobject'; import DeviceNameInput from '../../common/devicenameinput'; import Time from '../../common/time'; diff --git a/frontend/src/js/components/devices/device-details/installedsoftware.js b/frontend/src/js/components/devices/device-details/installedsoftware.js index f5e46a99..b5334c6f 100644 --- a/frontend/src/js/components/devices/device-details/installedsoftware.js +++ b/frontend/src/js/components/devices/device-details/installedsoftware.js @@ -16,7 +16,8 @@ import React from 'react'; import { deepmerge } from '@mui/utils'; import { makeStyles } from 'tss-react/mui'; -import { rootfsImageVersion, softwareTitleMap } from '../../../constants/releaseConstants'; +import { rootfsImageVersion, softwareTitleMap } from '@northern.tech/store/constants'; + import { extractSoftware, isEmpty } from '../../../helpers'; import { TwoColumnData } from '../../common/configurationobject'; import DeviceDataCollapse from './devicedatacollapse'; diff --git a/frontend/src/js/components/devices/device-details/monitoring.js b/frontend/src/js/components/devices/device-details/monitoring.js index 349ea0ad..ac4dfba5 100644 --- a/frontend/src/js/components/devices/device-details/monitoring.js +++ b/frontend/src/js/components/devices/device-details/monitoring.js @@ -16,10 +16,11 @@ import { useDispatch, useSelector } from 'react-redux'; import { useTheme } from '@mui/material/styles'; -import { getDeviceAlerts, setAlertListState } from '../../../actions/monitorActions'; -import { BENEFITS } from '../../../constants/appConstants'; -import { DEVICE_LIST_DEFAULTS } from '../../../constants/deviceConstants'; -import { getOfflineThresholdSettings, getTenantCapabilities } from '../../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { BENEFITS, DEVICE_LIST_DEFAULTS } from '@northern.tech/store/constants'; +import { getOfflineThresholdSettings, getTenantCapabilities } from '@northern.tech/store/selectors'; +import { getDeviceAlerts } from '@northern.tech/store/thunks'; + import DocsLink from '../../common/docslink'; import EnterpriseNotification from '../../common/enterpriseNotification'; import Pagination from '../../common/pagination'; @@ -29,6 +30,8 @@ import { DeviceConnectionNote } from './connection'; import DeviceDataCollapse from './devicedatacollapse'; import { DeviceOfflineHeaderNotification, NoAlertsHeaderNotification, monitoringSeverities, severityMap } from './notifications'; +const { setAlertListState } = storeActions; + const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS; export const DeviceMonitorsMissingNote = () => ( @@ -67,10 +70,10 @@ export const DeviceMonitoring = ({ device, onDetailsClick }) => { useEffect(() => { if (hasMonitor) { - dispatch(getDeviceAlerts(device.id, alertListState)); + dispatch(getDeviceAlerts({ id: device.id, config: alertListState })); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, [device.id, dispatch, pageNo, pageLength]); + }, [device.id, dispatch, hasMonitor, pageNo, pageLength]); const onChangePage = page => dispatch(setAlertListState({ page })); diff --git a/frontend/src/js/components/devices/device-groups.js b/frontend/src/js/components/devices/device-groups.js index e64b0cee..9a3e6be8 100644 --- a/frontend/src/js/components/devices/device-groups.js +++ b/frontend/src/js/components/devices/device-groups.js @@ -18,25 +18,8 @@ import { useLocation, useParams } from 'react-router-dom'; import { AddCircle as AddIcon } from '@mui/icons-material'; import { Dialog, DialogContent, DialogTitle } from '@mui/material'; -import pluralize from 'pluralize'; - -import { setOfflineThreshold } from '../../actions/appActions'; -import { - addDynamicGroup, - addStaticGroup, - removeDevicesFromGroup, - removeDynamicGroup, - removeStaticGroup, - selectGroup, - setDeviceFilters, - setDeviceListState, - updateDynamicGroup -} from '../../actions/deviceActions'; -import { setShowConnectingDialog } from '../../actions/userActions'; -import { SORTING_OPTIONS } from '../../constants/appConstants'; -import { DEVICE_FILTERING_OPTIONS, DEVICE_ISSUE_OPTIONS, DEVICE_STATES, emptyFilter } from '../../constants/deviceConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; -import { toggle } from '../../helpers'; +import storeActions from '@northern.tech/store/actions'; +import { DEVICE_FILTERING_OPTIONS, DEVICE_ISSUE_OPTIONS, DEVICE_STATES, SORTING_OPTIONS, emptyFilter, onboardingSteps } from '@northern.tech/store/constants'; import { getAcceptedDevices, getDeviceCountsByStatus, @@ -52,7 +35,21 @@ import { getSortedFilteringAttributes, getTenantCapabilities, getUserCapabilities -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { + addDynamicGroup, + addStaticGroup, + removeDevicesFromGroup, + removeDynamicGroup, + removeStaticGroup, + selectGroup, + setDeviceListState, + setOfflineThreshold, + updateDynamicGroup +} from '@northern.tech/store/thunks'; +import pluralize from 'pluralize'; + +import { toggle } from '../../helpers'; import { useLocationParams } from '../../utils/liststatehook'; import { getOnboardingComponentFor } from '../../utils/onboardingmanager'; import Global from '../settings/global'; @@ -66,6 +63,8 @@ import RemoveGroup from './group-management/remove-group'; import Groups from './groups'; import DeviceAdditionWidget from './widgets/deviceadditionwidget'; +const { setDeviceFilters, setShowConnectingDialog } = storeActions; + export const DeviceGroups = () => { const [createGroupExplanation, setCreateGroupExplanation] = useState(false); const [fromFilters, setFromFilters] = useState(false); @@ -139,7 +138,7 @@ export const DeviceGroups = () => { const { hasFullFiltering } = tenantCapabilities; if (groupName) { if (groupName != selectedGroup) { - dispatch(selectGroup(groupName, filters)); + dispatch(selectGroup({ group: groupName, filters })); } } else if (filters.length) { // dispatch setDeviceFilters even when filters are empty, otherwise filter will not be reset @@ -161,7 +160,7 @@ export const DeviceGroups = () => { return; } isInitialized.current = true; - dispatch(setDeviceListState({}, true, true)); + dispatch(setDeviceListState({ shouldSelectDevices: true, forceRefresh: true })); dispatch(setOfflineThreshold()); }); // eslint-disable-next-line react-hooks/exhaustive-deps @@ -183,7 +182,7 @@ export const DeviceGroups = () => { }; const createGroupFromDialog = (devices, group) => { - let request = fromFilters ? dispatch(addDynamicGroup(group, filters)) : dispatch(addStaticGroup(group, devices)); + let request = fromFilters ? dispatch(addDynamicGroup({ groupName: group, filterPredicates: filters })) : dispatch(addStaticGroup({ group, devices })); return request.then(() => { // reached end of list setCreateGroupExplanation(false); @@ -194,7 +193,7 @@ export const DeviceGroups = () => { const onGroupClick = () => { if (selectedGroup && groupFilters.length) { - return dispatch(updateDynamicGroup(selectedGroup, filters)); + return dispatch(updateDynamicGroup({ groupName: selectedGroup, filterPredicates: filters })); } setModifyGroupDialog(true); setFromFilters(true); @@ -206,7 +205,7 @@ export const DeviceGroups = () => { if (isGroupRemoval) { request = dispatch(removeStaticGroup(selectedGroup)); } else { - request = dispatch(removeDevicesFromGroup(selectedGroup, devices)); + request = dispatch(removeDevicesFromGroup({ group: selectedGroup, deviceIds: devices })); } return request.catch(console.log); }; @@ -233,7 +232,7 @@ export const DeviceGroups = () => { }; const onGroupSelect = groupName => { - dispatch(selectGroup(groupName)); + dispatch(selectGroup({ group: groupName })); dispatch(setDeviceListState({ page: 1, refreshTrigger: !refreshTrigger, selection: [] })); }; diff --git a/frontend/src/js/components/devices/device-status.js b/frontend/src/js/components/devices/device-status.js index b34aeed0..3b0df29e 100644 --- a/frontend/src/js/components/devices/device-status.js +++ b/frontend/src/js/components/devices/device-status.js @@ -17,10 +17,9 @@ import { Error as ErrorIcon, ReportProblemOutlined } from '@mui/icons-material'; import { Box, Chip, Tooltip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { DEVICE_STATES } from '@northern.tech/store/constants'; import pluralize from 'pluralize'; -import { DEVICE_STATES } from '../../constants/deviceConstants'; - const statusTypes = { default: { severity: 'none', notification: {} }, authRequests: { diff --git a/frontend/src/js/components/devices/devicelist.js b/frontend/src/js/components/devices/devicelist.js index fc9ddee0..97a02d06 100644 --- a/frontend/src/js/components/devices/devicelist.js +++ b/frontend/src/js/components/devices/devicelist.js @@ -18,9 +18,10 @@ import { Settings as SettingsIcon, Sort as SortIcon } from '@mui/icons-material' import { Checkbox } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { SORTING_OPTIONS, TIMEOUTS } from '../../constants/appConstants'; -import { DEVICE_LIST_DEFAULTS } from '../../constants/deviceConstants'; -import { deepCompare, isDarkMode, toggle } from '../../helpers'; +import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS, TIMEOUTS } from '@northern.tech/store/constants'; +import { isDarkMode } from '@northern.tech/store/utils'; + +import { deepCompare, toggle } from '../../helpers'; import useWindowSize from '../../utils/resizehook'; import Loader from '../common/loader'; import MenderTooltip from '../common/mendertooltip'; diff --git a/frontend/src/js/components/devices/devicelistitem.js b/frontend/src/js/components/devices/devicelistitem.js index c0a05f2b..c9ab703d 100644 --- a/frontend/src/js/components/devices/devicelistitem.js +++ b/frontend/src/js/components/devices/devicelistitem.js @@ -17,7 +17,8 @@ import React, { memo, useCallback, useState } from 'react'; import { Checkbox } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { DEVICE_STATES } from '../../constants/deviceConstants'; +import { DEVICE_STATES } from '@northern.tech/store/constants'; + import { deepCompare } from '../../helpers'; import DeviceIdentityDisplay from '../common/deviceidentity'; import { DefaultAttributeRenderer } from './base-devices'; diff --git a/frontend/src/js/components/devices/devicestatusnotification.js b/frontend/src/js/components/devices/devicestatusnotification.js index b1efca59..508cc197 100644 --- a/frontend/src/js/components/devices/devicestatusnotification.js +++ b/frontend/src/js/components/devices/devicestatusnotification.js @@ -15,9 +15,9 @@ import React from 'react'; import { makeStyles } from 'tss-react/mui'; +import { DEVICE_STATES } from '@northern.tech/store/constants'; import pluralize from 'pluralize'; -import { DEVICE_STATES } from '../../constants/deviceConstants'; import InfoText from '../common/infotext'; const useStyles = makeStyles()(theme => ({ diff --git a/frontend/src/js/components/devices/devicestatusnotification.test.js b/frontend/src/js/components/devices/devicestatusnotification.test.js index 375ebad4..43ed0990 100644 --- a/frontend/src/js/components/devices/devicestatusnotification.test.js +++ b/frontend/src/js/components/devices/devicestatusnotification.test.js @@ -13,12 +13,12 @@ // limitations under the License. import React from 'react'; +import { DEVICE_STATES } from '@northern.tech/store/constants'; import { screen } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { DEVICE_STATES } from '../../constants/deviceConstants'; import DeviceStatusNotification from './devicestatusnotification'; describe('DeviceStatusNotification Component', () => { diff --git a/frontend/src/js/components/devices/dialogs/custom-columns-dialog-content.js b/frontend/src/js/components/devices/dialogs/custom-columns-dialog-content.js index 486d7ea0..9e802829 100644 --- a/frontend/src/js/components/devices/dialogs/custom-columns-dialog-content.js +++ b/frontend/src/js/components/devices/dialogs/custom-columns-dialog-content.js @@ -18,7 +18,8 @@ import { DragDropContext, Draggable, Droppable } from 'react-beautiful-dnd'; import { Clear as ClearIcon, DragHandle as DragHandleIcon } from '@mui/icons-material'; import { DialogContent, FormControl, IconButton, ListItem } from '@mui/material'; -import { ATTRIBUTE_SCOPES } from '../../../constants/deviceConstants'; +import { ATTRIBUTE_SCOPES } from '@northern.tech/store/constants'; + import AttributeAutoComplete, { getOptionLabel } from '../widgets/attribute-autocomplete'; const DraggableListItem = ({ item, index, onRemove }) => { diff --git a/frontend/src/js/components/devices/dialogs/make-gateway-dialog.js b/frontend/src/js/components/devices/dialogs/make-gateway-dialog.js index 2b99739b..4fc7249f 100644 --- a/frontend/src/js/components/devices/dialogs/make-gateway-dialog.js +++ b/frontend/src/js/components/devices/dialogs/make-gateway-dialog.js @@ -16,7 +16,8 @@ import React from 'react'; // material ui import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; -import { getToken } from '../../../auth'; +import { getToken } from '@northern.tech/store/auth'; + import CopyCode from '../../common/copy-code'; import DocsLink from '../../common/docslink'; diff --git a/frontend/src/js/components/devices/dialogs/monitordetailsdialog.js b/frontend/src/js/components/devices/dialogs/monitordetailsdialog.js index bf29636a..87758d09 100644 --- a/frontend/src/js/components/devices/dialogs/monitordetailsdialog.js +++ b/frontend/src/js/components/devices/dialogs/monitordetailsdialog.js @@ -34,7 +34,8 @@ import { styled } from '@mui/material'; -import { TIMEOUTS } from '../../../constants/appConstants'; +import { TIMEOUTS } from '@northern.tech/store/constants'; + import { toggle } from '../../../helpers'; const CopyButton = ({ text, onCopy }) => ( diff --git a/frontend/src/js/components/devices/dialogs/preauth-dialog.js b/frontend/src/js/components/devices/dialogs/preauth-dialog.js index 35a4609c..150bc809 100644 --- a/frontend/src/js/components/devices/dialogs/preauth-dialog.js +++ b/frontend/src/js/components/devices/dialogs/preauth-dialog.js @@ -18,7 +18,8 @@ import { useDispatch } from 'react-redux'; import { InfoOutlined as InfoIcon } from '@mui/icons-material'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; -import { preauthDevice } from '../../../actions/deviceActions'; +import { preauthDevice } from '@northern.tech/store/thunks'; + import { isEmpty } from '../../../helpers'; import FileUpload from '../../common/forms/fileupload'; import KeyValueEditor from '../../common/forms/keyvalueeditor'; diff --git a/frontend/src/js/components/devices/dialogs/preauth-dialog.test.js b/frontend/src/js/components/devices/dialogs/preauth-dialog.test.js index ce245e58..bfde0585 100644 --- a/frontend/src/js/components/devices/dialogs/preauth-dialog.test.js +++ b/frontend/src/js/components/devices/dialogs/preauth-dialog.test.js @@ -14,6 +14,7 @@ import React from 'react'; import { Provider } from 'react-redux'; +import * as DeviceActions from '@northern.tech/store/devicesSlice/thunks'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import configureStore from 'redux-mock-store'; @@ -21,7 +22,6 @@ import { thunk } from 'redux-thunk'; import { defaultState, undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import * as DeviceActions from '../../../actions/deviceActions'; import PreauthDialog from './preauth-dialog'; const mockStore = configureStore([thunk]); diff --git a/frontend/src/js/components/devices/expanded-device.js b/frontend/src/js/components/devices/expanded-device.js index 4f76d72e..6e5f0f68 100644 --- a/frontend/src/js/components/devices/expanded-device.js +++ b/frontend/src/js/components/devices/expanded-device.js @@ -19,16 +19,8 @@ import { Close as CloseIcon, Link as LinkIcon } from '@mui/icons-material'; import { Chip, Divider, Drawer, IconButton, Tab, Tabs, Tooltip, chipClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import copy from 'copy-to-clipboard'; - -import GatewayConnectionIcon from '../../../assets/img/gateway-connection.svg'; -import GatewayIcon from '../../../assets/img/gateway.svg'; -import { setSnackbar } from '../../actions/appActions'; -import { decommissionDevice, getDeviceInfo, getGatewayDevices } from '../../actions/deviceActions'; -import { saveGlobalSettings } from '../../actions/userActions'; -import { TIMEOUTS, yes } from '../../constants/appConstants'; -import { DEVICE_STATES, EXTERNAL_PROVIDER } from '../../constants/deviceConstants'; -import { getDemoDeviceAddress, stringToBoolean } from '../../helpers'; +import storeActions from '@northern.tech/store/actions'; +import { DEVICE_STATES, EXTERNAL_PROVIDER, TIMEOUTS, yes } from '@northern.tech/store/constants'; import { getDeviceConfigDeployment, getDeviceTwinIntegrations, @@ -39,7 +31,13 @@ import { getTenantCapabilities, getUserCapabilities, getUserSettings -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { decommissionDevice, getDeviceInfo, getGatewayDevices, saveGlobalSettings } from '@northern.tech/store/thunks'; +import copy from 'copy-to-clipboard'; + +import GatewayConnectionIcon from '../../../assets/img/gateway-connection.svg'; +import GatewayIcon from '../../../assets/img/gateway.svg'; +import { getDemoDeviceAddress, stringToBoolean } from '../../helpers'; import DeviceIdentityDisplay from '../common/deviceidentity'; import DocsLink from '../common/docslink'; import { MenderTooltipClickable } from '../common/mendertooltip'; @@ -56,6 +54,8 @@ import MonitoringTab from './device-details/monitoring'; import DeviceNotifications from './device-details/notifications'; import DeviceQuickActions from './widgets/devicequickactions'; +const { setSnackbar } = storeActions; + const useStyles = makeStyles()(theme => ({ gatewayChip: { backgroundColor: theme.palette.grey[400], @@ -226,7 +226,7 @@ export const ExpandedDevice = ({ actionCallbacks, deviceId, onClose, setDetailsT }, [device.id, dispatch, mender_gateway_system_id]); // close expanded device - const onDecommissionDevice = device_id => dispatch(decommissionDevice(device_id)).finally(onClose); + const onDecommissionDevice = deviceId => dispatch(decommissionDevice({ deviceId })).finally(onClose); const copyLinkToClipboard = () => { const location = window.location.href.substring(0, window.location.href.indexOf('/devices') + '/devices'.length); diff --git a/frontend/src/js/components/devices/expanded-device.test.js b/frontend/src/js/components/devices/expanded-device.test.js index 8d86cfd6..c9e62cfb 100644 --- a/frontend/src/js/components/devices/expanded-device.test.js +++ b/frontend/src/js/components/devices/expanded-device.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { EXTERNAL_PROVIDER } from '@northern.tech/store/constants'; + import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { EXTERNAL_PROVIDER } from '../../constants/deviceConstants'; import ExpandedDevice from './expanded-device'; const preloadedState = { diff --git a/frontend/src/js/components/devices/group-management/create-group-explainer-content.js b/frontend/src/js/components/devices/group-management/create-group-explainer-content.js index 93be21b3..2e996dcb 100644 --- a/frontend/src/js/components/devices/group-management/create-group-explainer-content.js +++ b/frontend/src/js/components/devices/group-management/create-group-explainer-content.js @@ -16,9 +16,10 @@ import React from 'react'; import { Autorenew, LockOutlined } from '@mui/icons-material'; import { makeStyles } from 'tss-react/mui'; +import { BENEFITS } from '@northern.tech/store/constants'; + import dynamicImage from '../../../../assets/img/dynamic-group-creation.gif'; import staticImage from '../../../../assets/img/static-group-creation.gif'; -import { BENEFITS } from '../../../constants/appConstants'; import { DOCSTIPS, DocsTooltip } from '../../common/docslink'; import EnterpriseNotification from '../../common/enterpriseNotification'; import { InfoHintContainer } from '../../common/info-hint'; diff --git a/frontend/src/js/components/devices/group-management/create-group.js b/frontend/src/js/components/devices/group-management/create-group.js index 8d5bbf42..e180e7e5 100644 --- a/frontend/src/js/components/devices/group-management/create-group.js +++ b/frontend/src/js/components/devices/group-management/create-group.js @@ -16,7 +16,8 @@ import { useSelector } from 'react-redux'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; -import { getGroups, getSelectedGroupInfo } from '../../../selectors'; +import { getGroups, getSelectedGroupInfo } from '@northern.tech/store/selectors'; + import GroupDefinition from './group-definition'; export const CreateGroup = ({ addListOfDevices, fromFilters, isCreation, onClose, selectedDevices }) => { diff --git a/frontend/src/js/components/devices/group-management/group-definition.js b/frontend/src/js/components/devices/group-management/group-definition.js index 3fbd2431..3563ac9f 100644 --- a/frontend/src/js/components/devices/group-management/group-definition.js +++ b/frontend/src/js/components/devices/group-management/group-definition.js @@ -16,9 +16,9 @@ import React, { useState } from 'react'; import { Autocomplete, FormHelperText, TextField } from '@mui/material'; import { createFilterOptions } from '@mui/material/useAutocomplete'; +import { UNGROUPED_GROUP } from '@northern.tech/store/constants'; import validator from 'validator'; -import { UNGROUPED_GROUP } from '../../../constants/deviceConstants'; import { fullyDecodeURI } from '../../../helpers'; import DocsLink from '../../common/docslink'; import InfoText from '../../common/infotext'; diff --git a/frontend/src/js/components/devices/groups.js b/frontend/src/js/components/devices/groups.js index 003ce06c..efd96122 100644 --- a/frontend/src/js/components/devices/groups.js +++ b/frontend/src/js/components/devices/groups.js @@ -18,7 +18,8 @@ import { InfoOutlined as InfoIcon } from '@mui/icons-material'; import { List, ListItemButton, ListItemIcon, ListItemText, ListSubheader } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { ALL_DEVICES } from '../../constants/deviceConstants'; +import { ALL_DEVICES } from '@northern.tech/store/constants'; + import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips'; const useStyles = makeStyles()(theme => ({ diff --git a/frontend/src/js/components/devices/groups.test.js b/frontend/src/js/components/devices/groups.test.js index 7913743a..3cdd65a1 100644 --- a/frontend/src/js/components/devices/groups.test.js +++ b/frontend/src/js/components/devices/groups.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { getGroups } from '@northern.tech/store/selectors'; + import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { getGroups } from '../../selectors'; import Groups, { GroupItem, GroupsSubheader } from './groups'; describe('Groups Component', () => { diff --git a/frontend/src/js/components/devices/troubleshoot/filetransfer.js b/frontend/src/js/components/devices/troubleshoot/filetransfer.js index 831553e0..6f9eb85b 100644 --- a/frontend/src/js/components/devices/troubleshoot/filetransfer.js +++ b/frontend/src/js/components/devices/troubleshoot/filetransfer.js @@ -18,8 +18,9 @@ import { FileCopy as CopyPasteIcon } from '@mui/icons-material'; import { Button, Divider, IconButton, InputAdornment, Tab, Tabs, TextField, Tooltip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { deviceFileUpload } from '../../../actions/deviceActions'; -import { canAccess } from '../../../constants/appConstants'; +import { canAccess } from '@northern.tech/store/constants'; +import { deviceFileUpload } from '@northern.tech/store/thunks'; + import FileUpload from '../../common/forms/fileupload'; const tabs = [ @@ -98,7 +99,7 @@ export const FileTransfer = ({ setFile(selectedFile); }; - const onUploadClick = useCallback(() => dispatch(deviceFileUpload(deviceId, uploadPath, file)), [dispatch, deviceId, uploadPath, file]); + const onUploadClick = useCallback(() => dispatch(deviceFileUpload({ deviceId, path: uploadPath, file })), [dispatch, deviceId, uploadPath, file]); const fileInputProps = { error: !isValidDestination, diff --git a/frontend/src/js/components/devices/troubleshoot/terminal-wrapper.js b/frontend/src/js/components/devices/troubleshoot/terminal-wrapper.js index 90d13910..d4d187b4 100644 --- a/frontend/src/js/components/devices/troubleshoot/terminal-wrapper.js +++ b/frontend/src/js/components/devices/troubleshoot/terminal-wrapper.js @@ -19,11 +19,11 @@ import { Link } from 'react-router-dom'; import { Button } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { BEGINNING_OF_TIME, TIMEOUTS } from '@northern.tech/store/constants'; +import { getCurrentSession, getFeatures, getIsPreview, getTenantCapabilities, getUserCapabilities } from '@northern.tech/store/selectors'; import dayjs from 'dayjs'; import durationDayJs from 'dayjs/plugin/duration'; -import { BEGINNING_OF_TIME, TIMEOUTS } from '../../../constants/appConstants'; -import { getCurrentSession, getFeatures, getIsPreview, getTenantCapabilities, getUserCapabilities } from '../../../selectors'; import Tracking from '../../../tracking'; import { useSession } from '../../../utils/sockethook'; import { MaybeTime } from '../../common/time'; diff --git a/frontend/src/js/components/devices/troubleshoot/terminal.js b/frontend/src/js/components/devices/troubleshoot/terminal.js index 3bfd7ef7..368fe943 100644 --- a/frontend/src/js/components/devices/troubleshoot/terminal.js +++ b/frontend/src/js/components/devices/troubleshoot/terminal.js @@ -13,9 +13,9 @@ // limitations under the License. import React, { useCallback, useEffect, useMemo, useState } from 'react'; +import { DEVICE_MESSAGE_TYPES as MessageTypes } from '@northern.tech/store/constants'; import { WebLinksAddon } from '@xterm/addon-web-links'; -import { DEVICE_MESSAGE_TYPES as MessageTypes } from '../../../constants/deviceConstants'; import { toggle } from '../../../helpers'; import useWindowSize from '../../../utils/resizehook'; import XTerm from '../../common/xterm'; diff --git a/frontend/src/js/components/devices/widgets/attribute-autocomplete.js b/frontend/src/js/components/devices/widgets/attribute-autocomplete.js index 48d2bcad..0adc8548 100644 --- a/frontend/src/js/components/devices/widgets/attribute-autocomplete.js +++ b/frontend/src/js/components/devices/widgets/attribute-autocomplete.js @@ -16,8 +16,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; // material ui import { Autocomplete, TextField, createFilterOptions } from '@mui/material'; -import { TIMEOUTS } from '../../../constants/appConstants'; -import { emptyFilter } from '../../../constants/deviceConstants'; +import { TIMEOUTS, emptyFilter } from '@northern.tech/store/constants'; + import { defaultHeaders } from '../base-devices'; import { getFilterLabelByKey } from './filters'; diff --git a/frontend/src/js/components/devices/widgets/deviceadditionwidget.js b/frontend/src/js/components/devices/widgets/deviceadditionwidget.js index dcf7a4b4..5359373a 100644 --- a/frontend/src/js/components/devices/widgets/deviceadditionwidget.js +++ b/frontend/src/js/components/devices/widgets/deviceadditionwidget.js @@ -17,7 +17,8 @@ import { ArrowDropDown as ArrowDropDownIcon, Launch as LaunchIcon } from '@mui/i import { Button, ButtonGroup, Menu, MenuItem } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { canAccess } from '../../../constants/appConstants'; +import { canAccess } from '@northern.tech/store/constants'; + import DocsLink from '../../common/docslink'; const useStyles = makeStyles()(() => ({ diff --git a/frontend/src/js/components/devices/widgets/devicequickactions.js b/frontend/src/js/components/devices/widgets/devicequickactions.js index f00766c1..b26d2ad8 100644 --- a/frontend/src/js/components/devices/widgets/devicequickactions.js +++ b/frontend/src/js/components/devices/widgets/devicequickactions.js @@ -27,14 +27,19 @@ import { speedDialActionClasses } from '@mui/material/SpeedDialAction'; import { makeStyles } from 'tss-react/mui'; import { mdiTrashCanOutline as TrashCan } from '@mdi/js'; +import { DEVICE_STATES, TIMEOUTS, UNGROUPED_GROUP, onboardingSteps } from '@northern.tech/store/constants'; +import { + getDeviceById, + getFeatures, + getMappedDevicesList, + getOnboardingState, + getTenantCapabilities, + getUserCapabilities +} from '@northern.tech/store/selectors'; import pluralize from 'pluralize'; import GatewayIcon from '../../../../assets/img/gateway.svg'; -import { TIMEOUTS } from '../../../constants/appConstants'; -import { DEVICE_STATES, UNGROUPED_GROUP } from '../../../constants/deviceConstants'; -import { onboardingSteps } from '../../../constants/onboardingConstants'; import { stringToBoolean, toggle } from '../../../helpers'; -import { getDeviceById, getFeatures, getMappedDevicesList, getOnboardingState, getTenantCapabilities, getUserCapabilities } from '../../../selectors'; import { getOnboardingComponentFor } from '../../../utils/onboardingmanager'; import MaterialDesignIcon from '../../common/materialdesignicon'; diff --git a/frontend/src/js/components/devices/widgets/devicestateselection.js b/frontend/src/js/components/devices/widgets/devicestateselection.js new file mode 100644 index 00000000..f897c5cb --- /dev/null +++ b/frontend/src/js/components/devices/widgets/devicestateselection.js @@ -0,0 +1,49 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import React, { useMemo } from 'react'; + +// material ui +import { MenuItem, Select } from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; + +import { duplicateFilter } from '../../../helpers'; + +const useStyles = makeStyles()(theme => ({ + selection: { + fontSize: 13, + marginLeft: theme.spacing(0.5), + marginTop: 2, + '>div': { + paddingLeft: theme.spacing(0.5) + } + } +})); + +export const DeviceStateSelection = ({ onStateChange, selectedState = '', states }) => { + const { classes } = useStyles(); + const availableStates = useMemo(() => Object.values(states).filter(duplicateFilter), [states]); + + return ( +
+ Status: + +
+ ); +}; diff --git a/frontend/src/js/components/devices/widgets/filteritem.js b/frontend/src/js/components/devices/widgets/filteritem.js index fccfb5b3..aa910427 100644 --- a/frontend/src/js/components/devices/widgets/filteritem.js +++ b/frontend/src/js/components/devices/widgets/filteritem.js @@ -17,8 +17,8 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import { HighlightOff as HighlightOffIcon } from '@mui/icons-material'; import { FormHelperText, IconButton, MenuItem, Select, TextField } from '@mui/material'; -import { TIMEOUTS } from '../../../constants/appConstants'; -import { DEVICE_FILTERING_OPTIONS, emptyFilter } from '../../../constants/deviceConstants'; +import { DEVICE_FILTERING_OPTIONS, TIMEOUTS, emptyFilter } from '@northern.tech/store/constants'; + import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; import AttributeAutoComplete from './attribute-autocomplete'; diff --git a/frontend/src/js/components/devices/widgets/filters.js b/frontend/src/js/components/devices/widgets/filters.js index eb55f09d..5df86a1e 100644 --- a/frontend/src/js/components/devices/widgets/filters.js +++ b/frontend/src/js/components/devices/widgets/filters.js @@ -18,11 +18,8 @@ import { Add as AddIcon } from '@mui/icons-material'; // material ui import { Button, Chip, Collapse } from '@mui/material'; -import { getDeviceAttributes, setDeviceFilters, setDeviceListState } from '../../../actions/deviceActions'; -import { saveGlobalSettings } from '../../../actions/userActions'; -import { BENEFITS } from '../../../constants/appConstants'; -import { DEVICE_FILTERING_OPTIONS, emptyFilter } from '../../../constants/deviceConstants'; -import { deepCompare, toggle } from '../../../helpers'; +import storeActions from '@northern.tech/store/actions'; +import { BENEFITS, DEVICE_FILTERING_OPTIONS, emptyFilter } from '@northern.tech/store/constants'; import { getDeviceFilters, getFilterAttributes, @@ -31,12 +28,18 @@ import { getSelectedGroupInfo, getTenantCapabilities, getUserCapabilities -} from '../../../selectors'; +} from '@northern.tech/store/selectors'; +import { getDeviceAttributes, saveGlobalSettings, setDeviceListState } from '@northern.tech/store/thunks'; +import { filtersFilter } from '@northern.tech/store/utils'; + +import { deepCompare, toggle } from '../../../helpers'; import EnterpriseNotification from '../../common/enterpriseNotification'; import { InfoHintContainer } from '../../common/info-hint'; import MenderTooltip from '../../common/mendertooltip'; import FilterItem from './filteritem'; +const { setDeviceFilters } = storeActions; + export const getFilterLabelByKey = (key, attributes) => { const attr = attributes.find(attr => attr.key === key); return attr?.value ?? key ?? ''; @@ -44,13 +47,6 @@ export const getFilterLabelByKey = (key, attributes) => { const MAX_PREVIOUS_FILTERS_COUNT = 3; -const filterCompare = (filter, item) => Object.keys(emptyFilter).every(key => item[key].toString() === filter[key].toString()); - -export const filtersFilter = (item, index, array) => { - const firstIndex = array.findIndex(filter => filterCompare(filter, item)); - return firstIndex === index; -}; - export const Filters = ({ className = '', onGroupClick, open }) => { const [reset, setReset] = useState(false); const [newFilter, setNewFilter] = useState(emptyFilter); @@ -84,7 +80,7 @@ export const Filters = ({ className = '', onGroupClick, open }) => { filters => { const activeFilters = filters.filter(filtersFilter).filter(item => item.value !== ''); dispatch(setDeviceFilters(activeFilters)); - dispatch(setDeviceListState({ selectedId: undefined, page: 1 }, true, true)); + dispatch(setDeviceListState({ selectedId: undefined, page: 1, shouldSelectDevices: true, forceRefresh: true })); }, [dispatch] ); diff --git a/frontend/src/js/components/devices/widgets/issueselection.js b/frontend/src/js/components/devices/widgets/issueselection.js index 1211f4cb..3ee1fd21 100644 --- a/frontend/src/js/components/devices/widgets/issueselection.js +++ b/frontend/src/js/components/devices/widgets/issueselection.js @@ -16,7 +16,7 @@ import React, { useCallback, useMemo, useState } from 'react'; // material ui import { Checkbox, MenuItem, Select } from '@mui/material'; -import { DEVICE_ISSUE_OPTIONS } from '../../../constants/deviceConstants'; +import { DEVICE_ISSUE_OPTIONS } from '@northern.tech/store/constants'; const menuProps = { anchorOrigin: { diff --git a/frontend/src/js/components/devices/widgets/issueselection.test.js b/frontend/src/js/components/devices/widgets/issueselection.test.js index dcc34eea..10d7ca9d 100644 --- a/frontend/src/js/components/devices/widgets/issueselection.test.js +++ b/frontend/src/js/components/devices/widgets/issueselection.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { DEVICE_ISSUE_OPTIONS } from '@northern.tech/store/constants'; + import { undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import { DEVICE_ISSUE_OPTIONS } from '../../../constants/deviceConstants'; import DeviceIssuesSelection from './issueselection'; describe('DeviceIssuesSelection Component', () => { diff --git a/frontend/src/js/components/header/deploymentnotifications.js b/frontend/src/js/components/header/deploymentnotifications.js index 48764df5..8a89e70a 100644 --- a/frontend/src/js/components/header/deploymentnotifications.js +++ b/frontend/src/js/components/header/deploymentnotifications.js @@ -18,7 +18,7 @@ import { Link } from 'react-router-dom'; import { Refresh as RefreshIcon } from '@mui/icons-material'; import { makeStyles } from 'tss-react/mui'; -import { DEPLOYMENT_ROUTES } from '../../constants/deploymentConstants'; +import { DEPLOYMENT_ROUTES } from '@northern.tech/store/constants'; const useStyles = makeStyles()(theme => ({ icon: { color: theme.palette.grey[500], margin: '0 7px 0 10px', top: '5px', fontSize: '20px' } diff --git a/frontend/src/js/components/header/header.js b/frontend/src/js/components/header/header.js index e5c74062..1bd07fc8 100644 --- a/frontend/src/js/components/header/header.js +++ b/frontend/src/js/components/header/header.js @@ -36,19 +36,7 @@ import { } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import dayjs from 'dayjs'; -import Cookies from 'universal-cookie'; - -import enterpriseLogo from '../../../assets/img/headerlogo-enterprise.png'; -import logo from '../../../assets/img/headerlogo.png'; -import whiteEnterpriseLogo from '../../../assets/img/whiteheaderlogo-enterprise.png'; -import whiteLogo from '../../../assets/img/whiteheaderlogo.png'; -import { setFirstLoginAfterSignup, setSearchState } from '../../actions/appActions'; -import { getAllDeviceCounts } from '../../actions/deviceActions'; -import { initializeSelf, logoutUser, setAllTooltipsReadState, setHideAnnouncement, switchUserOrganization } from '../../actions/userActions'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { READ_STATES } from '../../constants/userConstants'; -import { isDarkMode, toggle } from '../../helpers'; +import { READ_STATES, TIMEOUTS } from '@northern.tech/store/constants'; import { getAcceptedDevices, getCurrentSession, @@ -60,7 +48,26 @@ import { getOrganization, getShowHelptips, getUserSettings -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { useAppInit } from '@northern.tech/store/storehooks'; +import { + getAllDeviceCounts, + initializeSelf, + logoutUser, + setAllTooltipsReadState, + setFirstLoginAfterSignup, + setHideAnnouncement, + setSearchState, + switchUserOrganization +} from '@northern.tech/store/thunks'; +import dayjs from 'dayjs'; +import Cookies from 'universal-cookie'; + +import enterpriseLogo from '../../../assets/img/headerlogo-enterprise.png'; +import logo from '../../../assets/img/headerlogo.png'; +import whiteEnterpriseLogo from '../../../assets/img/whiteheaderlogo-enterprise.png'; +import whiteLogo from '../../../assets/img/whiteheaderlogo.png'; +import { toggle } from '../../helpers'; import Tracking from '../../tracking'; import { useDebounce } from '../../utils/debouncehook'; import Search from '../common/search'; @@ -223,7 +230,7 @@ const AccountMenu = () => { ); }; -export const Header = ({ mode }) => { +export const Header = ({ isDarkMode }) => { const { classes } = useStyles(); const [gettingUser, setGettingUser] = useState(false); const [hasOfferCookie, setHasOfferCookie] = useState(false); @@ -247,6 +254,8 @@ export const Header = ({ mode }) => { const dispatch = useDispatch(); const deviceTimer = useRef(); + useAppInit(userId); + useEffect(() => { if ((!userId || !user.email?.length || !userSettingInitialized) && !gettingUser && token) { setGettingUser(true); @@ -283,7 +292,7 @@ export const Header = ({ mode }) => { const showOffer = isHosted && dayjs().isBefore(currentOffer.expires) && (organization.trial ? currentOffer.trial : currentOffer[organization.plan]) && !hasOfferCookie; - const headerLogo = isDarkMode(mode) ? (isEnterprise ? whiteEnterpriseLogo : whiteLogo) : isEnterprise ? enterpriseLogo : logo; + const headerLogo = isDarkMode ? (isEnterprise ? whiteEnterpriseLogo : whiteLogo) : isEnterprise ? enterpriseLogo : logo; return ( @@ -293,7 +302,7 @@ export const Header = ({ mode }) => { errorIconClassName={classes.redAnnouncementIcon} iconClassName={classes.demoAnnouncementIcon} sectionClassName={classes.demoTrialAnnouncement} - onHide={() => dispatch(setHideAnnouncement(true))} + onHide={() => dispatch(setHideAnnouncement({ shouldHide: true }))} /> )} {showOffer && } diff --git a/frontend/src/js/components/help/downloads.js b/frontend/src/js/components/help/downloads.js index 901fcbe1..f21e0ef7 100644 --- a/frontend/src/js/components/help/downloads.js +++ b/frontend/src/js/components/help/downloads.js @@ -17,17 +17,19 @@ import { useDispatch, useSelector } from 'react-redux'; import { ArrowDropDown, ExpandMore, FileDownloadOutlined as FileDownloadIcon, Launch } from '@mui/icons-material'; import { Accordion, AccordionDetails, AccordionSummary, Chip, Menu, MenuItem, Typography } from '@mui/material'; +import storeActions from '@northern.tech/store/actions'; +import { canAccess } from '@northern.tech/store/constants'; +import { getCurrentSession, getCurrentUser, getIsEnterprise, getTenantCapabilities, getVersionInformation } from '@northern.tech/store/selectors'; import copy from 'copy-to-clipboard'; import Cookies from 'universal-cookie'; -import { setSnackbar } from '../../actions/appActions'; -import { canAccess } from '../../constants/appConstants'; import { detectOsIdentifier, toggle } from '../../helpers'; -import { getCurrentSession, getCurrentUser, getIsEnterprise, getTenantCapabilities, getVersionInformation } from '../../selectors'; import Tracking from '../../tracking'; import CommonDocsLink from '../common/docslink'; import Time from '../common/time'; +const { setSnackbar } = storeActions; + const cookies = new Cookies(); const osMap = { diff --git a/frontend/src/js/components/help/help.js b/frontend/src/js/components/help/help.js index 6eb28248..c3a4d148 100644 --- a/frontend/src/js/components/help/help.js +++ b/frontend/src/js/components/help/help.js @@ -18,7 +18,8 @@ import { Navigate, useLocation, useParams } from 'react-router-dom'; import { Launch as LaunchIcon } from '@mui/icons-material'; import { ListItemIcon, useTheme } from '@mui/material'; -import { getFeatures } from '../../selectors'; +import { getFeatures } from '@northern.tech/store/selectors'; + import LeftNav from '../common/left-nav'; import Downloads from './downloads'; import GetStarted from './getting-started'; diff --git a/frontend/src/js/components/help/help.test.js b/frontend/src/js/components/help/help.test.js index 5909e00b..4ddd61fc 100644 --- a/frontend/src/js/components/help/help.test.js +++ b/frontend/src/js/components/help/help.test.js @@ -15,11 +15,11 @@ import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { getConfiguredStore } from '@northern.tech/store/store'; import { render as testingLibRender } from '@testing-library/react'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { getConfiguredStore } from '../../reducers'; import { Downloads } from './downloads'; import GettingStarted from './getting-started'; import Help from './help'; diff --git a/frontend/src/js/components/helptips/baseonboardingtip.js b/frontend/src/js/components/helptips/baseonboardingtip.js index 4f72754e..f233409d 100644 --- a/frontend/src/js/components/helptips/baseonboardingtip.js +++ b/frontend/src/js/components/helptips/baseonboardingtip.js @@ -23,12 +23,15 @@ import { import { buttonBaseClasses, buttonClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { setShowDismissOnboardingTipsDialog } from '../../actions/onboardingActions'; -import { TIMEOUTS } from '../../constants/appConstants'; +import storeActions from '@northern.tech/store/actions'; +import { TIMEOUTS } from '@northern.tech/store/constants'; + import { toggle } from '../../helpers'; import Tracking from '../../tracking'; import { OnboardingTooltip } from '../common/mendertooltip'; +const { setShowDismissOnboardingTipsDialog } = storeActions; + const iconWidth = 30; export const orientations = { diff --git a/frontend/src/js/components/helptips/helptooltips.js b/frontend/src/js/components/helptips/helptooltips.js index 876ef7b5..b48fab32 100644 --- a/frontend/src/js/components/helptips/helptooltips.js +++ b/frontend/src/js/components/helptips/helptooltips.js @@ -14,15 +14,17 @@ import React, { useCallback } from 'react'; import { useDispatch, useSelector } from 'react-redux'; -import { setSnackbar } from '../../actions/appActions'; -import { setAllTooltipsReadState, setTooltipReadState } from '../../actions/userActions'; -import { yes } from '../../constants/appConstants'; -import { READ_STATES } from '../../constants/userConstants'; -import { getDeviceById, getFeatures, getTooltipsState } from '../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { READ_STATES, yes } from '@northern.tech/store/constants'; +import { getDeviceById, getFeatures, getTooltipsState } from '@northern.tech/store/selectors'; +import { setAllTooltipsReadState, setTooltipReadState } from '@northern.tech/store/thunks'; + import ConfigurationObject from '../common/configurationobject'; import DocsLink from '../common/docslink'; import { HelpTooltip } from '../common/mendertooltip'; +const { setSnackbar } = storeActions; + const AuthExplainButton = () => ( <>

Device authorization status

diff --git a/frontend/src/js/components/helptips/onboardingcompletetip.js b/frontend/src/js/components/helptips/onboardingcompletetip.js index 9c6e79ca..6b826536 100644 --- a/frontend/src/js/components/helptips/onboardingcompletetip.js +++ b/frontend/src/js/components/helptips/onboardingcompletetip.js @@ -18,11 +18,10 @@ import { CheckCircle as CheckCircleIcon } from '@mui/icons-material'; import { Button } from '@mui/material'; import { withStyles } from 'tss-react/mui'; -import { getDeviceById, getDevicesByStatus } from '../../actions/deviceActions'; -import { setOnboardingComplete } from '../../actions/onboardingActions'; -import * as DeviceConstants from '../../constants/deviceConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; -import { getDemoDeviceAddress } from '../../selectors'; +import { DEVICE_STATES, onboardingSteps } from '@northern.tech/store/constants'; +import { getDemoDeviceAddress } from '@northern.tech/store/selectors'; +import { getDeviceById, getDevicesByStatus, setOnboardingComplete } from '@northern.tech/store/thunks'; + import Loader from '../common/loader'; import { MenderTooltipClickable } from '../common/mendertooltip'; @@ -41,7 +40,8 @@ export const OnboardingCompleteTip = ({ anchor, targetUrl }) => { const url = useSelector(getDemoDeviceAddress) || targetUrl; useEffect(() => { - dispatch(getDevicesByStatus(DeviceConstants.DEVICE_STATES.accepted)) + dispatch(getDevicesByStatus({ status: DEVICE_STATES.accepted })) + .unwrap() .then(tasks => { return Promise.all(tasks[tasks.length - 1].deviceAccu.ids.map(id => dispatch(getDeviceById(id)))); }) diff --git a/frontend/src/js/components/helptips/onboardingcompletetip.test.js b/frontend/src/js/components/helptips/onboardingcompletetip.test.js index 814ef919..6bb14749 100644 --- a/frontend/src/js/components/helptips/onboardingcompletetip.test.js +++ b/frontend/src/js/components/helptips/onboardingcompletetip.test.js @@ -15,10 +15,10 @@ import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; +import { getConfiguredStore } from '@northern.tech/store/store'; import { act, render as testingLibRender, waitFor } from '@testing-library/react'; import { defaultState, undefineds } from '../../../../tests/mockData'; -import { getConfiguredStore } from '../../reducers'; import OnboardingCompleteTip from './onboardingcompletetip'; describe('OnboardingCompleteTip Component', () => { diff --git a/frontend/src/js/components/helptips/onboardingtips.js b/frontend/src/js/components/helptips/onboardingtips.js index 986a7a46..afa35894 100644 --- a/frontend/src/js/components/helptips/onboardingtips.js +++ b/frontend/src/js/components/helptips/onboardingtips.js @@ -17,12 +17,14 @@ import { useDispatch } from 'react-redux'; import { Schedule as HelpIcon } from '@mui/icons-material'; import { Button } from '@mui/material'; -import { advanceOnboarding, setShowDismissOnboardingTipsDialog } from '../../actions/onboardingActions'; -import { setShowConnectingDialog } from '../../actions/userActions'; -import { ALL_DEVICES } from '../../constants/deviceConstants'; -import { onboardingSteps } from '../../constants/onboardingConstants'; +import storeActions from '@northern.tech/store/actions'; +import { ALL_DEVICES, onboardingSteps } from '@northern.tech/store/constants'; +import { advanceOnboarding } from '@northern.tech/store/thunks'; + import BaseOnboardingTip, { BaseOnboardingTooltip } from './baseonboardingtip'; +const { setShowConnectingDialog, setShowDismissOnboardingTipsDialog } = storeActions; + export const DevicePendingTip = props => ( } diff --git a/frontend/src/js/components/leftnav.js b/frontend/src/js/components/leftnav.js index 2d2ef92c..fad98c8f 100644 --- a/frontend/src/js/components/leftnav.js +++ b/frontend/src/js/components/leftnav.js @@ -19,13 +19,15 @@ import { Link, NavLink } from 'react-router-dom'; import { List, ListItem, ListItemText, Tooltip } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import storeActions from '@northern.tech/store/actions'; +import { TIMEOUTS, canAccess } from '@northern.tech/store/constants'; +import { getFeatures, getUserCapabilities, getVersionInformation } from '@northern.tech/store/selectors'; import copy from 'copy-to-clipboard'; -import { setSnackbar, setVersionInfo } from '../actions/appActions'; -import { TIMEOUTS, canAccess } from '../constants/appConstants'; -import { getFeatures, getUserCapabilities, getVersionInformation } from '../selectors'; import DocsLink from './common/docslink'; +const { setSnackbar, setVersionInformation } = storeActions; + const listItems = [ { route: '/', text: 'Dashboard', canAccess }, { route: '/devices', text: 'Devices', canAccess: ({ userCapabilities: { canReadDevices } }) => canReadDevices }, @@ -105,7 +107,7 @@ const VersionInfo = () => { setClicks(0); }, TIMEOUTS.threeSeconds); if (clicks > 5) { - dispatch(setVersionInfo({ Integration: 'next' })); + dispatch(setVersionInformation({ Integration: 'next' })); } onVersionClick(); }; diff --git a/frontend/src/js/components/login/login.js b/frontend/src/js/components/login/login.js index 0da48240..e4f529fb 100644 --- a/frontend/src/js/components/login/login.js +++ b/frontend/src/js/components/login/login.js @@ -19,16 +19,15 @@ import { ChevronRight } from '@mui/icons-material'; import { Button, Checkbox, Collapse, FormControlLabel } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import storeActions from '@northern.tech/store/actions'; +import { getToken } from '@northern.tech/store/auth'; +import { TIMEOUTS, locations, useradmApiUrl } from '@northern.tech/store/constants'; +import { getCurrentUser, getFeatures, getIsEnterprise } from '@northern.tech/store/selectors'; +import { loginUser, logoutUser } from '@northern.tech/store/thunks'; import Cookies from 'universal-cookie'; import LoginLogo from '../../../assets/img/loginlogo.svg'; import VeryMuch from '../../../assets/img/verymuch.svg'; -import { setSnackbar } from '../../actions/appActions'; -import { loginUser, logoutUser } from '../../actions/userActions'; -import { getToken } from '../../auth'; -import { TIMEOUTS, locations } from '../../constants/appConstants'; -import { useradmApiUrl } from '../../constants/userConstants'; -import { getCurrentUser, getFeatures, getIsEnterprise } from '../../selectors'; import { clearAllRetryTimers } from '../../utils/retrytimer'; import Form from '../common/forms/form'; import PasswordInput from '../common/forms/passwordinput'; @@ -37,6 +36,8 @@ import LinedHeader from '../common/lined-header'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips'; import { OAuth2Providers } from './oauth2providers'; +const { setSnackbar } = storeActions; + const cookies = new Cookies(); export const locationMap = { @@ -174,15 +175,17 @@ export const Login = () => { const onLoginClick = useCallback( loginData => { // set no expiry in localstorage to remember checkbox value and avoid any influence of expiration time that might occur with cookies - dispatch(loginUser(loginData, noExpiry)).catch(err => { - // don't reset the state once it was set - thus not setting `has2FA` solely based on the existence of 2fa in the error - if (err?.error?.includes('2fa')) { - setHas2FA(true); - } - if (!showPassword) { - setShowPassword(true); - } - }); + dispatch(loginUser({ ...loginData, stayLoggedIn: noExpiry })) + .unwrap() + .catch(err => { + // don't reset the state once it was set - thus not setting `has2FA` solely based on the existence of 2fa in the error + if (err?.error?.includes('2fa')) { + setHas2FA(true); + } + if (!showPassword) { + setShowPassword(true); + } + }); }, [dispatch, noExpiry, showPassword] ); diff --git a/frontend/src/js/components/login/login.test.js b/frontend/src/js/components/login/login.test.js index 454049f1..e8db9307 100644 --- a/frontend/src/js/components/login/login.test.js +++ b/frontend/src/js/components/login/login.test.js @@ -13,12 +13,12 @@ // limitations under the License. import React from 'react'; +import * as UserActions from '@northern.tech/store/usersSlice/thunks'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import * as UserActions from '../../actions/userActions'; import Login from './login'; const preloadedState = { @@ -54,9 +54,7 @@ describe('Login Component', () => { expect(await screen.findByLabelText(/Two Factor Authentication Code/i)).not.toBeVisible(); await user.click(screen.getByRole('button', { name: /Log in/i })); expect(loginSpy).toHaveBeenCalled(); - await waitFor(() => rerender(ui)); - await act(async () => jest.runAllTicks()); - expect(await screen.findByLabelText(/Two Factor Authentication Code/i)).toBeVisible(); + await waitFor(() => expect(screen.getByLabelText(/Two Factor Authentication Code/i)).toBeVisible()); const input = screen.getByDisplayValue('something-2fa@example.com'); await user.clear(input); await user.type(input, 'something@example.com'); @@ -64,6 +62,6 @@ describe('Login Component', () => { await waitFor(() => rerender(ui)); await user.click(screen.getByRole('button', { name: /Log in/i })); await act(async () => jest.runAllTicks()); - expect(loginSpy).toHaveBeenCalledWith({ email: 'something@example.com', password: 'mysecretpassword!123', token2fa: '123456' }, false); + expect(loginSpy).toHaveBeenCalledWith({ email: 'something@example.com', password: 'mysecretpassword!123', token2fa: '123456', stayLoggedIn: false }); }, 10000); }); diff --git a/frontend/src/js/components/login/password.js b/frontend/src/js/components/login/password.js index 04162bfd..0845d65a 100644 --- a/frontend/src/js/components/login/password.js +++ b/frontend/src/js/components/login/password.js @@ -15,8 +15,9 @@ import React, { useState } from 'react'; import { useDispatch } from 'react-redux'; import { Link } from 'react-router-dom'; +import { passwordResetStart } from '@northern.tech/store/thunks'; + import LoginLogo from '../../../assets/img/loginlogo.svg'; -import { passwordResetStart } from '../../actions/userActions'; import Form from '../common/forms/form'; import TextInput from '../common/forms/textinput'; import { LocationWarning } from './login'; diff --git a/frontend/src/js/components/login/password.test.js b/frontend/src/js/components/login/password.test.js index 5f16ed9f..cdda0373 100644 --- a/frontend/src/js/components/login/password.test.js +++ b/frontend/src/js/components/login/password.test.js @@ -13,12 +13,12 @@ // limitations under the License. import React from 'react'; +import * as UserActions from '@northern.tech/store/usersSlice/thunks'; import { screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import * as UserActions from '../../actions/userActions'; import Password from './password'; describe('Password Component', () => { diff --git a/frontend/src/js/components/login/passwordreset.js b/frontend/src/js/components/login/passwordreset.js index 8a00c68b..06cd771f 100644 --- a/frontend/src/js/components/login/passwordreset.js +++ b/frontend/src/js/components/login/passwordreset.js @@ -15,7 +15,8 @@ import React, { useState } from 'react'; import { useDispatch } from 'react-redux'; import { useParams } from 'react-router-dom'; -import { passwordResetComplete } from '../../actions/userActions'; +import { passwordResetComplete } from '@northern.tech/store/thunks'; + import Form from '../common/forms/form'; import PasswordInput from '../common/forms/passwordinput'; import { PasswordScreenContainer } from './password'; @@ -25,7 +26,7 @@ export const PasswordReset = () => { const { secretHash } = useParams(); const dispatch = useDispatch(); - const handleSubmit = formData => dispatch(passwordResetComplete(secretHash, formData.password)).then(() => setConfirm(true)); + const handleSubmit = formData => dispatch(passwordResetComplete({ secretHash, newPassword: formData.password })).then(() => setConfirm(true)); return ( diff --git a/frontend/src/js/components/login/passwordreset.test.js b/frontend/src/js/components/login/passwordreset.test.js index ffd281a3..ad56158a 100644 --- a/frontend/src/js/components/login/passwordreset.test.js +++ b/frontend/src/js/components/login/passwordreset.test.js @@ -15,13 +15,13 @@ import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { getConfiguredStore } from '@northern.tech/store/store'; +import * as UserActions from '@northern.tech/store/usersSlice/thunks'; import { screen, render as testingLibRender, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import * as UserActions from '../../actions/userActions'; -import { getConfiguredStore } from '../../reducers'; import Password from './password'; import PasswordReset from './passwordreset'; @@ -72,7 +72,7 @@ describe('PasswordReset Component', () => { await user.type(passwordInput, goodPassword); await waitFor(() => rerender(ui)); await user.click(screen.getByRole('button', { name: /Save password/i })); - await waitFor(() => expect(completeSpy).toHaveBeenCalledWith(secretHash, goodPassword)); + await waitFor(() => expect(completeSpy).toHaveBeenCalledWith({ secretHash, newPassword: goodPassword })); await waitFor(() => expect(screen.queryByText(/Your password has been updated./i)).toBeVisible()); }); }); diff --git a/frontend/src/js/components/login/signup-steps/orgdata-entry.js b/frontend/src/js/components/login/signup-steps/orgdata-entry.js index 9fc6c407..50f55c0d 100644 --- a/frontend/src/js/components/login/signup-steps/orgdata-entry.js +++ b/frontend/src/js/components/login/signup-steps/orgdata-entry.js @@ -17,7 +17,8 @@ import { useFormContext } from 'react-hook-form'; import { MenuItem, Select } from '@mui/material'; -import { locations } from '../../../constants/appConstants'; +import { locations } from '@northern.tech/store/constants'; + import DocsLink from '../../common/docslink'; import FormCheckbox from '../../common/forms/formcheckbox'; import TextInput from '../../common/forms/textinput'; diff --git a/frontend/src/js/components/login/signup.js b/frontend/src/js/components/login/signup.js index eb7f0888..a2ed86e5 100644 --- a/frontend/src/js/components/login/signup.js +++ b/frontend/src/js/components/login/signup.js @@ -18,13 +18,13 @@ import { Navigate, useParams } from 'react-router-dom'; import { formControlClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import storeActions from '@northern.tech/store/actions'; +import { TIMEOUTS, locations } from '@northern.tech/store/constants'; +import { createOrganizationTrial } from '@northern.tech/store/thunks'; import Cookies from 'universal-cookie'; import LoginLogo from '../../../assets/img/loginlogo.svg'; import SignupHero from '../../../assets/img/signuphero.svg'; -import { setSnackbar } from '../../actions/appActions'; -import { createOrganizationTrial } from '../../actions/organizationActions'; -import { TIMEOUTS, locations } from '../../constants/appConstants'; import { stringToBoolean } from '../../helpers'; import Form from '../common/forms/form'; import Loader from '../common/loader'; @@ -32,6 +32,8 @@ import { EntryLink } from './login'; import OrgDataEntry from './signup-steps/orgdata-entry'; import UserDataEntry from './signup-steps/userdata-entry'; +const { setSnackbar } = storeActions; + const cookies = new Cookies(); const useStyles = makeStyles()(theme => ({ background: { diff --git a/frontend/src/js/components/login/signup.test.js b/frontend/src/js/components/login/signup.test.js index 5ac81076..e973ff7e 100644 --- a/frontend/src/js/components/login/signup.test.js +++ b/frontend/src/js/components/login/signup.test.js @@ -14,6 +14,7 @@ import React from 'react'; import { Route, Routes } from 'react-router-dom'; +import { TIMEOUTS } from '@northern.tech/store/commonConstants'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import Cookies from 'universal-cookie'; @@ -22,6 +23,8 @@ import { undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; import Signup from './signup'; +const cookies = new Cookies(); + describe('Signup Component', () => { it('renders correctly', async () => { const { baseElement } = render(); @@ -59,12 +62,12 @@ describe('Signup Component', () => { await user.click(screen.getByRole('checkbox', { name: /by checking this you agree to our/i })); await waitFor(() => rerender(ui)); await waitFor(() => expect(screen.getByRole('button', { name: /complete signup/i })).toBeEnabled()); - const cookies = new Cookies(); cookies.set.mockReturnValue(); await user.click(screen.getByRole('button', { name: /complete signup/i })); await waitFor(() => expect(container.querySelector('.loaderContainer')).toBeVisible()); - await act(async () => jest.advanceTimersByTime(5000)); + await act(async () => jest.advanceTimersByTime(TIMEOUTS.refreshDefault)); await waitFor(() => rerender(ui)); + screen.debug(undefined, 20000000); await waitFor(() => expect(cookies.set).toHaveBeenLastCalledWith('firstLoginAfterSignup', true, { domain: '.mender.io', maxAge: 60, path: '/', sameSite: false }) ); diff --git a/frontend/src/js/components/releases/artifactdetails.js b/frontend/src/js/components/releases/artifactdetails.js index 290284cf..b7966793 100644 --- a/frontend/src/js/components/releases/artifactdetails.js +++ b/frontend/src/js/components/releases/artifactdetails.js @@ -27,15 +27,15 @@ import { import { Accordion, AccordionDetails, AccordionSummary, Button, List, ListItem, ListItemText } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { getUserCapabilities } from '@northern.tech/store/selectors'; +import { editArtifact, getArtifactInstallCount, getArtifactUrl } from '@northern.tech/store/thunks'; import pluralize from 'pluralize'; -import { editArtifact, getArtifactInstallCount, getArtifactUrl } from '../../actions/releaseActions'; import { extractSoftware, extractSoftwareItem, toggle } from '../../helpers'; -import { getUserCapabilities } from '../../selectors'; +import { EditableLongText } from '../common/editablelongtext'; import ExpandableAttribute from '../common/expandable-attribute'; import ArtifactPayload from './artifactPayload'; import ArtifactMetadataList from './artifactmetadatalist'; -import { EditableLongText } from './releasedetails'; const useStyles = makeStyles()(theme => ({ link: { marginTop: theme.spacing() }, @@ -159,7 +159,7 @@ export const ArtifactDetails = ({ artifact, open, showRemoveArtifactDialog }) => // eslint-disable-next-line react-hooks/exhaustive-deps }, [artifact.id, artifact.installCount, dispatch, open, softwareVersions.length]); - const onDescriptionChanged = useCallback(description => dispatch(editArtifact(artifact.id, { description })), [artifact.id, dispatch]); + const onDescriptionChanged = useCallback(description => dispatch(editArtifact({ id: artifact.id, body: { description } })), [artifact.id, dispatch]); const softwareItem = extractSoftwareItem(artifact.artifact_provides); const softwareInformation = softwareItem diff --git a/frontend/src/js/components/releases/dialogs/addTags.js b/frontend/src/js/components/releases/dialogs/addTags.js index 0fe18ee4..447c57e2 100644 --- a/frontend/src/js/components/releases/dialogs/addTags.js +++ b/frontend/src/js/components/releases/dialogs/addTags.js @@ -18,8 +18,9 @@ import { useDispatch } from 'react-redux'; import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { setReleaseTags, setReleasesListState } from '../../../actions/releaseActions.js'; -import ChipSelect from '../../common/chipselect.js'; +import { setReleaseTags, setReleasesListState } from '@northern.tech/store/thunks'; + +import ChipSelect from '../../common/chipselect'; const useStyles = makeStyles()(theme => ({ DialogContent: { @@ -47,7 +48,7 @@ export const AddTagsDialog = ({ selectedReleases, onClose }) => { const tags = getValues(inputName); dispatch(setReleasesListState({ loading: true })).then(() => { const addRequests = selectedReleases.reduce((accu, release) => { - accu.push(dispatch(setReleaseTags(release.name, [...new Set([...release.tags, ...tags])]))); + accu.push(dispatch(setReleaseTags({ name: release.name, tags: [...new Set([...release.tags, ...tags])] }))); return accu; }, []); return Promise.all(addRequests).then(onClose); diff --git a/frontend/src/js/components/releases/dialogs/addTags.test.js b/frontend/src/js/components/releases/dialogs/addTags.test.js index 5ba6d180..4fdc4010 100644 --- a/frontend/src/js/components/releases/dialogs/addTags.test.js +++ b/frontend/src/js/components/releases/dialogs/addTags.test.js @@ -15,7 +15,7 @@ import React from 'react'; import { undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import AddTags from './addTags.js'; +import AddTags from './addTags'; describe('releases addTags Component', () => { it('renders correctly', async () => { diff --git a/frontend/src/js/components/releases/dialogs/addartifact.js b/frontend/src/js/components/releases/dialogs/addartifact.js index 9a7ba200..799ce4ee 100644 --- a/frontend/src/js/components/releases/dialogs/addartifact.js +++ b/frontend/src/js/components/releases/dialogs/addartifact.js @@ -15,20 +15,22 @@ import React, { useCallback, useEffect, useRef, useState } from 'react'; import Dropzone from 'react-dropzone'; import { useDispatch, useSelector } from 'react-redux'; -import { CloudUpload, Delete as DeleteIcon, InsertDriveFile as InsertDriveFileIcon } from '@mui/icons-material'; -import { Button, Dialog, DialogActions, DialogContent, DialogTitle, Divider, IconButton } from '@mui/material'; +import { CloudUpload } from '@mui/icons-material'; +import { Button, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { setSnackbar } from '../../../actions/appActions'; -import { createArtifact, uploadArtifact } from '../../../actions/releaseActions'; -import { FileSize, unionizeStrings } from '../../../helpers'; -import { getDeviceTypes } from '../../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { getDeviceTypes } from '@northern.tech/store/selectors'; +import { createArtifact, uploadArtifact } from '@northern.tech/store/thunks'; + +import { unionizeStrings } from '../../../helpers'; import Tracking from '../../../tracking'; import useWindowSize from '../../../utils/resizehook'; -import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; import ArtifactInformationForm from './artifactinformationform'; import ArtifactUploadConfirmation from './artifactupload'; +const { setSnackbar } = storeActions; + const reFilename = new RegExp(/^[a-z0-9.,_-]+$/i); const useStyles = makeStyles()(theme => ({ @@ -55,46 +57,6 @@ const uploadTypes = { } }; -const fileInformationContent = { - mender: { - title: 'Mender Artifact', - icon: InsertDriveFileIcon, - infoId: 'menderArtifactUpload' - }, - singleFile: { - title: 'Single File', - icon: InsertDriveFileIcon, - infoId: 'singleFileUpload' - } -}; - -export const FileInformation = ({ file, type, onRemove }) => { - const { classes } = useStyles(); - if (!file) { - return
; - } - const { icon: Icon, infoId, title } = fileInformationContent[type]; - return ( - <> -

Selected {title}

-
- -
-
{file.name}
-
- -
-
- - - - -
- - - ); -}; - const commonExtensions = ['zip', 'txt', 'tar', 'html', 'tar.gzip', 'gzip']; const shortenFileName = name => { const extension = commonExtensions.find(extension => name.endsWith(extension)); @@ -173,9 +135,9 @@ export const AddArtifactDialog = ({ onCancel, onUploadStarted, releases, selecte const deviceTypes = useSelector(getDeviceTypes); const dispatch = useDispatch(); - const onCreateArtifact = useCallback((meta, file) => dispatch(createArtifact(meta, file)), [dispatch]); + const onCreateArtifact = useCallback((meta, file) => dispatch(createArtifact({ meta, file })), [dispatch]); const onSetSnackbar = useCallback((...args) => dispatch(setSnackbar(...args)), [dispatch]); - const onUploadArtifact = useCallback((meta, file) => dispatch(uploadArtifact(meta, file)), [dispatch]); + const onUploadArtifact = useCallback((meta, file) => dispatch(uploadArtifact({ meta, file })), [dispatch]); useEffect(() => { setCreation(current => ({ ...current, file: selectedFile })); diff --git a/frontend/src/js/components/releases/dialogs/addartifact.test.js b/frontend/src/js/components/releases/dialogs/addartifact.test.js index 420f9da1..ff6745b0 100644 --- a/frontend/src/js/components/releases/dialogs/addartifact.test.js +++ b/frontend/src/js/components/releases/dialogs/addartifact.test.js @@ -13,12 +13,12 @@ // limitations under the License. import React from 'react'; +import * as releaseActions from '@northern.tech/store/releasesSlice/thunks'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { defaultState, undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import * as ReleaseActions from '../../../actions/releaseActions'; import AddArtifact from './addartifact'; describe('AddArtifact Component', () => { @@ -36,7 +36,7 @@ describe('AddArtifact Component', () => { it('allows uploading a mender artifact', async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); const menderFile = new File(['testContent'], 'test.mender'); - const uploadSpy = jest.spyOn(ReleaseActions, 'uploadArtifact'); + const uploadSpy = jest.spyOn(releaseActions, 'uploadArtifact'); const ui = ; const { rerender } = render(ui, { preloadedState }); @@ -56,7 +56,7 @@ describe('AddArtifact Component', () => { it('allows creating a mender artifact', async () => { const user = userEvent.setup({ advanceTimers: jest.advanceTimersByTime }); - const creationSpy = jest.spyOn(ReleaseActions, 'createArtifact'); + const creationSpy = jest.spyOn(releaseActions, 'createArtifact'); const menderFile = new File(['testContent plain'], 'testFile.txt'); const ui = ; diff --git a/frontend/src/js/components/releases/dialogs/artifactinformationform.js b/frontend/src/js/components/releases/dialogs/artifactinformationform.js index fdd5aa69..de5c6d02 100644 --- a/frontend/src/js/components/releases/dialogs/artifactinformationform.js +++ b/frontend/src/js/components/releases/dialogs/artifactinformationform.js @@ -20,7 +20,7 @@ import ChipSelect from '../../common/chipselect'; import { DOCSTIPS, DocsTooltip } from '../../common/docslink'; import { InfoHintContainer } from '../../common/info-hint'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; -import { FileInformation } from './addartifact'; +import { FileInformation } from './fileinformation'; const defaultVersion = '1.0.0'; diff --git a/frontend/src/js/components/releases/dialogs/artifactupload.js b/frontend/src/js/components/releases/dialogs/artifactupload.js index 1a86216f..5e20ebca 100644 --- a/frontend/src/js/components/releases/dialogs/artifactupload.js +++ b/frontend/src/js/components/releases/dialogs/artifactupload.js @@ -13,7 +13,7 @@ // limitations under the License. import React, { useEffect } from 'react'; -import { FileInformation } from './addartifact'; +import { FileInformation } from './fileinformation'; export const ArtifactUploadConfirmation = ({ creation = {}, onRemove, updateCreation }) => { const { file, type } = creation; diff --git a/frontend/src/js/components/releases/dialogs/fileinformation.js b/frontend/src/js/components/releases/dialogs/fileinformation.js new file mode 100644 index 00000000..f30fa0bd --- /dev/null +++ b/frontend/src/js/components/releases/dialogs/fileinformation.js @@ -0,0 +1,73 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import React from 'react'; + +import { Delete as DeleteIcon, InsertDriveFile as InsertDriveFileIcon } from '@mui/icons-material'; +import { Divider, IconButton } from '@mui/material'; +import { makeStyles } from 'tss-react/mui'; + +import { FileSize } from '../../../helpers'; +import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; + +const useStyles = makeStyles()(theme => ({ + fileInfo: { + alignItems: 'center', + columnGap: theme.spacing(4), + display: 'grid', + gridTemplateColumns: 'max-content 1fr max-content max-content', + marginBottom: theme.spacing(2), + marginRight: theme.spacing(4) + }, + fileSizeWrapper: { marginTop: 5 } +})); + +const fileInformationContent = { + mender: { + title: 'Mender Artifact', + icon: InsertDriveFileIcon, + infoId: 'menderArtifactUpload' + }, + singleFile: { + title: 'Single File', + icon: InsertDriveFileIcon, + infoId: 'singleFileUpload' + } +}; + +export const FileInformation = ({ file, type, onRemove }) => { + const { classes } = useStyles(); + if (!file) { + return
; + } + const { icon: Icon, infoId, title } = fileInformationContent[type]; + return ( + <> +

Selected {title}

+
+ +
+
{file.name}
+
+ +
+
+ + + + +
+ + + ); +}; diff --git a/frontend/src/js/components/releases/releasedetails.js b/frontend/src/js/components/releases/releasedetails.js index edd5dfd4..d9e79a4f 100644 --- a/frontend/src/js/components/releases/releasedetails.js +++ b/frontend/src/js/components/releases/releasedetails.js @@ -38,30 +38,31 @@ import { SpeedDial, SpeedDialAction, SpeedDialIcon, - TextField, Tooltip } from '@mui/material'; import { speedDialActionClasses } from '@mui/material/SpeedDialAction'; import { makeStyles } from 'tss-react/mui'; +import storeActions from '@northern.tech/store/actions'; +import { DEPLOYMENT_ROUTES } from '@northern.tech/store/constants'; +import { getReleaseListState, getReleaseTags, getSelectedRelease, getUserCapabilities } from '@northern.tech/store/selectors'; +import { removeArtifact, removeRelease, selectRelease, setReleaseTags, updateReleaseInfo } from '@northern.tech/store/thunks'; import copy from 'copy-to-clipboard'; import pluralize from 'pluralize'; -import { setSnackbar } from '../../actions/appActions'; -import { removeArtifact, removeRelease, selectRelease, setReleaseTags, updateReleaseInfo } from '../../actions/releaseActions'; -import { DEPLOYMENT_ROUTES } from '../../constants/deploymentConstants'; import { FileSize, customSort, formatTime, toggle } from '../../helpers'; -import { getReleaseListState, getReleaseTags, getSelectedRelease, getUserCapabilities } from '../../selectors'; import { generateReleasesPath } from '../../utils/locationutils'; import useWindowSize from '../../utils/resizehook'; import ChipSelect from '../common/chipselect'; import { ConfirmationButtons, EditButton } from '../common/confirm'; -import ExpandableAttribute from '../common/expandable-attribute'; +import { EditableLongText } from '../common/editablelongtext'; import { RelativeTime } from '../common/time'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips'; import Artifact from './artifact'; import RemoveArtifactDialog from './dialogs/removeartifact'; +const { setSnackbar } = storeActions; + const DeviceTypeCompatibility = ({ artifact }) => { const compatible = artifact.artifact_depends ? artifact.artifact_depends.device_type.join(', ') : artifact.device_types_compatible.join(', '); return ( @@ -133,9 +134,7 @@ const useStyles = makeStyles()(theme => ({ label: { marginRight: theme.spacing(2), marginBottom: theme.spacing(4) - }, - notes: { display: 'block', whiteSpace: 'pre-wrap' }, - notesWrapper: { minWidth: theme.components?.MuiFormControl?.styleOverrides?.root?.minWidth } + } })); export const ReleaseQuickActions = ({ actionCallbacks, innerRef, selectedRelease, userCapabilities, releases }) => { @@ -191,73 +190,6 @@ export const ReleaseQuickActions = ({ actionCallbacks, innerRef, selectedRelease ); }; -export const EditableLongText = ({ contentFallback = '', fullWidth, original, onChange, placeholder = '-' }) => { - const [isEditing, setIsEditing] = useState(false); - const [value, setValue] = useState(original); - const { classes } = useStyles(); - - useEffect(() => { - setValue(original); - }, [original]); - - const onCancelClick = () => { - setValue(original); - setIsEditing(false); - }; - - const onEdit = ({ target: { value } }) => setValue(value); - - const onEditClick = () => setIsEditing(true); - - const onToggleEditing = useCallback( - event => { - event.stopPropagation(); - if (event.key && (event.key !== 'Enter' || event.shiftKey)) { - return; - } - if (isEditing) { - // save change - onChange(value); - } - setIsEditing(toggle); - }, - [isEditing, onChange, value] - ); - - const fullWidthClass = fullWidth ? 'full-width' : ''; - - return ( -
- {isEditing ? ( - <> - - - - ) : ( - <> - - - - )} -
- ); -}; - const ReleaseNotes = ({ onChange, release: { notes = '' } }) => ( <>

Release notes

@@ -410,9 +342,9 @@ export const ReleaseDetails = () => { const onDeleteRelease = () => dispatch(removeRelease(releaseName)).then(() => setConfirmReleaseDeletion(false)); - const onReleaseNotesChanged = useCallback(notes => dispatch(updateReleaseInfo(releaseName, { notes })), [dispatch, releaseName]); + const onReleaseNotesChanged = useCallback(notes => dispatch(updateReleaseInfo({ name: releaseName, info: { notes } })), [dispatch, releaseName]); - const onTagSelectionChanged = useCallback(tags => dispatch(setReleaseTags(releaseName, tags)), [dispatch, releaseName]); + const onTagSelectionChanged = useCallback(tags => dispatch(setReleaseTags({ name: releaseName, tags })), [dispatch, releaseName]); return ( diff --git a/frontend/src/js/components/releases/releases.js b/frontend/src/js/components/releases/releases.js index 464e7e57..6bc8dbce 100644 --- a/frontend/src/js/components/releases/releases.js +++ b/frontend/src/js/components/releases/releases.js @@ -18,10 +18,7 @@ import { CloudUpload } from '@mui/icons-material'; import { Button, Tab, Tabs, TextField } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import pluralize from 'pluralize'; - -import { getExistingReleaseTags, getReleases, getUpdateTypes, selectRelease, setReleasesListState } from '../../actions/releaseActions'; -import { BENEFITS, SORTING_OPTIONS, TIMEOUTS } from '../../constants/appConstants'; +import { BENEFITS, SORTING_OPTIONS, TIMEOUTS } from '@northern.tech/store/constants'; import { getHasReleases, getIsEnterprise, @@ -31,7 +28,10 @@ import { getSelectedRelease, getUpdateTypes as getUpdateTypesSelector, getUserCapabilities -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { getExistingReleaseTags, getReleases, getUpdateTypes, selectRelease, setReleasesListState } from '@northern.tech/store/thunks'; +import pluralize from 'pluralize'; + import { useDebounce } from '../../utils/debouncehook'; import { useLocationParams } from '../../utils/liststatehook'; import ChipSelect from '../common/chipselect'; diff --git a/frontend/src/js/components/releases/releaseslist.js b/frontend/src/js/components/releases/releaseslist.js index 25ec910d..52e9a38d 100644 --- a/frontend/src/js/components/releases/releaseslist.js +++ b/frontend/src/js/components/releases/releaseslist.js @@ -17,17 +17,19 @@ import { useDispatch, useSelector } from 'react-redux'; import { makeStyles } from 'tss-react/mui'; -import { setSnackbar } from '../../actions/appActions'; -import { removeRelease, selectRelease, setReleasesListState } from '../../actions/releaseActions.js'; -import { SORTING_OPTIONS, canAccess as canShow } from '../../constants/appConstants'; -import { DEVICE_LIST_DEFAULTS } from '../../constants/deviceConstants'; -import { getFeatures, getHasReleases, getReleaseListState, getReleasesList, getUserCapabilities } from '../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS, canAccess as canShow } from '@northern.tech/store/constants'; +import { getFeatures, getHasReleases, getReleaseListState, getReleasesList, getUserCapabilities } from '@northern.tech/store/selectors'; +import { removeRelease, selectRelease, setReleasesListState } from '@northern.tech/store/thunks'; + import DetailsTable from '../common/detailstable'; import Loader from '../common/loader'; import Pagination from '../common/pagination'; import { RelativeTime } from '../common/time'; -import AddTagsDialog from './dialogs/addTags.js'; -import { DeleteReleasesConfirmationDialog, ReleaseQuickActions } from './releasedetails.js'; +import AddTagsDialog from './dialogs/addTags'; +import { DeleteReleasesConfirmationDialog, ReleaseQuickActions } from './releasedetails'; + +const { setSnackbar } = storeActions; const columns = [ { diff --git a/frontend/src/js/components/search-result.js b/frontend/src/js/components/search-result.js index c4af8c6b..800346fe 100644 --- a/frontend/src/js/components/search-result.js +++ b/frontend/src/js/components/search-result.js @@ -20,12 +20,11 @@ import { Close as CloseIcon } from '@mui/icons-material'; import { ClickAwayListener, Drawer, IconButton, Typography } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { SORTING_OPTIONS, TIMEOUTS } from '@northern.tech/store/constants'; +import { getIdAttribute, getMappedDevicesList, getUserSettings } from '@northern.tech/store/selectors'; +import { setDeviceListState, setSearchState } from '@northern.tech/store/thunks'; import pluralize from 'pluralize'; -import { setSearchState } from '../actions/appActions'; -import { setDeviceListState } from '../actions/deviceActions'; -import { SORTING_OPTIONS, TIMEOUTS } from '../constants/appConstants'; -import { getIdAttribute, getMappedDevicesList, getUserSettings } from '../selectors'; import { getHeaders } from './devices/authorized-devices'; import { routes } from './devices/base-devices'; import Devicelist from './devices/devicelist'; diff --git a/frontend/src/js/components/settings/accesstokenmanagement.js b/frontend/src/js/components/settings/accesstokenmanagement.js index 36c4c387..add241f1 100644 --- a/frontend/src/js/components/settings/accesstokenmanagement.js +++ b/frontend/src/js/components/settings/accesstokenmanagement.js @@ -35,10 +35,11 @@ import { } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { generateToken, getTokens, revokeToken } from '../../actions/userActions'; -import { canAccess as canShow } from '../../constants/appConstants'; +import { canAccess as canShow } from '@northern.tech/store/constants'; +import { getCurrentUser, getIsEnterprise } from '@northern.tech/store/selectors'; +import { generateToken, getTokens, revokeToken } from '@northern.tech/store/thunks'; + import { customSort, toggle } from '../../helpers'; -import { getCurrentUser, getIsEnterprise } from '../../selectors'; import CopyCode from '../common/copy-code'; import Time, { RelativeTime } from '../common/time'; @@ -221,7 +222,10 @@ export const AccessTokenManagement = () => { setCurrentToken(token); }; - const onGenerateClick = config => dispatch(generateToken(config)).then(results => setCurrentToken(results[results.length - 1])); + const onGenerateClick = config => + dispatch(generateToken(config)) + .unwrap() + .then(results => setCurrentToken(results[results.length - 1])); const hasLastUsedInfo = useMemo(() => tokens.some(token => !!token.last_used), [tokens]); @@ -255,13 +259,16 @@ export const AccessTokenManagement = () => { - {tokens.sort(customSort(true, creationTimeAttribute)).map(token => ( - - {columns.map(column => ( - {column.render({ onRevokeTokenClick, token })} - ))} - - ))} + {tokens + .slice() + .sort(customSort(true, creationTimeAttribute)) + .map(token => ( + + {columns.map(column => ( + {column.render({ onRevokeTokenClick, token })} + ))} + + ))} )} diff --git a/frontend/src/js/components/settings/accesstokenmanagement.test.js b/frontend/src/js/components/settings/accesstokenmanagement.test.js index 04112007..14b7e6c1 100644 --- a/frontend/src/js/components/settings/accesstokenmanagement.test.js +++ b/frontend/src/js/components/settings/accesstokenmanagement.test.js @@ -13,12 +13,12 @@ // limitations under the License. import React from 'react'; +import * as UserActions from '@northern.tech/store/usersSlice/thunks'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { accessTokens, defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import * as UserActions from '../../actions/userActions'; import AccessTokenManagement, { AccessTokenCreationDialog, AccessTokenRevocationDialog } from './accesstokenmanagement'; const preloadedState = { diff --git a/frontend/src/js/components/settings/addonselection.js b/frontend/src/js/components/settings/addonselection.js index 2e195028..a36c936a 100644 --- a/frontend/src/js/components/settings/addonselection.js +++ b/frontend/src/js/components/settings/addonselection.js @@ -15,7 +15,8 @@ import React, { useMemo } from 'react'; import { Checkbox } from '@mui/material'; -import { ADDONS, PLANS } from '../../constants/appConstants'; +import { ADDONS, PLANS } from '@northern.tech/store/constants'; + import InfoText from '../common/infotext'; import { useStyles } from './planselection'; diff --git a/frontend/src/js/components/settings/artifactgeneration.js b/frontend/src/js/components/settings/artifactgeneration.js index 8f506e4e..be7b8d1f 100644 --- a/frontend/src/js/components/settings/artifactgeneration.js +++ b/frontend/src/js/components/settings/artifactgeneration.js @@ -19,9 +19,10 @@ import { InfoOutlined as InfoOutlinedIcon } from '@mui/icons-material'; import { Checkbox, FormControlLabel, TextField, Typography, formControlLabelClasses, textFieldClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { BENEFITS, TIMEOUTS } from '@northern.tech/store/constants'; +import { getDeploymentsConfig, saveDeltaDeploymentsConfig } from '@northern.tech/store/thunks'; + import DeltaIcon from '../../../assets/img/deltaicon.svg'; -import { getDeploymentsConfig, saveDeltaDeploymentsConfig } from '../../actions/deploymentActions'; -import { BENEFITS, TIMEOUTS } from '../../constants/appConstants'; import { useDebounce } from '../../utils/debouncehook'; import EnterpriseNotification from '../common/enterpriseNotification'; import InfoText from '../common/infotext'; diff --git a/frontend/src/js/components/settings/global.js b/frontend/src/js/components/settings/global.js index 0d453927..911ce6ff 100644 --- a/frontend/src/js/components/settings/global.js +++ b/frontend/src/js/components/settings/global.js @@ -17,13 +17,7 @@ import { useDispatch, useSelector } from 'react-redux'; import { Button, Checkbox, FormControl, FormControlLabel, FormHelperText, InputLabel, MenuItem, Select, Switch, TextField, Typography } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { getDeviceAttributes } from '../../actions/deviceActions'; -import { changeNotificationSetting } from '../../actions/monitorActions'; -import { getGlobalSettings, saveGlobalSettings } from '../../actions/userActions'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { DEVICE_ONLINE_CUTOFF } from '../../constants/deviceConstants'; -import { alertChannels } from '../../constants/monitorConstants'; -import { settingsKeys } from '../../constants/userConstants'; +import { DEVICE_ONLINE_CUTOFF, TIMEOUTS, alertChannels, settingsKeys } from '@northern.tech/store/constants'; import { getDeviceIdentityAttributes, getFeatures, @@ -33,7 +27,9 @@ import { getTenantCapabilities, getUserCapabilities, getUserRoles -} from '../../selectors'; +} from '@northern.tech/store/selectors'; +import { changeNotificationSetting, getDeviceAttributes, getGlobalSettings, saveGlobalSettings } from '@northern.tech/store/thunks'; + import { useDebounce } from '../../utils/debouncehook'; import DocsLink from '../common/docslink'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips'; @@ -152,7 +148,7 @@ export const GlobalSettingsDialog = ({ if (!window.sessionStorage.getItem(settingsKeys.initialized) || !timer.current || !canManageUsers) { return; } - saveGlobalSettings({ offlineThreshold: { interval: debouncedOfflineThreshold, intervalUnit: DEVICE_ONLINE_CUTOFF.intervalName } }, false, true); + saveGlobalSettings({ offlineThreshold: { interval: debouncedOfflineThreshold, intervalUnit: DEVICE_ONLINE_CUTOFF.intervalName }, notify: true }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [canManageUsers, debouncedOfflineThreshold, saveGlobalSettings]); @@ -165,7 +161,7 @@ export const GlobalSettingsDialog = ({ const onNotificationSettingsClick = ({ target: { checked } }, channel) => { setChannelSettings({ ...channelSettings, channel: { enabled: !checked } }); - onChangeNotificationSetting(!checked, channel); + onChangeNotificationSetting({ enabled: !checked, channel }); }; const onChangeOfflineInterval = ({ target: { validity, value } }) => { @@ -282,7 +278,7 @@ export const GlobalSettingsContainer = ({ closeDialog, dialog }) => { const onSaveGlobalSettings = useCallback((...args) => dispatch(saveGlobalSettings(...args)), [dispatch]); const saveAttributeSetting = (e, id_attribute) => - onSaveGlobalSettings({ ...updatedSettings, id_attribute }, false, true).then(() => { + onSaveGlobalSettings({ ...updatedSettings, id_attribute, notify: true }).then(() => { if (dialog) { closeDialog(e); } diff --git a/frontend/src/js/components/settings/global.test.js b/frontend/src/js/components/settings/global.test.js index 229a26c3..7a49e6a2 100644 --- a/frontend/src/js/components/settings/global.test.js +++ b/frontend/src/js/components/settings/global.test.js @@ -13,11 +13,11 @@ // limitations under the License. import React from 'react'; +import { TIMEOUTS } from '@northern.tech/store/constants'; import { act, screen, waitFor } from '@testing-library/react'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { TIMEOUTS } from '../../constants/appConstants'; import Global from './global'; const preloadedState = { diff --git a/frontend/src/js/components/settings/integrations.js b/frontend/src/js/components/settings/integrations.js index 9c36e644..0ecb3907 100644 --- a/frontend/src/js/components/settings/integrations.js +++ b/frontend/src/js/components/settings/integrations.js @@ -17,11 +17,11 @@ import { useDispatch, useSelector } from 'react-redux'; import { Button, Divider, MenuItem, Select, TextField } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { changeIntegration, createIntegration, deleteIntegration, getIntegrations } from '../../actions/organizationActions'; -import { TIMEOUTS } from '../../constants/appConstants'; -import { EXTERNAL_PROVIDER } from '../../constants/deviceConstants'; +import { EXTERNAL_PROVIDER, TIMEOUTS } from '@northern.tech/store/constants'; +import { getExternalIntegrations, getIsPreview } from '@northern.tech/store/selectors'; +import { changeIntegration, createIntegration, deleteIntegration, getIntegrations } from '@northern.tech/store/thunks'; + import { customSort } from '../../helpers'; -import { getExternalIntegrations, getIsPreview } from '../../selectors'; import { useDebounce } from '../../utils/debouncehook'; import Confirm from '../common/confirm'; import InfoHint from '../common/info-hint'; diff --git a/frontend/src/js/components/settings/integrations.test.js b/frontend/src/js/components/settings/integrations.test.js index fc8b1a63..34e62d03 100644 --- a/frontend/src/js/components/settings/integrations.test.js +++ b/frontend/src/js/components/settings/integrations.test.js @@ -13,11 +13,11 @@ // limitations under the License. import React from 'react'; +import { EXTERNAL_PROVIDER } from '@northern.tech/store/constants'; import { act } from '@testing-library/react'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { EXTERNAL_PROVIDER } from '../../constants/deviceConstants'; import { IntegrationConfiguration, Integrations } from './integrations'; const integrations = [ diff --git a/frontend/src/js/components/settings/organization/billing.js b/frontend/src/js/components/settings/organization/billing.js index aa920f8f..4a60bd27 100644 --- a/frontend/src/js/components/settings/organization/billing.js +++ b/frontend/src/js/components/settings/organization/billing.js @@ -20,13 +20,13 @@ import { Error as ErrorIcon, OpenInNew as OpenInNewIcon } from '@mui/icons-mater import { LinearProgress, List } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { ADDONS, PLANS } from '@northern.tech/store/constants'; +import { getAcceptedDevices, getDeviceLimit, getIsEnterprise, getOrganization, getUserRoles } from '@northern.tech/store/selectors'; +import { cancelRequest } from '@northern.tech/store/thunks'; import dayjs from 'dayjs'; import relativeTime from 'dayjs/plugin/relativeTime'; -import { cancelRequest } from '../../../actions/organizationActions'; -import { ADDONS, PLANS } from '../../../constants/appConstants'; import { toggle } from '../../../helpers'; -import { getAcceptedDevices, getDeviceLimit, getIsEnterprise, getOrganization, getUserRoles } from '../../../selectors'; import Alert from '../../common/alert'; import CancelRequestDialog from '../dialogs/cancelrequest'; import OrganizationPaymentSettings from './organizationpaymentsettings'; @@ -123,7 +123,7 @@ export const Billing = () => { }, []) || []; const cancelSubscriptionSubmit = async reason => - dispatch(cancelRequest(organization.id, reason)).then(() => { + dispatch(cancelRequest(reason)).then(() => { setCancelSubscription(false); setCancelSubscriptionConfirmation(true); }); diff --git a/frontend/src/js/components/settings/organization/organization.js b/frontend/src/js/components/settings/organization/organization.js index 415c8328..3cf398ca 100644 --- a/frontend/src/js/components/settings/organization/organization.js +++ b/frontend/src/js/components/settings/organization/organization.js @@ -20,28 +20,22 @@ import { FileCopy as CopyPasteIcon } from '@mui/icons-material'; import { Button, Checkbox, Collapse, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, List, MenuItem, Select } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import storeActions from '@northern.tech/store/actions'; +import { SSO_TYPES, TIMEOUTS, yes } from '@northern.tech/store/constants'; +import { getCurrentSession, getFeatures, getIsEnterprise, getIsPreview, getOrganization, getSsoConfig, getUserRoles } from '@northern.tech/store/selectors'; +import { changeSsoConfig, deleteSsoConfig, downloadLicenseReport, getSsoConfigs, getUserOrganization, storeSsoConfig } from '@northern.tech/store/thunks'; import copy from 'copy-to-clipboard'; import dayjs from 'dayjs'; -import { setSnackbar } from '../../../actions/appActions'; -import { - changeSsoConfig, - deleteSsoConfig, - downloadLicenseReport, - getSsoConfigs, - getUserOrganization, - storeSsoConfig -} from '../../../actions/organizationActions'; -import { TIMEOUTS, yes } from '../../../constants/appConstants'; -import { SSO_TYPES } from '../../../constants/organizationConstants.js'; import { createFileDownload, toggle } from '../../../helpers'; -import { getCurrentSession, getFeatures, getIsEnterprise, getIsPreview, getOrganization, getSsoConfig, getUserRoles } from '../../../selectors'; import ExpandableAttribute from '../../common/expandable-attribute'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; import Billing from './billing'; import OrganizationSettingsItem, { maxWidth } from './organizationsettingsitem'; import { SSOConfig } from './ssoconfig'; +const { setSnackbar } = storeActions; + const useStyles = makeStyles()(theme => ({ copyNotification: { height: 15 }, deviceLimitBar: { backgroundColor: theme.palette.grey[500], margin: '15px 0' }, @@ -139,7 +133,9 @@ export const Organization = () => { const onTokenExpansion = useCallback(() => setShowTokenWarning(true), []); const onDownloadReportClick = () => - dispatch(downloadLicenseReport()).then(report => createFileDownload(report, `Mender-license-report-${dayjs().format('YYYY-MM-DD')}`, token)); + dispatch(downloadLicenseReport()) + .unwrap() + .then(report => createFileDownload(report, `Mender-license-report-${dayjs().format('YYYY-MM-DD')}`, token)); const onTenantInfoClick = () => { copy(`Organization: ${org.name}, Tenant ID: ${org.id}`); diff --git a/frontend/src/js/components/settings/organization/organization.test.js b/frontend/src/js/components/settings/organization/organization.test.js index 0309bec9..146e1e37 100644 --- a/frontend/src/js/components/settings/organization/organization.test.js +++ b/frontend/src/js/components/settings/organization/organization.test.js @@ -15,13 +15,13 @@ import React from 'react'; import { drawerClasses } from '@mui/material'; +import { getSessionInfo } from '@northern.tech/store/auth'; import { act, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import MockDate from 'mockdate'; import { defaultState, undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import { getSessionInfo } from '../../../auth'; import { CancelSubscriptionAlert, CancelSubscriptionButton, DeviceLimitExpansionNotification, TrialExpirationNote } from './billing'; import MyOrganization, { OrgHeader } from './organization'; diff --git a/frontend/src/js/components/settings/organization/organizationpaymentsettings.js b/frontend/src/js/components/settings/organization/organizationpaymentsettings.js index b6322b50..a6e05f8d 100644 --- a/frontend/src/js/components/settings/organization/organizationpaymentsettings.js +++ b/frontend/src/js/components/settings/organization/organizationpaymentsettings.js @@ -17,11 +17,14 @@ import { useDispatch, useSelector } from 'react-redux'; // material ui import { Error as ErrorIcon } from '@mui/icons-material'; -import { setSnackbar } from '../../../actions/appActions'; -import { confirmCardUpdate, getCurrentCard, startCardUpdate } from '../../../actions/organizationActions'; +import storeActions from '@northern.tech/store/actions'; +import { confirmCardUpdate, getCurrentCard, startCardUpdate } from '@northern.tech/store/thunks'; + import CardSection from '../cardsection'; import OrganizationSettingsItem from './organizationsettingsitem'; +const { setSnackbar } = storeActions; + export const OrganizationPaymentSettings = () => { const [isUpdatingPaymentDetails, setIsUpdatingPaymentDetails] = useState(false); const card = useSelector(state => state.organization.card); diff --git a/frontend/src/js/components/settings/organization/ssoconfig.js b/frontend/src/js/components/settings/organization/ssoconfig.js index feb1d169..dbaea672 100644 --- a/frontend/src/js/components/settings/organization/ssoconfig.js +++ b/frontend/src/js/components/settings/organization/ssoconfig.js @@ -20,7 +20,8 @@ import { Button } from '@mui/material'; import { listItemTextClasses } from '@mui/material/ListItemText'; import { makeStyles } from 'tss-react/mui'; -import { SSO_TYPES, XML_METADATA_FORMAT } from '../../../constants/organizationConstants.js'; +import { SSO_TYPES, XML_METADATA_FORMAT } from '@northern.tech/store/constants'; + import { toggle } from '../../../helpers'; import ExpandableAttribute from '../../common/expandable-attribute'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; diff --git a/frontend/src/js/components/settings/organization/ssoconfig.test.js b/frontend/src/js/components/settings/organization/ssoconfig.test.js index 365148c2..18cc7f00 100644 --- a/frontend/src/js/components/settings/organization/ssoconfig.test.js +++ b/frontend/src/js/components/settings/organization/ssoconfig.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { SSO_TYPES } from '@northern.tech/store/constants'; + import { undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import { SSO_TYPES } from '../../../constants/organizationConstants.js'; import SSOConfig from './ssoconfig'; describe('SamlConfig Component', () => { diff --git a/frontend/src/js/components/settings/organization/ssoeditor.js b/frontend/src/js/components/settings/organization/ssoeditor.js index 42d42e63..7f41ee4f 100644 --- a/frontend/src/js/components/settings/organization/ssoeditor.js +++ b/frontend/src/js/components/settings/organization/ssoeditor.js @@ -19,9 +19,9 @@ import { Close as CloseIcon, CloudUpload, FileCopyOutlined as CopyPasteIcon } fr import { Button, Divider, Drawer, IconButton } from '@mui/material'; import Editor, { loader } from '@monaco-editor/react'; +import { JSON_METADATA_FORMAT, XML_METADATA_FORMAT } from '@northern.tech/store/constants'; import copy from 'copy-to-clipboard'; -import { JSON_METADATA_FORMAT, XML_METADATA_FORMAT } from '../../../constants/organizationConstants.js'; import { createFileDownload } from '../../../helpers'; import Loader from '../../common/loader'; diff --git a/frontend/src/js/components/settings/organization/ssoeditor.test.js b/frontend/src/js/components/settings/organization/ssoeditor.test.js index aae62d15..47776139 100644 --- a/frontend/src/js/components/settings/organization/ssoeditor.test.js +++ b/frontend/src/js/components/settings/organization/ssoeditor.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { SSO_TYPES } from '@northern.tech/store/constants'; + import { undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import { SSO_TYPES } from '../../../constants/organizationConstants.js'; import SSOEditor from './ssoeditor'; describe('SSOEditor Component', () => { diff --git a/frontend/src/js/components/settings/planselection.js b/frontend/src/js/components/settings/planselection.js index 578318cb..f360d43d 100644 --- a/frontend/src/js/components/settings/planselection.js +++ b/frontend/src/js/components/settings/planselection.js @@ -15,8 +15,9 @@ import React from 'react'; import { makeStyles } from 'tss-react/mui'; -import { PLANS } from '../../constants/appConstants'; -import { isDarkMode } from '../../helpers.js'; +import { PLANS } from '@northern.tech/store/constants'; +import { isDarkMode } from '@northern.tech/store/utils'; + import InfoText from '../common/infotext'; export const useStyles = makeStyles()(theme => ({ diff --git a/frontend/src/js/components/settings/quoterequestform.js b/frontend/src/js/components/settings/quoterequestform.js index 2640c811..347be3b8 100644 --- a/frontend/src/js/components/settings/quoterequestform.js +++ b/frontend/src/js/components/settings/quoterequestform.js @@ -15,7 +15,7 @@ import React, { useState } from 'react'; import { Button, FormControl, FormHelperText, TextField } from '@mui/material'; -import { ADDONS, PLANS } from '../../constants/appConstants'; +import { ADDONS, PLANS } from '@northern.tech/store/constants'; const quoteRequest = { default: { diff --git a/frontend/src/js/components/settings/reportinglimits.js b/frontend/src/js/components/settings/reportinglimits.js index 228bbea8..8cbb21ee 100644 --- a/frontend/src/js/components/settings/reportinglimits.js +++ b/frontend/src/js/components/settings/reportinglimits.js @@ -32,7 +32,8 @@ import { } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { getReportingLimits } from '../../actions/deviceActions'; +import { getReportingLimits } from '@northern.tech/store/thunks'; + import { toggle } from '../../helpers'; import { InfoHintContainer } from '../common/info-hint'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../helptips/helptooltips'; diff --git a/frontend/src/js/components/settings/roledefinition.js b/frontend/src/js/components/settings/roledefinition.js index 4674c7ea..6b53f9e3 100644 --- a/frontend/src/js/components/settings/roledefinition.js +++ b/frontend/src/js/components/settings/roledefinition.js @@ -36,11 +36,18 @@ import { } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { + ALL_DEVICES, + ALL_RELEASES, + emptyRole, + emptyUiPermissions, + itemUiPermissionsReducer, + rolesById, + uiPermissionsByArea, + uiPermissionsById +} from '@northern.tech/store/constants'; import validator from 'validator'; -import { ALL_DEVICES } from '../../constants/deviceConstants'; -import { ALL_RELEASES } from '../../constants/releaseConstants'; -import { emptyRole, emptyUiPermissions, itemUiPermissionsReducer, rolesById, uiPermissionsByArea, uiPermissionsById } from '../../constants/userConstants'; import { deepCompare, isEmpty, toggle } from '../../helpers'; const menuProps = { diff --git a/frontend/src/js/components/settings/roledefinition.test.js b/frontend/src/js/components/settings/roledefinition.test.js index 2b800caf..6f4069d0 100644 --- a/frontend/src/js/components/settings/roledefinition.test.js +++ b/frontend/src/js/components/settings/roledefinition.test.js @@ -13,12 +13,11 @@ // limitations under the License. import React from 'react'; +import { ALL_DEVICES, emptyRole } from '@northern.tech/store/constants'; import { screen } from '@testing-library/react'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { ALL_DEVICES } from '../../constants/deviceConstants.js'; -import { emptyRole } from '../../constants/userConstants'; import RoleDefinition from './roledefinition'; describe('Roles Component', () => { diff --git a/frontend/src/js/components/settings/roles.js b/frontend/src/js/components/settings/roles.js index 7bd26652..ea0a7230 100644 --- a/frontend/src/js/components/settings/roles.js +++ b/frontend/src/js/components/settings/roles.js @@ -18,12 +18,10 @@ import { useDispatch, useSelector } from 'react-redux'; import { Add as AddIcon, ArrowRightAlt as ArrowRightAltIcon } from '@mui/icons-material'; import { Chip } from '@mui/material'; -import { getDynamicGroups, getGroups } from '../../actions/deviceActions'; -import { getExistingReleaseTags } from '../../actions/releaseActions.js'; -import { createRole, editRole, getRoles, removeRole } from '../../actions/userActions'; -import { BENEFITS } from '../../constants/appConstants'; -import { emptyRole, rolesById } from '../../constants/userConstants'; -import { getGroupsByIdWithoutUngrouped, getIsEnterprise, getReleaseTagsById, getRolesList } from '../../selectors'; +import { BENEFITS, emptyRole, rolesById } from '@northern.tech/store/constants'; +import { getGroupsByIdWithoutUngrouped, getIsEnterprise, getReleaseTagsById, getRolesList } from '@northern.tech/store/selectors'; +import { createRole, editRole, getDynamicGroups, getExistingReleaseTags, getGroups, getRoles, removeRole } from '@northern.tech/store/thunks'; + import DetailsTable from '../common/detailstable'; import { DocsTooltip } from '../common/docslink'; import EnterpriseNotification from '../common/enterpriseNotification'; diff --git a/frontend/src/js/components/settings/roles.test.js b/frontend/src/js/components/settings/roles.test.js index 6ae80fda..380275a7 100644 --- a/frontend/src/js/components/settings/roles.test.js +++ b/frontend/src/js/components/settings/roles.test.js @@ -13,14 +13,13 @@ // limitations under the License. import React from 'react'; +import { ALL_DEVICES, ALL_RELEASES } from '@northern.tech/store/constants'; +import * as UserActions from '@northern.tech/store/usersSlice/thunks'; import { act, screen, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render, selectMaterialUiSelectOption } from '../../../../tests/setupTests'; -import * as UserActions from '../../actions/userActions'; -import { ALL_DEVICES } from '../../constants/deviceConstants'; -import { ALL_RELEASES } from '../../constants/releaseConstants'; import Roles from './roles'; describe('Roles Component', () => { diff --git a/frontend/src/js/components/settings/settings.js b/frontend/src/js/components/settings/settings.js index 69f12352..9702f567 100644 --- a/frontend/src/js/components/settings/settings.js +++ b/frontend/src/js/components/settings/settings.js @@ -18,10 +18,10 @@ import { Navigate, useParams } from 'react-router-dom'; // material ui import { Payment as PaymentIcon } from '@mui/icons-material'; +import { TIMEOUTS, canAccess } from '@northern.tech/store/constants'; +import { getCurrentUser, getFeatures, getOrganization, getTenantCapabilities, getUserCapabilities, getUserRoles } from '@northern.tech/store/selectors'; import { Elements } from '@stripe/react-stripe-js'; -import { TIMEOUTS, canAccess } from '../../constants/appConstants'; -import { getCurrentUser, getFeatures, getOrganization, getTenantCapabilities, getUserCapabilities, getUserRoles } from '../../selectors'; import LeftNav from '../common/left-nav'; import SelfUserManagement from '../settings/user-management/selfusermanagement'; import UserManagement from '../settings/user-management/usermanagement'; diff --git a/frontend/src/js/components/settings/settings.test.js b/frontend/src/js/components/settings/settings.test.js index 59cff725..00199ff6 100644 --- a/frontend/src/js/components/settings/settings.test.js +++ b/frontend/src/js/components/settings/settings.test.js @@ -15,11 +15,11 @@ import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter, Route, Routes } from 'react-router-dom'; +import { getSessionInfo } from '@northern.tech/store/auth'; +import { getConfiguredStore } from '@northern.tech/store/store'; import { act, render as testingLibRender } from '@testing-library/react'; import { defaultState, undefineds } from '../../../../tests/mockData'; -import { getSessionInfo } from '../../auth'; -import { getConfiguredStore } from '../../reducers'; import Settings from './settings'; describe('Settings Component', () => { diff --git a/frontend/src/js/components/settings/upgrade.js b/frontend/src/js/components/settings/upgrade.js index 70595c57..70af7a14 100644 --- a/frontend/src/js/components/settings/upgrade.js +++ b/frontend/src/js/components/settings/upgrade.js @@ -17,13 +17,12 @@ import { useNavigate } from 'react-router-dom'; import { InfoOutlined as InfoOutlinedIcon, LocalOffer as LocalOfferIcon } from '@mui/icons-material'; +import storeActions from '@northern.tech/store/actions'; +import { PLANS, TIMEOUTS } from '@northern.tech/store/constants'; +import { getFeatures, getOrganization } from '@northern.tech/store/selectors'; +import { cancelUpgrade, completeUpgrade, getDeviceLimit, getUserOrganization, requestPlanChange, startUpgrade } from '@northern.tech/store/thunks'; import dayjs from 'dayjs'; -import { setSnackbar } from '../../actions/appActions'; -import { getDeviceLimit } from '../../actions/deviceActions'; -import { cancelUpgrade, completeUpgrade, getUserOrganization, requestPlanChange, startUpgrade } from '../../actions/organizationActions'; -import { PLANS, TIMEOUTS } from '../../constants/appConstants'; -import { getFeatures, getOrganization } from '../../selectors'; import InfoText from '../common/infotext'; import Loader from '../common/loader'; import AddOnSelection from './addonselection'; @@ -31,6 +30,8 @@ import CardSection from './cardsection'; import PlanSelection from './planselection'; import QuoteRequestForm from './quoterequestform'; +const { setSnackbar } = storeActions; + const offerTag = ( End of year offer @@ -105,7 +106,7 @@ export const Upgrade = () => { } const handleUpgrade = async () => - dispatch(completeUpgrade(org.id, updatedPlan)).then(() => { + dispatch(completeUpgrade({ tenantId: org.id, plan: updatedPlan })).then(() => { setUpgraded(true); setTimeout(() => { dispatch(getDeviceLimit()); @@ -125,12 +126,15 @@ export const Upgrade = () => { const onSendRequest = (message, addons = addOns) => dispatch( - requestPlanChange(org.id, { - current_plan: PLANS[org.plan || PLANS.os.id].name, - requested_plan: PLANS[updatedPlan].name, - current_addons: addOnsToString(org.addons) || '-', - requested_addons: addOnsToString(addons) || '-', - user_message: message + requestPlanChange({ + tenantId: org.id, + content: { + current_plan: PLANS[org.plan || PLANS.os.id].name, + requested_plan: PLANS[updatedPlan].name, + current_addons: addOnsToString(org.addons) || '-', + requested_addons: addOnsToString(addons) || '-', + user_message: message + } }) ); diff --git a/frontend/src/js/components/settings/upgrade.test.js b/frontend/src/js/components/settings/upgrade.test.js index f893a574..eb708ffb 100644 --- a/frontend/src/js/components/settings/upgrade.test.js +++ b/frontend/src/js/components/settings/upgrade.test.js @@ -13,12 +13,12 @@ // limitations under the License. import React from 'react'; +import { getSessionInfo } from '@northern.tech/store/auth'; import { Elements } from '@stripe/react-stripe-js'; import { loadStripe } from '@stripe/stripe-js'; import { defaultState, undefineds } from '../../../../tests/mockData'; import { render } from '../../../../tests/setupTests'; -import { getSessionInfo } from '../../auth'; import Upgrade, { PostUpgradeNote, PricingContactNote } from './upgrade'; describe('smaller components', () => { diff --git a/frontend/src/js/components/settings/user-management/selfusermanagement.js b/frontend/src/js/components/settings/user-management/selfusermanagement.js index 1ac31411..0084cd6f 100644 --- a/frontend/src/js/components/settings/user-management/selfusermanagement.js +++ b/frontend/src/js/components/settings/user-management/selfusermanagement.js @@ -17,12 +17,12 @@ import { useDispatch, useSelector } from 'react-redux'; import { Button, Switch, TextField } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; -import { setSnackbar } from '../../../actions/appActions'; -import { editUser, saveUserSettings } from '../../../actions/userActions'; -import { DARK_MODE, LIGHT_MODE } from '../../../constants/appConstants'; -import * as UserConstants from '../../../constants/userConstants'; -import { isDarkMode, toggle } from '../../../helpers'; -import { getCurrentSession, getCurrentUser, getFeatures, getIsEnterprise, getUserSettings } from '../../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { DARK_MODE, LIGHT_MODE, OWN_USER_ID } from '@northern.tech/store/constants'; +import { getCurrentSession, getCurrentUser, getFeatures, getIsDarkMode, getIsEnterprise, getUserSettings } from '@northern.tech/store/selectors'; +import { editUser, saveUserSettings } from '@northern.tech/store/thunks'; + +import { toggle } from '../../../helpers'; import ExpandableAttribute from '../../common/expandable-attribute'; import Form from '../../common/forms/form'; import PasswordInput from '../../common/forms/passwordinput'; @@ -33,6 +33,8 @@ import { CopyTextToClipboard } from '../organization/organization'; import TwoFactorAuthSetup from './twofactorauthsetup'; import { UserId, getUserSSOState } from './userdefinition'; +const { setSnackbar } = storeActions; + const useStyles = makeStyles()(() => ({ formField: { width: 400, maxWidth: '100%' }, changeButton: { margin: '30px 0 0 15px' }, @@ -56,24 +58,27 @@ export const SelfUserManagement = () => { const { isOAuth2, provider } = getUserSSOState(currentUser); const { email, id: userId } = currentUser; const hasTracking = useSelector(state => !!state.app.trackerCode); - const { trackingConsentGiven: hasTrackingConsent, mode } = useSelector(getUserSettings); + const { trackingConsentGiven: hasTrackingConsent } = useSelector(getUserSettings); + const isDarkMode = useSelector(getIsDarkMode); const { token } = useSelector(getCurrentSession); const editSubmit = userData => { if (userData.password != userData.password_confirmation) { dispatch(setSnackbar(`The passwords don't match`)); } else { - dispatch(editUser(UserConstants.OWN_USER_ID, userData)).then(() => { - setEditEmail(false); - setEditPass(false); - }); + dispatch(editUser({ ...userData, id: OWN_USER_ID })) + .unwrap() + .then(() => { + setEditEmail(false); + setEditPass(false); + }); } }; const handleEmail = () => setEditEmail(toggle); const toggleMode = () => { - const newMode = isDarkMode(mode) ? LIGHT_MODE : DARK_MODE; + const newMode = isDarkMode ? LIGHT_MODE : DARK_MODE; dispatch(saveUserSettings({ mode: newMode })); }; @@ -118,7 +123,7 @@ export const SelfUserManagement = () => { ))}

Enable dark theme

- +
{!isOAuth2 ? ( canHave2FA && diff --git a/frontend/src/js/components/settings/user-management/selfusermanagement.test.js b/frontend/src/js/components/settings/user-management/selfusermanagement.test.js index 64d00930..c449d154 100644 --- a/frontend/src/js/components/settings/user-management/selfusermanagement.test.js +++ b/frontend/src/js/components/settings/user-management/selfusermanagement.test.js @@ -13,13 +13,13 @@ // limitations under the License. import React from 'react'; +import { getSessionInfo } from '@northern.tech/store/auth'; +import { yes } from '@northern.tech/store/constants'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { defaultState, undefineds } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import { getSessionInfo } from '../../../auth'; -import { yes } from '../../../constants/appConstants'; import SelfUserManagement from './selfusermanagement'; describe('SelfUserManagement Component', () => { diff --git a/frontend/src/js/components/settings/user-management/twofactorauth-steps/authsetup.js b/frontend/src/js/components/settings/user-management/twofactorauth-steps/authsetup.js index 59526b4b..e6b61be8 100644 --- a/frontend/src/js/components/settings/user-management/twofactorauth-steps/authsetup.js +++ b/frontend/src/js/components/settings/user-management/twofactorauth-steps/authsetup.js @@ -16,7 +16,8 @@ import React, { useEffect, useRef, useState } from 'react'; import { CheckCircle as CheckCircleIcon } from '@mui/icons-material'; import { Button } from '@mui/material'; -import { twoFAStates } from '../../../../constants/userConstants'; +import { twoFAStates } from '@northern.tech/store/constants'; + import Form from '../../../common/forms/form'; import TextInput from '../../../common/forms/textinput'; import Loader from '../../../common/loader'; diff --git a/frontend/src/js/components/settings/user-management/twofactorauth-steps/emailverification.js b/frontend/src/js/components/settings/user-management/twofactorauth-steps/emailverification.js index ec63fc9c..e77c2e46 100644 --- a/frontend/src/js/components/settings/user-management/twofactorauth-steps/emailverification.js +++ b/frontend/src/js/components/settings/user-management/twofactorauth-steps/emailverification.js @@ -15,7 +15,8 @@ import React, { useEffect, useState } from 'react'; import { Button } from '@mui/material'; -import { TIMEOUTS } from '../../../../constants/appConstants'; +import { TIMEOUTS } from '@northern.tech/store/constants'; + import Form from '../../../common/forms/form'; import TextInput from '../../../common/forms/textinput'; import Loader from '../../../common/loader'; diff --git a/frontend/src/js/components/settings/user-management/twofactorauthsetup.js b/frontend/src/js/components/settings/user-management/twofactorauthsetup.js index a2859b82..f7b1ca7b 100644 --- a/frontend/src/js/components/settings/user-management/twofactorauthsetup.js +++ b/frontend/src/js/components/settings/user-management/twofactorauthsetup.js @@ -16,14 +16,17 @@ import { useDispatch, useSelector } from 'react-redux'; import { Collapse, Switch } from '@mui/material'; -import { setSnackbar } from '../../../actions/appActions'; -import { disableUser2fa, enableUser2fa, get2FAQRCode, verify2FA, verifyEmailComplete, verifyEmailStart } from '../../../actions/userActions'; -import { twoFAStates } from '../../../constants/userConstants'; -import { getCurrentUser, getHas2FA } from '../../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { twoFAStates } from '@northern.tech/store/constants'; +import { getCurrentUser, getHas2FA } from '@northern.tech/store/selectors'; +import { disableUser2fa, enableUser2fa, get2FAQRCode, verify2FA, verifyEmailComplete, verifyEmailStart } from '@northern.tech/store/thunks'; + import InfoText from '../../common/infotext'; import AuthSetup from './twofactorauth-steps/authsetup'; import EmailVerification from './twofactorauth-steps/emailverification'; +const { setSnackbar } = storeActions; + export const TwoFactorAuthSetup = () => { const activationCode = useSelector(state => state.users.activationCode); const currentUser = useSelector(getCurrentUser); diff --git a/frontend/src/js/components/settings/user-management/userdefinition.js b/frontend/src/js/components/settings/user-management/userdefinition.js index b051938d..da2653af 100644 --- a/frontend/src/js/components/settings/user-management/userdefinition.js +++ b/frontend/src/js/components/settings/user-management/userdefinition.js @@ -30,10 +30,10 @@ import { } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { uiPermissionsByArea, uiPermissionsById } from '@northern.tech/store/constants'; +import { mapUserRolesToUiPermissions } from '@northern.tech/store/utils'; import validator from 'validator'; -import { mapUserRolesToUiPermissions } from '../../../actions/userActions'; -import { uiPermissionsByArea, uiPermissionsById } from '../../../constants/userConstants'; import { toggle } from '../../../helpers'; import { TwoColumnData } from '../../common/configurationobject'; import { OAuth2Providers, genericProvider } from '../../login/oauth2providers'; diff --git a/frontend/src/js/components/settings/user-management/userform.js b/frontend/src/js/components/settings/user-management/userform.js index af3752bb..9ac6e099 100644 --- a/frontend/src/js/components/settings/user-management/userform.js +++ b/frontend/src/js/components/settings/user-management/userform.js @@ -31,11 +31,10 @@ import { Tooltip } from '@mui/material'; +import { BENEFITS, rolesById, rolesByName, uiPermissionsById } from '@northern.tech/store/constants'; import pluralize from 'pluralize'; import { isUUID } from 'validator'; -import { BENEFITS } from '../../../constants/appConstants'; -import { rolesById, rolesByName, uiPermissionsById } from '../../../constants/userConstants'; import EnterpriseNotification from '../../common/enterpriseNotification'; import Form from '../../common/forms/form'; import FormCheckbox from '../../common/forms/formcheckbox'; diff --git a/frontend/src/js/components/settings/user-management/userlist.js b/frontend/src/js/components/settings/user-management/userlist.js index 92b8ed86..a52bfa83 100644 --- a/frontend/src/js/components/settings/user-management/userlist.js +++ b/frontend/src/js/components/settings/user-management/userlist.js @@ -17,7 +17,8 @@ import React, { useMemo } from 'react'; import { ArrowRightAlt as ArrowRightAltIcon, Check as CheckIcon } from '@mui/icons-material'; import { Chip } from '@mui/material'; -import { twoFAStates } from '../../../constants/userConstants'; +import { twoFAStates } from '@northern.tech/store/constants'; + import DetailsTable from '../../common/detailstable'; import Time, { RelativeTime } from '../../common/time'; diff --git a/frontend/src/js/components/settings/user-management/usermanagement.js b/frontend/src/js/components/settings/user-management/usermanagement.js index 7c47013e..951288c9 100644 --- a/frontend/src/js/components/settings/user-management/usermanagement.js +++ b/frontend/src/js/components/settings/user-management/usermanagement.js @@ -18,13 +18,16 @@ import { Add as AddIcon } from '@mui/icons-material'; // material ui import { Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle } from '@mui/material'; -import { setSnackbar } from '../../../actions/appActions'; -import { addUserToCurrentTenant, createUser, editUser, getUserList, passwordResetStart, removeUser } from '../../../actions/userActions'; -import { getCurrentUser, getFeatures, getIsEnterprise, getRolesById, getUserCapabilities } from '../../../selectors'; +import storeActions from '@northern.tech/store/actions'; +import { getCurrentUser, getFeatures, getIsEnterprise, getRolesById, getUserCapabilities } from '@northern.tech/store/selectors'; +import { addUserToCurrentTenant, createUser, editUser, getUserList, passwordResetStart, removeUser } from '@northern.tech/store/thunks'; + import { UserDefinition } from './userdefinition'; import UserForm from './userform'; import UserList from './userlist'; +const { setSnackbar } = storeActions; + const actions = { add: 'addUser', create: 'createUser', @@ -67,10 +70,10 @@ export const UserManagement = () => { const users = useSelector(state => Object.values(state.users.byId)); const props = { canManageUsers, - addUser: (id, tenantId) => dispatch(addUserToCurrentTenant(id, tenantId)), + addUser: id => dispatch(addUserToCurrentTenant(id)), createUser: userData => dispatch(createUser(userData)), currentUser, - editUser: (id, userData) => dispatch(editUser(id, userData)), + editUser: (id, userData) => dispatch(editUser({ ...userData, id })), isEnterprise, isHosted, removeUser: id => dispatch(removeUser(id)), diff --git a/frontend/src/js/components/settings/user-management/usermanagement.test.js b/frontend/src/js/components/settings/user-management/usermanagement.test.js index a851e40a..5fb068a3 100644 --- a/frontend/src/js/components/settings/user-management/usermanagement.test.js +++ b/frontend/src/js/components/settings/user-management/usermanagement.test.js @@ -13,13 +13,13 @@ // limitations under the License. import React from 'react'; +import { yes } from '@northern.tech/store/constants'; +import * as UserActions from '@northern.tech/store/usersSlice/thunks'; import { act, screen, waitFor, within } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; import { defaultState, undefineds, userId } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import * as UserActions from '../../../actions/userActions'; -import { yes } from '../../../constants/appConstants'; import UserManagement from './usermanagement'; const preloadedState = { diff --git a/frontend/src/js/components/settings/webhooks/activity.js b/frontend/src/js/components/settings/webhooks/activity.js index ac91a839..b86fad41 100644 --- a/frontend/src/js/components/settings/webhooks/activity.js +++ b/frontend/src/js/components/settings/webhooks/activity.js @@ -19,7 +19,8 @@ import { Accordion, AccordionDetails, AccordionSummary, accordionClasses } from import { accordionSummaryClasses } from '@mui/material/AccordionSummary'; import { makeStyles } from 'tss-react/mui'; -import { DEVICE_LIST_DEFAULTS } from '../../../constants/deviceConstants'; +import { DEVICE_LIST_DEFAULTS } from '@northern.tech/store/constants'; + import { toggle } from '../../../helpers'; import Pagination from '../../common/pagination'; import Time from '../../common/time'; diff --git a/frontend/src/js/components/settings/webhooks/configuration.js b/frontend/src/js/components/settings/webhooks/configuration.js index 2f852e4b..0352cfca 100644 --- a/frontend/src/js/components/settings/webhooks/configuration.js +++ b/frontend/src/js/components/settings/webhooks/configuration.js @@ -17,10 +17,9 @@ import React, { useCallback, useEffect, useState } from 'react'; import { Close as CloseIcon } from '@mui/icons-material'; import { Button, Divider, Drawer, IconButton, TextField } from '@mui/material'; +import { EXTERNAL_PROVIDER, emptyWebhook } from '@northern.tech/store/constants'; import validator from 'validator'; -import { EXTERNAL_PROVIDER } from '../../../constants/deviceConstants'; -import { emptyWebhook } from '../../../constants/organizationConstants'; import MenderTooltip from '../../common/mendertooltip'; import { HELPTOOLTIPS, MenderHelpTooltip } from '../../helptips/helptooltips'; diff --git a/frontend/src/js/components/settings/webhooks/management.js b/frontend/src/js/components/settings/webhooks/management.js index e5f648aa..d0fa0a79 100644 --- a/frontend/src/js/components/settings/webhooks/management.js +++ b/frontend/src/js/components/settings/webhooks/management.js @@ -17,7 +17,8 @@ import React, { useState } from 'react'; import { Close as CloseIcon } from '@mui/icons-material'; import { Button, Divider, Drawer, IconButton, Tab, Tabs } from '@mui/material'; -import { emptyWebhook } from '../../../constants/organizationConstants'; +import { emptyWebhook } from '@northern.tech/store/constants'; + import WebhookActivity from './activity'; import WebhookConfiguration from './configuration'; diff --git a/frontend/src/js/components/settings/webhooks/webhooks.js b/frontend/src/js/components/settings/webhooks/webhooks.js index 7110a36b..df87cfbc 100644 --- a/frontend/src/js/components/settings/webhooks/webhooks.js +++ b/frontend/src/js/components/settings/webhooks/webhooks.js @@ -17,9 +17,9 @@ import { useDispatch, useSelector } from 'react-redux'; // material ui import { ArrowRightAlt as ArrowRightAltIcon } from '@mui/icons-material'; -import { changeIntegration, createIntegration, deleteIntegration, getIntegrations, getWebhookEvents } from '../../../actions/organizationActions'; -import { EXTERNAL_PROVIDER } from '../../../constants/deviceConstants'; -import { emptyWebhook } from '../../../constants/organizationConstants'; +import { EXTERNAL_PROVIDER, emptyWebhook } from '@northern.tech/store/constants'; +import { changeIntegration, createIntegration, deleteIntegration, getIntegrations, getWebhookEvents } from '@northern.tech/store/thunks'; + import DetailsTable from '../../common/detailstable'; import DocsLink from '../../common/docslink'; import Time from '../../common/time'; diff --git a/frontend/src/js/components/settings/webhooks/webhooks.test.js b/frontend/src/js/components/settings/webhooks/webhooks.test.js index 6146d621..eedc0240 100644 --- a/frontend/src/js/components/settings/webhooks/webhooks.test.js +++ b/frontend/src/js/components/settings/webhooks/webhooks.test.js @@ -13,9 +13,10 @@ // limitations under the License. import React from 'react'; +import { EXTERNAL_PROVIDER } from '@northern.tech/store/constants'; + import { defaultState, undefineds, webhookEvents } from '../../../../../tests/mockData'; import { render } from '../../../../../tests/setupTests'; -import { EXTERNAL_PROVIDER } from '../../../constants/deviceConstants'; import Activity from './activity'; import { WebhookCreation } from './configuration'; import Management from './management'; diff --git a/frontend/src/js/components/uploads.js b/frontend/src/js/components/uploads.js index 3be01495..4c4f181b 100644 --- a/frontend/src/js/components/uploads.js +++ b/frontend/src/js/components/uploads.js @@ -18,9 +18,9 @@ import { Cancel as CancelIcon } from '@mui/icons-material'; import { Drawer, IconButton, LinearProgress, Tooltip, drawerClasses } from '@mui/material'; import { makeStyles } from 'tss-react/mui'; +import { cancelFileUpload } from '@northern.tech/store/thunks'; import pluralize from 'pluralize'; -import { cancelFileUpload } from '../actions/releaseActions'; import { FileSize } from '../helpers'; const useStyles = makeStyles()(theme => ({ diff --git a/frontend/src/js/config/routes.test.js b/frontend/src/js/config/routes.test.js index 2de27d82..6bb863cd 100644 --- a/frontend/src/js/config/routes.test.js +++ b/frontend/src/js/config/routes.test.js @@ -15,10 +15,10 @@ import React from 'react'; import { Provider } from 'react-redux'; import { MemoryRouter } from 'react-router-dom'; +import { getConfiguredStore } from '@northern.tech/store/store'; import { act, screen, render as testingLibRender } from '@testing-library/react'; import { defaultState } from '../../../tests/mockData'; -import { getConfiguredStore } from '../reducers'; import { PublicRoutes } from './routes'; describe('Router', () => { diff --git a/frontend/src/js/helpers.js b/frontend/src/js/helpers.js index 2a7868cc..799f5da6 100644 --- a/frontend/src/js/helpers.js +++ b/frontend/src/js/helpers.js @@ -16,16 +16,6 @@ import React from 'react'; import pluralize from 'pluralize'; import Cookies from 'universal-cookie'; -import { DARK_MODE } from './constants/appConstants'; -import { - DEPLOYMENT_STATES, - defaultStats, - deploymentDisplayStates, - deploymentStatesToSubstates, - deploymentStatesToSubstatesWithSkipped -} from './constants/deploymentConstants'; -import { ATTRIBUTE_SCOPES, DEVICE_FILTERING_OPTIONS } from './constants/deviceConstants'; - const isEncoded = uri => { uri = uri || ''; return uri !== decodeURIComponent(uri); @@ -38,54 +28,6 @@ export const fullyDecodeURI = uri => { return uri; }; -export const groupDeploymentDevicesStats = deployment => { - const deviceStatCollector = (deploymentStates, devices) => - Object.values(devices).reduce((accu, device) => (deploymentStates.includes(device.status) ? accu + 1 : accu), 0); - - const inprogress = deviceStatCollector(deploymentStatesToSubstates.inprogress, deployment.devices); - const pending = deviceStatCollector(deploymentStatesToSubstates.pending, deployment.devices); - const successes = deviceStatCollector(deploymentStatesToSubstates.successes, deployment.devices); - const failures = deviceStatCollector(deploymentStatesToSubstates.failures, deployment.devices); - const paused = deviceStatCollector(deploymentStatesToSubstates.paused, deployment.devices); - return { inprogress, paused, pending, successes, failures }; -}; - -export const statCollector = (items, statistics) => items.reduce((accu, property) => accu + Number(statistics[property] || 0), 0); -export const groupDeploymentStats = (deployment, withSkipped) => { - const { statistics = {} } = deployment; - const { status = {} } = statistics; - const stats = { ...defaultStats, ...status }; - let groupStates = deploymentStatesToSubstates; - let result = {}; - if (withSkipped) { - groupStates = deploymentStatesToSubstatesWithSkipped; - result.skipped = statCollector(groupStates.skipped, stats); - } - result = { - ...result, - // don't include 'pending' as inprogress, as all remaining devices will be pending - we don't discriminate based on phase membership - inprogress: statCollector(groupStates.inprogress, stats), - pending: (deployment.max_devices ? deployment.max_devices - deployment.device_count : 0) + statCollector(groupStates.pending, stats), - successes: statCollector(groupStates.successes, stats), - failures: statCollector(groupStates.failures, stats), - paused: statCollector(groupStates.paused, stats) - }; - return result; -}; - -export const getDeploymentState = deployment => { - const { status: deploymentStatus = DEPLOYMENT_STATES.pending } = deployment; - const { inprogress: currentProgressCount, paused } = groupDeploymentStats(deployment); - - let status = deploymentDisplayStates[deploymentStatus]; - if (deploymentStatus === DEPLOYMENT_STATES.pending && currentProgressCount === 0) { - status = 'queued'; - } else if (paused > 0) { - status = deploymentDisplayStates.paused; - } - return status; -}; - export const isEmpty = obj => { for (const _ in obj) { return false; @@ -93,23 +35,8 @@ export const isEmpty = obj => { return true; }; -export const extractErrorMessage = (err, fallback = '') => - err.response?.data?.error?.message || err.response?.data?.error || err.error || err.message || fallback; - -export const preformatWithRequestID = (res, failMsg) => { - // ellipsis line - if (failMsg.length > 100) failMsg = `${failMsg.substring(0, 220)}...`; - - try { - if (res?.data && Object.keys(res.data).includes('request_id')) { - let shortRequestUUID = res.data['request_id'].substring(0, 8); - return `${failMsg} [Request ID: ${shortRequestUUID}]`; - } - } catch (e) { - console.log('failed to extract request id:', e); - } - return failMsg; -}; +export const yes = () => true; +export const canAccess = yes; export const versionCompare = (v1, v2) => { const partsV1 = `${v1}`.split('.'); @@ -303,31 +230,6 @@ export const unionizeStrings = (someStrings, someOtherStrings) => { return [...uniqueStrings]; }; -export const generateDeploymentGroupDetails = (filter, groupName) => - filter && filter.terms?.length - ? `${groupName} (${filter.terms - .map(filter => `${filter.attribute || filter.key} ${DEVICE_FILTERING_OPTIONS[filter.type || filter.operator].shortform} ${filter.value}`) - .join(', ')})` - : groupName; - -export const mapDeviceAttributes = (attributes = []) => - attributes.reduce( - (accu, attribute) => { - if (!(attribute.value && attribute.name) && attribute.scope === ATTRIBUTE_SCOPES.inventory) { - return accu; - } - accu[attribute.scope || ATTRIBUTE_SCOPES.inventory] = { - ...accu[attribute.scope || ATTRIBUTE_SCOPES.inventory], - [attribute.name]: attribute.value - }; - if (attribute.name === 'device_type' && attribute.scope === ATTRIBUTE_SCOPES.inventory) { - accu.inventory.device_type = [].concat(attribute.value); - } - return accu; - }, - { inventory: { device_type: [], artifact_name: '' }, identity: {}, monitor: {}, system: {}, tags: {} } - ); - export const getFormattedSize = bytes => { const suffixes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(1024)); @@ -528,5 +430,3 @@ export const getISOStringBoundaries = currentDate => { const date = [currentDate.getUTCFullYear(), `0${currentDate.getUTCMonth() + 1}`.slice(-2), `0${currentDate.getUTCDate()}`.slice(-2)].join('-'); return { start: `${date}T00:00:00.000`, end: `${date}T23:59:59.999` }; }; - -export const isDarkMode = mode => mode === DARK_MODE; diff --git a/frontend/src/js/helpers.test.js b/frontend/src/js/helpers.test.js index 2aa9a719..ff2d612f 100644 --- a/frontend/src/js/helpers.test.js +++ b/frontend/src/js/helpers.test.js @@ -13,9 +13,8 @@ // limitations under the License. import React from 'react'; -import { defaultState, token, undefineds } from '../../tests/mockData'; +import { defaultState, undefineds } from '../../tests/mockData'; import { render } from '../../tests/setupTests'; -import { DARK_MODE, LIGHT_MODE } from './constants/appConstants.js'; import { FileSize, customSort, @@ -25,18 +24,12 @@ import { extractSoftware, formatTime, fullyDecodeURI, - generateDeploymentGroupDetails, getDebConfigurationCode, getDemoDeviceAddress, getFormattedSize, getPhaseDeviceCount, getRemainderPercent, - groupDeploymentDevicesStats, - groupDeploymentStats, - isDarkMode, isEmpty, - mapDeviceAttributes, - preformatWithRequestID, standardizePhases, stringToBoolean, unionizeStrings, @@ -268,80 +261,6 @@ describe('unionizeStrings function', () => { }); }); -describe('mapDeviceAttributes function', () => { - const defaultAttributes = { - inventory: { device_type: [], artifact_name: '' }, - identity: {}, - monitor: {}, - system: {}, - tags: {} - }; - it('works with empty attributes', async () => { - expect(mapDeviceAttributes()).toEqual(defaultAttributes); - expect(mapDeviceAttributes([])).toEqual(defaultAttributes); - }); - it('handles unscoped attributes', async () => { - const testAttributesObject1 = { name: 'this1', value: 'that1' }; - expect(mapDeviceAttributes([testAttributesObject1])).toEqual({ - ...defaultAttributes, - inventory: { - ...defaultAttributes.inventory, - this1: 'that1' - } - }); - const testAttributesObject2 = { name: 'this2', value: 'that2' }; - expect(mapDeviceAttributes([testAttributesObject1, testAttributesObject2])).toEqual({ - ...defaultAttributes, - inventory: { - ...defaultAttributes.inventory, - this1: 'that1', - this2: 'that2' - } - }); - expect(mapDeviceAttributes([testAttributesObject1, testAttributesObject2, testAttributesObject2])).toEqual({ - ...defaultAttributes, - inventory: { - ...defaultAttributes.inventory, - this1: 'that1', - this2: 'that2' - } - }); - }); - it('handles scoped attributes', async () => { - const testAttributesObject1 = { name: 'this1', value: 'that1', scope: 'inventory' }; - expect(mapDeviceAttributes([testAttributesObject1])).toEqual({ - ...defaultAttributes, - inventory: { - ...defaultAttributes.inventory, - this1: 'that1' - } - }); - const testAttributesObject2 = { name: 'this2', value: 'that2', scope: 'identity' }; - expect(mapDeviceAttributes([testAttributesObject1, testAttributesObject2])).toEqual({ - ...defaultAttributes, - identity: { - ...defaultAttributes.identity, - this2: 'that2' - }, - inventory: { - ...defaultAttributes.inventory, - this1: 'that1' - } - }); - expect(mapDeviceAttributes([testAttributesObject1, testAttributesObject2, testAttributesObject2])).toEqual({ - ...defaultAttributes, - identity: { - ...defaultAttributes.identity, - this2: 'that2' - }, - inventory: { - ...defaultAttributes.inventory, - this1: 'that1' - } - }); - }); -}); - describe('getPhaseDeviceCount function', () => { it('works with empty attributes', async () => { expect(getPhaseDeviceCount(120, 10, 20, false)).toEqual(12); @@ -417,22 +336,6 @@ describe('getDemoDeviceAddress function', () => { expect(getDemoDeviceAddress(Object.values(defaultState.devices.byId), 'physical')).toEqual('192.168.10.141'); }); }); -describe('preformatWithRequestID function', () => { - it('works as expected', async () => { - expect(preformatWithRequestID({ data: { request_id: 'someUuidLikeLongerText' } }, token)).toEqual( - 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJjZTNkMGY4Yy1hZWRlLTQwMzAtYjM5MS03ZDUwMjBlYjg3M2UiLCJzdWIiOiJhMzBhNzgwYi1iODQzLTUzNDQtODBlMy0wZmQ5NWE0ZjZmYzMiLCJleHAiOjE2MDY4MTUzNjksImlhdCI6MTYwNjIxMDU2OSwibWVuZGVyLnRlbmF... [Request ID: someUuid]' - ); - expect(preformatWithRequestID({ data: {} }, token)).toEqual( - 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJjZTNkMGY4Yy1hZWRlLTQwMzAtYjM5MS03ZDUwMjBlYjg3M2UiLCJzdWIiOiJhMzBhNzgwYi1iODQzLTUzNDQtODBlMy0wZmQ5NWE0ZjZmYzMiLCJleHAiOjE2MDY4MTUzNjksImlhdCI6MTYwNjIxMDU2OSwibWVuZGVyLnRlbmF...' - ); - expect(preformatWithRequestID(undefined, token)).toEqual( - 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJjZTNkMGY4Yy1hZWRlLTQwMzAtYjM5MS03ZDUwMjBlYjg3M2UiLCJzdWIiOiJhMzBhNzgwYi1iODQzLTUzNDQtODBlMy0wZmQ5NWE0ZjZmYzMiLCJleHAiOjE2MDY4MTUzNjksImlhdCI6MTYwNjIxMDU2OSwibWVuZGVyLnRlbmF...' - ); - const expectedText = 'short text'; - expect(preformatWithRequestID({ data: { request_id: 'someUuidLikeLongerText' } }, expectedText)).toEqual('short text [Request ID: someUuid]'); - expect(preformatWithRequestID(undefined, expectedText)).toEqual(expectedText); - }); -}); describe('extractSoftware function', () => { it('works as expected', async () => { @@ -456,27 +359,6 @@ describe('extractSoftware function', () => { }); }); -describe('generateDeploymentGroupDetails function', () => { - it('works as expected', async () => { - expect(generateDeploymentGroupDetails({ terms: defaultState.devices.groups.byId.testGroupDynamic.filters }, 'testGroupDynamic')).toEqual( - 'testGroupDynamic (group = things)' - ); - expect( - generateDeploymentGroupDetails( - { - terms: [ - { scope: 'system', key: 'group', operator: '$eq', value: 'things' }, - { scope: 'system', key: 'group', operator: '$nexists', value: 'otherThings' }, - { scope: 'system', key: 'group', operator: '$nin', value: 'a,small,list' } - ] - }, - 'testGroupDynamic' - ) - ).toEqual(`testGroupDynamic (group = things, group doesn't exist otherThings, group not in a,small,list)`); - expect(generateDeploymentGroupDetails({ terms: undefined }, 'testGroupDynamic')).toEqual('testGroupDynamic'); - }); -}); - describe('standardizePhases function', () => { it('works as expected', async () => { const phases = [ @@ -557,54 +439,3 @@ describe('validatePhases function', () => { ).toEqual(true); }); }); - -describe('deployment stats grouping functions', () => { - it('groups correctly based on deployment stats', async () => { - let deployment = { - statistics: { - status: { - aborted: 2, - 'already-installed': 1, - decommissioned: 1, - downloading: 3, - failure: 1, - installing: 1, - noartifact: 1, - pending: 2, - paused: 0, - rebooting: 1, - success: 1 - } - } - }; - expect(groupDeploymentStats(deployment)).toEqual({ inprogress: 5, paused: 0, pending: 2, successes: 3, failures: 4 }); - deployment = { ...deployment, max_devices: 100, device_count: 10 }; - expect(groupDeploymentStats(deployment)).toEqual({ inprogress: 5, paused: 0, pending: 92, successes: 3, failures: 4 }); - }); - it('groups correctly based on deployment devices states', async () => { - const deployment = { - devices: { - a: { status: 'aborted' }, - b: { status: 'already-installed' }, - c: { status: 'decommissioned' }, - d: { status: 'downloading' }, - e: { status: 'failure' }, - f: { status: 'installing' }, - g: { status: 'noartifact' }, - h: { status: 'pending' }, - i: { status: 'rebooting' }, - j: { status: 'success' } - } - }; - expect(groupDeploymentDevicesStats(deployment)).toEqual({ inprogress: 3, paused: 0, pending: 1, successes: 3, failures: 3 }); - }); -}); - -describe('isDarkMode function', () => { - it('should return `true` if DARK_MODE was passed in', () => { - expect(isDarkMode(DARK_MODE)).toEqual(true); - }); - it('should return `false` if LIGHT_MODE was passed in', () => { - expect(isDarkMode(LIGHT_MODE)).toEqual(false); - }); -}); diff --git a/frontend/src/js/reducers/appReducer.js b/frontend/src/js/reducers/appReducer.js deleted file mode 100644 index 62ff2ce7..00000000 --- a/frontend/src/js/reducers/appReducer.js +++ /dev/null @@ -1,141 +0,0 @@ -// Copyright 2019 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import * as AppConstants from '../constants/appConstants'; -import * as UserConstants from '../constants/userConstants'; - -const getYesterday = () => { - const today = new Date(); - today.setDate(today.getDate() - 1); - return today.toISOString(); -}; - -export const initialState = { - cancelSource: undefined, - hostAddress: null, - snackbar: { - open: false, - message: '' - }, - // return boolean rather than organization details - features: { - hasAuditlogs: false, - hasDeltaProgress: false, - hasMultitenancy: false, - hasDeviceConfig: false, - hasDeviceConnect: false, - hasMonitor: false, - hasReporting: false, - isDemoMode: false, - isHosted: false, - isEnterprise: false - }, - firstLoginAfterSignup: false, - hostedAnnouncement: '', - docsVersion: '', - recaptchaSiteKey: '', - searchState: { - deviceIds: [], - searchTerm: '', - searchTotal: 0, - sort: { - direction: AppConstants.SORTING_OPTIONS.desc - // key: null, - // scope: null - } - }, - stripeAPIKey: '', - trackerCode: '', - uploadsById: { - // id: { uploading: false, uploadProgress: 0, cancelSource: undefined } - }, - newThreshold: getYesterday(), - offlineThreshold: getYesterday(), - versionInformation: { - Integration: '', - 'Mender-Client': '', - 'Mender-Artifact': '', - 'Meta-Mender': '', - Deployments: '', - Deviceauth: '', - Inventory: '', - GUI: 'latest' - }, - yesterday: undefined -}; - -// exclude 'pendings-redirect' since this is expected to persist refreshes - the rest should be better to be redone -const keys = ['sessionDeploymentChecker', UserConstants.settingsKeys.initialized]; -const resetEnvironment = () => { - keys.map(key => window.sessionStorage.removeItem(key)); -}; - -resetEnvironment(); - -const appReducer = (state = initialState, action) => { - switch (action.type) { - case AppConstants.SET_FEATURES: - return { - ...state, - features: { - ...state.features, - ...action.value - } - }; - case AppConstants.SET_SNACKBAR: - return { - ...state, - snackbar: action.snackbar - }; - case AppConstants.SET_FIRST_LOGIN_AFTER_SIGNUP: - return { - ...state, - firstLoginAfterSignup: action.firstLoginAfterSignup - }; - case AppConstants.SET_ANNOUNCEMENT: - return { - ...state, - hostedAnnouncement: action.announcement - }; - case AppConstants.SET_SEARCH_STATE: - return { - ...state, - searchState: action.state - }; - case AppConstants.SET_OFFLINE_THRESHOLD: - return { - ...state, - offlineThreshold: action.value - }; - case AppConstants.UPLOAD_PROGRESS: - return { - ...state, - uploadsById: action.uploads - }; - case AppConstants.SET_VERSION_INFORMATION: - return { - ...state, - docsVersion: action.docsVersion, - versionInformation: action.value - }; - case AppConstants.SET_ENVIRONMENT_DATA: - return { - ...state, - ...action.value - }; - default: - return state; - } -}; - -export default appReducer; diff --git a/frontend/src/js/reducers/appReducer.test.js b/frontend/src/js/reducers/appReducer.test.js deleted file mode 100644 index 04447f2d..00000000 --- a/frontend/src/js/reducers/appReducer.test.js +++ /dev/null @@ -1,99 +0,0 @@ -// Copyright 2020 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import * as AppConstants from '../constants/appConstants'; -import reducer, { initialState } from './appReducer'; - -const snackbarMessage = 'Run the tests'; - -describe('app reducer', () => { - it('should return the initial state', async () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - it('should handle SET_SNACKBAR', async () => { - expect( - reducer(undefined, { - type: AppConstants.SET_SNACKBAR, - snackbar: { open: true, message: snackbarMessage } - }).snackbar - ).toEqual({ - open: true, - message: snackbarMessage - }); - - expect( - reducer(initialState, { - type: AppConstants.SET_SNACKBAR, - snackbar: { open: true, message: snackbarMessage } - }).snackbar - ).toEqual({ - open: true, - message: snackbarMessage - }); - }); - - it('should handle SET_FIRST_LOGIN_AFTER_SIGNUP', async () => { - expect( - reducer(undefined, { - type: AppConstants.SET_FIRST_LOGIN_AFTER_SIGNUP, - firstLoginAfterSignup: true - }).firstLoginAfterSignup - ).toEqual(true); - - expect( - reducer(initialState, { - type: AppConstants.SET_FIRST_LOGIN_AFTER_SIGNUP, - firstLoginAfterSignup: false - }).firstLoginAfterSignup - ).toEqual(false); - }); - it('should handle SET_ANNOUNCEMENT', async () => { - expect(reducer(undefined, { type: AppConstants.SET_ANNOUNCEMENT, announcement: 'something' }).hostedAnnouncement).toEqual('something'); - expect(reducer(initialState, { type: AppConstants.SET_ANNOUNCEMENT, announcement: undefined }).hostedAnnouncement).toEqual(undefined); - }); - it('should handle SET_SEARCH_STATE', async () => { - expect(reducer(undefined, { type: AppConstants.SET_SEARCH_STATE, state: { aWhole: 'newState' } }).searchState).toEqual({ aWhole: 'newState' }); - expect(reducer(initialState, { type: AppConstants.SET_SEARCH_STATE, state: undefined }).searchState).toEqual(undefined); - }); - it('should handle SET_OFFLINE_THRESHOLD', async () => { - expect(reducer(undefined, { type: AppConstants.SET_OFFLINE_THRESHOLD, value: 'something' }).offlineThreshold).toEqual('something'); - expect(reducer(initialState, { type: AppConstants.SET_OFFLINE_THRESHOLD, value: undefined }).offlineThreshold).toEqual(undefined); - }); - - it('should handle SET_VERSION_INFORMATION', async () => { - expect(reducer(undefined, { type: AppConstants.SET_VERSION_INFORMATION, value: 'something' }).versionInformation).toEqual('something'); - expect(reducer(initialState, { type: AppConstants.SET_VERSION_INFORMATION, value: undefined }).versionInformation).toEqual(undefined); - expect(reducer(undefined, { type: AppConstants.SET_VERSION_INFORMATION, docsVersion: 'something' }).docsVersion).toEqual('something'); - expect(reducer(initialState, { type: AppConstants.SET_VERSION_INFORMATION, docsVersion: undefined }).docsVersion).toEqual(undefined); - }); - - it('should handle UPLOAD_PROGRESS', async () => { - const { uploadsById } = reducer(undefined, { - type: AppConstants.UPLOAD_PROGRESS, - uploads: { uploading: true, uploadProgress: 40 } - }); - expect(uploadsById).toEqual({ - uploading: true, - uploadProgress: 40 - }); - - const { uploadsById: uploading2 } = reducer(initialState, { - type: AppConstants.UPLOAD_PROGRESS, - uploads: { uploading: true, uploadProgress: 40 } - }); - expect(uploading2).toEqual({ - uploading: true, - uploadProgress: 40 - }); - }); -}); diff --git a/frontend/src/js/reducers/deploymentReducer.js b/frontend/src/js/reducers/deploymentReducer.js deleted file mode 100644 index 46509cf2..00000000 --- a/frontend/src/js/reducers/deploymentReducer.js +++ /dev/null @@ -1,175 +0,0 @@ -// Copyright 2019 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import * as DeploymentConstants from '../constants/deploymentConstants'; -import * as DeviceConstants from '../constants/deviceConstants'; - -export const initialState = { - byId: { - // [id]: { statistics: { status: {}, total_size }, devices: { [deploymentId]: { id, log } }, totalDeviceCount } - }, - byStatus: { - finished: { deploymentIds: [], total: 0 }, - inprogress: { deploymentIds: [], total: 0 }, - pending: { deploymentIds: [], total: 0 }, - scheduled: { deploymentIds: [], total: 0 } - }, - config: { - binaryDelta: { - timeout: -1, - duplicatesWindow: -1, - compressionLevel: -1, - disableChecksum: false, - disableDecompression: false, - inputWindow: -1, - instructionBuffer: -1, - sourceWindow: -1 - }, - binaryDeltaLimits: { - timeout: { ...DeploymentConstants.limitDefault, default: 60, max: 3600, min: 60 }, - sourceWindow: DeploymentConstants.limitDefault, - inputWindow: DeploymentConstants.limitDefault, - duplicatesWindow: DeploymentConstants.limitDefault, - instructionBuffer: DeploymentConstants.limitDefault - } - }, - deploymentDeviceLimit: 5000, - selectedDeviceIds: [], - selectionState: { - finished: { ...DeviceConstants.DEVICE_LIST_DEFAULTS, endDate: undefined, search: '', selection: [], startDate: undefined, total: 0, type: '' }, - inprogress: { ...DeviceConstants.DEVICE_LIST_DEFAULTS, perPage: DeploymentConstants.DEFAULT_PENDING_INPROGRESS_COUNT, selection: [] }, - pending: { ...DeviceConstants.DEVICE_LIST_DEFAULTS, perPage: DeploymentConstants.DEFAULT_PENDING_INPROGRESS_COUNT, selection: [] }, - scheduled: { ...DeviceConstants.DEVICE_LIST_DEFAULTS, selection: [] }, - general: { - state: DeploymentConstants.DEPLOYMENT_ROUTES.active.key, - showCreationDialog: false, - showReportDialog: false, - reportType: null // DeploymentConstants.DEPLOYMENT_TYPES.configuration|DeploymentConstants.DEPLOYMENT_TYPES.software - }, - selectedId: undefined - } -}; - -const deploymentReducer = (state = initialState, action) => { - switch (action.type) { - case DeploymentConstants.CREATE_DEPLOYMENT: - return { - ...state, - byId: { - ...state.byId, - [action.deploymentId]: { - ...DeploymentConstants.deploymentPrototype, - ...action.deployment - } - }, - byStatus: { - ...state.byStatus, - pending: { - ...state.byStatus.pending, - deploymentIds: [...state.byStatus.pending.deploymentIds, action.deploymentId], - total: state.byStatus.pending.total + 1 - } - }, - selectionState: { - ...state.selectionState, - pending: { - ...state.selectionState.pending, - selection: [action.deploymentId, ...state.selectionState.pending.selection], - total: state.selectionState.pending.total + 1 - } - } - }; - case DeploymentConstants.REMOVE_DEPLOYMENT: { - // eslint-disable-next-line no-unused-vars - const { [action.deploymentId]: removedDeployment, ...remainder } = state.byId; - return { - ...state, - byId: remainder - }; - } - case DeploymentConstants.RECEIVE_DEPLOYMENT: - case DeploymentConstants.RECEIVE_DEPLOYMENT_DEVICE_LOG: - return { - ...state, - byId: { - ...state.byId, - [action.deployment.id]: action.deployment - } - }; - case DeploymentConstants.RECEIVE_DEPLOYMENT_DEVICES: - return { - ...state, - byId: { - ...state.byId, - [action.deploymentId]: { - ...state.byId[action.deploymentId], - devices: action.devices, - totalDeviceCount: action.totalDeviceCount - } - }, - selectedDeviceIds: action.selectedDeviceIds - }; - case DeploymentConstants.RECEIVE_DEPLOYMENTS: - return { - ...state, - byId: { - ...state.byId, - ...action.deployments - } - }; - case DeploymentConstants.RECEIVE_INPROGRESS_DEPLOYMENTS: - case DeploymentConstants.RECEIVE_PENDING_DEPLOYMENTS: - case DeploymentConstants.RECEIVE_SCHEDULED_DEPLOYMENTS: - case DeploymentConstants.RECEIVE_FINISHED_DEPLOYMENTS: - return { - ...state, - byStatus: { - ...state.byStatus, - [action.status]: { - ...state.byStatus[action.status], - deploymentIds: action.deploymentIds, - total: action.total - } - } - }; - case DeploymentConstants.SELECT_INPROGRESS_DEPLOYMENTS: - case DeploymentConstants.SELECT_PENDING_DEPLOYMENTS: - case DeploymentConstants.SELECT_SCHEDULED_DEPLOYMENTS: - case DeploymentConstants.SELECT_FINISHED_DEPLOYMENTS: - return { - ...state, - selectionState: { - ...state.selectionState, - [action.status]: { - ...state.selectionState[action.status], - selection: action.deploymentIds, - total: action.total - } - } - }; - case DeploymentConstants.SET_DEPLOYMENTS_STATE: - return { - ...state, - selectionState: action.state - }; - case DeploymentConstants.SET_DEPLOYMENTS_CONFIG: - return { - ...state, - config: action.config - }; - default: - return state; - } -}; - -export default deploymentReducer; diff --git a/frontend/src/js/reducers/deploymentReducer.test.js b/frontend/src/js/reducers/deploymentReducer.test.js deleted file mode 100644 index d1d0c9e0..00000000 --- a/frontend/src/js/reducers/deploymentReducer.test.js +++ /dev/null @@ -1,108 +0,0 @@ -/*eslint import/namespace: ['error', { allowComputed: true }]*/ -import { defaultState } from '../../../tests/mockData'; -import * as DeploymentConstants from '../constants/deploymentConstants'; -import reducer, { initialState } from './deploymentReducer'; - -const { - RECEIVE_DEPLOYMENT, - RECEIVE_DEPLOYMENTS, - RECEIVE_DEPLOYMENT_DEVICE_LOG, - RECEIVE_DEPLOYMENT_DEVICES, - DEPLOYMENT_STATES, - SET_DEPLOYMENTS_CONFIG, - SET_DEPLOYMENTS_STATE, - REMOVE_DEPLOYMENT, - CREATE_DEPLOYMENT -} = DeploymentConstants; - -describe('deployment reducer', () => { - it('should return the initial state', async () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - - it('should handle RECEIVE_DEPLOYMENT', async () => { - expect(reducer(undefined, { type: RECEIVE_DEPLOYMENT, deployment: defaultState.deployments.byId.d1 }).byId.d1).toEqual(defaultState.deployments.byId.d1); - expect(reducer(initialState, { type: RECEIVE_DEPLOYMENT, deployment: defaultState.deployments.byId.d1 }).byId.d1).toEqual(defaultState.deployments.byId.d1); - }); - it('should handle RECEIVE_DEPLOYMENTS', async () => { - const { statistics } = defaultState.deployments.byId.d1; - expect(reducer(undefined, { type: RECEIVE_DEPLOYMENTS, deployments: { plain: 'passing' } }).byId.plain).toBeTruthy(); - expect( - reducer(initialState, { type: RECEIVE_DEPLOYMENTS, deployments: { [defaultState.deployments.byId.d1.id]: { statistics } } }).byId.d1.statistics - ).toBeTruthy(); - }); - it('should handle RECEIVE_DEPLOYMENT_DEVICE_LOG', async () => { - const { devices } = defaultState.deployments.byId.d1; - expect(reducer(undefined, { type: RECEIVE_DEPLOYMENT_DEVICE_LOG, deployment: defaultState.deployments.byId.d1 }).byId.d1.devices.a1.id).toEqual( - devices.a1.id - ); - expect(reducer(initialState, { type: RECEIVE_DEPLOYMENT_DEVICE_LOG, deployment: defaultState.deployments.byId.d1 }).byId.d1.devices.a1.id).toEqual( - devices.a1.id - ); - }); - it('should handle RECEIVE_DEPLOYMENT_DEVICES', async () => { - const { devices, id } = defaultState.deployments.byId.d1; - expect( - reducer(undefined, { - type: RECEIVE_DEPLOYMENT_DEVICES, - deploymentId: id, - devices, - selectedDeviceIds: [devices.a1.id], - totalDeviceCount: 500 - }).byId.d1.totalDeviceCount - ).toEqual(500); - expect( - reducer(defaultState.deployments, { - type: RECEIVE_DEPLOYMENT_DEVICES, - deploymentId: id, - devices, - selectedDeviceIds: [devices.a1.id], - totalDeviceCount: 500 - }).byId.d1.statistics - ).toEqual(defaultState.deployments.byId.d1.statistics); - }); - it('should handle RECEIVE__DEPLOYMENTS', async () => { - Object.values(DEPLOYMENT_STATES).forEach(status => { - expect( - reducer(undefined, { type: DeploymentConstants[`RECEIVE_${status.toUpperCase()}_DEPLOYMENTS`], deploymentIds: ['a1'], total: 1, status }).byStatus[ - status - ] - ).toEqual({ deploymentIds: ['a1'], total: 1 }); - expect( - reducer(initialState, { type: DeploymentConstants[`RECEIVE_${status.toUpperCase()}_DEPLOYMENTS`], deploymentIds: ['a1'], total: 1, status }).byStatus[ - status - ] - ).toEqual({ deploymentIds: ['a1'], total: 1 }); - }); - }); - it('should handle SELECT__DEPLOYMENTS', async () => { - Object.values(DEPLOYMENT_STATES).forEach(status => { - expect( - reducer(undefined, { type: DeploymentConstants[`SELECT_${status.toUpperCase()}_DEPLOYMENTS`], deploymentIds: ['a1'], status }).selectionState[status] - .selection - ).toEqual(['a1']); - expect( - reducer(initialState, { type: DeploymentConstants[`SELECT_${status.toUpperCase()}_DEPLOYMENTS`], deploymentIds: ['a1'], status }).selectionState[status] - .selection - ).toEqual(['a1']); - }); - }); - it('should handle SET_DEPLOYMENTS_STATE', async () => { - const newState = { something: 'new' }; - expect(reducer(undefined, { type: SET_DEPLOYMENTS_STATE, state: newState }).selectionState).toEqual(newState); - expect(reducer(initialState, { type: SET_DEPLOYMENTS_STATE, state: newState }).selectionState).toEqual(newState); - }); - it('should handle REMOVE_DEPLOYMENT', async () => { - let state = reducer(undefined, { type: RECEIVE_DEPLOYMENT, deployment: defaultState.deployments.byId.d1 }); - expect(reducer(state, { type: REMOVE_DEPLOYMENT, deploymentId: defaultState.deployments.byId.d1.id }).byId).toEqual({}); - expect(reducer(initialState, { type: REMOVE_DEPLOYMENT, deploymentId: 'a1' }).byId).toEqual({}); - }); - it('should handle CREATE_DEPLOYMENT', async () => { - expect(reducer(undefined, { type: CREATE_DEPLOYMENT, deployment: { name: 'test' }, deploymentId: 'test' }).byId.test.devices).toEqual({}); - expect(reducer(initialState, { type: CREATE_DEPLOYMENT, deployment: { name: 'test' }, deploymentId: 'a1' }).byStatus.pending.deploymentIds).toContain('a1'); - }); - it('should handle SET_DEPLOYMENTS_CONFIG', async () => { - expect(reducer(undefined, { type: SET_DEPLOYMENTS_CONFIG, config: { name: 'test' } }).config).toEqual({ name: 'test' }); - expect(reducer(initialState, { type: SET_DEPLOYMENTS_CONFIG, config: { name: 'test' } }).config).toEqual({ name: 'test' }); - }); -}); diff --git a/frontend/src/js/reducers/deviceReducer.js b/frontend/src/js/reducers/deviceReducer.js deleted file mode 100644 index 5a20ebd0..00000000 --- a/frontend/src/js/reducers/deviceReducer.js +++ /dev/null @@ -1,266 +0,0 @@ -// Copyright 2019 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { SORTING_OPTIONS } from '../constants/appConstants'; -import * as DeviceConstants from '../constants/deviceConstants'; -import * as MonitorConstants from '../constants/monitorConstants'; -import { duplicateFilter } from '../helpers'; - -export const initialState = { - byId: { - // [deviceId]: { - // ..., - // twinsByIntegration: { [external.provider.id]: twinData } - // } - }, - byStatus: { - [DeviceConstants.DEVICE_STATES.accepted]: { deviceIds: [], total: 0 }, - active: { deviceIds: [], total: 0 }, - inactive: { deviceIds: [], total: 0 }, - [DeviceConstants.DEVICE_STATES.pending]: { deviceIds: [], total: 0 }, - [DeviceConstants.DEVICE_STATES.preauth]: { deviceIds: [], total: 0 }, - [DeviceConstants.DEVICE_STATES.rejected]: { deviceIds: [], total: 0 } - }, - deviceList: { - deviceIds: [], - ...DeviceConstants.DEVICE_LIST_DEFAULTS, - selectedAttributes: [], - selectedIssues: [], - selection: [], - sort: { - direction: SORTING_OPTIONS.desc - // key: null, - // scope: null - }, - state: DeviceConstants.DEVICE_STATES.accepted, - total: 0 - }, - filters: [ - // { key: 'device_type', value: 'raspberry', operator: '$eq', scope: 'inventory' } - ], - filteringAttributes: { identityAttributes: [], inventoryAttributes: [], systemAttributes: [], tagAttributes: [] }, - filteringAttributesLimit: 10, - filteringAttributesConfig: { - attributes: { - // inventory: ['some_attribute'] - }, - count: 0, - limit: 100 - }, - reports: [ - // { items: [{ key: "someKey", count: 42 }], otherCount: 123, total: } - ], - total: 0, - limit: 0, - groups: { - byId: { - // groupName: { deviceIds: [], total: 0, filters: [] }, - // dynamo: { deviceIds: [], total: 3, filters: [{ a: 1 }] } - }, - selectedGroup: undefined - } -}; - -const deviceReducer = (state = initialState, action) => { - switch (action.type) { - case DeviceConstants.RECEIVE_GROUPS: - case DeviceConstants.RECEIVE_DYNAMIC_GROUPS: - case DeviceConstants.REMOVE_STATIC_GROUP: - case DeviceConstants.REMOVE_DYNAMIC_GROUP: - return { - ...state, - groups: { - ...state.groups, - byId: action.groups - } - }; - case DeviceConstants.ADD_TO_GROUP: { - let group = { - deviceIds: action.deviceIds, - filters: [], - total: 1 - }; - if (state.groups.byId[action.group]) { - group = { - filters: [], - ...state.groups.byId[action.group], - deviceIds: [...state.groups.byId[action.group].deviceIds, ...action.deviceIds], - total: state.groups.byId[action.group].total + 1 - }; - group.deviceIds.filter(duplicateFilter); - } - return { - ...state, - groups: { - ...state.groups, - byId: { - ...state.groups.byId, - [action.group]: group - } - } - }; - } - case DeviceConstants.REMOVE_FROM_GROUP: { - const { deviceIds = [], total = 0, ...maybeExistingGroup } = state.groups.byId[action.group] || {}; - const group = { - ...maybeExistingGroup, - deviceIds: deviceIds.filter(id => !action.deviceIds.includes(id)), - total: Math.max(total - action.deviceIds.length, 0) - }; - let byId = {}; - let selectedGroup = state.groups.selectedGroup; - if (group.total || group.deviceIds.length) { - byId = { - ...state.groups.byId, - [action.group]: group - }; - } else if (state.groups.selectedGroup === action.group) { - selectedGroup = undefined; - // eslint-disable-next-line no-unused-vars - const { [action.group]: removal, ...remainingById } = state.groups.byId; - byId = remainingById; - } - return { - ...state, - groups: { - ...state.groups, - byId, - selectedGroup - } - }; - } - case DeviceConstants.ADD_DYNAMIC_GROUP: - case DeviceConstants.ADD_STATIC_GROUP: - case DeviceConstants.RECEIVE_GROUP_DEVICES: - return { - ...state, - groups: { - ...state.groups, - byId: { ...state.groups.byId, [action.groupName]: action.group } - } - }; - case DeviceConstants.SELECT_GROUP: - return { - ...state, - deviceList: { - ...state.deviceList, - deviceIds: state.groups.byId[action.group] && state.groups.byId[action.group].deviceIds.length > 0 ? state.groups.byId[action.group].deviceIds : [] - }, - groups: { - ...state.groups, - selectedGroup: action.group - } - }; - case DeviceConstants.SET_DEVICE_LIST_STATE: - return { ...state, deviceList: action.state }; - case DeviceConstants.SET_FILTER_ATTRIBUTES: - return { ...state, filteringAttributes: action.attributes }; - case DeviceConstants.SET_FILTERABLES_CONFIG: - return { - ...state, - filteringAttributesConfig: { - attributes: action.attributes, - count: action.count, - limit: action.limit - } - }; - case DeviceConstants.RECEIVE_DEVICES: - return { - ...state, - byId: { - ...state.byId, - ...action.devicesById - } - }; - case DeviceConstants.SET_DEVICE_FILTERS: { - const filters = action.filters.filter(filter => filter.key && filter.operator && filter.scope && typeof filter.value !== 'undefined'); - return { - ...state, - filters - }; - } - - case DeviceConstants.SET_INACTIVE_DEVICES: - return { - ...state, - byStatus: { - ...state.byStatus, - active: { - total: action.activeDeviceTotal - }, - inactive: { - total: action.inactiveDeviceTotal - } - } - }; - case DeviceConstants.SET_DEVICE_REPORTS: - return { - ...state, - reports: action.reports - }; - case DeviceConstants.SET_PENDING_DEVICES: - case DeviceConstants.SET_REJECTED_DEVICES: - case DeviceConstants.SET_PREAUTHORIZED_DEVICES: - case DeviceConstants.SET_ACCEPTED_DEVICES: { - const statusDeviceInfo = action.total || action.forceUpdate ? { deviceIds: action.deviceIds, total: action.total } : state.byStatus[action.status]; - return { - ...state, - byStatus: { - ...state.byStatus, - [action.status]: { - ...statusDeviceInfo - } - } - }; - } - - case DeviceConstants.SET_ACCEPTED_DEVICES_COUNT: - case DeviceConstants.SET_PENDING_DEVICES_COUNT: - case DeviceConstants.SET_REJECTED_DEVICES_COUNT: - case DeviceConstants.SET_PREAUTHORIZED_DEVICES_COUNT: - return { - ...state, - byStatus: { - ...state.byStatus, - [action.status]: { - ...state.byStatus[action.status], - total: action.count - } - } - }; - case DeviceConstants.SET_TOTAL_DEVICES: - return { ...state, total: action.count }; - case DeviceConstants.SET_DEVICE_LIMIT: - return { ...state, limit: action.limit }; - case DeviceConstants.RECEIVE_DEVICE: - case DeviceConstants.RECEIVE_DEVICE_CONFIG: - case DeviceConstants.RECEIVE_DEVICE_CONNECT: - case MonitorConstants.RECEIVE_DEVICE_MONITOR_CONFIG: { - const { device } = action; - return { - ...state, - byId: { - ...state.byId, - [device.id]: { - ...state.byId[device.id], - ...device - } - } - }; - } - default: - return state; - } -}; - -export default deviceReducer; diff --git a/frontend/src/js/reducers/deviceReducer.test.js b/frontend/src/js/reducers/deviceReducer.test.js deleted file mode 100644 index a24ee833..00000000 --- a/frontend/src/js/reducers/deviceReducer.test.js +++ /dev/null @@ -1,189 +0,0 @@ -/*eslint import/namespace: ['error', { allowComputed: true }]*/ -import { defaultState } from '../../../tests/mockData'; -import * as DeviceConstants from '../constants/deviceConstants'; -import reducer, { initialState } from './deviceReducer'; - -const { - ADD_DYNAMIC_GROUP, - ADD_STATIC_GROUP, - ADD_TO_GROUP, - DEVICE_STATES, - RECEIVE_DEVICE, - RECEIVE_DEVICES, - RECEIVE_DYNAMIC_GROUPS, - RECEIVE_GROUP_DEVICES, - RECEIVE_GROUPS, - REMOVE_DYNAMIC_GROUP, - REMOVE_FROM_GROUP, - REMOVE_STATIC_GROUP, - SELECT_GROUP, - SET_DEVICE_FILTERS, - SET_DEVICE_LIMIT, - SET_DEVICE_LIST_STATE, - SET_DEVICE_REPORTS, - SET_FILTER_ATTRIBUTES, - SET_FILTERABLES_CONFIG, - SET_INACTIVE_DEVICES, - SET_TOTAL_DEVICES -} = DeviceConstants; - -describe('device reducer', () => { - it('should return the initial state', async () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - - it('should handle RECEIVE_GROUPS', async () => { - expect(reducer(undefined, { type: RECEIVE_GROUPS, groups: defaultState.devices.groups.byId }).groups.byId).toEqual(defaultState.devices.groups.byId); - expect(reducer(initialState, { type: RECEIVE_GROUPS, groups: defaultState.devices.groups.byId }).groups.byId).toEqual(defaultState.devices.groups.byId); - expect(reducer(initialState, { type: RECEIVE_GROUPS, groups: { testExtra: { deviceIds: [], total: 0, filters: [] } } }).groups.byId.testExtra).toEqual({ - deviceIds: [], - total: 0, - filters: [] - }); - }); - it('should handle RECEIVE_GROUP_DEVICES', async () => { - expect( - reducer(undefined, { - type: RECEIVE_GROUP_DEVICES, - groupName: 'testGroupDynamic', - group: defaultState.devices.groups.byId.testGroupDynamic - }).groups.byId.testGroupDynamic - ).toEqual(defaultState.devices.groups.byId.testGroupDynamic); - expect( - reducer(initialState, { - type: RECEIVE_GROUP_DEVICES, - groupName: 'testGroupDynamic', - group: defaultState.devices.groups.byId.testGroupDynamic - }).groups.byId.testGroupDynamic - ).toEqual(defaultState.devices.groups.byId.testGroupDynamic); - }); - it('should handle RECEIVE_DYNAMIC_GROUPS', async () => { - expect(reducer(undefined, { type: RECEIVE_DYNAMIC_GROUPS, groups: defaultState.devices.groups.byId }).groups.byId).toEqual( - defaultState.devices.groups.byId - ); - expect(reducer(initialState, { type: RECEIVE_DYNAMIC_GROUPS, groups: defaultState.devices.groups.byId }).groups.byId).toEqual( - defaultState.devices.groups.byId - ); - expect( - reducer(initialState, { type: RECEIVE_DYNAMIC_GROUPS, groups: { testExtra: { deviceIds: [], total: 0, filters: [] } } }).groups.byId.testExtra - ).toEqual({ deviceIds: [], total: 0, filters: [] }); - }); - it('should handle ADD_TO_GROUP', async () => { - let state = reducer(undefined, { type: RECEIVE_GROUPS, groups: defaultState.devices.groups.byId }); - expect(reducer(state, { type: ADD_TO_GROUP, group: 'testExtra', deviceIds: ['d1'] }).groups.byId.testExtra.deviceIds).toHaveLength(1); - expect(reducer(initialState, { type: ADD_TO_GROUP, group: 'testGroup', deviceIds: ['123', '1243'] }).groups.byId.testGroup.deviceIds).toHaveLength(2); - }); - it('should handle REMOVE_FROM_GROUP', async () => { - let state = reducer(undefined, { type: RECEIVE_GROUPS, groups: defaultState.devices.groups.byId }); - state = reducer(state, { type: SELECT_GROUP, group: 'testGroup' }); - expect( - reducer(state, { type: REMOVE_FROM_GROUP, group: 'testGroup', deviceIds: [defaultState.devices.groups.byId.testGroup.deviceIds[0]] }).groups.byId - .testGroup.deviceIds - ).toHaveLength(defaultState.devices.groups.byId.testGroup.deviceIds.length - 1); - expect( - reducer(state, { type: REMOVE_FROM_GROUP, group: 'testGroup', deviceIds: defaultState.devices.groups.byId.testGroup.deviceIds }).groups.byId.testGroup - ).toBeFalsy(); - expect(reducer(initialState, { type: REMOVE_FROM_GROUP, group: 'testExtra', deviceIds: ['123', '1243'] }).groups.byId.testExtra).toBeFalsy(); - }); - it('should handle ADD_DYNAMIC_GROUP', async () => { - expect(reducer(undefined, { type: ADD_DYNAMIC_GROUP, groupName: 'test', group: { something: 'test' } }).groups.byId.test.something).toBeTruthy(); - expect(reducer(initialState, { type: ADD_DYNAMIC_GROUP, groupName: 'test', group: { something: 'test' } }).groups.byId.test.something).toBeTruthy(); - }); - it('should handle ADD_STATIC_GROUP', async () => { - expect(reducer(undefined, { type: ADD_STATIC_GROUP, groupName: 'test', group: { something: 'test' } }).groups.byId.test.something).toBeTruthy(); - expect(reducer(initialState, { type: ADD_STATIC_GROUP, groupName: 'test', group: { something: 'test' } }).groups.byId.test.something).toBeTruthy(); - }); - - it('should handle REMOVE_DYNAMIC_GROUP', async () => { - let state = reducer(undefined, { type: RECEIVE_GROUPS, groups: defaultState.devices.groups.byId }); - // eslint-disable-next-line no-unused-vars - const { testGroupDynamic, ...remainder } = defaultState.devices.groups.byId; - expect(Object.keys(reducer(state, { type: REMOVE_DYNAMIC_GROUP, groups: remainder }).groups.byId)).toHaveLength( - Object.keys(defaultState.devices.groups.byId).length - 1 - ); - expect(Object.keys(reducer(initialState, { type: REMOVE_DYNAMIC_GROUP, groups: remainder }).groups.byId)).toHaveLength( - Object.keys(defaultState.devices.groups.byId).length - 1 - ); - }); - it('should handle REMOVE_STATIC_GROUP', async () => { - let state = reducer(undefined, { type: RECEIVE_GROUPS, groups: defaultState.devices.groups.byId }); - // eslint-disable-next-line no-unused-vars - const { testGroup, ...remainder } = defaultState.devices.groups.byId; - expect(Object.keys(reducer(state, { type: REMOVE_STATIC_GROUP, groups: remainder }).groups.byId)).toHaveLength( - Object.keys(defaultState.devices.groups.byId).length - 1 - ); - expect(Object.keys(reducer(initialState, { type: REMOVE_STATIC_GROUP, groups: remainder }).groups.byId)).toHaveLength( - Object.keys(defaultState.devices.groups.byId).length - 1 - ); - }); - it('should handle SET_DEVICE_LIST_STATE', async () => { - expect(reducer(undefined, { type: SET_DEVICE_LIST_STATE, state: { deviceIds: ['test'] } }).deviceList.deviceIds).toEqual(['test']); - expect(reducer(initialState, { type: SET_DEVICE_LIST_STATE, state: { deviceIds: ['test'] } }).deviceList.deviceIds).toEqual(['test']); - }); - it('should handle SET_DEVICE_FILTERS', async () => { - expect(reducer(undefined, { type: SET_DEVICE_FILTERS, filters: defaultState.devices.groups.byId.testGroupDynamic.filters }).filters).toHaveLength(1); - expect(reducer(initialState, { type: SET_DEVICE_FILTERS, filters: [{ key: 'test', operator: 'test' }] }).filters).toHaveLength(0); - }); - it('should handle SET_FILTERABLES_CONFIG', async () => { - expect(reducer(undefined, { type: SET_FILTERABLES_CONFIG, attributes: { asd: true } }).filteringAttributesConfig).toEqual({ - attributes: { asd: true }, - count: undefined, - limit: undefined - }); - expect(reducer(initialState, { type: SET_FILTERABLES_CONFIG, attributes: { asd: true }, count: 1, limit: 10 }).filteringAttributesConfig).toEqual({ - attributes: { asd: true }, - count: 1, - limit: 10 - }); - }); - it('should handle SET_FILTER_ATTRIBUTES', async () => { - expect(reducer(undefined, { type: SET_FILTER_ATTRIBUTES, attributes: { things: '12' } }).filteringAttributes).toEqual({ - things: '12' - }); - expect(reducer(initialState, { type: SET_FILTER_ATTRIBUTES, attributes: { things: '12' } }).filteringAttributes).toEqual({ - things: '12' - }); - }); - it('should handle SET_TOTAL_DEVICES', async () => { - expect(reducer(undefined, { type: SET_TOTAL_DEVICES, count: 2 }).total).toEqual(2); - expect(reducer(initialState, { type: SET_TOTAL_DEVICES, count: 4 }).total).toEqual(4); - }); - it('should handle SET_DEVICE_LIMIT', async () => { - expect(reducer(undefined, { type: SET_DEVICE_LIMIT, limit: 500 }).limit).toEqual(500); - expect(reducer(initialState, { type: SET_DEVICE_LIMIT, limit: 200 }).limit).toEqual(200); - }); - - it('should handle RECEIVE_DEVICE', async () => { - expect(reducer(undefined, { type: RECEIVE_DEVICE, device: defaultState.devices.byId.b1 }).byId.b1).toEqual(defaultState.devices.byId.b1); - expect(reducer(initialState, { type: RECEIVE_DEVICE, device: defaultState.devices.byId.b1 }).byId).not.toBe({}); - }); - it('should handle RECEIVE_DEVICES', async () => { - expect(reducer(undefined, { type: RECEIVE_DEVICES, devicesById: defaultState.devices.byId }).byId).toEqual(defaultState.devices.byId); - expect(reducer(initialState, { type: RECEIVE_DEVICES, devicesById: defaultState.devices.byId }).byId).toEqual(defaultState.devices.byId); - }); - it('should handle SET_INACTIVE_DEVICES', async () => { - expect(reducer(undefined, { type: SET_INACTIVE_DEVICES, activeDeviceTotal: 1, inactiveDeviceTotal: 1 }).byStatus.active.total).toBeTruthy(); - expect(reducer(initialState, { type: SET_INACTIVE_DEVICES, activeDeviceTotal: 1, inactiveDeviceTotal: 1 }).byStatus.inactive.total).toEqual(1); - }); - it('should handle SET_DEVICE_REPORTS', async () => { - expect(reducer(undefined, { type: SET_DEVICE_REPORTS, reports: [1, 2, 3] }).reports).toHaveLength(3); - expect(reducer(initialState, { type: SET_DEVICE_REPORTS, reports: [{ something: 'here' }] }).reports).toEqual([{ something: 'here' }]); - }); - it('should handle SET__DEVICES', async () => { - Object.values(DEVICE_STATES).forEach(status => { - expect( - reducer(undefined, { type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES`], deviceIds: ['a1'], total: 1, status }).byStatus[status] - ).toEqual({ deviceIds: ['a1'], total: 1 }); - expect(reducer(initialState, { type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES`], deviceIds: ['a1'], status }).byStatus[status]).toEqual({ - deviceIds: [], - total: 0 - }); - }); - }); - it('should handle SET__DEVICES_COUNT', async () => { - Object.values(DEVICE_STATES).forEach(status => { - expect(reducer(undefined, { type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES_COUNT`], count: 1, status }).byStatus[status].total).toEqual(1); - expect(reducer(initialState, { type: DeviceConstants[`SET_${status.toUpperCase()}_DEVICES_COUNT`], count: 1, status }).byStatus[status].total).toEqual(1); - }); - }); -}); diff --git a/frontend/src/js/reducers/index.js b/frontend/src/js/reducers/index.js deleted file mode 100644 index 7f18a689..00000000 --- a/frontend/src/js/reducers/index.js +++ /dev/null @@ -1,67 +0,0 @@ -// Copyright 2019 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { combineReducers, configureStore } from '@reduxjs/toolkit'; - -import { defaultState } from '../../../tests/mockData'; -import { SET_SNACKBAR, UPLOAD_PROGRESS } from '../constants/appConstants'; -import { RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS } from '../constants/organizationConstants'; -import { USER_LOGOUT } from '../constants/userConstants'; -import appReducer from './appReducer'; -import deploymentReducer from './deploymentReducer'; -import deviceReducer from './deviceReducer'; -import monitorReducer from './monitorReducer'; -import onboardingReducer from './onboardingReducer'; -import organizationReducer from './organizationReducer'; -import releaseReducer from './releaseReducer'; -import userReducer from './userReducer'; - -const rootReducer = combineReducers({ - app: appReducer, - devices: deviceReducer, - deployments: deploymentReducer, - monitor: monitorReducer, - onboarding: onboardingReducer, - organization: organizationReducer, - releases: releaseReducer, - users: userReducer -}); - -export const sessionReducer = (state, action) => { - if (action.type === USER_LOGOUT) { - state = undefined; - } - return rootReducer(state, action); -}; - -export const getConfiguredStore = (options = {}) => { - const { preloadedState = { ...defaultState }, ...config } = options; - return configureStore({ - ...config, - preloadedState, - reducer: sessionReducer, - middleware: getDefaultMiddleware => - getDefaultMiddleware({ - immutableCheck: { - ignoredPaths: ['app.uploadsById'] - }, - serializableCheck: { - ignoredActions: [RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, SET_SNACKBAR, UPLOAD_PROGRESS], - ignoredActionPaths: ['uploads', 'snackbar'], - ignoredPaths: ['app.uploadsById', 'app.snackbar', 'organization.externalDeviceIntegrations'] - } - }) - }); -}; - -export default getConfiguredStore({ preloadedState: {} }); diff --git a/frontend/src/js/reducers/monitorReducer.js b/frontend/src/js/reducers/monitorReducer.js deleted file mode 100644 index 6ceeddc5..00000000 --- a/frontend/src/js/reducers/monitorReducer.js +++ /dev/null @@ -1,103 +0,0 @@ -// Copyright 2021 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { DEVICE_ISSUE_OPTIONS, DEVICE_LIST_DEFAULTS } from '../constants/deviceConstants'; -import * as MonitorConstants from '../constants/monitorConstants'; - -export const initialState = { - alerts: { - alertList: { ...DEVICE_LIST_DEFAULTS, total: 0 }, - byDeviceId: {} - }, - issueCounts: { - byType: Object.values(DEVICE_ISSUE_OPTIONS).reduce((accu, { key }) => ({ ...accu, [key]: { filtered: 0, total: 0 } }), {}) - }, - settings: { - global: { - channels: { - ...Object.keys(MonitorConstants.alertChannels).reduce((accu, item) => ({ ...accu, [item]: { enabled: true } }), {}) - } - } - } -}; - -const monitorReducer = (state = initialState, action) => { - switch (action.type) { - case MonitorConstants.CHANGE_ALERT_CHANNEL: - return { - ...state, - settings: { - ...state.settings, - global: { - ...state.settings.global, - channels: { - ...state.settings.global.channels, - [action.channel]: { enabled: action.enabled } - } - } - } - }; - case MonitorConstants.RECEIVE_DEVICE_ALERTS: - return { - ...state, - alerts: { - ...state.alerts, - byDeviceId: { - ...state.alerts.byDeviceId, - [action.deviceId]: { - ...state.alerts.byDeviceId[action.deviceId], - alerts: action.alerts - } - } - } - }; - case MonitorConstants.RECEIVE_LATEST_DEVICE_ALERTS: - return { - ...state, - alerts: { - ...state.alerts, - byDeviceId: { - ...state.alerts.byDeviceId, - [action.deviceId]: { - ...state.alerts.byDeviceId[action.deviceId], - latest: action.alerts - } - } - } - }; - case MonitorConstants.RECEIVE_DEVICE_ISSUE_COUNTS: - return { - ...state, - issueCounts: { - ...state.issueCounts, - byType: { - ...state.issueCounts.byType, - [action.issueType]: action.counts - } - } - }; - case MonitorConstants.SET_ALERT_LIST_STATE: - return { - ...state, - alerts: { - ...state.alerts, - alertList: action.value - } - }; - - default: - return state; - } -}; - -export default monitorReducer; diff --git a/frontend/src/js/reducers/monitorReducer.test.js b/frontend/src/js/reducers/monitorReducer.test.js deleted file mode 100644 index e8f97c23..00000000 --- a/frontend/src/js/reducers/monitorReducer.test.js +++ /dev/null @@ -1,80 +0,0 @@ -// Copyright 2021 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { defaultState } from '../../../tests/mockData'; -import { DEVICE_ISSUE_OPTIONS } from '../constants/deviceConstants'; -import * as MonitorConstants from '../constants/monitorConstants'; -import reducer, { initialState } from './monitorReducer'; - -describe('monitor reducer', () => { - it('should return the initial state', async () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - - it('should handle CHANGE_ALERT_CHANNEL', async () => { - expect( - reducer(undefined, { type: MonitorConstants.CHANGE_ALERT_CHANNEL, channel: MonitorConstants.alertChannels.email, enabled: false }).settings.global - .channels[MonitorConstants.alertChannels.email].enabled - ).toEqual(false); - expect( - reducer(initialState, { type: MonitorConstants.CHANGE_ALERT_CHANNEL, channel: MonitorConstants.alertChannels.email, enabled: true }).settings.global - .channels[MonitorConstants.alertChannels.email].enabled - ).toEqual(true); - }); - it('should handle RECEIVE_DEVICE_ALERTS', async () => { - expect( - reducer(undefined, { type: MonitorConstants.RECEIVE_DEVICE_ALERTS, deviceId: defaultState.devices.byId.a1.id, alerts: [] }).alerts.byDeviceId[ - defaultState.devices.byId.a1.id - ].alerts - ).toEqual([]); - - expect( - reducer(initialState, { type: MonitorConstants.RECEIVE_DEVICE_ALERTS, deviceId: defaultState.devices.byId.a1.id, alerts: [123, 456] }).alerts.byDeviceId[ - defaultState.devices.byId.a1.id - ].alerts - ).toEqual([123, 456]); - }); - it('should handle RECEIVE_LATEST_DEVICE_ALERTS', async () => { - expect( - reducer(undefined, { type: MonitorConstants.RECEIVE_LATEST_DEVICE_ALERTS, deviceId: defaultState.devices.byId.a1.id, alerts: [] }).alerts.byDeviceId[ - defaultState.devices.byId.a1.id - ].latest - ).toEqual([]); - - expect( - reducer(initialState, { type: MonitorConstants.RECEIVE_LATEST_DEVICE_ALERTS, deviceId: defaultState.devices.byId.a1.id, alerts: [123, 456] }).alerts - .byDeviceId[defaultState.devices.byId.a1.id].latest - ).toEqual([123, 456]); - }); - it('should handle RECEIVE_DEVICE_ISSUE_COUNTS', async () => { - expect( - reducer(undefined, { - type: MonitorConstants.RECEIVE_DEVICE_ISSUE_COUNTS, - issueType: DEVICE_ISSUE_OPTIONS.monitoring.key, - counts: { filtered: 1, total: 3 } - }).issueCounts.byType[DEVICE_ISSUE_OPTIONS.monitoring.key] - ).toEqual({ filtered: 1, total: 3 }); - - expect( - reducer(initialState, { - type: MonitorConstants.RECEIVE_DEVICE_ISSUE_COUNTS, - issueType: DEVICE_ISSUE_OPTIONS.monitoring.key, - counts: { total: 3 } - }).issueCounts.byType[DEVICE_ISSUE_OPTIONS.monitoring.key] - ).toEqual({ total: 3 }); - }); - it('should handle SET_ALERT_LIST_STATE', async () => { - expect(reducer(undefined, { type: MonitorConstants.SET_ALERT_LIST_STATE, value: { total: 3 } }).alerts.alertList).toEqual({ total: 3 }); - expect(reducer(initialState, { type: MonitorConstants.SET_ALERT_LIST_STATE, value: 'something' }).alerts.alertList).toEqual('something'); - }); -}); diff --git a/frontend/src/js/reducers/onboardingReducer.js b/frontend/src/js/reducers/onboardingReducer.js deleted file mode 100644 index dde22f0b..00000000 --- a/frontend/src/js/reducers/onboardingReducer.js +++ /dev/null @@ -1,73 +0,0 @@ -// Copyright 2020 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import * as OnboardingConstants from '../constants/onboardingConstants'; - -export const initialState = { - approach: null, - complete: false, - deviceType: null, - demoArtifactPort: 85, - progress: null, - showTips: null, - showTipsDialog: false -}; - -const userReducer = (state = initialState, action) => { - switch (action.type) { - case OnboardingConstants.SET_ONBOARDING_STATE: - return { - ...state, - ...action.value - }; - case OnboardingConstants.SET_DEMO_ARTIFACT_PORT: - return { - ...state, - demoArtifactPort: action.value - }; - case OnboardingConstants.SET_SHOW_ONBOARDING_HELP: - return { - ...state, - showTips: action.show - }; - case OnboardingConstants.SET_SHOW_ONBOARDING_HELP_DIALOG: - return { - ...state, - showTipsDialog: action.show - }; - case OnboardingConstants.SET_ONBOARDING_COMPLETE: - return { - ...state, - complete: action.complete - }; - case OnboardingConstants.SET_ONBOARDING_PROGRESS: - return { - ...state, - progress: action.value - }; - case OnboardingConstants.SET_ONBOARDING_DEVICE_TYPE: - return { - ...state, - deviceType: action.value - }; - case OnboardingConstants.SET_ONBOARDING_APPROACH: - return { - ...state, - approach: action.value - }; - default: - return state; - } -}; - -export default userReducer; diff --git a/frontend/src/js/reducers/onboardingReducer.test.js b/frontend/src/js/reducers/onboardingReducer.test.js deleted file mode 100644 index 15905174..00000000 --- a/frontend/src/js/reducers/onboardingReducer.test.js +++ /dev/null @@ -1,45 +0,0 @@ -// Copyright 2020 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import * as OnboardingConstants from '../constants/onboardingConstants'; -import reducer, { initialState } from './onboardingReducer'; - -describe('organization reducer', () => { - it('should return the initial state', async () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - it('should handle SET_SHOW_ONBOARDING_HELP', async () => { - expect(reducer(undefined, { type: OnboardingConstants.SET_SHOW_ONBOARDING_HELP, show: true }).showTips).toEqual(true); - expect(reducer(initialState, { type: OnboardingConstants.SET_SHOW_ONBOARDING_HELP, show: false }).showTips).toEqual(false); - }); - it('should handle SET_SHOW_ONBOARDING_HELP_DIALOG', async () => { - expect(reducer(undefined, { type: OnboardingConstants.SET_SHOW_ONBOARDING_HELP_DIALOG, show: true }).showTipsDialog).toEqual(true); - expect(reducer(initialState, { type: OnboardingConstants.SET_SHOW_ONBOARDING_HELP_DIALOG, show: false }).showTipsDialog).toEqual(false); - }); - it('should handle SET_ONBOARDING_COMPLETE', async () => { - expect(reducer(undefined, { type: OnboardingConstants.SET_ONBOARDING_COMPLETE, complete: true }).complete).toEqual(true); - expect(reducer(initialState, { type: OnboardingConstants.SET_ONBOARDING_COMPLETE, complete: false }).complete).toEqual(false); - }); - it('should handle SET_ONBOARDING_PROGRESS', async () => { - expect(reducer(undefined, { type: OnboardingConstants.SET_ONBOARDING_PROGRESS, value: 'test' }).progress).toEqual('test'); - expect(reducer(initialState, { type: OnboardingConstants.SET_ONBOARDING_PROGRESS, value: 'test' }).progress).toEqual('test'); - }); - it('should handle SET_ONBOARDING_DEVICE_TYPE', async () => { - expect(reducer(undefined, { type: OnboardingConstants.SET_ONBOARDING_DEVICE_TYPE, value: 'bbb' }).deviceType).toEqual('bbb'); - expect(reducer(initialState, { type: OnboardingConstants.SET_ONBOARDING_DEVICE_TYPE, value: 'rpi4' }).deviceType).toEqual('rpi4'); - }); - it('should handle SET_ONBOARDING_APPROACH', async () => { - expect(reducer(undefined, { type: OnboardingConstants.SET_ONBOARDING_APPROACH, value: 'physical' }).approach).toEqual('physical'); - expect(reducer(initialState, { type: OnboardingConstants.SET_ONBOARDING_APPROACH, value: 'virtual' }).approach).toEqual('virtual'); - }); -}); diff --git a/frontend/src/js/reducers/organizationReducer.js b/frontend/src/js/reducers/organizationReducer.js deleted file mode 100644 index 07b91177..00000000 --- a/frontend/src/js/reducers/organizationReducer.js +++ /dev/null @@ -1,126 +0,0 @@ -// Copyright 2020 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { SORTING_OPTIONS } from '../constants/appConstants'; -import { DEVICE_LIST_DEFAULTS } from '../constants/deviceConstants'; -import { - RECEIVE_AUDIT_LOGS, - RECEIVE_CURRENT_CARD, - RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, - RECEIVE_SETUP_INTENT, - RECEIVE_SSO_CONFIGS, - RECEIVE_WEBHOOK_EVENTS, - SET_AUDITLOG_STATE, - SET_ORGANIZATION -} from '../constants/organizationConstants'; - -export const initialState = { - card: { - last4: '', - expiration: { month: 1, year: 2020 }, - brand: '' - }, - intentId: null, - organization: { - // id, name, status, tenant_token, plan - }, - auditlog: { - events: [], - selectionState: { - ...DEVICE_LIST_DEFAULTS, - detail: undefined, - endDate: undefined, - selectedIssue: undefined, - sort: { direction: SORTING_OPTIONS.desc }, - startDate: undefined, - total: 0, - type: undefined, - user: undefined - } - }, - externalDeviceIntegrations: [ - // { , id, provider } - ], - ssoConfigs: [], - webhooks: { - // [id]: { events: [] } - // for now: - events: [], - eventsTotal: 0 - } -}; - -const organizationReducer = (state = initialState, action) => { - switch (action.type) { - case RECEIVE_AUDIT_LOGS: - return { - ...state, - auditlog: { - ...state.auditlog, - events: action.events, - selectionState: { - ...state.auditlog.selectionState, - total: action.total - } - } - }; - case SET_AUDITLOG_STATE: - return { - ...state, - auditlog: { - ...state.auditlog, - selectionState: action.state - } - }; - case RECEIVE_CURRENT_CARD: - return { - ...state, - card: action.card - }; - case RECEIVE_SETUP_INTENT: - return { - ...state, - intentId: action.intentId - }; - case SET_ORGANIZATION: - return { - ...state, - organization: { - ...action.organization - } - }; - case RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS: - return { - ...state, - externalDeviceIntegrations: action.value - }; - case RECEIVE_SSO_CONFIGS: - return { - ...state, - ssoConfigs: action.value - }; - case RECEIVE_WEBHOOK_EVENTS: - return { - ...state, - webhooks: { - ...state.webhooks, - events: action.value, - eventsTotal: action.total - } - }; - default: - return state; - } -}; - -export default organizationReducer; diff --git a/frontend/src/js/reducers/organizationReducer.test.js b/frontend/src/js/reducers/organizationReducer.test.js deleted file mode 100644 index 8d557729..00000000 --- a/frontend/src/js/reducers/organizationReducer.test.js +++ /dev/null @@ -1,77 +0,0 @@ -// Copyright 2020 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { defaultState } from '../../../tests/mockData'; -import * as OrganizationConstants from '../constants/organizationConstants'; -import reducer, { initialState } from './organizationReducer'; - -describe('organization reducer', () => { - it('should return the initial state', async () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - - it('should handle RECEIVE_AUDIT_LOGS', async () => { - expect( - reducer(undefined, { type: OrganizationConstants.RECEIVE_AUDIT_LOGS, events: defaultState.organization.auditlog.events, total: 2 }).auditlog - .selectionState.total - ).toEqual(2); - expect( - reducer(initialState, { type: OrganizationConstants.RECEIVE_AUDIT_LOGS, events: defaultState.organization.auditlog.events, total: 4 }).auditlog - .selectionState.total - ).toEqual(4); - }); - it('should handle SET_AUDITLOG_STATE', async () => { - const newState = { something: 'new' }; - expect(reducer(undefined, { type: OrganizationConstants.SET_AUDITLOG_STATE, state: newState }).auditlog.selectionState).toEqual(newState); - expect(reducer(initialState, { type: OrganizationConstants.SET_AUDITLOG_STATE, state: newState }).auditlog.selectionState).toEqual(newState); - }); - it('should handle RECEIVE_CURRENT_CARD', async () => { - expect(reducer(undefined, { type: OrganizationConstants.RECEIVE_CURRENT_CARD, card: defaultState.organization.card }).card).toEqual( - defaultState.organization.card - ); - expect(reducer(initialState, { type: OrganizationConstants.RECEIVE_CURRENT_CARD, card: defaultState.organization.card }).card).toEqual( - defaultState.organization.card - ); - }); - it('should handle RECEIVE_SETUP_INTENT', async () => { - expect(reducer(undefined, { type: OrganizationConstants.RECEIVE_SETUP_INTENT, intentId: defaultState.organization.intentId }).intentId).toEqual( - defaultState.organization.intentId - ); - expect(reducer(initialState, { type: OrganizationConstants.RECEIVE_SETUP_INTENT, intentId: 4 }).intentId).toEqual(4); - }); - it('should handle SET_ORGANIZATION', async () => { - expect( - reducer(undefined, { type: OrganizationConstants.SET_ORGANIZATION, organization: defaultState.organization.organization }).organization.plan - ).toEqual(defaultState.organization.organization.plan); - expect( - reducer(initialState, { type: OrganizationConstants.SET_ORGANIZATION, organization: defaultState.organization.organization }).organization.name - ).toEqual(defaultState.organization.organization.name); - }); - it('should handle RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS', async () => { - expect(reducer(undefined, { type: OrganizationConstants.RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, value: [] }).externalDeviceIntegrations).toEqual([]); - expect(reducer(initialState, { type: OrganizationConstants.RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, value: [12, 23] }).externalDeviceIntegrations).toEqual([ - 12, 23 - ]); - }); - it('should handle RECEIVE_WEBHOOK_EVENTS', async () => { - expect(reducer(undefined, { type: OrganizationConstants.RECEIVE_WEBHOOK_EVENTS, value: [] }).webhooks.events).toEqual([]); - expect(reducer(initialState, { type: OrganizationConstants.RECEIVE_WEBHOOK_EVENTS, value: [12, 23], total: 5 }).webhooks).toEqual({ - events: [12, 23], - eventsTotal: 5 - }); - }); - it('should handle RECEIVE_SSO_CONFIGS', async () => { - expect(reducer(undefined, { type: OrganizationConstants.RECEIVE_SSO_CONFIGS, value: [] }).ssoConfigs).toEqual([]); - expect(reducer(initialState, { type: OrganizationConstants.RECEIVE_SSO_CONFIGS, value: [12, 23] }).ssoConfigs).toEqual([12, 23]); - }); -}); diff --git a/frontend/src/js/reducers/releaseReducer.test.js b/frontend/src/js/reducers/releaseReducer.test.js deleted file mode 100644 index 61bf04fa..00000000 --- a/frontend/src/js/reducers/releaseReducer.test.js +++ /dev/null @@ -1,115 +0,0 @@ -// Copyright 2020 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import * as ReleaseConstants from '../constants/releaseConstants'; -import reducer, { initialState } from './releaseReducer'; - -const testRelease = { - artifacts: [ - { - id: '123', - name: 'test', - description: '-', - device_types_compatible: ['test'], - updates: [{ files: [{ size: 123, name: '' }], type_info: { type: 'rootfs-image' } }], - url: '' - } - ], - device_types_compatible: ['test'], - name: 'test' -}; -describe('release reducer', () => { - it('should return the initial state', async () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - it('should handle UPDATED_ARTIFACT', async () => { - expect( - reducer(undefined, { - type: ReleaseConstants.UPDATED_ARTIFACT, - release: { ...testRelease, artifacts: [{ ...testRelease.artifacts[0], name: 'testUpdated' }] } - }).byId[testRelease.name].artifacts - ).toEqual([{ ...testRelease.artifacts[0], name: 'testUpdated' }]); - expect( - reducer(initialState, { - type: ReleaseConstants.UPDATED_ARTIFACT, - release: { ...testRelease, artifacts: [{ ...testRelease.artifacts[0], name: 'testUpdated' }] } - }).byId[testRelease.name].artifacts - ).toEqual([{ ...testRelease.artifacts[0], name: 'testUpdated' }]); - }); - it('should handle ARTIFACTS_SET_ARTIFACT_URL', async () => { - expect( - reducer(undefined, { - type: ReleaseConstants.ARTIFACTS_SET_ARTIFACT_URL, - release: { ...testRelease, artifacts: [{ ...testRelease.artifacts[0], url: 'testUpdated' }] } - }).byId[testRelease.name].artifacts - ).toEqual([{ ...testRelease.artifacts[0], url: 'testUpdated' }]); - expect( - reducer(initialState, { - type: ReleaseConstants.ARTIFACTS_SET_ARTIFACT_URL, - release: { ...testRelease, artifacts: [{ ...testRelease.artifacts[0], url: 'testUpdated' }] } - }).byId[testRelease.name].artifacts - ).toEqual([{ ...testRelease.artifacts[0], url: 'testUpdated' }]); - }); - it('should handle ARTIFACTS_REMOVED_ARTIFACT', async () => { - expect( - reducer(undefined, { type: ReleaseConstants.ARTIFACTS_REMOVED_ARTIFACT, release: { ...testRelease, artifacts: [] } }).byId[testRelease.name].artifacts - ).toEqual([]); - expect( - reducer(initialState, { type: ReleaseConstants.ARTIFACTS_REMOVED_ARTIFACT, release: { ...testRelease, artifacts: [] } }).byId[testRelease.name].artifacts - ).toEqual([]); - }); - it('should handle RECEIVE_RELEASE', async () => { - expect(reducer(undefined, { type: ReleaseConstants.RECEIVE_RELEASE, release: { ...testRelease, name: 'test2' } }).byId.test2).toEqual({ - ...testRelease, - name: 'test2' - }); - expect(reducer(initialState, { type: ReleaseConstants.RECEIVE_RELEASE, release: { ...testRelease, name: 'test2' } }).byId.test2).toEqual({ - ...testRelease, - name: 'test2' - }); - }); - it('should handle RECEIVE_RELEASES', async () => { - expect( - reducer(undefined, { type: ReleaseConstants.RECEIVE_RELEASES, releases: { test: testRelease, test2: { ...testRelease, name: 'test2' } } }).byId - ).toEqual({ test: testRelease, test2: { ...testRelease, name: 'test2' } }); - expect( - reducer(initialState, { type: ReleaseConstants.RECEIVE_RELEASES, releases: { test: testRelease, test2: { ...testRelease, name: 'test2' } } }).byId - ).toEqual({ test: testRelease, test2: { ...testRelease, name: 'test2' } }); - }); - it('should handle RELEASE_REMOVED', async () => { - expect(reducer(undefined, { type: ReleaseConstants.RELEASE_REMOVED, release: 'test' }).byId).toEqual({}); - expect(reducer({ ...initialState, byId: { test: testRelease } }, { type: ReleaseConstants.RELEASE_REMOVED, release: 'test' }).byId).toEqual({}); - expect( - reducer({ ...initialState, byId: { test: testRelease }, selectedRelease: 'test' }, { type: ReleaseConstants.RELEASE_REMOVED, release: 'test' }) - .selectedRelease - ).toEqual(null); - expect( - reducer( - { ...initialState, byId: { test: testRelease, test2: testRelease }, selectedRelease: 'test2' }, - { type: ReleaseConstants.RELEASE_REMOVED, release: 'test' } - ).selectedRelease - ).toEqual('test2'); - }); - it('should handle SELECTED_RELEASE', async () => { - expect(reducer(undefined, { type: ReleaseConstants.SELECTED_RELEASE, release: 'test' }).selectedRelease).toEqual('test'); - expect(reducer(initialState, { type: ReleaseConstants.SELECTED_RELEASE, release: 'test' }).selectedRelease).toEqual('test'); - }); - it('should handle SET_RELEASES_LIST_STATE', async () => { - expect(reducer(undefined, { type: ReleaseConstants.SET_RELEASES_LIST_STATE, value: { something: 'special' } }).releasesList).toEqual({ - something: 'special' - }); - expect(reducer(initialState, { type: ReleaseConstants.SET_RELEASES_LIST_STATE, value: { something: 'special' } }).releasesList).toEqual({ - something: 'special' - }); - }); -}); diff --git a/frontend/src/js/reducers/userReducer.js b/frontend/src/js/reducers/userReducer.js deleted file mode 100644 index 3af12c7a..00000000 --- a/frontend/src/js/reducers/userReducer.js +++ /dev/null @@ -1,191 +0,0 @@ -// Copyright 2019 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import * as UserConstants from '../constants/userConstants'; - -export const initialState = { - byId: {}, - currentUser: null, - customColumns: [], - currentSession: { - // { token: window.localStorage.getItem('JWT'), expiresAt: '2023-01-01T00:15:00.000Z' | undefined }, // expiresAt depending on the stay logged in setting - }, - qrCode: null, - globalSettings: { - id_attribute: undefined, - previousFilters: [], - previousPhases: [], - retries: 0 - }, - permissionSetsById: { - ...UserConstants.defaultPermissionSets - }, - rolesById: { - ...UserConstants.rolesById - }, - showConnectDeviceDialog: false, - showStartupNotification: false, - tooltips: { - byId: { - // : { readState: } // this object is getting enhanced by the tooltip texts in the app constants - } - }, - userSettings: { - columnSelection: [], - onboarding: {} - } -}; - -const userReducer = (state = initialState, action) => { - switch (action.type) { - case UserConstants.RECEIVED_QR_CODE: - return { - ...state, - qrCode: action.value - }; - case UserConstants.SUCCESSFULLY_LOGGED_IN: - return { - ...state, - currentSession: action.value - }; - case UserConstants.RECEIVED_USER_LIST: - return { - ...state, - byId: { ...action.users } - }; - case UserConstants.RECEIVED_ACTIVATION_CODE: - return { - ...state, - activationCode: action.code - }; - case UserConstants.RECEIVED_USER: - return { - ...state, - byId: { - ...state.byId, - [action.user.id]: { - ...action.user - } - }, - currentUser: action.user.id - }; - case UserConstants.CREATED_USER: - // the new user gets a 0 as id, since this will be overwritten by the retrieved userlist anyway + there is no way to know the id before - return { - ...state, - byId: { - ...state.byId, - 0: action.user - } - }; - case UserConstants.REMOVED_USER: { - // eslint-disable-next-line no-unused-vars - const { [action.userId]: removedUser, ...byId } = state.byId; - return { - ...state, - byId, - currentUser: state.currentUser === action.userId ? null : state.currentUser - }; - } - case UserConstants.UPDATED_USER: - return { - ...state, - byId: { - ...state.byId, - [action.userId]: { - ...state.byId[action.userId], - ...action.user - } - } - }; - case UserConstants.RECEIVED_PERMISSION_SETS: - return { - ...state, - permissionSetsById: action.value - }; - case UserConstants.RECEIVED_ROLES: - case UserConstants.REMOVED_ROLE: - return { - ...state, - rolesById: action.value - }; - case UserConstants.CREATED_ROLE: - case UserConstants.UPDATED_ROLE: - return { - ...state, - rolesById: { - ...state.rolesById, - [action.roleId]: { - ...state.rolesById[action.roleId], - ...action.role - } - } - }; - case UserConstants.SET_CUSTOM_COLUMNS: - return { - ...state, - customColumns: action.value - }; - case UserConstants.SET_GLOBAL_SETTINGS: - return { - ...state, - settingsInitialized: true, - globalSettings: { - ...state.globalSettings, - ...action.settings - } - }; - case UserConstants.SET_USER_SETTINGS: - return { - ...state, - userSettingsInitialized: true, - userSettings: { - ...state.userSettings, - ...action.settings - } - }; - case UserConstants.SET_SHOW_CONNECT_DEVICE: - return { - ...state, - showConnectDeviceDialog: action.show - }; - case UserConstants.SET_SHOW_STARTUP_NOTIFICATION: - return { - ...state, - showStartupNotification: action.value - }; - case UserConstants.SET_TOOLTIP_STATE: - return { - ...state, - tooltips: { - ...state.tooltips, - byId: { - ...state.tooltips.byId, - [action.id]: action.value - } - } - }; - case UserConstants.SET_TOOLTIPS_STATE: - return { - ...state, - tooltips: { - ...state.tooltips, - byId: action.value - } - }; - default: - return state; - } -}; - -export default userReducer; diff --git a/frontend/src/js/reducers/userReducer.test.js b/frontend/src/js/reducers/userReducer.test.js deleted file mode 100644 index c3822006..00000000 --- a/frontend/src/js/reducers/userReducer.test.js +++ /dev/null @@ -1,150 +0,0 @@ -// Copyright 2020 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { defaultState } from '../../../tests/mockData'; -import * as UserConstants from '../constants/userConstants'; -import reducer, { initialState } from './userReducer'; - -const testUser = { - created_ts: '', - email: 'test@example.com', - id: '123', - roles: ['RBAC_ROLE_PERMIT_ALL'], - tfasecret: '', - updated_ts: '' -}; - -const newDescription = 'new description'; - -describe('user reducer', () => { - it('should return the initial state', async () => { - expect(reducer(undefined, {})).toEqual(initialState); - }); - - it('should handle RECEIVED_QR_CODE', async () => { - expect(reducer(undefined, { type: UserConstants.RECEIVED_QR_CODE, value: '123' }).qrCode).toEqual('123'); - expect(reducer(initialState, { type: UserConstants.RECEIVED_QR_CODE, value: '123' }).qrCode).toEqual('123'); - }); - - it('should handle SUCCESSFULLY_LOGGED_IN', async () => { - expect(reducer(undefined, { type: UserConstants.SUCCESSFULLY_LOGGED_IN, value: '123' }).currentSession).toEqual('123'); - expect(reducer(initialState, { type: UserConstants.SUCCESSFULLY_LOGGED_IN, value: '123' }).currentSession).toEqual('123'); - }); - - it('should handle RECEIVED_USER_LIST', async () => { - expect(reducer(undefined, { type: UserConstants.RECEIVED_USER_LIST, users: { '123': testUser } }).byId).toEqual({ '123': testUser }); - expect(reducer({ ...initialState, byId: { '123': testUser } }, { type: UserConstants.RECEIVED_USER_LIST, users: { '456': testUser } }).byId).toEqual({ - '456': testUser - }); - }); - - it('should handle RECEIVED_ACTIVATION_CODE', async () => { - expect(reducer(undefined, { type: UserConstants.RECEIVED_ACTIVATION_CODE, code: 'code' }).activationCode).toEqual('code'); - expect(reducer({ ...initialState }, { type: UserConstants.RECEIVED_ACTIVATION_CODE, code: 'code' }).activationCode).toEqual('code'); - }); - - it('should handle RECEIVED_USER', async () => { - expect(reducer(undefined, { type: UserConstants.RECEIVED_USER, user: testUser }).byId).toEqual({ '123': testUser }); - expect(reducer({ ...initialState, byId: { '123': testUser } }, { type: UserConstants.RECEIVED_USER, user: testUser }).byId).toEqual({ '123': testUser }); - }); - - it('should handle CREATED_USER', async () => { - expect(reducer(undefined, { type: UserConstants.CREATED_USER, user: testUser }).byId).toEqual({ 0: testUser }); - expect(reducer({ ...initialState, byId: { '123': testUser } }, { type: UserConstants.CREATED_USER, user: testUser }).byId).toEqual({ - '123': testUser, - 0: testUser - }); - }); - - it('should handle REMOVED_USER', async () => { - expect(reducer(undefined, { type: UserConstants.REMOVED_USER, userId: '123' }).byId).toEqual({}); - expect(reducer({ ...initialState, byId: { '123': testUser, '456': testUser } }, { type: UserConstants.REMOVED_USER, userId: '123' }).byId).toEqual({ - '456': testUser - }); - }); - - it('should handle UPDATED_USER', async () => { - expect(reducer(undefined, { type: UserConstants.UPDATED_USER, userId: '123', user: testUser }).byId).toEqual({ '123': testUser }); - - expect( - reducer( - { ...initialState, byId: { '123': testUser } }, - { type: UserConstants.UPDATED_USER, userId: '123', user: { ...testUser, email: 'test@mender.io' } } - ).byId['123'].email - ).toEqual('test@mender.io'); - }); - it('should handle RECEIVED_ROLES', async () => { - const roles = reducer(undefined, { type: UserConstants.RECEIVED_ROLES, value: { ...defaultState.users.rolesById } }).rolesById; - Object.entries(defaultState.users.rolesById).forEach(([key, role]) => expect(roles[key]).toEqual(role)); - expect( - reducer( - { ...initialState, rolesById: { ...defaultState.users.rolesById, thingsRole: { test: 'test' } } }, - { type: UserConstants.RECEIVED_ROLES, value: { ...defaultState.users.rolesById } } - ).rolesById.thingsRole - ).toBeFalsy(); - }); - it('should handle REMOVED_ROLE', async () => { - // eslint-disable-next-line no-unused-vars - const { [defaultState.users.rolesById.test.name]: removedRole, ...rolesById } = defaultState.users.rolesById; - expect(reducer(undefined, { type: UserConstants.REMOVED_ROLE, value: defaultState.users.rolesById.test.name }).rolesById.test).toBeFalsy(); - expect( - reducer({ ...initialState, rolesById: { ...defaultState.users.rolesById } }, { type: UserConstants.REMOVED_ROLE, value: rolesById }).rolesById.test - ).toBeFalsy(); - }); - it('should handle CREATED_ROLE', async () => { - expect( - reducer(undefined, { - type: UserConstants.CREATED_ROLE, - roleId: 'newRole', - role: { name: 'newRole', description: newDescription, groups: ['123'] } - }).rolesById.newRole.description - ).toEqual(newDescription); - expect( - reducer( - { ...initialState }, - { - type: UserConstants.CREATED_ROLE, - roleId: 'newRole', - role: { name: 'newRole', description: newDescription, groups: ['123'] } - } - ).rolesById.newRole.description - ).toEqual(newDescription); - }); - it('should handle UPDATED_ROLE', async () => { - expect( - reducer(undefined, { type: UserConstants.UPDATED_ROLE, roleId: 'RBAC_ROLE_CI', role: { description: newDescription } }).rolesById.RBAC_ROLE_CI.name - ).toEqual('Releases Manager'); - expect( - reducer({ ...initialState }, { type: UserConstants.UPDATED_ROLE, roleId: 'RBAC_ROLE_CI', role: { description: newDescription } }).rolesById.RBAC_ROLE_CI - .name - ).toEqual('Releases Manager'); - }); - it('should handle SET_CUSTOM_COLUMNS', async () => { - expect(reducer(undefined, { type: UserConstants.SET_CUSTOM_COLUMNS, value: 'test' }).customColumns).toEqual('test'); - expect(reducer({ ...initialState }, { type: UserConstants.SET_CUSTOM_COLUMNS, value: 'test' }).customColumns).toEqual('test'); - }); - it('should handle SET_GLOBAL_SETTINGS', async () => { - expect(reducer(undefined, { type: UserConstants.SET_GLOBAL_SETTINGS, settings: { newSetting: 'test' } }).globalSettings).toEqual({ - ...initialState.globalSettings, - newSetting: 'test' - }); - expect(reducer({ ...initialState }, { type: UserConstants.SET_GLOBAL_SETTINGS, settings: { newSetting: 'test' } }).globalSettings).toEqual({ - ...initialState.globalSettings, - newSetting: 'test' - }); - }); - it('should handle SET_SHOW_CONNECT_DEVICE', async () => { - expect(reducer(undefined, { type: UserConstants.SET_SHOW_CONNECT_DEVICE, show: false }).showConnectDeviceDialog).toEqual(false); - expect(reducer({ ...initialState }, { type: UserConstants.SET_SHOW_CONNECT_DEVICE, show: true }).showConnectDeviceDialog).toEqual(true); - }); -}); diff --git a/frontend/src/js/selectors/index.js b/frontend/src/js/selectors/index.js deleted file mode 100644 index cbc077c7..00000000 --- a/frontend/src/js/selectors/index.js +++ /dev/null @@ -1,499 +0,0 @@ -// Copyright 2020 Northern.tech AS -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -import { createSelector } from '@reduxjs/toolkit'; - -import { defaultReports } from '../actions/deviceActions'; -import { mapUserRolesToUiPermissions } from '../actions/userActions'; -import { PLANS } from '../constants/appConstants'; -import { DEPLOYMENT_STATES } from '../constants/deploymentConstants'; -import { - ALL_DEVICES, - ATTRIBUTE_SCOPES, - DEVICE_ISSUE_OPTIONS, - DEVICE_LIST_MAXIMUM_LENGTH, - DEVICE_ONLINE_CUTOFF, - DEVICE_STATES, - EXTERNAL_PROVIDER, - UNGROUPED_GROUP -} from '../constants/deviceConstants'; -import { READ_STATES, rolesByName, twoFAStates, uiPermissionsById } from '../constants/userConstants'; -import { attributeDuplicateFilter, duplicateFilter, getDemoDeviceAddress as getDemoDeviceAddressHelper, versionCompare } from '../helpers'; -import { onboardingSteps } from '../utils/onboardingmanager'; - -const getAppDocsVersion = state => state.app.docsVersion; -export const getFeatures = state => state.app.features; -export const getTooltipsById = state => state.users.tooltips.byId; -export const getRolesById = state => state.users.rolesById; -export const getOrganization = state => state.organization.organization; -export const getAcceptedDevices = state => state.devices.byStatus.accepted; -export const getCurrentSession = state => state.users.currentSession; -const getDevicesByStatus = state => state.devices.byStatus; -export const getDevicesById = state => state.devices.byId; -export const getDeviceReports = state => state.devices.reports; -export const getGroupsById = state => state.devices.groups.byId; -const getSelectedGroup = state => state.devices.groups.selectedGroup; -const getSearchedDevices = state => state.app.searchState.deviceIds; -const getListedDevices = state => state.devices.deviceList.deviceIds; -const getFilteringAttributes = state => state.devices.filteringAttributes; -export const getDeviceFilters = state => state.devices.filters || []; -const getFilteringAttributesFromConfig = state => state.devices.filteringAttributesConfig.attributes; -export const getSortedFilteringAttributes = createSelector([getFilteringAttributes], filteringAttributes => ({ - ...filteringAttributes, - identityAttributes: [...filteringAttributes.identityAttributes, 'id'] -})); -export const getDeviceLimit = state => state.devices.limit; -const getDevicesList = state => Object.values(state.devices.byId); -const getOnboarding = state => state.onboarding; -export const getGlobalSettings = state => state.users.globalSettings; -const getIssueCountsByType = state => state.monitor.issueCounts.byType; -const getSelectedReleaseId = state => state.releases.selectedRelease; -export const getReleasesById = state => state.releases.byId; -export const getReleaseTags = state => state.releases.tags; -export const getReleaseListState = state => state.releases.releasesList; -const getListedReleases = state => state.releases.releasesList.releaseIds; -export const getUpdateTypes = state => state.releases.updateTypes; -export const getExternalIntegrations = state => state.organization.externalDeviceIntegrations; -const getDeploymentsById = state => state.deployments.byId; -export const getDeploymentsByStatus = state => state.deployments.byStatus; -const getSelectedDeploymentDeviceIds = state => state.deployments.selectedDeviceIds; -export const getDeploymentsSelectionState = state => state.deployments.selectionState; -export const getFullVersionInformation = state => state.app.versionInformation; -export const getAuditLog = state => state.organization.auditlog.events; -export const getAuditLogSelectionState = state => state.organization.auditlog.selectionState; -const getCurrentUserId = state => state.users.currentUser; -const getUsersById = state => state.users.byId; -export const getCurrentUser = createSelector([getUsersById, getCurrentUserId], (usersById, userId) => usersById[userId] ?? {}); -export const getUserSettings = state => state.users.userSettings; -export const getSsoConfig = ({ organization: { ssoConfigs = [] } }) => ssoConfigs[0]; - -export const getVersionInformation = createSelector([getFullVersionInformation, getFeatures], ({ Integration, ...remainder }, { isHosted }) => - isHosted && Integration !== 'next' ? remainder : { ...remainder, Integration } -); -export const getIsPreview = createSelector([getFullVersionInformation], ({ Integration }) => versionCompare(Integration, 'next') > -1); - -export const getShowHelptips = createSelector([getTooltipsById], tooltips => - Object.values(tooltips).reduce((accu, { readState }) => accu || readState === READ_STATES.unread, false) -); - -export const getMappedDeploymentSelection = createSelector( - [getDeploymentsSelectionState, (_, deploymentsState) => deploymentsState, getDeploymentsById], - (selectionState, deploymentsState, deploymentsById) => { - const { selection = [] } = selectionState[deploymentsState] ?? {}; - return selection.reduce((accu, id) => { - if (deploymentsById[id]) { - accu.push(deploymentsById[id]); - } - return accu; - }, []); - } -); - -export const getDeploymentRelease = createSelector( - [getDeploymentsById, getDeploymentsSelectionState, getReleasesById], - (deploymentsById, { selectedId }, releasesById) => { - const deployment = deploymentsById[selectedId] || {}; - return deployment.artifact_name && releasesById[deployment.artifact_name] ? releasesById[deployment.artifact_name] : { device_types_compatible: [] }; - } -); - -export const getSelectedDeploymentData = createSelector( - [getDeploymentsById, getDeploymentsSelectionState, getDevicesById, getSelectedDeploymentDeviceIds], - (deploymentsById, { selectedId }, devicesById, selectedDeviceIds) => { - const deployment = deploymentsById[selectedId] ?? {}; - const { devices = {} } = deployment; - return { - deployment, - selectedDevices: selectedDeviceIds.map(deviceId => ({ ...devicesById[deviceId], ...devices[deviceId] })) - }; - } -); - -export const getHas2FA = createSelector( - [getCurrentUser], - currentUser => currentUser.hasOwnProperty('tfa_status') && currentUser.tfa_status === twoFAStates.enabled -); - -export const getDemoDeviceAddress = createSelector([getDevicesList, getOnboarding], (devices, { approach, demoArtifactPort }) => { - const demoDeviceAddress = `http://${getDemoDeviceAddressHelper(devices, approach)}`; - return demoArtifactPort ? `${demoDeviceAddress}:${demoArtifactPort}` : demoDeviceAddress; -}); - -export const getDeviceReportsForUser = createSelector( - [getUserSettings, getCurrentUserId, getGlobalSettings, getDevicesById], - ({ reports }, currentUserId, globalSettings, devicesById) => { - return reports || globalSettings[`${currentUserId}-reports`] || (Object.keys(devicesById).length ? defaultReports : []); - } -); - -const deviceMapDefault = { defaultObject: { auth_sets: [] }, cutOffSize: DEVICE_LIST_MAXIMUM_LENGTH }; -const listItemMapper = (byId, ids, { defaultObject, cutOffSize }) => { - return ids.slice(0, cutOffSize).reduce((accu, id) => { - if (id && byId[id]) { - accu.push({ ...defaultObject, ...byId[id] }); - } - return accu; - }, []); -}; - -const listTypeDeviceIdMap = { - deviceList: getListedDevices, - search: getSearchedDevices -}; -const getDeviceMappingDefaults = () => deviceMapDefault; -export const getMappedDevicesList = createSelector( - [getDevicesById, (state, listType) => listTypeDeviceIdMap[listType](state), getDeviceMappingDefaults], - listItemMapper -); - -export const getDeviceCountsByStatus = createSelector([getDevicesByStatus], byStatus => - Object.values(DEVICE_STATES).reduce((accu, state) => { - accu[state] = byStatus[state].total || 0; - return accu; - }, {}) -); - -export const getDeviceById = createSelector([getDevicesById, (_, deviceId) => deviceId], (devicesById, deviceId = '') => devicesById[deviceId] ?? {}); - -export const getAuditLogEntry = createSelector([getAuditLog, getAuditLogSelectionState], (events, { selectedId }) => { - if (!selectedId) { - return; - } - const [eventAction, eventTime] = atob(selectedId).split('|'); - return events.find(item => item.action === eventAction && item.time === eventTime); -}); - -export const getAuditlogDevice = createSelector([getAuditLogEntry, getDevicesById], (auditlogEvent, devicesById) => { - let auditlogDevice = {}; - if (auditlogEvent) { - const { object = {} } = auditlogEvent; - const { device = {}, id, type } = object; - auditlogDevice = type === 'device' ? { id, ...device } : auditlogDevice; - } - return { ...auditlogDevice, ...devicesById[auditlogDevice.id] }; -}); - -export const getDeviceConfigDeployment = createSelector([getDeviceById, getDeploymentsById], (device, deploymentsById) => { - const { config = {} } = device; - const { deployment_id: configDeploymentId } = config; - const deviceConfigDeployment = deploymentsById[configDeploymentId] || {}; - return { device, deviceConfigDeployment }; -}); - -export const getSelectedGroupInfo = createSelector( - [getAcceptedDevices, getGroupsById, getSelectedGroup], - ({ total: acceptedDeviceTotal }, groupsById, selectedGroup) => { - let groupCount = acceptedDeviceTotal; - let groupFilters = []; - if (selectedGroup && groupsById[selectedGroup]) { - groupCount = groupsById[selectedGroup].total; - groupFilters = groupsById[selectedGroup].filters || []; - } - return { groupCount, selectedGroup, groupFilters }; - } -); - -const defaultIdAttribute = Object.freeze({ attribute: 'id', scope: ATTRIBUTE_SCOPES.identity }); -export const getIdAttribute = createSelector([getGlobalSettings], ({ id_attribute = { ...defaultIdAttribute } }) => id_attribute); - -export const getLimitMaxed = createSelector([getAcceptedDevices, getDeviceLimit], ({ total: acceptedDevices = 0 }, deviceLimit) => - Boolean(deviceLimit && deviceLimit <= acceptedDevices) -); - -export const getFilterAttributes = createSelector( - [getGlobalSettings, getFilteringAttributes], - ({ previousFilters }, { identityAttributes, inventoryAttributes, systemAttributes, tagAttributes }) => { - const deviceNameAttribute = { key: 'name', value: 'Name', scope: ATTRIBUTE_SCOPES.tags, category: ATTRIBUTE_SCOPES.tags, priority: 1 }; - const deviceIdAttribute = { key: 'id', value: 'Device ID', scope: ATTRIBUTE_SCOPES.identity, category: ATTRIBUTE_SCOPES.identity, priority: 1 }; - const checkInAttribute = { key: 'check_in_time', value: 'Latest activity', scope: ATTRIBUTE_SCOPES.system, category: ATTRIBUTE_SCOPES.system, priority: 4 }; - const updateAttribute = { ...checkInAttribute, key: 'updated_ts', value: 'Last inventory update' }; - const firstRequestAttribute = { key: 'created_ts', value: 'First request', scope: ATTRIBUTE_SCOPES.system, category: ATTRIBUTE_SCOPES.system, priority: 4 }; - const attributes = [ - ...previousFilters.map(item => ({ - ...item, - value: deviceIdAttribute.key === item.key ? deviceIdAttribute.value : item.key, - category: 'recently used', - priority: 0 - })), - deviceNameAttribute, - deviceIdAttribute, - ...identityAttributes.map(item => ({ key: item, value: item, scope: ATTRIBUTE_SCOPES.identity, category: ATTRIBUTE_SCOPES.identity, priority: 1 })), - ...inventoryAttributes.map(item => ({ key: item, value: item, scope: ATTRIBUTE_SCOPES.inventory, category: ATTRIBUTE_SCOPES.inventory, priority: 2 })), - ...tagAttributes.map(item => ({ key: item, value: item, scope: ATTRIBUTE_SCOPES.tags, category: ATTRIBUTE_SCOPES.tags, priority: 3 })), - checkInAttribute, - updateAttribute, - firstRequestAttribute, - ...systemAttributes.map(item => ({ key: item, value: item, scope: ATTRIBUTE_SCOPES.system, category: ATTRIBUTE_SCOPES.system, priority: 4 })) - ]; - return attributeDuplicateFilter(attributes, 'key'); - } -); - -const getFilteringAttributesLimit = state => state.devices.filteringAttributesLimit; - -export const getDeviceIdentityAttributes = createSelector( - [getFilteringAttributes, getFilteringAttributesLimit], - ({ identityAttributes }, filteringAttributesLimit) => { - // limit the selection of the available attribute to AVAILABLE_ATTRIBUTE_LIMIT - const attributes = identityAttributes.slice(0, filteringAttributesLimit); - return attributes.reduce( - (accu, value) => { - accu.push({ value, label: value, scope: 'identity' }); - return accu; - }, - [ - { value: 'name', label: 'Name', scope: 'tags' }, - { value: 'id', label: 'Device ID', scope: 'identity' } - ] - ); - } -); - -// eslint-disable-next-line no-unused-vars -export const getGroupsByIdWithoutUngrouped = createSelector([getGroupsById], ({ [UNGROUPED_GROUP.id]: ungrouped, ...groups }) => groups); - -export const getGroups = createSelector([getGroupsById], groupsById => { - const groupNames = Object.keys(groupsById).sort(); - const groupedGroups = Object.entries(groupsById) - .sort((a, b) => a[0].localeCompare(b[0])) - .reduce( - (accu, [groupname, group]) => { - const name = groupname === UNGROUPED_GROUP.id ? UNGROUPED_GROUP.name : groupname; - const groupItem = { ...group, groupId: name, name: groupname }; - if (group.filters.length > 0) { - if (groupname !== UNGROUPED_GROUP.id) { - accu.dynamic.push(groupItem); - } else { - accu.ungrouped.push(groupItem); - } - } else { - accu.static.push(groupItem); - } - return accu; - }, - { dynamic: [], static: [], ungrouped: [] } - ); - return { groupNames, ...groupedGroups }; -}); - -export const getDeviceTwinIntegrations = createSelector([getExternalIntegrations], integrations => - integrations.filter(integration => integration.id && EXTERNAL_PROVIDER[integration.provider]?.deviceTwin) -); - -export const getOfflineThresholdSettings = createSelector([getGlobalSettings], ({ offlineThreshold }) => ({ - interval: offlineThreshold?.interval || DEVICE_ONLINE_CUTOFF.interval, - intervalUnit: offlineThreshold?.intervalUnit || DEVICE_ONLINE_CUTOFF.intervalName -})); - -export const getOnboardingState = createSelector([getOnboarding, getUserSettings], ({ complete, progress, showTips, ...remainder }, { onboarding = {} }) => ({ - ...remainder, - ...onboarding, - complete: onboarding.complete || complete, - progress: - Object.keys(onboardingSteps).findIndex(step => step === progress) > Object.keys(onboardingSteps).findIndex(step => step === onboarding.progress) - ? progress - : onboarding.progress, - showTips: !onboarding.showTips ? onboarding.showTips : showTips -})); - -export const getTooltipsState = createSelector([getTooltipsById, getUserSettings], (byId, { tooltips = {} }) => - Object.entries(byId).reduce( - (accu, [id, value]) => { - accu[id] = { ...accu[id], ...value }; - return accu; - }, - { ...tooltips } - ) -); - -export const getDocsVersion = createSelector([getAppDocsVersion, getFeatures], (appDocsVersion, { isHosted }) => { - // if hosted, use latest docs version - const docsVersion = appDocsVersion ? `${appDocsVersion}/` : 'development/'; - return isHosted ? '' : docsVersion; -}); - -export const getIsEnterprise = createSelector( - [getOrganization, getFeatures], - ({ plan = PLANS.os.id }, { isEnterprise, isHosted }) => isEnterprise || (isHosted && plan === PLANS.enterprise.id) -); - -export const getAttributesList = createSelector( - [getFilteringAttributes, getFilteringAttributesFromConfig], - ({ identityAttributes = [], inventoryAttributes = [] }, { identity = [], inventory = [] }) => - [...identityAttributes, ...inventoryAttributes, ...identity, ...inventory].filter(duplicateFilter) -); - -export const getRolesList = createSelector([getRolesById], rolesById => Object.entries(rolesById).map(([id, role]) => ({ id, ...role }))); - -export const getUserRoles = createSelector( - [getCurrentUser, getRolesById, getIsEnterprise, getFeatures, getOrganization], - (currentUser, rolesById, isEnterprise, { isHosted, hasMultitenancy }, { plan = PLANS.os.id }) => { - const isAdmin = currentUser.roles?.length - ? currentUser.roles.some(role => role === rolesByName.admin) - : !(hasMultitenancy || isEnterprise || (isHosted && plan !== PLANS.os.id)); - const uiPermissions = isAdmin - ? mapUserRolesToUiPermissions([rolesByName.admin], rolesById) - : mapUserRolesToUiPermissions(currentUser.roles || [], rolesById); - return { isAdmin, uiPermissions }; - } -); - -const hasPermission = (thing, permission) => Object.values(thing).some(permissions => permissions.includes(permission)); - -export const getUserCapabilities = createSelector([getUserRoles], ({ uiPermissions }) => { - const canManageReleases = hasPermission(uiPermissions.releases, uiPermissionsById.manage.value); - const canReadReleases = canManageReleases || hasPermission(uiPermissions.releases, uiPermissionsById.read.value); - const canUploadReleases = canManageReleases || hasPermission(uiPermissions.releases, uiPermissionsById.upload.value); - - const canAuditlog = uiPermissions.auditlog.includes(uiPermissionsById.read.value); - - const canReadUsers = uiPermissions.userManagement.includes(uiPermissionsById.read.value); - const canManageUsers = uiPermissions.userManagement.includes(uiPermissionsById.manage.value); - - const canReadDevices = hasPermission(uiPermissions.groups, uiPermissionsById.read.value); - const canWriteDevices = Object.values(uiPermissions.groups).some( - groupPermissions => groupPermissions.includes(uiPermissionsById.read.value) && groupPermissions.length > 1 - ); - const canTroubleshoot = hasPermission(uiPermissions.groups, uiPermissionsById.connect.value); - const canManageDevices = hasPermission(uiPermissions.groups, uiPermissionsById.manage.value); - const canConfigure = hasPermission(uiPermissions.groups, uiPermissionsById.configure.value); - - const canDeploy = uiPermissions.deployments.includes(uiPermissionsById.deploy.value) || hasPermission(uiPermissions.groups, uiPermissionsById.deploy.value); - const canReadDeployments = uiPermissions.deployments.includes(uiPermissionsById.read.value); - - return { - canAuditlog, - canConfigure, - canDeploy, - canManageDevices, - canManageReleases, - canManageUsers, - canReadDeployments, - canReadDevices, - canReadReleases, - canReadUsers, - canTroubleshoot, - canUploadReleases, - canWriteDevices, - groupsPermissions: uiPermissions.groups, - releasesPermissions: uiPermissions.releases - }; -}); - -export const getTenantCapabilities = createSelector( - [getFeatures, getOrganization, getIsEnterprise], - ( - { hasAuditlogs: isAuditlogEnabled, hasDeviceConfig: isDeviceConfigEnabled, hasDeviceConnect: isDeviceConnectEnabled, hasMonitor: isMonitorEnabled }, - { addons = [], plan = PLANS.os.id }, - isEnterprise - ) => { - const canDelta = isEnterprise || plan === PLANS.professional.id; - const hasAuditlogs = isAuditlogEnabled && isEnterprise; - const hasDeviceConfig = isDeviceConfigEnabled && addons.some(addon => addon.name === 'configure' && Boolean(addon.enabled)); - const hasDeviceConnect = isDeviceConnectEnabled && (!isEnterprise || addons.some(addon => addon.name === 'troubleshoot' && Boolean(addon.enabled))); - const hasMonitor = isMonitorEnabled && addons.some(addon => addon.name === 'monitor' && Boolean(addon.enabled)); - return { - canDelta, - canRetry: canDelta, - canSchedule: canDelta, - hasAuditlogs, - hasDeviceConfig, - hasDeviceConnect, - hasFullFiltering: canDelta, - hasMonitor, - isEnterprise, - plan - }; - } -); - -export const getAvailableIssueOptionsByType = createSelector( - [getFeatures, getTenantCapabilities, getIssueCountsByType], - ({ hasReporting }, { hasFullFiltering, hasMonitor }, issueCounts) => - Object.values(DEVICE_ISSUE_OPTIONS).reduce((accu, { isCategory, key, needsFullFiltering, needsMonitor, needsReporting, title }) => { - if (isCategory || (needsReporting && !hasReporting) || (needsFullFiltering && !hasFullFiltering) || (needsMonitor && !hasMonitor)) { - return accu; - } - accu[key] = { count: issueCounts[key].filtered, key, title }; - return accu; - }, {}) -); - -export const getDeviceTypes = createSelector([getAcceptedDevices, getDevicesById], ({ deviceIds = [] }, devicesById) => - Object.keys( - deviceIds.slice(0, 200).reduce((accu, item) => { - const { device_type: deviceTypes = [] } = devicesById[item] ? devicesById[item].attributes : {}; - accu = deviceTypes.reduce((deviceTypeAccu, deviceType) => { - if (deviceType.length > 1) { - deviceTypeAccu[deviceType] = deviceTypeAccu[deviceType] ? deviceTypeAccu[deviceType] + 1 : 1; - } - return deviceTypeAccu; - }, accu); - return accu; - }, {}) - ) -); - -const defaultGroupSelectionOptions = {}; -export const getGroupNames = createSelector( - [getGroupsById, getUserRoles, (_, options = defaultGroupSelectionOptions) => options], - (groupsById, { uiPermissions }, { staticOnly }) => { - // eslint-disable-next-line no-unused-vars - const { [UNGROUPED_GROUP.id]: ungrouped, ...groups } = groupsById; - if (staticOnly) { - return Object.keys(uiPermissions.groups).sort(); - } - return Object.keys( - Object.entries(groups).reduce((accu, [groupName, group]) => { - if (group.filter || uiPermissions.groups[ALL_DEVICES]) { - accu[groupName] = group; - } - return accu; - }, uiPermissions.groups) - ).sort(); - } -); - -const getReleaseMappingDefaults = () => ({}); -export const getReleasesList = createSelector([getReleasesById, getListedReleases, getReleaseMappingDefaults], listItemMapper); - -export const getReleaseTagsById = createSelector([getReleaseTags], releaseTags => releaseTags.reduce((accu, key) => ({ ...accu, [key]: key }), {})); -export const getHasReleases = createSelector( - [getReleaseListState, getReleasesById], - ({ searchTotal, total }, byId) => !!(Object.keys(byId).length || total || searchTotal) -); - -export const getSelectedRelease = createSelector([getReleasesById, getSelectedReleaseId], (byId, id) => byId[id] ?? {}); - -const relevantDeploymentStates = [DEPLOYMENT_STATES.pending, DEPLOYMENT_STATES.inprogress, DEPLOYMENT_STATES.finished]; -export const DEPLOYMENT_CUTOFF = 3; -export const getRecentDeployments = createSelector([getDeploymentsById, getDeploymentsByStatus], (deploymentsById, deploymentsByStatus) => - Object.entries(deploymentsByStatus).reduce( - (accu, [state, byStatus]) => { - if (!relevantDeploymentStates.includes(state) || !byStatus.deploymentIds.length) { - return accu; - } - accu[state] = byStatus.deploymentIds - .reduce((accu, id) => { - if (deploymentsById[id]) { - accu.push(deploymentsById[id]); - } - return accu; - }, []) - .slice(0, DEPLOYMENT_CUTOFF); - accu.total += byStatus.total; - return accu; - }, - { total: 0 } - ) -); diff --git a/frontend/src/js/store/actions.ts b/frontend/src/js/store/actions.ts new file mode 100644 index 00000000..4bd4e96d --- /dev/null +++ b/frontend/src/js/store/actions.ts @@ -0,0 +1,32 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { actions as appActions } from './appSlice'; +import { actions as deploymentActions } from './deploymentsSlice'; +import { actions as devicesActions } from './devicesSlice'; +import { actions as monitorActions } from './monitorSlice'; +import { actions as onboardingActions } from './onboardingSlice'; +import { actions as organizationActions } from './organizationSlice'; +import { actions as releaseActions } from './releasesSlice'; +import { actions as userActions } from './usersSlice'; + +export default { + ...appActions, + ...deploymentActions, + ...devicesActions, + ...monitorActions, + ...onboardingActions, + ...organizationActions, + ...releaseActions, + ...userActions +}; diff --git a/frontend/src/js/api/general-api.test.js b/frontend/src/js/store/api/general-api.test.ts similarity index 97% rename from frontend/src/js/api/general-api.test.js rename to frontend/src/js/store/api/general-api.test.ts index a1447a1c..80592be0 100644 --- a/frontend/src/js/api/general-api.test.js +++ b/frontend/src/js/store/api/general-api.test.ts @@ -19,7 +19,6 @@ describe('General API module', () => { it('should allow GET requests', done => { Api.get(testLocation) .then(res => { - console.log(res.config.headers.Authorization); expect(res.config.headers.Authorization).toMatch(/Bearer/); return res.config.method === 'get' ? done() : done('failed'); }) diff --git a/frontend/src/js/api/general-api.js b/frontend/src/js/store/api/general-api.ts similarity index 88% rename from frontend/src/js/api/general-api.js rename to frontend/src/js/store/api/general-api.ts index 141c504b..6d366871 100644 --- a/frontend/src/js/api/general-api.js +++ b/frontend/src/js/store/api/general-api.ts @@ -11,24 +11,11 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck import axios, { isCancel } from 'axios'; import { cleanUp, getToken } from '../auth'; -import { TIMEOUTS } from '../constants/appConstants'; - -export const headerNames = { - link: 'link', - location: 'location', - total: 'x-total-count' -}; - -export const apiRoot = '/api/management'; -export const apiUrl = { - v1: `${apiRoot}/v1`, - v2: `${apiRoot}/v2` -}; - -export const MAX_PAGE_SIZE = 500; +import { TIMEOUTS } from '../constants'; const unauthorizedRedirect = error => { if (!isCancel(error) && error.response?.status === 401 && getToken()) { diff --git a/frontend/src/js/api/types/AWSCredentials.ts b/frontend/src/js/store/api/types/AWSCredentials.ts similarity index 100% rename from frontend/src/js/api/types/AWSCredentials.ts rename to frontend/src/js/store/api/types/AWSCredentials.ts diff --git a/frontend/src/js/api/types/Actor.ts b/frontend/src/js/store/api/types/Actor.ts similarity index 100% rename from frontend/src/js/api/types/Actor.ts rename to frontend/src/js/store/api/types/Actor.ts diff --git a/frontend/src/js/api/types/Addon.ts b/frontend/src/js/store/api/types/Addon.ts similarity index 100% rename from frontend/src/js/api/types/Addon.ts rename to frontend/src/js/store/api/types/Addon.ts diff --git a/frontend/src/js/api/types/AddonTenantadm.ts b/frontend/src/js/store/api/types/AddonTenantadm.ts similarity index 100% rename from frontend/src/js/api/types/AddonTenantadm.ts rename to frontend/src/js/store/api/types/AddonTenantadm.ts diff --git a/frontend/src/js/api/types/Alert.ts b/frontend/src/js/store/api/types/Alert.ts similarity index 100% rename from frontend/src/js/api/types/Alert.ts rename to frontend/src/js/store/api/types/Alert.ts diff --git a/frontend/src/js/api/types/AlertDetails.ts b/frontend/src/js/store/api/types/AlertDetails.ts similarity index 100% rename from frontend/src/js/api/types/AlertDetails.ts rename to frontend/src/js/store/api/types/AlertDetails.ts diff --git a/frontend/src/js/api/types/AlertSubject.ts b/frontend/src/js/store/api/types/AlertSubject.ts similarity index 100% rename from frontend/src/js/api/types/AlertSubject.ts rename to frontend/src/js/store/api/types/AlertSubject.ts diff --git a/frontend/src/js/api/types/ApiBurst.ts b/frontend/src/js/store/api/types/ApiBurst.ts similarity index 100% rename from frontend/src/js/api/types/ApiBurst.ts rename to frontend/src/js/store/api/types/ApiBurst.ts diff --git a/frontend/src/js/api/types/ApiBurstTenantadm.ts b/frontend/src/js/store/api/types/ApiBurstTenantadm.ts similarity index 100% rename from frontend/src/js/api/types/ApiBurstTenantadm.ts rename to frontend/src/js/store/api/types/ApiBurstTenantadm.ts diff --git a/frontend/src/js/api/types/ApiLimits.ts b/frontend/src/js/store/api/types/ApiLimits.ts similarity index 100% rename from frontend/src/js/api/types/ApiLimits.ts rename to frontend/src/js/store/api/types/ApiLimits.ts diff --git a/frontend/src/js/api/types/ApiLimitsTenantadm.ts b/frontend/src/js/store/api/types/ApiLimitsTenantadm.ts similarity index 100% rename from frontend/src/js/api/types/ApiLimitsTenantadm.ts rename to frontend/src/js/store/api/types/ApiLimitsTenantadm.ts diff --git a/frontend/src/js/api/types/ApiQuota.ts b/frontend/src/js/store/api/types/ApiQuota.ts similarity index 100% rename from frontend/src/js/api/types/ApiQuota.ts rename to frontend/src/js/store/api/types/ApiQuota.ts diff --git a/frontend/src/js/api/types/ApiQuotaTenantadm.ts b/frontend/src/js/store/api/types/ApiQuotaTenantadm.ts similarity index 100% rename from frontend/src/js/api/types/ApiQuotaTenantadm.ts rename to frontend/src/js/store/api/types/ApiQuotaTenantadm.ts diff --git a/frontend/src/js/api/types/Artifact.ts b/frontend/src/js/store/api/types/Artifact.ts similarity index 100% rename from frontend/src/js/api/types/Artifact.ts rename to frontend/src/js/store/api/types/Artifact.ts diff --git a/frontend/src/js/api/types/ArtifactDeployments.ts b/frontend/src/js/store/api/types/ArtifactDeployments.ts similarity index 100% rename from frontend/src/js/api/types/ArtifactDeployments.ts rename to frontend/src/js/store/api/types/ArtifactDeployments.ts diff --git a/frontend/src/js/api/types/ArtifactInfo.ts b/frontend/src/js/store/api/types/ArtifactInfo.ts similarity index 100% rename from frontend/src/js/api/types/ArtifactInfo.ts rename to frontend/src/js/store/api/types/ArtifactInfo.ts diff --git a/frontend/src/js/api/types/ArtifactInfoDeployments.ts b/frontend/src/js/store/api/types/ArtifactInfoDeployments.ts similarity index 100% rename from frontend/src/js/api/types/ArtifactInfoDeployments.ts rename to frontend/src/js/store/api/types/ArtifactInfoDeployments.ts diff --git a/frontend/src/js/api/types/ArtifactLink.ts b/frontend/src/js/store/api/types/ArtifactLink.ts similarity index 100% rename from frontend/src/js/api/types/ArtifactLink.ts rename to frontend/src/js/store/api/types/ArtifactLink.ts diff --git a/frontend/src/js/api/types/ArtifactTypeInfo.ts b/frontend/src/js/store/api/types/ArtifactTypeInfo.ts similarity index 100% rename from frontend/src/js/api/types/ArtifactTypeInfo.ts rename to frontend/src/js/store/api/types/ArtifactTypeInfo.ts diff --git a/frontend/src/js/api/types/ArtifactTypeInfoDeployments.ts b/frontend/src/js/store/api/types/ArtifactTypeInfoDeployments.ts similarity index 100% rename from frontend/src/js/api/types/ArtifactTypeInfoDeployments.ts rename to frontend/src/js/store/api/types/ArtifactTypeInfoDeployments.ts diff --git a/frontend/src/js/api/types/ArtifactUpdate.ts b/frontend/src/js/store/api/types/ArtifactUpdate.ts similarity index 100% rename from frontend/src/js/api/types/ArtifactUpdate.ts rename to frontend/src/js/store/api/types/ArtifactUpdate.ts diff --git a/frontend/src/js/api/types/ArtifactUploadLink.ts b/frontend/src/js/store/api/types/ArtifactUploadLink.ts similarity index 100% rename from frontend/src/js/api/types/ArtifactUploadLink.ts rename to frontend/src/js/store/api/types/ArtifactUploadLink.ts diff --git a/frontend/src/js/api/types/Attribute.ts b/frontend/src/js/store/api/types/Attribute.ts similarity index 100% rename from frontend/src/js/api/types/Attribute.ts rename to frontend/src/js/store/api/types/Attribute.ts diff --git a/frontend/src/js/api/types/AttributeInventory.ts b/frontend/src/js/store/api/types/AttributeInventory.ts similarity index 100% rename from frontend/src/js/api/types/AttributeInventory.ts rename to frontend/src/js/store/api/types/AttributeInventory.ts diff --git a/frontend/src/js/api/types/AuditLog.ts b/frontend/src/js/store/api/types/AuditLog.ts similarity index 100% rename from frontend/src/js/api/types/AuditLog.ts rename to frontend/src/js/store/api/types/AuditLog.ts diff --git a/frontend/src/js/api/types/AuthSet.ts b/frontend/src/js/store/api/types/AuthSet.ts similarity index 100% rename from frontend/src/js/api/types/AuthSet.ts rename to frontend/src/js/store/api/types/AuthSet.ts diff --git a/frontend/src/js/api/types/AuthSetDeviceauth.ts b/frontend/src/js/store/api/types/AuthSetDeviceauth.ts similarity index 100% rename from frontend/src/js/api/types/AuthSetDeviceauth.ts rename to frontend/src/js/store/api/types/AuthSetDeviceauth.ts diff --git a/frontend/src/js/api/types/AutoAuthRequest.ts b/frontend/src/js/store/api/types/AutoAuthRequest.ts similarity index 100% rename from frontend/src/js/api/types/AutoAuthRequest.ts rename to frontend/src/js/store/api/types/AutoAuthRequest.ts diff --git a/frontend/src/js/api/types/AzureSharedAccessSecret.ts b/frontend/src/js/store/api/types/AzureSharedAccessSecret.ts similarity index 100% rename from frontend/src/js/api/types/AzureSharedAccessSecret.ts rename to frontend/src/js/store/api/types/AzureSharedAccessSecret.ts diff --git a/frontend/src/js/api/types/BillingInfo.ts b/frontend/src/js/store/api/types/BillingInfo.ts similarity index 100% rename from frontend/src/js/api/types/BillingInfo.ts rename to frontend/src/js/store/api/types/BillingInfo.ts diff --git a/frontend/src/js/api/types/BinaryDeltaConfiguration.ts b/frontend/src/js/store/api/types/BinaryDeltaConfiguration.ts similarity index 100% rename from frontend/src/js/api/types/BinaryDeltaConfiguration.ts rename to frontend/src/js/store/api/types/BinaryDeltaConfiguration.ts diff --git a/frontend/src/js/api/types/BinaryDeltaConfigurationDeployments.ts b/frontend/src/js/store/api/types/BinaryDeltaConfigurationDeployments.ts similarity index 100% rename from frontend/src/js/api/types/BinaryDeltaConfigurationDeployments.ts rename to frontend/src/js/store/api/types/BinaryDeltaConfigurationDeployments.ts diff --git a/frontend/src/js/api/types/BinaryDeltaLimits.ts b/frontend/src/js/store/api/types/BinaryDeltaLimits.ts similarity index 100% rename from frontend/src/js/api/types/BinaryDeltaLimits.ts rename to frontend/src/js/store/api/types/BinaryDeltaLimits.ts diff --git a/frontend/src/js/api/types/BinaryDeltaLimitsDeployments.ts b/frontend/src/js/store/api/types/BinaryDeltaLimitsDeployments.ts similarity index 100% rename from frontend/src/js/api/types/BinaryDeltaLimitsDeployments.ts rename to frontend/src/js/store/api/types/BinaryDeltaLimitsDeployments.ts diff --git a/frontend/src/js/api/types/BoundingBox.ts b/frontend/src/js/store/api/types/BoundingBox.ts similarity index 100% rename from frontend/src/js/api/types/BoundingBox.ts rename to frontend/src/js/store/api/types/BoundingBox.ts diff --git a/frontend/src/js/api/types/CancelRequest.ts b/frontend/src/js/store/api/types/CancelRequest.ts similarity index 100% rename from frontend/src/js/api/types/CancelRequest.ts rename to frontend/src/js/store/api/types/CancelRequest.ts diff --git a/frontend/src/js/api/types/CardData.ts b/frontend/src/js/store/api/types/CardData.ts similarity index 100% rename from frontend/src/js/api/types/CardData.ts rename to frontend/src/js/store/api/types/CardData.ts diff --git a/frontend/src/js/api/types/CardSetupData.ts b/frontend/src/js/store/api/types/CardSetupData.ts similarity index 100% rename from frontend/src/js/api/types/CardSetupData.ts rename to frontend/src/js/store/api/types/CardSetupData.ts diff --git a/frontend/src/js/api/types/CheckoutData.ts b/frontend/src/js/store/api/types/CheckoutData.ts similarity index 100% rename from frontend/src/js/api/types/CheckoutData.ts rename to frontend/src/js/store/api/types/CheckoutData.ts diff --git a/frontend/src/js/api/types/Configuration.ts b/frontend/src/js/store/api/types/Configuration.ts similarity index 100% rename from frontend/src/js/api/types/Configuration.ts rename to frontend/src/js/store/api/types/Configuration.ts diff --git a/frontend/src/js/api/types/Count.ts b/frontend/src/js/store/api/types/Count.ts similarity index 100% rename from frontend/src/js/api/types/Count.ts rename to frontend/src/js/store/api/types/Count.ts diff --git a/frontend/src/js/api/types/Credentials.ts b/frontend/src/js/store/api/types/Credentials.ts similarity index 100% rename from frontend/src/js/api/types/Credentials.ts rename to frontend/src/js/store/api/types/Credentials.ts diff --git a/frontend/src/js/api/types/DBusSubsystem.ts b/frontend/src/js/store/api/types/DBusSubsystem.ts similarity index 100% rename from frontend/src/js/api/types/DBusSubsystem.ts rename to frontend/src/js/store/api/types/DBusSubsystem.ts diff --git a/frontend/src/js/api/types/DeltaConfiguration.ts b/frontend/src/js/store/api/types/DeltaConfiguration.ts similarity index 100% rename from frontend/src/js/api/types/DeltaConfiguration.ts rename to frontend/src/js/store/api/types/DeltaConfiguration.ts diff --git a/frontend/src/js/api/types/DeltaConfigurationDeployments.ts b/frontend/src/js/store/api/types/DeltaConfigurationDeployments.ts similarity index 100% rename from frontend/src/js/api/types/DeltaConfigurationDeployments.ts rename to frontend/src/js/store/api/types/DeltaConfigurationDeployments.ts diff --git a/frontend/src/js/api/types/Deployment.ts b/frontend/src/js/store/api/types/Deployment.ts similarity index 100% rename from frontend/src/js/api/types/Deployment.ts rename to frontend/src/js/store/api/types/Deployment.ts diff --git a/frontend/src/js/api/types/DeploymentAggregation.ts b/frontend/src/js/store/api/types/DeploymentAggregation.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentAggregation.ts rename to frontend/src/js/store/api/types/DeploymentAggregation.ts diff --git a/frontend/src/js/api/types/DeploymentAggregationItem.ts b/frontend/src/js/store/api/types/DeploymentAggregationItem.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentAggregationItem.ts rename to frontend/src/js/store/api/types/DeploymentAggregationItem.ts diff --git a/frontend/src/js/api/types/DeploymentAggregationTerm.ts b/frontend/src/js/store/api/types/DeploymentAggregationTerm.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentAggregationTerm.ts rename to frontend/src/js/store/api/types/DeploymentAggregationTerm.ts diff --git a/frontend/src/js/api/types/DeploymentAggregationTerms.ts b/frontend/src/js/store/api/types/DeploymentAggregationTerms.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentAggregationTerms.ts rename to frontend/src/js/store/api/types/DeploymentAggregationTerms.ts diff --git a/frontend/src/js/api/types/DeploymentAttributeProjection.ts b/frontend/src/js/store/api/types/DeploymentAttributeProjection.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentAttributeProjection.ts rename to frontend/src/js/store/api/types/DeploymentAttributeProjection.ts diff --git a/frontend/src/js/api/types/DeploymentAuditlogs.ts b/frontend/src/js/store/api/types/DeploymentAuditlogs.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentAuditlogs.ts rename to frontend/src/js/store/api/types/DeploymentAuditlogs.ts diff --git a/frontend/src/js/api/types/DeploymentDeployments.ts b/frontend/src/js/store/api/types/DeploymentDeployments.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentDeployments.ts rename to frontend/src/js/store/api/types/DeploymentDeployments.ts diff --git a/frontend/src/js/api/types/DeploymentFilterTerm.ts b/frontend/src/js/store/api/types/DeploymentFilterTerm.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentFilterTerm.ts rename to frontend/src/js/store/api/types/DeploymentFilterTerm.ts diff --git a/frontend/src/js/api/types/DeploymentIdentifier.ts b/frontend/src/js/store/api/types/DeploymentIdentifier.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentIdentifier.ts rename to frontend/src/js/store/api/types/DeploymentIdentifier.ts diff --git a/frontend/src/js/api/types/DeploymentPhase.ts b/frontend/src/js/store/api/types/DeploymentPhase.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentPhase.ts rename to frontend/src/js/store/api/types/DeploymentPhase.ts diff --git a/frontend/src/js/api/types/DeploymentReporting.ts b/frontend/src/js/store/api/types/DeploymentReporting.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentReporting.ts rename to frontend/src/js/store/api/types/DeploymentReporting.ts diff --git a/frontend/src/js/api/types/DeploymentSearchTerms.ts b/frontend/src/js/store/api/types/DeploymentSearchTerms.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentSearchTerms.ts rename to frontend/src/js/store/api/types/DeploymentSearchTerms.ts diff --git a/frontend/src/js/api/types/DeploymentSortTerm.ts b/frontend/src/js/store/api/types/DeploymentSortTerm.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentSortTerm.ts rename to frontend/src/js/store/api/types/DeploymentSortTerm.ts diff --git a/frontend/src/js/api/types/DeploymentStatistics.ts b/frontend/src/js/store/api/types/DeploymentStatistics.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentStatistics.ts rename to frontend/src/js/store/api/types/DeploymentStatistics.ts diff --git a/frontend/src/js/api/types/DeploymentStatusStatistics.ts b/frontend/src/js/store/api/types/DeploymentStatusStatistics.ts similarity index 100% rename from frontend/src/js/api/types/DeploymentStatusStatistics.ts rename to frontend/src/js/store/api/types/DeploymentStatusStatistics.ts diff --git a/frontend/src/js/api/types/Device.ts b/frontend/src/js/store/api/types/Device.ts similarity index 100% rename from frontend/src/js/api/types/Device.ts rename to frontend/src/js/store/api/types/Device.ts diff --git a/frontend/src/js/api/types/DeviceAggregation.ts b/frontend/src/js/store/api/types/DeviceAggregation.ts similarity index 100% rename from frontend/src/js/api/types/DeviceAggregation.ts rename to frontend/src/js/store/api/types/DeviceAggregation.ts diff --git a/frontend/src/js/api/types/DeviceAggregationItem.ts b/frontend/src/js/store/api/types/DeviceAggregationItem.ts similarity index 100% rename from frontend/src/js/api/types/DeviceAggregationItem.ts rename to frontend/src/js/store/api/types/DeviceAggregationItem.ts diff --git a/frontend/src/js/api/types/DeviceAggregationTerm.ts b/frontend/src/js/store/api/types/DeviceAggregationTerm.ts similarity index 100% rename from frontend/src/js/api/types/DeviceAggregationTerm.ts rename to frontend/src/js/store/api/types/DeviceAggregationTerm.ts diff --git a/frontend/src/js/api/types/DeviceAggregationTerms.ts b/frontend/src/js/store/api/types/DeviceAggregationTerms.ts similarity index 100% rename from frontend/src/js/api/types/DeviceAggregationTerms.ts rename to frontend/src/js/store/api/types/DeviceAggregationTerms.ts diff --git a/frontend/src/js/api/types/DeviceAttribute.ts b/frontend/src/js/store/api/types/DeviceAttribute.ts similarity index 100% rename from frontend/src/js/api/types/DeviceAttribute.ts rename to frontend/src/js/store/api/types/DeviceAttribute.ts diff --git a/frontend/src/js/api/types/DeviceAttributeProjection.ts b/frontend/src/js/store/api/types/DeviceAttributeProjection.ts similarity index 100% rename from frontend/src/js/api/types/DeviceAttributeProjection.ts rename to frontend/src/js/store/api/types/DeviceAttributeProjection.ts diff --git a/frontend/src/js/api/types/DeviceAuditlogs.ts b/frontend/src/js/store/api/types/DeviceAuditlogs.ts similarity index 100% rename from frontend/src/js/api/types/DeviceAuditlogs.ts rename to frontend/src/js/store/api/types/DeviceAuditlogs.ts diff --git a/frontend/src/js/api/types/DeviceAuthEvent.ts b/frontend/src/js/store/api/types/DeviceAuthEvent.ts similarity index 100% rename from frontend/src/js/api/types/DeviceAuthEvent.ts rename to frontend/src/js/store/api/types/DeviceAuthEvent.ts diff --git a/frontend/src/js/api/types/DeviceConfiguration.ts b/frontend/src/js/store/api/types/DeviceConfiguration.ts similarity index 100% rename from frontend/src/js/api/types/DeviceConfiguration.ts rename to frontend/src/js/store/api/types/DeviceConfiguration.ts diff --git a/frontend/src/js/api/types/DeviceDeployment.ts b/frontend/src/js/store/api/types/DeviceDeployment.ts similarity index 100% rename from frontend/src/js/api/types/DeviceDeployment.ts rename to frontend/src/js/store/api/types/DeviceDeployment.ts diff --git a/frontend/src/js/api/types/DeviceDeployments.ts b/frontend/src/js/store/api/types/DeviceDeployments.ts similarity index 100% rename from frontend/src/js/api/types/DeviceDeployments.ts rename to frontend/src/js/store/api/types/DeviceDeployments.ts diff --git a/frontend/src/js/api/types/DeviceDeviceauth.ts b/frontend/src/js/store/api/types/DeviceDeviceauth.ts similarity index 100% rename from frontend/src/js/api/types/DeviceDeviceauth.ts rename to frontend/src/js/store/api/types/DeviceDeviceauth.ts diff --git a/frontend/src/js/api/types/DeviceFilterAttribute.ts b/frontend/src/js/store/api/types/DeviceFilterAttribute.ts similarity index 100% rename from frontend/src/js/api/types/DeviceFilterAttribute.ts rename to frontend/src/js/store/api/types/DeviceFilterAttribute.ts diff --git a/frontend/src/js/api/types/DeviceFilterTerm.ts b/frontend/src/js/store/api/types/DeviceFilterTerm.ts similarity index 100% rename from frontend/src/js/api/types/DeviceFilterTerm.ts rename to frontend/src/js/store/api/types/DeviceFilterTerm.ts diff --git a/frontend/src/js/api/types/DeviceInventory.ts b/frontend/src/js/store/api/types/DeviceInventory.ts similarity index 100% rename from frontend/src/js/api/types/DeviceInventory.ts rename to frontend/src/js/store/api/types/DeviceInventory.ts diff --git a/frontend/src/js/api/types/DeviceInventoryInventory.ts b/frontend/src/js/store/api/types/DeviceInventoryInventory.ts similarity index 100% rename from frontend/src/js/api/types/DeviceInventoryInventory.ts rename to frontend/src/js/store/api/types/DeviceInventoryInventory.ts diff --git a/frontend/src/js/api/types/DeviceReporting.ts b/frontend/src/js/store/api/types/DeviceReporting.ts similarity index 100% rename from frontend/src/js/api/types/DeviceReporting.ts rename to frontend/src/js/store/api/types/DeviceReporting.ts diff --git a/frontend/src/js/api/types/DeviceSearchTerms.ts b/frontend/src/js/store/api/types/DeviceSearchTerms.ts similarity index 100% rename from frontend/src/js/api/types/DeviceSearchTerms.ts rename to frontend/src/js/store/api/types/DeviceSearchTerms.ts diff --git a/frontend/src/js/api/types/DeviceSortTerm.ts b/frontend/src/js/store/api/types/DeviceSortTerm.ts similarity index 100% rename from frontend/src/js/api/types/DeviceSortTerm.ts rename to frontend/src/js/store/api/types/DeviceSortTerm.ts diff --git a/frontend/src/js/api/types/DeviceState.ts b/frontend/src/js/store/api/types/DeviceState.ts similarity index 100% rename from frontend/src/js/api/types/DeviceState.ts rename to frontend/src/js/store/api/types/DeviceState.ts diff --git a/frontend/src/js/api/types/DeviceStateDeviceconnect.ts b/frontend/src/js/store/api/types/DeviceStateDeviceconnect.ts similarity index 100% rename from frontend/src/js/api/types/DeviceStateDeviceconnect.ts rename to frontend/src/js/store/api/types/DeviceStateDeviceconnect.ts diff --git a/frontend/src/js/api/types/DeviceStateIot_manager.ts b/frontend/src/js/store/api/types/DeviceStateIot_manager.ts similarity index 100% rename from frontend/src/js/api/types/DeviceStateIot_manager.ts rename to frontend/src/js/store/api/types/DeviceStateIot_manager.ts diff --git a/frontend/src/js/api/types/DeviceStatus.ts b/frontend/src/js/store/api/types/DeviceStatus.ts similarity index 100% rename from frontend/src/js/api/types/DeviceStatus.ts rename to frontend/src/js/store/api/types/DeviceStatus.ts diff --git a/frontend/src/js/api/types/DeviceWithImage.ts b/frontend/src/js/store/api/types/DeviceWithImage.ts similarity index 100% rename from frontend/src/js/api/types/DeviceWithImage.ts rename to frontend/src/js/store/api/types/DeviceWithImage.ts diff --git a/frontend/src/js/api/types/DirectUploadMetadata.ts b/frontend/src/js/store/api/types/DirectUploadMetadata.ts similarity index 100% rename from frontend/src/js/api/types/DirectUploadMetadata.ts rename to frontend/src/js/store/api/types/DirectUploadMetadata.ts diff --git a/frontend/src/js/api/types/EmailVerificationCompletion.ts b/frontend/src/js/store/api/types/EmailVerificationCompletion.ts similarity index 100% rename from frontend/src/js/api/types/EmailVerificationCompletion.ts rename to frontend/src/js/store/api/types/EmailVerificationCompletion.ts diff --git a/frontend/src/js/api/types/EmailVerificationRequest.ts b/frontend/src/js/store/api/types/EmailVerificationRequest.ts similarity index 100% rename from frontend/src/js/api/types/EmailVerificationRequest.ts rename to frontend/src/js/store/api/types/EmailVerificationRequest.ts diff --git a/frontend/src/js/api/types/Error.ts b/frontend/src/js/store/api/types/Error.ts similarity index 100% rename from frontend/src/js/api/types/Error.ts rename to frontend/src/js/store/api/types/Error.ts diff --git a/frontend/src/js/api/types/ErrorAuditlogs.ts b/frontend/src/js/store/api/types/ErrorAuditlogs.ts similarity index 100% rename from frontend/src/js/api/types/ErrorAuditlogs.ts rename to frontend/src/js/store/api/types/ErrorAuditlogs.ts diff --git a/frontend/src/js/api/types/ErrorDeployments.ts b/frontend/src/js/store/api/types/ErrorDeployments.ts similarity index 100% rename from frontend/src/js/api/types/ErrorDeployments.ts rename to frontend/src/js/store/api/types/ErrorDeployments.ts diff --git a/frontend/src/js/api/types/ErrorDeviceauth.ts b/frontend/src/js/store/api/types/ErrorDeviceauth.ts similarity index 100% rename from frontend/src/js/api/types/ErrorDeviceauth.ts rename to frontend/src/js/store/api/types/ErrorDeviceauth.ts diff --git a/frontend/src/js/api/types/ErrorDeviceconfig.ts b/frontend/src/js/store/api/types/ErrorDeviceconfig.ts similarity index 100% rename from frontend/src/js/api/types/ErrorDeviceconfig.ts rename to frontend/src/js/store/api/types/ErrorDeviceconfig.ts diff --git a/frontend/src/js/api/types/ErrorDeviceconnect.ts b/frontend/src/js/store/api/types/ErrorDeviceconnect.ts similarity index 100% rename from frontend/src/js/api/types/ErrorDeviceconnect.ts rename to frontend/src/js/store/api/types/ErrorDeviceconnect.ts diff --git a/frontend/src/js/api/types/ErrorDevicemonitor.ts b/frontend/src/js/store/api/types/ErrorDevicemonitor.ts similarity index 100% rename from frontend/src/js/api/types/ErrorDevicemonitor.ts rename to frontend/src/js/store/api/types/ErrorDevicemonitor.ts diff --git a/frontend/src/js/api/types/ErrorExt.ts b/frontend/src/js/store/api/types/ErrorExt.ts similarity index 100% rename from frontend/src/js/api/types/ErrorExt.ts rename to frontend/src/js/store/api/types/ErrorExt.ts diff --git a/frontend/src/js/api/types/ErrorInventory.ts b/frontend/src/js/store/api/types/ErrorInventory.ts similarity index 100% rename from frontend/src/js/api/types/ErrorInventory.ts rename to frontend/src/js/store/api/types/ErrorInventory.ts diff --git a/frontend/src/js/api/types/ErrorIot_manager.ts b/frontend/src/js/store/api/types/ErrorIot_manager.ts similarity index 100% rename from frontend/src/js/api/types/ErrorIot_manager.ts rename to frontend/src/js/store/api/types/ErrorIot_manager.ts diff --git a/frontend/src/js/api/types/ErrorNotFound.ts b/frontend/src/js/store/api/types/ErrorNotFound.ts similarity index 100% rename from frontend/src/js/api/types/ErrorNotFound.ts rename to frontend/src/js/store/api/types/ErrorNotFound.ts diff --git a/frontend/src/js/api/types/ErrorReporting.ts b/frontend/src/js/store/api/types/ErrorReporting.ts similarity index 100% rename from frontend/src/js/api/types/ErrorReporting.ts rename to frontend/src/js/store/api/types/ErrorReporting.ts diff --git a/frontend/src/js/api/types/ErrorTenantadm.ts b/frontend/src/js/store/api/types/ErrorTenantadm.ts similarity index 100% rename from frontend/src/js/api/types/ErrorTenantadm.ts rename to frontend/src/js/store/api/types/ErrorTenantadm.ts diff --git a/frontend/src/js/api/types/ErrorUseradm.ts b/frontend/src/js/store/api/types/ErrorUseradm.ts similarity index 100% rename from frontend/src/js/api/types/ErrorUseradm.ts rename to frontend/src/js/store/api/types/ErrorUseradm.ts diff --git a/frontend/src/js/api/types/Event.ts b/frontend/src/js/store/api/types/Event.ts similarity index 100% rename from frontend/src/js/api/types/Event.ts rename to frontend/src/js/store/api/types/Event.ts diff --git a/frontend/src/js/api/types/ExternalIdentity.ts b/frontend/src/js/store/api/types/ExternalIdentity.ts similarity index 100% rename from frontend/src/js/api/types/ExternalIdentity.ts rename to frontend/src/js/store/api/types/ExternalIdentity.ts diff --git a/frontend/src/js/api/types/Features.ts b/frontend/src/js/store/api/types/Features.ts similarity index 100% rename from frontend/src/js/api/types/Features.ts rename to frontend/src/js/store/api/types/Features.ts diff --git a/frontend/src/js/api/types/FileUpload.ts b/frontend/src/js/store/api/types/FileUpload.ts similarity index 100% rename from frontend/src/js/api/types/FileUpload.ts rename to frontend/src/js/store/api/types/FileUpload.ts diff --git a/frontend/src/js/api/types/Filter.ts b/frontend/src/js/store/api/types/Filter.ts similarity index 100% rename from frontend/src/js/api/types/Filter.ts rename to frontend/src/js/store/api/types/Filter.ts diff --git a/frontend/src/js/api/types/FilterAttribute.ts b/frontend/src/js/store/api/types/FilterAttribute.ts similarity index 100% rename from frontend/src/js/api/types/FilterAttribute.ts rename to frontend/src/js/store/api/types/FilterAttribute.ts diff --git a/frontend/src/js/api/types/FilterDefinition.ts b/frontend/src/js/store/api/types/FilterDefinition.ts similarity index 100% rename from frontend/src/js/api/types/FilterDefinition.ts rename to frontend/src/js/store/api/types/FilterDefinition.ts diff --git a/frontend/src/js/api/types/FilterDeployments.ts b/frontend/src/js/store/api/types/FilterDeployments.ts similarity index 100% rename from frontend/src/js/api/types/FilterDeployments.ts rename to frontend/src/js/store/api/types/FilterDeployments.ts diff --git a/frontend/src/js/api/types/FilterInventory.ts b/frontend/src/js/store/api/types/FilterInventory.ts similarity index 100% rename from frontend/src/js/api/types/FilterInventory.ts rename to frontend/src/js/store/api/types/FilterInventory.ts diff --git a/frontend/src/js/api/types/FilterPredicate.ts b/frontend/src/js/store/api/types/FilterPredicate.ts similarity index 100% rename from frontend/src/js/api/types/FilterPredicate.ts rename to frontend/src/js/store/api/types/FilterPredicate.ts diff --git a/frontend/src/js/api/types/FilterPredicateDeployments.ts b/frontend/src/js/store/api/types/FilterPredicateDeployments.ts similarity index 100% rename from frontend/src/js/api/types/FilterPredicateDeployments.ts rename to frontend/src/js/store/api/types/FilterPredicateDeployments.ts diff --git a/frontend/src/js/api/types/FilterPredicateInventory.ts b/frontend/src/js/store/api/types/FilterPredicateInventory.ts similarity index 100% rename from frontend/src/js/api/types/FilterPredicateInventory.ts rename to frontend/src/js/store/api/types/FilterPredicateInventory.ts diff --git a/frontend/src/js/api/types/GeoBoundingBox.ts b/frontend/src/js/store/api/types/GeoBoundingBox.ts similarity index 100% rename from frontend/src/js/api/types/GeoBoundingBox.ts rename to frontend/src/js/store/api/types/GeoBoundingBox.ts diff --git a/frontend/src/js/api/types/GeoBoundingBoxFilter.ts b/frontend/src/js/store/api/types/GeoBoundingBoxFilter.ts similarity index 100% rename from frontend/src/js/api/types/GeoBoundingBoxFilter.ts rename to frontend/src/js/store/api/types/GeoBoundingBoxFilter.ts diff --git a/frontend/src/js/api/types/GeoDistance.ts b/frontend/src/js/store/api/types/GeoDistance.ts similarity index 100% rename from frontend/src/js/api/types/GeoDistance.ts rename to frontend/src/js/store/api/types/GeoDistance.ts diff --git a/frontend/src/js/api/types/GeoDistanceFilter.ts b/frontend/src/js/store/api/types/GeoDistanceFilter.ts similarity index 100% rename from frontend/src/js/api/types/GeoDistanceFilter.ts rename to frontend/src/js/store/api/types/GeoDistanceFilter.ts diff --git a/frontend/src/js/api/types/GeoPoint.ts b/frontend/src/js/store/api/types/GeoPoint.ts similarity index 100% rename from frontend/src/js/api/types/GeoPoint.ts rename to frontend/src/js/store/api/types/GeoPoint.ts diff --git a/frontend/src/js/api/types/Group.ts b/frontend/src/js/store/api/types/Group.ts similarity index 100% rename from frontend/src/js/api/types/Group.ts rename to frontend/src/js/store/api/types/Group.ts diff --git a/frontend/src/js/api/types/HTTP.ts b/frontend/src/js/store/api/types/HTTP.ts similarity index 100% rename from frontend/src/js/api/types/HTTP.ts rename to frontend/src/js/store/api/types/HTTP.ts diff --git a/frontend/src/js/api/types/IdentityData.ts b/frontend/src/js/store/api/types/IdentityData.ts similarity index 100% rename from frontend/src/js/api/types/IdentityData.ts rename to frontend/src/js/store/api/types/IdentityData.ts diff --git a/frontend/src/js/api/types/InputParameter.ts b/frontend/src/js/store/api/types/InputParameter.ts similarity index 100% rename from frontend/src/js/api/types/InputParameter.ts rename to frontend/src/js/store/api/types/InputParameter.ts diff --git a/frontend/src/js/api/types/Integration.ts b/frontend/src/js/store/api/types/Integration.ts similarity index 100% rename from frontend/src/js/api/types/Integration.ts rename to frontend/src/js/store/api/types/Integration.ts diff --git a/frontend/src/js/api/types/JobStatus.ts b/frontend/src/js/store/api/types/JobStatus.ts similarity index 100% rename from frontend/src/js/api/types/JobStatus.ts rename to frontend/src/js/store/api/types/JobStatus.ts diff --git a/frontend/src/js/api/types/Limit.ts b/frontend/src/js/store/api/types/Limit.ts similarity index 100% rename from frontend/src/js/api/types/Limit.ts rename to frontend/src/js/store/api/types/Limit.ts diff --git a/frontend/src/js/api/types/LimitDeployments.ts b/frontend/src/js/store/api/types/LimitDeployments.ts similarity index 100% rename from frontend/src/js/api/types/LimitDeployments.ts rename to frontend/src/js/store/api/types/LimitDeployments.ts diff --git a/frontend/src/js/api/types/LimitDeviceauth.ts b/frontend/src/js/store/api/types/LimitDeviceauth.ts similarity index 100% rename from frontend/src/js/api/types/LimitDeviceauth.ts rename to frontend/src/js/store/api/types/LimitDeviceauth.ts diff --git a/frontend/src/js/api/types/LimitUseradm.ts b/frontend/src/js/store/api/types/LimitUseradm.ts similarity index 100% rename from frontend/src/js/api/types/LimitUseradm.ts rename to frontend/src/js/store/api/types/LimitUseradm.ts diff --git a/frontend/src/js/api/types/Limits.ts b/frontend/src/js/store/api/types/Limits.ts similarity index 100% rename from frontend/src/js/api/types/Limits.ts rename to frontend/src/js/store/api/types/Limits.ts diff --git a/frontend/src/js/api/types/LineDescriptor.ts b/frontend/src/js/store/api/types/LineDescriptor.ts similarity index 100% rename from frontend/src/js/api/types/LineDescriptor.ts rename to frontend/src/js/store/api/types/LineDescriptor.ts diff --git a/frontend/src/js/api/types/LogSubsystem.ts b/frontend/src/js/store/api/types/LogSubsystem.ts similarity index 100% rename from frontend/src/js/api/types/LogSubsystem.ts rename to frontend/src/js/store/api/types/LogSubsystem.ts diff --git a/frontend/src/js/api/types/LoginOptions.ts b/frontend/src/js/store/api/types/LoginOptions.ts similarity index 100% rename from frontend/src/js/api/types/LoginOptions.ts rename to frontend/src/js/store/api/types/LoginOptions.ts diff --git a/frontend/src/js/api/types/ManagementAPIConfiguration.ts b/frontend/src/js/store/api/types/ManagementAPIConfiguration.ts similarity index 100% rename from frontend/src/js/api/types/ManagementAPIConfiguration.ts rename to frontend/src/js/store/api/types/ManagementAPIConfiguration.ts diff --git a/frontend/src/js/api/types/MenderTypes.ts b/frontend/src/js/store/api/types/MenderTypes.ts similarity index 100% rename from frontend/src/js/api/types/MenderTypes.ts rename to frontend/src/js/store/api/types/MenderTypes.ts diff --git a/frontend/src/js/api/types/MonitorConfiguration.ts b/frontend/src/js/store/api/types/MonitorConfiguration.ts similarity index 100% rename from frontend/src/js/api/types/MonitorConfiguration.ts rename to frontend/src/js/store/api/types/MonitorConfiguration.ts diff --git a/frontend/src/js/api/types/NewConfigurationDeployment.ts b/frontend/src/js/store/api/types/NewConfigurationDeployment.ts similarity index 100% rename from frontend/src/js/api/types/NewConfigurationDeployment.ts rename to frontend/src/js/store/api/types/NewConfigurationDeployment.ts diff --git a/frontend/src/js/api/types/NewConfigurationDeploymentResponse.ts b/frontend/src/js/store/api/types/NewConfigurationDeploymentResponse.ts similarity index 100% rename from frontend/src/js/api/types/NewConfigurationDeploymentResponse.ts rename to frontend/src/js/store/api/types/NewConfigurationDeploymentResponse.ts diff --git a/frontend/src/js/api/types/NewDeployment.ts b/frontend/src/js/store/api/types/NewDeployment.ts similarity index 100% rename from frontend/src/js/api/types/NewDeployment.ts rename to frontend/src/js/store/api/types/NewDeployment.ts diff --git a/frontend/src/js/api/types/NewDeploymentForGroup.ts b/frontend/src/js/store/api/types/NewDeploymentForGroup.ts similarity index 100% rename from frontend/src/js/api/types/NewDeploymentForGroup.ts rename to frontend/src/js/store/api/types/NewDeploymentForGroup.ts diff --git a/frontend/src/js/api/types/NewDeploymentPhase.ts b/frontend/src/js/store/api/types/NewDeploymentPhase.ts similarity index 100% rename from frontend/src/js/api/types/NewDeploymentPhase.ts rename to frontend/src/js/store/api/types/NewDeploymentPhase.ts diff --git a/frontend/src/js/api/types/NewDeploymentPhaseDeployments.ts b/frontend/src/js/store/api/types/NewDeploymentPhaseDeployments.ts similarity index 100% rename from frontend/src/js/api/types/NewDeploymentPhaseDeployments.ts rename to frontend/src/js/store/api/types/NewDeploymentPhaseDeployments.ts diff --git a/frontend/src/js/api/types/NewDeploymentV2.ts b/frontend/src/js/store/api/types/NewDeploymentV2.ts similarity index 100% rename from frontend/src/js/api/types/NewDeploymentV2.ts rename to frontend/src/js/store/api/types/NewDeploymentV2.ts diff --git a/frontend/src/js/api/types/NewTenant.ts b/frontend/src/js/store/api/types/NewTenant.ts similarity index 100% rename from frontend/src/js/api/types/NewTenant.ts rename to frontend/src/js/store/api/types/NewTenant.ts diff --git a/frontend/src/js/api/types/Object.ts b/frontend/src/js/store/api/types/Object.ts similarity index 100% rename from frontend/src/js/api/types/Object.ts rename to frontend/src/js/store/api/types/Object.ts diff --git a/frontend/src/js/api/types/OwnUserUpdate.ts b/frontend/src/js/store/api/types/OwnUserUpdate.ts similarity index 100% rename from frontend/src/js/api/types/OwnUserUpdate.ts rename to frontend/src/js/store/api/types/OwnUserUpdate.ts diff --git a/frontend/src/js/api/types/PasswordResetCompletion.ts b/frontend/src/js/store/api/types/PasswordResetCompletion.ts similarity index 100% rename from frontend/src/js/api/types/PasswordResetCompletion.ts rename to frontend/src/js/store/api/types/PasswordResetCompletion.ts diff --git a/frontend/src/js/api/types/PasswordResetRequest.ts b/frontend/src/js/store/api/types/PasswordResetRequest.ts similarity index 100% rename from frontend/src/js/api/types/PasswordResetRequest.ts rename to frontend/src/js/store/api/types/PasswordResetRequest.ts diff --git a/frontend/src/js/api/types/Permission.ts b/frontend/src/js/store/api/types/Permission.ts similarity index 100% rename from frontend/src/js/api/types/Permission.ts rename to frontend/src/js/store/api/types/Permission.ts diff --git a/frontend/src/js/api/types/PermissionObject.ts b/frontend/src/js/store/api/types/PermissionObject.ts similarity index 100% rename from frontend/src/js/api/types/PermissionObject.ts rename to frontend/src/js/store/api/types/PermissionObject.ts diff --git a/frontend/src/js/api/types/PermissionSet.ts b/frontend/src/js/store/api/types/PermissionSet.ts similarity index 100% rename from frontend/src/js/api/types/PermissionSet.ts rename to frontend/src/js/store/api/types/PermissionSet.ts diff --git a/frontend/src/js/api/types/PermissionSetWithScope.ts b/frontend/src/js/store/api/types/PermissionSetWithScope.ts similarity index 100% rename from frontend/src/js/api/types/PermissionSetWithScope.ts rename to frontend/src/js/store/api/types/PermissionSetWithScope.ts diff --git a/frontend/src/js/api/types/PersonalAccessToken.ts b/frontend/src/js/store/api/types/PersonalAccessToken.ts similarity index 100% rename from frontend/src/js/api/types/PersonalAccessToken.ts rename to frontend/src/js/store/api/types/PersonalAccessToken.ts diff --git a/frontend/src/js/api/types/PersonalAccessTokenRequest.ts b/frontend/src/js/store/api/types/PersonalAccessTokenRequest.ts similarity index 100% rename from frontend/src/js/api/types/PersonalAccessTokenRequest.ts rename to frontend/src/js/store/api/types/PersonalAccessTokenRequest.ts diff --git a/frontend/src/js/api/types/Plan.ts b/frontend/src/js/store/api/types/Plan.ts similarity index 100% rename from frontend/src/js/api/types/Plan.ts rename to frontend/src/js/store/api/types/Plan.ts diff --git a/frontend/src/js/api/types/PlanBindingDetails.ts b/frontend/src/js/store/api/types/PlanBindingDetails.ts similarity index 100% rename from frontend/src/js/api/types/PlanBindingDetails.ts rename to frontend/src/js/store/api/types/PlanBindingDetails.ts diff --git a/frontend/src/js/api/types/PlanChangeRequest.ts b/frontend/src/js/store/api/types/PlanChangeRequest.ts similarity index 100% rename from frontend/src/js/api/types/PlanChangeRequest.ts rename to frontend/src/js/store/api/types/PlanChangeRequest.ts diff --git a/frontend/src/js/api/types/PlanLimits.ts b/frontend/src/js/store/api/types/PlanLimits.ts similarity index 100% rename from frontend/src/js/api/types/PlanLimits.ts rename to frontend/src/js/store/api/types/PlanLimits.ts diff --git a/frontend/src/js/api/types/PreAuthSet.ts b/frontend/src/js/store/api/types/PreAuthSet.ts similarity index 100% rename from frontend/src/js/api/types/PreAuthSet.ts rename to frontend/src/js/store/api/types/PreAuthSet.ts diff --git a/frontend/src/js/api/types/Release.ts b/frontend/src/js/store/api/types/Release.ts similarity index 100% rename from frontend/src/js/api/types/Release.ts rename to frontend/src/js/store/api/types/Release.ts diff --git a/frontend/src/js/api/types/ReleaseDeployments.ts b/frontend/src/js/store/api/types/ReleaseDeployments.ts similarity index 100% rename from frontend/src/js/api/types/ReleaseDeployments.ts rename to frontend/src/js/store/api/types/ReleaseDeployments.ts diff --git a/frontend/src/js/api/types/ReleaseUpdate.ts b/frontend/src/js/store/api/types/ReleaseUpdate.ts similarity index 100% rename from frontend/src/js/api/types/ReleaseUpdate.ts rename to frontend/src/js/store/api/types/ReleaseUpdate.ts diff --git a/frontend/src/js/api/types/Releases.ts b/frontend/src/js/store/api/types/Releases.ts similarity index 100% rename from frontend/src/js/api/types/Releases.ts rename to frontend/src/js/store/api/types/Releases.ts diff --git a/frontend/src/js/api/types/ReleasesDeleteError.ts b/frontend/src/js/store/api/types/ReleasesDeleteError.ts similarity index 100% rename from frontend/src/js/api/types/ReleasesDeleteError.ts rename to frontend/src/js/store/api/types/ReleasesDeleteError.ts diff --git a/frontend/src/js/api/types/ReleasesDeployments.ts b/frontend/src/js/store/api/types/ReleasesDeployments.ts similarity index 100% rename from frontend/src/js/api/types/ReleasesDeployments.ts rename to frontend/src/js/store/api/types/ReleasesDeployments.ts diff --git a/frontend/src/js/api/types/Role.ts b/frontend/src/js/store/api/types/Role.ts similarity index 100% rename from frontend/src/js/api/types/Role.ts rename to frontend/src/js/store/api/types/Role.ts diff --git a/frontend/src/js/api/types/RolePermission.ts b/frontend/src/js/store/api/types/RolePermission.ts similarity index 100% rename from frontend/src/js/api/types/RolePermission.ts rename to frontend/src/js/store/api/types/RolePermission.ts diff --git a/frontend/src/js/api/types/RolePermissionObject.ts b/frontend/src/js/store/api/types/RolePermissionObject.ts similarity index 100% rename from frontend/src/js/api/types/RolePermissionObject.ts rename to frontend/src/js/store/api/types/RolePermissionObject.ts diff --git a/frontend/src/js/api/types/RoleUseradm.ts b/frontend/src/js/store/api/types/RoleUseradm.ts similarity index 100% rename from frontend/src/js/api/types/RoleUseradm.ts rename to frontend/src/js/store/api/types/RoleUseradm.ts diff --git a/frontend/src/js/api/types/SAMLMetadata.ts b/frontend/src/js/store/api/types/SAMLMetadata.ts similarity index 100% rename from frontend/src/js/api/types/SAMLMetadata.ts rename to frontend/src/js/store/api/types/SAMLMetadata.ts diff --git a/frontend/src/js/api/types/Scope.ts b/frontend/src/js/store/api/types/Scope.ts similarity index 100% rename from frontend/src/js/api/types/Scope.ts rename to frontend/src/js/store/api/types/Scope.ts diff --git a/frontend/src/js/api/types/SelectAttribute.ts b/frontend/src/js/store/api/types/SelectAttribute.ts similarity index 100% rename from frontend/src/js/api/types/SelectAttribute.ts rename to frontend/src/js/store/api/types/SelectAttribute.ts diff --git a/frontend/src/js/api/types/ServiceSubsystem.ts b/frontend/src/js/store/api/types/ServiceSubsystem.ts similarity index 100% rename from frontend/src/js/api/types/ServiceSubsystem.ts rename to frontend/src/js/store/api/types/ServiceSubsystem.ts diff --git a/frontend/src/js/api/types/Settings.ts b/frontend/src/js/store/api/types/Settings.ts similarity index 100% rename from frontend/src/js/api/types/Settings.ts rename to frontend/src/js/store/api/types/Settings.ts diff --git a/frontend/src/js/api/types/SortCriteria.ts b/frontend/src/js/store/api/types/SortCriteria.ts similarity index 100% rename from frontend/src/js/api/types/SortCriteria.ts rename to frontend/src/js/store/api/types/SortCriteria.ts diff --git a/frontend/src/js/api/types/SsoObject.ts b/frontend/src/js/store/api/types/SsoObject.ts similarity index 100% rename from frontend/src/js/api/types/SsoObject.ts rename to frontend/src/js/store/api/types/SsoObject.ts diff --git a/frontend/src/js/api/types/Status.ts b/frontend/src/js/store/api/types/Status.ts similarity index 100% rename from frontend/src/js/api/types/Status.ts rename to frontend/src/js/store/api/types/Status.ts diff --git a/frontend/src/js/api/types/StatusDeviceauth.ts b/frontend/src/js/store/api/types/StatusDeviceauth.ts similarity index 100% rename from frontend/src/js/api/types/StatusDeviceauth.ts rename to frontend/src/js/store/api/types/StatusDeviceauth.ts diff --git a/frontend/src/js/api/types/StatusTenantadm.ts b/frontend/src/js/store/api/types/StatusTenantadm.ts similarity index 100% rename from frontend/src/js/api/types/StatusTenantadm.ts rename to frontend/src/js/store/api/types/StatusTenantadm.ts diff --git a/frontend/src/js/api/types/StorageLimit.ts b/frontend/src/js/store/api/types/StorageLimit.ts similarity index 100% rename from frontend/src/js/api/types/StorageLimit.ts rename to frontend/src/js/store/api/types/StorageLimit.ts diff --git a/frontend/src/js/api/types/SubscriptionData.ts b/frontend/src/js/store/api/types/SubscriptionData.ts similarity index 100% rename from frontend/src/js/api/types/SubscriptionData.ts rename to frontend/src/js/store/api/types/SubscriptionData.ts diff --git a/frontend/src/js/api/types/SupportRequest.ts b/frontend/src/js/store/api/types/SupportRequest.ts similarity index 100% rename from frontend/src/js/api/types/SupportRequest.ts rename to frontend/src/js/store/api/types/SupportRequest.ts diff --git a/frontend/src/js/api/types/Tag.ts b/frontend/src/js/store/api/types/Tag.ts similarity index 100% rename from frontend/src/js/api/types/Tag.ts rename to frontend/src/js/store/api/types/Tag.ts diff --git a/frontend/src/js/api/types/Tags.ts b/frontend/src/js/store/api/types/Tags.ts similarity index 100% rename from frontend/src/js/api/types/Tags.ts rename to frontend/src/js/store/api/types/Tags.ts diff --git a/frontend/src/js/api/types/TaskResult.ts b/frontend/src/js/store/api/types/TaskResult.ts similarity index 100% rename from frontend/src/js/api/types/TaskResult.ts rename to frontend/src/js/store/api/types/TaskResult.ts diff --git a/frontend/src/js/api/types/TaskResultCLI.ts b/frontend/src/js/store/api/types/TaskResultCLI.ts similarity index 100% rename from frontend/src/js/api/types/TaskResultCLI.ts rename to frontend/src/js/store/api/types/TaskResultCLI.ts diff --git a/frontend/src/js/api/types/TaskResultHTTPRequest.ts b/frontend/src/js/store/api/types/TaskResultHTTPRequest.ts similarity index 100% rename from frontend/src/js/api/types/TaskResultHTTPRequest.ts rename to frontend/src/js/store/api/types/TaskResultHTTPRequest.ts diff --git a/frontend/src/js/api/types/TaskResultHTTPResponse.ts b/frontend/src/js/store/api/types/TaskResultHTTPResponse.ts similarity index 100% rename from frontend/src/js/api/types/TaskResultHTTPResponse.ts rename to frontend/src/js/store/api/types/TaskResultHTTPResponse.ts diff --git a/frontend/src/js/api/types/Tenant.ts b/frontend/src/js/store/api/types/Tenant.ts similarity index 100% rename from frontend/src/js/api/types/Tenant.ts rename to frontend/src/js/store/api/types/Tenant.ts diff --git a/frontend/src/js/api/types/TenantApiLimits.ts b/frontend/src/js/store/api/types/TenantApiLimits.ts similarity index 100% rename from frontend/src/js/api/types/TenantApiLimits.ts rename to frontend/src/js/store/api/types/TenantApiLimits.ts diff --git a/frontend/src/js/api/types/TenantApiLimitsTenantadm.ts b/frontend/src/js/store/api/types/TenantApiLimitsTenantadm.ts similarity index 100% rename from frontend/src/js/api/types/TenantApiLimitsTenantadm.ts rename to frontend/src/js/store/api/types/TenantApiLimitsTenantadm.ts diff --git a/frontend/src/js/api/types/TenantIdName.ts b/frontend/src/js/store/api/types/TenantIdName.ts similarity index 100% rename from frontend/src/js/api/types/TenantIdName.ts rename to frontend/src/js/store/api/types/TenantIdName.ts diff --git a/frontend/src/js/api/types/TenantIdsData.ts b/frontend/src/js/store/api/types/TenantIdsData.ts similarity index 100% rename from frontend/src/js/api/types/TenantIdsData.ts rename to frontend/src/js/store/api/types/TenantIdsData.ts diff --git a/frontend/src/js/api/types/TenantInfo.ts b/frontend/src/js/store/api/types/TenantInfo.ts similarity index 100% rename from frontend/src/js/api/types/TenantInfo.ts rename to frontend/src/js/store/api/types/TenantInfo.ts diff --git a/frontend/src/js/api/types/TenantShortInfo.ts b/frontend/src/js/store/api/types/TenantShortInfo.ts similarity index 100% rename from frontend/src/js/api/types/TenantShortInfo.ts rename to frontend/src/js/store/api/types/TenantShortInfo.ts diff --git a/frontend/src/js/api/types/TenantTenantadm.ts b/frontend/src/js/store/api/types/TenantTenantadm.ts similarity index 100% rename from frontend/src/js/api/types/TenantTenantadm.ts rename to frontend/src/js/store/api/types/TenantTenantadm.ts diff --git a/frontend/src/js/api/types/TenantsIdName.ts b/frontend/src/js/store/api/types/TenantsIdName.ts similarity index 100% rename from frontend/src/js/api/types/TenantsIdName.ts rename to frontend/src/js/store/api/types/TenantsIdName.ts diff --git a/frontend/src/js/api/types/Update.ts b/frontend/src/js/store/api/types/Update.ts similarity index 100% rename from frontend/src/js/api/types/Update.ts rename to frontend/src/js/store/api/types/Update.ts diff --git a/frontend/src/js/api/types/UpdateDeployments.ts b/frontend/src/js/store/api/types/UpdateDeployments.ts similarity index 100% rename from frontend/src/js/api/types/UpdateDeployments.ts rename to frontend/src/js/store/api/types/UpdateDeployments.ts diff --git a/frontend/src/js/api/types/UpdateFile.ts b/frontend/src/js/store/api/types/UpdateFile.ts similarity index 100% rename from frontend/src/js/api/types/UpdateFile.ts rename to frontend/src/js/store/api/types/UpdateFile.ts diff --git a/frontend/src/js/api/types/UpdateFileDeployments.ts b/frontend/src/js/store/api/types/UpdateFileDeployments.ts similarity index 100% rename from frontend/src/js/api/types/UpdateFileDeployments.ts rename to frontend/src/js/store/api/types/UpdateFileDeployments.ts diff --git a/frontend/src/js/api/types/UpdateTypes.ts b/frontend/src/js/store/api/types/UpdateTypes.ts similarity index 100% rename from frontend/src/js/api/types/UpdateTypes.ts rename to frontend/src/js/store/api/types/UpdateTypes.ts diff --git a/frontend/src/js/api/types/UpgradeCompleteRequest.ts b/frontend/src/js/store/api/types/UpgradeCompleteRequest.ts similarity index 100% rename from frontend/src/js/api/types/UpgradeCompleteRequest.ts rename to frontend/src/js/store/api/types/UpgradeCompleteRequest.ts diff --git a/frontend/src/js/api/types/User.ts b/frontend/src/js/store/api/types/User.ts similarity index 100% rename from frontend/src/js/api/types/User.ts rename to frontend/src/js/store/api/types/User.ts diff --git a/frontend/src/js/api/types/UserAuditlogs.ts b/frontend/src/js/store/api/types/UserAuditlogs.ts similarity index 100% rename from frontend/src/js/api/types/UserAuditlogs.ts rename to frontend/src/js/store/api/types/UserAuditlogs.ts diff --git a/frontend/src/js/api/types/UserNew.ts b/frontend/src/js/store/api/types/UserNew.ts similarity index 100% rename from frontend/src/js/api/types/UserNew.ts rename to frontend/src/js/store/api/types/UserNew.ts diff --git a/frontend/src/js/api/types/UserUpdate.ts b/frontend/src/js/store/api/types/UserUpdate.ts similarity index 100% rename from frontend/src/js/api/types/UserUpdate.ts rename to frontend/src/js/store/api/types/UserUpdate.ts diff --git a/frontend/src/js/api/types/UserUseradm.ts b/frontend/src/js/store/api/types/UserUseradm.ts similarity index 100% rename from frontend/src/js/api/types/UserUseradm.ts rename to frontend/src/js/store/api/types/UserUseradm.ts diff --git a/frontend/src/js/api/types/UserWithTenantInfo.ts b/frontend/src/js/store/api/types/UserWithTenantInfo.ts similarity index 100% rename from frontend/src/js/api/types/UserWithTenantInfo.ts rename to frontend/src/js/store/api/types/UserWithTenantInfo.ts diff --git a/frontend/src/js/api/types/XDeltaArgs.ts b/frontend/src/js/store/api/types/XDeltaArgs.ts similarity index 100% rename from frontend/src/js/api/types/XDeltaArgs.ts rename to frontend/src/js/store/api/types/XDeltaArgs.ts diff --git a/frontend/src/js/api/types/XDeltaArgsDeployments.ts b/frontend/src/js/store/api/types/XDeltaArgsDeployments.ts similarity index 100% rename from frontend/src/js/api/types/XDeltaArgsDeployments.ts rename to frontend/src/js/store/api/types/XDeltaArgsDeployments.ts diff --git a/frontend/src/js/api/types/XDeltaArgsLimits.ts b/frontend/src/js/store/api/types/XDeltaArgsLimits.ts similarity index 100% rename from frontend/src/js/api/types/XDeltaArgsLimits.ts rename to frontend/src/js/store/api/types/XDeltaArgsLimits.ts diff --git a/frontend/src/js/api/types/XDeltaArgsLimitsDeployments.ts b/frontend/src/js/store/api/types/XDeltaArgsLimitsDeployments.ts similarity index 100% rename from frontend/src/js/api/types/XDeltaArgsLimitsDeployments.ts rename to frontend/src/js/store/api/types/XDeltaArgsLimitsDeployments.ts diff --git a/frontend/src/js/api/users-api.js b/frontend/src/js/store/api/users-api.ts similarity index 98% rename from frontend/src/js/api/users-api.js rename to frontend/src/js/store/api/users-api.ts index ba309401..72b89874 100644 --- a/frontend/src/js/api/users-api.js +++ b/frontend/src/js/store/api/users-api.ts @@ -11,6 +11,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck import axios from 'axios'; import { getToken } from '../auth'; diff --git a/frontend/src/js/constants/appConstants.js b/frontend/src/js/store/appSlice/constants.ts similarity index 85% rename from frontend/src/js/constants/appConstants.js rename to frontend/src/js/store/appSlice/constants.ts index 0a620edd..a92da99b 100644 --- a/frontend/src/js/constants/appConstants.js +++ b/frontend/src/js/store/appSlice/constants.ts @@ -13,48 +13,36 @@ // limitations under the License. import { BarChart as BarChartIcon, PieChartOutline as PieChartIcon } from '@mui/icons-material'; -import FlagEU from '../../assets/img/flag-eu.svg'; -import FlagUS from '../../assets/img/flag-us.svg'; +import FlagEU from '../../../assets/img/flag-eu.svg'; +import FlagUS from '../../../assets/img/flag-us.svg'; const startingDeviceCount = { os: 'for first 50 devices', professional: 'for first 250 devices' }; -const oneSecond = 1000; +export const apiRoot = '/api/management'; +export const apiUrl = { + v1: `${apiRoot}/v1`, + v2: `${apiRoot}/v2` +}; + +export const headerNames = { + link: 'link', + location: 'location', + total: 'x-total-count' +}; export const chartTypes = { bar: { key: 'bar', Icon: BarChartIcon }, pie: { key: 'pie', Icon: PieChartIcon } }; export const emptyChartSelection = { software: '', group: '', chartType: chartTypes.bar.key, attribute: 'artifact_name' }; +export const defaultReportType = 'distribution'; +export const defaultReports = [{ ...emptyChartSelection, group: null, attribute: 'artifact_name', type: defaultReportType }]; -export const RECEIVED_HOSTED_LINKS = 'RECEIVED_HOSTED_LINKS'; -export const SET_ANNOUNCEMENT = 'SET_ANNOUNCEMENT'; -export const SET_ENVIRONMENT_DATA = 'SET_ENVIRONMENT_DATA'; -export const SET_FEATURES = 'SET_FEATURES'; -export const SET_FIRST_LOGIN_AFTER_SIGNUP = 'SET_FIRST_LOGIN_AFTER_SIGNUP'; -export const SET_SEARCH_STATE = 'SET_SEARCH_STATE'; -export const SET_SNACKBAR = 'SET_SNACKBAR'; -export const SET_VERSION_INFORMATION = 'SET_VERSION_INFORMATION'; -export const SET_OFFLINE_THRESHOLD = 'SET_OFFLINE_THRESHOLD'; -export const UPLOAD_PROGRESS = 'UPLOAD_PROGRESS'; -export const SORTING_OPTIONS = { - asc: 'asc', - desc: 'desc' -}; export const BEGINNING_OF_TIME = '2016-01-01T00:00:00.000Z'; -export const TIMEOUTS = { - debounceDefault: 700, - debounceShort: 300, - halfASecond: 0.5 * oneSecond, - oneSecond, - twoSeconds: 2 * oneSecond, - threeSeconds: 3 * oneSecond, - fiveSeconds: 5 * oneSecond, - refreshDefault: 10 * oneSecond, - refreshLong: 60 * oneSecond -}; + export const locations = { eu: { key: 'eu', title: 'EU', location: 'eu.hosted.mender.io', icon: FlagEU }, us: { key: 'us', title: 'US', location: 'hosted.mender.io', icon: FlagUS } diff --git a/frontend/src/js/store/appSlice/index.ts b/frontend/src/js/store/appSlice/index.ts new file mode 100644 index 00000000..53c97643 --- /dev/null +++ b/frontend/src/js/store/appSlice/index.ts @@ -0,0 +1,157 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { SORTING_OPTIONS } from '@northern.tech/store/constants'; +import { createSlice } from '@reduxjs/toolkit'; + +export const sliceName = 'app'; + +const getYesterday = () => { + const today = new Date(); + today.setDate(today.getDate() - 1); + return today.toISOString(); +}; + +export const initialState = { + cancelSource: undefined, + demoArtifactLink: 'https://dgsbl4vditpls.cloudfront.net/mender-demo-artifact.mender', + hostAddress: null, + snackbar: { + open: false, + message: '', + maxWidth: 900, + autoHideDuration: undefined, + action: undefined, + children: undefined, + onClick: undefined, + onClose: undefined + }, + // return boolean rather than organization details + features: { + hasAuditlogs: false, + hasDeltaProgress: false, + hasMultitenancy: false, + hasDeviceConfig: false, + hasDeviceConnect: false, + hasMonitor: false, + hasReporting: false, + isDemoMode: false, + isHosted: false, + isEnterprise: false + }, + firstLoginAfterSignup: false, + hostedAnnouncement: '', + docsVersion: '', + recaptchaSiteKey: '', + searchState: { + deviceIds: [], + searchTerm: '', + searchTotal: 0, + sort: { + direction: SORTING_OPTIONS.desc + // key: null, + // scope: null + } + }, + stripeAPIKey: '', + trackerCode: '', + uploadsById: { + // id: { uploading: false, uploadProgress: 0, cancelSource: undefined } + }, + newThreshold: getYesterday(), + offlineThreshold: getYesterday(), + versionInformation: { + Integration: '', + 'Mender-Client': '', + 'Mender-Artifact': '', + 'Meta-Mender': '', + Deployments: '', + Deviceauth: '', + Inventory: '', + GUI: 'latest' + }, + yesterday: undefined +}; + +export const appSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + setFeatures: (state, action) => { + state.features = { + ...state.features, + ...action.payload + }; + }, + setSnackbar: (state, { payload }) => { + let { message, autoHideDuration, action, children, onClick, onClose } = payload; + if (typeof payload === 'string' || payload instanceof String) { + message = payload; + } + state.snackbar = { + open: message ? true : false, + message, + maxWidth: 900, + autoHideDuration, + action, + children, + onClick, + onClose + }; + }, + setFirstLoginAfterSignup: (state, action) => { + state.firstLoginAfterSignup = action.payload; + }, + setAnnouncement: (state, action) => { + state.hostedAnnouncement = action.payload; + }, + setSearchState: (state, action) => { + state.searchState = { + ...state.searchState, + ...action.payload + }; + }, + setOfflineThreshold: (state, action) => { + state.offlineThreshold = action.payload; + }, + initUpload: (state, action) => { + const { id, upload } = action.payload; + state.uploadsById[id] = upload; + }, + uploadProgress: (state, action) => { + const { id, progress } = action.payload; + state.uploadsById[id] = { + ...state.uploadsById[id], + progress + }; + }, + cleanUpUpload: (state, action) => { + // eslint-disable-next-line no-unused-vars + const { [action.payload]: current, ...remainder } = state.uploadsById; + state.uploadsById = remainder; + }, + setVersionInformation: (state, action) => { + state.versionInformation = { + ...state.versionInformation, + ...action.payload + }; + }, + setEnvironmentData: (state, action) => { + return { ...state, ...action.payload }; + } + } +}); + +export const actions = appSlice.actions; +export default appSlice.reducer; diff --git a/frontend/src/js/store/appSlice/reducer.test.ts b/frontend/src/js/store/appSlice/reducer.test.ts new file mode 100644 index 00000000..09bc860e --- /dev/null +++ b/frontend/src/js/store/appSlice/reducer.test.ts @@ -0,0 +1,97 @@ +// Copyright 2020 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { SORTING_OPTIONS } from '@northern.tech/store/commonConstants'; + +import reducer, { actions, initialState } from '.'; + +const snackbarMessage = 'Run the tests'; +const initialSearchState = { + deviceIds: [], + searchTerm: '', + searchTotal: 0, + sort: { direction: SORTING_OPTIONS.desc } +}; + +describe('app reducer', () => { + it('should return the initial state', async () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + it('should handle SET_SNACKBAR', async () => { + expect(reducer(undefined, { type: actions.setSnackbar, payload: { open: true, message: snackbarMessage } }).snackbar).toEqual({ + open: true, + maxWidth: 900, + message: snackbarMessage + }); + + expect(reducer(initialState, { type: actions.setSnackbar, payload: { open: true, message: snackbarMessage } }).snackbar).toEqual({ + open: true, + maxWidth: 900, + message: snackbarMessage + }); + }); + + it('should handle SET_FIRST_LOGIN_AFTER_SIGNUP', async () => { + expect(reducer(undefined, { type: actions.setFirstLoginAfterSignup, payload: true }).firstLoginAfterSignup).toEqual(true); + + expect(reducer(initialState, { type: actions.setFirstLoginAfterSignup, payload: false }).firstLoginAfterSignup).toEqual(false); + }); + it('should handle SET_ANNOUNCEMENT', async () => { + expect(reducer(undefined, { type: actions.setAnnouncement, payload: 'something' }).hostedAnnouncement).toEqual('something'); + expect(reducer(initialState, { type: actions.setAnnouncement, payload: undefined }).hostedAnnouncement).toEqual(undefined); + }); + it('should handle SET_SEARCH_STATE', async () => { + expect(reducer(undefined, { type: actions.setSearchState, payload: { aWhole: 'newState' } }).searchState).toEqual({ + ...initialSearchState, + aWhole: 'newState' + }); + expect(reducer(initialState, { type: actions.setSearchState, payload: undefined }).searchState).toEqual({ ...initialSearchState }); + }); + it('should handle SET_OFFLINE_THRESHOLD', async () => { + expect(reducer(undefined, { type: actions.setOfflineThreshold, payload: 'something' }).offlineThreshold).toEqual('something'); + expect(reducer(initialState, { type: actions.setOfflineThreshold, payload: undefined }).offlineThreshold).toEqual(undefined); + }); + + const versionInformation = { + Deployments: '', + Deviceauth: '', + GUI: 'latest', + Integration: '', + Inventory: '', + 'Mender-Artifact': '', + 'Mender-Client': '', + 'Meta-Mender': '' + }; + it('should handle SET_VERSION_INFORMATION', async () => { + expect(reducer(undefined, { type: actions.setVersionInformation, payload: { something: 'something' } }).versionInformation).toEqual({ + ...versionInformation, + something: 'something' + }); + expect(reducer(initialState, { type: actions.setVersionInformation, payload: undefined }).versionInformation).toEqual(versionInformation); + expect(reducer(undefined, { type: actions.setVersionInformation, payload: { docsVersion: 'something' } }).versionInformation.docsVersion).toEqual( + 'something' + ); + expect(reducer(initialState, { type: actions.setVersionInformation, payload: { docsVersion: undefined } }).versionInformation.docsVersion).toEqual( + undefined + ); + }); + + it('should handle UPLOAD_PROGRESS', async () => { + const { uploadsById } = reducer(undefined, { type: actions.uploadProgress, payload: { id: 1, progress: 40 } }); + expect(uploadsById['1']).toEqual({ progress: 40 }); + + const { uploadsById: uploading2 } = reducer(initialState, { type: actions.uploadProgress, payload: { id: 'foo', progress: 40 } }); + expect(uploading2.foo).toEqual({ progress: 40 }); + }); +}); diff --git a/frontend/src/js/store/appSlice/selectors.ts b/frontend/src/js/store/appSlice/selectors.ts new file mode 100644 index 00000000..878d453f --- /dev/null +++ b/frontend/src/js/store/appSlice/selectors.ts @@ -0,0 +1,34 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { createSelector } from '@reduxjs/toolkit'; + +import { versionCompare } from '../../helpers'; + +const getAppDocsVersion = state => state.app.docsVersion; +export const getFeatures = state => state.app.features; +export const getFullVersionInformation = state => state.app.versionInformation; + +export const getDocsVersion = createSelector([getAppDocsVersion, getFeatures], (appDocsVersion, { isHosted }) => { + // if hosted, use latest docs version + const docsVersion = appDocsVersion ? `${appDocsVersion}/` : 'development/'; + return isHosted ? '' : docsVersion; +}); + +export const getSearchState = state => state.app.searchState; + +export const getSearchedDevices = createSelector([getSearchState], ({ deviceIds }) => deviceIds); +export const getVersionInformation = createSelector([getFullVersionInformation, getFeatures], ({ Integration, ...remainder }, { isHosted }) => + isHosted && Integration !== 'next' ? remainder : { ...remainder, Integration } +); +export const getIsPreview = createSelector([getFullVersionInformation], ({ Integration }) => versionCompare(Integration, 'next') > -1); diff --git a/frontend/src/js/store/appSlice/thunks.test.ts b/frontend/src/js/store/appSlice/thunks.test.ts new file mode 100644 index 00000000..109bfcba --- /dev/null +++ b/frontend/src/js/store/appSlice/thunks.test.ts @@ -0,0 +1,118 @@ +// Copyright 2020 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { commonErrorHandler } from '@northern.tech/store/store'; +import { searchDevices } from '@northern.tech/store/thunks'; +import configureMockStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; + +import { actions } from '.'; +import { defaultState } from '../../../../tests/mockData'; +import { actions as deviceActions } from '../devicesSlice'; +import { getLatestReleaseInfo, setFirstLoginAfterSignup, setOfflineThreshold, setSearchState } from './thunks'; + +export const latestSaasReleaseTag = 'saas-v2023.05.02'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +/* eslint-disable sonarjs/no-identical-functions */ +describe('app actions', () => { + it('should handle different error message formats', async () => { + const store = mockStore({ ...defaultState }); + const err = { response: { data: { error: { message: 'test' } } }, id: '123' }; + await expect(commonErrorHandler(err, 'testContext', store.dispatch)).rejects.toEqual(err); + const expectedActions = [ + { + type: actions.setSnackbar.type, + payload: { message: `testContext ${err.response.data.error.message}`, action: 'Copy to clipboard' } + } + ]; + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should not get the latest release info when not hosted', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [{ type: getLatestReleaseInfo.pending.type }, { type: getLatestReleaseInfo.fulfilled.type }]; + await store.dispatch(getLatestReleaseInfo()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + }); + it('should get the latest release info when hosted', async () => { + const store = mockStore({ + ...defaultState, + app: { + ...defaultState.app, + features: { + ...defaultState.app.features, + isHosted: true + } + } + }); + const expectedActions = [ + { type: getLatestReleaseInfo.pending.type }, + { + type: actions.setVersionInformation.type, + payload: { backend: latestSaasReleaseTag, GUI: latestSaasReleaseTag, Integration: '1.2.3', 'Mender-Client': '3.2.1', 'Mender-Artifact': '1.3.7' } + }, + { type: getLatestReleaseInfo.fulfilled.type } + ]; + await store.dispatch(getLatestReleaseInfo()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + + it('should store first login after Signup', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: setFirstLoginAfterSignup.pending.type }, + { type: actions.setFirstLoginAfterSignup.type, payload: true }, + { type: setFirstLoginAfterSignup.fulfilled.type } + ]; + await store.dispatch(setFirstLoginAfterSignup(true)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should calculate yesterdays timestamp', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: setOfflineThreshold.pending.type }, + { type: actions.setOfflineThreshold.type, payload: '2019-01-12T13:00:00.900Z' }, + { type: setOfflineThreshold.fulfilled.type } + ]; + await store.dispatch(setOfflineThreshold()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should handle searching', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: setSearchState.pending.type }, + { type: searchDevices.pending.type }, + { type: actions.setSearchState.type, payload: { ...defaultState.app.searchState, isSearching: true, searchTerm: 'next!' } }, + { type: deviceActions.receivedDevices.type, payload: {} }, + { type: searchDevices.fulfilled.type }, + { type: actions.setSearchState.type, payload: { deviceIds: [], isSearching: false, searchTotal: 0 } }, + { type: setSearchState.fulfilled.type } + ]; + await store.dispatch(setSearchState({ searchTerm: 'next!' })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); diff --git a/frontend/src/js/store/appSlice/thunks.ts b/frontend/src/js/store/appSlice/thunks.ts new file mode 100644 index 00000000..78fd382f --- /dev/null +++ b/frontend/src/js/store/appSlice/thunks.ts @@ -0,0 +1,148 @@ +// Copyright 2019 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import GeneralApi from '@northern.tech/store/api/general-api'; +import { getOfflineThresholdSettings } from '@northern.tech/store/selectors'; +import { searchDevices } from '@northern.tech/store/thunks'; +import { extractErrorMessage, getComparisonCompatibleVersion } from '@northern.tech/store/utils'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import Cookies from 'universal-cookie'; + +import { actions, sliceName } from '.'; +import { deepCompare } from '../../helpers'; +import { getFeatures, getSearchState } from './selectors'; + +const cookies = new Cookies(); + +/* + General +*/ +export const setFirstLoginAfterSignup = createAsyncThunk(`${sliceName}/setFirstLoginAfterSignup`, (firstLoginAfterSignup, { dispatch }) => { + cookies.set('firstLoginAfterSignup', !!firstLoginAfterSignup, { maxAge: 60, path: '/', domain: '.mender.io', sameSite: false }); + dispatch(actions.setFirstLoginAfterSignup(!!firstLoginAfterSignup)); +}); + +const dateFunctionMap = { + getDays: 'getDate', + setDays: 'setDate' +}; +export const setOfflineThreshold = createAsyncThunk(`${sliceName}/setOfflineThreshold`, (_, { dispatch, getState }) => { + const { interval, intervalUnit } = getOfflineThresholdSettings(getState()); + const today = new Date(); + const intervalName = `${intervalUnit.charAt(0).toUpperCase()}${intervalUnit.substring(1)}`; + const setter = dateFunctionMap[`set${intervalName}`] ?? `set${intervalName}`; + const getter = dateFunctionMap[`get${intervalName}`] ?? `get${intervalName}`; + today[setter](today[getter]() - interval); + let value; + try { + value = today.toISOString(); + } catch { + return Promise.resolve(dispatch(actions.setSnackbar('There was an error saving the offline threshold, please check your settings.'))); + } + return Promise.resolve(dispatch(actions.setOfflineThreshold(value))); +}); + +const versionRegex = new RegExp(/\d+\.\d+/); +const getLatestRelease = thing => { + const latestKey = Object.keys(thing) + .filter(key => versionRegex.test(key)) + .sort() + .reverse()[0]; + return thing[latestKey]; +}; + +const repoKeyMap = { + integration: 'Integration', + mender: 'Mender-Client', + 'mender-artifact': 'Mender-Artifact' +}; + +const deductSaasState = (latestRelease, guiTags, saasReleases) => { + const latestGuiTag = guiTags.length ? guiTags[0].name : ''; + const latestSaasRelease = latestGuiTag.startsWith('saas-v') ? { date: latestGuiTag.split('-v')[1].replaceAll('.', '-'), tag: latestGuiTag } : saasReleases[0]; + return latestSaasRelease.date > latestRelease.release_date ? latestSaasRelease.tag : latestRelease.release; +}; + +export const getLatestReleaseInfo = createAsyncThunk(`${sliceName}/getLatestReleaseInfo`, (_, { dispatch, getState }) => { + if (!getFeatures(getState()).isHosted) { + return Promise.resolve(); + } + return Promise.all([GeneralApi.get('/versions.json'), GeneralApi.get('/tags.json')]) + .catch(err => { + console.log('init error:', extractErrorMessage(err)); + return Promise.resolve([{ data: {} }, { data: [] }]); + }) + .then(([{ data }, { data: guiTags }]) => { + if (!guiTags.length) { + return Promise.resolve(); + } + const { releases, saas } = data; + const latestRelease = getLatestRelease(getLatestRelease(releases)); + const { latestRepos, latestVersions } = latestRelease.repos.reduce( + (accu, item) => { + if (repoKeyMap[item.name]) { + accu.latestVersions[repoKeyMap[item.name]] = getComparisonCompatibleVersion(item.version); + } + accu.latestRepos[item.name] = getComparisonCompatibleVersion(item.version); + return accu; + }, + { latestVersions: { ...getState().app.versionInformation }, latestRepos: {} } + ); + const info = deductSaasState(latestRelease, guiTags, saas); + return Promise.resolve( + dispatch( + actions.setVersionInformation({ + ...latestVersions, + backend: info, + GUI: info, + latestRelease: { + releaseDate: latestRelease.release_date, + repos: latestRepos + } + }) + ) + ); + }); +}); + +export const setSearchState = createAsyncThunk(`${sliceName}/setSearchState`, (searchState, { dispatch, getState }) => { + const currentState = getSearchState(getState()); + let nextState = { + ...currentState, + ...searchState, + sort: { + ...currentState.sort, + ...searchState.sort + } + }; + let tasks = []; + // eslint-disable-next-line no-unused-vars + const { isSearching: currentSearching, deviceIds: currentDevices, searchTotal: currentTotal, ...currentRequestState } = currentState; + // eslint-disable-next-line no-unused-vars + const { isSearching: nextSearching, deviceIds: nextDevices, searchTotal: nextTotal, ...nextRequestState } = nextState; + if (nextRequestState.searchTerm && !deepCompare(currentRequestState, nextRequestState)) { + nextState.isSearching = true; + tasks.push( + dispatch(searchDevices(nextState)) + .unwrap() + .then(results => { + const searchResult = results[results.length - 1]; + return dispatch(actions.setSearchState({ ...searchResult, isSearching: false })); + }) + .catch(() => dispatch(actions.setSearchState({ isSearching: false, searchTotal: 0 }))) + ); + } + tasks.push(dispatch(actions.setSearchState(nextState))); + return Promise.all(tasks); +}); diff --git a/frontend/src/js/auth.test.js b/frontend/src/js/store/auth.test.ts similarity index 100% rename from frontend/src/js/auth.test.js rename to frontend/src/js/store/auth.test.ts diff --git a/frontend/src/js/auth.js b/frontend/src/js/store/auth.ts similarity index 98% rename from frontend/src/js/auth.js rename to frontend/src/js/store/auth.ts index f71ebe5d..4ae4d584 100644 --- a/frontend/src/js/auth.js +++ b/frontend/src/js/store/auth.ts @@ -11,9 +11,10 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck import Cookies from 'universal-cookie'; -import { TIMEOUTS } from './constants/appConstants'; +import { TIMEOUTS } from './constants'; const cookies = new Cookies(); diff --git a/frontend/src/js/constants/deviceConstants.js b/frontend/src/js/store/commonConstants.tsx similarity index 72% rename from frontend/src/js/constants/deviceConstants.js rename to frontend/src/js/store/commonConstants.tsx index 15667963..9e1785bf 100644 --- a/frontend/src/js/constants/deviceConstants.js +++ b/frontend/src/js/store/commonConstants.tsx @@ -1,4 +1,4 @@ -// Copyright 2019 Northern.tech AS +// Copyright 2024 Northern.tech AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,22 +11,27 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck import React from 'react'; import { mdiAws as AWS, mdiMicrosoftAzure as Azure } from '@mdi/js'; -const credentialTypes = { - aws: 'aws', - http: 'http', - sas: 'sas', - x509: 'x509' +export const DEVICE_LIST_DEFAULTS = { + page: 1, + perPage: 20 }; + export const timeUnits = { days: 'days', minutes: 'minutes', hours: 'hours' }; +export const ALL_DEVICES = 'All devices'; +export const UNGROUPED_GROUP = { id: '*|=ungrouped=|*', name: 'Unassigned' }; + +export const DEVICE_LIST_MAXIMUM_LENGTH = 50; + export const DEVICE_FILTERING_OPTIONS = { $eq: { key: '$eq', title: 'equals', shortform: '=' }, $ne: { key: '$ne', title: 'not equal', shortform: '!=' }, @@ -91,107 +96,7 @@ export const DEVICE_FILTERING_OPTIONS = { help: `The "regular expression" operator matches the selected field's value with a Perl compatible regular expression (PCRE), automatically anchored by ^. If the regular expression is not valid, the filter will produce no results. If you need to specify options and flags, you can provide the full regex in the format of /regex/flags, for example.` } }; -export const emptyFilter = { key: '', value: '', operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'inventory' }; - -export const SELECT_GROUP = 'SELECT_GROUP'; -export const ADD_TO_GROUP = 'ADD_TO_GROUP'; -export const ADD_DYNAMIC_GROUP = 'ADD_DYNAMIC_GROUP'; -export const ADD_STATIC_GROUP = 'ADD_STATIC_GROUP'; -export const REMOVE_DYNAMIC_GROUP = 'REMOVE_DYNAMIC_GROUP'; -export const REMOVE_STATIC_GROUP = 'REMOVE_STATIC_GROUP'; -export const REMOVE_FROM_GROUP = 'REMOVE_FROM_GROUP'; -export const RECEIVE_GROUPS = 'RECEIVE_GROUPS'; -export const RECEIVE_DYNAMIC_GROUPS = 'RECEIVE_DYNAMIC_GROUPS'; -export const RECEIVE_DEVICE = 'RECEIVE_DEVICE'; -export const RECEIVE_DEVICES = 'RECEIVE_DEVICES'; -export const RECEIVE_DEVICE_CONFIG = 'RECEIVE_DEVICE_CONFIG'; -export const RECEIVE_DEVICE_CONNECT = 'RECEIVE_DEVICE_CONNECT'; -export const RECEIVE_GROUP_DEVICES = 'RECEIVE_GROUP_DEVICES'; -export const SET_TOTAL_DEVICES = 'SET_TOTAL_DEVICES'; -export const SET_ACCEPTED_DEVICES_COUNT = 'SET_ACCEPTED_DEVICES_COUNT'; -export const SET_PENDING_DEVICES_COUNT = 'SET_PENDING_DEVICES_COUNT'; -export const SET_REJECTED_DEVICES_COUNT = 'SET_REJECTED_DEVICES_COUNT'; -export const SET_PREAUTHORIZED_DEVICES_COUNT = 'SET_PREAUTHORIZED_DEVICES_COUNT'; -export const SET_FILTER_ATTRIBUTES = 'SET_FILTER_ATTRIBUTES'; -export const SET_FILTERABLES_CONFIG = 'SET_FILTERABLES_CONFIG'; -export const SET_DEVICE_FILTERS = 'SET_DEVICE_FILTERS'; - -export const SET_ACCEPTED_DEVICES = 'SET_ACCEPTED_DEVICES'; -export const SET_PENDING_DEVICES = 'SET_PENDING_DEVICES'; -export const SET_REJECTED_DEVICES = 'SET_REJECTED_DEVICES'; -export const SET_PREAUTHORIZED_DEVICES = 'SET_PREAUTHORIZED_DEVICES'; - -export const SET_INACTIVE_DEVICES = 'SET_INACTIVE_DEVICES'; -export const SET_DEVICE_LIST_STATE = 'SET_DEVICE_LIST_STATE'; -export const SET_DEVICE_LIMIT = 'SET_DEVICE_LIMIT'; - -export const SET_DEVICE_REPORTS = 'SET_DEVICE_REPORTS'; - -export const EXTERNAL_PROVIDER = { - 'iot-core': { - credentialsType: credentialTypes.aws, - icon: AWS, - title: 'AWS IoT Core', - twinTitle: 'Device Shadow', - provider: 'iot-core', - enabled: true, - deviceTwin: true, - configHint: <>For help finding your AWS IoT Core connection string, check the AWS IoT documentation. - }, - 'iot-hub': { - credentialsType: credentialTypes.sas, - icon: Azure, - title: 'Azure IoT Hub', - twinTitle: 'Device Twin', - provider: 'iot-hub', - enabled: true, - deviceTwin: true, - configHint: ( - - For help finding your Azure IoT Hub connection string, look under 'Shared access policies' in the Microsoft Azure UI as described{' '} - { - - here - - } - . - - ) - }, - webhook: { - credentialsType: credentialTypes.http, - deviceTwin: false, - // disable the webhook provider here, since it is treated different than other integrations, with a custom configuration & management view, etc. - enabled: false, - provider: 'webhook' - } -}; - -// see https://github.com/mendersoftware/go-lib-micro/tree/master/ws -// for the description of proto_header and the consts -// *Note*: this needs to be aligned with mender-connect and deviceconnect. -export const DEVICE_MESSAGE_PROTOCOLS = { - Shell: 1 -}; -export const DEVICE_MESSAGE_TYPES = { - Delay: 'delay', - New: 'new', - Ping: 'ping', - Pong: 'pong', - Resize: 'resize', - Shell: 'shell', - Stop: 'stop' -}; -export const DEVICE_LIST_DEFAULTS = { - page: 1, - perPage: 20 -}; -export const DEVICE_LIST_MAXIMUM_LENGTH = 50; export const DEVICE_ISSUE_OPTIONS = { issues: { isCategory: true, @@ -248,20 +153,26 @@ export const DEVICE_ISSUE_OPTIONS = { title: 'Gateway devices' } }; -// we can't include the dismiss state with the rest since this would include dismissed devices in several queries -export const DEVICE_DISMISSAL_STATE = 'dismiss'; -export const DEVICE_STATES = { - accepted: 'accepted', - pending: 'pending', - preauth: 'preauthorized', - rejected: 'rejected' +const oneSecond = 1000; +export const TIMEOUTS = { + debounceDefault: 700, + debounceShort: 300, + halfASecond: 0.5 * oneSecond, + oneSecond, + twoSeconds: 2 * oneSecond, + threeSeconds: 3 * oneSecond, + fiveSeconds: 5 * oneSecond, + refreshDefault: 10 * oneSecond, + refreshLong: 60 * oneSecond }; -export const DEVICE_CONNECT_STATES = { - connected: 'connected', - disconnected: 'disconnected', - unknown: 'unknown' + +export const SORTING_OPTIONS = { + asc: 'asc', + desc: 'desc' }; + export const DEVICE_ONLINE_CUTOFF = { interval: 1, intervalName: timeUnits.days }; + export const ATTRIBUTE_SCOPES = { inventory: 'inventory', identity: 'identity', @@ -269,5 +180,73 @@ export const ATTRIBUTE_SCOPES = { system: 'system', tags: 'tags' }; -export const ALL_DEVICES = 'All devices'; -export const UNGROUPED_GROUP = { id: '*|=ungrouped=|*', name: 'Unassigned' }; + +export const defaultIdAttribute = Object.freeze({ attribute: 'id', scope: ATTRIBUTE_SCOPES.identity }); + +const credentialTypes = { + aws: 'aws', + http: 'http', + sas: 'sas', + x509: 'x509' +}; +export const EXTERNAL_PROVIDER = { + 'iot-core': { + credentialsType: credentialTypes.aws, + icon: AWS, + title: 'AWS IoT Core', + twinTitle: 'Device Shadow', + provider: 'iot-core', + enabled: true, + deviceTwin: true, + configHint: <>For help finding your AWS IoT Core connection string, check the AWS IoT documentation. + }, + 'iot-hub': { + credentialsType: credentialTypes.sas, + icon: Azure, + title: 'Azure IoT Hub', + twinTitle: 'Device Twin', + provider: 'iot-hub', + enabled: true, + deviceTwin: true, + configHint: ( + + For help finding your Azure IoT Hub connection string, look under 'Shared access policies' in the Microsoft Azure UI as described{' '} + { + + here + + } + . + + ) + }, + webhook: { + credentialsType: credentialTypes.http, + deviceTwin: false, + // disable the webhook provider here, since it is treated different than other integrations, with a custom configuration & management view, etc. + enabled: false, + provider: 'webhook' + } +}; +export const MAX_PAGE_SIZE = 500; + +export const ALL_RELEASES = 'All releases'; + +export const emptyUiPermissions = Object.freeze({ + auditlog: [], + deployments: [], + groups: Object.freeze({}), + releases: Object.freeze({}), + userManagement: [] +}); + +export const emptyRole = Object.freeze({ + name: undefined, + description: '', + permissions: [], + uiPermissions: Object.freeze({ ...emptyUiPermissions }) +}); diff --git a/frontend/src/js/store/commonSelectors.ts b/frontend/src/js/store/commonSelectors.ts new file mode 100644 index 00000000..dfe39e19 --- /dev/null +++ b/frontend/src/js/store/commonSelectors.ts @@ -0,0 +1,254 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { createSelector } from '@reduxjs/toolkit'; + +import { attributeDuplicateFilter, getDemoDeviceAddress as getDemoDeviceAddressHelper } from '../helpers'; +import { PLANS, defaultReports } from './appSlice/constants'; +import { getFeatures, getSearchedDevices } from './appSlice/selectors'; +import { ALL_DEVICES, ATTRIBUTE_SCOPES, DEVICE_ISSUE_OPTIONS, DEVICE_LIST_MAXIMUM_LENGTH, UNGROUPED_GROUP } from './commonConstants'; +import { getDeploymentsById, getDeploymentsSelectionState, getSelectedDeploymentDeviceIds } from './deploymentsSlice/selectors'; +import { inventoryApiUrlV2, reportingApiUrl } from './devicesSlice/constants'; +import { getDeviceById, getDevicesById, getFilteringAttributes, getGroupsById, getListedDevices } from './devicesSlice/selectors'; +import { getIssueCountsByType } from './monitorSlice/selectors'; +import { onboardingSteps } from './onboardingSlice/constants'; +import { getOnboarding } from './onboardingSlice/selectors'; +import { getAuditLogEntry, getOrganization } from './organizationSlice/selectors'; +import { getReleasesById } from './releasesSlice/selectors'; +import { rolesByName, uiPermissionsById } from './usersSlice/constants'; +import { getCurrentUser, getGlobalSettings, getRolesById, getUserSettings } from './usersSlice/selectors'; +import { listItemMapper, mapUserRolesToUiPermissions } from './utils'; + +export const getIsEnterprise = createSelector( + [getOrganization, getFeatures], + ({ plan = PLANS.os.id }, { isEnterprise, isHosted }) => isEnterprise || (isHosted && plan === PLANS.enterprise.id) +); + +export const getAttrsEndpoint = createSelector([getFeatures], ({ hasReporting }) => + hasReporting ? `${reportingApiUrl}/devices/search/attributes` : `${inventoryApiUrlV2}/filters/attributes` +); +export const getSearchEndpoint = createSelector([getFeatures], ({ hasReporting }) => + hasReporting ? `${reportingApiUrl}/devices/search` : `${inventoryApiUrlV2}/filters/search` +); + +export const getUserRoles = createSelector( + [getCurrentUser, getRolesById, getIsEnterprise, getFeatures, getOrganization], + (currentUser, rolesById, isEnterprise, { isHosted, hasMultitenancy }, { plan = PLANS.os.id }) => { + const isAdmin = currentUser.roles?.length + ? currentUser.roles.some(role => role === rolesByName.admin) + : !(hasMultitenancy || isEnterprise || (isHosted && plan !== PLANS.os.id)); + const uiPermissions = isAdmin + ? mapUserRolesToUiPermissions([rolesByName.admin], rolesById) + : mapUserRolesToUiPermissions(currentUser.roles || [], rolesById); + return { isAdmin, uiPermissions }; + } +); + +const hasPermission = (thing, permission) => Object.values(thing).some(permissions => permissions.includes(permission)); + +export const getUserCapabilities = createSelector([getUserRoles], ({ uiPermissions }) => { + const canManageReleases = hasPermission(uiPermissions.releases, uiPermissionsById.manage.value); + const canReadReleases = canManageReleases || hasPermission(uiPermissions.releases, uiPermissionsById.read.value); + const canUploadReleases = canManageReleases || hasPermission(uiPermissions.releases, uiPermissionsById.upload.value); + + const canAuditlog = uiPermissions.auditlog.includes(uiPermissionsById.read.value); + + const canReadUsers = uiPermissions.userManagement.includes(uiPermissionsById.read.value); + const canManageUsers = uiPermissions.userManagement.includes(uiPermissionsById.manage.value); + + const canReadDevices = hasPermission(uiPermissions.groups, uiPermissionsById.read.value); + const canWriteDevices = Object.values(uiPermissions.groups).some( + groupPermissions => groupPermissions.includes(uiPermissionsById.read.value) && groupPermissions.length > 1 + ); + const canTroubleshoot = hasPermission(uiPermissions.groups, uiPermissionsById.connect.value); + const canManageDevices = hasPermission(uiPermissions.groups, uiPermissionsById.manage.value); + const canConfigure = hasPermission(uiPermissions.groups, uiPermissionsById.configure.value); + + const canDeploy = uiPermissions.deployments.includes(uiPermissionsById.deploy.value) || hasPermission(uiPermissions.groups, uiPermissionsById.deploy.value); + const canReadDeployments = uiPermissions.deployments.includes(uiPermissionsById.read.value); + + return { + canAuditlog, + canConfigure, + canDeploy, + canManageDevices, + canManageReleases, + canManageUsers, + canReadDeployments, + canReadDevices, + canReadReleases, + canReadUsers, + canTroubleshoot, + canUploadReleases, + canWriteDevices, + groupsPermissions: uiPermissions.groups, + releasesPermissions: uiPermissions.releases + }; +}); + +export const getTenantCapabilities = createSelector( + [getFeatures, getOrganization, getIsEnterprise], + ( + { hasAuditlogs: isAuditlogEnabled, hasDeviceConfig: isDeviceConfigEnabled, hasDeviceConnect: isDeviceConnectEnabled, hasMonitor: isMonitorEnabled }, + { addons = [], plan = PLANS.os.id }, + isEnterprise + ) => { + const canDelta = isEnterprise || plan === PLANS.professional.id; + const hasAuditlogs = isAuditlogEnabled && isEnterprise; + const hasDeviceConfig = isDeviceConfigEnabled && addons.some(addon => addon.name === 'configure' && Boolean(addon.enabled)); + const hasDeviceConnect = isDeviceConnectEnabled && (!isEnterprise || addons.some(addon => addon.name === 'troubleshoot' && Boolean(addon.enabled))); + const hasMonitor = isMonitorEnabled && addons.some(addon => addon.name === 'monitor' && Boolean(addon.enabled)); + return { + canDelta, + canRetry: canDelta, + canSchedule: canDelta, + hasAuditlogs, + hasDeviceConfig, + hasDeviceConnect, + hasFullFiltering: canDelta, + hasMonitor, + isEnterprise, + plan + }; + } +); + +export const getFilterAttributes = createSelector( + [getGlobalSettings, getFilteringAttributes], + ({ previousFilters }, { identityAttributes, inventoryAttributes, systemAttributes, tagAttributes }) => { + const deviceNameAttribute = { key: 'name', value: 'Name', scope: ATTRIBUTE_SCOPES.tags, category: ATTRIBUTE_SCOPES.tags, priority: 1 }; + const deviceIdAttribute = { key: 'id', value: 'Device ID', scope: ATTRIBUTE_SCOPES.identity, category: ATTRIBUTE_SCOPES.identity, priority: 1 }; + const checkInAttribute = { key: 'check_in_time', value: 'Latest activity', scope: ATTRIBUTE_SCOPES.system, category: ATTRIBUTE_SCOPES.system, priority: 4 }; + const updateAttribute = { ...checkInAttribute, key: 'updated_ts', value: 'Last inventory update' }; + const firstRequestAttribute = { key: 'created_ts', value: 'First request', scope: ATTRIBUTE_SCOPES.system, category: ATTRIBUTE_SCOPES.system, priority: 4 }; + const attributes = [ + ...previousFilters.map(item => ({ + ...item, + value: deviceIdAttribute.key === item.key ? deviceIdAttribute.value : item.key, + category: 'recently used', + priority: 0 + })), + deviceNameAttribute, + deviceIdAttribute, + ...identityAttributes.map(item => ({ key: item, value: item, scope: ATTRIBUTE_SCOPES.identity, category: ATTRIBUTE_SCOPES.identity, priority: 1 })), + ...inventoryAttributes.map(item => ({ key: item, value: item, scope: ATTRIBUTE_SCOPES.inventory, category: ATTRIBUTE_SCOPES.inventory, priority: 2 })), + ...tagAttributes.map(item => ({ key: item, value: item, scope: ATTRIBUTE_SCOPES.tags, category: ATTRIBUTE_SCOPES.tags, priority: 3 })), + checkInAttribute, + updateAttribute, + firstRequestAttribute, + ...systemAttributes.map(item => ({ key: item, value: item, scope: ATTRIBUTE_SCOPES.system, category: ATTRIBUTE_SCOPES.system, priority: 4 })) + ]; + return attributeDuplicateFilter(attributes, 'key'); + } +); + +export const getOnboardingState = createSelector([getOnboarding, getUserSettings], ({ complete, progress, showTips, ...remainder }, { onboarding = {} }) => ({ + ...remainder, + ...onboarding, + complete: onboarding.complete || complete, + progress: + Object.keys(onboardingSteps).findIndex(step => step === progress) > Object.keys(onboardingSteps).findIndex(step => step === onboarding.progress) + ? progress + : onboarding.progress, + showTips: !onboarding.showTips ? onboarding.showTips : showTips +})); + +export const getDemoDeviceAddress = createSelector([getDevicesById, getOnboarding], (devicesById, { approach, demoArtifactPort }) => { + const demoDeviceAddress = `http://${getDemoDeviceAddressHelper(Object.values(devicesById), approach)}`; + return demoArtifactPort ? `${demoDeviceAddress}:${demoArtifactPort}` : demoDeviceAddress; +}); + +export const getDeviceConfigDeployment = createSelector([getDeviceById, getDeploymentsById], (device, deploymentsById) => { + const { config = {} } = device; + const { deployment_id: configDeploymentId } = config; + const deviceConfigDeployment = deploymentsById[configDeploymentId] || {}; + return { device, deviceConfigDeployment }; +}); + +export const getDeploymentRelease = createSelector( + [getDeploymentsById, getDeploymentsSelectionState, getReleasesById], + (deploymentsById, { selectedId }, releasesById) => { + const deployment = deploymentsById[selectedId] || {}; + return deployment.artifact_name && releasesById[deployment.artifact_name] ? releasesById[deployment.artifact_name] : { device_types_compatible: [] }; + } +); + +export const getSelectedDeploymentData = createSelector( + [getDeploymentsById, getDeploymentsSelectionState, getDevicesById, getSelectedDeploymentDeviceIds], + (deploymentsById, { selectedId }, devicesById, selectedDeviceIds) => { + const deployment = deploymentsById[selectedId] ?? {}; + const { devices = {} } = deployment; + return { + deployment, + selectedDevices: selectedDeviceIds.map(deviceId => ({ ...devicesById[deviceId], ...devices[deviceId] })) + }; + } +); + +export const getAvailableIssueOptionsByType = createSelector( + [getFeatures, getTenantCapabilities, getIssueCountsByType], + ({ hasReporting }, { hasFullFiltering, hasMonitor }, issueCounts) => + Object.values(DEVICE_ISSUE_OPTIONS).reduce((accu, { isCategory, key, needsFullFiltering, needsMonitor, needsReporting, title }) => { + if (isCategory || (needsReporting && !hasReporting) || (needsFullFiltering && !hasFullFiltering) || (needsMonitor && !hasMonitor)) { + return accu; + } + accu[key] = { count: issueCounts[key].filtered, key, title }; + return accu; + }, {}) +); + +export const getGroupNames = createSelector([getGroupsById, getUserRoles, (_, options = {}) => options], (groupsById, { uiPermissions }, { staticOnly }) => { + // eslint-disable-next-line no-unused-vars + const { [UNGROUPED_GROUP.id]: ungrouped, ...groups } = groupsById; + if (staticOnly) { + return Object.keys(uiPermissions.groups).sort(); + } + return Object.keys( + Object.entries(groups).reduce((accu, [groupName, group]) => { + if (group.filterId || uiPermissions.groups[ALL_DEVICES]) { + accu[groupName] = group; + } + return accu; + }, uiPermissions.groups) + ).sort(); +}); + +export const getDeviceReportsForUser = createSelector( + [getUserSettings, getCurrentUser, getGlobalSettings, getDevicesById], + ({ reports }, { id: currentUserId }, globalSettings, devicesById) => { + return reports || globalSettings[`${currentUserId}-reports`] || (Object.keys(devicesById).length ? defaultReports : []); + } +); + +const listTypeDeviceIdMap = { + deviceList: getListedDevices, + search: getSearchedDevices +}; +const deviceMapDefault = { defaultObject: { auth_sets: [] }, cutOffSize: DEVICE_LIST_MAXIMUM_LENGTH }; +const getDeviceMappingDefaults = () => deviceMapDefault; + +export const getMappedDevicesList = createSelector( + [getDevicesById, (state, listType) => listTypeDeviceIdMap[listType](state), getDeviceMappingDefaults], + listItemMapper +); + +export const getAuditlogDevice = createSelector([getAuditLogEntry, getDevicesById], (auditlogEvent, devicesById) => { + let auditlogDevice = {}; + if (auditlogEvent) { + const { object = {} } = auditlogEvent; + const { device = {}, id, type } = object; + auditlogDevice = type === 'device' ? { id, ...device } : auditlogDevice; + } + return { ...auditlogDevice, ...devicesById[auditlogDevice.id] }; +}); diff --git a/frontend/src/js/store/constants.ts b/frontend/src/js/store/constants.ts new file mode 100644 index 00000000..e18c1fa4 --- /dev/null +++ b/frontend/src/js/store/constants.ts @@ -0,0 +1,22 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +export * from './appSlice/constants'; +export * from './commonConstants'; +export * from './deploymentsSlice/constants'; +export * from './devicesSlice/constants'; +export * from './monitorSlice/constants'; +export * from './onboardingSlice/constants'; +export * from './organizationSlice/constants'; +export * from './releasesSlice/constants'; +export * from './usersSlice/constants'; diff --git a/frontend/src/js/constants/deploymentConstants.js b/frontend/src/js/store/deploymentsSlice/constants.ts similarity index 82% rename from frontend/src/js/constants/deploymentConstants.js rename to frontend/src/js/store/deploymentsSlice/constants.ts index aa293ceb..d05267b9 100644 --- a/frontend/src/js/constants/deploymentConstants.js +++ b/frontend/src/js/store/deploymentsSlice/constants.ts @@ -11,11 +11,13 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { SORTING_OPTIONS } from './appConstants'; -import { DEVICE_LIST_DEFAULTS } from './deviceConstants'; +import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS, apiUrl } from '@northern.tech/store/constants'; const alreadyInstalled = 'already-installed'; +export const deploymentsApiUrl = `${apiUrl.v1}/deployments`; +export const deploymentsApiUrlV2 = `${apiUrl.v2}/deployments`; + export const deploymentSubstates = { aborted: 'aborted', alreadyInstalled, @@ -97,23 +99,6 @@ export const listDefaultsByState = { sort: { direction: SORTING_OPTIONS.desc } }; -export const CREATE_DEPLOYMENT = 'CREATE_DEPLOYMENT'; -export const REMOVE_DEPLOYMENT = 'REMOVE_DEPLOYMENT'; -export const RECEIVE_DEPLOYMENT = 'RECEIVE_DEPLOYMENT'; -export const RECEIVE_DEPLOYMENT_DEVICE_LOG = 'RECEIVE_DEPLOYMENT_DEVICE_LOG'; -export const RECEIVE_DEPLOYMENT_DEVICES = 'RECEIVE_DEPLOYMENT_DEVICES'; -export const RECEIVE_DEPLOYMENTS = 'RECEIVE_DEPLOYMENTS'; -export const RECEIVE_PENDING_DEPLOYMENTS = 'RECEIVE_PENDING_DEPLOYMENTS'; -export const RECEIVE_INPROGRESS_DEPLOYMENTS = 'RECEIVE_INPROGRESS_DEPLOYMENTS'; -export const RECEIVE_SCHEDULED_DEPLOYMENTS = 'RECEIVE_SCHEDULED_DEPLOYMENTS'; -export const RECEIVE_FINISHED_DEPLOYMENTS = 'RECEIVE_FINISHED_DEPLOYMENTS'; -export const SELECT_INPROGRESS_DEPLOYMENTS = 'SELECT_INPROGRESS_DEPLOYMENTS'; -export const SELECT_PENDING_DEPLOYMENTS = 'SELECT_PENDING_DEPLOYMENTS'; -export const SELECT_SCHEDULED_DEPLOYMENTS = 'SELECT_SCHEDULED_DEPLOYMENTS'; -export const SELECT_FINISHED_DEPLOYMENTS = 'SELECT_FINISHED_DEPLOYMENTS'; -export const SELECT_DEPLOYMENT = 'SELECT_DEPLOYMENT'; -export const SET_DEPLOYMENTS_CONFIG = 'SET_DEPLOYMENTS_CONFIG'; -export const SET_DEPLOYMENTS_STATE = 'SET_DEPLOYMENTS_STATE'; export const DEFAULT_PENDING_INPROGRESS_COUNT = 10; export const DEPLOYMENT_ROUTES = { active: { diff --git a/frontend/src/js/store/deploymentsSlice/index.ts b/frontend/src/js/store/deploymentsSlice/index.ts new file mode 100644 index 00000000..5a540581 --- /dev/null +++ b/frontend/src/js/store/deploymentsSlice/index.ts @@ -0,0 +1,145 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { DEVICE_LIST_DEFAULTS } from '@northern.tech/store/constants'; +import { createSlice } from '@reduxjs/toolkit'; + +import { DEFAULT_PENDING_INPROGRESS_COUNT, DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, deploymentPrototype, limitDefault } from './constants'; + +export const sliceName = 'deployments'; + +export const initialState = { + byId: { + // [id]: { statistics: { status: {}, total_size }, devices: { [deploymentId]: { id, log } }, totalDeviceCount } + }, + byStatus: { + finished: { deploymentIds: [], total: 0 }, + inprogress: { deploymentIds: [], total: 0 }, + pending: { deploymentIds: [], total: 0 }, + scheduled: { deploymentIds: [], total: 0 } + }, + config: { + binaryDelta: { + timeout: -1, + duplicatesWindow: -1, + compressionLevel: -1, + disableChecksum: false, + disableDecompression: false, + inputWindow: -1, + instructionBuffer: -1, + sourceWindow: -1 + }, + binaryDeltaLimits: { + timeout: { ...limitDefault, default: 60, max: 3600, min: 60 }, + sourceWindow: limitDefault, + inputWindow: limitDefault, + duplicatesWindow: limitDefault, + instructionBuffer: limitDefault + } + }, + deploymentDeviceLimit: 5000, + selectedDeviceIds: [], + selectionState: { + finished: { ...DEVICE_LIST_DEFAULTS, endDate: undefined, search: '', selection: [], startDate: undefined, total: 0, type: '' }, + inprogress: { ...DEVICE_LIST_DEFAULTS, perPage: DEFAULT_PENDING_INPROGRESS_COUNT, selection: [] }, + pending: { ...DEVICE_LIST_DEFAULTS, perPage: DEFAULT_PENDING_INPROGRESS_COUNT, selection: [] }, + scheduled: { ...DEVICE_LIST_DEFAULTS, selection: [] }, + general: { + state: DEPLOYMENT_ROUTES.active.key, + showCreationDialog: false, + showReportDialog: false, + reportType: null // DeploymentConstants.DEPLOYMENT_TYPES.configuration|DeploymentConstants.DEPLOYMENT_TYPES.software + }, + selectedId: undefined + } +}; + +export const deploymentsSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + createdDeployment: (state, action) => { + state.byId[action.payload.id] = { + ...deploymentPrototype, + ...action.payload + }; + state.byStatus[DEPLOYMENT_STATES.pending].total = state.byStatus[DEPLOYMENT_STATES.pending].total + 1; + state.byStatus[DEPLOYMENT_STATES.pending].deploymentIds = [...state.byStatus.pending.deploymentIds, action.payload.id]; + (state.selectionState[DEPLOYMENT_STATES.pending].selection = [action.payload.id, ...state.selectionState[DEPLOYMENT_STATES.pending].selection]), + (state.selectionState[DEPLOYMENT_STATES.pending].total = state.selectionState[DEPLOYMENT_STATES.pending].total + 1); + }, + removedDeployment: (state, action) => { + // eslint-disable-next-line no-unused-vars + const { [action.payload]: removedDeployment, ...remainder } = state.byId; + state.byId = remainder; + }, + receivedDeployment: (state, action) => { + state.byId[action.payload.id] = { + ...(state.byId[action.payload.id] || {}), + ...action.payload + }; + }, + receivedDeploymentDeviceLog: (state, action) => { + const { id, deviceId, log } = action.payload; + const deployment = { + ...deploymentPrototype, + ...state.byId[id] + }; + state.byId[id] = { + ...deployment, + devices: { + ...deployment.devices, + [deviceId]: { + ...deployment.devices[deviceId], + log + } + } + }; + }, + receivedDeploymentDevices: (state, action) => { + const { id, devices, selectedDeviceIds, totalDeviceCount } = action.payload; + state.byId[id] = { + ...state.byId[id], + devices, + totalDeviceCount + }; + state.selectedDeviceIds = selectedDeviceIds; + }, + receivedDeployments: (state, action) => { + state.byId = { + ...state.byId, + ...action.payload + }; + }, + receivedDeploymentsForStatus: (state, action) => { + const { status, deploymentIds, total } = action.payload; + state.byStatus[status].deploymentIds = deploymentIds; + state.byStatus[status].total = total; + }, + selectDeploymentsForStatus: (state, action) => { + const { status, deploymentIds, total } = action.payload; + state.selectionState[status].selection = deploymentIds; + state.selectionState[status].total = total; + }, + setDeploymentsState: (state, action) => { + state.selectionState = action.payload; + }, + setDeploymentsConfig: (state, action) => { + state.config = action.payload; + } + } +}); + +export const actions = deploymentsSlice.actions; +export default deploymentsSlice.reducer; diff --git a/frontend/src/js/store/deploymentsSlice/reducer.test.ts b/frontend/src/js/store/deploymentsSlice/reducer.test.ts new file mode 100644 index 00000000..1bbd4999 --- /dev/null +++ b/frontend/src/js/store/deploymentsSlice/reducer.test.ts @@ -0,0 +1,108 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck + +/*eslint import/namespace: ['error', { allowComputed: true }]*/ +import reducer, { actions, initialState } from '.'; +import { defaultState } from '../../../../tests/mockData'; +import { DEPLOYMENT_STATES } from './constants'; + +describe('deployment reducer', () => { + it('should return the initial state', async () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + + it('should handle RECEIVE_DEPLOYMENT', async () => { + expect(reducer(undefined, { type: actions.receivedDeployment, payload: defaultState.deployments.byId.d1 }).byId.d1).toEqual( + defaultState.deployments.byId.d1 + ); + expect(reducer(initialState, { type: actions.receivedDeployment, payload: defaultState.deployments.byId.d1 }).byId.d1).toEqual( + defaultState.deployments.byId.d1 + ); + }); + it('should handle RECEIVE_DEPLOYMENTS', async () => { + const { statistics } = defaultState.deployments.byId.d1; + expect(reducer(undefined, { type: actions.receivedDeployments, payload: { plain: 'passing' } }).byId.plain).toBeTruthy(); + expect( + reducer(initialState, { type: actions.receivedDeployments, payload: { [defaultState.deployments.byId.d1.id]: { statistics } } }).byId.d1.statistics + ).toBeTruthy(); + }); + it('should handle RECEIVE_DEPLOYMENT_DEVICE_LOG', async () => { + expect( + reducer(undefined, { + type: actions.receivedDeploymentDeviceLog, + payload: { id: defaultState.deployments.byId.d1.id, deviceId: defaultState.deployments.byId.d1.devices.a1.id, log: 'foo' } + }).byId.d1.devices.a1.log + ).toEqual('foo'); + expect( + reducer(initialState, { + type: actions.receivedDeploymentDeviceLog, + payload: { id: defaultState.deployments.byId.d1.id, deviceId: defaultState.deployments.byId.d1.devices.a1.id, log: 'bar' } + }).byId.d1.devices.a1.log + ).toEqual('bar'); + }); + it('should handle RECEIVE_DEPLOYMENT_DEVICES', async () => { + const { devices, id } = defaultState.deployments.byId.d1; + expect( + reducer(undefined, { + type: actions.receivedDeploymentDevices, + payload: { id, devices, selectedDeviceIds: [devices.a1.id], totalDeviceCount: 500 } + }).byId.d1.totalDeviceCount + ).toEqual(500); + expect( + reducer(defaultState.deployments, { + type: actions.receivedDeploymentDevices, + payload: { id, devices, selectedDeviceIds: [devices.a1.id], totalDeviceCount: 500 } + }).byId.d1.statistics + ).toEqual(defaultState.deployments.byId.d1.statistics); + }); + it('should handle RECEIVE__DEPLOYMENTS', async () => { + Object.values(DEPLOYMENT_STATES).forEach(status => { + expect(reducer(undefined, { type: actions.receivedDeploymentsForStatus, payload: { deploymentIds: ['a1'], total: 1, status } }).byStatus[status]).toEqual( + { deploymentIds: ['a1'], total: 1 } + ); + expect( + reducer(initialState, { type: actions.receivedDeploymentsForStatus, payload: { deploymentIds: ['a1'], total: 1, status } }).byStatus[status] + ).toEqual({ deploymentIds: ['a1'], total: 1 }); + }); + }); + it('should handle SELECT__DEPLOYMENTS', async () => { + Object.values(DEPLOYMENT_STATES).forEach(status => { + expect( + reducer(undefined, { type: actions.selectDeploymentsForStatus, payload: { deploymentIds: ['a1'], status } }).selectionState[status].selection + ).toEqual(['a1']); + expect( + reducer(initialState, { type: actions.selectDeploymentsForStatus, payload: { deploymentIds: ['a1'], status } }).selectionState[status].selection + ).toEqual(['a1']); + }); + }); + it('should handle SET_DEPLOYMENTS_STATE', async () => { + const newState = { something: 'new' }; + expect(reducer(undefined, { type: actions.setDeploymentsState, payload: newState }).selectionState).toEqual(newState); + expect(reducer(initialState, { type: actions.setDeploymentsState, payload: newState }).selectionState).toEqual(newState); + }); + it('should handle REMOVE_DEPLOYMENT', async () => { + let state = reducer(undefined, { type: actions.receivedDeployment, payload: defaultState.deployments.byId.d1 }); + expect(reducer(state, { type: actions.removedDeployment, payload: defaultState.deployments.byId.d1.id }).byId).toEqual({}); + expect(reducer(initialState, { type: actions.removedDeployment, payload: 'a1' }).byId).toEqual({}); + }); + it('should handle CREATE_DEPLOYMENT', async () => { + expect(reducer(undefined, { type: actions.createdDeployment, payload: { name: 'test', id: 'test' } }).byId.test.devices).toEqual({}); + expect(reducer(initialState, { type: actions.createdDeployment, payload: { name: 'test', id: 'a1' } }).byStatus.pending.deploymentIds).toContain('a1'); + }); + it('should handle SET_DEPLOYMENTS_CONFIG', async () => { + expect(reducer(undefined, { type: actions.setDeploymentsConfig, payload: { name: 'test' } }).config).toEqual({ name: 'test' }); + expect(reducer(initialState, { type: actions.setDeploymentsConfig, payload: { name: 'test' } }).config).toEqual({ name: 'test' }); + }); +}); diff --git a/frontend/src/js/store/deploymentsSlice/selectors.ts b/frontend/src/js/store/deploymentsSlice/selectors.ts new file mode 100644 index 00000000..85e54c1e --- /dev/null +++ b/frontend/src/js/store/deploymentsSlice/selectors.ts @@ -0,0 +1,58 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { createSelector } from '@reduxjs/toolkit'; + +import { DEPLOYMENT_STATES } from './constants'; + +export const getDeploymentsById = state => state.deployments.byId; +export const getDeploymentsByStatus = state => state.deployments.byStatus; +export const getSelectedDeploymentDeviceIds = state => state.deployments.selectedDeviceIds; +export const getDeploymentsSelectionState = state => state.deployments.selectionState; + +export const getMappedDeploymentSelection = createSelector( + [getDeploymentsSelectionState, (_, deploymentsState) => deploymentsState, getDeploymentsById], + (selectionState, deploymentsState, deploymentsById) => { + const { selection = [] } = selectionState[deploymentsState] ?? {}; + return selection.reduce((accu, id) => { + if (deploymentsById[id]) { + accu.push(deploymentsById[id]); + } + return accu; + }, []); + } +); + +const relevantDeploymentStates = [DEPLOYMENT_STATES.pending, DEPLOYMENT_STATES.inprogress, DEPLOYMENT_STATES.finished]; +export const DEPLOYMENT_CUTOFF = 3; +export const getRecentDeployments = createSelector([getDeploymentsById, getDeploymentsByStatus], (deploymentsById, deploymentsByStatus) => + Object.entries(deploymentsByStatus).reduce( + (accu, [state, byStatus]) => { + if (!relevantDeploymentStates.includes(state) || !byStatus.deploymentIds.length) { + return accu; + } + accu[state] = byStatus.deploymentIds + .reduce((accu, id) => { + if (deploymentsById[id]) { + accu.push(deploymentsById[id]); + } + return accu; + }, []) + .slice(0, DEPLOYMENT_CUTOFF); + accu.total += byStatus.total; + return accu; + }, + { total: 0 } + ) +); diff --git a/frontend/src/js/actions/deploymentActions.test.js b/frontend/src/js/store/deploymentsSlice/thunks.test.ts similarity index 52% rename from frontend/src/js/actions/deploymentActions.test.js rename to frontend/src/js/store/deploymentsSlice/thunks.test.ts index 88346fa8..2b86bb73 100644 --- a/frontend/src/js/actions/deploymentActions.test.js +++ b/frontend/src/js/store/deploymentsSlice/thunks.test.ts @@ -11,14 +11,17 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck +import { getGlobalSettings, saveGlobalSettings, setOfflineThreshold } from '@northern.tech/store/thunks'; import configureMockStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; -import { defaultState } from '../../../tests/mockData'; -import * as AppConstants from '../constants/appConstants'; -import * as DeploymentConstants from '../constants/deploymentConstants'; -import * as DeviceConstants from '../constants/deviceConstants'; -import * as UserConstants from '../constants/userConstants'; +import { actions } from '.'; +import { defaultState } from '../../../../tests/mockData'; +import { actions as appActions } from '../appSlice'; +import { actions as deviceActions } from '../devicesSlice'; +import { actions as userActions } from '../usersSlice'; +import * as DeploymentConstants from './constants'; import { abortDeployment, createDeployment, @@ -27,11 +30,12 @@ import { getDeploymentsConfig, getDeviceDeployments, getDeviceLog, + getSingleDeployment, resetDeviceDeployments, saveDeltaDeploymentsConfig, setDeploymentsState, updateDeploymentControlMap -} from './deploymentActions'; +} from './thunks'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -63,85 +67,75 @@ const deploymentsConfig = { const defaultResponseActions = { creation: { - type: DeploymentConstants.CREATE_DEPLOYMENT, - deployment: { devices: [{ id: Object.keys(defaultState.devices.byId)[0], status: 'pending' }], statistics: { status: {} } }, - deploymentId: createdDeployment.id + type: actions.createdDeployment.type, + isImportant: true, + payload: { id: createdDeployment.id, devices: [{ id: Object.keys(defaultState.devices.byId)[0], status: 'pending' }], statistics: { status: {} } } }, devices: { - type: DeploymentConstants.RECEIVE_DEPLOYMENT_DEVICES, - deploymentId: defaultState.deployments.byId.d1.id, - devices: defaultState.deployments.byId.d1.devices, - selectedDeviceIds: [defaultState.deployments.byId.d1.devices.a1.id], - totalDeviceCount: 1 - }, - log: { - type: DeploymentConstants.RECEIVE_DEPLOYMENT_DEVICE_LOG, - deployment: { - ...defaultState.deployments.byId.d1, - devices: { - ...defaultState.deployments.byId.d1.devices, - a1: { - ...defaultState.deployments.byId.d1.devices.a1, - log: 'test' - } - } + type: actions.receivedDeploymentDevices.type, + payload: { + id: defaultState.deployments.byId.d1.id, + devices: defaultState.deployments.byId.d1.devices, + selectedDeviceIds: [defaultState.deployments.byId.d1.devices.a1.id], + totalDeviceCount: 1 } }, - snackbar: { - type: AppConstants.SET_SNACKBAR, - snackbar: { - maxWidth: '900px', - message: 'Deployment created successfully', - open: true + log: { + type: actions.receivedDeploymentDeviceLog.type, + payload: { + id: defaultState.deployments.byId.d1.id, + deviceId: defaultState.deployments.byId.d1.devices.a1.id, + log: 'test' } }, - receive: { - type: DeploymentConstants.RECEIVE_DEPLOYMENT, - deployment: createdDeployment - }, - receiveMultiple: { type: DeploymentConstants.RECEIVE_DEPLOYMENTS, deployments: {} }, - receiveInprogress: { type: DeploymentConstants.RECEIVE_INPROGRESS_DEPLOYMENTS, deploymentIds: [], status: 'inprogress', total: 0 }, - remove: { type: DeploymentConstants.REMOVE_DEPLOYMENT, deploymentId: defaultState.deployments.byId.d1.id }, - select: { - type: DeploymentConstants.SELECT_DEPLOYMENT, - deploymentId: createdDeployment.id - }, + snackbar: { type: appActions.setSnackbar.type, payload: 'Deployment created successfully' }, + receive: { type: actions.receivedDeployment.type, payload: createdDeployment }, + receiveMultiple: { type: actions.receivedDeployments.type, payload: {} }, + receiveInprogress: { type: actions.receivedDeploymentsForStatus.type, payload: { deploymentIds: [], status: 'inprogress', total: 0 } }, + remove: { type: actions.removedDeployment.type, payload: defaultState.deployments.byId.d1.id }, selectMultiple: { - type: DeploymentConstants.SELECT_INPROGRESS_DEPLOYMENTS, - deploymentIds: Object.keys(defaultState.deployments.byId), - status: 'inprogress' + type: actions.selectDeploymentsForStatus.type, + payload: { deploymentIds: Object.keys(defaultState.deployments.byId), status: 'inprogress' } }, - setOfflineThreshold: { type: AppConstants.SET_OFFLINE_THRESHOLD, value: '2019-01-12T13:00:00.900Z' } + setOfflineThreshold: { type: appActions.setOfflineThreshold.type, payload: '2019-01-12T13:00:00.900Z' } }; // eslint-disable-next-line no-unused-vars const { id_attribute, ...retrievedSettings } = defaultState.users.globalSettings; +const assertionFunction = + storeActions => + ({ type, isImportant, payload }, index) => { + expect(storeActions[index].type).toEqual(type); + if (isImportant) { + expect(storeActions[index].payload).toEqual(payload); + } + }; + /* eslint-disable sonarjs/no-identical-functions */ describe('deployment actions', () => { it('should allow aborting deployments', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ + { type: abortDeployment.pending.type }, defaultResponseActions.receiveMultiple, defaultResponseActions.receiveInprogress, defaultResponseActions.remove, - { - ...defaultResponseActions.snackbar, - snackbar: { - ...defaultResponseActions.snackbar.snackbar, - message: 'The deployment was successfully aborted' - } - } + { ...defaultResponseActions.snackbar, payload: 'The deployment was successfully aborted' }, + { type: abortDeployment.fulfilled.type } ]; - return store.dispatch(abortDeployment(defaultState.deployments.byId.d1.id)).then(() => { - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); + return store + .dispatch(abortDeployment(defaultState.deployments.byId.d1.id)) + .unwrap() + .then(() => { + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); }); it(`should reject aborting deployments that don't exist`, () => { const store = mockStore({ ...defaultState }); - const abortedDeployment = store.dispatch(abortDeployment(`${defaultState.deployments.byId.d1.id}-invalid`)); + const abortedDeployment = store.dispatch(abortDeployment(`${defaultState.deployments.byId.d1.id}-invalid`)).unwrap(); expect(typeof abortedDeployment === Promise); expect(abortedDeployment).rejects.toBeTruthy(); }); @@ -160,84 +154,100 @@ describe('deployment actions', () => { } }); const expectedActions = [ + { type: createDeployment.pending.type }, defaultResponseActions.creation, - { - ...defaultResponseActions.snackbar, - snackbar: { - ...defaultResponseActions.snackbar.snackbar, - autoHideDuration: AppConstants.TIMEOUTS.fiveSeconds - } - }, + { type: getSingleDeployment.pending.type }, + defaultResponseActions.snackbar, + { type: saveGlobalSettings.pending.type }, + { type: getGlobalSettings.pending.type }, defaultResponseActions.receive, - { type: UserConstants.SET_GLOBAL_SETTINGS, settings: retrievedSettings }, + { type: getSingleDeployment.fulfilled.type }, + { type: userActions.setGlobalSettings.type }, + { type: setOfflineThreshold.pending.type }, defaultResponseActions.setOfflineThreshold, - { type: UserConstants.SET_GLOBAL_SETTINGS, settings: { ...defaultState.users.globalSettings, hasDeployments: true } } + { type: setOfflineThreshold.fulfilled.type }, + { type: getGlobalSettings.fulfilled.type }, + { type: userActions.setGlobalSettings.type }, + { type: saveGlobalSettings.fulfilled.type }, + { type: createDeployment.fulfilled.type } ]; - return store.dispatch(createDeployment({ devices: [Object.keys(defaultState.devices.byId)[0]] })).then(() => { + return store.dispatch(createDeployment({ newDeployment: { devices: [Object.keys(defaultState.devices.byId)[0]] } })).then(() => { const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + expectedActions.map(assertionFunction(storeActions)); }); }); it('should allow creating deployments with a filter', async () => { const store = mockStore({ ...defaultState }); const filter_id = '1234'; const expectedActions = [ - { ...defaultResponseActions.creation, deployment: { devices: [], filter_id, statistics: { status: {} } } }, - { - ...defaultResponseActions.snackbar, - snackbar: { - ...defaultResponseActions.snackbar.snackbar, - autoHideDuration: AppConstants.TIMEOUTS.fiveSeconds - } - }, + { type: createDeployment.pending.type }, + { ...defaultResponseActions.creation, payload: { ...defaultResponseActions.creation.payload, devices: [], filter_id, statistics: { status: {} } } }, + { type: getSingleDeployment.pending.type }, + defaultResponseActions.snackbar, + { type: saveGlobalSettings.pending.type }, + { type: getGlobalSettings.pending.type }, defaultResponseActions.receive, - { type: UserConstants.SET_GLOBAL_SETTINGS, settings: retrievedSettings }, + { type: getSingleDeployment.fulfilled.type }, + { type: userActions.setGlobalSettings.type }, + { type: setOfflineThreshold.pending.type }, defaultResponseActions.setOfflineThreshold, - { type: UserConstants.SET_GLOBAL_SETTINGS, settings: retrievedSettings } + { type: setOfflineThreshold.fulfilled.type }, + { type: getGlobalSettings.fulfilled.type }, + { type: userActions.setGlobalSettings.type }, + { type: saveGlobalSettings.fulfilled.type }, + { type: createDeployment.fulfilled.type } ]; - return store.dispatch(createDeployment({ filter_id })).then(() => { + return store.dispatch(createDeployment({ newDeployment: { filter_id } })).then(() => { const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + expectedActions.map(assertionFunction(storeActions)); }); }); it('should allow creating deployments with a group', async () => { const store = mockStore({ ...defaultState }); const group = Object.keys(defaultState.devices.groups.byId)[0]; const expectedActions = [ - { ...defaultResponseActions.creation, deployment: { devices: [], group, statistics: { status: {} } } }, - { - ...defaultResponseActions.snackbar, - snackbar: { - ...defaultResponseActions.snackbar.snackbar, - autoHideDuration: AppConstants.TIMEOUTS.fiveSeconds - } - }, + { type: createDeployment.pending.type }, + { ...defaultResponseActions.creation, payload: { ...defaultResponseActions.creation.payload, devices: [], group, statistics: { status: {} } } }, + { type: getSingleDeployment.pending.type }, + defaultResponseActions.snackbar, + { type: saveGlobalSettings.pending.type }, + { type: getGlobalSettings.pending.type }, defaultResponseActions.receive, - { type: UserConstants.SET_GLOBAL_SETTINGS, settings: retrievedSettings }, + { type: getSingleDeployment.fulfilled.type }, + { type: userActions.setGlobalSettings.type }, + { type: setOfflineThreshold.pending.type }, defaultResponseActions.setOfflineThreshold, - { type: UserConstants.SET_GLOBAL_SETTINGS, settings: retrievedSettings } + { type: setOfflineThreshold.fulfilled.type }, + { type: getGlobalSettings.fulfilled.type }, + { type: userActions.setGlobalSettings.type }, + { type: saveGlobalSettings.fulfilled.type }, + { type: createDeployment.fulfilled.type } ]; - return store.dispatch(createDeployment({ group })).then(() => { + return store.dispatch(createDeployment({ newDeployment: { group } })).then(() => { const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + expectedActions.map(assertionFunction(storeActions)); }); }); it('should allow deployments retrieval', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { ...defaultResponseActions.receiveMultiple, deployments: defaultState.deployments.byId }, + { type: getDeploymentsByStatus.pending.type }, + { ...defaultResponseActions.receiveMultiple, payload: defaultState.deployments.byId }, { ...defaultResponseActions.receiveInprogress, - deploymentIds: Object.keys(defaultState.deployments.byId), - total: defaultState.deployments.byStatus.inprogress.total + payload: { + deploymentIds: Object.keys(defaultState.deployments.byId), + total: defaultState.deployments.byStatus.inprogress.total + } }, - defaultResponseActions.selectMultiple + defaultResponseActions.selectMultiple, + { type: getDeploymentsByStatus.fulfilled.type } ]; return store - .dispatch(getDeploymentsByStatus('inprogress', null, null, undefined, undefined, Object.keys(defaultState.devices.groups.byId)[0], 'configuration', true)) + .dispatch(getDeploymentsByStatus({ status: 'inprogress', group: Object.keys(defaultState.devices.groups.byId)[0], type: 'configuration' })) .then(() => { const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -246,17 +256,19 @@ describe('deployment actions', () => { }); it('should allow deployment device log retrieval', async () => { const store = mockStore({ ...defaultState }); - const expectedActions = [defaultResponseActions.log]; - return store.dispatch(getDeviceLog(Object.keys(defaultState.deployments.byId)[0], defaultState.deployments.byId.d1.devices.a1.id)).then(() => { - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); + const expectedActions = [{ type: getDeviceLog.pending.type }, defaultResponseActions.log, { type: getDeviceLog.fulfilled.type }]; + return store + .dispatch(getDeviceLog({ deploymentId: Object.keys(defaultState.deployments.byId)[0], deviceId: defaultState.deployments.byId.d1.devices.a1.id })) + .then(() => { + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); }); it('should allow deployment device list retrieval', async () => { const store = mockStore({ ...defaultState }); - const expectedActions = [defaultResponseActions.devices]; - return store.dispatch(getDeploymentDevices(Object.keys(defaultState.deployments.byId)[0])).then(() => { + const expectedActions = [{ type: getDeploymentDevices.pending.type }, defaultResponseActions.devices, { type: getDeploymentDevices.fulfilled.type }]; + return store.dispatch(getDeploymentDevices({ id: Object.keys(defaultState.deployments.byId)[0] })).then(() => { const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -265,10 +277,11 @@ describe('deployment actions', () => { it('should allow device deployment history retrieval', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ + { type: getDeviceDeployments.pending.type }, { - type: DeviceConstants.RECEIVE_DEVICE, - device: { - ...defaultState.devices.byId.a1, + type: deviceActions.receivedDevice.type, + payload: { + id: defaultState.devices.byId.a1.id, deploymentsCount: 34, deviceDeployments: [ { @@ -281,9 +294,10 @@ describe('deployment actions', () => { } ] } - } + }, + { type: getDeviceDeployments.fulfilled.type } ]; - await store.dispatch(getDeviceDeployments(defaultState.devices.byId.a1.id)); + await store.dispatch(getDeviceDeployments({ deviceId: defaultState.devices.byId.a1.id })); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -291,10 +305,12 @@ describe('deployment actions', () => { it('should allow device deployment history deletion', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ + { type: resetDeviceDeployments.pending.type }, + { type: getDeviceDeployments.pending.type }, { - type: DeviceConstants.RECEIVE_DEVICE, - device: { - ...defaultState.devices.byId.a1, + type: deviceActions.receivedDevice.type, + payload: { + id: defaultState.devices.byId.a1.id, deploymentsCount: 34, deviceDeployments: [ { @@ -307,7 +323,9 @@ describe('deployment actions', () => { } ] } - } + }, + { type: getDeviceDeployments.fulfilled.type }, + { type: resetDeviceDeployments.fulfilled.type } ]; await store.dispatch(resetDeviceDeployments(defaultState.devices.byId.a1.id)); const storeActions = store.getActions(); @@ -316,8 +334,14 @@ describe('deployment actions', () => { }); it('should allow updating a deployment to continue the execution', async () => { const store = mockStore({ ...defaultState }); - const expectedActions = [defaultResponseActions.receive]; - return store.dispatch(updateDeploymentControlMap(createdDeployment.id, { something: 'continue' })).then(() => { + const expectedActions = [ + { type: updateDeploymentControlMap.pending.type }, + { type: getSingleDeployment.pending.type }, + defaultResponseActions.receive, + { type: getSingleDeployment.fulfilled.type }, + { type: updateDeploymentControlMap.fulfilled.type } + ]; + return store.dispatch(updateDeploymentControlMap({ deploymentId: createdDeployment.id, updateControlMap: { something: 'continue' } })).then(() => { const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -333,9 +357,10 @@ describe('deployment actions', () => { }) ); const expectedActions = [ + { type: setDeploymentsState.pending.type }, { - type: DeploymentConstants.SET_DEPLOYMENTS_STATE, - state: { + type: actions.setDeploymentsState.type, + payload: { ...defaultState.deployments.selectionState, finished: { ...defaultState.deployments.selectionState.finished, @@ -348,7 +373,10 @@ describe('deployment actions', () => { selectedId: createdDeployment.id } }, - defaultResponseActions.receive + { type: getSingleDeployment.pending.type }, + defaultResponseActions.receive, + { type: getSingleDeployment.fulfilled.type }, + { type: setDeploymentsState.fulfilled.type } ]; const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -357,7 +385,11 @@ describe('deployment actions', () => { it('should allow retrieving config for deployments', async () => { const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: DeploymentConstants.SET_DEPLOYMENTS_CONFIG, config: deploymentsConfig }]; + const expectedActions = [ + { type: getDeploymentsConfig.pending.type }, + { type: actions.setDeploymentsConfig.type, payload: deploymentsConfig }, + { type: getDeploymentsConfig.fulfilled.type } + ]; return store.dispatch(getDeploymentsConfig()).then(() => { const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -379,8 +411,10 @@ describe('deployment actions', () => { // eslint-disable-next-line no-unused-vars const { hasDelta, ...expectedConfig } = deploymentsConfig; const expectedActions = [ - { type: DeploymentConstants.SET_DEPLOYMENTS_CONFIG, config: { ...expectedConfig, binaryDelta: { ...expectedConfig.binaryDelta, ...changedConfig } } }, - { type: AppConstants.SET_SNACKBAR, snackbar: { maxWidth: '900px', message: 'Settings saved successfully', open: true } } + { type: saveDeltaDeploymentsConfig.pending.type }, + { type: actions.setDeploymentsConfig.type, payload: { ...expectedConfig, binaryDelta: { ...expectedConfig.binaryDelta, ...changedConfig } } }, + { ...defaultResponseActions.setSnackbar, payload: 'Settings saved successfully' }, + { type: saveDeltaDeploymentsConfig.fulfilled.type } ]; return store.dispatch(saveDeltaDeploymentsConfig(changedConfig)).then(() => { const storeActions = store.getActions(); diff --git a/frontend/src/js/store/deploymentsSlice/thunks.ts b/frontend/src/js/store/deploymentsSlice/thunks.ts new file mode 100644 index 00000000..38c22e95 --- /dev/null +++ b/frontend/src/js/store/deploymentsSlice/thunks.ts @@ -0,0 +1,410 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck + +/*eslint import/namespace: ['error', { allowComputed: true }]*/ +import storeActions from '@northern.tech/store/actions'; +import GeneralApi from '@northern.tech/store/api/general-api'; +import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS, TIMEOUTS, headerNames } from '@northern.tech/store/constants'; +import { getDevicesById, getGlobalSettings, getOrganization, getUserCapabilities } from '@northern.tech/store/selectors'; +import { commonErrorHandler } from '@northern.tech/store/store'; +import { getDeviceAuth, getDeviceById, saveGlobalSettings } from '@northern.tech/store/thunks'; +import { mapTermsToFilters } from '@northern.tech/store/utils'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import isUUID from 'validator/lib/isUUID'; + +import { actions, sliceName } from '.'; +import { deepCompare, isEmpty, standardizePhases, startTimeSort } from '../../helpers'; +import Tracking from '../../tracking'; +import { DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, DEPLOYMENT_TYPES, deploymentPrototype, deploymentsApiUrl, deploymentsApiUrlV2 } from './constants'; +import { getDeploymentsById, getDeploymentsByStatus as getDeploymentsByStatusSelector } from './selectors'; + +const { receivedDevice, setSnackbar } = storeActions; + +// default per page until pagination and counting integrated +const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS; + +export const deriveDeploymentGroup = ({ filter = {}, group, groups = [], name }) => (group || (groups.length === 1 && !isUUID(name)) ? groups[0] : filter.name); + +const transformDeployments = (deployments, deploymentsById) => + deployments.sort(startTimeSort).reduce( + (accu, item) => { + const filter = item.filter ?? {}; + let deployment = { + ...deploymentPrototype, + ...deploymentsById[item.id], + ...item, + filter: item.filter ? { ...filter, name: filter.name ?? filter.id, filters: mapTermsToFilters(filter.terms) } : undefined, + name: decodeURIComponent(item.name) + }; + // deriving the group in a second step to potentially make use of the merged data from the existing group state + the decoded name + deployment = { ...deployment, group: deriveDeploymentGroup(deployment) }; + accu.deployments[item.id] = deployment; + accu.deploymentIds.push(item.id); + return accu; + }, + { deployments: {}, deploymentIds: [] } + ); + +/*Deployments */ +export const getDeploymentsByStatus = createAsyncThunk(`${sliceName}/getDeploymentsByStatus`, (options = {}, { dispatch, getState }) => { + const { status, page = defaultPage, per_page = defaultPerPage, startDate, endDate, group, type, shouldSelect = true, sort = SORTING_OPTIONS.desc } = options; + const created_after = startDate ? `&created_after=${startDate}` : ''; + const created_before = endDate ? `&created_before=${endDate}` : ''; + const search = group ? `&search=${group}` : ''; + const typeFilter = type ? `&type=${type}` : ''; + return GeneralApi.get( + `${deploymentsApiUrl}/deployments?status=${status}&per_page=${per_page}&page=${page}${created_after}${created_before}${search}${typeFilter}&sort=${sort}` + ).then(res => { + const { deployments, deploymentIds } = transformDeployments(res.data, getState().deployments.byId); + const total = Number(res.headers[headerNames.total]); + let tasks = [ + dispatch(actions.receivedDeployments(deployments)), + dispatch( + actions.receivedDeploymentsForStatus({ + deploymentIds, + status, + total: !(startDate || endDate || group || type) ? total : getState().deployments.byStatus[status].total + }) + ) + ]; + tasks = deploymentIds.reduce((accu, deploymentId) => { + if (deployments[deploymentId].type === DEPLOYMENT_TYPES.configuration) { + accu.push(dispatch(getSingleDeployment(deploymentId))); + } + return accu; + }, tasks); + if (shouldSelect) { + tasks.push(dispatch(actions.selectDeploymentsForStatus({ deploymentIds, status, total }))); + } + tasks.push({ deploymentIds, total }); + return Promise.all(tasks); + }); +}); + +const isWithinFirstMonth = expirationDate => { + if (!expirationDate) { + return false; + } + const endOfFirstMonth = new Date(expirationDate); + endOfFirstMonth.setMonth(endOfFirstMonth.getMonth() - 11); + return endOfFirstMonth > new Date(); +}; + +const trackDeploymentCreation = (totalDeploymentCount, hasDeployments, trial_expiration) => { + Tracking.event({ category: 'deployments', action: 'create' }); + if (!totalDeploymentCount) { + if (!hasDeployments) { + Tracking.event({ category: 'deployments', action: 'create_initial_deployment' }); + if (isWithinFirstMonth(trial_expiration)) { + Tracking.event({ category: 'deployments', action: 'create_initial_deployment_first_month' }); + } + } + Tracking.event({ category: 'deployments', action: 'create_initial_deployment_user' }); + } +}; + +const MAX_PREVIOUS_PHASES_COUNT = 5; +export const createDeployment = createAsyncThunk(`${sliceName}/createDeployment`, ({ newDeployment, hasNewRetryDefault = false }, { dispatch, getState }) => { + let request; + if (newDeployment.filter_id) { + request = GeneralApi.post(`${deploymentsApiUrlV2}/deployments`, newDeployment); + } else if (newDeployment.group) { + request = GeneralApi.post(`${deploymentsApiUrl}/deployments/group/${newDeployment.group}`, newDeployment); + } else { + request = GeneralApi.post(`${deploymentsApiUrl}/deployments`, newDeployment); + } + const totalDeploymentCount = Object.values(getDeploymentsByStatusSelector(getState())).reduce((accu, item) => accu + item.total, 0); + const { hasDeployments } = getGlobalSettings(getState()); + const { trial_expiration } = getOrganization(getState()); + return request + .catch(err => commonErrorHandler(err, 'Error creating deployment.', dispatch)) + .then(data => { + const lastslashindex = data.headers.location.lastIndexOf('/'); + const deploymentId = data.headers.location.substring(lastslashindex + 1); + const deployment = { + ...newDeployment, + id: deploymentId, + devices: newDeployment.devices ? newDeployment.devices.map(id => ({ id, status: 'pending' })) : [], + statistics: { status: {} } + }; + let tasks = [ + dispatch(actions.createdDeployment(deployment)), + dispatch(getSingleDeployment(deploymentId)), + dispatch(setSnackbar('Deployment created successfully', TIMEOUTS.fiveSeconds)) + ]; + // track in GA + trackDeploymentCreation(totalDeploymentCount, hasDeployments, trial_expiration); + const { canManageUsers } = getUserCapabilities(getState()); + if (canManageUsers) { + const { phases, retries } = newDeployment; + const { previousPhases = [], retries: previousRetries = 0 } = getGlobalSettings(getState()); + let newSettings = { retries: hasNewRetryDefault ? retries : previousRetries, hasDeployments: true }; + if (phases) { + const standardPhases = standardizePhases(phases); + let prevPhases = previousPhases.map(standardizePhases); + if (!prevPhases.find(previousPhaseList => previousPhaseList.every(oldPhase => standardPhases.find(phase => deepCompare(phase, oldPhase))))) { + prevPhases.push(standardPhases); + } + newSettings.previousPhases = prevPhases.slice(-1 * MAX_PREVIOUS_PHASES_COUNT); + } + tasks.push(dispatch(saveGlobalSettings(newSettings))); + } + return Promise.all(tasks); + }); +}); + +export const getDeploymentDevices = createAsyncThunk(`${sliceName}/getDeploymentDevices`, (options, { dispatch, getState }) => { + const { id, page = defaultPage, perPage = defaultPerPage } = options; + return GeneralApi.get(`${deploymentsApiUrl}/deployments/${id}/devices/list?&page=${page}&per_page=${perPage}`).then(response => { + const { devices: deploymentDevices = {} } = getState().deployments.byId[id] || {}; + const devices = response.data.reduce((accu, item) => { + accu[item.id] = item; + const log = (deploymentDevices[item.id] || {}).log; + if (log) { + accu[item.id].log = log; + } + return accu; + }, {}); + const selectedDeviceIds = Object.keys(devices); + let tasks = [ + dispatch( + actions.receivedDeploymentDevices({ + id, + devices, + selectedDeviceIds, + totalDeviceCount: Number(response.headers[headerNames.total]) + }) + ) + ]; + const devicesById = getDevicesById(getState()); + // only update those that have changed & lack data + const lackingData = selectedDeviceIds.reduce((accu, deviceId) => { + const device = devicesById[deviceId]; + if (!device || !device.identity_data || !device.attributes || Object.keys(device.attributes).length === 0) { + accu.push(deviceId); + } + return accu; + }, []); + // get device artifact, inventory and identity details not listed in schedule data + tasks = lackingData.reduce((accu, deviceId) => [...accu, dispatch(getDeviceById(deviceId)), dispatch(getDeviceAuth(deviceId))], tasks); + return Promise.all(tasks); + }); +}); + +const parseDeviceDeployment = ({ + deployment: { id, artifact_name: release, status: deploymentStatus }, + device: { created, deleted, id: deviceId, finished, status, log } +}) => ({ + id, + release, + created, + deleted, + deviceId, + finished, + status, + log, + route: Object.values(DEPLOYMENT_ROUTES).reduce((accu, { key, states }) => { + if (!accu) { + return states.includes(deploymentStatus) ? key : accu; + } + return accu; + }, '') +}); + +export const getDeviceDeployments = createAsyncThunk(`${sliceName}/getDeviceDeployments`, (options, { dispatch }) => { + const { deviceId, filterSelection = [], page = defaultPage, perPage = defaultPerPage } = options; + const filters = filterSelection.map(item => `&status=${item}`).join(''); + return GeneralApi.get(`${deploymentsApiUrl}/deployments/devices/${deviceId}?page=${page}&per_page=${perPage}${filters}`) + .then(({ data, headers }) => + Promise.resolve( + dispatch( + receivedDevice({ + id: deviceId, + deviceDeployments: data.map(parseDeviceDeployment), + deploymentsCount: Number(headers[headerNames.total]) + }) + ) + ) + ) + .catch(err => commonErrorHandler(err, 'There was an error retrieving the device deployment history:', dispatch)); +}); + +export const resetDeviceDeployments = createAsyncThunk(`${sliceName}/resetDeviceDeployments`, (deviceId, { dispatch }) => + GeneralApi.delete(`${deploymentsApiUrl}/deployments/devices/${deviceId}/history`) + .then(() => Promise.resolve(dispatch(getDeviceDeployments({ deviceId })))) + .catch(err => commonErrorHandler(err, 'There was an error resetting the device deployment history:', dispatch)) +); + +export const getSingleDeployment = createAsyncThunk(`${sliceName}/getSingleDeployment`, (id, { dispatch, getState }) => + GeneralApi.get(`${deploymentsApiUrl}/deployments/${id}`).then(({ data }) => { + const { deployments } = transformDeployments([data], getState().deployments.byId); + return Promise.resolve(dispatch(actions.receivedDeployment(deployments[id]))); + }) +); + +export const getDeviceLog = createAsyncThunk(`${sliceName}/getDeviceLog`, ({ deploymentId, deviceId }, { dispatch }) => + GeneralApi.get(`${deploymentsApiUrl}/deployments/${deploymentId}/devices/${deviceId}/log`) + .catch(e => { + console.log('no log here', e); + return Promise.reject(); + }) + .then(({ data: log }) => + Promise.all([Promise.resolve(dispatch(actions.receivedDeploymentDeviceLog({ id: deploymentId, deviceId, log }))), Promise.resolve(log)]) + ) +); + +export const abortDeployment = createAsyncThunk(`${sliceName}/abortDeployment`, (deploymentId, { dispatch, getState }) => + GeneralApi.put(`${deploymentsApiUrl}/deployments/${deploymentId}/status`, { status: 'aborted' }) + .then(() => { + const deploymentsByStatus = getDeploymentsByStatusSelector(getState()); + let status = DEPLOYMENT_STATES.pending; + let index = deploymentsByStatus.pending.deploymentIds.findIndex(id => id === deploymentId); + if (index < 0) { + status = DEPLOYMENT_STATES.inprogress; + index = deploymentsByStatus.inprogress.deploymentIds.findIndex(id => id === deploymentId); + } + const deploymentIds = [...deploymentsByStatus[status].deploymentIds.slice(0, index), ...deploymentsByStatus[status].deploymentIds.slice(index + 1)]; + const deploymentsById = getDeploymentsById(getState()); + const deployments = deploymentIds.reduce((accu, id) => { + accu[id] = deploymentsById[id]; + return accu; + }, {}); + const total = Math.max(deploymentsByStatus[status].total - 1, 0); + return Promise.all([ + dispatch(actions.receivedDeployments(deployments)), + dispatch(actions.receivedDeploymentsForStatus({ deploymentIds, status, total })), + dispatch(actions.removedDeployment(deploymentId)), + dispatch(setSnackbar('The deployment was successfully aborted')) + ]); + }) + .catch(err => commonErrorHandler(err, 'There was an error while aborting the deployment:', dispatch)) +); + +export const updateDeploymentControlMap = createAsyncThunk(`${sliceName}/updateDeploymentControlMap`, ({ deploymentId, updateControlMap }, { dispatch }) => + GeneralApi.patch(`${deploymentsApiUrl}/deployments/${deploymentId}`, { update_control_map: updateControlMap }) + .catch(err => commonErrorHandler(err, 'There was an error while updating the deployment status:', dispatch)) + .then(() => Promise.resolve(dispatch(getSingleDeployment(deploymentId)))) +); + +export const setDeploymentsState = createAsyncThunk(`${sliceName}/setDeploymentsState`, (selection, { dispatch, getState }) => { + // eslint-disable-next-line no-unused-vars + const { page, perPage, ...selectionState } = selection; + const currentState = getState().deployments.selectionState; + let nextState = { + ...currentState, + ...selectionState, + ...Object.keys(DEPLOYMENT_STATES).reduce((accu, item) => { + accu[item] = { + ...currentState[item], + ...selectionState[item] + }; + return accu; + }, {}), + general: { + ...currentState.general, + ...selectionState.general + } + }; + let tasks = [dispatch(actions.setDeploymentsState(nextState))]; + if (nextState.selectedId && currentState.selectedId !== nextState.selectedId) { + tasks.push(dispatch(getSingleDeployment(nextState.selectedId))); + } + return Promise.all(tasks); +}); + +const deltaAttributeMappings = [ + { here: 'compressionLevel', there: 'compression_level' }, + { here: 'disableChecksum', there: 'disable_checksum' }, + { here: 'disableDecompression', there: 'disable_external_decompression' }, + { here: 'sourceWindow', there: 'source_window_size' }, + { here: 'inputWindow', there: 'input_window_size' }, + { here: 'duplicatesWindow', there: 'compression_duplicates_window' }, + { here: 'instructionBuffer', there: 'instruction_buffer_size' } +]; + +const mapExternalDeltaConfig = (config = {}) => + deltaAttributeMappings.reduce((accu, { here, there }) => { + if (config[there] !== undefined) { + accu[here] = config[there]; + } + return accu; + }, {}); + +export const getDeploymentsConfig = createAsyncThunk(`${sliceName}/getDeploymentsConfig`, (_, { dispatch, getState }) => + GeneralApi.get(`${deploymentsApiUrl}/config`).then(({ data }) => { + const oldConfig = getState().deployments.config; + const { delta = {} } = data; + const { binary_delta = {}, binary_delta_limits = {} } = delta; + const { xdelta_args = {}, timeout: timeoutConfig = oldConfig.binaryDelta.timeout } = binary_delta; + const { xdelta_args_limits = {}, timeout: timeoutLimit = oldConfig.binaryDeltaLimits.timeout } = binary_delta_limits; + const config = { + ...oldConfig, + hasDelta: Boolean(delta.enabled), + binaryDelta: { + ...oldConfig.binaryDelta, + timeout: timeoutConfig, + ...mapExternalDeltaConfig(xdelta_args) + }, + binaryDeltaLimits: { + ...oldConfig.binaryDeltaLimits, + timeout: timeoutLimit, + ...mapExternalDeltaConfig(xdelta_args_limits) + } + }; + return Promise.resolve(dispatch(actions.setDeploymentsConfig(config))); + }) +); + +// traverse a source object and remove undefined & empty object properties to only return an attribute if there really is content worth sending +const deepClean = source => + Object.entries(source).reduce((accu, [key, value]) => { + if (value !== undefined) { + let cleanedValue = typeof value === 'object' ? deepClean(value) : value; + if (cleanedValue === undefined || (typeof cleanedValue === 'object' && isEmpty(cleanedValue))) { + return accu; + } + accu = { ...(accu ?? {}), [key]: cleanedValue }; + } + return accu; + }, undefined); + +export const saveDeltaDeploymentsConfig = createAsyncThunk(`${sliceName}/saveDeltaDeploymentsConfig`, (config, { dispatch, getState }) => { + const configChange = { + timeout: config.timeout, + xdelta_args: deltaAttributeMappings.reduce((accu, { here, there }) => { + if (config[here] !== undefined) { + accu[there] = config[here]; + } + return accu; + }, {}) + }; + const result = deepClean(configChange); + if (!result) { + return Promise.resolve(); + } + return GeneralApi.put(`${deploymentsApiUrl}/config/binary_delta`, result) + .catch(err => commonErrorHandler(err, 'There was a problem storing your delta artifact generation configuration.', dispatch)) + .then(() => { + const oldConfig = getState().deployments.config; + const newConfig = { + ...oldConfig, + binaryDelta: { + ...oldConfig.binaryDelta, + ...config + } + }; + return Promise.all([dispatch(actions.setDeploymentsConfig(newConfig)), dispatch(setSnackbar('Settings saved successfully'))]); + }); +}); diff --git a/frontend/src/js/store/devicesSlice/constants.ts b/frontend/src/js/store/devicesSlice/constants.ts new file mode 100644 index 00000000..217846ef --- /dev/null +++ b/frontend/src/js/store/devicesSlice/constants.ts @@ -0,0 +1,56 @@ +// Copyright 2019 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { DEVICE_FILTERING_OPTIONS, apiUrl } from '@northern.tech/store/constants'; + +export const emptyFilter = { key: null, value: '', operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'inventory' }; + +export const deviceAuthV2 = `${apiUrl.v2}/devauth`; +export const deviceConnect = `${apiUrl.v1}/deviceconnect`; +export const inventoryApiUrl = `${apiUrl.v1}/inventory`; +export const inventoryApiUrlV2 = `${apiUrl.v2}/inventory`; +export const deviceConfig = `${apiUrl.v1}/deviceconfig/configurations/device`; +export const reportingApiUrl = `${apiUrl.v1}/reporting`; +export const iotManagerBaseURL = `${apiUrl.v1}/iot-manager`; + +// see https://github.com/mendersoftware/go-lib-micro/tree/master/ws +// for the description of proto_header and the consts +// *Note*: this needs to be aligned with mender-connect and deviceconnect. +export const DEVICE_MESSAGE_PROTOCOLS = { + Shell: 1 +}; +export const DEVICE_MESSAGE_TYPES = { + Delay: 'delay', + New: 'new', + Ping: 'ping', + Pong: 'pong', + Resize: 'resize', + Shell: 'shell', + Stop: 'stop' +}; + +// we can't include the dismiss state with the rest since this would include dismissed devices in several queries +export const DEVICE_DISMISSAL_STATE = 'dismiss'; +export const DEVICE_STATES = { + accepted: 'accepted', + pending: 'pending', + preauth: 'preauthorized', + rejected: 'rejected' +}; +export const DEVICE_CONNECT_STATES = { + connected: 'connected', + disconnected: 'disconnected', + unknown: 'unknown' +}; + +export const geoAttributes = ['geo-lat', 'geo-lon'].map(attribute => ({ attribute, scope: 'inventory' })); diff --git a/frontend/src/js/store/devicesSlice/index.ts b/frontend/src/js/store/devicesSlice/index.ts new file mode 100644 index 00000000..5bd1ed08 --- /dev/null +++ b/frontend/src/js/store/devicesSlice/index.ts @@ -0,0 +1,223 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS } from '@northern.tech/store/commonConstants'; +import { createSlice } from '@reduxjs/toolkit'; + +import { deepCompare, duplicateFilter } from '../../helpers'; +import { DEVICE_STATES } from './constants'; + +export const sliceName = 'devices'; + +export const initialState = { + byId: { + // [deviceId]: { + // ..., + // twinsByIntegration: { [external.provider.id]: twinData } + // } + }, + byStatus: { + [DEVICE_STATES.accepted]: { deviceIds: [], total: 0 }, + active: { deviceIds: [], total: 0 }, + inactive: { deviceIds: [], total: 0 }, + [DEVICE_STATES.pending]: { deviceIds: [], total: 0 }, + [DEVICE_STATES.preauth]: { deviceIds: [], total: 0 }, + [DEVICE_STATES.rejected]: { deviceIds: [], total: 0 } + }, + deviceList: { + deviceIds: [], + ...DEVICE_LIST_DEFAULTS, + selectedAttributes: [], + selectedIssues: [], + selection: [], + sort: { + direction: SORTING_OPTIONS.desc + // key: null, + // scope: null + }, + state: DEVICE_STATES.accepted, + total: 0 + }, + filters: [ + // { key: 'device_type', value: 'raspberry', operator: '$eq', scope: 'inventory' } + ], + filteringAttributes: { identityAttributes: [], inventoryAttributes: [], systemAttributes: [], tagAttributes: [] }, + filteringAttributesLimit: 10, + filteringAttributesConfig: { + attributes: { + // inventory: ['some_attribute'] + }, + count: 0, + limit: 100 + }, + reports: [ + // { items: [{ key: "someKey", count: 42 }], otherCount: 123, total: } + ], + total: 0, + limit: 0, + groups: { + byId: { + // groupName: { deviceIds: [], total: 0, filters: [] }, + // dynamo: { deviceIds: [], total: 3, filters: [{ a: 1 }] } + }, + selectedGroup: undefined + } +}; + +export const devicesSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + receivedGroups: (state, action) => { + state.groups.byId = action.payload; + }, + addToGroup: (state, action) => { + const { group, deviceIds } = action.payload; + const maybeExistingGroup = { + filters: [], + deviceIds: [], + ...state.groups.byId[group] + }; + state.groups.byId[group] = { + ...maybeExistingGroup, + deviceIds: [...maybeExistingGroup.deviceIds, ...deviceIds].filter(duplicateFilter), + total: (maybeExistingGroup.total || 0) + 1 + }; + }, + removeFromGroup: (state, action) => { + const { group, deviceIds: removedIds } = action.payload; + const { deviceIds = [], total = 0, ...maybeExistingGroup } = state.groups.byId[group] || {}; + const changedGroup = { + ...maybeExistingGroup, + deviceIds: deviceIds.filter(id => !removedIds.includes(id)), + total: Math.max(total - removedIds.length, 0) + }; + if (changedGroup.total || changedGroup.deviceIds.length) { + state.groups.byId[group] = changedGroup; + return; + } else if (state.groups.selectedGroup === group) { + state.groups.selectedGroup = undefined; + } + // eslint-disable-next-line no-unused-vars + const { [group]: removal, ...remainingById } = state.groups.byId; + state.groups.byId = remainingById; + }, + addGroup: (state, action) => { + const { groupName, group } = action.payload; + state.groups.byId[groupName] = { + ...state.groups.byId[groupName], + ...group + }; + }, + selectGroup: (state, { payload: group }) => { + state.deviceList.deviceIds = state.groups.byId[group] && state.groups.byId[group].deviceIds.length > 0 ? state.groups.byId[group].deviceIds : []; + state.groups.selectedGroup = group; + }, + removeGroup: (state, action) => { + // eslint-disable-next-line no-unused-vars + const { [action.payload]: removal, ...remainingById } = state.groups.byId; + state.groups.byId = remainingById; + state.groups.selectedGroup = state.groups.selectedGroup === action.payload ? undefined : state.groups.selectedGroup; + }, + setDeviceListState: (state, action) => { + state.deviceList = { + ...state.deviceList, + ...action.payload, + sort: { + ...state.deviceList.sort, + ...action.payload.sort + } + }; + }, + setFilterAttributes: (state, action) => { + state.filteringAttributes = action.payload; + }, + setFilterablesConfig: (state, action) => { + state.filteringAttributesConfig = action.payload; + }, + receivedDevices: (state, action) => { + state.byId = { + ...state.byId, + ...action.payload + }; + }, + setDeviceFilters: (state, action) => { + if (deepCompare(action.payload, state.filters)) { + return; + } + state.filters = action.payload.filter(filter => filter.key && filter.operator && filter.scope && typeof filter.value !== 'undefined'); + }, + setInactiveDevices: (state, action) => { + const { activeDeviceTotal, inactiveDeviceTotal } = action.payload; + state.byStatus.active.total = activeDeviceTotal; + state.byStatus.inactive.total = inactiveDeviceTotal; + }, + setDeviceReports: (state, action) => { + state.reports = action.payload; + }, + setDevicesByStatus: (state, action) => { + const { forceUpdate, status, total, deviceIds } = action.payload; + state.byStatus[status] = total || forceUpdate ? { deviceIds, total } : state.byStatus[status]; + }, + setDevicesCountByStatus: (state, action) => { + const { count, status } = action.payload; + state.byStatus[status].total = count; + }, + setTotalDevices: (state, action) => { + state.total = action.payload; + }, + setDeviceLimit: (state, action) => { + state.limit = action.payload; + }, + receivedDevice: (state, action) => { + state.byId[action.payload.id] = { + ...state.byId[action.payload.id], + ...action.payload + }; + }, + maybeUpdateDevicesByStatus: (state, action) => { + const { deviceId, authId } = action.payload; + const device = state.byId[deviceId]; + const hasMultipleAuthSets = authId ? device.auth_sets.filter(authset => authset.id !== authId).length > 0 : false; + if (!hasMultipleAuthSets && Object.values(DEVICE_STATES).includes(device.status)) { + const deviceIds = state.byStatus[device.status].deviceIds.filter(id => id !== deviceId); + state.byStatus[device.status] = { deviceIds, total: Math.max(0, state.byStatus[device.status].total - 1) }; + } + } + } + // extraReducers: { + // [setDeviceListState.fulfilled]: (state, action) => { + // console.log('action', action); + // console.log('action', action); + // console.log('action', action); + // console.log('action', action); + // state.deviceList = { + // ...state.deviceList, + // ...action.payload, + // sort: { + // ...state.deviceList.sort, + // ...action.payload.sort + // } + // }; + // } + // } + // The following would likely be the way to go with a simpler store setup, but ours seems too entangled for this to provide much benefit + // import * as actionCreators from './thunks'; + // extraReducers: { + // [actionCreators.setDeviceListState.fulfilled]: setDeviceListState + // } +}); + +export const actions = devicesSlice.actions; +export default devicesSlice.reducer; diff --git a/frontend/src/js/store/devicesSlice/reducer.test.ts b/frontend/src/js/store/devicesSlice/reducer.test.ts new file mode 100644 index 00000000..d4f38d15 --- /dev/null +++ b/frontend/src/js/store/devicesSlice/reducer.test.ts @@ -0,0 +1,194 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck + +/*eslint import/namespace: ['error', { allowComputed: true }]*/ +import reducer, { actions, initialState } from '.'; +import { defaultState } from '../../../../tests/mockData'; +import { DEVICE_STATES } from './constants'; + +describe('device reducer', () => { + it('should return the initial state', async () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + + it('should handle RECEIVE_GROUPS', async () => { + expect(reducer(undefined, { type: actions.receivedGroups, payload: defaultState.devices.groups.byId }).groups.byId).toEqual( + defaultState.devices.groups.byId + ); + expect(reducer(initialState, { type: actions.receivedGroups, payload: defaultState.devices.groups.byId }).groups.byId).toEqual( + defaultState.devices.groups.byId + ); + expect( + reducer(initialState, { type: actions.receivedGroups, payload: { testExtra: { deviceIds: [], total: 0, filters: [] } } }).groups.byId.testExtra + ).toEqual({ + deviceIds: [], + total: 0, + filters: [] + }); + }); + it('should handle RECEIVE_GROUP_DEVICES', async () => { + expect( + reducer(undefined, { + type: actions.addGroup, + payload: { + groupName: 'testGroupDynamic', + group: defaultState.devices.groups.byId.testGroupDynamic + } + }).groups.byId.testGroupDynamic + ).toEqual(defaultState.devices.groups.byId.testGroupDynamic); + expect( + reducer(initialState, { + type: actions.addGroup, + payload: { + groupName: 'testGroupDynamic', + group: defaultState.devices.groups.byId.testGroupDynamic + } + }).groups.byId.testGroupDynamic + ).toEqual(defaultState.devices.groups.byId.testGroupDynamic); + }); + it('should handle RECEIVE_DYNAMIC_GROUPS', async () => { + expect(reducer(undefined, { type: actions.receivedGroups, payload: defaultState.devices.groups.byId }).groups.byId).toEqual( + defaultState.devices.groups.byId + ); + expect(reducer(initialState, { type: actions.receivedGroups, payload: defaultState.devices.groups.byId }).groups.byId).toEqual( + defaultState.devices.groups.byId + ); + expect( + reducer(initialState, { type: actions.receivedGroups, payload: { testExtra: { deviceIds: [], total: 0, filters: [] } } }).groups.byId.testExtra + ).toEqual({ deviceIds: [], total: 0, filters: [] }); + }); + it('should handle ADD_TO_GROUP', async () => { + let state = reducer(undefined, { type: actions.receivedGroups, payload: defaultState.devices.groups.byId }); + expect(reducer(state, { type: actions.addToGroup, payload: { group: 'testExtra', deviceIds: ['d1'] } }).groups.byId.testExtra.deviceIds).toHaveLength(1); + expect( + reducer(initialState, { type: actions.addToGroup, payload: { group: 'testGroup', deviceIds: ['123', '1243'] } }).groups.byId.testGroup.deviceIds + ).toHaveLength(2); + }); + it('should handle REMOVE_FROM_GROUP', async () => { + let state = reducer(undefined, { type: actions.receivedGroups, payload: defaultState.devices.groups.byId }); + state = reducer(state, { type: actions.selectGroup, payload: 'testGroup' }); + expect( + reducer(state, { type: actions.removeFromGroup, payload: { group: 'testGroup', deviceIds: [defaultState.devices.groups.byId.testGroup.deviceIds[0]] } }) + .groups.byId.testGroup.deviceIds + ).toHaveLength(defaultState.devices.groups.byId.testGroup.deviceIds.length - 1); + expect( + reducer(state, { type: actions.removeFromGroup, payload: { group: 'testGroup', deviceIds: defaultState.devices.groups.byId.testGroup.deviceIds } }).groups + .byId.testGroup + ).toBeFalsy(); + expect( + reducer(initialState, { type: actions.removeFromGroup, payload: { group: 'testExtra', deviceIds: ['123', '1243'] } }).groups.byId.testExtra + ).toBeFalsy(); + }); + it('should handle ADD_DYNAMIC_GROUP', async () => { + expect( + reducer(undefined, { type: actions.addGroup, payload: { groupName: 'test', group: { something: 'test' } } }).groups.byId.test.something + ).toBeTruthy(); + expect( + reducer(initialState, { type: actions.addGroup, payload: { groupName: 'test', group: { something: 'test' } } }).groups.byId.test.something + ).toBeTruthy(); + }); + it('should handle ADD_STATIC_GROUP', async () => { + expect( + reducer(undefined, { type: actions.addGroup, payload: { groupName: 'test', group: { something: 'test' } } }).groups.byId.test.something + ).toBeTruthy(); + expect( + reducer(initialState, { type: actions.addGroup, payload: { groupName: 'test', group: { something: 'test' } } }).groups.byId.test.something + ).toBeTruthy(); + }); + + it('should handle REMOVE_DYNAMIC_GROUP', async () => { + let state = reducer(undefined, { type: actions.receivedGroups, payload: defaultState.devices.groups.byId }); + expect(Object.keys(reducer(state, { type: actions.removeGroup, payload: 'testGroupDynamic' }).groups.byId)).toHaveLength( + Object.keys(defaultState.devices.groups.byId).length - 1 + ); + expect(Object.keys(reducer(initialState, { type: actions.removeGroup, payload: 'testGroupDynamic' }).groups.byId)).toHaveLength(0); + }); + it('should handle REMOVE_STATIC_GROUP', async () => { + let state = reducer(undefined, { type: actions.receivedGroups, payload: defaultState.devices.groups.byId }); + expect(Object.keys(reducer(state, { type: actions.removeGroup, payload: 'testGroup' }).groups.byId)).toHaveLength( + Object.keys(defaultState.devices.groups.byId).length - 1 + ); + expect(Object.keys(reducer(initialState, { type: actions.removeGroup, payload: 'testGroup' }).groups.byId)).toHaveLength(0); + }); + it('should handle SET_DEVICE_LIST_STATE', async () => { + expect(reducer(undefined, { type: actions.setDeviceListState, payload: { deviceIds: ['test'] } }).deviceList.deviceIds).toEqual(['test']); + expect(reducer(initialState, { type: actions.setDeviceListState, payload: { deviceIds: ['test'] } }).deviceList.deviceIds).toEqual(['test']); + }); + it('should handle SET_DEVICE_FILTERS', async () => { + expect(reducer(undefined, { type: actions.setDeviceFilters, payload: defaultState.devices.groups.byId.testGroupDynamic.filters }).filters).toHaveLength(1); + expect(reducer(initialState, { type: actions.setDeviceFilters, payload: [{ key: 'test', operator: 'test' }] }).filters).toHaveLength(0); + }); + it('should handle SET_FILTERABLES_CONFIG', async () => { + expect(reducer(undefined, { type: actions.setFilterablesConfig, payload: { attributes: { asd: true } } }).filteringAttributesConfig).toEqual({ + attributes: { asd: true }, + count: undefined, + limit: undefined + }); + expect( + reducer(initialState, { type: actions.setFilterablesConfig, payload: { attributes: { asd: true }, count: 1, limit: 10 } }).filteringAttributesConfig + ).toEqual({ attributes: { asd: true }, count: 1, limit: 10 }); + }); + it('should handle SET_FILTER_ATTRIBUTES', async () => { + expect(reducer(undefined, { type: actions.setFilterAttributes, payload: { things: '12' } }).filteringAttributes).toEqual({ things: '12' }); + expect(reducer(initialState, { type: actions.setFilterAttributes, payload: { things: '12' } }).filteringAttributes).toEqual({ things: '12' }); + }); + it('should handle SET_TOTAL_DEVICES', async () => { + expect(reducer(undefined, { type: actions.setTotalDevices, payload: 2 }).total).toEqual(2); + expect(reducer(initialState, { type: actions.setTotalDevices, payload: 4 }).total).toEqual(4); + }); + it('should handle SET_DEVICE_LIMIT', async () => { + expect(reducer(undefined, { type: actions.setDeviceLimit, payload: 500 }).limit).toEqual(500); + expect(reducer(initialState, { type: actions.setDeviceLimit, payload: 200 }).limit).toEqual(200); + }); + + it('should handle RECEIVE_DEVICE', async () => { + expect(reducer(undefined, { type: actions.receivedDevice, payload: defaultState.devices.byId.b1 }).byId.b1).toEqual(defaultState.devices.byId.b1); + expect(reducer(initialState, { type: actions.receivedDevice, payload: defaultState.devices.byId.b1 }).byId).not.toBe({}); + }); + it('should handle RECEIVE_DEVICES', async () => { + expect(reducer(undefined, { type: actions.receivedDevices, payload: defaultState.devices.byId }).byId).toEqual(defaultState.devices.byId); + expect(reducer(initialState, { type: actions.receivedDevices, payload: defaultState.devices.byId }).byId).toEqual(defaultState.devices.byId); + }); + it('should handle SET_INACTIVE_DEVICES', async () => { + expect( + reducer(undefined, { type: actions.setInactiveDevices, payload: { activeDeviceTotal: 1, inactiveDeviceTotal: 1 } }).byStatus.active.total + ).toBeTruthy(); + expect( + reducer(initialState, { type: actions.setInactiveDevices, payload: { activeDeviceTotal: 1, inactiveDeviceTotal: 1 } }).byStatus.inactive.total + ).toEqual(1); + }); + it('should handle SET_DEVICE_REPORTS', async () => { + expect(reducer(undefined, { type: actions.setDeviceReports, payload: [1, 2, 3] }).reports).toHaveLength(3); + expect(reducer(initialState, { type: actions.setDeviceReports, payload: [{ something: 'here' }] }).reports).toEqual([{ something: 'here' }]); + }); + it('should handle SET__DEVICES', async () => { + Object.values(DEVICE_STATES).forEach(status => { + expect(reducer(undefined, { type: actions.setDevicesByStatus, payload: { deviceIds: ['a1'], total: 1, status } }).byStatus[status]).toEqual({ + deviceIds: ['a1'], + total: 1 + }); + expect(reducer(initialState, { type: actions.setDevicesByStatus, payload: { deviceIds: ['a1'], status } }).byStatus[status]).toEqual({ + deviceIds: [], + total: 0 + }); + }); + }); + it('should handle SET__DEVICES_COUNT', async () => { + Object.values(DEVICE_STATES).forEach(status => { + expect(reducer(undefined, { type: actions.setDevicesCountByStatus, payload: { count: 1, status } }).byStatus[status].total).toEqual(1); + expect(reducer(initialState, { type: actions.setDevicesCountByStatus, payload: { count: 1, status } }).byStatus[status].total).toEqual(1); + }); + }); +}); diff --git a/frontend/src/js/store/devicesSlice/selectors.ts b/frontend/src/js/store/devicesSlice/selectors.ts new file mode 100644 index 00000000..7cd76669 --- /dev/null +++ b/frontend/src/js/store/devicesSlice/selectors.ts @@ -0,0 +1,128 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { DEVICE_STATES, UNGROUPED_GROUP } from '@northern.tech/store/constants'; +import { createSelector } from '@reduxjs/toolkit'; + +import { duplicateFilter } from '../../helpers'; + +export const getAcceptedDevices = state => state.devices.byStatus.accepted; +export const getDevicesByStatus = state => state.devices.byStatus; +export const getDevicesById = state => state.devices.byId; +export const getDeviceReports = state => state.devices.reports; +export const getGroupsById = state => state.devices.groups.byId; +export const getSelectedGroup = state => state.devices.groups.selectedGroup; + +export const getDeviceListState = state => state.devices.deviceList; +export const getListedDevices = state => state.devices.deviceList.deviceIds; +export const getFilteringAttributes = state => state.devices.filteringAttributes; +export const getDeviceFilters = state => state.devices.filters || []; +const getFilteringAttributesFromConfig = state => state.devices.filteringAttributesConfig.attributes; +export const getSortedFilteringAttributes = createSelector([getFilteringAttributes], filteringAttributes => ({ + ...filteringAttributes, + identityAttributes: [...filteringAttributes.identityAttributes, 'id'] +})); +export const getDeviceLimit = state => state.devices.limit; +const getFilteringAttributesLimit = state => state.devices.filteringAttributesLimit; + +export const getDeviceIdentityAttributes = createSelector( + [getFilteringAttributes, getFilteringAttributesLimit], + ({ identityAttributes }, filteringAttributesLimit) => { + // limit the selection of the available attribute to AVAILABLE_ATTRIBUTE_LIMIT + const attributes = identityAttributes.slice(0, filteringAttributesLimit); + return attributes.reduce( + (accu, value) => { + accu.push({ value, label: value, scope: 'identity' }); + return accu; + }, + [ + { value: 'name', label: 'Name', scope: 'tags' }, + { value: 'id', label: 'Device ID', scope: 'identity' } + ] + ); + } +); + +export const getDeviceCountsByStatus = createSelector([getDevicesByStatus], byStatus => + Object.values(DEVICE_STATES).reduce((accu, state) => { + accu[state] = byStatus[state].total || 0; + return accu; + }, {}) +); + +export const getDeviceById = createSelector([getDevicesById, (_, deviceId) => deviceId], (devicesById, deviceId = '') => devicesById[deviceId] ?? {}); + +export const getSelectedGroupInfo = createSelector( + [getAcceptedDevices, getGroupsById, getSelectedGroup], + ({ total: acceptedDeviceTotal }, groupsById, selectedGroup) => { + let groupCount = acceptedDeviceTotal; + let groupFilters = []; + if (selectedGroup && groupsById[selectedGroup]) { + groupCount = groupsById[selectedGroup].total; + groupFilters = groupsById[selectedGroup].filters || []; + } + return { groupCount, selectedGroup, groupFilters }; + } +); + +export const getLimitMaxed = createSelector([getAcceptedDevices, getDeviceLimit], ({ total: acceptedDevices = 0 }, deviceLimit) => + Boolean(deviceLimit && deviceLimit <= acceptedDevices) +); + +// eslint-disable-next-line no-unused-vars +export const getGroupsByIdWithoutUngrouped = createSelector([getGroupsById], ({ [UNGROUPED_GROUP.id]: ungrouped, ...groups }) => groups); + +export const getGroups = createSelector([getGroupsById], groupsById => { + const groupNames = Object.keys(groupsById).sort(); + const groupedGroups = Object.entries(groupsById) + .sort((a, b) => a[0].localeCompare(b[0])) + .reduce( + (accu, [groupname, group]) => { + const name = groupname === UNGROUPED_GROUP.id ? UNGROUPED_GROUP.name : groupname; + const groupItem = { ...group, groupId: name, name: groupname }; + if (group.filters.length > 0) { + if (groupname !== UNGROUPED_GROUP.id) { + accu.dynamic.push(groupItem); + } else { + accu.ungrouped.push(groupItem); + } + } else { + accu.static.push(groupItem); + } + return accu; + }, + { dynamic: [], static: [], ungrouped: [] } + ); + return { groupNames, ...groupedGroups }; +}); +export const getAttributesList = createSelector( + [getFilteringAttributes, getFilteringAttributesFromConfig], + ({ identityAttributes = [], inventoryAttributes = [] }, { identity = [], inventory = [] }) => + [...identityAttributes, ...inventoryAttributes, ...identity, ...inventory].filter(duplicateFilter) +); + +export const getDeviceTypes = createSelector([getAcceptedDevices, getDevicesById], ({ deviceIds = [] }, devicesById) => + Object.keys( + deviceIds.slice(0, 200).reduce((accu, item) => { + const { device_type: deviceTypes = [] } = devicesById[item] ? devicesById[item].attributes : {}; + accu = deviceTypes.reduce((deviceTypeAccu, deviceType) => { + if (deviceType.length > 1) { + deviceTypeAccu[deviceType] = deviceTypeAccu[deviceType] ? deviceTypeAccu[deviceType] + 1 : 1; + } + return deviceTypeAccu; + }, accu); + return accu; + }, {}) + ) +); diff --git a/frontend/src/js/store/devicesSlice/thunks.test.tsx b/frontend/src/js/store/devicesSlice/thunks.test.tsx new file mode 100644 index 00000000..7a992a41 --- /dev/null +++ b/frontend/src/js/store/devicesSlice/thunks.test.tsx @@ -0,0 +1,1245 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck + +/*eslint import/namespace: ['error', { allowComputed: true }]*/ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import { getSingleDeployment } from '@northern.tech/store/thunks'; +import configureMockStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; + +import { actions } from '.'; +import { inventoryDevice } from '../../../../tests/__mocks__/deviceHandlers'; +import { defaultState } from '../../../../tests/mockData'; +import { act, mockAbortController } from '../../../../tests/setupTests'; +import { actions as appActions } from '../appSlice'; +import { EXTERNAL_PROVIDER, UNGROUPED_GROUP } from '../constants'; +import { actions as deploymentActions } from '../deploymentsSlice'; +import { DEVICE_STATES } from './constants'; +import { + addDevicesToGroup, + addDynamicGroup, + addStaticGroup, + applyDeviceConfig, + decommissionDevice, + deleteAuthset, + deriveInactiveDevices, + deriveReportsData, + deviceFileUpload, + getAllDeviceCounts, + getAllDevicesByStatus, + getAllDynamicGroupDevices, + getAllGroupDevices, + getDeviceAttributes, + getDeviceAuth, + getDeviceById, + getDeviceConfig, + getDeviceConnect, + getDeviceCount, + getDeviceFileDownloadLink, + getDeviceInfo, + getDeviceLimit, + getDeviceTwin, + getDevicesByStatus, + getDevicesWithAuth, + getDynamicGroups, + getGatewayDevices, + getGroupDevices, + getGroups, + getReportingLimits, + getReportsData, + getReportsDataWithoutBackendSupport, + getSessionDetails, + getSystemDevices, + preauthDevice, + removeDevicesFromGroup, + removeDynamicGroup, + removeStaticGroup, + selectGroup, + setDeviceConfig, + setDeviceListState, + setDeviceTags, + setDeviceTwin, + updateDeviceAuth, + updateDevicesAuth, + updateDynamicGroup +} from './thunks'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +const groupUpdateSuccessMessage = 'The group was updated successfully'; +const getGroupSuccessNotification = groupName => ( + <> + {groupUpdateSuccessMessage} - click here to see it. + +); + +// eslint-disable-next-line no-unused-vars +const { attributes, check_in_time, updated_ts, ...expectedDevice } = defaultState.devices.byId.a1; +const receivedExpectedDevice = { type: actions.receivedDevices.type, payload: { [defaultState.devices.byId.a1.id]: expectedDevice } }; +const defaultDeviceListState = { + type: actions.setDeviceListState.type, + payload: { + deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], + isLoading: false, + total: 2 + } +}; +const acceptedDevices = { + type: actions.setDevicesByStatus.type, + payload: { + deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], + status: DEVICE_STATES.accepted, + total: defaultState.devices.byStatus.accepted.total + } +}; + +const defaultResults = { + receivedDynamicGroups: { + type: actions.receivedGroups.type, + payload: { + testGroupDynamic: { + deviceIds: [], + filters: [ + { key: 'id', operator: '$in', scope: 'identity', value: ['a1'] }, + { key: 'mac', operator: '$nexists', scope: 'identity', value: false }, + { key: 'kernel', operator: '$exists', scope: 'identity', value: true } + ], + id: 'filter1', + total: 0 + } + } + }, + addedUngroupedGroup: { + type: actions.addGroup.type, + payload: { + groupName: UNGROUPED_GROUP.id, + group: { + filters: [{ key: 'group', operator: '$nin', scope: 'system', value: [Object.keys(defaultState.devices.groups.byId)[0]] }] + } + } + }, + receiveDefaultDevice: { type: actions.receivedDevices.type, payload: { [defaultState.devices.byId.a1.id]: defaultState.devices.byId.a1 } }, + acceptedDevices, + receivedExpectedDevice, + defaultDeviceListState, + postDeviceAuthActions: [ + { type: setDeviceListState.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: actions.setDeviceListState.type, payload: { deviceIds: [], isLoading: true, refreshTrigger: true } }, + { + type: actions.receivedDevices.type, + payload: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } + }, + acceptedDevices, + { type: getDevicesWithAuth.pending.type }, + receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + { + type: actions.setDeviceListState.type, + payload: { deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], isLoading: false, total: 2 } + }, + { type: setDeviceListState.fulfilled.type } + ] +}; + +/* eslint-disable sonarjs/no-identical-functions */ +describe('selecting things', () => { + it('should allow device list selections', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: setDeviceListState.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: actions.setDeviceListState.type, payload: { deviceIds: ['a1'], isLoading: true } }, + defaultResults.receivedExpectedDevice, + defaultResults.acceptedDevices, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + { type: actions.setDeviceListState.type, payload: { deviceIds: ['a1', 'b1'], isLoading: false } }, + { type: setDeviceListState.fulfilled.type } + ]; + await store.dispatch(setDeviceListState({ deviceIds: ['a1'] })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow device list selections without device retrieval', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: setDeviceListState.pending.type }, + { type: actions.setDeviceListState.type, payload: { deviceIds: ['a1'], isLoading: false } }, + { type: setDeviceListState.fulfilled.type } + ]; + await store.dispatch(setDeviceListState({ deviceIds: ['a1'], setOnly: true })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow static group selection', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'testGroup'; + await store.dispatch(selectGroup({ group: groupName })); + // eslint-disable-next-line no-unused-vars + const { attributes, updated_ts, ...expectedDevice } = defaultState.devices.byId.a1; + const expectedActions = [ + { type: selectGroup.pending.type }, + { type: actions.setDeviceFilters.type, payload: [] }, + { type: getGroupDevices.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: actions.selectGroup.type, payload: groupName }, + { type: actions.receivedDevices.type, payload: { [defaultState.devices.byId.a1.id]: { ...expectedDevice, attributes } } }, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + { + type: actions.addGroup.type, + payload: { group: { deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], total: 2 }, groupName } + }, + { type: getGroupDevices.fulfilled.type }, + { type: selectGroup.fulfilled.type } + ]; + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow dynamic group selection', async () => { + const store = mockStore({ ...defaultState }); + await store.dispatch(selectGroup({ group: 'testGroupDynamic' })); + const expectedActions = [ + { type: selectGroup.pending.type }, + { type: actions.setDeviceFilters.type, payload: [{ scope: 'system', key: 'group', operator: '$eq', value: 'things' }] }, + { type: actions.selectGroup.type, payload: 'testGroupDynamic' }, + { type: selectGroup.fulfilled.type } + ]; + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow dynamic group selection with extra filters', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: selectGroup.pending.type }, + { + type: actions.setDeviceFilters.type, + payload: [ + { scope: 'system', key: 'group', operator: '$eq', value: 'things' }, + { scope: 'system', key: 'group2', operator: '$eq', value: 'things2' } + ] + }, + { type: actions.selectGroup.type, payload: 'testGroupDynamic' }, + { type: selectGroup.fulfilled.type } + ]; + await store.dispatch( + selectGroup({ + group: 'testGroupDynamic', + filters: [...defaultState.devices.groups.byId.testGroupDynamic.filters, { scope: 'system', key: 'group2', operator: '$eq', value: 'things2' }] + }) + ); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); + +describe('overall device information retrieval', () => { + it('should allow count retrieval', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getDeviceCount.pending.type }, + { type: getDeviceCount.pending.type }, + { type: getDeviceCount.pending.type }, + { type: getDeviceCount.pending.type }, + { + type: actions.setDevicesCountByStatus.type, + payload: { count: defaultState.devices.byStatus.accepted.total, status: DEVICE_STATES.accepted } + }, + { + type: actions.setDevicesCountByStatus.type, + payload: { count: defaultState.devices.byStatus.pending.total, status: DEVICE_STATES.pending } + }, + { + type: actions.setDevicesCountByStatus.type, + payload: { count: defaultState.devices.byStatus.preauthorized.total, status: DEVICE_STATES.preauth } + }, + { + type: actions.setDevicesCountByStatus.type, + payload: { count: defaultState.devices.byStatus.rejected.total, status: DEVICE_STATES.rejected } + }, + { type: getDeviceCount.fulfilled.type }, + { type: getDeviceCount.fulfilled.type }, + { type: getDeviceCount.fulfilled.type }, + { type: getDeviceCount.fulfilled.type } + ]; + await Promise.all(Object.values(DEVICE_STATES).map(status => store.dispatch(getDeviceCount(status)))).then(() => { + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + }); + it('should allow count retrieval for all state counts', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getAllDeviceCounts.pending.type }, + { type: getDeviceCount.pending.type }, + { type: getDeviceCount.pending.type }, + ...[DEVICE_STATES.accepted, DEVICE_STATES.pending].map(status => ({ + type: actions.setDevicesCountByStatus.type, + payload: { count: defaultState.devices.byStatus[status].total, status } + })), + { type: getDeviceCount.fulfilled.type }, + { type: getDeviceCount.fulfilled.type }, + { type: getAllDeviceCounts.fulfilled.type } + ]; + await store.dispatch(getAllDeviceCounts()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + + it('should allow limit retrieval', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getDeviceLimit.pending.type }, + { type: actions.setDeviceLimit.type, payload: defaultState.devices.limit }, + { type: getDeviceLimit.fulfilled.type } + ]; + await store.dispatch(getDeviceLimit()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow attribute retrieval and group results', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getDeviceAttributes.pending.type }, + { type: actions.setFilterAttributes.type, payload: {} }, + { type: getDeviceAttributes.fulfilled.type } + ]; + await store.dispatch(getDeviceAttributes()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + const receivedAttributes = storeActions.find(item => item.type === actions.setFilterAttributes.type).payload; + expect(Object.keys(receivedAttributes)).toHaveLength(4); + Object.entries(receivedAttributes).forEach(([key, value]) => { + expect(key).toBeTruthy(); + expect(value).toBeTruthy(); + }); + }); + it('should allow attribute config + limit retrieval and group results', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getReportingLimits.pending.type }, + { + type: actions.setFilterablesConfig.type, + payload: { + attributes: { + identity: ['status', 'mac'], + inventory: [ + 'artifact_name', + 'cpu_model', + 'device_type', + 'hostname', + 'ipv4_wlan0', + 'ipv6_wlan0', + 'kernel', + 'mac_eth0', + 'mac_wlan0', + 'mem_total_kB', + 'mender_bootloader_integration', + 'mender_client_version', + 'network_interfaces', + 'os', + 'rootfs_type' + ], + system: ['created_ts', 'updated_ts', 'group'] + }, + count: 20, + limit: 100 + } + }, + { type: getReportingLimits.fulfilled.type } + ]; + await store.dispatch(getReportingLimits()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + + it('should allow getting device aggregation data for use in the dashboard/ reports', async () => { + const store = mockStore({ + ...defaultState, + devices: { ...defaultState.devices, byStatus: { ...defaultState.devices.byStatus, accepted: { ...defaultState.devices.byStatus.accepted, total: 50 } } } + }); + const expectedActions = [ + { type: getReportsData.pending.type }, + { + type: actions.setDeviceReports.type, + payload: [ + { + items: [ + { count: 6, key: 'test' }, + { count: 1, key: 'original' } + ], + otherCount: 43, + total: 50 + } + ] + }, + { type: getReportsData.fulfilled.type } + ]; + await store.dispatch(getReportsData()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow getting device aggregation data for use in the dashboard/ reports even if the reporting service is not ready', async () => { + const groupName = 'testGroup'; + const groupNameDynamic = 'testGroupDynamic'; + const store = mockStore({ + ...defaultState, + users: { + ...defaultState.users, + userSettings: { + ...defaultState.users.userSettings, + reports: [{ attribute: 'ipv4_wlan0', chartType: 'bar', group: groupName, type: 'distribution' }] + } + } + }); + const expectedActions = [ + { type: getReportsDataWithoutBackendSupport.pending.type }, + { type: getAllDevicesByStatus.pending.type }, + { type: getGroups.pending.type }, + { type: getDynamicGroups.pending.type }, + { type: actions.receivedGroups.type, payload: { testGroup: defaultState.devices.groups.byId.testGroup } }, + { type: getDevicesByStatus.pending.type }, + defaultResults.receivedDynamicGroups, + { type: getDynamicGroups.fulfilled.type }, + defaultResults.receivedExpectedDevice, + defaultResults.acceptedDevices, + { type: deriveInactiveDevices.pending.type }, + { type: actions.setInactiveDevices.type, payload: { activeDeviceTotal: 0, inactiveDeviceTotal: 2 } }, + { type: deriveReportsData.pending.type }, + { type: actions.setDeviceReports.type, payload: [{ items: [{ count: 2, key: '192.168.10.141/24' }], otherCount: 0, total: 2 }] }, + { type: deriveInactiveDevices.fulfilled.type }, + { type: deriveReportsData.fulfilled.type }, + { type: getAllDevicesByStatus.fulfilled.type }, + defaultResults.receiveDefaultDevice, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + defaultResults.addedUngroupedGroup, + { type: getGroups.fulfilled.type }, + { type: getAllGroupDevices.pending.type }, + { type: getAllDynamicGroupDevices.pending.type }, + defaultResults.receivedExpectedDevice, + { + type: actions.addGroup.type, + payload: { group: { deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], total: 2 }, groupName } + }, + { type: actions.receivedDevices.type, payload: {} }, + { type: actions.addGroup.type, payload: { group: { deviceIds: [], total: 0 }, groupName: groupNameDynamic } }, + { type: getAllGroupDevices.fulfilled.type }, + { type: getAllDynamicGroupDevices.fulfilled.type }, + { type: deriveReportsData.pending.type }, + { type: actions.setDeviceReports.type, payload: [{ items: [{ count: 2, key: '192.168.10.141/24' }], otherCount: 0, total: 2 }] }, + { type: deriveReportsData.fulfilled.type }, + { type: getReportsDataWithoutBackendSupport.fulfilled.type } + ]; + await store.dispatch(getReportsDataWithoutBackendSupport()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow system devices retrieval', async () => { + const store = mockStore({ + ...defaultState, + app: { + ...defaultState.app, + features: { + ...defaultState.app.features, + isEnterprise: true + } + } + }); + const expectedActions = [ + { type: getSystemDevices.pending.type }, + { + type: actions.receivedDevices.type, + payload: { + [defaultState.devices.byId.a1.id]: { + ...defaultState.devices.byId.a1, + systemDeviceIds: [], + systemDeviceTotal: 0 + } + } + }, + { type: getSystemDevices.fulfilled.type } + ]; + await store.dispatch(getSystemDevices({ id: defaultState.devices.byId.a1.id })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow system devices retrieval', async () => { + const gatewayDevice = defaultState.devices.byId.a1; + const store = mockStore({ + ...defaultState, + app: { + ...defaultState.app, + features: { + ...defaultState.app.features, + isEnterprise: true + } + }, + devices: { + ...defaultState.devices, + byId: { + ...defaultState.devices.byId, + [gatewayDevice.id]: { + ...gatewayDevice, + attributes: { + ...gatewayDevice.attributes, + mender_gateway_system_id: 'gatewaySystem' + } + } + } + } + }); + const expectedActions = [ + { type: getGatewayDevices.pending.type }, + { type: actions.receivedDevice.type, payload: { id: gatewayDevice.id, gatewayIds: [] } }, + { type: getGatewayDevices.fulfilled.type } + ]; + await store.dispatch(getGatewayDevices(defaultState.devices.byId.a1.id)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); + +describe('device auth handling', () => { + const deviceUpdateSuccessMessage = 'Device authorization status was updated successfully'; + it('should allow device auth information retrieval', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getDeviceAuth.pending.type }, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDeviceAuth.fulfilled.type } + ]; + await store.dispatch(getDeviceAuth(defaultState.devices.byId.a1.id)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should return device auth device as a promise result', async () => { + const store = mockStore({ ...defaultState }); + const device = await store.dispatch(getDeviceAuth(defaultState.devices.byId.a1.id)); + expect(device).toBeDefined(); + }); + it('should allow single device auth updates', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: updateDeviceAuth.pending.type }, + { type: getDeviceAuth.pending.type }, + { type: getDevicesWithAuth.pending.type }, + { type: appActions.setSnackbar.type, payload: deviceUpdateSuccessMessage }, + defaultResults.receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDeviceAuth.fulfilled.type }, + { type: actions.maybeUpdateDevicesByStatus.type }, + { type: setDeviceListState.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: actions.setDeviceListState.type, payload: { deviceIds: [], isLoading: true, refreshTrigger: true } }, + { + type: actions.receivedDevices.type, + payload: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } + }, + acceptedDevices, + { type: getDevicesWithAuth.pending.type }, + receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + { + type: actions.setDeviceListState.type, + payload: { deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], total: 2, isLoading: false } + }, + { type: setDeviceListState.fulfilled.type }, + { type: updateDeviceAuth.fulfilled.type } + ]; + await store.dispatch( + updateDeviceAuth({ deviceId: defaultState.devices.byId.a1.id, authId: defaultState.devices.byId.a1.auth_sets[0].id, status: DEVICE_STATES.pending }) + ); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow multiple device auth updates', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: updateDevicesAuth.pending.type }, + { type: getDevicesWithAuth.pending.type }, + { type: getDevicesWithAuth.fulfilled.type }, + { type: updateDeviceAuth.pending.type }, + { type: getDeviceAuth.pending.type }, + { type: getDevicesWithAuth.pending.type }, + { type: appActions.setSnackbar.type, payload: deviceUpdateSuccessMessage }, + defaultResults.receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDeviceAuth.fulfilled.type }, + { type: actions.maybeUpdateDevicesByStatus.type }, + { type: setDeviceListState.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: actions.setDeviceListState.type, payload: { deviceIds: [], total: 0, isLoading: true } }, + receivedExpectedDevice, + defaultResults.acceptedDevices, + { type: getDevicesWithAuth.pending.type }, + receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + { + type: actions.setDeviceListState.type, + payload: { deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], total: 2, isLoading: false } + }, + { type: setDeviceListState.fulfilled.type }, + { type: updateDeviceAuth.fulfilled.type }, + { + type: appActions.setSnackbar.type, + payload: + '1 device was updated successfully. 1 device has more than one pending authset. Expand this device to individually adjust its authorization status. ' + }, + { type: updateDevicesAuth.fulfilled.type } + ]; + await store.dispatch(updateDevicesAuth({ deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.c1.id], status: DEVICE_STATES.pending })); + await act(async () => { + jest.runOnlyPendingTimers(); + jest.runAllTicks(); + }); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow preauthorizing devices', async () => { + const store = mockStore({ ...defaultState }); + // eslint-disable-next-line no-unused-vars + const expectedActions = [ + { type: preauthDevice.pending.type }, + { type: appActions.setSnackbar.type, payload: 'Device was successfully added to the preauthorization list' }, + { type: preauthDevice.fulfilled.type } + ]; + await store.dispatch( + preauthDevice({ + ...defaultState.devices.byId.a1.auth_sets[0], + identity_data: { ...defaultState.devices.byId.a1.auth_sets[0].identity_data, mac: '12:34:56' }, + pubkey: 'test' + }) + ); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should notify about duplicate device preauthorization attempts', async () => { + const store = mockStore({ ...defaultState }); + await store + .dispatch(preauthDevice(defaultState.devices.byId.a1.auth_sets[0])) + .unwrap() + .catch(({ message }) => expect(message).toContain('identity data set already exists')); + }); + it('should allow single device auth set deletion', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: deleteAuthset.pending.type }, + { type: appActions.setSnackbar.type, payload: deviceUpdateSuccessMessage }, + { type: actions.maybeUpdateDevicesByStatus.type }, + ...defaultResults.postDeviceAuthActions, + { type: deleteAuthset.fulfilled.type } + ]; + await store.dispatch(deleteAuthset({ deviceId: defaultState.devices.byId.a1.id, authId: defaultState.devices.byId.a1.auth_sets[0].id })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow single device decomissioning', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: decommissionDevice.pending.type }, + { type: appActions.setSnackbar.type, payload: 'Device was decommissioned successfully' }, + { type: actions.maybeUpdateDevicesByStatus.type }, + ...defaultResults.postDeviceAuthActions, + { type: decommissionDevice.fulfilled.type } + ]; + await store.dispatch(decommissionDevice({ deviceId: defaultState.devices.byId.a1.id })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); + +describe('static grouping related actions', () => { + it('should allow retrieving static groups', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getGroups.pending.type }, + { type: actions.receivedGroups.type, payload: { testGroup: defaultState.devices.groups.byId.testGroup } }, + { type: getDevicesByStatus.pending.type }, + { + type: actions.receivedDevices.type, + payload: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } + }, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receiveDefaultDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + defaultResults.addedUngroupedGroup, + { type: getGroups.fulfilled.type } + ]; + await store.dispatch(getGroups()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow creating static groups', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'createdTestGroup'; + const expectedActions = [ + { type: addStaticGroup.pending.type }, + { type: addDevicesToGroup.pending.type }, + { type: actions.addToGroup.type, payload: { group: groupName, deviceIds: [defaultState.devices.byId.a1.id] } }, + { type: getGroups.pending.type }, + { type: actions.receivedGroups.type, payload: { testGroup: defaultState.devices.groups.byId.testGroup } }, + { type: getDevicesByStatus.pending.type }, + { + type: actions.receivedDevices.type, + payload: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } + }, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receiveDefaultDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + defaultResults.addedUngroupedGroup, + { type: getGroups.fulfilled.type }, + { type: addDevicesToGroup.fulfilled.type }, + { type: actions.addGroup.type, payload: { groupName, group: { deviceIds: [], total: 0, filters: [] } } }, + { type: setDeviceListState.pending.type }, + { type: actions.setDeviceListState.type, payload: { ...defaultState.devices.deviceList, deviceIds: [], setOnly: true } }, + { type: getGroups.pending.type }, + { type: appActions.setSnackbar.type, payload: getGroupSuccessNotification(groupName) }, + { type: setDeviceListState.fulfilled.type }, + { type: actions.receivedGroups.type, payload: { testGroup: defaultState.devices.groups.byId.testGroup } }, + { type: getDevicesByStatus.pending.type }, + defaultResults.receiveDefaultDevice, + { type: getDevicesWithAuth.pending.type }, + { + type: actions.receivedDevices.type, + payload: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } + }, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + defaultResults.addedUngroupedGroup, + { type: getGroups.fulfilled.type }, + { type: addStaticGroup.fulfilled.type } + ]; + await store.dispatch(addStaticGroup({ group: groupName, devices: [defaultState.devices.byId.a1] })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow extending static groups', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'createdTestGroup'; + const expectedActions = [ + { type: addDevicesToGroup.pending.type }, + { type: actions.addToGroup.type, payload: { group: groupName, deviceIds: [defaultState.devices.byId.b1.id] } }, + { type: addDevicesToGroup.fulfilled.type } + ]; + await store.dispatch(addDevicesToGroup({ group: groupName, deviceIds: [defaultState.devices.byId.b1.id] })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow shrinking static groups', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'testGroup'; + const expectedActions = [ + { type: removeDevicesFromGroup.pending.type }, + { type: actions.removeFromGroup.type, payload: { group: groupName, deviceIds: [defaultState.devices.byId.b1.id] } }, + { type: appActions.setSnackbar.type, payload: 'The device was removed from the group' }, + { type: removeDevicesFromGroup.fulfilled.type } + ]; + await store.dispatch(removeDevicesFromGroup({ group: groupName, deviceIds: [defaultState.devices.byId.b1.id] })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow removing static groups', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'testGroup'; + const expectedActions = [ + { type: removeStaticGroup.pending.type }, + { type: actions.removeGroup.type, payload: groupName }, + { type: getGroups.pending.type }, + { type: appActions.setSnackbar.type, payload: 'Group was removed successfully' }, + { type: actions.receivedGroups.type, payload: { testGroup: defaultState.devices.groups.byId.testGroup } }, + { type: getDevicesByStatus.pending.type }, + { + type: actions.receivedDevices.type, + payload: { [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, updated_ts: inventoryDevice.updated_ts } } + }, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receiveDefaultDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + defaultResults.addedUngroupedGroup, + { type: getGroups.fulfilled.type }, + { type: removeStaticGroup.fulfilled.type } + ]; + await store.dispatch(removeStaticGroup(groupName)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow device retrieval for static groups', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'testGroup'; + // eslint-disable-next-line no-unused-vars + const { attributes, updated_ts, ...expectedDevice } = defaultState.devices.byId.a1; + const expectedActions = [ + { type: getGroupDevices.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: actions.receivedDevices.type, payload: { [defaultState.devices.byId.a1.id]: { ...expectedDevice, attributes } } }, + { + type: actions.setDevicesByStatus.type, + payload: { deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], status: DEVICE_STATES.accepted, total: 2 } + }, + { type: getDevicesWithAuth.pending.type }, + { type: actions.receivedDevices.type, payload: { [expectedDevice.id]: { ...expectedDevice, updated_ts } } }, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + { type: getGroupDevices.fulfilled.type } + ]; + await store.dispatch(getGroupDevices(groupName)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + const devicesById = storeActions.find(item => item.type === actions.receivedDevices.type).payload; + expect(devicesById[defaultState.devices.byId.a1.id]).toBeTruthy(); + expect(new Date(devicesById[defaultState.devices.byId.a1.id].updated_ts).getTime()).toBeGreaterThanOrEqual(new Date(updated_ts).getTime()); + }); + it('should allow complete device retrieval for static groups', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'testGroup'; + const expectedActions = [ + { type: getAllGroupDevices.pending.type }, + defaultResults.receivedExpectedDevice, + { + type: actions.addGroup.type, + payload: { group: { deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], total: 2 }, groupName } + }, + { type: getAllGroupDevices.fulfilled.type } + ]; + await store.dispatch(getAllGroupDevices(groupName)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); + +describe('dynamic grouping related actions', () => { + it('should allow retrieving dynamic groups', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [{ type: getDynamicGroups.pending.type }, defaultResults.receivedDynamicGroups, { type: getDynamicGroups.fulfilled.type }]; + await store.dispatch(getDynamicGroups()); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + + it('should allow creating dynamic groups', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'createdTestGroup'; + const expectedActions = [ + { type: addDynamicGroup.pending.type }, + { + type: actions.addGroup.type, + payload: { groupName, group: { filters: [{ key: 'group', operator: '$nin', scope: 'system', value: ['testGroup'] }] } } + }, + { type: actions.setDeviceFilters.type, payload: [] }, + { type: appActions.setSnackbar.type, payload: getGroupSuccessNotification(groupName) }, + { type: getDynamicGroups.pending.type }, + defaultResults.receivedDynamicGroups, + { type: getDynamicGroups.fulfilled.type }, + { type: addDynamicGroup.fulfilled.type } + ]; + await store.dispatch(addDynamicGroup({ groupName, filterPredicates: [{ key: 'group', operator: '$nin', scope: 'system', value: ['testGroup'] }] })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow complete device retrieval for dynamic groups', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'testGroupDynamic'; + const expectedActions = [ + { type: getAllDynamicGroupDevices.pending.type }, + { type: actions.receivedDevices.type, payload: {} }, + { type: actions.addGroup.type, payload: { group: { deviceIds: [], total: 0 }, groupName } }, + { type: getAllDynamicGroupDevices.fulfilled.type } + ]; + await store.dispatch(getAllDynamicGroupDevices(groupName)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow dynamic group updates', async () => { + const groupName = 'testGroupDynamic'; + const store = mockStore({ + ...defaultState, + devices: { + ...defaultState.devices, + groups: { + ...defaultState.devices.groups, + selectedGroup: groupName + } + } + }); + const expectedActions = [ + { type: updateDynamicGroup.pending.type }, + { type: addDynamicGroup.pending.type }, + { type: actions.addGroup.type, payload: { groupName, group: { filters: [] } } }, + { type: actions.setDeviceFilters.type, payload: defaultState.devices.groups.byId.testGroupDynamic.filters }, + { type: appActions.setSnackbar.type, payload: groupUpdateSuccessMessage }, + { type: getDynamicGroups.pending.type }, + defaultResults.receivedDynamicGroups, + { type: getDynamicGroups.fulfilled.type }, + { type: addDynamicGroup.fulfilled.type }, + { type: updateDynamicGroup.fulfilled.type } + ]; + await store.dispatch(updateDynamicGroup({ groupName, filterPredicates: [] })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow removing dynamic groups', async () => { + const store = mockStore({ ...defaultState }); + const groupName = 'testGroupDynamic'; + const expectedActions = [ + { type: removeDynamicGroup.pending.type }, + { type: actions.removeGroup.type, payload: groupName }, + { type: appActions.setSnackbar.type, payload: 'Group was removed successfully' }, + { type: removeDynamicGroup.fulfilled.type } + ]; + await store.dispatch(removeDynamicGroup(groupName)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); + +describe('device retrieval ', () => { + it('should allow single device retrieval from inventory', async () => { + const store = mockStore({ + ...defaultState + }); + const { attributes, id } = defaultState.devices.byId.a1; + const expectedActions = [ + { type: getDeviceById.pending.type }, + { type: actions.receivedDevice.type, payload: { attributes, id } }, + { type: getDeviceById.fulfilled.type } + ]; + await store.dispatch(getDeviceById(defaultState.devices.byId.a1.id)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow single device retrieval from detailed sources', async () => { + const store = mockStore({ + ...defaultState, + app: { ...defaultState.app, features: { ...defaultState.app.features, hasDeviceConnect: true } }, + organization: { ...defaultState.organization, addons: [], externalDeviceIntegrations: [{ ...EXTERNAL_PROVIDER['iot-hub'], id: 'test' }] } + }); + const { attributes, updated_ts, id, ...expectedDevice } = defaultState.devices.byId.a1; + const expectedActions = [ + { type: getDeviceInfo.pending.type }, + { type: getDeviceAuth.pending.type }, + { type: getDevicesWithAuth.pending.type }, + { type: getDeviceTwin.pending.type }, + { type: getDeviceById.pending.type }, + { type: getDeviceConnect.pending.type }, + { type: actions.receivedDevices.type, payload: { [id]: { ...expectedDevice, id } } }, + { type: actions.receivedDevice.type, payload: { attributes, id } }, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDeviceById.fulfilled.type }, + { type: getDeviceAuth.fulfilled.type }, + { type: actions.receivedDevice.type, payload: { connect_status: 'connected', connect_updated_ts: updated_ts, id } }, + { type: actions.receivedDevice.type, payload: expectedDevice }, + { type: getDeviceConnect.fulfilled.type }, + { type: getDeviceTwin.fulfilled.type }, + { type: getDeviceInfo.fulfilled.type } + ]; + await store.dispatch(getDeviceInfo(defaultState.devices.byId.a1.id)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow retrieving multiple devices by status', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getDevicesByStatus.pending.type }, + defaultResults.receivedExpectedDevice, + defaultResults.acceptedDevices, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type } + ]; + await store.dispatch(getDevicesByStatus({ status: DEVICE_STATES.accepted })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow retrieving multiple devices by status and select if requested', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getDevicesByStatus.pending.type }, + defaultResults.receivedExpectedDevice, + { + type: actions.setDevicesByStatus.type, + payload: { deviceIds: [defaultState.devices.byId.a1.id], status: DEVICE_STATES.accepted, total: defaultState.devices.byStatus.accepted.total } + }, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type } + ]; + await store.dispatch(getDevicesByStatus({ status: DEVICE_STATES.accepted, perPage: 1, shouldSelectDevices: true })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow retrieving devices based on devicelist state', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: setDeviceListState.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: actions.setDeviceListState.type, payload: { ...defaultState.devices.deviceList, perPage: 2, deviceIds: [], isLoading: true } }, + defaultResults.receivedExpectedDevice, + defaultResults.acceptedDevices, + { type: getDevicesWithAuth.pending.type }, + defaultResults.receivedExpectedDevice, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + // the following perPage setting should be 2 as well, but the test backend seems to respond too fast for the state change to propagate + defaultResults.defaultDeviceListState, + { type: setDeviceListState.fulfilled.type } + ]; + await store.dispatch(setDeviceListState({ page: 1, perPage: 2, refreshTrigger: true })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow retrieving all devices per status', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getAllDevicesByStatus.pending.type }, + defaultResults.receivedExpectedDevice, + defaultResults.acceptedDevices, + { type: deriveInactiveDevices.pending.type }, + { type: actions.setInactiveDevices.type, payload: { activeDeviceTotal: 0, inactiveDeviceTotal: 2 } }, + { type: deriveReportsData.pending.type }, + { type: actions.setDeviceReports.type, payload: [{ items: [{ count: 2, key: 'undefined' }], otherCount: 0, total: 2 }] }, + { type: deriveInactiveDevices.fulfilled.type }, + { type: deriveReportsData.fulfilled.type }, + { type: getAllDevicesByStatus.fulfilled.type } + ]; + await store.dispatch(getAllDevicesByStatus(DEVICE_STATES.accepted)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow retrieving devices per status and their auth data', async () => { + const store = mockStore({ ...defaultState }); + const { + a1: { attributes: attributes1, ...expectedDevice1 }, // eslint-disable-line no-unused-vars + b1: { attributes: attributes2, auth_sets, ...expectedDevice2 } // eslint-disable-line no-unused-vars + } = defaultState.devices.byId; + const expectedActions = [ + { type: getDevicesWithAuth.pending.type }, + { type: actions.receivedDevices.type, payload: { [expectedDevice1.id]: expectedDevice1, [expectedDevice2.id]: expectedDevice2 } }, + { type: getDevicesWithAuth.fulfilled.type } + ]; + await store.dispatch(getDevicesWithAuth([defaultState.devices.byId.a1, defaultState.devices.byId.b1])); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); + +const deviceConfig = { + configured: { aNumber: 42, something: 'else', test: true }, + reported: { aNumber: 42, something: 'else', test: true }, + updated_ts: defaultState.devices.byId.a1.updated_ts, + reported_ts: '2019-01-01T09:25:01.000Z' +}; + +describe('device config ', () => { + it('should allow single device config retrieval', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getDeviceConfig.pending.type }, + { type: actions.receivedDevice.type, payload: { config: deviceConfig, id: defaultState.devices.byId.a1.id } }, + { type: getDeviceConfig.fulfilled.type } + ]; + await store.dispatch(getDeviceConfig(defaultState.devices.byId.a1.id)); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should not have a problem with unknown devices on config retrieval', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [{ type: getDeviceConfig.pending.type }, { type: getDeviceConfig.fulfilled.type }]; + await store.dispatch(getDeviceConfig('testId')); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + + it('should allow single device config update', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: setDeviceConfig.pending.type }, + { type: getDeviceConfig.pending.type }, + { type: actions.receivedDevice.type, payload: { config: deviceConfig, id: defaultState.devices.byId.a1.id } }, + { type: getDeviceConfig.fulfilled.type }, + { type: setDeviceConfig.fulfilled.type } + ]; + await store.dispatch(setDeviceConfig({ deviceId: defaultState.devices.byId.a1.id, config: { something: 'asdl' } })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow single device config deployment', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: applyDeviceConfig.pending.type }, + { type: actions.receivedDevice.type, payload: { ...defaultState.devices.byId.a1, config: { deployment_id: '' } } }, + { type: getSingleDeployment.type }, + { type: deploymentActions.receivedDeployment.type, payload: { ...defaultState.deployments.byId.d1, id: 'config1', created: '2019-01-01T09:25:01.000Z' } }, + { type: getSingleDeployment.type }, + { type: applyDeviceConfig.fulfilled.type } + ]; + const result = store.dispatch(applyDeviceConfig({ deviceId: defaultState.devices.byId.a1.id, config: { something: 'asdl' } })); + await act(async () => jest.runAllTicks()); + result.then(() => { + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + }); + it('should allow setting device tags', async () => { + const store = mockStore({ ...defaultState }); + const { attributes, id } = defaultState.devices.byId.a1; + const expectedActions = [ + { type: setDeviceTags.pending.type }, + { type: getDeviceById.pending.type }, + { type: actions.receivedDevice.type, payload: { attributes, id } }, + { type: getDeviceById.fulfilled.type }, + { type: actions.receivedDevice.type, payload: { id, tags: { something: 'asdl' } } }, + { type: appActions.setSnackbar.type, payload: 'Device name changed' }, + { type: setDeviceTags.fulfilled.type } + ]; + await store.dispatch(setDeviceTags({ deviceId: defaultState.devices.byId.a1.id, tags: { something: 'asdl' } })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); + +describe('troubleshooting related actions', () => { + it('should allow session info retrieval', async () => { + const store = mockStore({ ...defaultState }); + const endDate = '2019-01-01T12:16:22.667Z'; + const sessionId = 'abd313a8-ee88-48ab-9c99-fbcd80048e6e'; + const result = await store + .dispatch(getSessionDetails({ sessionId, deviceId: defaultState.devices.byId.a1.id, userId: defaultState.users.currentUser, endDate })) + .unwrap(); + + expect(result).toMatchObject({ start: new Date(endDate), end: new Date(endDate) }); + }); + + it('should allow device file transfers', async () => { + const store = mockStore({ ...defaultState }); + const link = await store.dispatch(getDeviceFileDownloadLink({ deviceId: 'aDeviceId', path: '/tmp/file' })).unwrap(); + expect(link).toBe('/api/management/v1/deviceconnect/devices/aDeviceId/download?path=%2Ftmp%2Ffile'); + const expectedActions = [ + { type: getDeviceFileDownloadLink.pending.type }, + { type: getDeviceFileDownloadLink.fulfilled.type }, + { type: deviceFileUpload.pending.type }, + { type: appActions.setSnackbar.type, payload: 'Uploading file' }, + { + type: appActions.initUpload.type, + payload: { id: 'mock-uuid', upload: { cancelSource: mockAbortController, uploadProgress: 0 } } + }, + { type: appActions.uploadProgress.type, payload: { id: 'mock-uuid', progress: 100 } }, + { type: appActions.setSnackbar.type, payload: 'Upload successful' }, + { type: appActions.cleanUpUpload.type, payload: 'mock-uuid' }, + { type: deviceFileUpload.fulfilled.type } + ]; + await store.dispatch(deviceFileUpload({ deviceId: defaultState.devices.byId.a1.id, path: '/tmp/file', file: 'file' })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); + +describe('device twin related actions', () => { + it('should allow retrieving twin data from azure', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: getDeviceTwin.pending.type }, + { type: actions.receivedDevice.type, payload: defaultState.devices.byId.a1 }, + { type: getDeviceTwin.fulfilled.type } + ]; + await store.dispatch(getDeviceTwin({ deviceId: defaultState.devices.byId.a1.id, integration: EXTERNAL_PROVIDER['iot-hub'] })); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); + it('should allow configuring twin data on azure', async () => { + const store = mockStore({ ...defaultState }); + const expectedActions = [ + { type: setDeviceTwin.pending.type }, + { type: actions.receivedDevice.type, payload: defaultState.devices.byId.a1 }, + { type: setDeviceTwin.fulfilled.type } + ]; + await store.dispatch( + setDeviceTwin({ + deviceId: defaultState.devices.byId.a1.id, + integration: EXTERNAL_PROVIDER['iot-hub'], + settings: { something: 'asdl' } + }) + ); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + }); +}); diff --git a/frontend/src/js/store/devicesSlice/thunks.tsx b/frontend/src/js/store/devicesSlice/thunks.tsx new file mode 100644 index 00000000..1086979c --- /dev/null +++ b/frontend/src/js/store/devicesSlice/thunks.tsx @@ -0,0 +1,1098 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// @ts-nocheck +/*eslint import/namespace: ['error', { allowComputed: true }]*/ +import React from 'react'; +import { Link } from 'react-router-dom'; + +import storeActions from '@northern.tech/store/actions'; +import GeneralApi from '@northern.tech/store/api/general-api'; +import { + ALL_DEVICES, + DEVICE_FILTERING_OPTIONS, + DEVICE_LIST_DEFAULTS, + EXTERNAL_PROVIDER, + MAX_PAGE_SIZE, + SORTING_OPTIONS, + TIMEOUTS, + UNGROUPED_GROUP, + auditLogsApiUrl, + defaultReports, + headerNames, + rootfsImageVersion +} from '@northern.tech/store/constants'; +import { + getAttrsEndpoint, + getCurrentUser, + getDeviceTwinIntegrations, + getGlobalSettings, + getIdAttribute, + getSearchEndpoint, + getTenantCapabilities, + getUserCapabilities, + getUserSettings +} from '@northern.tech/store/selectors'; +import { commonErrorFallback, commonErrorHandler } from '@northern.tech/store/store'; +import { getDeviceMonitorConfig, getLatestDeviceAlerts, getSingleDeployment, saveGlobalSettings } from '@northern.tech/store/thunks'; +import { + convertDeviceListStateToFilters, + extractErrorMessage, + filtersFilter, + mapDeviceAttributes, + mapFiltersToTerms, + mapTermsToFilters, + progress +} from '@northern.tech/store/utils'; +import { createAsyncThunk } from '@reduxjs/toolkit'; +import { isCancel } from 'axios'; +import pluralize from 'pluralize'; +import { v4 as uuid } from 'uuid'; + +import { actions, sliceName } from '.'; +import { routes } from '../../components/devices/base-devices'; +import { attributeDuplicateFilter, deepCompare, getSnackbarMessage } from '../../helpers'; +import { chartColorPalette } from '../../themes/Mender'; +import { + DEVICE_STATES, + deviceAuthV2, + deviceConfig, + deviceConnect, + emptyFilter, + geoAttributes, + inventoryApiUrl, + inventoryApiUrlV2, + iotManagerBaseURL, + reportingApiUrl +} from './constants'; +import { + getDeviceById as getDeviceByIdSelector, + getDeviceFilters, + getDeviceListState, + getDevicesById, + getGroupsById, + getGroups as getGroupsSelector, + getSelectedGroup +} from './selectors'; + +const { cleanUpUpload, initUpload, setSnackbar, uploadProgress } = storeActions; +const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS; + +const defaultAttributes = [ + { scope: 'identity', attribute: 'status' }, + { scope: 'inventory', attribute: 'artifact_name' }, + { scope: 'inventory', attribute: 'device_type' }, + { scope: 'inventory', attribute: 'mender_is_gateway' }, + { scope: 'inventory', attribute: 'mender_gateway_system_id' }, + { scope: 'inventory', attribute: rootfsImageVersion }, + { scope: 'monitor', attribute: 'alerts' }, + { scope: 'system', attribute: 'created_ts' }, + { scope: 'system', attribute: 'updated_ts' }, + { scope: 'system', attribute: 'check_in_time' }, + { scope: 'system', attribute: 'group' }, + { scope: 'tags', attribute: 'name' } +]; + +export const getGroups = createAsyncThunk(`${sliceName}/getGroups`, (_, { dispatch, getState }) => + GeneralApi.get(`${inventoryApiUrl}/groups`).then(res => { + const state = getGroupsById(getState()); + const dynamicGroups = Object.entries(state).reduce((accu, [id, group]) => { + if (group.id || (group.filters?.length && id !== UNGROUPED_GROUP.id)) { + accu[id] = group; + } + return accu; + }, {}); + const groups = res.data.reduce((accu, group) => { + accu[group] = { deviceIds: [], filters: [], total: 0, ...state[group] }; + return accu; + }, dynamicGroups); + const filters = [{ key: 'group', value: res.data, operator: DEVICE_FILTERING_OPTIONS.$nin.key, scope: 'system' }]; + return Promise.all([ + dispatch(actions.receivedGroups(groups)), + dispatch(getDevicesByStatus({ filterSelection: filters, group: 0, page: 1, perPage: 1, status: undefined })) + ]).then(promises => { + const devicesRetrieval = promises[promises.length - 1] || []; + const { payload } = devicesRetrieval || {}; + const result = payload[payload.length - 1] || {}; + if (!result.total) { + return Promise.resolve(); + } + return Promise.resolve( + dispatch( + actions.addGroup({ + groupName: UNGROUPED_GROUP.id, + group: { filters: [{ key: 'group', value: res.data, operator: DEVICE_FILTERING_OPTIONS.$nin.key, scope: 'system' }] } + }) + ) + ); + }); + }) +); + +export const addDevicesToGroup = createAsyncThunk(`${sliceName}/addDevicesToGroup`, ({ group, deviceIds, isCreation }, { dispatch }) => + GeneralApi.patch(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds) + .then(() => dispatch(actions.addToGroup({ group, deviceIds }))) + .finally(() => (isCreation ? Promise.resolve(dispatch(getGroups())) : {})) +); + +export const removeDevicesFromGroup = createAsyncThunk(`${sliceName}/removeDevicesFromGroup`, ({ group, deviceIds }, { dispatch }) => + GeneralApi.delete(`${inventoryApiUrl}/groups/${group}/devices`, deviceIds).then(() => + Promise.all([ + dispatch(actions.removeFromGroup({ group, deviceIds })), + dispatch(setSnackbar(`The ${pluralize('devices', deviceIds.length)} ${pluralize('were', deviceIds.length)} removed from the group`, TIMEOUTS.fiveSeconds)) + ]) + ) +); + +const getGroupNotification = (newGroup, selectedGroup) => { + const successMessage = 'The group was updated successfully'; + if (newGroup === selectedGroup) { + return [successMessage, TIMEOUTS.fiveSeconds]; + } + return [ + <> + {successMessage} - click here to see it. + , + 5000, + undefined, + undefined, + () => {} + ]; +}; + +export const addStaticGroup = createAsyncThunk(`${sliceName}/addStaticGroup`, ({ group, devices }, { dispatch, getState }) => + Promise.resolve(dispatch(addDevicesToGroup({ group, deviceIds: devices.map(({ id }) => id), isCreation: true }))) + .then(() => + Promise.resolve( + dispatch( + actions.addGroup({ + group: { deviceIds: [], total: 0, filters: [], ...getState().devices.groups.byId[group] }, + groupName: group + }) + ) + ).then(() => + Promise.all([ + dispatch(setDeviceListState({ setOnly: true })), + dispatch(getGroups()), + dispatch(setSnackbar(...getGroupNotification(group, getState().devices.groups.selectedGroup))) + ]) + ) + ) + .catch(err => commonErrorHandler(err, `Group could not be updated:`, dispatch)) +); + +export const removeStaticGroup = createAsyncThunk(`${sliceName}/removeStaticGroup`, (groupName, { dispatch }) => + GeneralApi.delete(`${inventoryApiUrl}/groups/${groupName}`).then(() => + Promise.all([ + dispatch(actions.removeGroup(groupName)), + dispatch(getGroups()), + dispatch(setSnackbar('Group was removed successfully', TIMEOUTS.fiveSeconds)) + ]) + ) +); + +export const getDynamicGroups = createAsyncThunk(`${sliceName}/getDynamicGroups`, (_, { dispatch, getState }) => + GeneralApi.get(`${inventoryApiUrlV2}/filters?per_page=${MAX_PAGE_SIZE}`) + .then(({ data: filters }) => { + const state = getGroupsById(getState()); + const staticGroups = Object.entries(state).reduce((accu, [id, group]) => { + if (!(group.id || group.filters?.length)) { + accu[id] = group; + } + return accu; + }, {}); + const groups = (filters || []).reduce((accu, filter) => { + accu[filter.name] = { + deviceIds: [], + total: 0, + ...state[filter.name], + id: filter.id, + filters: mapTermsToFilters(filter.terms) + }; + return accu; + }, staticGroups); + return Promise.resolve(dispatch(actions.receivedGroups(groups))); + }) + .catch(() => console.log('Dynamic group retrieval failed - likely accessing a non-enterprise backend')) +); + +export const addDynamicGroup = createAsyncThunk(`${sliceName}/addDynamicGroup`, ({ groupName, filterPredicates }, { dispatch, getState }) => + GeneralApi.post(`${inventoryApiUrlV2}/filters`, { name: groupName, terms: mapFiltersToTerms(filterPredicates) }) + .then(res => + Promise.resolve( + dispatch( + actions.addGroup({ + groupName, + group: { + id: res.headers[headerNames.location].substring(res.headers[headerNames.location].lastIndexOf('/') + 1), + filters: filterPredicates + } + }) + ) + ).then(() => { + const { cleanedFilters } = getGroupFilters(groupName, getState().devices.groups); + return Promise.all([ + dispatch(actions.setDeviceFilters(cleanedFilters)), + dispatch(setSnackbar(...getGroupNotification(groupName, getState().devices.groups.selectedGroup))), + dispatch(getDynamicGroups()) + ]); + }) + ) + .catch(err => commonErrorHandler(err, `Group could not be updated:`, dispatch)) +); + +export const updateDynamicGroup = createAsyncThunk(`${sliceName}/updateDynamicGroup`, ({ groupName, filterPredicates }, { dispatch, getState }) => { + const filterId = getState().devices.groups.byId[groupName].id; + return GeneralApi.delete(`${inventoryApiUrlV2}/filters/${filterId}`).then(() => Promise.resolve(dispatch(addDynamicGroup({ groupName, filterPredicates })))); +}); + +export const removeDynamicGroup = createAsyncThunk(`${sliceName}/removeDynamicGroup`, (groupName, { dispatch, getState }) => { + const filterId = getState().devices.groups.byId[groupName].id; + return GeneralApi.delete(`${inventoryApiUrlV2}/filters/${filterId}`).then(() => + Promise.all([dispatch(actions.removeGroup(groupName)), dispatch(setSnackbar('Group was removed successfully', TIMEOUTS.fiveSeconds))]) + ); +}); + +/* + * Device inventory functions + */ +const getGroupFilters = (group, groupsState, filters = []) => { + const groupName = group === UNGROUPED_GROUP.id || group === UNGROUPED_GROUP.name ? UNGROUPED_GROUP.id : group; + const selectedGroup = groupsState.byId[groupName]; + const groupFilterLength = selectedGroup?.filters?.length || 0; + const cleanedFilters = groupFilterLength ? [...filters, ...selectedGroup.filters].filter(filtersFilter) : filters; + return { cleanedFilters, groupName, selectedGroup, groupFilterLength }; +}; + +export const selectGroup = createAsyncThunk(`${sliceName}/selectGroup`, ({ group, filters = [] }, { dispatch, getState }) => { + const { cleanedFilters, groupName, selectedGroup, groupFilterLength } = getGroupFilters(group, getState().devices.groups, filters); + if (getSelectedGroup(getState()) === groupName && ((filters.length === 0 && !groupFilterLength) || filters.length === cleanedFilters.length)) { + return Promise.resolve(); + } + let tasks = []; + if (groupFilterLength) { + tasks.push(dispatch(actions.setDeviceFilters(cleanedFilters))); + } else { + tasks.push(dispatch(actions.setDeviceFilters(filters))); + tasks.push(dispatch(getGroupDevices({ group: groupName, perPage: 1, shouldIncludeAllStates: true }))); + } + const selectedGroupName = selectedGroup || !Object.keys(getGroupsById(getState())).length ? groupName : undefined; + tasks.push(dispatch(actions.selectGroup(selectedGroupName))); + return Promise.all(tasks); +}); + +const getEarliestTs = (dateA = '', dateB = '') => (!dateA || !dateB ? dateA || dateB : dateA < dateB ? dateA : dateB); + +const reduceReceivedDevices = (devices, ids, state, status) => + devices.reduce( + (accu, device) => { + const stateDevice = getDeviceByIdSelector(state, device.id); + const { + attributes: storedAttributes = {}, + identity_data: storedIdentity = {}, + monitor: storedMonitor = {}, + tags: storedTags = {}, + group: storedGroup + } = stateDevice; + const { identity, inventory, monitor, system = {}, tags } = mapDeviceAttributes(device.attributes); + device.tags = { ...storedTags, ...tags }; + device.group = system.group ?? storedGroup; + device.monitor = { ...storedMonitor, ...monitor }; + device.identity_data = { ...storedIdentity, ...identity, ...(device.identity_data ? device.identity_data : {}) }; + device.status = status ? status : device.status || identity.status; + device.check_in_time_rounded = system.check_in_time ?? stateDevice.check_in_time_rounded; + device.check_in_time_exact = device.check_in_time ?? stateDevice.check_in_time_exact; + device.created_ts = getEarliestTs(getEarliestTs(system.created_ts, device.created_ts), stateDevice.created_ts); + device.updated_ts = device.attributes ? device.updated_ts : stateDevice.updated_ts; + device.isNew = new Date(device.created_ts) > new Date(state.app.newThreshold); + device.isOffline = new Date(device.check_in_time_rounded) < new Date(state.app.offlineThreshold) || device.check_in_time_rounded === undefined; + // all the other mapped attributes return as empty objects if there are no attributes to map, but identity will be initialized with an empty state + // for device_type and artifact_name, potentially overwriting existing info, so rely on stored information instead if there are no attributes + device.attributes = device.attributes ? { ...storedAttributes, ...inventory } : storedAttributes; + accu.devicesById[device.id] = { ...stateDevice, ...device }; + accu.ids.push(device.id); + return accu; + }, + { ids, devicesById: {} } + ); + +export const getGroupDevices = createAsyncThunk(`${sliceName}/getGroupDevices`, (options, { dispatch, getState }) => { + const { group, shouldIncludeAllStates, ...remainder } = options; + const { cleanedFilters: filterSelection } = getGroupFilters(group, getState().devices.groups); + return Promise.resolve( + dispatch(getDevicesByStatus({ ...remainder, filterSelection, group, status: shouldIncludeAllStates ? undefined : DEVICE_STATES.accepted })) + ) + .unwrap() + .then(results => { + if (!group) { + return Promise.resolve(); + } + const { deviceAccu, total } = results[results.length - 1]; + const stateGroup = getState().devices.groups.byId[group]; + if (!stateGroup && !total && !deviceAccu.ids.length) { + return Promise.resolve(); + } + return Promise.resolve( + dispatch( + actions.addGroup({ + group: { + deviceIds: deviceAccu.ids.length === total || deviceAccu.ids.length > stateGroup?.deviceIds ? deviceAccu.ids : stateGroup.deviceIds, + total + }, + groupName: group + }) + ) + ); + }); +}); + +export const getAllGroupDevices = createAsyncThunk(`${sliceName}/getAllGroupDevices`, (group, { dispatch, getState }) => { + if (!group || (!!group && (!getGroupsById(getState())[group] || getGroupsById(getState())[group].filters.length))) { + return Promise.resolve(); + } + const { attributes, filterTerms } = prepareSearchArguments({ + filters: [], + group, + state: getState(), + status: DEVICE_STATES.accepted + }); + const getAllDevices = (perPage = MAX_PAGE_SIZE, page = defaultPage, devices = []) => + GeneralApi.post(getSearchEndpoint(getState()), { + page, + per_page: perPage, + filters: filterTerms, + attributes + }).then(res => { + const state = getState(); + const deviceAccu = reduceReceivedDevices(res.data, devices, state); + dispatch(actions.receivedDevices(deviceAccu.devicesById)); + const total = Number(res.headers[headerNames.total]); + if (total > perPage * page) { + return getAllDevices(perPage, page + 1, deviceAccu.ids); + } + return Promise.resolve(dispatch(actions.addGroup({ group: { deviceIds: deviceAccu.ids, total: deviceAccu.ids.length }, groupName: group }))); + }); + return getAllDevices(); +}); + +export const getAllDynamicGroupDevices = createAsyncThunk(`${sliceName}/getAllDynamicGroupDevices`, (group, { dispatch, getState }) => { + if (!!group && (!getGroupsById(getState())[group] || !getGroupsById(getState())[group].filters.length)) { + return Promise.resolve(); + } + const { attributes, filterTerms: filters } = prepareSearchArguments({ + filters: getState().devices.groups.byId[group].filters, + state: getState(), + status: DEVICE_STATES.accepted + }); + const getAllDevices = (perPage = MAX_PAGE_SIZE, page = defaultPage, devices = []) => + GeneralApi.post(getSearchEndpoint(getState()), { page, per_page: perPage, filters, attributes }).then(res => { + const state = getState(); + const deviceAccu = reduceReceivedDevices(res.data, devices, state); + dispatch(actions.receivedDevices(deviceAccu.devicesById)); + const total = Number(res.headers[headerNames.total]); + if (total > deviceAccu.ids.length) { + return getAllDevices(perPage, page + 1, deviceAccu.ids); + } + return Promise.resolve(dispatch(actions.addGroup({ group: { deviceIds: deviceAccu.ids, total }, groupName: group }))); + }); + return getAllDevices(); +}); + +export const getDeviceById = createAsyncThunk(`${sliceName}/getDeviceById`, (id, { dispatch, getState }) => + GeneralApi.get(`${inventoryApiUrl}/devices/${id}`) + .then(res => { + const device = reduceReceivedDevices([res.data], [], getState()).devicesById[id]; + device.etag = res.headers.etag; + dispatch(actions.receivedDevice(device)); + return Promise.resolve(device); + }) + .catch(err => { + const errMsg = extractErrorMessage(err); + if (errMsg.includes('Not Found')) { + console.log(`${id} does not have any inventory information`); + const device = reduceReceivedDevices( + [ + { + id, + attributes: [ + { name: 'status', value: 'decomissioned', scope: 'identity' }, + { name: 'decomissioned', value: 'true', scope: 'inventory' } + ] + } + ], + [], + getState() + ).devicesById[id]; + dispatch(actions.receivedDevice(device)); + } + }) +); + +export const getDeviceInfo = createAsyncThunk(`${sliceName}/getDeviceInfo`, (deviceId, { dispatch, getState }) => { + const device = getDeviceByIdSelector(getState(), deviceId); + const { hasDeviceConfig, hasDeviceConnect, hasMonitor } = getTenantCapabilities(getState()); + const { canConfigure } = getUserCapabilities(getState()); + const integrations = getDeviceTwinIntegrations(getState()); + let tasks = [dispatch(getDeviceAuth(deviceId)), ...integrations.map(integration => dispatch(getDeviceTwin({ deviceId, integration })))]; + if (hasDeviceConfig && canConfigure && [DEVICE_STATES.accepted, DEVICE_STATES.preauth].includes(device.status)) { + tasks.push(dispatch(getDeviceConfig(deviceId))); + } + if (device.status === DEVICE_STATES.accepted) { + // Get full device identity details for single selected device + tasks.push(dispatch(getDeviceById(deviceId))); + if (hasDeviceConnect) { + tasks.push(dispatch(getDeviceConnect(deviceId))); + } + if (hasMonitor) { + tasks.push(dispatch(getLatestDeviceAlerts({ id: deviceId }))); + tasks.push(dispatch(getDeviceMonitorConfig(deviceId))); + } + } + return Promise.all(tasks); +}); + +export const deriveInactiveDevices = createAsyncThunk(`${sliceName}/deriveInactiveDevices`, (deviceIds, { dispatch, getState }) => { + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + const yesterdaysIsoString = yesterday.toISOString(); + // now boil the list down to the ones that were not updated since yesterday + const devices = deviceIds.reduce( + (accu, id) => { + const device = getDeviceByIdSelector(getState(), id); + if (device && device.updated_ts > yesterdaysIsoString) { + accu.active.push(id); + } else { + accu.inactive.push(id); + } + return accu; + }, + { active: [], inactive: [] } + ); + return dispatch(actions.setInactiveDevices({ activeDeviceTotal: devices.active.length, inactiveDeviceTotal: devices.inactive.length })); +}); + +/* + Device Auth + admission + */ +export const getDeviceCount = createAsyncThunk(`${sliceName}/getDeviceCount`, (status, { dispatch, getState }) => + GeneralApi.post(getSearchEndpoint(getState()), { + page: 1, + per_page: 1, + filters: mapFiltersToTerms([{ key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]), + attributes: defaultAttributes + }).then(response => { + const count = Number(response.headers[headerNames.total]); + if (status) { + return dispatch(actions.setDevicesCountByStatus({ count, status })); + } + return dispatch(actions.setTotalDevices(count)); + }) +); + +export const getAllDeviceCounts = createAsyncThunk(`${sliceName}/getAllDeviceCounts`, (_, { dispatch }) => + Promise.all([DEVICE_STATES.accepted, DEVICE_STATES.pending].map(status => dispatch(getDeviceCount(status)))) +); + +export const getDeviceLimit = createAsyncThunk(`${sliceName}/getDeviceLimit`, (_, { dispatch }) => + GeneralApi.get(`${deviceAuthV2}/limits/max_devices`).then(res => dispatch(actions.setDeviceLimit(res.data.limit))) +); + +export const setDeviceListState = createAsyncThunk( + `${sliceName}/setDeviceListState`, + ({ shouldSelectDevices = true, forceRefresh, fetchAuth = true, ...selectionState }, { dispatch, getState }) => { + const currentState = getDeviceListState(getState()); + const refreshTrigger = forceRefresh ? !currentState.refreshTrigger : selectionState.refreshTrigger; + let nextState = { + ...currentState, + setOnly: false, + refreshTrigger, + ...selectionState, + sort: { ...currentState.sort, ...selectionState.sort } + }; + let tasks = []; + // eslint-disable-next-line no-unused-vars + const { isLoading: currentLoading, deviceIds: currentDevices, selection: currentSelection, ...currentRequestState } = currentState; + // eslint-disable-next-line no-unused-vars + const { isLoading: nextLoading, deviceIds: nextDevices, selection: nextSelection, ...nextRequestState } = nextState; + if (!nextState.setOnly && !deepCompare(currentRequestState, nextRequestState)) { + const { direction: sortDown = SORTING_OPTIONS.desc, key: sortCol, scope: sortScope } = nextState.sort ?? {}; + const sortBy = sortCol ? [{ attribute: sortCol, order: sortDown, scope: sortScope }] : undefined; + const applicableSelectedState = nextState.state === routes.allDevices.key ? undefined : nextState.state; + nextState.isLoading = true; + tasks.push( + dispatch(getDevicesByStatus({ ...nextState, status: applicableSelectedState, sortOptions: sortBy, fetchAuth })) + .unwrap() + .then(results => { + const { deviceAccu, total } = results[results.length - 1]; + const devicesState = shouldSelectDevices ? { deviceIds: deviceAccu.ids, total, isLoading: false } : { isLoading: false }; + return Promise.resolve(dispatch(actions.setDeviceListState(devicesState))); + }) + // whatever happens, change "loading" back to null + .catch(() => Promise.resolve({ isLoading: false })) + ); + } + tasks.push(dispatch(actions.setDeviceListState(nextState))); + return Promise.all(tasks); + } +); + +// get devices from inventory +export const getDevicesByStatus = createAsyncThunk(`${sliceName}/getDevicesByStatus`, (options, { dispatch, getState }) => { + const { + status, + fetchAuth = true, + filterSelection, + group, + selectedIssues = [], + page = defaultPage, + perPage = defaultPerPage, + sortOptions = [], + selectedAttributes = [] + } = options; + const state = getState(); + const { applicableFilters, filterTerms } = convertDeviceListStateToFilters({ + filters: filterSelection ?? getDeviceFilters(state), + group: group ?? getSelectedGroup(state), + groups: state.devices.groups, + offlineThreshold: state.app.offlineThreshold, + selectedIssues, + status + }); + const attributes = [...defaultAttributes, getIdAttribute(getState()), ...selectedAttributes]; + return GeneralApi.post(getSearchEndpoint(getState()), { + page, + per_page: perPage, + filters: filterTerms, + sort: sortOptions, + attributes + }) + .then(response => { + const state = getState(); + const deviceAccu = reduceReceivedDevices(response.data, [], state, status); + let total = !applicableFilters.length ? Number(response.headers[headerNames.total]) : null; + if (status && state.devices.byStatus[status].total === deviceAccu.ids.length) { + total = deviceAccu.ids.length; + } + let tasks = [dispatch(actions.receivedDevices(deviceAccu.devicesById))]; + if (status) { + tasks.push(dispatch(actions.setDevicesByStatus({ deviceIds: deviceAccu.ids, status, total }))); + } + // for each device, get device identity info + const receivedDevices = Object.values(deviceAccu.devicesById); + if (receivedDevices.length && fetchAuth) { + tasks.push(dispatch(getDevicesWithAuth(receivedDevices))); + } + tasks.push(Promise.resolve({ deviceAccu, total: Number(response.headers[headerNames.total]) })); + return Promise.all(tasks); + }) + .catch(err => commonErrorHandler(err, `${status} devices couldn't be loaded.`, dispatch, commonErrorFallback)); +}); + +export const getAllDevicesByStatus = createAsyncThunk(`${sliceName}/getAllDevicesByStatus`, (status, { dispatch, getState }) => { + const attributes = [...defaultAttributes, getIdAttribute(getState())]; + const getAllDevices = (perPage = MAX_PAGE_SIZE, page = 1, devices = []) => + GeneralApi.post(getSearchEndpoint(getState()), { + page, + per_page: perPage, + filters: mapFiltersToTerms([{ key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }]), + attributes + }).then(res => { + const state = getState(); + const deviceAccu = reduceReceivedDevices(res.data, devices, state, status); + dispatch(actions.receivedDevices(deviceAccu.devicesById)); + const total = Number(res.headers[headerNames.total]); + if (total > state.deployments.deploymentDeviceLimit) { + return Promise.resolve(); + } + if (total > perPage * page) { + return getAllDevices(perPage, page + 1, deviceAccu.ids); + } + let tasks = [dispatch(actions.setDevicesByStatus({ deviceIds: deviceAccu.ids, forceUpdate: true, status, total: deviceAccu.ids.length }))]; + if (status === DEVICE_STATES.accepted && deviceAccu.ids.length === total) { + tasks.push(dispatch(deriveInactiveDevices(deviceAccu.ids))); + tasks.push(dispatch(deriveReportsData())); + } + return Promise.all(tasks); + }); + return getAllDevices(); +}); + +export const searchDevices = createAsyncThunk(`${sliceName}/searchDevices`, (passedOptions = {}, { dispatch, getState }) => { + const state = getState(); + let options = { ...state.app.searchState, ...passedOptions }; + const { page = defaultPage, searchTerm, sortOptions = [] } = options; + const { columnSelection = [] } = getUserSettings(state); + const selectedAttributes = columnSelection.map(column => ({ attribute: column.key, scope: column.scope })); + const attributes = attributeDuplicateFilter([...defaultAttributes, getIdAttribute(state), ...selectedAttributes], 'attribute'); + return GeneralApi.post(getSearchEndpoint(getState()), { + page, + per_page: 10, + filters: [], + sort: sortOptions, + text: searchTerm, + attributes + }) + .then(response => { + const deviceAccu = reduceReceivedDevices(response.data, [], getState()); + return Promise.all([ + dispatch(actions.receivedDevices(deviceAccu.devicesById)), + Promise.resolve({ deviceIds: deviceAccu.ids, searchTotal: Number(response.headers[headerNames.total]) }) + ]); + }) + .catch(err => commonErrorHandler(err, `devices couldn't be searched.`, dispatch, commonErrorFallback)); +}); + +const ATTRIBUTE_LIST_CUTOFF = 100; +const attributeReducer = (attributes = []) => + attributes.slice(0, ATTRIBUTE_LIST_CUTOFF).reduce( + (accu, { name, scope }) => { + if (!accu[scope]) { + accu[scope] = []; + } + accu[scope].push(name); + return accu; + }, + { identity: [], inventory: [], system: [], tags: [] } + ); + +export const getDeviceAttributes = createAsyncThunk(`${sliceName}/getDeviceAttributes`, (_, { dispatch, getState }) => + GeneralApi.get(getAttrsEndpoint(getState())).then(({ data }) => { + // TODO: remove the array fallback once the inventory attributes endpoint is fixed + const { identity: identityAttributes, inventory: inventoryAttributes, system: systemAttributes, tags: tagAttributes } = attributeReducer(data || []); + return dispatch(actions.setFilterAttributes({ identityAttributes, inventoryAttributes, systemAttributes, tagAttributes })); + }) +); + +export const getReportingLimits = createAsyncThunk(`${sliceName}/getReportingLimits`, (_, { dispatch }) => + GeneralApi.get(`${reportingApiUrl}/devices/attributes`) + .catch(err => commonErrorHandler(err, `filterable attributes limit & usage could not be retrieved.`, dispatch, commonErrorFallback)) + .then(({ data }) => { + const { attributes, count, limit } = data; + const groupedAttributes = attributeReducer(attributes); + return Promise.resolve(dispatch(actions.setFilterablesConfig({ count, limit, attributes: groupedAttributes }))); + }) +); + +export const ensureVersionString = (software, fallback) => + software.length && software !== 'artifact_name' ? (software.endsWith('.version') ? software : `${software}.version`) : fallback; + +const getSingleReportData = (reportConfig, groups) => { + const { attribute, group, software = '' } = reportConfig; + const filters = [{ key: 'status', scope: 'identity', operator: DEVICE_FILTERING_OPTIONS.$eq.key, value: 'accepted' }]; + if (group) { + const staticGroupFilter = { key: 'group', scope: 'system', operator: DEVICE_FILTERING_OPTIONS.$eq.key, value: group }; + const { cleanedFilters: groupFilters } = getGroupFilters(group, groups); + filters.push(...(groupFilters.length ? groupFilters : [staticGroupFilter])); + } + const aggregationAttribute = ensureVersionString(software, attribute); + return GeneralApi.post(`${reportingApiUrl}/devices/aggregate`, { + aggregations: [{ attribute: aggregationAttribute, name: '*', scope: 'inventory', size: chartColorPalette.length }], + filters: mapFiltersToTerms(filters) + }).then(({ data }) => ({ data, reportConfig })); +}; + +export const getReportsData = createAsyncThunk(`${sliceName}/getReportsData`, (_, { dispatch, getState }) => { + const state = getState(); + const currentUserId = getCurrentUser(state).id; + const reports = + getUserSettings(state).reports || getGlobalSettings(state)[`${currentUserId}-reports`] || (Object.keys(getDevicesById(state)).length ? defaultReports : []); + return Promise.all(reports.map(report => getSingleReportData(report, getState().devices.groups))).then(results => { + const devicesState = getState().devices; + const totalDeviceCount = devicesState.byStatus.accepted.total; + const newReports = results.map(({ data, reportConfig }) => { + let { items, other_count } = data[0]; + const { attribute, group, software = '' } = reportConfig; + const dataCount = items.reduce((accu, item) => accu + item.count, 0); + // the following is needed to show reports including both old (artifact_name) & current style (rootfs-image.version) device software + const otherCount = !group && (software === rootfsImageVersion || attribute === 'artifact_name') ? totalDeviceCount - dataCount : other_count; + return { items, otherCount, total: otherCount + dataCount }; + }); + return Promise.resolve(dispatch(actions.setDeviceReports(newReports))); + }); +}); + +const initializeDistributionData = (report, groups, devices, totalDeviceCount) => { + const { attribute, group = '', software = '' } = report; + const effectiveAttribute = software ? software : attribute; + const { deviceIds, total = 0 } = groups[group] || {}; + const relevantDevices = groups[group] ? deviceIds.map(id => devices[id]) : Object.values(devices); + const distributionByAttribute = relevantDevices.reduce((accu, item) => { + if (!item.attributes || item.status !== DEVICE_STATES.accepted) return accu; + if (!accu[item.attributes[effectiveAttribute]]) { + accu[item.attributes[effectiveAttribute]] = 0; + } + accu[item.attributes[effectiveAttribute]] = accu[item.attributes[effectiveAttribute]] + 1; + return accu; + }, {}); + const distributionByAttributeSorted = Object.entries(distributionByAttribute).sort((pairA, pairB) => pairB[1] - pairA[1]); + const items = distributionByAttributeSorted.map(([key, count]) => ({ key, count })); + const dataCount = items.reduce((accu, item) => accu + item.count, 0); + // the following is needed to show reports including both old (artifact_name) & current style (rootfs-image.version) device software + const otherCount = (groups[group] ? total : totalDeviceCount) - dataCount; + return { items, otherCount, total: otherCount + dataCount }; +}; + +export const deriveReportsData = createAsyncThunk(`${sliceName}/deriveReportsData`, (_, { dispatch, getState }) => { + const state = getState(); + const { + groups: { byId: groupsById }, + byId, + byStatus: { + accepted: { total } + } + } = state.devices; + const reports = + getUserSettings(state).reports || state.users.globalSettings[`${state.users.currentUser}-reports`] || (Object.keys(byId).length ? defaultReports : []); + const newReports = reports.map(report => initializeDistributionData(report, groupsById, byId, total)); + return Promise.resolve(dispatch(actions.setDeviceReports(newReports))); +}); + +export const getReportsDataWithoutBackendSupport = createAsyncThunk(`${sliceName}/getReportsDataWithoutBackendSupport`, (_, { dispatch, getState }) => + Promise.all([dispatch(getAllDevicesByStatus(DEVICE_STATES.accepted)), dispatch(getGroups()), dispatch(getDynamicGroups())]).then(() => { + const { dynamic: dynamicGroups, static: staticGroups } = getGroupsSelector(getState()); + return Promise.all([ + ...staticGroups.map(({ groupId }) => dispatch(getAllGroupDevices(groupId))), + ...dynamicGroups.map(({ groupId }) => dispatch(getAllDynamicGroupDevices(groupId))) + ]).then(() => dispatch(deriveReportsData())); + }) +); + +export const getDeviceConnect = createAsyncThunk(`${sliceName}/getDeviceConnect`, (id, { dispatch }) => + GeneralApi.get(`${deviceConnect}/devices/${id}`).then(({ data }) => + Promise.all([dispatch(actions.receivedDevice({ connect_status: data.status, connect_updated_ts: data.updated_ts, id })), Promise.resolve(data)]) + ) +); + +export const getSessionDetails = createAsyncThunk(`${sliceName}/getSessionDetails`, ({ sessionId, deviceId, userId, startDate, endDate }) => { + const createdAfter = startDate ? `&created_after=${Math.round(Date.parse(startDate) / 1000)}` : ''; + const createdBefore = endDate ? `&created_before=${Math.round(Date.parse(endDate) / 1000)}` : ''; + const objectSearch = `&object_id=${deviceId}`; + return GeneralApi.get(`${auditLogsApiUrl}/logs?per_page=500${createdAfter}${createdBefore}&actor_id=${userId}${objectSearch}`).then( + ({ data: auditLogEntries }) => { + const { start, end } = auditLogEntries.reduce( + (accu, item) => { + if (item.meta?.session_id?.includes(sessionId)) { + accu.start = new Date(item.action.startsWith('open') ? item.time : accu.start); + accu.end = new Date(item.action.startsWith('close') ? item.time : accu.end); + } + return accu; + }, + { start: startDate || endDate, end: endDate || startDate } + ); + return Promise.resolve({ start, end }); + } + ); +}); + +export const getDeviceFileDownloadLink = createAsyncThunk(`${sliceName}/getDeviceFileDownloadLink`, ({ deviceId, path }) => + Promise.resolve(`${deviceConnect}/devices/${deviceId}/download?path=${encodeURIComponent(path)}`) +); + +export const deviceFileUpload = createAsyncThunk(`${sliceName}/deviceFileUpload`, ({ deviceId, path, file }, { dispatch }) => { + let formData = new FormData(); + formData.append('path', path); + formData.append('file', file); + const uploadId = uuid(); + const cancelSource = new AbortController(); + return Promise.all([ + dispatch(setSnackbar('Uploading file')), + dispatch(initUpload({ id: uploadId, upload: { inprogress: true, uploadProgress: 0, cancelSource } })), + GeneralApi.uploadPut( + `${deviceConnect}/devices/${deviceId}/upload`, + formData, + e => dispatch(uploadProgress({ id: uploadId, progress: progress(e) })), + cancelSource.signal + ) + ]) + .then(() => Promise.resolve(dispatch(setSnackbar('Upload successful', TIMEOUTS.fiveSeconds)))) + .catch(err => { + if (isCancel(err)) { + return dispatch(setSnackbar('The upload has been cancelled', TIMEOUTS.fiveSeconds)); + } + return commonErrorHandler(err, `Error uploading file to device.`, dispatch); + }) + .finally(() => dispatch(cleanUpUpload(uploadId))); +}); + +export const getDeviceAuth = createAsyncThunk(`${sliceName}/getDeviceAuth`, (id, { dispatch }) => + dispatch(getDevicesWithAuth([{ id }])) + .unwrap() + .then(results => { + if (results[results.length - 1]) { + return Promise.resolve(results[results.length - 1][0]); + } + return Promise.resolve(); + }) +); + +export const getDevicesWithAuth = createAsyncThunk(`${sliceName}/getDevicesWithAuth`, (devices, { dispatch, getState }) => + devices.length + ? GeneralApi.get(`${deviceAuthV2}/devices?id=${devices.map(device => device.id).join('&id=')}`) + .then(({ data: receivedDevices }) => { + const { devicesById } = reduceReceivedDevices(receivedDevices, [], getState()); + return Promise.all([dispatch(actions.receivedDevices(devicesById)), Promise.resolve(receivedDevices)]); + }) + .catch(err => commonErrorHandler(err, `Error: ${err}`, dispatch)) + : Promise.resolve([[], []]) +); + +export const updateDeviceAuth = createAsyncThunk(`${sliceName}/updateDeviceAuth`, ({ deviceId, authId, status }, { dispatch, getState }) => + GeneralApi.put(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}/status`, { status }) + .then(() => Promise.all([dispatch(getDeviceAuth(deviceId)), dispatch(setSnackbar('Device authorization status was updated successfully'))])) + .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch)) + .then(() => Promise.resolve(dispatch(actions.maybeUpdateDevicesByStatus({ deviceId, authId })))) + .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getDeviceListState(getState()).refreshTrigger }))) +); + +export const updateDevicesAuth = createAsyncThunk(`${sliceName}/updateDevicesAuth`, ({ deviceIds, status }, { dispatch, getState }) => { + let devices = getDevicesById(getState()); + const deviceIdsWithoutAuth = deviceIds.reduce((accu, id) => (devices[id].auth_sets ? accu : [...accu, { id }]), []); + return dispatch(getDevicesWithAuth(deviceIdsWithoutAuth)).then(() => { + devices = getDevicesById(getState()); + // for each device, get id and id of authset & make api call to accept + // if >1 authset, skip instead + const deviceAuthUpdates = deviceIds.map(id => { + const device = devices[id]; + if (device.auth_sets.length !== 1) { + return Promise.reject(); + } + // api call device.id and device.authsets[0].id + return dispatch(updateDeviceAuth({ authId: device.auth_sets[0].id, deviceId: device.id, status })) + .unwrap() + .catch(err => commonErrorHandler(err, 'The action was stopped as there was a problem updating a device authorization status: ', dispatch, '', false)); + }); + return Promise.allSettled(deviceAuthUpdates).then(results => { + const { skipped, count } = results.reduce( + (accu, item) => { + if (item.status === 'rejected') { + accu.skipped = accu.skipped + 1; + } else { + accu.count = accu.count + 1; + } + return accu; + }, + { skipped: 0, count: 0 } + ); + const message = getSnackbarMessage(skipped, count); + // break if an error occurs, display status up til this point before error message + return dispatch(setSnackbar(message)); + }); + }); +}); + +export const deleteAuthset = createAsyncThunk(`${sliceName}/deleteAuthset`, ({ deviceId, authId }, { dispatch, getState }) => + GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}/auth/${authId}`) + .then(() => Promise.all([dispatch(setSnackbar('Device authorization status was updated successfully'))])) + .catch(err => commonErrorHandler(err, 'There was a problem updating the device authorization status:', dispatch)) + .then(() => Promise.resolve(dispatch(actions.maybeUpdateDevicesByStatus({ deviceId, authId })))) + .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger }))) +); + +export const preauthDevice = createAsyncThunk(`${sliceName}/preauthDevice`, (authset, { dispatch }) => + GeneralApi.post(`${deviceAuthV2}/devices`, authset) + .catch(err => { + if (err.response.status === 409) { + return Promise.reject('A device with a matching identity data set already exists'); + } + return commonErrorHandler(err, 'The device could not be added:', dispatch); + }) + .then(() => Promise.resolve(dispatch(setSnackbar('Device was successfully added to the preauthorization list', TIMEOUTS.fiveSeconds)))) +); + +export const decommissionDevice = createAsyncThunk(`${sliceName}/decommissionDevice`, ({ deviceId, authId }, { dispatch, getState }) => + GeneralApi.delete(`${deviceAuthV2}/devices/${deviceId}`) + .then(() => Promise.resolve(dispatch(setSnackbar('Device was decommissioned successfully')))) + .catch(err => commonErrorHandler(err, 'There was a problem decommissioning the device:', dispatch)) + .then(() => Promise.resolve(dispatch(actions.maybeUpdateDevicesByStatus({ deviceId, authId })))) + // trigger reset of device list list! + .finally(() => dispatch(setDeviceListState({ refreshTrigger: !getState().devices.deviceList.refreshTrigger }))) +); + +export const getDeviceConfig = createAsyncThunk(`${sliceName}/getDeviceConfig`, (deviceId, { dispatch }) => + GeneralApi.get(`${deviceConfig}/${deviceId}`) + .then(({ data }) => Promise.all([dispatch(actions.receivedDevice({ id: deviceId, config: data })), Promise.resolve(data)])) + .catch(err => { + // if we get a proper error response we most likely queried a device without an existing config check-in and we can just ignore the call + if (err.response?.data?.error.status_code !== 404) { + return commonErrorHandler(err, `There was an error retrieving the configuration for device ${deviceId}.`, dispatch, commonErrorFallback); + } + }) +); + +export const setDeviceConfig = createAsyncThunk(`${sliceName}/setDeviceConfig`, ({ deviceId, config }, { dispatch }) => + GeneralApi.put(`${deviceConfig}/${deviceId}`, config) + .catch(err => commonErrorHandler(err, `There was an error setting the configuration for device ${deviceId}.`, dispatch, commonErrorFallback)) + .then(() => Promise.resolve(dispatch(getDeviceConfig(deviceId)))) +); + +export const applyDeviceConfig = createAsyncThunk( + `${sliceName}/applyDeviceConfig`, + ({ deviceId, configDeploymentConfiguration, isDefault, config }, { dispatch, getState }) => + GeneralApi.post(`${deviceConfig}/${deviceId}/deploy`, configDeploymentConfiguration) + .catch(err => commonErrorHandler(err, `There was an error deploying the configuration to device ${deviceId}.`, dispatch, commonErrorFallback)) + .then(({ data }) => { + const device = getDeviceByIdSelector(getState(), deviceId); + const { canManageUsers } = getUserCapabilities(getState()); + let tasks = [ + dispatch(actions.receivedDevice({ ...device, config: { ...device.config, deployment_id: data.deployment_id } })), + new Promise(resolve => setTimeout(() => resolve(dispatch(getSingleDeployment(data.deployment_id))), TIMEOUTS.oneSecond)) + ]; + if (isDefault && canManageUsers) { + const { previous } = getGlobalSettings(getState()).defaultDeviceConfig ?? {}; + tasks.push(dispatch(saveGlobalSettings({ defaultDeviceConfig: { current: config, previous } }))); + } + return Promise.all(tasks); + }) +); + +export const setDeviceTags = createAsyncThunk(`${sliceName}/setDeviceTags`, ({ deviceId, tags }, { dispatch }) => + // to prevent tag set failures, retrieve the device & use the freshest etag we can get + Promise.resolve(dispatch(getDeviceById(deviceId))).then(device => { + const headers = device.etag ? { 'If-Match': device.etag } : {}; + return GeneralApi.put( + `${inventoryApiUrl}/devices/${deviceId}/tags`, + Object.entries(tags).map(([name, value]) => ({ name, value })), + { headers } + ) + .catch(err => commonErrorHandler(err, `There was an error setting tags for device ${deviceId}.`, dispatch, 'Please check your connection.')) + .then(() => Promise.all([dispatch(actions.receivedDevice({ ...device, id: deviceId, tags })), dispatch(setSnackbar('Device name changed'))])); + }) +); + +export const getDeviceTwin = createAsyncThunk(`${sliceName}/getDeviceTwin`, ({ deviceId, integration }, { dispatch, getState }) => { + let providerResult = {}; + return GeneralApi.get(`${iotManagerBaseURL}/devices/${deviceId}/state`) + .then(({ data }) => { + providerResult = { ...data, twinError: '' }; + }) + .catch(err => { + providerResult = { + twinError: `There was an error getting the ${EXTERNAL_PROVIDER[integration.provider].twinTitle.toLowerCase()} for device ${deviceId}. ${err}` + }; + }) + .finally(() => { + const device = getDeviceByIdSelector(getState(), deviceId); + Promise.resolve(dispatch(actions.receivedDevice({ ...device, twinsByIntegration: { ...device.twinsByIntegration, ...providerResult } }))); + }); +}); + +export const setDeviceTwin = createAsyncThunk(`${sliceName}/setDeviceTwin`, ({ deviceId, integration, settings }, { dispatch, getState }) => + GeneralApi.put(`${iotManagerBaseURL}/devices/${deviceId}/state/${integration.id}`, { desired: settings }) + .catch(err => + commonErrorHandler( + err, + `There was an error updating the ${EXTERNAL_PROVIDER[integration.provider].twinTitle.toLowerCase()} for device ${deviceId}.`, + dispatch + ) + ) + .then(() => { + const device = getDeviceByIdSelector(getState(), deviceId); + const { twinsByIntegration = {} } = device; + const { [integration.id]: currentState = {} } = twinsByIntegration; + return Promise.resolve( + dispatch(actions.receivedDevice({ ...device, twinsByIntegration: { ...twinsByIntegration, [integration.id]: { ...currentState, desired: settings } } })) + ); + }) +); + +const prepareSearchArguments = ({ filters, group, state, status }) => { + const { filterTerms } = convertDeviceListStateToFilters({ filters, group, offlineThreshold: state.app.offlineThreshold, selectedIssues: [], status }); + const { columnSelection = [] } = getUserSettings(state); + const selectedAttributes = columnSelection.map(column => ({ attribute: column.key, scope: column.scope })); + const attributes = [...defaultAttributes, getIdAttribute(state), ...selectedAttributes]; + return { attributes, filterTerms }; +}; + +export const getSystemDevices = createAsyncThunk(`${sliceName}/getSystemDevices`, (options, { dispatch, getState }) => { + const { id, page = defaultPage, perPage = defaultPerPage, sortOptions = [] } = options; + const state = getState(); + const { hasFullFiltering } = getTenantCapabilities(state); + if (!hasFullFiltering) { + return Promise.resolve(); + } + const { attributes: deviceAttributes = {} } = getDeviceByIdSelector(state, id); + const { mender_gateway_system_id = '' } = deviceAttributes; + const filters = [ + { ...emptyFilter, key: 'mender_is_gateway', operator: DEVICE_FILTERING_OPTIONS.$ne.key, value: 'true', scope: 'inventory' }, + { ...emptyFilter, key: 'mender_gateway_system_id', value: mender_gateway_system_id, scope: 'inventory' } + ]; + const { attributes, filterTerms } = prepareSearchArguments({ filters, state }); + return GeneralApi.post(getSearchEndpoint(getState()), { + page, + per_page: perPage, + filters: filterTerms, + sort: sortOptions, + attributes + }) + .catch(err => commonErrorHandler(err, `There was an error getting system devices device ${id}.`, dispatch, 'Please check your connection.')) + .then(({ data, headers }) => { + const state = getState(); + const { devicesById, ids } = reduceReceivedDevices(data, [], state); + const device = { + ...getDeviceByIdSelector(state, id), + systemDeviceIds: ids, + systemDeviceTotal: Number(headers[headerNames.total]) + }; + return Promise.resolve(dispatch(actions.receivedDevices({ ...devicesById, [id]: device }))); + }); +}); + +export const getGatewayDevices = createAsyncThunk(`${sliceName}/getGatewayDevices`, (deviceId, { dispatch, getState }) => { + const state = getState(); + const { attributes = {} } = getDeviceByIdSelector(state, deviceId); + const { mender_gateway_system_id = '' } = attributes; + const filters = [ + { ...emptyFilter, key: 'id', operator: DEVICE_FILTERING_OPTIONS.$ne.key, value: deviceId, scope: 'identity' }, + { ...emptyFilter, key: 'mender_is_gateway', value: 'true', scope: 'inventory' }, + { ...emptyFilter, key: 'mender_gateway_system_id', value: mender_gateway_system_id, scope: 'inventory' } + ]; + const { attributes: attributeSelection, filterTerms } = prepareSearchArguments({ filters, state }); + return GeneralApi.post(getSearchEndpoint(getState()), { + page: 1, + per_page: MAX_PAGE_SIZE, + filters: filterTerms, + attributes: attributeSelection + }).then(({ data }) => { + const { ids } = reduceReceivedDevices(data, [], getState()); + let tasks = ids.map(deviceId => dispatch(getDeviceInfo(deviceId))); + tasks.push(dispatch(actions.receivedDevice({ id: deviceId, gatewayIds: ids }))); + return Promise.all(tasks); + }); +}); + +export const getDevicesInBounds = createAsyncThunk(`${sliceName}/getDevicesInBounds`, ({ bounds, group }, { dispatch, getState }) => { + const state = getState(); + const { filterTerms } = convertDeviceListStateToFilters({ + group: group === ALL_DEVICES ? undefined : group, + groups: state.devices.groups, + status: DEVICE_STATES.accepted + }); + return GeneralApi.post(getSearchEndpoint(getState()), { + page: 1, + per_page: MAX_PAGE_SIZE, + filters: filterTerms, + attributes: geoAttributes, + geo_bounding_box_filter: { + geo_bounding_box: { + location: { + top_left: { lat: bounds._northEast.lat, lon: bounds._southWest.lng }, + bottom_right: { lat: bounds._southWest.lat, lon: bounds._northEast.lng } + } + } + } + }).then(({ data }) => { + const { devicesById } = reduceReceivedDevices(data, [], getState()); + return Promise.resolve(dispatch(actions.receivedDevices(devicesById))); + }); +}); diff --git a/frontend/src/js/constants/monitorConstants.js b/frontend/src/js/store/monitorSlice/constants.ts similarity index 62% rename from frontend/src/js/constants/monitorConstants.js rename to frontend/src/js/store/monitorSlice/constants.ts index 279de099..8e81e9fd 100644 --- a/frontend/src/js/constants/monitorConstants.js +++ b/frontend/src/js/store/monitorSlice/constants.ts @@ -10,11 +10,8 @@ // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and +import { apiUrl } from '@northern.tech/store/constants'; + // limitations under the License. -export const CHANGE_ALERT_CHANNEL = 'CHANGE_ALERT_CHANNEL'; -export const RECEIVE_DEVICE_ALERTS = 'RECEIVE_DEVICE_ALERTS'; -export const RECEIVE_DEVICE_MONITOR_CONFIG = 'RECEIVE_DEVICE_MONITOR_CONFIG'; -export const RECEIVE_DEVICE_ISSUE_COUNTS = 'RECEIVE_DEVICE_ISSUE_COUNTS'; -export const RECEIVE_LATEST_DEVICE_ALERTS = 'RECEIVE_LATEST_DEVICE_ALERTS'; -export const SET_ALERT_LIST_STATE = 'SET_ALERT_LIST_STATE'; export const alertChannels = { email: 'email' }; +export const monitorApiUrlv1 = `${apiUrl.v1}/devicemonitor`; diff --git a/frontend/src/js/store/monitorSlice/index.ts b/frontend/src/js/store/monitorSlice/index.ts new file mode 100644 index 00000000..f0d2e9d6 --- /dev/null +++ b/frontend/src/js/store/monitorSlice/index.ts @@ -0,0 +1,65 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { DEVICE_ISSUE_OPTIONS, DEVICE_LIST_DEFAULTS } from '@northern.tech/store/commonConstants'; +import { createSlice } from '@reduxjs/toolkit'; + +import { alertChannels } from './constants'; + +export const sliceName = 'monitor'; + +export const initialState = { + alerts: { + alertList: { ...DEVICE_LIST_DEFAULTS, total: 0 }, + byDeviceId: {} + }, + issueCounts: { + byType: Object.values(DEVICE_ISSUE_OPTIONS).reduce((accu, { key }) => ({ ...accu, [key]: { filtered: 0, total: 0 } }), {}) + }, + settings: { + global: { + channels: { + ...Object.keys(alertChannels).reduce((accu, item) => ({ ...accu, [item]: { enabled: true } }), {}) + } + } + } +}; + +export const monitorSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + changeAlertChannel: (state, action) => { + const { channel, enabled } = action.payload; + state.settings.global.channels[channel] = { enabled }; + }, + receiveDeviceAlerts: (state, action) => { + const { deviceId, alerts } = action.payload; + state.alerts.byDeviceId[deviceId] = { alerts }; + }, + receiveLatestDeviceAlerts: (state, action) => { + const { deviceId, alerts } = action.payload; + state.alerts.byDeviceId[deviceId] = { ...state.alerts.byDeviceId[deviceId], latest: alerts }; + }, + receiveDeviceIssueCounts: (state, action) => { + const { issueType, counts } = action.payload; + state.issueCounts.byType[issueType] = counts; + }, + setAlertListState: (state, action) => { + state.alerts.alertList = { ...state.alerts.alertList, ...action.payload }; + } + } +}); + +export const actions = monitorSlice.actions; +export default monitorSlice.reducer; diff --git a/frontend/src/js/store/monitorSlice/reducer.test.ts b/frontend/src/js/store/monitorSlice/reducer.test.ts new file mode 100644 index 00000000..2020e039 --- /dev/null +++ b/frontend/src/js/store/monitorSlice/reducer.test.ts @@ -0,0 +1,79 @@ +// Copyright 2021 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import reducer, { actions, initialState } from '.'; +import { defaultState } from '../../../../tests/mockData'; +import { DEVICE_ISSUE_OPTIONS, DEVICE_LIST_DEFAULTS } from '../constants'; +import { alertChannels } from './constants'; + +describe('monitor reducer', () => { + it('should return the initial state', async () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + + it('should handle CHANGE_ALERT_CHANNEL', async () => { + expect( + reducer(undefined, { type: actions.changeAlertChannel, payload: { channel: alertChannels.email, enabled: false } }).settings.global.channels[ + alertChannels.email + ].enabled + ).toEqual(false); + expect( + reducer(initialState, { type: actions.changeAlertChannel, payload: { channel: alertChannels.email, enabled: true } }).settings.global.channels[ + alertChannels.email + ].enabled + ).toEqual(true); + }); + it('should handle RECEIVE_DEVICE_ALERTS', async () => { + expect( + reducer(undefined, { type: actions.receiveDeviceAlerts, payload: { deviceId: defaultState.devices.byId.a1.id, alerts: [] } }).alerts.byDeviceId[ + defaultState.devices.byId.a1.id + ].alerts + ).toEqual([]); + expect( + reducer(initialState, { type: actions.receiveDeviceAlerts, payload: { deviceId: defaultState.devices.byId.a1.id, alerts: [123, 456] } }).alerts + .byDeviceId[defaultState.devices.byId.a1.id].alerts + ).toEqual([123, 456]); + }); + it('should handle RECEIVE_LATEST_DEVICE_ALERTS', async () => { + expect( + reducer(undefined, { type: actions.receiveLatestDeviceAlerts, payload: { deviceId: defaultState.devices.byId.a1.id, alerts: [] } }).alerts.byDeviceId[ + defaultState.devices.byId.a1.id + ].latest + ).toEqual([]); + expect( + reducer(initialState, { type: actions.receiveLatestDeviceAlerts, payload: { deviceId: defaultState.devices.byId.a1.id, alerts: [123, 456] } }).alerts + .byDeviceId[defaultState.devices.byId.a1.id].latest + ).toEqual([123, 456]); + }); + it('should handle RECEIVE_DEVICE_ISSUE_COUNTS', async () => { + expect( + reducer(undefined, { + type: actions.receiveDeviceIssueCounts, + payload: { issueType: DEVICE_ISSUE_OPTIONS.monitoring.key, counts: { filtered: 1, total: 3 } } + }).issueCounts.byType[DEVICE_ISSUE_OPTIONS.monitoring.key] + ).toEqual({ filtered: 1, total: 3 }); + expect( + reducer(initialState, { type: actions.receiveDeviceIssueCounts, payload: { issueType: DEVICE_ISSUE_OPTIONS.monitoring.key, counts: { total: 3 } } }) + .issueCounts.byType[DEVICE_ISSUE_OPTIONS.monitoring.key] + ).toEqual({ total: 3 }); + }); + it('should handle SET_ALERT_LIST_STATE', async () => { + expect(reducer(undefined, { type: actions.setAlertListState, payload: { total: 3 } }).alerts.alertList).toEqual({ ...DEVICE_LIST_DEFAULTS, total: 3 }); + expect(reducer(initialState, { type: actions.setAlertListState, payload: { something: 'something' } }).alerts.alertList).toEqual({ + ...DEVICE_LIST_DEFAULTS, + total: 0, + something: 'something' + }); + }); +}); diff --git a/frontend/src/js/store/monitorSlice/selectors.ts b/frontend/src/js/store/monitorSlice/selectors.ts new file mode 100644 index 00000000..4d4ea8b0 --- /dev/null +++ b/frontend/src/js/store/monitorSlice/selectors.ts @@ -0,0 +1,15 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const getIssueCountsByType = state => state.monitor.issueCounts.byType; diff --git a/frontend/src/js/actions/monitorActions.test.js b/frontend/src/js/store/monitorSlice/thunks.test.ts similarity index 64% rename from frontend/src/js/actions/monitorActions.test.js rename to frontend/src/js/store/monitorSlice/thunks.test.ts index e8c6222f..373603db 100644 --- a/frontend/src/js/actions/monitorActions.test.js +++ b/frontend/src/js/store/monitorSlice/thunks.test.ts @@ -11,14 +11,16 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck +import { DEVICE_ISSUE_OPTIONS } from '@northern.tech/store/commonConstants'; import configureMockStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; -import { defaultState } from '../../../tests/mockData'; -import * as AppConstants from '../constants/appConstants'; -import { DEVICE_ISSUE_OPTIONS } from '../constants/deviceConstants'; -import * as MonitorConstants from '../constants/monitorConstants'; -import { changeNotificationSetting, getDeviceAlerts, getDeviceMonitorConfig, getIssueCountsByType, getLatestDeviceAlerts } from './monitorActions'; +import { actions } from '.'; +import { defaultState } from '../../../../tests/mockData'; +import { actions as appActions } from '../appSlice'; +import { actions as deviceActions } from '../devicesSlice'; +import { changeNotificationSetting, getDeviceAlerts, getDeviceMonitorConfig, getIssueCountsByType, getLatestDeviceAlerts } from './thunks'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -29,14 +31,12 @@ describe('monitor actions', () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { - type: MonitorConstants.RECEIVE_DEVICE_ALERTS, - deviceId: defaultState.devices.byId.a1.id, - alerts: [] - }, - { type: MonitorConstants.SET_ALERT_LIST_STATE, value: { page: 1, perPage: 20, total: 1 } } + { type: getDeviceAlerts.pending.type }, + { type: actions.receiveDeviceAlerts.type, payload: { deviceId: defaultState.devices.byId.a1.id, alerts: [] } }, + { type: actions.setAlertListState.type, payload: { total: 1 } }, + { type: getDeviceAlerts.fulfilled.type } ]; - const request = store.dispatch(getDeviceAlerts(defaultState.devices.byId.a1.id)); + const request = store.dispatch(getDeviceAlerts({ id: defaultState.devices.byId.a1.id })); expect(request).resolves.toBeTruthy(); await request.then(() => { const storeActions = store.getActions(); @@ -48,13 +48,11 @@ describe('monitor actions', () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { - type: MonitorConstants.RECEIVE_LATEST_DEVICE_ALERTS, - deviceId: defaultState.devices.byId.a1.id, - alerts: [] - } + { type: getLatestDeviceAlerts.pending.type }, + { type: actions.receiveLatestDeviceAlerts.type, payload: { deviceId: defaultState.devices.byId.a1.id, alerts: [] } }, + { type: getLatestDeviceAlerts.fulfilled.type } ]; - const request = store.dispatch(getLatestDeviceAlerts(defaultState.devices.byId.a1.id)); + const request = store.dispatch(getLatestDeviceAlerts({ id: defaultState.devices.byId.a1.id })); expect(request).resolves.toBeTruthy(); await request.then(() => { const storeActions = store.getActions(); @@ -66,13 +64,11 @@ describe('monitor actions', () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { - type: MonitorConstants.RECEIVE_DEVICE_ISSUE_COUNTS, - issueType: DEVICE_ISSUE_OPTIONS.monitoring.key, - counts: { filtered: 4, total: 4 } - } + { type: getIssueCountsByType.pending.type }, + { type: actions.receiveDeviceIssueCounts.type, payload: { issueType: DEVICE_ISSUE_OPTIONS.monitoring.key, counts: { filtered: 4, total: 4 } } }, + { type: getIssueCountsByType.fulfilled.type } ]; - const request = store.dispatch(getIssueCountsByType(DEVICE_ISSUE_OPTIONS.monitoring.key)); + const request = store.dispatch(getIssueCountsByType({ type: DEVICE_ISSUE_OPTIONS.monitoring.key })); expect(request).resolves.toBeTruthy(); await request.then(() => { const storeActions = store.getActions(); @@ -84,10 +80,9 @@ describe('monitor actions', () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { - type: MonitorConstants.RECEIVE_DEVICE_MONITOR_CONFIG, - device: { id: defaultState.devices.byId.a1.id, monitors: [{ something: 'here' }] } - } + { type: getDeviceMonitorConfig.pending.type }, + { type: deviceActions.receivedDevice.type, payload: { id: defaultState.devices.byId.a1.id, monitors: [{ something: 'here' }] } }, + { type: getDeviceMonitorConfig.fulfilled.type } ]; const request = store.dispatch(getDeviceMonitorConfig(defaultState.devices.byId.a1.id)); expect(request).resolves.toBeTruthy(); @@ -101,19 +96,12 @@ describe('monitor actions', () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { - type: MonitorConstants.CHANGE_ALERT_CHANNEL, - channel: 'email', - enabled: false - }, - { - type: AppConstants.SET_SNACKBAR, - snackbar: { - message: 'Successfully disabled email alerts' - } - } + { type: changeNotificationSetting.pending.type }, + { type: actions.changeAlertChannel.type, payload: { channel: 'email', enabled: false } }, + { type: appActions.setSnackbar.type, payload: 'Successfully disabled email alerts' }, + { type: changeNotificationSetting.fulfilled.type } ]; - const request = store.dispatch(changeNotificationSetting(false)); + const request = store.dispatch(changeNotificationSetting({ enabled: false })); expect(request).resolves.toBeTruthy(); await request.then(() => { const storeActions = store.getActions(); diff --git a/frontend/src/js/store/monitorSlice/thunks.ts b/frontend/src/js/store/monitorSlice/thunks.ts new file mode 100644 index 00000000..46fe7a9c --- /dev/null +++ b/frontend/src/js/store/monitorSlice/thunks.ts @@ -0,0 +1,101 @@ +// Copyright 2021 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import storeActions from '@northern.tech/store/actions'; +import Api from '@northern.tech/store/api/general-api'; +import { DEVICE_LIST_DEFAULTS, TIMEOUTS, alertChannels, headerNames } from '@northern.tech/store/constants'; +import { getDeviceFilters, getSearchEndpoint } from '@northern.tech/store/selectors'; +import { commonErrorFallback, commonErrorHandler } from '@northern.tech/store/store'; +import { convertDeviceListStateToFilters } from '@northern.tech/store/utils'; +import { createAsyncThunk } from '@reduxjs/toolkit'; + +import { actions, sliceName } from '.'; +import { monitorApiUrlv1 } from './constants'; + +const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS; + +const cutoffLength = 75; +const ellipsis = '...'; +const longTextTrimmer = text => (text.length >= cutoffLength + ellipsis.length ? `${text.substring(0, cutoffLength + ellipsis.length)}${ellipsis}` : text); + +const sanitizeDeviceAlerts = alerts => alerts.map(alert => ({ ...alert, fullName: alert.name, name: longTextTrimmer(alert.name) })); + +export const getDeviceAlerts = createAsyncThunk(`${sliceName}/getDeviceAlerts`, ({ id, config = {} }, { dispatch }) => { + const { page = defaultPage, perPage = defaultPerPage, issuedBefore, issuedAfter, sortAscending = false } = config; + const issued_after = issuedAfter ? `&issued_after=${issuedAfter}` : ''; + const issued_before = issuedBefore ? `&issued_before=${issuedBefore}` : ''; + return Api.get(`${monitorApiUrlv1}/devices/${id}/alerts?page=${page}&per_page=${perPage}${issued_after}${issued_before}&sort_ascending=${sortAscending}`) + .catch(err => commonErrorHandler(err, `Retrieving device alerts for device ${id} failed:`, dispatch)) + .then(res => + Promise.all([ + dispatch(actions.receiveDeviceAlerts({ deviceId: id, alerts: sanitizeDeviceAlerts(res.data) })), + dispatch(actions.setAlertListState({ total: Number(res.headers[headerNames.total]) })) + ]) + ); +}); + +export const getLatestDeviceAlerts = createAsyncThunk(`${sliceName}/getLatestDeviceAlerts`, ({ id, config = {} }, { dispatch }) => { + const { page = defaultPage, perPage = 10 } = config; + return Api.get(`${monitorApiUrlv1}/devices/${id}/alerts/latest?page=${page}&per_page=${perPage}`) + .catch(err => commonErrorHandler(err, `Retrieving device alerts for device ${id} failed:`, dispatch)) + .then(res => Promise.resolve(dispatch(actions.receiveLatestDeviceAlerts({ deviceId: id, alerts: sanitizeDeviceAlerts(res.data) })))); +}); + +export const getIssueCountsByType = createAsyncThunk(`${sliceName}/getIssueCountsByType`, ({ type, options = {} }, { dispatch, getState }) => { + const state = getState(); + const { filters = getDeviceFilters(state), group, status, ...remainder } = options; + const { applicableFilters: nonMonitorFilters, filterTerms } = convertDeviceListStateToFilters({ + ...remainder, + filters, + group, + offlineThreshold: state.app.offlineThreshold, + selectedIssues: [type], + status + }); + return Api.post(getSearchEndpoint(getState()), { + page: 1, + per_page: 1, + filters: filterTerms, + attributes: [{ scope: 'identity', attribute: 'status' }] + }) + .catch(err => commonErrorHandler(err, `Retrieving issue counts failed:`, dispatch, commonErrorFallback)) + .then(res => { + const total = nonMonitorFilters.length ? state.monitor.issueCounts.byType[type].total : Number(res.headers[headerNames.total]); + const filtered = nonMonitorFilters.length ? Number(res.headers[headerNames.total]) : total; + if (total === state.monitor.issueCounts.byType[type].total && filtered === state.monitor.issueCounts.byType[type].filtered) { + return Promise.resolve(); + } + return Promise.resolve(dispatch(actions.receiveDeviceIssueCounts({ counts: { filtered, total }, issueType: type }))); + }); +}); + +export const getDeviceMonitorConfig = createAsyncThunk(`${sliceName}/getDeviceMonitorConfig`, (id, { dispatch }) => + Api.get(`${monitorApiUrlv1}/devices/${id}/config`) + .catch(err => commonErrorHandler(err, `Retrieving device monitor config for device ${id} failed:`, dispatch)) + .then(({ data }) => Promise.all([dispatch(storeActions.receivedDevice({ id, monitors: data }), Promise.resolve(data))])) +); + +export const changeNotificationSetting = createAsyncThunk( + `${sliceName}/changeNotificationSetting`, + ({ enabled, channel = alertChannels.email }, { dispatch }) => { + return Api.put(`${monitorApiUrlv1}/settings/global/channel/alerts/${channel}/status`, { enabled }) + .catch(err => commonErrorHandler(err, `${enabled ? 'En' : 'Dis'}abling ${channel} alerts failed:`, dispatch)) + .then(() => + Promise.all([ + dispatch(actions.changeAlertChannel({ channel, enabled })), + dispatch(storeActions.setSnackbar(`Successfully ${enabled ? 'en' : 'dis'}abled ${channel} alerts`, TIMEOUTS.fiveSeconds)) + ]) + ); + } +); diff --git a/frontend/src/js/constants/onboardingConstants.js b/frontend/src/js/store/onboardingSlice/constants.ts similarity index 74% rename from frontend/src/js/constants/onboardingConstants.js rename to frontend/src/js/store/onboardingSlice/constants.ts index 4796294b..09b4a4eb 100644 --- a/frontend/src/js/constants/onboardingConstants.js +++ b/frontend/src/js/store/onboardingSlice/constants.ts @@ -31,12 +31,3 @@ export const onboardingSteps = { DEPLOYMENTS_PAST_COMPLETED_FAILURE: 'deployments-past-completed-failure', ONBOARDING_CANCELED: 'onboarding-canceled' }; -export const SET_DEMO_ARTIFACT_PORT = 'SET_DEMO_ARTIFACT_PORT'; -export const SET_ONBOARDING_COMPLETE = 'SET_ONBOARDING_COMPLETE'; -export const SET_ONBOARDING_PROGRESS = 'SET_ONBOARDING_PROGRESS'; -export const SET_ONBOARDING_DEVICE_TYPE = 'SET_ONBOARDING_DEVICE_TYPE'; -export const SET_ONBOARDING_APPROACH = 'SET_ONBOARDING_APPROACH'; -export const SET_SHOW_CREATE_ARTIFACT = 'SET_SHOW_CREATE_ARTIFACT'; -export const SET_SHOW_ONBOARDING_HELP = 'SET_SHOW_ONBOARDING_HELP'; -export const SET_SHOW_ONBOARDING_HELP_DIALOG = 'SET_SHOW_ONBOARDING_HELP_DIALOG'; -export const SET_ONBOARDING_STATE = 'SET_ONBOARDING_STATE'; diff --git a/frontend/src/js/store/onboardingSlice/index.ts b/frontend/src/js/store/onboardingSlice/index.ts new file mode 100644 index 00000000..5488246d --- /dev/null +++ b/frontend/src/js/store/onboardingSlice/index.ts @@ -0,0 +1,60 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { createSlice } from '@reduxjs/toolkit'; + +export const sliceName = 'onboarding'; + +export const initialState = { + approach: null, + complete: false, + deviceType: null, + demoArtifactPort: 85, + progress: null, + showTips: null, + showTipsDialog: false +}; + +export const onboardingSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + setOnboardingState: (state, action) => { + state = { ...state, ...action.payload }; + }, + setDemoArtifactPort: (state, action) => { + state.demoArtifactPort = action.payload; + }, + setShowOnboardingHelp: (state, action) => { + state.showTips = action.payload; + }, + setShowDismissOnboardingTipsDialog: (state, action) => { + state.showTipsDialog = action.payload; + }, + setOnboardingComplete: (state, action) => { + state.complete = action.payload; + }, + setOnboardingProgress: (state, action) => { + state.progress = action.payload; + }, + setOnboardingDeviceType: (state, action) => { + state.deviceType = action.payload; + }, + setOnboardingApproach: (state, action) => { + state.approach = action.payload; + } + } +}); + +export const actions = onboardingSlice.actions; +export default onboardingSlice.reducer; diff --git a/frontend/src/js/store/onboardingSlice/reducer.test.ts b/frontend/src/js/store/onboardingSlice/reducer.test.ts new file mode 100644 index 00000000..194f7a81 --- /dev/null +++ b/frontend/src/js/store/onboardingSlice/reducer.test.ts @@ -0,0 +1,45 @@ +// Copyright 2020 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import reducer, { actions, initialState } from '.'; + +describe('organization reducer', () => { + it('should return the initial state', async () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + it('should handle SET_SHOW_ONBOARDING_HELP', async () => { + expect(reducer(undefined, { type: actions.setShowOnboardingHelp, payload: true }).showTips).toEqual(true); + expect(reducer(initialState, { type: actions.setShowOnboardingHelp, payload: false }).showTips).toEqual(false); + }); + it('should handle SET_SHOW_ONBOARDING_HELP_DIALOG', async () => { + expect(reducer(undefined, { type: actions.setShowDismissOnboardingTipsDialog, payload: true }).showTipsDialog).toEqual(true); + expect(reducer(initialState, { type: actions.setShowDismissOnboardingTipsDialog, payload: false }).showTipsDialog).toEqual(false); + }); + it('should handle SET_ONBOARDING_COMPLETE', async () => { + expect(reducer(undefined, { type: actions.setOnboardingComplete, payload: true }).complete).toEqual(true); + expect(reducer(initialState, { type: actions.setOnboardingComplete, payload: false }).complete).toEqual(false); + }); + it('should handle SET_ONBOARDING_PROGRESS', async () => { + expect(reducer(undefined, { type: actions.setOnboardingProgress, payload: 'test' }).progress).toEqual('test'); + expect(reducer(initialState, { type: actions.setOnboardingProgress, payload: 'test' }).progress).toEqual('test'); + }); + it('should handle SET_ONBOARDING_DEVICE_TYPE', async () => { + expect(reducer(undefined, { type: actions.setOnboardingDeviceType, payload: 'bbb' }).deviceType).toEqual('bbb'); + expect(reducer(initialState, { type: actions.setOnboardingDeviceType, payload: 'rpi4' }).deviceType).toEqual('rpi4'); + }); + it('should handle SET_ONBOARDING_APPROACH', async () => { + expect(reducer(undefined, { type: actions.setOnboardingApproach, payload: 'physical' }).approach).toEqual('physical'); + expect(reducer(initialState, { type: actions.setOnboardingApproach, payload: 'virtual' }).approach).toEqual('virtual'); + }); +}); diff --git a/frontend/src/js/store/onboardingSlice/selectors.ts b/frontend/src/js/store/onboardingSlice/selectors.ts new file mode 100644 index 00000000..1890409c --- /dev/null +++ b/frontend/src/js/store/onboardingSlice/selectors.ts @@ -0,0 +1,15 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +export const getOnboarding = state => state.onboarding; diff --git a/frontend/src/js/actions/onboardingActions.test.js b/frontend/src/js/store/onboardingSlice/thunks.test.ts similarity index 56% rename from frontend/src/js/actions/onboardingActions.test.js rename to frontend/src/js/store/onboardingSlice/thunks.test.ts index b03b7d72..563fccb9 100644 --- a/frontend/src/js/actions/onboardingActions.test.js +++ b/frontend/src/js/store/onboardingSlice/thunks.test.ts @@ -11,23 +11,16 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck +import { getUserSettings, saveUserSettings } from '@northern.tech/store/thunks'; import configureMockStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; -import { defaultState } from '../../../tests/mockData'; -import * as OnboardingConstants from '../constants/onboardingConstants'; -import * as UserConstants from '../constants/userConstants'; -import { onboardingSteps } from '../utils/onboardingmanager'; -import { - advanceOnboarding, - getOnboardingState, - setOnboardingApproach, - setOnboardingCanceled, - setOnboardingComplete, - setOnboardingDeviceType, - setShowDismissOnboardingTipsDialog, - setShowOnboardingHelp -} from './onboardingActions'; +import { actions } from '.'; +import { defaultState } from '../../../../tests/mockData'; +import { actions as userActions } from '../usersSlice'; +import { onboardingSteps } from './constants'; +import { advanceOnboarding, getOnboardingState, setOnboardingApproach, setOnboardingCanceled, setOnboardingComplete, setOnboardingDeviceType } from './thunks'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -45,10 +38,11 @@ export const defaultOnboardingState = { }; export const expectedOnboardingActions = [ - { type: OnboardingConstants.SET_ONBOARDING_COMPLETE, complete: false }, + { type: getOnboardingState.pending.type }, + { type: actions.setOnboardingComplete.type, payload: false }, { - type: OnboardingConstants.SET_ONBOARDING_STATE, - value: { + type: actions.setOnboardingState.type, + payload: { ...defaultOnboardingState, address: 'http://192.168.10.141:85', approach: 'physical', @@ -57,10 +51,13 @@ export const expectedOnboardingActions = [ showTips: true } }, - { type: UserConstants.SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: userActions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getUserSettings.fulfilled.type }, { - type: UserConstants.SET_USER_SETTINGS, - settings: { + type: userActions.setUserSettings.type, + payload: { ...defaultState.users.userSettings, onboarding: { ...defaultOnboardingState, @@ -71,7 +68,9 @@ export const expectedOnboardingActions = [ showTips: true } } - } + }, + { type: saveUserSettings.fulfilled.type }, + { type: getOnboardingState.fulfilled.type } ]; describe('onboarding actions', () => { @@ -79,21 +78,29 @@ describe('onboarding actions', () => { const store = mockStore({ ...defaultState }); await store.dispatch(setOnboardingComplete(true)); const expectedActions = [ - { type: OnboardingConstants.SET_ONBOARDING_COMPLETE, complete: true }, - { type: OnboardingConstants.SET_SHOW_ONBOARDING_HELP, show: false }, - { type: OnboardingConstants.SET_ONBOARDING_PROGRESS, value: OnboardingConstants.onboardingSteps.DEPLOYMENTS_PAST_COMPLETED_FAILURE }, - { type: UserConstants.SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, + { type: setOnboardingComplete.pending.type }, + { type: actions.setOnboardingComplete.type, payload: true }, + { type: actions.setShowOnboardingHelp.type, payload: false }, + { type: advanceOnboarding.pending.type }, + { type: actions.setOnboardingProgress.type, payload: onboardingSteps.DEPLOYMENTS_PAST_COMPLETED_FAILURE }, + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: userActions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getUserSettings.fulfilled.type }, { - type: UserConstants.SET_USER_SETTINGS, - settings: { + type: userActions.setUserSettings.type, + payload: { ...defaultState.users.userSettings, onboarding: { ...defaultOnboardingState, complete: true, - progress: OnboardingConstants.onboardingSteps.DEPLOYMENTS_PAST_COMPLETED_FAILURE + progress: onboardingSteps.DEPLOYMENTS_PAST_COMPLETED_FAILURE } } - } + }, + { type: saveUserSettings.fulfilled.type }, + { type: advanceOnboarding.fulfilled.type }, + { type: setOnboardingComplete.fulfilled.type } ]; const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -103,18 +110,24 @@ describe('onboarding actions', () => { const store = mockStore({ ...defaultState }); await store.dispatch(setOnboardingApproach('test')); const expectedActions = [ - { type: OnboardingConstants.SET_ONBOARDING_APPROACH, value: 'test' }, - { type: UserConstants.SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, + { type: setOnboardingApproach.pending.type }, + { type: actions.setOnboardingApproach.type, payload: 'test' }, + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: userActions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getUserSettings.fulfilled.type }, { - type: UserConstants.SET_USER_SETTINGS, - settings: { + type: userActions.setUserSettings.type, + payload: { ...defaultState.users.userSettings, onboarding: { ...defaultOnboardingState, approach: 'test' } } - } + }, + { type: saveUserSettings.fulfilled.type }, + { type: setOnboardingApproach.fulfilled.type } ]; const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -124,14 +137,15 @@ describe('onboarding actions', () => { const store = mockStore({ ...defaultState }); await store.dispatch(setOnboardingDeviceType('testtype')); const expectedActions = [ + { type: setOnboardingDeviceType.pending.type }, + { type: actions.setOnboardingDeviceType.type, payload: 'testtype' }, + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: userActions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getUserSettings.fulfilled.type }, { - type: OnboardingConstants.SET_ONBOARDING_DEVICE_TYPE, - value: 'testtype' - }, - { type: UserConstants.SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, - { - type: UserConstants.SET_USER_SETTINGS, - settings: { + type: userActions.setUserSettings.type, + payload: { ...defaultState.users.userSettings, columnSelection: [], onboarding: { @@ -139,39 +153,9 @@ describe('onboarding actions', () => { deviceType: 'testtype' } } - } - ]; - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => Object.keys(action).map(key => expect(storeActions[index][key]).toEqual(action[key]))); - }); - it('should pass on onboarding tips visibility confirmation', async () => { - const store = mockStore({ ...defaultState }); - await store.dispatch(setShowDismissOnboardingTipsDialog(true)); - const expectedActions = [ - { - type: OnboardingConstants.SET_SHOW_ONBOARDING_HELP_DIALOG, - show: true - } - ]; - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => Object.keys(action).map(key => expect(storeActions[index][key]).toEqual(action[key]))); - }); - it('should pass on onboarding tips visibility', async () => { - const store = mockStore({ ...defaultState }); - await store.dispatch(setShowOnboardingHelp(true)); - const expectedActions = [ - { type: OnboardingConstants.SET_SHOW_ONBOARDING_HELP, show: true }, - { type: UserConstants.SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, - { - type: UserConstants.SET_USER_SETTINGS, - settings: { - ...defaultState.users.userSettings, - columnSelection: [], - onboarding: { ...defaultOnboardingState, showTips: true } - } - } + }, + { type: saveUserSettings.fulfilled.type }, + { type: setOnboardingDeviceType.fulfilled.type } ]; const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -179,22 +163,27 @@ describe('onboarding actions', () => { }); it('should advance onboarding by one step', async () => { const store = mockStore({ ...defaultState }); - const stepNames = Object.keys(onboardingSteps); - await store.dispatch(advanceOnboarding(stepNames[0])); + await store.dispatch(advanceOnboarding(onboardingSteps.DASHBOARD_ONBOARDING_START)); const expectedActions = [ - { type: OnboardingConstants.SET_ONBOARDING_PROGRESS, value: stepNames[1] }, - { type: UserConstants.SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, + { type: advanceOnboarding.pending.type }, + { type: actions.setOnboardingProgress.type, payload: onboardingSteps.DEVICES_PENDING_ONBOARDING_START }, + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: userActions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getUserSettings.fulfilled.type }, { - type: UserConstants.SET_USER_SETTINGS, - settings: { + type: userActions.setUserSettings.type, + payload: { ...defaultState.users.userSettings, columnSelection: [], onboarding: { ...defaultOnboardingState, - progress: stepNames[1] + progress: onboardingSteps.DEVICES_PENDING_ONBOARDING_START } } - } + }, + { type: saveUserSettings.fulfilled.type }, + { type: advanceOnboarding.fulfilled.type } ]; const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -204,14 +193,19 @@ describe('onboarding actions', () => { const store = mockStore({ ...defaultState }); await store.dispatch(setOnboardingCanceled()); const expectedActions = [ - { type: OnboardingConstants.SET_SHOW_ONBOARDING_HELP, show: false }, - { type: OnboardingConstants.SET_SHOW_ONBOARDING_HELP_DIALOG, show: false }, - { type: OnboardingConstants.SET_ONBOARDING_COMPLETE, complete: true }, - { type: OnboardingConstants.SET_ONBOARDING_PROGRESS, value: 'onboarding-canceled' }, - { type: UserConstants.SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, + { type: setOnboardingCanceled.pending.type }, + { type: actions.setShowOnboardingHelp.type, payload: false }, + { type: actions.setShowDismissOnboardingTipsDialog.type, payload: false }, + { type: actions.setOnboardingComplete.type, payload: true }, + { type: advanceOnboarding.pending.type }, + { type: actions.setOnboardingProgress.type, payload: 'onboarding-canceled' }, + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: userActions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getUserSettings.fulfilled.type }, { - type: UserConstants.SET_USER_SETTINGS, - settings: { + type: userActions.setUserSettings.type, + payload: { ...defaultState.users.userSettings, columnSelection: [], onboarding: { @@ -220,7 +214,10 @@ describe('onboarding actions', () => { progress: 'onboarding-canceled' } } - } + }, + { type: saveUserSettings.fulfilled.type }, + { type: advanceOnboarding.fulfilled.type }, + { type: setOnboardingCanceled.fulfilled.type } ]; const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); diff --git a/frontend/src/js/actions/onboardingActions.js b/frontend/src/js/store/onboardingSlice/thunks.ts similarity index 58% rename from frontend/src/js/actions/onboardingActions.js rename to frontend/src/js/store/onboardingSlice/thunks.ts index cc2cb67e..f0a21207 100644 --- a/frontend/src/js/actions/onboardingActions.js +++ b/frontend/src/js/store/onboardingSlice/thunks.ts @@ -11,28 +11,40 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck +import { DEVICE_STATES, onboardingSteps as onboardingStepNames } from '@northern.tech/store/constants'; +import { getOnboardingState as getCurrentOnboardingState, getUserCapabilities } from '@northern.tech/store/selectors'; +import { saveUserSettings } from '@northern.tech/store/thunks'; +import { createAsyncThunk } from '@reduxjs/toolkit'; import Cookies from 'universal-cookie'; -import { DEVICE_STATES } from '../constants/deviceConstants'; -import { - SET_DEMO_ARTIFACT_PORT, - SET_ONBOARDING_APPROACH, - SET_ONBOARDING_COMPLETE, - SET_ONBOARDING_DEVICE_TYPE, - SET_ONBOARDING_PROGRESS, - SET_ONBOARDING_STATE, - SET_SHOW_ONBOARDING_HELP, - SET_SHOW_ONBOARDING_HELP_DIALOG, - onboardingSteps as onboardingStepNames -} from '../constants/onboardingConstants'; -import { getDemoDeviceAddress } from '../helpers'; -import { getOnboardingState as getCurrentOnboardingState, getUserCapabilities } from '../selectors'; -import Tracking from '../tracking'; -import { applyOnboardingFallbacks, onboardingSteps } from '../utils/onboardingmanager'; -import { saveUserSettings } from './userActions'; +import { actions, sliceName } from '.'; +import { getDemoDeviceAddress } from '../../helpers'; +import Tracking from '../../tracking'; +import { onboardingSteps } from '../../utils/onboardingmanager'; const cookies = new Cookies(); +export const applyOnboardingFallbacks = progress => { + const step = onboardingSteps[progress]; + if (step && step.fallbackStep) { + return step.fallbackStep; + } + return progress; +}; + +const determineProgress = (acceptedDevices, pendingDevices, releases, pastDeployments) => { + const steps = Object.keys(onboardingSteps); + let progress = -1; + progress = pendingDevices.length > 1 ? steps.findIndex(step => step === onboardingStepNames.DEVICES_PENDING_ACCEPTING_ONBOARDING) : progress; + progress = acceptedDevices.length >= 1 ? steps.findIndex(step => step === onboardingStepNames.DEVICES_ACCEPTED_ONBOARDING) : progress; + progress = + acceptedDevices.length > 1 && releases.length > 1 && pastDeployments.length > 1 + ? steps.findIndex(step => step === onboardingStepNames.DEPLOYMENTS_PAST_COMPLETED) + : progress; + return steps[progress]; +}; + const deductOnboardingState = ({ devicesById, devicesByStatus, onboardingState, pastDeployments, releases, userCapabilities, userId }) => { const { canDeploy, canManageDevices, canReadDeployments, canReadDevices, canReadReleases, canUploadReleases } = userCapabilities; const userCookie = cookies.get(`${userId}-onboarded`); @@ -63,92 +75,62 @@ const deductOnboardingState = ({ devicesById, devicesByStatus, onboardingState, }; }; -export const getOnboardingState = () => (dispatch, getState) => { - const store = getState(); - let onboardingState = getCurrentOnboardingState(store); +export const getOnboardingState = createAsyncThunk(`${sliceName}/getOnboardingState`, (_, { dispatch, getState }) => { + const state = getState(); + let onboardingState = getCurrentOnboardingState(state); if (!onboardingState.complete) { - const userId = getState().users.currentUser; + const userId = state.users.currentUser; onboardingState = deductOnboardingState({ - devicesById: store.devices.byId, - devicesByStatus: store.devices.byStatus, + devicesById: state.devices.byId, + devicesByStatus: state.devices.byStatus, onboardingState, - pastDeployments: store.deployments.byStatus.finished.deploymentIds, - releases: Object.values(store.releases.byId), - userCapabilities: getUserCapabilities(store), + pastDeployments: state.deployments.byStatus.finished.deploymentIds, + releases: Object.values(state.releases.byId), + userCapabilities: getUserCapabilities(state), userId }); } onboardingState.progress = onboardingState.progress || onboardingStepNames.DASHBOARD_ONBOARDING_START; - const demoDeviceAddress = `http://${getDemoDeviceAddress(Object.values(store.devices.byId), onboardingState.approach)}`; - onboardingState.address = store.onboarding.demoArtifactPort ? `${demoDeviceAddress}:${store.onboarding.demoArtifactPort}` : demoDeviceAddress; - return Promise.resolve(dispatch(setOnboardingState(onboardingState))); -}; - -export const setShowOnboardingHelp = - (show, update = true) => - dispatch => { - let tasks = [dispatch({ type: SET_SHOW_ONBOARDING_HELP, show })]; - if (update) { - tasks.push(dispatch(saveUserSettings({ onboarding: { showTips: show } }))); - } - return Promise.all(tasks); - }; - -const setOnboardingProgress = value => dispatch => dispatch({ type: SET_ONBOARDING_PROGRESS, value }); - -export const setOnboardingDeviceType = - (value, update = true) => - dispatch => { - let tasks = [dispatch({ type: SET_ONBOARDING_DEVICE_TYPE, value })]; - if (update) { - tasks.push(dispatch(saveUserSettings({ onboarding: { deviceType: value } }))); - } - return Promise.all(tasks); - }; - -export const setOnboardingApproach = - (value, update = true) => - dispatch => { - let tasks = [dispatch({ type: SET_ONBOARDING_APPROACH, value })]; - if (update) { - tasks.push(dispatch(saveUserSettings({ onboarding: { approach: value } }))); - } - return Promise.all(tasks); - }; + const demoDeviceAddress = `http://${getDemoDeviceAddress(Object.values(state.devices.byId), onboardingState.approach)}`; + onboardingState.address = state.onboarding.demoArtifactPort ? `${demoDeviceAddress}:${state.onboarding.demoArtifactPort}` : demoDeviceAddress; + return Promise.all([ + dispatch(actions.setOnboardingComplete(onboardingState.complete)), + dispatch(actions.setOnboardingState(onboardingState)), + dispatch(saveUserSettings({ onboarding: onboardingState })) + ]); +}); -export const setShowDismissOnboardingTipsDialog = show => dispatch => dispatch({ type: SET_SHOW_ONBOARDING_HELP_DIALOG, show }); +export const setOnboardingDeviceType = createAsyncThunk(`${sliceName}/setOnboardingDeviceType`, (value, { dispatch }) => + Promise.all([dispatch(actions.setOnboardingDeviceType(value)), dispatch(saveUserSettings({ onboarding: { deviceType: value } }))]) +); -export const setDemoArtifactPort = port => dispatch => dispatch({ type: SET_DEMO_ARTIFACT_PORT, value: port }); +export const setOnboardingApproach = createAsyncThunk(`${sliceName}/setOnboardingApproach`, (value, { dispatch }) => + Promise.all([dispatch(actions.setOnboardingApproach(value)), dispatch(saveUserSettings({ onboarding: { approach: value } }))]) +); -export const setOnboardingComplete = val => dispatch => { - let tasks = [Promise.resolve(dispatch({ type: SET_ONBOARDING_COMPLETE, complete: val }))]; - if (val) { - tasks.push(Promise.resolve(dispatch({ type: SET_SHOW_ONBOARDING_HELP, show: false }))); +export const setOnboardingComplete = createAsyncThunk(`${sliceName}/setOnboardingComplete`, (value, { dispatch }) => { + let tasks = [Promise.resolve(dispatch(actions.setOnboardingComplete(value)))]; + if (value) { + tasks.push(Promise.resolve(dispatch(actions.setShowOnboardingHelp(false)))); tasks.push(Promise.resolve(dispatch(advanceOnboarding(onboardingStepNames.DEPLOYMENTS_PAST_COMPLETED)))); } return Promise.all(tasks); -}; +}); -export const setOnboardingCanceled = () => dispatch => +export const setOnboardingCanceled = createAsyncThunk(`${sliceName}/setOnboardingCanceled`, (_, { dispatch }) => Promise.all([ - Promise.resolve(dispatch(setShowOnboardingHelp(false, false))), - Promise.resolve(dispatch(setShowDismissOnboardingTipsDialog(false))), - Promise.resolve(dispatch({ type: SET_ONBOARDING_COMPLETE, complete: true })) + Promise.resolve(dispatch(actions.setShowOnboardingHelp(false))), + Promise.resolve(dispatch(actions.setShowDismissOnboardingTipsDialog(false))), + Promise.resolve(dispatch(actions.setOnboardingComplete(true))) ]) // using DEPLOYMENTS_PAST_COMPLETED to ensure we get the intended onboarding state set after // _advancing_ the onboarding progress .then(() => dispatch(advanceOnboarding(onboardingStepNames.DEPLOYMENTS_PAST_COMPLETED_FAILURE))) // since we can't advance after ONBOARDING_CANCELED, track the step manually here - .then(() => Tracking.event({ category: 'onboarding', action: onboardingSteps.ONBOARDING_CANCELED })); - -const setOnboardingState = state => dispatch => - Promise.all([ - dispatch(setOnboardingComplete(state.complete)), - dispatch({ type: SET_ONBOARDING_STATE, value: state }), - dispatch(saveUserSettings({ onboarding: state })) - ]); + .then(() => Tracking.event({ category: 'onboarding', action: onboardingSteps.ONBOARDING_CANCELED })) +); -export const advanceOnboarding = stepId => (dispatch, getState) => { +export const advanceOnboarding = createAsyncThunk(`${sliceName}/advanceOnboarding`, (stepId, { dispatch, getState }) => { const steps = Object.keys(onboardingSteps); const progress = steps.findIndex(step => step === getState().onboarding.progress); const stepIndex = steps.findIndex(step => step === stepId); @@ -162,17 +144,5 @@ export const advanceOnboarding = stepId => (dispatch, getState) => { state.complete = stepIndex + 1 >= Object.keys(onboardingSteps).findIndex(step => step === onboardingStepNames.DEPLOYMENTS_PAST_COMPLETED_FAILURE) ? true : state.complete; Tracking.event({ category: 'onboarding', action: stepId }); - return Promise.all([dispatch(setOnboardingProgress(madeProgress)), dispatch(saveUserSettings({ onboarding: state }))]); -}; - -const determineProgress = (acceptedDevices, pendingDevices, releases, pastDeployments) => { - const steps = Object.keys(onboardingSteps); - let progress = -1; - progress = pendingDevices.length > 1 ? steps.findIndex(step => step === onboardingStepNames.DEVICES_PENDING_ACCEPTING_ONBOARDING) : progress; - progress = acceptedDevices.length >= 1 ? steps.findIndex(step => step === onboardingStepNames.DEVICES_ACCEPTED_ONBOARDING) : progress; - progress = - acceptedDevices.length > 1 && releases.length > 1 && pastDeployments.length > 1 - ? steps.findIndex(step => step === onboardingStepNames.DEPLOYMENTS_PAST_COMPLETED) - : progress; - return steps[progress]; -}; + return Promise.all([dispatch(actions.setOnboardingProgress(madeProgress)), dispatch(saveUserSettings({ onboarding: state }))]); +}); diff --git a/frontend/src/js/constants/organizationConstants.js b/frontend/src/js/store/organizationSlice/constants.ts similarity index 80% rename from frontend/src/js/constants/organizationConstants.js rename to frontend/src/js/store/organizationSlice/constants.ts index 8a3930a8..8d3f81f1 100644 --- a/frontend/src/js/constants/organizationConstants.js +++ b/frontend/src/js/store/organizationSlice/constants.ts @@ -11,8 +11,13 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { EXTERNAL_PROVIDER } from './deviceConstants'; -import { useradmApiUrl } from './userConstants'; +// @ts-nocheck +import { EXTERNAL_PROVIDER, apiUrl, useradmApiUrl } from '@northern.tech/store/constants'; + +export const auditLogsApiUrl = `${apiUrl.v1}/auditlogs`; +export const tenantadmApiUrlv1 = `${apiUrl.v1}/tenantadm`; +export const tenantadmApiUrlv2 = `${apiUrl.v2}/tenantadm`; +export const ssoIdpApiUrlv1 = `${apiUrl.v1}/useradm/sso/idp/metadata`; export const XML_METADATA_FORMAT = 'xml'; export const JSON_METADATA_FORMAT = 'json'; @@ -53,14 +58,7 @@ export const AUDIT_LOGS_TYPES = [ { title: 'Device', queryParameter: 'object_id', value: 'device' }, { title: 'User', queryParameter: 'object_id', value: 'user' } ]; -export const RECEIVE_AUDIT_LOGS = 'RECEIVE_AUDIT_LOGS'; -export const RECEIVE_CURRENT_CARD = 'RECEIVE_CURRENT_CARD'; -export const RECEIVE_SETUP_INTENT = 'RECEIVE_SETUP_INTENT'; -export const SET_AUDITLOG_STATE = 'SET_AUDITLOG_STATE'; -export const SET_ORGANIZATION = 'SET_ORGANIZATION'; -export const RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS = 'RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS'; -export const RECEIVE_SSO_CONFIGS = 'RECEIVE_SSO_CONFIGS'; -export const RECEIVE_WEBHOOK_EVENTS = 'RECEIVE_WEBHOOK_EVENTS'; + export const emptyWebhook = { description: '', enabled: true, diff --git a/frontend/src/js/store/organizationSlice/index.ts b/frontend/src/js/store/organizationSlice/index.ts new file mode 100644 index 00000000..605d2c14 --- /dev/null +++ b/frontend/src/js/store/organizationSlice/index.ts @@ -0,0 +1,94 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS } from '@northern.tech/store/commonConstants'; +import { createSlice } from '@reduxjs/toolkit'; + +export const sliceName = 'organization'; + +export const initialState = { + card: { + last4: '', + expiration: { month: 1, year: 2020 }, + brand: '' + }, + intentId: null, + organization: { + // id, name, status, tenant_token, plan + }, + auditlog: { + events: [], + selectionState: { + ...DEVICE_LIST_DEFAULTS, + detail: undefined, + endDate: undefined, + selectedIssue: undefined, + sort: { direction: SORTING_OPTIONS.desc }, + startDate: undefined, + total: 0, + type: undefined, + user: undefined + } + }, + externalDeviceIntegrations: [ + // { , id, provider } + ], + ssoConfigs: [], + webhooks: { + // [id]: { events: [] } + // for now: + events: [], + eventsTotal: 0 + } +}; + +export const organizationSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + receiveAuditLogs: (state, action) => { + const { events, total } = action.payload; + state.auditlog.events = events; + state.auditlog.selectionState.total = total; + }, + setAuditLogState: (state, action) => { + state.auditlog.selectionState = { + ...state.auditlog.selectionState, + ...action.payload + }; + }, + receiveCurrentCard: (state, action) => { + state.card = action.payload; + }, + receiveSetupIntent: (state, action) => { + state.intentId = action.payload; + }, + setOrganization: (state, action) => { + state.organization = action.payload; + }, + receiveExternalDeviceIntegrations: (state, action) => { + state.externalDeviceIntegrations = action.payload; + }, + receiveSsoConfigs: (state, action) => { + state.ssoConfigs = action.payload; + }, + receiveWebhookEvents: (state, action) => { + const { value, total } = action.payload; + state.webhooks.events = value; + state.webhooks.eventsTotal = total; + } + } +}); + +export const actions = organizationSlice.actions; +export default organizationSlice.reducer; diff --git a/frontend/src/js/store/organizationSlice/reducer.test.ts b/frontend/src/js/store/organizationSlice/reducer.test.ts new file mode 100644 index 00000000..e9d53055 --- /dev/null +++ b/frontend/src/js/store/organizationSlice/reducer.test.ts @@ -0,0 +1,78 @@ +// Copyright 2020 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { SORTING_OPTIONS } from '@northern.tech/store/commonConstants'; + +import reducer, { actions, initialState } from '.'; +import { defaultState } from '../../../../tests/mockData'; + +describe('organization reducer', () => { + it('should return the initial state', async () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + + it('should handle RECEIVE_AUDIT_LOGS', async () => { + expect( + reducer(undefined, { type: actions.receiveAuditLogs, payload: { events: defaultState.organization.auditlog.events, total: 2 } }).auditlog.selectionState + .total + ).toEqual(2); + expect( + reducer(initialState, { type: actions.receiveAuditLogs, payload: { events: defaultState.organization.auditlog.events, total: 4 } }).auditlog + .selectionState.total + ).toEqual(4); + }); + it('should handle SET_AUDITLOG_STATE', async () => { + const newState = { something: 'new' }; + expect(reducer(undefined, { type: actions.setAuditLogState, payload: newState }).auditlog.selectionState.something).toEqual(newState.something); + expect(reducer(initialState, { type: actions.setAuditLogState, payload: newState }).auditlog.selectionState).toEqual({ + ...defaultState.organization.auditlog.selectionState, + ...newState, + sort: { direction: SORTING_OPTIONS.desc }, + total: 0 + }); + }); + it('should handle RECEIVE_CURRENT_CARD', async () => { + expect(reducer(undefined, { type: actions.receiveCurrentCard, payload: defaultState.organization.card }).card).toEqual(defaultState.organization.card); + expect(reducer(initialState, { type: actions.receiveCurrentCard, payload: defaultState.organization.card }).card).toEqual(defaultState.organization.card); + }); + it('should handle RECEIVE_SETUP_INTENT', async () => { + expect(reducer(undefined, { type: actions.receiveSetupIntent, payload: defaultState.organization.intentId }).intentId).toEqual( + defaultState.organization.intentId + ); + expect(reducer(initialState, { type: actions.receiveSetupIntent, payload: 4 }).intentId).toEqual(4); + }); + it('should handle SET_ORGANIZATION', async () => { + expect(reducer(undefined, { type: actions.setOrganization, payload: defaultState.organization.organization }).organization.plan).toEqual( + defaultState.organization.organization.plan + ); + expect(reducer(initialState, { type: actions.setOrganization, payload: defaultState.organization.organization }).organization.name).toEqual( + defaultState.organization.organization.name + ); + }); + it('should handle RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS', async () => { + expect(reducer(undefined, { type: actions.receiveExternalDeviceIntegrations, payload: [] }).externalDeviceIntegrations).toEqual([]); + expect(reducer(initialState, { type: actions.receiveExternalDeviceIntegrations, payload: [12, 23] }).externalDeviceIntegrations).toEqual([12, 23]); + }); + it('should handle RECEIVE_WEBHOOK_EVENTS', async () => { + expect(reducer(undefined, { type: actions.receiveWebhookEvents, payload: { value: [] } }).webhooks.events).toEqual([]); + expect(reducer(initialState, { type: actions.receiveWebhookEvents, payload: { value: [12, 23], total: 5 } }).webhooks).toEqual({ + events: [12, 23], + eventsTotal: 5 + }); + }); + it('should handle RECEIVE_SSO_CONFIGS', async () => { + expect(reducer(undefined, { type: actions.receiveSsoConfigs, payload: [] }).ssoConfigs).toEqual([]); + expect(reducer(initialState, { type: actions.receiveSsoConfigs, payload: [12, 23] }).ssoConfigs).toEqual([12, 23]); + }); +}); diff --git a/frontend/src/js/store/organizationSlice/selectors.ts b/frontend/src/js/store/organizationSlice/selectors.ts new file mode 100644 index 00000000..b85b30c8 --- /dev/null +++ b/frontend/src/js/store/organizationSlice/selectors.ts @@ -0,0 +1,34 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { EXTERNAL_PROVIDER } from '@northern.tech/store/constants'; +import { createSelector } from '@reduxjs/toolkit'; + +export const getOrganization = state => state.organization.organization; +export const getExternalIntegrations = state => state.organization.externalDeviceIntegrations; +export const getAuditlogState = state => state.organization.auditlog.selectionState; +export const getAuditLog = state => state.organization.auditlog.events; +export const getAuditLogSelectionState = state => state.organization.auditlog.selectionState; +export const getSsoConfig = ({ organization: { ssoConfigs = [] } }) => ssoConfigs[0]; + +export const getDeviceTwinIntegrations = createSelector([getExternalIntegrations], integrations => + integrations.filter(integration => integration.id && EXTERNAL_PROVIDER[integration.provider]?.deviceTwin) +); + +export const getAuditLogEntry = createSelector([getAuditLog, getAuditLogSelectionState], (events, { selectedId }) => { + if (!selectedId) { + return; + } + const [eventAction, eventTime] = atob(selectedId).split('|'); + return events.find(item => item.action === eventAction && item.time === eventTime); +}); diff --git a/frontend/src/js/actions/organizationActions.test.js b/frontend/src/js/store/organizationSlice/thunks.test.ts similarity index 64% rename from frontend/src/js/actions/organizationActions.test.js rename to frontend/src/js/store/organizationSlice/thunks.test.ts index 1eb31d83..9bb58166 100644 --- a/frontend/src/js/actions/organizationActions.test.js +++ b/frontend/src/js/store/organizationSlice/thunks.test.ts @@ -11,23 +11,18 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck +import { EXTERNAL_PROVIDER } from '@northern.tech/store/commonConstants'; import configureMockStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; -import { defaultState, webhookEvents } from '../../../tests/mockData'; +import { actions } from '.'; +import { defaultState, webhookEvents } from '../../../../tests/mockData'; +import { actions as appActions } from '../appSlice'; +import { locations } from '../appSlice/constants'; import { getSessionInfo } from '../auth'; -import { SET_ANNOUNCEMENT, SET_FIRST_LOGIN_AFTER_SIGNUP, SET_SNACKBAR, locations } from '../constants/appConstants'; -import { EXTERNAL_PROVIDER } from '../constants/deviceConstants'; -import { - RECEIVE_AUDIT_LOGS, - RECEIVE_CURRENT_CARD, - RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, - RECEIVE_SETUP_INTENT, - RECEIVE_SSO_CONFIGS, - RECEIVE_WEBHOOK_EVENTS, - SET_AUDITLOG_STATE, - SET_ORGANIZATION -} from '../constants/organizationConstants'; +import { TIMEOUTS } from '../commonConstants'; +import { SSO_TYPES } from './constants'; import { cancelRequest, cancelUpgrade, @@ -44,6 +39,7 @@ import { getAuditLogsCsvLink, getCurrentCard, getIntegrations, + getSsoConfigById, getSsoConfigs, getTargetLocation, getUserOrganization, @@ -55,7 +51,7 @@ import { startUpgrade, storeSsoConfig, tenantDataDivergedMessage -} from './organizationActions'; +} from './thunks'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -76,7 +72,11 @@ const oldHostname = window.location.hostname; describe('organization actions', () => { it('should handle different error message formats', async () => { const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: SET_SNACKBAR, snackbar: { message: 'Deactivation request was sent successfully' } }]; + const expectedActions = [ + { type: cancelRequest.pending.type }, + { type: appActions.setSnackbar.type, payload: { message: 'Deactivation request was sent successfully', autoHideDuration: TIMEOUTS.fiveSeconds } }, + { type: cancelRequest.fulfilled.type } + ]; await store.dispatch(cancelRequest(defaultState.organization.organization.id, 'testReason')).then(() => { const storeActions = store.getActions(); expect(storeActions).toHaveLength(expectedActions.length); @@ -112,7 +112,7 @@ describe('organization actions', () => { it('should handle trial creation', async () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); - const expectedActions = [{ type: SET_FIRST_LOGIN_AFTER_SIGNUP, firstLoginAfterSignup: true }]; + const expectedActions = [{ type: appActions.setFirstLoginAfterSignup.type, payload: true }]; const result = store.dispatch( createOrganizationTrial({ 'g-recaptcha-response': 'test', @@ -134,7 +134,11 @@ describe('organization actions', () => { it('should handle credit card details retrieval', async () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); - const expectedActions = [{ type: RECEIVE_CURRENT_CARD, card: defaultState.organization.card }]; + const expectedActions = [ + { type: getCurrentCard.pending.type }, + { type: actions.receiveCurrentCard.type, payload: defaultState.organization.card }, + { type: getCurrentCard.fulfilled.type } + ]; await store.dispatch(getCurrentCard()).then(() => { const storeActions = store.getActions(); expect(storeActions).toHaveLength(expectedActions.length); @@ -146,8 +150,10 @@ describe('organization actions', () => { const store = mockStore({ ...defaultState, users: { ...defaultState.users, currentSession: getSessionInfo() } }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { type: SET_ORGANIZATION, organization: defaultState.organization.organization }, - { type: SET_ANNOUNCEMENT, announcement: tenantDataDivergedMessage } + { type: getUserOrganization.pending.type }, + { type: actions.setOrganization.type, payload: defaultState.organization.organization }, + { type: appActions.setAnnouncement.type, payload: tenantDataDivergedMessage }, + { type: getUserOrganization.fulfilled.type } ]; await store.dispatch(getUserOrganization()).then(() => { const storeActions = store.getActions(); @@ -159,7 +165,11 @@ describe('organization actions', () => { it('should handle support request sending', async () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); - const expectedActions = [{ type: SET_SNACKBAR, snackbar: { message: 'Your request was sent successfully' } }]; + const expectedActions = [ + { type: sendSupportMessage.pending.type }, + { type: appActions.setSnackbar.type, payload: { message: 'Your request was sent successfully', autoHideDuration: TIMEOUTS.fiveSeconds } }, + { type: sendSupportMessage.fulfilled.type } + ]; await store.dispatch(sendSupportMessage({ body: 'test', subject: 'testsubject' })).then(() => { const storeActions = store.getActions(); expect(storeActions).toHaveLength(expectedActions.length); @@ -170,15 +180,22 @@ describe('organization actions', () => { it('should handle schema based support request sending', async () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); - const expectedActions = [{ type: SET_SNACKBAR, snackbar: { message: 'Your request was sent successfully' } }]; + const expectedActions = [ + { type: requestPlanChange.pending.type }, + { type: appActions.setSnackbar.type, payload: { message: 'Your request was sent successfully', autoHideDuration: TIMEOUTS.fiveSeconds } }, + { type: requestPlanChange.fulfilled.type } + ]; await store .dispatch( - requestPlanChange(defaultState.organization.organization.id, { - current_plan: 'Basic', - requested_plan: 'Enterprise', - current_addons: 'something,extra', - requested_addons: 'something,extra,special', - user_message: 'more please' + requestPlanChange({ + tenantId: defaultState.organization.organization.id, + content: { + current_plan: 'Basic', + requested_plan: 'Enterprise', + current_addons: 'something,extra', + requested_addons: 'something,extra,special', + user_message: 'more please' + } }) ) .then(() => { @@ -191,64 +208,79 @@ describe('organization actions', () => { it('should handle license report downloads', async () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); - const result = await store.dispatch(downloadLicenseReport()); + const expectedActions = [{ type: downloadLicenseReport.pending.type }, { type: downloadLicenseReport.fulfilled.type }]; + const result = await store.dispatch(downloadLicenseReport()).unwrap(); const storeActions = store.getActions(); - expect(storeActions).toHaveLength(0); + expect(storeActions).toHaveLength(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); expect(result).toEqual('test,report'); }); it('should handle account upgrade init', async () => { const store = mockStore({ ...defaultState }); - await store.dispatch(startUpgrade(defaultState.organization.organization.id)).then(secret => { - expect(store.getActions()).toHaveLength(0); - expect(secret).toEqual('testSecret'); - }); + const expectedActions = [{ type: startUpgrade.pending.type }, { type: startUpgrade.fulfilled.type }]; + const secret = await store.dispatch(startUpgrade(defaultState.organization.organization.id)).unwrap(); + const storeActions = store.getActions(); + expect(storeActions).toHaveLength(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); + expect(secret).toEqual('testSecret'); }); it('should handle account upgrade cancelling', async () => { const store = mockStore({ ...defaultState }); - await store.dispatch(cancelUpgrade(defaultState.organization.organization.id)).then(() => expect(store.getActions()).toHaveLength(0)); + const expectedActions = [{ type: cancelUpgrade.pending.type }, { type: cancelUpgrade.fulfilled.type }]; + await store.dispatch(cancelUpgrade(defaultState.organization.organization.id)); + const storeActions = store.getActions(); + expect(storeActions).toHaveLength(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); it('should handle account upgrade completion', async () => { const store = mockStore({ ...defaultState, users: { ...defaultState.users, currentSession: getSessionInfo() } }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { organization: defaultState.organization.organization, type: SET_ORGANIZATION }, - { type: SET_ANNOUNCEMENT, announcement: tenantDataDivergedMessage } + { type: completeUpgrade.pending.type }, + { type: getUserOrganization.pending.type }, + { type: actions.setOrganization.type, payload: defaultState.organization.organization }, + { type: appActions.setAnnouncement.type, payload: tenantDataDivergedMessage }, + { type: getUserOrganization.fulfilled.type }, + { type: completeUpgrade.fulfilled.type } ]; - await store.dispatch(completeUpgrade(defaultState.organization.organization.id, 'enterprise')).then(() => { - const storeActions = store.getActions(); - expect(storeActions).toHaveLength(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); + await store.dispatch(completeUpgrade({ tenantId: defaultState.organization.organization.id, plan: 'enterprise' })); + const storeActions = store.getActions(); + expect(storeActions).toHaveLength(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); it('should handle confirm card update initialization', async () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); - const expectedActions = [{ intentId: 'testIntent', type: RECEIVE_SETUP_INTENT }]; - await store.dispatch(startCardUpdate()).then(secret => { - const storeActions = store.getActions(); - expect(secret).toEqual('testSecret'); - expect(storeActions).toHaveLength(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); + const expectedActions = [ + { type: startCardUpdate.pending.type }, + { type: actions.receiveSetupIntent.type, payload: 'testIntent' }, + { type: startCardUpdate.fulfilled.type } + ]; + const secret = await store.dispatch(startCardUpdate()).unwrap(); + const storeActions = store.getActions(); + expect(secret).toEqual('testSecret'); + expect(storeActions).toHaveLength(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); it('should handle confirm card update confirmation', async () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'Payment card was updated successfully' } }, - { type: RECEIVE_SETUP_INTENT, intentId: null } + { type: confirmCardUpdate.pending.type }, + { type: appActions.setSnackbar.type, payload: 'Payment card was updated successfully' }, + { type: actions.receiveSetupIntent.type, payload: null }, + { type: confirmCardUpdate.fulfilled.type } ]; const request = store.dispatch(confirmCardUpdate()); expect(request).resolves.toBeTruthy(); await request.then(() => { const storeActions = store.getActions(); expect(storeActions).toHaveLength(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); }); @@ -267,11 +299,12 @@ describe('organization actions', () => { }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ + { type: getAuditLogs.pending.type }, { - type: RECEIVE_AUDIT_LOGS, - events: defaultState.organization.auditlog.events, - total: defaultState.organization.auditlog.selectionState.total - } + type: actions.receiveAuditLogs.type, + payload: { events: defaultState.organization.auditlog.events, total: defaultState.organization.auditlog.selectionState.total } + }, + { type: getAuditLogs.fulfilled.type } ]; const request = store.dispatch(getAuditLogs({ page: 1, perPage: 20 })); expect(request).resolves.toBeTruthy(); @@ -281,12 +314,19 @@ describe('organization actions', () => { expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); }); - it('should allow deployment state tracking', async () => { + it('should allow auditlog state tracking', async () => { const store = mockStore({ ...defaultState }); await store.dispatch(setAuditlogsState({ page: 1, sort: { direction: 'something' } })); const expectedActions = [ - { type: SET_AUDITLOG_STATE, state: { ...defaultState.organization.auditlog.selectionState, isLoading: true, sort: { direction: 'something' } } }, - { type: SET_AUDITLOG_STATE, state: { ...defaultState.organization.auditlog.selectionState, isLoading: false } } + { type: setAuditlogsState.pending.type }, + { type: getAuditLogs.pending.type }, + { + type: actions.setAuditLogState.type, + payload: { ...defaultState.organization.auditlog.selectionState, isLoading: true, sort: { direction: 'something' } } + }, + { type: getAuditLogs.fulfilled.type }, + { type: actions.setAuditLogState.type, payload: { isLoading: false } }, + { type: setAuditlogsState.fulfilled.type } ]; const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -295,11 +335,13 @@ describe('organization actions', () => { it('should handle csv information download', async () => { const store = mockStore({ ...defaultState }); expect(store.getActions()).toHaveLength(0); - const request = store.dispatch(getAuditLogsCsvLink()); + const expectedActions = [{ type: getAuditLogsCsvLink.pending.type }, { type: getAuditLogsCsvLink.fulfilled.type }]; + const request = store.dispatch(getAuditLogsCsvLink()).unwrap(); expect(request).resolves.toBeTruthy(); await request.then(link => { const storeActions = store.getActions(); - expect(storeActions).toHaveLength(0); + expect(storeActions.length).toEqual(expectedActions.length); + expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); expect(link).toEqual('/api/management/v1/auditlogs/logs/export?limit=20000&sort=desc'); }); }); @@ -316,8 +358,12 @@ describe('organization actions', () => { }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'The integration was set up successfully' } }, - { type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, value: expectedDeviceProviders } + { type: createIntegration.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The integration was set up successfully' }, + { type: getIntegrations.pending.type }, + { type: actions.receiveExternalDeviceIntegrations.type, payload: expectedDeviceProviders }, + { type: getIntegrations.fulfilled.type }, + { type: createIntegration.fulfilled.type } ]; const request = store.dispatch(createIntegration({ connection_string: 'testString', provider: 'iot-hub' })); expect(request).resolves.toBeTruthy(); @@ -340,8 +386,12 @@ describe('organization actions', () => { }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'The integration was updated successfully' } }, - { type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, value: expectedDeviceProviders } + { type: changeIntegration.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The integration was updated successfully' }, + { type: getIntegrations.pending.type }, + { type: actions.receiveExternalDeviceIntegrations.type, payload: expectedDeviceProviders }, + { type: getIntegrations.fulfilled.type }, + { type: changeIntegration.fulfilled.type } ]; const request = store.dispatch(changeIntegration({ connection_string: 'testString2', id: 1, provider: 'iot-hub' })); expect(request).resolves.toBeTruthy(); @@ -364,10 +414,9 @@ describe('organization actions', () => { }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { - type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, - value: expectedDeviceProviders - } + { type: getIntegrations.pending.type }, + { type: actions.receiveExternalDeviceIntegrations.type, payload: expectedDeviceProviders }, + { type: getIntegrations.fulfilled.type } ]; const request = store.dispatch(getIntegrations()); expect(request).resolves.toBeTruthy(); @@ -381,8 +430,10 @@ describe('organization actions', () => { const store = mockStore({ ...defaultState, externalDeviceIntegrations: [{ id: 1, something: 'something' }] }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'The integration was removed successfully' } }, - { type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, value: [] } + { type: deleteIntegration.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The integration was removed successfully' }, + { type: actions.receiveExternalDeviceIntegrations.type, payload: [] }, + { type: deleteIntegration.fulfilled.type } ]; const request = store.dispatch(deleteIntegration({ id: 1 })); expect(request).resolves.toBeTruthy(); @@ -407,7 +458,11 @@ describe('organization actions', () => { } }); expect(store.getActions()).toHaveLength(0); - const expectedActions = [{ type: RECEIVE_WEBHOOK_EVENTS, value: webhookEvents, total: 2 }]; + const expectedActions = [ + { type: getWebhookEvents.pending.type }, + { type: actions.receiveWebhookEvents.type, payload: { value: webhookEvents, total: 2 } }, + { type: getWebhookEvents.fulfilled.type } + ]; const request = store.dispatch(getWebhookEvents()); expect(request).resolves.toBeTruthy(); await request.then(() => { @@ -435,8 +490,12 @@ describe('organization actions', () => { expect(store.getActions()).toHaveLength(0); const defaultEvent = webhookEvents[0]; const expectedActions = [ - { type: RECEIVE_WEBHOOK_EVENTS, value: [defaultEvent], total: 1 }, - { type: RECEIVE_WEBHOOK_EVENTS, value: existingEvents, total: 2 } + { type: getWebhookEvents.pending.type }, + { type: actions.receiveWebhookEvents.type, payload: { value: [defaultEvent], total: 1 } }, + { type: getWebhookEvents.pending.type }, + { type: actions.receiveWebhookEvents.type, payload: { value: existingEvents, total: 2 } }, + { type: getWebhookEvents.fulfilled.type }, + { type: getWebhookEvents.fulfilled.type } ]; const request = store.dispatch(getWebhookEvents({ page: 1, perPage: 1 })); expect(request).resolves.toBeTruthy(); @@ -456,10 +515,20 @@ describe('organization actions', () => { }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'The SSO configuration was stored successfully' } }, - { type: RECEIVE_SSO_CONFIGS, value: expectedSsoConfigs } + { type: storeSsoConfig.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The SSO configuration was stored successfully' }, + { type: getSsoConfigs.pending.type }, + { type: getSsoConfigById.pending.type }, + { type: getSsoConfigById.pending.type }, + { type: getSsoConfigById.fulfilled.type }, + { type: getSsoConfigById.fulfilled.type }, + { type: actions.receiveSsoConfigs.type }, + { type: getSsoConfigs.fulfilled.type }, + { type: storeSsoConfig.fulfilled.type } ]; - const request = store.dispatch(storeSsoConfig({ connection_string: 'testString', provider: 'iot-hub' })); + const request = store.dispatch( + storeSsoConfig({ config: { connection_string: 'testString', provider: 'iot-hub' }, contentType: SSO_TYPES.oidc.contentType }) + ); expect(request).resolves.toBeTruthy(); await request.then(() => { const storeActions = store.getActions(); @@ -480,10 +549,20 @@ describe('organization actions', () => { }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'The SSO configuration was updated successfully' } }, - { type: RECEIVE_SSO_CONFIGS, value: expectedSsoConfigs } + { type: changeSsoConfig.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The SSO configuration was updated successfully' }, + { type: getSsoConfigs.pending.type }, + { type: getSsoConfigById.pending.type }, + { type: getSsoConfigById.pending.type }, + { type: getSsoConfigById.fulfilled.type }, + { type: getSsoConfigById.fulfilled.type }, + { type: actions.receiveSsoConfigs.type }, + { type: getSsoConfigs.fulfilled.type }, + { type: changeSsoConfig.fulfilled.type } ]; - const request = store.dispatch(changeSsoConfig({ connection_string: 'testString2', id: 1, provider: 'iot-hub' })); + const request = store.dispatch( + changeSsoConfig({ config: { connection_string: 'testString2', id: 1, provider: 'iot-hub' }, contentType: SSO_TYPES.oidc.contentType }) + ); expect(request).resolves.toBeTruthy(); await request.then(() => { const storeActions = store.getActions(); @@ -503,7 +582,15 @@ describe('organization actions', () => { } }); expect(store.getActions()).toHaveLength(0); - const expectedActions = [{ type: RECEIVE_SSO_CONFIGS, value: expectedSsoConfigs }]; + const expectedActions = [ + { type: getSsoConfigs.pending.type }, + { type: getSsoConfigById.pending.type }, + { type: getSsoConfigById.pending.type }, + { type: getSsoConfigById.fulfilled.type }, + { type: getSsoConfigById.fulfilled.type }, + { type: actions.receiveSsoConfigs.type, payload: expectedSsoConfigs }, + { type: getSsoConfigs.fulfilled.type } + ]; const request = store.dispatch(getSsoConfigs()); expect(request).resolves.toBeTruthy(); await request.then(() => { @@ -516,8 +603,10 @@ describe('organization actions', () => { const store = mockStore({ ...defaultState, organization: { ...defaultState.organization, ssoConfigs: [...expectedSsoConfigs] } }); expect(store.getActions()).toHaveLength(0); const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'The SSO configuration was removed successfully' } }, - { type: RECEIVE_SSO_CONFIGS, value: [expectedSsoConfigs[1]] } + { type: deleteSsoConfig.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The SSO configuration was removed successfully' }, + { type: actions.receiveSsoConfigs.type, payload: [expectedSsoConfigs[1]] }, + { type: deleteSsoConfig.fulfilled.type } ]; const request = store.dispatch(deleteSsoConfig({ id: '1' })); expect(request).resolves.toBeTruthy(); diff --git a/frontend/src/js/actions/organizationActions.js b/frontend/src/js/store/organizationSlice/thunks.ts similarity index 51% rename from frontend/src/js/actions/organizationActions.js rename to frontend/src/js/store/organizationSlice/thunks.ts index 3816b5c4..2191c7bc 100644 --- a/frontend/src/js/actions/organizationActions.js +++ b/frontend/src/js/store/organizationSlice/thunks.ts @@ -11,41 +11,34 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck +import storeActions from '@northern.tech/store/actions'; +import Api from '@northern.tech/store/api/general-api'; +import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS, TIMEOUTS, deviceAuthV2, headerNames, iotManagerBaseURL, locations } from '@northern.tech/store/constants'; +import { getCurrentSession, getTenantCapabilities } from '@northern.tech/store/selectors'; +import { commonErrorFallback, commonErrorHandler } from '@northern.tech/store/store'; +import { setFirstLoginAfterSignup } from '@northern.tech/store/thunks'; +import { createAsyncThunk } from '@reduxjs/toolkit'; import { jwtDecode } from 'jwt-decode'; import hashString from 'md5'; import Cookies from 'universal-cookie'; -import Api, { apiUrl, headerNames } from '../api/general-api'; -import { SET_ANNOUNCEMENT, SORTING_OPTIONS, TIMEOUTS, locations } from '../constants/appConstants'; -import { DEVICE_LIST_DEFAULTS } from '../constants/deviceConstants'; -import { - RECEIVE_AUDIT_LOGS, - RECEIVE_CURRENT_CARD, - RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, - RECEIVE_SETUP_INTENT, - RECEIVE_SSO_CONFIGS, - RECEIVE_WEBHOOK_EVENTS, - SET_AUDITLOG_STATE, - SET_ORGANIZATION, - SSO_TYPES -} from '../constants/organizationConstants'; -import { deepCompare } from '../helpers'; -import { getCurrentSession, getTenantCapabilities } from '../selectors'; -import { commonErrorFallback, commonErrorHandler, setFirstLoginAfterSignup, setSnackbar } from './appActions'; -import { deviceAuthV2, iotManagerBaseURL } from './deviceActions'; +import { actions, sliceName } from '.'; +import { deepCompare } from '../../helpers'; +import { SSO_TYPES, auditLogsApiUrl, ssoIdpApiUrlv1, tenantadmApiUrlv1, tenantadmApiUrlv2 } from './constants'; +import { getAuditlogState, getOrganization } from './selectors'; const cookies = new Cookies(); -export const auditLogsApiUrl = `${apiUrl.v1}/auditlogs`; -export const tenantadmApiUrlv1 = `${apiUrl.v1}/tenantadm`; -export const tenantadmApiUrlv2 = `${apiUrl.v2}/tenantadm`; -export const ssoIdpApiUrlv1 = `${apiUrl.v1}/useradm/sso/idp/metadata`; +const { setAnnouncement, setSnackbar } = storeActions; const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS; -export const cancelRequest = (tenantId, reason) => dispatch => - Api.post(`${tenantadmApiUrlv2}/tenants/${tenantId}/cancel`, { reason: reason }).then(() => - Promise.resolve(dispatch(setSnackbar('Deactivation request was sent successfully', TIMEOUTS.fiveSeconds, ''))) +export const cancelRequest = createAsyncThunk(`${sliceName}/cancelRequest`, (reason, { dispatch, getState }) => { + const { id: tenantId } = getOrganization(getState()); + return Api.post(`${tenantadmApiUrlv2}/tenants/${tenantId}/cancel`, { reason }).then(() => + Promise.resolve(dispatch(setSnackbar({ message: 'Deactivation request was sent successfully', autoHideDuration: TIMEOUTS.fiveSeconds }))) ); +}); export const getTargetLocation = key => { if (devLocations.includes(window.location.hostname)) { @@ -61,14 +54,14 @@ export const getTargetLocation = key => { }; const devLocations = ['localhost', 'docker.mender.io']; -export const createOrganizationTrial = data => dispatch => { +export const createOrganizationTrial = createAsyncThunk(`${sliceName}/createOrganizationTrial`, (data, { dispatch }) => { const { key } = locations[data.location]; const targetLocation = getTargetLocation(key); const target = `${targetLocation}${tenantadmApiUrlv2}/tenants/trial`; return Api.postUnauthorized(target, data) .catch(err => { if (err.response.status >= 400 && err.response.status < 500) { - dispatch(setSnackbar(err.response.data.error, TIMEOUTS.fiveSeconds, '')); + dispatch(setSnackbar({ message: err.response.data.error, autoHideDuration: TIMEOUTS.fiveSeconds })); return Promise.reject(err); } }) @@ -84,58 +77,43 @@ export const createOrganizationTrial = data => dispatch => { }, TIMEOUTS.fiveSeconds) ); }); -}; +}); -export const startCardUpdate = () => dispatch => +export const startCardUpdate = createAsyncThunk(`${sliceName}/startCardUpdate`, (_, { dispatch }) => Api.post(`${tenantadmApiUrlv2}/billing/card`) - .then(res => { - dispatch({ - type: RECEIVE_SETUP_INTENT, - intentId: res.data.intent_id - }); - return Promise.resolve(res.data.secret); + .then(({ data }) => { + dispatch(actions.receiveSetupIntent(data.intent_id)); + return Promise.resolve(data.secret); }) - .catch(err => commonErrorHandler(err, `Updating the card failed:`, dispatch)); + .catch(err => commonErrorHandler(err, `Updating the card failed:`, dispatch)) +); -export const confirmCardUpdate = () => (dispatch, getState) => +export const confirmCardUpdate = createAsyncThunk(`${sliceName}/confirmCardUpdate`, (_, { dispatch, getState }) => Api.post(`${tenantadmApiUrlv2}/billing/card/${getState().organization.intentId}/confirm`) - .then(() => - Promise.all([ - dispatch(setSnackbar('Payment card was updated successfully')), - dispatch({ - type: RECEIVE_SETUP_INTENT, - intentId: null - }) - ]) - ) - .catch(err => commonErrorHandler(err, `Updating the card failed:`, dispatch)); + .then(() => Promise.all([dispatch(setSnackbar('Payment card was updated successfully')), dispatch(actions.receiveSetupIntent(null))])) + .catch(err => commonErrorHandler(err, `Updating the card failed:`, dispatch)) +); -export const getCurrentCard = () => dispatch => +export const getCurrentCard = createAsyncThunk(`${sliceName}/getCurrentCard`, (_, { dispatch }) => Api.get(`${tenantadmApiUrlv2}/billing`).then(res => { const { last4, exp_month, exp_year, brand } = res.data.card || {}; - return Promise.resolve( - dispatch({ - type: RECEIVE_CURRENT_CARD, - card: { - brand, - last4, - expiration: { month: exp_month, year: exp_year } - } - }) - ); - }); + return Promise.resolve(dispatch(actions.receiveCurrentCard({ brand, last4, expiration: { month: exp_month, year: exp_year } }))); + }) +); -export const startUpgrade = tenantId => dispatch => +export const startUpgrade = createAsyncThunk(`${sliceName}/startUpgrade`, (tenantId, { dispatch }) => Api.post(`${tenantadmApiUrlv2}/tenants/${tenantId}/upgrade/start`) .then(({ data }) => Promise.resolve(data.secret)) - .catch(err => commonErrorHandler(err, `There was an error upgrading your account:`, dispatch)); + .catch(err => commonErrorHandler(err, `There was an error upgrading your account:`, dispatch)) +); -export const cancelUpgrade = tenantId => () => Api.post(`${tenantadmApiUrlv2}/tenants/${tenantId}/upgrade/cancel`); +export const cancelUpgrade = createAsyncThunk(`${sliceName}/cancelUpgrade`, tenantId => Api.post(`${tenantadmApiUrlv2}/tenants/${tenantId}/upgrade/cancel`)); -export const completeUpgrade = (tenantId, plan) => dispatch => +export const completeUpgrade = createAsyncThunk(`${sliceName}/completeUpgrade`, ({ tenantId, plan }, { dispatch }) => Api.post(`${tenantadmApiUrlv2}/tenants/${tenantId}/upgrade/complete`, { plan }) .catch(err => commonErrorHandler(err, `There was an error upgrading your account:`, dispatch)) - .then(() => Promise.resolve(dispatch(getUserOrganization()))); + .then(() => Promise.resolve(dispatch(getUserOrganization()))) +); const prepareAuditlogQuery = ({ startDate, endDate, user: userFilter, type, detail: detailFilter, sort = {} }) => { const userId = userFilter?.id || userFilter; @@ -149,26 +127,27 @@ const prepareAuditlogQuery = ({ startDate, endDate, user: userFilter, type, deta return `${createdAfter}${createdBefore}${userSearch}${typeSearch}${objectSearch}&sort=${direction}`; }; -export const getAuditLogs = selectionState => (dispatch, getState) => { +export const getAuditLogs = createAsyncThunk(`${sliceName}/getAuditLogs`, (selectionState, { dispatch, getState }) => { const { page, perPage } = selectionState; const { hasAuditlogs } = getTenantCapabilities(getState()); if (!hasAuditlogs) { return Promise.resolve(); } return Api.get(`${auditLogsApiUrl}/logs?page=${page}&per_page=${perPage}${prepareAuditlogQuery(selectionState)}`) - .then(res => { - let total = res.headers[headerNames.total]; - total = Number(total || res.data.length); - return Promise.resolve(dispatch({ type: RECEIVE_AUDIT_LOGS, events: res.data, total })); + .then(({ data, headers }) => { + let total = headers[headerNames.total]; + total = Number(total || data.length); + return Promise.resolve(dispatch(actions.receiveAuditLogs({ events: data, total }))); }) .catch(err => commonErrorHandler(err, `There was an error retrieving audit logs:`, dispatch)); -}; +}); -export const getAuditLogsCsvLink = () => (dispatch, getState) => - Promise.resolve(`${auditLogsApiUrl}/logs/export?limit=20000${prepareAuditlogQuery(getState().organization.auditlog.selectionState)}`); +export const getAuditLogsCsvLink = createAsyncThunk(`${sliceName}/getAuditLogsCsvLink`, (_, { getState }) => + Promise.resolve(`${auditLogsApiUrl}/logs/export?limit=20000${prepareAuditlogQuery(getAuditlogState(getState()))}`) +); -export const setAuditlogsState = selectionState => (dispatch, getState) => { - const currentState = getState().organization.auditlog.selectionState; +export const setAuditlogsState = createAsyncThunk(`${sliceName}/setAuditlogsState`, (selectionState, { dispatch, getState }) => { + const currentState = getAuditlogState(getState()); let nextState = { ...currentState, ...selectionState, @@ -181,19 +160,20 @@ export const setAuditlogsState = selectionState => (dispatch, getState) => { const { isLoading: selectionLoading, selectedIssue: selectionIssue, ...selectionRequestState } = nextState; if (!deepCompare(currentRequestState, selectionRequestState)) { nextState.isLoading = true; - tasks.push(dispatch(getAuditLogs(nextState)).finally(() => dispatch(setAuditlogsState({ isLoading: false })))); + tasks.push(dispatch(getAuditLogs(nextState)).finally(() => dispatch(actions.setAuditLogState({ isLoading: false })))); } - tasks.push(dispatch({ type: SET_AUDITLOG_STATE, state: nextState })); + tasks.push(dispatch(actions.setAuditLogState(nextState))); return Promise.all(tasks); -}; +}); /* Tenant management + Hosted Mender */ export const tenantDataDivergedMessage = 'The system detected there is a change in your plan or purchased add-ons. Please log out and log in again'; -export const getUserOrganization = () => (dispatch, getState) => - Api.get(`${tenantadmApiUrlv1}/user/tenant`).then(res => { - let tasks = [dispatch({ type: SET_ORGANIZATION, organization: res.data })]; + +export const getUserOrganization = createAsyncThunk(`${sliceName}/getUserOrganization`, (_, { dispatch, getState }) => { + return Api.get(`${tenantadmApiUrlv1}/user/tenant`).then(res => { + let tasks = [dispatch(actions.setOrganization(res.data))]; const { addons, plan, trial } = res.data; const { token } = getCurrentSession(getState()); const jwt = jwtDecode(token); @@ -201,51 +181,56 @@ export const getUserOrganization = () => (dispatch, getState) => if (!deepCompare({ addons, plan, trial }, jwtData)) { const hash = hashString(tenantDataDivergedMessage); cookies.remove(`${jwt.sub}${hash}`); - tasks.push(dispatch({ type: SET_ANNOUNCEMENT, announcement: tenantDataDivergedMessage })); + tasks.push(dispatch(setAnnouncement(tenantDataDivergedMessage))); } return Promise.all(tasks); }); +}); -export const sendSupportMessage = content => dispatch => +export const sendSupportMessage = createAsyncThunk(`${sliceName}/sendSupportMessage`, (content, { dispatch }) => Api.post(`${tenantadmApiUrlv2}/contact/support`, content) .catch(err => commonErrorHandler(err, 'There was an error sending your request', dispatch, commonErrorFallback)) - .then(() => Promise.resolve(dispatch(setSnackbar('Your request was sent successfully', TIMEOUTS.fiveSeconds, '')))); + .then(() => Promise.resolve(dispatch(setSnackbar({ message: 'Your request was sent successfully', autoHideDuration: TIMEOUTS.fiveSeconds })))) +); -export const requestPlanChange = (tenantId, content) => dispatch => +export const requestPlanChange = createAsyncThunk(`${sliceName}/requestPlanChange`, ({ content, tenantId }, { dispatch }) => Api.post(`${tenantadmApiUrlv2}/tenants/${tenantId}/plan`, content) .catch(err => commonErrorHandler(err, 'There was an error sending your request', dispatch, commonErrorFallback)) - .then(() => Promise.resolve(dispatch(setSnackbar('Your request was sent successfully', TIMEOUTS.fiveSeconds, '')))); + .then(() => Promise.resolve(dispatch(setSnackbar({ message: 'Your request was sent successfully', autoHideDuration: TIMEOUTS.fiveSeconds })))) +); -export const downloadLicenseReport = () => dispatch => +export const downloadLicenseReport = createAsyncThunk(`${sliceName}/downloadLicenseReport`, (_, { dispatch }) => Api.get(`${deviceAuthV2}/reports/devices`) .catch(err => commonErrorHandler(err, 'There was an error downloading the report', dispatch, commonErrorFallback)) - .then(res => res.data); + .then(res => res.data) +); -export const createIntegration = integration => dispatch => { - // eslint-disable-next-line no-unused-vars - const { credentials, id, provider, ...remainder } = integration; - return Api.post(`${iotManagerBaseURL}/integrations`, { provider, credentials, ...remainder }) +// eslint-disable-next-line no-unused-vars +export const createIntegration = createAsyncThunk(`${sliceName}/createIntegration`, ({ id, ...integration }, { dispatch }) => + Api.post(`${iotManagerBaseURL}/integrations`, integration) .catch(err => commonErrorHandler(err, 'There was an error creating the integration', dispatch, commonErrorFallback)) - .then(() => Promise.all([dispatch(setSnackbar('The integration was set up successfully')), dispatch(getIntegrations())])); -}; + .then(() => Promise.all([dispatch(setSnackbar('The integration was set up successfully')), dispatch(getIntegrations())])) +); -export const changeIntegration = integration => dispatch => - Api.put(`${iotManagerBaseURL}/integrations/${integration.id}/credentials`, integration.credentials) +export const changeIntegration = createAsyncThunk(`${sliceName}/changeIntegration`, ({ id, credentials }, { dispatch }) => + Api.put(`${iotManagerBaseURL}/integrations/${id}/credentials`, credentials) .catch(err => commonErrorHandler(err, 'There was an error updating the integration', dispatch, commonErrorFallback)) - .then(() => Promise.all([dispatch(setSnackbar('The integration was updated successfully')), dispatch(getIntegrations())])); + .then(() => Promise.all([dispatch(setSnackbar('The integration was updated successfully')), dispatch(getIntegrations())])) +); -export const deleteIntegration = integration => (dispatch, getState) => - Api.delete(`${iotManagerBaseURL}/integrations/${integration.id}`, {}) +export const deleteIntegration = createAsyncThunk(`${sliceName}/deleteIntegration`, ({ id, provider }, { dispatch, getState }) => + Api.delete(`${iotManagerBaseURL}/integrations/${id}`, {}) .catch(err => commonErrorHandler(err, 'There was an error removing the integration', dispatch, commonErrorFallback)) .then(() => { - const integrations = getState().organization.externalDeviceIntegrations.filter(item => integration.provider !== item.provider); + const integrations = getState().organization.externalDeviceIntegrations.filter(item => provider !== item.provider); return Promise.all([ dispatch(setSnackbar('The integration was removed successfully')), - dispatch({ type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, value: integrations }) + dispatch(actions.receiveExternalDeviceIntegrations(integrations)) ]); - }); + }) +); -export const getIntegrations = () => (dispatch, getState) => +export const getIntegrations = createAsyncThunk(`${sliceName}/getIntegrations`, (_, { dispatch, getState }) => Api.get(`${iotManagerBaseURL}/integrations`) .catch(err => commonErrorHandler(err, 'There was an error retrieving the integration', dispatch, commonErrorFallback)) .then(({ data }) => { @@ -256,35 +241,36 @@ export const getIntegrations = () => (dispatch, getState) => accu.push(integration); return accu; }, []); - return Promise.resolve(dispatch({ type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, value: integrations })); - }); + return Promise.resolve(dispatch(actions.receiveExternalDeviceIntegrations(integrations))); + }) +); -export const getWebhookEvents = - (config = {}) => - (dispatch, getState) => { - const { isFollowUp, page = defaultPage, perPage = defaultPerPage } = config; - return Api.get(`${iotManagerBaseURL}/events?page=${page}&per_page=${perPage}`) - .catch(err => commonErrorHandler(err, 'There was an error retrieving activity for this integration', dispatch, commonErrorFallback)) - .then(({ data }) => { - let tasks = [ - dispatch({ - type: RECEIVE_WEBHOOK_EVENTS, +export const getWebhookEvents = createAsyncThunk(`${sliceName}/getWebhookEvents`, (config = {}, { dispatch, getState }) => { + const { isFollowUp, page = defaultPage, perPage = defaultPerPage } = config; + return Api.get(`${iotManagerBaseURL}/events?page=${page}&per_page=${perPage}`) + .catch(err => commonErrorHandler(err, 'There was an error retrieving activity for this integration', dispatch, commonErrorFallback)) + .then(({ data }) => { + let tasks = [ + dispatch( + actions.receiveWebhookEvents({ value: isFollowUp ? getState().organization.webhooks.events : data, total: (page - 1) * perPage + data.length }) - ]; - if (data.length >= perPage && !isFollowUp) { - tasks.push(dispatch(getWebhookEvents({ isFollowUp: true, page: page + 1, perPage: 1 }))); - } - return Promise.all(tasks); - }); - }; + ) + ]; + if (data.length >= perPage && !isFollowUp) { + tasks.push(dispatch(getWebhookEvents({ isFollowUp: true, page: page + 1, perPage: 1 }))); + } + return Promise.all(tasks); + }); +}); const ssoConfigActions = { create: { success: 'stored', error: 'storing' }, edit: { success: 'updated', error: 'updating' }, read: { success: '', error: 'retrieving' }, - remove: { success: 'removed', error: 'removing' } + remove: { success: 'removed', error: 'removing' }, + readMultiple: { success: '', error: 'retrieving' } }; const ssoConfigActionErrorHandler = (err, type) => dispatch => @@ -292,43 +278,42 @@ const ssoConfigActionErrorHandler = (err, type) => dispatch => const ssoConfigActionSuccessHandler = type => dispatch => dispatch(setSnackbar(`The SSO configuration was ${ssoConfigActions[type].success} successfully`)); -export const storeSsoConfig = - ({ config, contentType }) => - dispatch => - Api.post(ssoIdpApiUrlv1, config, { headers: { 'Content-Type': contentType, Accept: 'application/json' } }) - .catch(err => dispatch(ssoConfigActionErrorHandler(err, 'create'))) - .then(() => Promise.all([dispatch(ssoConfigActionSuccessHandler('create')), dispatch(getSsoConfigs())])); - -export const changeSsoConfig = - ({ id, config, contentType }) => - dispatch => - Api.put(`${ssoIdpApiUrlv1}/${id}`, config, { headers: { 'Content-Type': contentType, Accept: 'application/json' } }) - .catch(err => dispatch(ssoConfigActionErrorHandler(err, 'edit'))) - .then(() => Promise.all([dispatch(ssoConfigActionSuccessHandler('edit')), dispatch(getSsoConfigs())])); - -export const deleteSsoConfig = - ({ id }) => - (dispatch, getState) => - Api.delete(`${ssoIdpApiUrlv1}/${id}`) - .catch(err => dispatch(ssoConfigActionErrorHandler(err, 'remove'))) - .then(() => { - const configs = getState().organization.ssoConfigs.filter(item => id !== item.id); - return Promise.all([dispatch(ssoConfigActionSuccessHandler('remove')), dispatch({ type: RECEIVE_SSO_CONFIGS, value: configs })]); - }); - -const getSsoConfigById = config => dispatch => +export const storeSsoConfig = createAsyncThunk(`${sliceName}/storeSsoConfig`, ({ config, contentType }, { dispatch }) => + Api.post(ssoIdpApiUrlv1, config, { headers: { 'Content-Type': contentType, Accept: 'application/json' } }) + .catch(err => dispatch(ssoConfigActionErrorHandler(err, 'create'))) + .then(() => Promise.all([dispatch(ssoConfigActionSuccessHandler('create')), dispatch(getSsoConfigs())])) +); + +export const changeSsoConfig = createAsyncThunk(`${sliceName}/changeSsoConfig`, ({ config, contentType }, { dispatch }) => + Api.put(`${ssoIdpApiUrlv1}/${config.id}`, config, { headers: { 'Content-Type': contentType, Accept: 'application/json' } }) + .catch(err => dispatch(ssoConfigActionErrorHandler(err, 'edit'))) + .then(() => Promise.all([dispatch(ssoConfigActionSuccessHandler('edit')), dispatch(getSsoConfigs())])) +); + +export const deleteSsoConfig = createAsyncThunk(`${sliceName}/deleteSsoConfig`, ({ id }, { dispatch, getState }) => + Api.delete(`${ssoIdpApiUrlv1}/${id}`) + .catch(err => dispatch(ssoConfigActionErrorHandler(err, 'remove'))) + .then(() => { + const configs = getState().organization.ssoConfigs.filter(item => id !== item.id); + return Promise.all([dispatch(ssoConfigActionSuccessHandler('remove')), dispatch(actions.receiveSsoConfigs(configs))]); + }) +); + +export const getSsoConfigById = createAsyncThunk(`${sliceName}/getSsoConfigById`, (config, { dispatch }) => Api.get(`${ssoIdpApiUrlv1}/${config.id}`) .catch(err => dispatch(ssoConfigActionErrorHandler(err, 'read'))) .then(({ data, headers }) => { const sso = Object.values(SSO_TYPES).find(({ contentType }) => contentType === headers['content-type']); return sso ? Promise.resolve({ ...config, config: data, type: sso.id }) : Promise.reject('Unsupported SSO config content type.'); - }); + }) +); -export const getSsoConfigs = () => dispatch => +export const getSsoConfigs = createAsyncThunk(`${sliceName}/getSsoConfigs`, (_, { dispatch }) => Api.get(ssoIdpApiUrlv1) - .catch(err => commonErrorHandler(err, 'There was an error retrieving SSO configurations', dispatch, commonErrorFallback)) + .catch(err => dispatch(ssoConfigActionErrorHandler(err, 'readMultiple'))) .then(({ data }) => - Promise.all(data.map(config => Promise.resolve(dispatch(getSsoConfigById(config))))) - .then(configs => dispatch({ type: RECEIVE_SSO_CONFIGS, value: configs })) + Promise.all(data.map(config => dispatch(getSsoConfigById(config)).unwrap())) + .then(configs => dispatch(actions.receiveSsoConfigs(configs))) .catch(err => commonErrorHandler(err, err, dispatch, '')) - ); + ) +); diff --git a/frontend/src/js/constants/releaseConstants.js b/frontend/src/js/store/releasesSlice/constants.ts similarity index 57% rename from frontend/src/js/constants/releaseConstants.js rename to frontend/src/js/store/releasesSlice/constants.ts index d8e3d3ac..317220aa 100644 --- a/frontend/src/js/constants/releaseConstants.js +++ b/frontend/src/js/store/releasesSlice/constants.ts @@ -12,20 +12,7 @@ // See the License for the specific language governing permissions and // limitations under the License. export const ARTIFACT_GENERATION_TYPE = { SINGLE_FILE: 'single_file' }; -export const ARTIFACTS_REMOVED_ARTIFACT = 'ARTIFACTS_REMOVED_ARTIFACT'; -export const ARTIFACTS_SET_ARTIFACT_URL = 'ARTIFACTS_SET_ARTIFACT_URL'; -export const UPDATED_ARTIFACT = 'UPDATED_ARTIFACT'; -export const RECEIVE_ARTIFACTS = 'RECEIVE_ARTIFACTS'; -export const RECEIVE_RELEASE = 'RECEIVE_RELEASE'; -export const RECEIVE_RELEASE_TAGS = 'RECEIVE_RELEASE_TAGS'; -export const RECEIVE_RELEASE_TYPES = 'RECEIVE_RELEASE_TYPES'; -export const RECEIVE_RELEASES = 'RECEIVE_RELEASES'; -export const RELEASE_REMOVED = 'RELEASE_REMOVED'; -export const SELECTED_RELEASE = 'SELECTED_RELEASE'; -export const SET_RELEASES_LIST_STATE = 'SET_RELEASES_LIST_STATE'; + export const currentArtifact = 'artifact_name'; export const rootfsImageVersion = 'rootfs-image.version'; - export const softwareTitleMap = { [rootfsImageVersion]: { title: 'Root filesystem', priority: 0, key: rootfsImageVersion } }; - -export const ALL_RELEASES = 'All releases'; diff --git a/frontend/src/js/reducers/releaseReducer.js b/frontend/src/js/store/releasesSlice/index.ts similarity index 54% rename from frontend/src/js/reducers/releaseReducer.js rename to frontend/src/js/store/releasesSlice/index.ts index e671173f..f033584f 100644 --- a/frontend/src/js/reducers/releaseReducer.js +++ b/frontend/src/js/store/releasesSlice/index.ts @@ -1,4 +1,4 @@ -// Copyright 2019 Northern.tech AS +// Copyright 2023 Northern.tech AS // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. @@ -11,9 +11,11 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { SORTING_OPTIONS } from '../constants/appConstants'; -import * as DeviceConstants from '../constants/deviceConstants'; -import * as ReleaseConstants from '../constants/releaseConstants'; +// @ts-nocheck +import { DEVICE_LIST_DEFAULTS, SORTING_OPTIONS } from '@northern.tech/store/constants'; +import { createSlice } from '@reduxjs/toolkit'; + +export const sliceName = 'releases'; export const initialState = { /* @@ -55,7 +57,7 @@ export const initialState = { */ }, releasesList: { - ...DeviceConstants.DEVICE_LIST_DEFAULTS, + ...DEVICE_LIST_DEFAULTS, searchedIds: [], releaseIds: [], selection: [], @@ -78,54 +80,37 @@ export const initialState = { selectedRelease: null }; -const releaseReducer = (state = initialState, action) => { - switch (action.type) { - case ReleaseConstants.ARTIFACTS_REMOVED_ARTIFACT: - case ReleaseConstants.ARTIFACTS_SET_ARTIFACT_URL: - case ReleaseConstants.UPDATED_ARTIFACT: - case ReleaseConstants.RECEIVE_RELEASE: - return { - ...state, - byId: { - ...state.byId, - [action.release.name]: action.release - } - }; - case ReleaseConstants.RECEIVE_RELEASE_TAGS: - return { - ...state, - tags: action.tags - }; - case ReleaseConstants.RECEIVE_RELEASE_TYPES: - return { - ...state, - updateTypes: action.types - }; - case ReleaseConstants.RECEIVE_RELEASES: { - return { - ...state, - byId: action.releases - }; - } - case ReleaseConstants.RELEASE_REMOVED: { +export const releaseSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + receiveRelease: (state, action) => { + const { name } = action.payload; + state.byId[name] = action.payload; + }, + receiveReleases: (state, action) => { + state.byId = action.payload; + }, + receiveReleaseTags: (state, action) => { + state.tags = action.payload; + }, + receiveReleaseTypes: (state, action) => { + state.updateTypes = action.payload; + }, + removeRelease: (state, action) => { // eslint-disable-next-line no-unused-vars - const { [action.release]: toBeRemoved, ...byId } = state.byId; - return { - ...state, - byId, - selectedRelease: action.release === state.selectedRelease ? null : state.selectedRelease - }; + const { [action.payload]: toBeRemoved, ...byId } = state.byId; + state.byId = byId; + state.selectedRelease = action.payload === state.selectedRelease ? null : state.selectedRelease; + }, + selectedRelease: (state, action) => { + state.selectedRelease = action.payload; + }, + setReleaseListState: (state, action) => { + state.releasesList = action.payload; } - case ReleaseConstants.SELECTED_RELEASE: - return { - ...state, - selectedRelease: action.release - }; - case ReleaseConstants.SET_RELEASES_LIST_STATE: - return { ...state, releasesList: action.value }; - default: - return state; } -}; +}); -export default releaseReducer; +export const actions = releaseSlice.actions; +export default releaseSlice.reducer; diff --git a/frontend/src/js/store/releasesSlice/reducer.test.ts b/frontend/src/js/store/releasesSlice/reducer.test.ts new file mode 100644 index 00000000..ee03502b --- /dev/null +++ b/frontend/src/js/store/releasesSlice/reducer.test.ts @@ -0,0 +1,78 @@ +// Copyright 2020 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import reducer, { actions, initialState } from '.'; + +const testRelease = { + artifacts: [ + { + id: '123', + name: 'test', + description: '-', + device_types_compatible: ['test'], + updates: [{ files: [{ size: 123, name: '' }], type_info: { type: 'rootfs-image' } }], + url: '' + } + ], + device_types_compatible: ['test'], + name: 'test' +}; +describe('release reducer', () => { + it('should return the initial state', async () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + it('should handle RECEIVE_RELEASE', async () => { + expect(reducer(undefined, { type: actions.receiveRelease, payload: { ...testRelease, name: 'test2' } }).byId.test2).toEqual({ + ...testRelease, + name: 'test2' + }); + expect(reducer(initialState, { type: actions.receiveRelease, payload: { ...testRelease, name: 'test2' } }).byId.test2).toEqual({ + ...testRelease, + name: 'test2' + }); + }); + it('should handle RECEIVE_RELEASES', async () => { + expect(reducer(undefined, { type: actions.receiveReleases, payload: { test: testRelease, test2: { ...testRelease, name: 'test2' } } }).byId).toEqual({ + test: testRelease, + test2: { ...testRelease, name: 'test2' } + }); + expect(reducer(initialState, { type: actions.receiveReleases, payload: { test: testRelease, test2: { ...testRelease, name: 'test2' } } }).byId).toEqual({ + test: testRelease, + test2: { ...testRelease, name: 'test2' } + }); + }); + it('should handle RELEASE_REMOVED', async () => { + expect(reducer(undefined, { type: actions.removeRelease, payload: 'test' }).byId).toEqual({}); + expect(reducer({ ...initialState, byId: { test: testRelease } }, { type: actions.removeRelease, payload: 'test' }).byId).toEqual({}); + expect( + reducer({ ...initialState, byId: { test: testRelease }, selectedRelease: 'test' }, { type: actions.removeRelease, payload: 'test' }).selectedRelease + ).toEqual(null); + expect( + reducer({ ...initialState, byId: { test: testRelease, test2: testRelease }, selectedRelease: 'test2' }, { type: actions.removeRelease, payload: 'test' }) + .selectedRelease + ).toEqual('test2'); + }); + it('should handle SELECTED_RELEASE', async () => { + expect(reducer(undefined, { type: actions.selectedRelease, payload: 'test' }).selectedRelease).toEqual('test'); + expect(reducer(initialState, { type: actions.selectedRelease, payload: 'test' }).selectedRelease).toEqual('test'); + }); + it('should handle SET_RELEASES_LIST_STATE', async () => { + expect(reducer(undefined, { type: actions.setReleaseListState, payload: { something: 'special' } }).releasesList).toEqual({ + something: 'special' + }); + expect(reducer(initialState, { type: actions.setReleaseListState, payload: { something: 'special' } }).releasesList).toEqual({ + something: 'special' + }); + }); +}); diff --git a/frontend/src/js/store/releasesSlice/selectors.ts b/frontend/src/js/store/releasesSlice/selectors.ts new file mode 100644 index 00000000..c3cbef1c --- /dev/null +++ b/frontend/src/js/store/releasesSlice/selectors.ts @@ -0,0 +1,33 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +import { listItemMapper } from '@northern.tech/store/utils'; +import { createSelector } from '@reduxjs/toolkit'; + +const getSelectedReleaseId = state => state.releases.selectedRelease; +export const getReleasesById = state => state.releases.byId; +export const getReleaseTags = state => state.releases.tags; +export const getReleaseListState = state => state.releases.releasesList; +const getListedReleases = state => state.releases.releasesList.releaseIds; +export const getUpdateTypes = state => state.releases.updateTypes; + +const getReleaseMappingDefaults = () => ({}); +export const getReleasesList = createSelector([getReleasesById, getListedReleases, getReleaseMappingDefaults], listItemMapper); + +export const getReleaseTagsById = createSelector([getReleaseTags], releaseTags => releaseTags.reduce((accu, key) => ({ ...accu, [key]: key }), {})); +export const getHasReleases = createSelector( + [getReleaseListState, getReleasesById], + ({ searchTotal, total }, byId) => !!(Object.keys(byId).length || total || searchTotal) +); + +export const getSelectedRelease = createSelector([getReleasesById, getSelectedReleaseId], (byId, id) => byId[id] ?? {}); diff --git a/frontend/src/js/actions/releaseActions.test.js b/frontend/src/js/store/releasesSlice/thunks.test.ts similarity index 52% rename from frontend/src/js/actions/releaseActions.test.js rename to frontend/src/js/store/releasesSlice/thunks.test.ts index 3473bf41..72cecd57 100644 --- a/frontend/src/js/actions/releaseActions.test.js +++ b/frontend/src/js/store/releasesSlice/thunks.test.ts @@ -11,13 +11,14 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck import configureMockStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; -import { defaultState } from '../../../tests/mockData'; -import { mockAbortController } from '../../../tests/setupTests'; -import * as AppConstants from '../constants/appConstants'; -import * as ReleaseConstants from '../constants/releaseConstants'; +import { actions } from '.'; +import { defaultState } from '../../../../tests/mockData'; +import { mockAbortController } from '../../../../tests/setupTests'; +import { actions as appActions } from '../appSlice'; import { createArtifact, editArtifact, @@ -31,9 +32,10 @@ import { removeRelease, selectRelease, setReleaseTags, + setReleasesListState, updateReleaseInfo, uploadArtifact -} from './releaseActions'; +} from './thunks'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -65,7 +67,11 @@ describe('release actions', () => { it('should retrieve a single release by name', async () => { const store = mockStore({ ...defaultState }); store.clearActions(); - const expectedActions = [{ type: ReleaseConstants.RECEIVE_RELEASE, release: defaultState.releases.byId.r1 }]; + const expectedActions = [ + { type: getRelease.pending.type }, + { type: actions.receiveRelease.type, payload: defaultState.releases.byId.r1 }, + { type: getRelease.fulfilled.type } + ]; await store.dispatch(getRelease(defaultState.releases.byId.r1.name)); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -74,11 +80,13 @@ describe('release actions', () => { it('should retrieve a list of releases', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { type: ReleaseConstants.RECEIVE_RELEASES, releases: defaultState.releases.byId }, + { type: getReleases.pending.type }, + { type: actions.receiveReleases.type, payload: defaultState.releases.byId }, { - type: ReleaseConstants.SET_RELEASES_LIST_STATE, - value: { ...defaultState.releases.releasesList, releaseIds: ['release-1'], total: 5000 } - } + type: actions.setReleaseListState.type, + payload: { ...defaultState.releases.releasesList, releaseIds: ['release-1'], total: 5000 } + }, + { type: getReleases.fulfilled.type } ]; await store.dispatch(getReleases({ perPage: 1, sort: { direction: 'asc', key: 'name' } })); const storeActions = store.getActions(); @@ -88,15 +96,17 @@ describe('release actions', () => { it('should retrieve a search filtered list of releases', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { type: ReleaseConstants.RECEIVE_RELEASES, releases: defaultState.releases.byId }, + { type: getReleases.pending.type }, + { type: actions.receiveReleases.type, payload: defaultState.releases.byId }, { - type: ReleaseConstants.SET_RELEASES_LIST_STATE, - value: { + type: actions.setReleaseListState.type, + payload: { ...defaultState.releases.releasesList, releaseIds: retrievedReleaseIds, searchTotal: 1234 } - } + }, + { type: getReleases.fulfilled.type } ]; await store.dispatch(getReleases({ searchTerm: 'something' })); const storeActions = store.getActions(); @@ -106,10 +116,11 @@ describe('release actions', () => { it('should retrieve a deployment creation search filtered list of releases', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { type: ReleaseConstants.RECEIVE_RELEASES, releases: defaultState.releases.byId }, + { type: getReleases.pending.type }, + { type: actions.receiveReleases.type, payload: defaultState.releases.byId }, { - type: ReleaseConstants.SET_RELEASES_LIST_STATE, - value: { + type: actions.setReleaseListState.type, + payload: { ...defaultState.releases.releasesList, searchedIds: [ 'release-999', @@ -124,7 +135,8 @@ describe('release actions', () => { 'release-990' ] } - } + }, + { type: getReleases.fulfilled.type } ]; await store.dispatch(getReleases({ perPage: 10, searchOnly: true, searchTerm: 'something' })); const storeActions = store.getActions(); @@ -134,13 +146,15 @@ describe('release actions', () => { it('should retrieve the device installation base for an artifact', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ + { type: getArtifactInstallCount.pending.type }, { - type: ReleaseConstants.RECEIVE_RELEASE, - release: { + type: actions.receiveRelease.type, + payload: { ...defaultState.releases.byId.r1, artifacts: [{ ...defaultState.releases.byId.r1.artifacts[0], installCount: 0 }] } - } + }, + { type: getArtifactInstallCount.fulfilled.type } ]; await store.dispatch(getArtifactInstallCount('art1')).then(() => { const storeActions = store.getActions(); @@ -151,9 +165,10 @@ describe('release actions', () => { it('should retrieve the download url for an artifact', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ + { type: getArtifactUrl.pending.type }, { - type: ReleaseConstants.ARTIFACTS_SET_ARTIFACT_URL, - release: { + type: actions.receiveRelease.type, + payload: { ...defaultState.releases.byId.r1, artifacts: [ { @@ -162,7 +177,8 @@ describe('release actions', () => { } ] } - } + }, + { type: getArtifactUrl.fulfilled.type } ]; await store.dispatch(getArtifactUrl('art1')).then(() => { const storeActions = store.getActions(); @@ -172,11 +188,15 @@ describe('release actions', () => { }); it('should select a release by name', async () => { const store = mockStore({ ...defaultState }); - await store.dispatch(selectRelease(defaultState.releases.byId.r1.name)); const expectedActions = [ - { type: ReleaseConstants.SELECTED_RELEASE, release: defaultState.releases.byId.r1.name }, - { type: ReleaseConstants.RECEIVE_RELEASE, release: defaultState.releases.byId.r1 } + { type: selectRelease.pending.type }, + { type: actions.selectedRelease.type, payload: defaultState.releases.byId.r1.name }, + { type: getRelease.pending.type }, + { type: actions.receiveRelease.type, payload: defaultState.releases.byId.r1 }, + { type: getRelease.fulfilled.type }, + { type: selectRelease.fulfilled.type } ]; + await store.dispatch(selectRelease(defaultState.releases.byId.r1.name)); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -184,17 +204,27 @@ describe('release actions', () => { it('should allow creating an artifact', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { type: AppConstants.SET_SNACKBAR, snackbar: { message: 'Generating artifact' } }, + { type: createArtifact.pending.type }, + { type: appActions.setSnackbar.type, payload: 'Generating artifact' }, { - type: AppConstants.UPLOAD_PROGRESS, - uploads: { 'mock-uuid': { cancelSource: mockAbortController, name: undefined, size: undefined, uploadProgress: 0 } } + type: appActions.initUpload.type, + payload: { + id: 'mock-uuid', + upload: { cancelSource: mockAbortController, name: 'createdRelease', size: undefined, uploadProgress: 0 } + } }, - { type: AppConstants.UPLOAD_PROGRESS, uploads: {} }, - { type: AppConstants.SET_SNACKBAR, snackbar: { message: 'Upload successful' } }, - { type: AppConstants.UPLOAD_PROGRESS, uploads: {} }, - { type: ReleaseConstants.SELECTED_RELEASE, release: 'createdRelease' } + { type: appActions.uploadProgress.type, payload: { id: 'mock-uuid', progress: 100 } }, + { type: appActions.setSnackbar.type, payload: 'Upload successful' }, + { type: appActions.cleanUpUpload.type, payload: 'mock-uuid' }, + { type: createArtifact.fulfilled.type }, + { type: getReleases.pending.type }, + { type: selectRelease.pending.type }, + { type: actions.selectedRelease.type, payload: 'createdRelease' }, + { type: getReleases.pending.type } ]; - await store.dispatch(createArtifact({ name: 'createdRelease', some: 'thing', someList: ['test', 'more'], complex: { objectThing: 'yes' } }, 'filethings')); + await store.dispatch( + createArtifact({ file: { name: 'createdRelease', some: 'thing', someList: ['test', 'more'], complex: { objectThing: 'yes' } }, meta: 'filethings' }) + ); jest.runAllTimers(); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -203,19 +233,27 @@ describe('release actions', () => { it('should support editing artifact information', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ + { type: editArtifact.pending.type }, { - type: ReleaseConstants.UPDATED_ARTIFACT, - release: { + type: actions.receiveRelease.type, + payload: { ...defaultState.releases.byId.r1, artifacts: [{ ...defaultState.releases.byId.r1.artifacts[0], description: 'something new' }] } }, - { type: AppConstants.SET_SNACKBAR, snackbar: { message: 'Artifact details were updated successfully.' } }, - { type: ReleaseConstants.SELECTED_RELEASE, release: defaultState.releases.byId.r1.name }, - { type: ReleaseConstants.RECEIVE_RELEASE, release: defaultState.releases.byId.r1 }, - { type: ReleaseConstants.RECEIVE_RELEASE, release: defaultState.releases.byId.r1 } + { type: appActions.setSnackbar.type, payload: 'Artifact details were updated successfully.' }, + { type: getReleases.pending.type }, + { type: selectRelease.pending.type }, + { type: actions.selectedRelease.type, payload: defaultState.releases.byId.r1.name }, + { type: getReleases.pending.type }, + { type: actions.receiveRelease.type, payload: defaultState.releases.byId.r1 }, + { type: actions.receiveRelease.type, payload: defaultState.releases.byId.r1 }, + { type: getReleases.fulfilled.type }, + { type: getReleases.fulfilled.type }, + { type: selectRelease.fulfilled.type }, + { type: editArtifact.fulfilled.type } ]; - await store.dispatch(editArtifact(defaultState.releases.byId.r1.artifacts[0].id, { description: 'something new' })); + await store.dispatch(editArtifact({ id: defaultState.releases.byId.r1.artifacts[0].id, body: { description: 'something new' } })); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -223,20 +261,22 @@ describe('release actions', () => { it('should support uploading .mender artifact files', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { type: AppConstants.SET_SNACKBAR, snackbar: { message: 'Uploading artifact' } }, + { type: uploadArtifact.pending.type }, + { type: appActions.setSnackbar.type, payload: 'Uploading artifact' }, { - type: AppConstants.UPLOAD_PROGRESS, - uploads: { 'mock-uuid': { cancelSource: mockAbortController, name: undefined, size: 1234, uploadProgress: 0 } } + type: appActions.initUpload.type, + payload: { id: 'mock-uuid', upload: { cancelSource: mockAbortController, name: defaultState.releases.byId.r1.name, size: 1234, uploadProgress: 0 } } }, - { type: AppConstants.UPLOAD_PROGRESS, uploads: {} }, - { type: AppConstants.SET_SNACKBAR, snackbar: { message: 'Upload successful' } }, - { type: ReleaseConstants.SELECTED_RELEASE, release: defaultState.releases.byId.r1.name }, - { type: ReleaseConstants.RECEIVE_RELEASE, release: defaultState.releases.byId.r1 }, - { type: ReleaseConstants.RECEIVE_RELEASES, releases: defaultState.releases.byId }, - { type: ReleaseConstants.SET_RELEASES_LIST_STATE, value: { ...defaultState.releases.releasesList, releaseIds: retrievedReleaseIds, total: 5000 } }, - { type: AppConstants.UPLOAD_PROGRESS, uploads: {} } + { type: appActions.uploadProgress.type, payload: { id: 'mock-uuid', progress: 100 } }, + { type: appActions.setSnackbar.type, payload: 'Upload successful' }, + { type: getReleases.pending.type }, + { type: actions.receiveReleases.type, payload: defaultState.releases.byId }, + { type: actions.setReleaseListState.type, payload: { ...defaultState.releases.releasesList, releaseIds: retrievedReleaseIds, total: 5000 } }, + { type: getReleases.fulfilled.type }, + { type: appActions.cleanUpUpload.type, payload: 'mock-uuid' }, + { type: uploadArtifact.fulfilled.type } ]; - await store.dispatch(uploadArtifact({ description: 'new artifact to upload', name: defaultState.releases.byId.r1.name }, { size: 1234 })); + await store.dispatch(uploadArtifact({ file: { name: defaultState.releases.byId.r1.name, size: 1234 }, meta: { description: 'new artifact to upload' } })); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -244,14 +284,19 @@ describe('release actions', () => { it('should remove an artifact by name', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { type: ReleaseConstants.RELEASE_REMOVED, release: defaultState.releases.byId.r1.name }, - { type: ReleaseConstants.SET_RELEASES_LIST_STATE, value: { ...defaultState.releases.releasesList, isLoading: true, releaseIds: [], total: 0 } }, - { type: ReleaseConstants.RECEIVE_RELEASES, releases: defaultState.releases.byId }, - { - type: ReleaseConstants.SET_RELEASES_LIST_STATE, - value: { ...defaultState.releases.releasesList, releaseIds: retrievedReleaseIds, total: 5000 } - }, - { type: ReleaseConstants.SET_RELEASES_LIST_STATE, value: { ...defaultState.releases.releasesList } } + { type: removeArtifact.pending.type }, + { type: actions.removeRelease.type, payload: defaultState.releases.byId.r1.name }, + { type: setReleasesListState.pending.type }, + { type: getReleases.pending.type }, + { type: actions.setReleaseListState.type, payload: { ...defaultState.releases.releasesList, isLoading: true, releaseIds: [], total: 0 } }, + { type: actions.receiveReleases.type, payload: defaultState.releases.byId }, + { type: actions.setReleaseListState.type, payload: { ...defaultState.releases.releasesList, releaseIds: retrievedReleaseIds, total: 5000 } }, + { type: getReleases.fulfilled.type }, + { type: setReleasesListState.pending.type }, + { type: actions.setReleaseListState.type, payload: { ...defaultState.releases.releasesList } }, + { type: setReleasesListState.fulfilled.type }, + { type: setReleasesListState.fulfilled.type }, + { type: removeArtifact.fulfilled.type } ]; await store.dispatch(removeArtifact('art1')); const storeActions = store.getActions(); @@ -261,15 +306,24 @@ describe('release actions', () => { it('should remove a release by name', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { type: ReleaseConstants.RELEASE_REMOVED, release: defaultState.releases.byId.r1.name }, - { type: ReleaseConstants.SET_RELEASES_LIST_STATE, value: { ...defaultState.releases.releasesList, isLoading: true, releaseIds: [], total: 0 } }, - { type: ReleaseConstants.RECEIVE_RELEASES, releases: defaultState.releases.byId }, - { - type: ReleaseConstants.SET_RELEASES_LIST_STATE, - value: { ...defaultState.releases.releasesList, releaseIds: retrievedReleaseIds, total: 5000 } - }, - { type: ReleaseConstants.SET_RELEASES_LIST_STATE, value: { ...defaultState.releases.releasesList } }, - { type: ReleaseConstants.SELECTED_RELEASE, release: null } + { type: removeRelease.pending.type }, + { type: removeArtifact.pending.type }, + { type: actions.removeRelease.type, payload: defaultState.releases.byId.r1.name }, + { type: setReleasesListState.pending.type }, + { type: getReleases.pending.type }, + { type: actions.setReleaseListState.type, payload: { ...defaultState.releases.releasesList, isLoading: true, releaseIds: [], total: 0 } }, + { type: actions.receiveReleases.type, payload: defaultState.releases.byId }, + { type: actions.setReleaseListState.type, payload: { ...defaultState.releases.releasesList, releaseIds: retrievedReleaseIds, total: 5000 } }, + { type: getReleases.fulfilled.type }, + { type: setReleasesListState.pending.type }, + { type: actions.setReleaseListState.type, payload: { ...defaultState.releases.releasesList } }, + { type: setReleasesListState.fulfilled.type }, + { type: setReleasesListState.fulfilled.type }, + { type: removeArtifact.fulfilled.type }, + { type: selectRelease.pending.type }, + { type: actions.selectedRelease.type, payload: null }, + { type: selectRelease.fulfilled.type }, + { type: removeRelease.fulfilled.type } ]; await store.dispatch(removeRelease(defaultState.releases.byId.r1.name)); const storeActions = store.getActions(); @@ -278,15 +332,23 @@ describe('release actions', () => { }); it('should retrieve existing release tags', async () => { const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: ReleaseConstants.RECEIVE_RELEASE_TAGS, tags: ['foo', 'bar'] }]; + const expectedActions = [ + { type: getExistingReleaseTags.pending.type }, + { type: actions.receiveReleaseTags.type, payload: ['foo', 'bar'] }, + { type: getExistingReleaseTags.fulfilled.type } + ]; await store.dispatch(getExistingReleaseTags()); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); - it('should retrieve existing release tags', async () => { + it('should retrieve existing update types', async () => { const store = mockStore({ ...defaultState }); - const expectedActions = [{ type: ReleaseConstants.RECEIVE_RELEASE_TYPES, types: ['single-file', 'not-this'] }]; + const expectedActions = [ + { type: getUpdateTypes.pending.type }, + { type: actions.receiveReleaseTypes.type, payload: ['single-file', 'not-this'] }, + { type: getUpdateTypes.fulfilled.type } + ]; await store.dispatch(getUpdateTypes()); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); @@ -295,13 +357,15 @@ describe('release actions', () => { it('should allow setting new release tags', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ + { type: setReleaseTags.pending.type }, { - type: ReleaseConstants.RECEIVE_RELEASE, - release: { ...defaultState.releases.byId.r1, tags: ['foo', 'bar'] } + type: actions.receiveRelease.type, + payload: { ...defaultState.releases.byId.r1, tags: ['foo', 'bar'] } }, - { type: AppConstants.SET_SNACKBAR, snackbar: { message: 'Release tags were set successfully.' } } + { type: appActions.setSnackbar.type, payload: 'Release tags were set successfully.' }, + { type: setReleaseTags.fulfilled.type } ]; - await store.dispatch(setReleaseTags(defaultState.releases.byId.r1.name, ['foo', 'bar'])); + await store.dispatch(setReleaseTags({ name: defaultState.releases.byId.r1.name, tags: ['foo', 'bar'] })); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -309,13 +373,15 @@ describe('release actions', () => { it('should allow extending the release info', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ + { type: updateReleaseInfo.pending.type }, { - type: ReleaseConstants.RECEIVE_RELEASE, - release: { ...defaultState.releases.byId.r1, notes: 'this & that' } + type: actions.receiveRelease.type, + payload: { ...defaultState.releases.byId.r1, notes: 'this & that' } }, - { type: AppConstants.SET_SNACKBAR, snackbar: { message: 'Release details were updated successfully.' } } + { type: appActions.setSnackbar.type, payload: 'Release details were updated successfully.' }, + { type: updateReleaseInfo.fulfilled.type } ]; - await store.dispatch(updateReleaseInfo(defaultState.releases.byId.r1.name, { notes: 'this & that' })); + await store.dispatch(updateReleaseInfo({ name: defaultState.releases.byId.r1.name, info: { notes: 'this & that' } })); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); diff --git a/frontend/src/js/actions/releaseActions.js b/frontend/src/js/store/releasesSlice/thunks.ts similarity index 61% rename from frontend/src/js/actions/releaseActions.js rename to frontend/src/js/store/releasesSlice/thunks.ts index 34e11773..9850f40b 100644 --- a/frontend/src/js/actions/releaseActions.js +++ b/frontend/src/js/store/releasesSlice/thunks.ts @@ -11,19 +11,32 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck +import storeActions from '@northern.tech/store/actions'; +import GeneralApi from '@northern.tech/store/api/general-api'; +import { + DEVICE_LIST_DEFAULTS, + SORTING_OPTIONS, + TIMEOUTS, + deploymentsApiUrl, + deploymentsApiUrlV2, + emptyFilter, + headerNames +} from '@northern.tech/store/constants'; +import { getSearchEndpoint } from '@northern.tech/store/selectors'; +import { commonErrorFallback, commonErrorHandler } from '@northern.tech/store/store'; +import { convertDeviceListStateToFilters, progress } from '@northern.tech/store/utils'; +import { createAsyncThunk } from '@reduxjs/toolkit'; import { isCancel } from 'axios'; import { v4 as uuid } from 'uuid'; -import { commonErrorFallback, commonErrorHandler, setSnackbar } from '../actions/appActions'; -import GeneralApi, { headerNames } from '../api/general-api'; -import { SORTING_OPTIONS, TIMEOUTS, UPLOAD_PROGRESS } from '../constants/appConstants'; -import { DEVICE_LIST_DEFAULTS, emptyFilter } from '../constants/deviceConstants'; -import * as ReleaseConstants from '../constants/releaseConstants'; -import { customSort, deepCompare, duplicateFilter, extractSoftwareItem } from '../helpers'; -import { formatReleases } from '../utils/locationutils'; -import { deploymentsApiUrl, deploymentsApiUrlV2 } from './deploymentActions'; -import { convertDeviceListStateToFilters, getSearchEndpoint } from './deviceActions'; +import { actions, sliceName } from '.'; +import { customSort, deepCompare, duplicateFilter, extractSoftwareItem } from '../../helpers'; +import { formatReleases } from '../../utils/locationutils'; +import { ARTIFACT_GENERATION_TYPE } from './constants'; +import { getReleasesById } from './selectors'; +const { setSnackbar, initUpload, uploadProgress, cleanUpUpload } = storeActions; const { page: defaultPage, perPage: defaultPerPage } = DEVICE_LIST_DEFAULTS; const sortingDefaults = { direction: SORTING_OPTIONS.desc, key: 'modified' }; @@ -66,8 +79,8 @@ const findArtifactIndexInRelease = (releases, id) => ); /* Artifacts */ -export const getArtifactInstallCount = id => (dispatch, getState) => { - let { release, index } = findArtifactIndexInRelease(getState().releases.byId, id); +export const getArtifactInstallCount = createAsyncThunk(`${sliceName}/getArtifactInstallCount`, (id, { dispatch, getState }) => { + let { release, index } = findArtifactIndexInRelease(getReleasesById(getState()), id); if (!release || index === -1) { return; } @@ -78,7 +91,7 @@ export const getArtifactInstallCount = id => (dispatch, getState) => { const { filterTerms } = convertDeviceListStateToFilters({ filters: [{ ...emptyFilter, key: attribute, value: version, scope: 'inventory' }] }); - return GeneralApi.post(getSearchEndpoint(getState().app.features.hasReporting), { + return GeneralApi.post(getSearchEndpoint(getState()), { page: 1, per_page: 1, filters: filterTerms, @@ -86,7 +99,7 @@ export const getArtifactInstallCount = id => (dispatch, getState) => { }) .catch(err => commonErrorHandler(err, `Retrieving artifact installation count failed:`, dispatch, commonErrorFallback)) .then(({ headers }) => { - let { release, index } = findArtifactIndexInRelease(getState().releases.byId, id); + let { release, index } = findArtifactIndexInRelease(getReleasesById(getState()), id); if (!release || index === -1) { return; } @@ -97,14 +110,13 @@ export const getArtifactInstallCount = id => (dispatch, getState) => { ...release, artifacts: releaseArtifacts }; - return dispatch({ type: ReleaseConstants.RECEIVE_RELEASE, release }); + return dispatch(actions.receiveRelease(release)); }); -}; +}); -export const getArtifactUrl = id => (dispatch, getState) => +export const getArtifactUrl = createAsyncThunk(`${sliceName}/getArtifactUrl`, (id, { dispatch, getState }) => GeneralApi.get(`${deploymentsApiUrl}/artifacts/${id}/download`).then(response => { - const state = getState(); - let { release, index } = findArtifactIndexInRelease(state.releases.byId, id); + let { release, index } = findArtifactIndexInRelease(getReleasesById(getState()), id); if (!release || index === -1) { return dispatch(getReleases()); } @@ -117,16 +129,11 @@ export const getArtifactUrl = id => (dispatch, getState) => ...release, artifacts: releaseArtifacts }; - return dispatch({ type: ReleaseConstants.ARTIFACTS_SET_ARTIFACT_URL, release }); - }); - -export const cleanUpUpload = uploadId => (dispatch, getState) => { - // eslint-disable-next-line no-unused-vars - const { [uploadId]: current, ...remainder } = getState().app.uploadsById; - return Promise.resolve(dispatch({ type: UPLOAD_PROGRESS, uploads: remainder })); -}; + return dispatch(actions.receiveRelease(release)); + }) +); -export const createArtifact = (meta, file) => (dispatch, getState) => { +export const createArtifact = createAsyncThunk(`${sliceName}/createArtifact`, ({ file, meta }, { dispatch }) => { let formData = Object.entries(meta).reduce((accu, [key, value]) => { if (Array.isArray(value)) { accu.append(key, value.join(',')); @@ -137,20 +144,24 @@ export const createArtifact = (meta, file) => (dispatch, getState) => { } return accu; }, new FormData()); - formData.append('type', ReleaseConstants.ARTIFACT_GENERATION_TYPE.SINGLE_FILE); + formData.append('type', ARTIFACT_GENERATION_TYPE.SINGLE_FILE); formData.append('file', file); const uploadId = uuid(); const cancelSource = new AbortController(); - const uploads = { ...getState().app.uploadsById, [uploadId]: { name: file.name, size: file.size, uploadProgress: 0, cancelSource } }; return Promise.all([ dispatch(setSnackbar('Generating artifact')), - dispatch({ type: UPLOAD_PROGRESS, uploads }), - GeneralApi.upload(`${deploymentsApiUrl}/artifacts/generate`, formData, e => dispatch(progress(e, uploadId)), cancelSource.signal) + dispatch(initUpload({ id: uploadId, upload: { name: file.name, size: file.size, uploadProgress: 0, cancelSource } })), + GeneralApi.upload( + `${deploymentsApiUrl}/artifacts/generate`, + formData, + e => dispatch(uploadProgress({ id: uploadId, progress: progress(e) })), + cancelSource.signal + ) ]) .then(() => { setTimeout(() => { dispatch(getReleases()); - dispatch(selectRelease(meta.name)); + dispatch(selectRelease(file.name)); }, TIMEOUTS.oneSecond); return Promise.resolve(dispatch(setSnackbar('Upload successful', TIMEOUTS.fiveSeconds))); }) @@ -161,20 +172,19 @@ export const createArtifact = (meta, file) => (dispatch, getState) => { return commonErrorHandler(err, `Artifact couldn't be generated.`, dispatch); }) .finally(() => dispatch(cleanUpUpload(uploadId))); -}; +}); -export const uploadArtifact = (meta, file) => (dispatch, getState) => { +export const uploadArtifact = createAsyncThunk(`${sliceName}/uploadArtifact`, ({ file, meta }, { dispatch }) => { let formData = new FormData(); formData.append('size', file.size); formData.append('description', meta.description); formData.append('artifact', file); const uploadId = uuid(); const cancelSource = new AbortController(); - const uploads = { ...getState().app.uploadsById, [uploadId]: { name: file.name, size: file.size, uploadProgress: 0, cancelSource } }; return Promise.all([ dispatch(setSnackbar('Uploading artifact')), - dispatch({ type: UPLOAD_PROGRESS, uploads }), - GeneralApi.upload(`${deploymentsApiUrl}/artifacts`, formData, e => dispatch(progress(e, uploadId)), cancelSource.signal) + dispatch(initUpload({ id: uploadId, upload: { name: file.name, size: file.size, uploadProgress: 0, cancelSource } })), + GeneralApi.upload(`${deploymentsApiUrl}/artifacts`, formData, e => dispatch(uploadProgress({ id: uploadId, progress: progress(e) })), cancelSource.signal) ]) .then(() => { const tasks = [dispatch(setSnackbar('Upload successful', TIMEOUTS.fiveSeconds)), dispatch(getReleases())]; @@ -190,51 +200,45 @@ export const uploadArtifact = (meta, file) => (dispatch, getState) => { return commonErrorHandler(err, `Artifact couldn't be uploaded.`, dispatch); }) .finally(() => dispatch(cleanUpUpload(uploadId))); -}; +}); -export const progress = (e, uploadId) => (dispatch, getState) => { - let uploadProgress = (e.loaded / e.total) * 100; - uploadProgress = uploadProgress < 50 ? Math.ceil(uploadProgress) : Math.round(uploadProgress); - const uploads = { ...getState().app.uploadsById, [uploadId]: { ...getState().app.uploadsById[uploadId], uploadProgress } }; - return dispatch({ type: UPLOAD_PROGRESS, uploads }); -}; - -export const cancelFileUpload = id => (dispatch, getState) => { - const { [id]: current, ...remainder } = getState().app.uploadsById; +export const cancelFileUpload = createAsyncThunk(`${sliceName}/cancelFileUpload`, (id, { dispatch, getState }) => { + const { [id]: current } = getState().app.uploadsById; current.cancelSource.abort(); - return Promise.resolve(dispatch({ type: UPLOAD_PROGRESS, uploads: remainder })); -}; + return Promise.resolve(dispatch(cleanUpUpload(id))); +}); -export const editArtifact = (id, body) => (dispatch, getState) => +export const editArtifact = createAsyncThunk(`${sliceName}/editArtifact`, ({ id, body }, { dispatch, getState }) => GeneralApi.put(`${deploymentsApiUrl}/artifacts/${id}`, body) .catch(err => commonErrorHandler(err, `Artifact details couldn't be updated.`, dispatch)) .then(() => { const state = getState(); - let { release, index } = findArtifactIndexInRelease(state.releases.byId, id); + let { release, index } = findArtifactIndexInRelease(getReleasesById(state), id); if (!release || index === -1) { return dispatch(getReleases()); } release.artifacts[index].description = body.description; return Promise.all([ - dispatch({ type: ReleaseConstants.UPDATED_ARTIFACT, release }), + dispatch(actions.receiveRelease(release)), dispatch(setSnackbar('Artifact details were updated successfully.', TIMEOUTS.fiveSeconds, '')), dispatch(getRelease(release.name)), dispatch(selectRelease(release.name)) ]); - }); + }) +); -export const removeArtifact = id => (dispatch, getState) => +export const removeArtifact = createAsyncThunk(`${sliceName}/removeArtifact`, (id, { dispatch, getState }) => GeneralApi.delete(`${deploymentsApiUrl}/artifacts/${id}`) .then(() => { const state = getState(); - let { release, index } = findArtifactIndexInRelease(state.releases.byId, id); + let { release, index } = findArtifactIndexInRelease(getReleasesById(state), id); const releaseArtifacts = [...release.artifacts]; releaseArtifacts.splice(index, 1); if (!releaseArtifacts.length) { const { releasesList } = state.releases; const releaseIds = releasesList.releaseIds.filter(id => release.name !== id); return Promise.all([ - dispatch({ type: ReleaseConstants.RELEASE_REMOVED, release: release.name }), + dispatch(actions.removeRelease(release.name)), dispatch( setReleasesListState({ releaseIds, @@ -244,26 +248,25 @@ export const removeArtifact = id => (dispatch, getState) => ) ]); } - return Promise.all([ - dispatch(setSnackbar('Artifact was removed', TIMEOUTS.fiveSeconds, '')), - dispatch({ type: ReleaseConstants.ARTIFACTS_REMOVED_ARTIFACT, release }) - ]); + return Promise.all([dispatch(setSnackbar('Artifact was removed', TIMEOUTS.fiveSeconds, '')), dispatch(actions.receiveRelease(release))]); }) - .catch(err => commonErrorHandler(err, `Error removing artifact:`, dispatch)); + .catch(err => commonErrorHandler(err, `Error removing artifact:`, dispatch)) +); -export const removeRelease = id => (dispatch, getState) => - Promise.all(getState().releases.byId[id].artifacts.map(({ id }) => dispatch(removeArtifact(id)))).then(() => dispatch(selectRelease())); +export const removeRelease = createAsyncThunk(`${sliceName}/removeRelease`, (releaseId, { dispatch, getState }) => + Promise.all(getReleasesById(getState())[releaseId].artifacts.map(({ id }) => dispatch(removeArtifact(id)))).then(() => dispatch(selectRelease())) +); -export const selectRelease = release => dispatch => { +export const selectRelease = createAsyncThunk(`${sliceName}/selectRelease`, (release, { dispatch }) => { const name = release ? release.name || release : null; - let tasks = [dispatch({ type: ReleaseConstants.SELECTED_RELEASE, release: name })]; + let tasks = [dispatch(actions.selectedRelease(name))]; if (name) { tasks.push(dispatch(getRelease(name))); } return Promise.all(tasks); -}; +}); -export const setReleasesListState = selectionState => (dispatch, getState) => { +export const setReleasesListState = createAsyncThunk(`${sliceName}/setReleasesListState`, (selectionState, { dispatch, getState }) => { const currentState = getState().releases.releasesList; let nextState = { ...currentState, @@ -279,9 +282,9 @@ export const setReleasesListState = selectionState => (dispatch, getState) => { nextState.isLoading = true; tasks.push(dispatch(getReleases(nextState)).finally(() => dispatch(setReleasesListState({ isLoading: false })))); } - tasks.push(dispatch({ type: ReleaseConstants.SET_RELEASES_LIST_STATE, value: nextState })); + tasks.push(dispatch(actions.setReleaseListState(nextState))); return Promise.all(tasks); -}; +}); /* Releases */ @@ -315,62 +318,62 @@ const deductSearchState = (receivedReleases, config, total, state) => { return releaseListState; }; -export const getReleases = - (passedConfig = {}) => - (dispatch, getState) => { - const config = { ...getState().releases.releasesList, ...passedConfig }; - return releaseListRetrieval(config) - .then(({ data: receivedReleases = [], headers = {} }) => { - const total = headers[headerNames.total] ? Number(headers[headerNames.total]) : 0; - const state = getState().releases; - const flatReleases = reduceReceivedReleases(receivedReleases, state.byId); - const combinedReleases = { ...state.byId, ...flatReleases }; - const releaseListState = deductSearchState(receivedReleases, config, total, state); - return Promise.all([ - dispatch({ type: ReleaseConstants.RECEIVE_RELEASES, releases: combinedReleases }), - dispatch({ type: ReleaseConstants.SET_RELEASES_LIST_STATE, value: releaseListState }) - ]); - }) - .catch(err => commonErrorHandler(err, `Please check your connection`, dispatch)); - }; +export const getReleases = createAsyncThunk(`${sliceName}/getReleases`, (passedConfig = {}, { dispatch, getState }) => { + const config = { ...getState().releases.releasesList, ...passedConfig }; + return releaseListRetrieval(config) + .then(({ data: receivedReleases = [], headers = {} }) => { + const total = headers[headerNames.total] ? Number(headers[headerNames.total]) : 0; + const state = getState().releases; + const flatReleases = reduceReceivedReleases(receivedReleases, state.byId); + const combinedReleases = { ...state.byId, ...flatReleases }; + let tasks = [dispatch(actions.receiveReleases(combinedReleases))]; + const releaseListState = deductSearchState(receivedReleases, config, total, state); + tasks.push(dispatch(actions.setReleaseListState(releaseListState))); + return Promise.all(tasks); + }) + .catch(err => commonErrorHandler(err, `Please check your connection`, dispatch)); +}); -export const getRelease = name => (dispatch, getState) => +export const getRelease = createAsyncThunk(`${sliceName}/getReleases`, (name, { dispatch, getState }) => releaseListRetrieval({ searchTerm: name, page: 1, perPage: 1 }).then(({ data: releases }) => { if (releases.length) { - const stateRelease = getState().releases.byId[releases[0].name] || {}; - return Promise.resolve(dispatch({ type: ReleaseConstants.RECEIVE_RELEASE, release: flattenRelease(releases[0], stateRelease) })); + const stateRelease = getReleasesById(getState())[releases[0].name] || {}; + return Promise.resolve(dispatch(actions.receiveRelease(flattenRelease(releases[0], stateRelease)))); } return Promise.resolve(null); - }); + }) +); -export const updateReleaseInfo = (name, info) => (dispatch, getState) => +export const updateReleaseInfo = createAsyncThunk(`${sliceName}/updateReleaseInfo`, ({ name, info }, { dispatch, getState }) => GeneralApi.patch(`${deploymentsApiUrlV2}/deployments/releases/${name}`, info) .catch(err => commonErrorHandler(err, `Release details couldn't be updated.`, dispatch)) .then(() => { return Promise.all([ - dispatch({ type: ReleaseConstants.RECEIVE_RELEASE, release: { ...getState().releases.byId[name], ...info } }), + dispatch(actions.receiveRelease({ ...getReleasesById(getState())[name], ...info, name })), dispatch(setSnackbar('Release details were updated successfully.', TIMEOUTS.fiveSeconds, '')) ]); - }); + }) +); -export const setReleaseTags = - (name, tags = []) => - (dispatch, getState) => - GeneralApi.put(`${deploymentsApiUrlV2}/deployments/releases/${name}/tags`, tags) - .catch(err => commonErrorHandler(err, `Release tags couldn't be set.`, dispatch)) - .then(() => { - return Promise.all([ - dispatch({ type: ReleaseConstants.RECEIVE_RELEASE, release: { ...getState().releases.byId[name], tags } }), - dispatch(setSnackbar('Release tags were set successfully.', TIMEOUTS.fiveSeconds, '')) - ]); - }); +export const setReleaseTags = createAsyncThunk(`${sliceName}/setReleaseTags`, ({ name, tags = [] }, { dispatch, getState }) => + GeneralApi.put(`${deploymentsApiUrlV2}/deployments/releases/${name}/tags`, tags) + .catch(err => commonErrorHandler(err, `Release tags couldn't be set.`, dispatch)) + .then(() => { + return Promise.all([ + dispatch(actions.receiveRelease({ ...getReleasesById(getState())[name], name, tags })), + dispatch(setSnackbar('Release tags were set successfully.', TIMEOUTS.fiveSeconds, '')) + ]); + }) +); -export const getExistingReleaseTags = () => dispatch => +export const getExistingReleaseTags = createAsyncThunk(`${sliceName}/getReleaseTags`, (_, { dispatch }) => GeneralApi.get(`${deploymentsApiUrlV2}/releases/all/tags`) .catch(err => commonErrorHandler(err, `Existing release tags couldn't be retrieved.`, dispatch)) - .then(({ data: tags }) => Promise.resolve(dispatch({ type: ReleaseConstants.RECEIVE_RELEASE_TAGS, tags }))); + .then(({ data: tags }) => Promise.resolve(dispatch(actions.receiveReleaseTags(tags)))) +); -export const getUpdateTypes = () => dispatch => +export const getUpdateTypes = createAsyncThunk(`${sliceName}/getReleaseTypes`, (_, { dispatch }) => GeneralApi.get(`${deploymentsApiUrlV2}/releases/all/types`) .catch(err => commonErrorHandler(err, `Existing update types couldn't be retrieved.`, dispatch)) - .then(({ data: types }) => Promise.resolve(dispatch({ type: ReleaseConstants.RECEIVE_RELEASE_TYPES, types }))); + .then(({ data: types }) => Promise.resolve(dispatch(actions.receiveReleaseTypes(types)))) +); diff --git a/frontend/src/js/store/selectors.ts b/frontend/src/js/store/selectors.ts new file mode 100644 index 00000000..b8290562 --- /dev/null +++ b/frontend/src/js/store/selectors.ts @@ -0,0 +1,22 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +export * from './appSlice/selectors'; +export * from './commonSelectors'; +export * from './deploymentsSlice/selectors'; +export * from './devicesSlice/selectors'; +export * from './monitorSlice/selectors'; +export * from './onboardingSlice/selectors'; +export * from './organizationSlice/selectors'; +export * from './releasesSlice/selectors'; +export * from './usersSlice/selectors'; diff --git a/frontend/src/js/store/store.ts b/frontend/src/js/store/store.ts new file mode 100644 index 00000000..2083fdca --- /dev/null +++ b/frontend/src/js/store/store.ts @@ -0,0 +1,89 @@ +// Copyright 2019 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { combineReducers, configureStore } from '@reduxjs/toolkit'; + +import actions from './actions'; +import appSlice from './appSlice'; +import { getToken } from './auth'; +import { USER_LOGOUT, settingsKeys } from './constants'; +import deploymentSlice from './deploymentsSlice'; +import deviceSlice from './devicesSlice'; +import monitorSlice from './monitorSlice'; +import onboardingSlice from './onboardingSlice'; +import organizationSlice, { actions as organizationActions } from './organizationSlice'; +import releaseSlice from './releasesSlice'; +import userSlice from './usersSlice'; +import { extractErrorMessage, preformatWithRequestID } from './utils'; + +const { setSnackbar, uploadProgress } = actions; + +// exclude 'pendings-redirect' since this is expected to persist refreshes - the rest should be better to be redone +const keys = ['sessionDeploymentChecker', settingsKeys.initialized]; +const resetEnvironment = () => { + keys.map(key => window.sessionStorage.removeItem(key)); +}; + +resetEnvironment(); + +export const commonErrorFallback = 'Please check your connection.'; +export const commonErrorHandler = (err, errorContext, dispatch, fallback, mightBeAuthRelated = false) => { + const errMsg = extractErrorMessage(err, fallback); + if (mightBeAuthRelated || getToken()) { + dispatch(setSnackbar({ message: preformatWithRequestID(err.response, `${errorContext} ${errMsg}`), action: 'Copy to clipboard' })); + } + return Promise.reject(err); +}; + +const rootReducer = combineReducers({ + app: appSlice, + devices: deviceSlice, + deployments: deploymentSlice, + monitor: monitorSlice, + onboarding: onboardingSlice, + organization: organizationSlice, + releases: releaseSlice, + users: userSlice +}); + +export const sessionReducer = (state, action) => { + if (action.type === USER_LOGOUT) { + state = undefined; + } + return rootReducer(state, action); +}; + +export const getConfiguredStore = (options = {}) => { + const { preloadedState = {}, ...config } = options; + return configureStore({ + ...config, + preloadedState, + reducer: sessionReducer, + middleware: getDefaultMiddleware => + getDefaultMiddleware({ + immutableCheck: { + ignoredPaths: ['app.uploadsById'] + }, + serializableCheck: { + ignoredActions: [organizationActions.receiveExternalDeviceIntegrations.name, setSnackbar.name, uploadProgress.name], + ignoredActionPaths: ['uploads', 'snackbar', /payload\..*$/], + ignoredPaths: ['app.uploadsById', 'app.snackbar', 'organization.externalDeviceIntegrations'] + } + }) + }); +}; + +export default getConfiguredStore({ + preloadedState: {} +}); diff --git a/frontend/src/js/store/storehooks.test.tsx b/frontend/src/js/store/storehooks.test.tsx new file mode 100644 index 00000000..ba44b51b --- /dev/null +++ b/frontend/src/js/store/storehooks.test.tsx @@ -0,0 +1,383 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import React from 'react'; +import { Provider } from 'react-redux'; + +import { + getDeploymentsByStatus, + getDeviceAttributes, + getDeviceLimit, + getDevicesByStatus, + getDevicesWithAuth, + getDynamicGroups, + getGroups, + getIntegrations, + getReleases, + getUserOrganization, + tenantDataDivergedMessage +} from '@northern.tech/store/thunks'; +import { act, renderHook } from '@testing-library/react'; +import configureMockStore from 'redux-mock-store'; +import { thunk } from 'redux-thunk'; + +import { inventoryDevice } from '../../../tests/__mocks__/deviceHandlers'; +import { defaultState, receivedPermissionSets, receivedRoles, userId } from '../../../tests/mockData'; +import { actions as appActions } from './appSlice'; +import { getLatestReleaseInfo, setOfflineThreshold } from './appSlice/thunks'; +import { latestSaasReleaseTag } from './appSlice/thunks.test'; +import { getSessionInfo } from './auth'; +import { EXTERNAL_PROVIDER, TIMEOUTS, UNGROUPED_GROUP, timeUnits } from './commonConstants'; +import { DEVICE_STATES } from './constants'; +import { actions as deploymentsActions } from './deploymentsSlice'; +import { actions as deviceActions } from './devicesSlice'; +import { actions as onboardingActions } from './onboardingSlice'; +import { defaultOnboardingState, expectedOnboardingActions } from './onboardingSlice/thunks.test'; +import { actions as organizationActions } from './organizationSlice'; +import { actions as releasesActions } from './releasesSlice'; +import { useAppInit } from './storehooks'; +import { actions as userActions } from './usersSlice'; +import { getGlobalSettings, getPermissionSets, getRoles, getUserSettings, saveUserSettings } from './usersSlice/thunks'; + +const middlewares = [thunk]; +const mockStore = configureMockStore(middlewares); + +const attributeReducer = (accu, item) => { + if (item.scope === 'inventory') { + accu[item.name] = item.value; + if (item.name === 'device_type') { + accu[item.name] = [].concat(item.value); + } + } + return accu; +}; + +// eslint-disable-next-line no-unused-vars +const { attributes, ...expectedDevice } = defaultState.devices.byId.a1; +export const receivedInventoryDevice = { + ...defaultState.devices.byId.a1, + attributes: inventoryDevice.attributes.reduce(attributeReducer, {}), + identity_data: { ...defaultState.devices.byId.a1.identity_data, status: DEVICE_STATES.accepted }, + isNew: false, + isOffline: true, + monitor: {}, + tags: {}, + updated_ts: inventoryDevice.updated_ts +}; + +const appInitActions = [ + { type: userActions.successfullyLoggedIn.type }, //, payload: { token } + { type: onboardingActions.setOnboardingComplete.type, payload: false }, + { type: onboardingActions.setDemoArtifactPort.type, payload: 85 }, + { type: appActions.setFeatures.type, payload: { ...defaultState.app.features, hasMultitenancy: true } }, + { + type: appActions.setVersionInformation.type, + payload: { + docsVersion: '', + value: { + Deployments: '1.2.3', + Deviceauth: null, + GUI: undefined, + Integration: 'master', + Inventory: null, + 'Mender-Artifact': undefined, + 'Mender-Client': 'next', + 'Meta-Mender': 'saas-123.34' + } + } + }, + { type: appActions.setEnvironmentData.type, payload: { hostAddress: null, hostedAnnouncement: '', recaptchaSiteKey: '', stripeAPIKey: '', trackerCode: '' } }, + { type: getLatestReleaseInfo.pending.type }, + { type: getUserSettings.pending.type }, + { type: getGlobalSettings.pending.type }, + { type: getDeviceAttributes.pending.type }, + { type: getDeploymentsByStatus.pending.type }, + { type: getDeploymentsByStatus.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: getDevicesByStatus.pending.type }, + { type: getDynamicGroups.pending.type }, + { type: getGroups.pending.type }, + { type: getIntegrations.pending.type }, + { type: getReleases.pending.type }, + { type: getDeviceLimit.pending.type }, + { type: getRoles.pending.type }, + { type: getPermissionSets.pending.type }, + { type: appActions.setFirstLoginAfterSignup.type, payload: false }, + { type: getUserOrganization.pending.type }, + { type: deploymentsActions.receivedDeployments.type, payload: defaultState.deployments.byId }, + { + type: deploymentsActions.receivedDeploymentsForStatus.type, + payload: { deploymentIds: Object.keys(defaultState.deployments.byId), status: 'finished', total: Object.keys(defaultState.deployments.byId).length } + }, + { type: deploymentsActions.receivedDeployments.type, payload: defaultState.deployments.byId }, + { + type: deploymentsActions.receivedDeploymentsForStatus.type, + payload: { deploymentIds: Object.keys(defaultState.deployments.byId), status: 'inprogress', total: Object.keys(defaultState.deployments.byId).length } + }, + { + type: deploymentsActions.selectDeploymentsForStatus.type, + payload: { deploymentIds: Object.keys(defaultState.deployments.byId), status: 'inprogress', total: Object.keys(defaultState.deployments.byId).length } + }, + { type: getDeploymentsByStatus.fulfilled.type }, + { type: getDeploymentsByStatus.fulfilled.type }, + { type: deviceActions.setDeviceLimit.type, payload: 500 }, + { type: getDeviceLimit.fulfilled.type }, + { + type: deviceActions.receivedGroups.type, + payload: { + testGroup: defaultState.devices.groups.byId.testGroup, + testGroupDynamic: { filters: [{ key: 'group', operator: '$eq', scope: 'system', value: 'things' }], id: 'filter1' } + } + }, + { type: getDevicesByStatus.pending.type }, + { type: deviceActions.setFilterAttributes.type }, + { type: getDeviceAttributes.fulfilled.type }, + { + type: deviceActions.receivedGroups.type, + payload: { + testGroup: defaultState.devices.groups.byId.testGroup, + testGroupDynamic: { + deviceIds: [], + filters: [ + { key: 'id', operator: '$in', scope: 'identity', value: [defaultState.devices.byId.a1.id] }, + { key: 'mac', operator: '$nexists', scope: 'identity', value: false }, + { key: 'kernel', operator: '$exists', scope: 'identity', value: true } + ], + id: 'filter1', + total: 0 + } + } + }, + { type: getDynamicGroups.fulfilled.type }, + { + type: deviceActions.receivedDevices.type, + payload: { + [defaultState.devices.byId.a1.id]: { ...receivedInventoryDevice, group: 'test' }, + [defaultState.devices.byId.b1.id]: { + ...receivedInventoryDevice, + id: defaultState.devices.byId.b1.id, + group: 'test', + identity_data: { ...defaultState.devices.byId.b1.identity_data, status: DEVICE_STATES.accepted } + } + } + }, + { + type: deviceActions.setDevicesByStatus.type, + payload: { + deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], + status: DEVICE_STATES.accepted, + total: defaultState.devices.byStatus.accepted.deviceIds.length + } + }, + { type: getDevicesWithAuth.pending.type }, + { type: deviceActions.receivedDevices.type, payload: { [expectedDevice.id]: { ...receivedInventoryDevice, group: 'test', status: 'pending' } } }, + { + type: deviceActions.setDevicesByStatus.type, + payload: { + deviceIds: Array.from({ length: defaultState.devices.byStatus.pending.total }, () => defaultState.devices.byId.a1.id), + status: DEVICE_STATES.pending, + total: defaultState.devices.byStatus.pending.deviceIds.length + } + }, + { type: getDevicesWithAuth.pending.type }, + { type: deviceActions.receivedDevices.type, payload: {} }, + { type: deviceActions.setDevicesByStatus.type, payload: { deviceIds: [], status: 'preauthorized', total: 0 } }, + { type: deviceActions.receivedDevices.type, payload: {} }, + { type: deviceActions.setDevicesByStatus.type, payload: { deviceIds: [], status: 'rejected', total: 0 } }, + { + type: appActions.setVersionInformation.type, + payload: { + GUI: latestSaasReleaseTag, + Integration: '1.2.3', + 'Mender-Artifact': '1.3.7', + 'Mender-Client': '3.2.1', + backend: latestSaasReleaseTag, + latestRelease: { + releaseDate: '2022-02-02', + repos: { + integration: '1.2.3', + mender: '3.2.1', + 'mender-artifact': '1.3.7', + 'other-service': '1.1.0', + service: '3.0.0' + } + } + } + }, + { type: organizationActions.setOrganization.type, payload: defaultState.organization.organization }, + { type: appActions.setAnnouncement.type, payload: tenantDataDivergedMessage }, + { type: getDevicesByStatus.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + { type: getLatestReleaseInfo.fulfilled.type }, + { type: getUserOrganization.fulfilled.type }, + { + type: organizationActions.receiveExternalDeviceIntegrations.type, + payload: [ + { connection_string: 'something_else', id: 1, provider: EXTERNAL_PROVIDER['iot-hub'].provider }, + { id: 2, provider: EXTERNAL_PROVIDER['iot-core'].provider, something: 'new' } + ] + }, + { type: getIntegrations.fulfilled.type }, + { type: releasesActions.receiveReleases.type, payload: defaultState.releases.byId }, + { + type: releasesActions.setReleaseListState.type, + payload: { ...defaultState.releases.releasesList, releaseIds: [defaultState.releases.byId.r1.name], page: 42 } + }, + { type: getReleases.fulfilled.type }, + { + type: deviceActions.receivedDevices.type, + payload: { + [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} }, + [defaultState.devices.byId.b1.id]: { ...defaultState.devices.byId.b1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} } + } + }, + { + type: deviceActions.receivedDevices.type, + payload: { + [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} } + } + }, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesWithAuth.fulfilled.type }, + { + type: deviceActions.receivedDevices.type, + payload: { + [expectedDevice.id]: { ...receivedInventoryDevice, group: 'test' }, + [defaultState.devices.byId.b1.id]: { ...receivedInventoryDevice, id: defaultState.devices.byId.b1.id, group: 'test' } + } + }, + { type: getDevicesWithAuth.pending.type }, + { type: getDevicesByStatus.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + { type: userActions.setGlobalSettings.type, payload: { ...defaultState.users.globalSettings } }, + { type: setOfflineThreshold.pending.type }, + { type: appActions.setOfflineThreshold.type, payload: '2019-01-12T13:00:06.900Z' }, + { type: setOfflineThreshold.fulfilled.type }, + { type: userActions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getGlobalSettings.fulfilled.type }, + { type: getUserSettings.fulfilled.type }, + { type: userActions.receivedPermissionSets.type, payload: receivedPermissionSets }, + { type: getPermissionSets.fulfilled.type }, + { type: userActions.receivedRoles.type, payload: receivedRoles }, + { type: getRoles.fulfilled.type }, + { + type: deviceActions.receivedDevices.type, + payload: { + [defaultState.devices.byId.a1.id]: { ...defaultState.devices.byId.a1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} }, + [defaultState.devices.byId.b1.id]: { ...defaultState.devices.byId.b1, group: undefined, isNew: false, isOffline: true, monitor: {}, tags: {} } + } + }, + { type: getDevicesWithAuth.fulfilled.type }, + { type: getDevicesByStatus.fulfilled.type }, + { + type: deviceActions.addGroup.type, + payload: { + groupName: UNGROUPED_GROUP.id, + group: { + filters: [{ key: 'group', operator: '$nin', scope: 'system', value: [Object.keys(defaultState.devices.groups.byId)[0]] }] + } + } + }, + { type: getGroups.fulfilled.type }, + { type: deviceActions.setDeviceListState.type, payload: { selectedAttributes: [] } }, + { type: userActions.setTooltipsState.type, payload: {} }, + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: userActions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getUserSettings.fulfilled.type }, + { type: userActions.setUserSettings.type, payload: { ...defaultState.users.userSettings, onboarding: defaultOnboardingState } }, + { type: saveUserSettings.fulfilled.type }, + ...expectedOnboardingActions +]; + +it('should try to get all required app information', async () => { + const store = mockStore({ + ...defaultState, + app: { ...defaultState.app, features: { ...defaultState.app.features, isHosted: true } }, + users: { + ...defaultState.users, + currentSession: getSessionInfo(), + globalSettings: { ...defaultState.users.globalSettings, id_attribute: { attribute: 'mac', scope: 'identity' } } + }, + releases: { ...defaultState.releases, releasesList: { ...defaultState.releases.releasesList, page: 42 } } + }); + const wrapper = ({ children }) => {children}; + renderHook(() => useAppInit(userId), { wrapper }); + await act(async () => { + jest.runAllTimers(); + jest.runAllTicks(); + }); + + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(appInitActions.length); + appInitActions.map((action, index) => Object.keys(action).map(key => expect(storeActions[index][key]).toEqual(action[key]))); +}); +it('should execute the offline threshold migration for multi day thresholds', async () => { + const store = mockStore({ + ...defaultState, + app: { ...defaultState.app, features: { ...defaultState.app.features, isHosted: true } }, + users: { + ...defaultState.users, + currentSession: getSessionInfo(), + globalSettings: { + ...defaultState.users.globalSettings, + id_attribute: { attribute: 'mac', scope: 'identity' }, + offlineThreshold: { interval: 48, intervalUnit: timeUnits.hours } + } + }, + releases: { ...defaultState.releases, releasesList: { ...defaultState.releases.releasesList, page: 42 } } + }); + const wrapper = ({ children }) => {children}; + renderHook(() => useAppInit(userId), { wrapper }); + await act(async () => { + jest.runAllTimers(); + jest.runAllTicks(); + }); + + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(appInitActions.length + 9); // 3 = get settings + set settings + set offline threshold + const settingStorageAction = storeActions.find(action => action.type === userActions.setGlobalSettings.type && action.payload.offlineThreshold); + expect(settingStorageAction.payload.offlineThreshold.interval).toEqual(2); + expect(settingStorageAction.payload.offlineThreshold.intervalUnit).toEqual(timeUnits.days); +}); +it('should trigger the offline threshold migration dialog', async () => { + const store = mockStore({ + ...defaultState, + app: { ...defaultState.app, features: { ...defaultState.app.features, isHosted: true } }, + users: { + ...defaultState.users, + currentSession: getSessionInfo(), + globalSettings: { + ...defaultState.users.globalSettings, + id_attribute: { attribute: 'mac', scope: 'identity' }, + offlineThreshold: { interval: 15, intervalUnit: 'minutes' } + } + }, + releases: { ...defaultState.releases, releasesList: { ...defaultState.releases.releasesList, page: 42 } } + }); + + const wrapper = ({ children }) => {children}; + renderHook(() => useAppInit(userId), { wrapper }); + await jest.advanceTimersByTimeAsync(TIMEOUTS.fiveSeconds + TIMEOUTS.oneSecond); + await jest.runAllTimersAsync(); + await act(async () => { + jest.runAllTicks(); + }); + const storeActions = store.getActions(); + expect(storeActions.length).toEqual(appInitActions.length + 1); // only setShowStartupNotification should be addded + const notificationAction = storeActions.find(action => action.type === userActions.setShowStartupNotification.type); + expect(notificationAction.payload).toBeTruthy(); +}); diff --git a/frontend/src/js/store/storehooks.ts b/frontend/src/js/store/storehooks.ts new file mode 100644 index 00000000..b3e67dd6 --- /dev/null +++ b/frontend/src/js/store/storehooks.ts @@ -0,0 +1,268 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { useCallback, useEffect, useRef } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; + +import dayjs from 'dayjs'; +import durationDayJs from 'dayjs/plugin/duration'; +import Cookies from 'universal-cookie'; + +import { getOnboardingComponentFor } from '../utils/onboardingmanager'; +import storeActions from './actions'; +import { getSessionInfo } from './auth'; +import { DEPLOYMENT_STATES, DEVICE_STATES, TIMEOUTS, onboardingSteps, timeUnits } from './constants'; +import { + getDevicesByStatus as getDevicesByStatusSelector, + getFeatures, + getGlobalSettings as getGlobalSettingsSelector, + getIsEnterprise, + getOfflineThresholdSettings, + getOnboardingState as getOnboardingStateSelector, + getSortedFilteringAttributes, + getUserCapabilities, + getUserSettings as getUserSettingsSelector +} from './selectors'; +import { + getDeploymentsByStatus, + getDeviceAttributes, + getDeviceById, + getDeviceLimit, + getDevicesByStatus, + getDynamicGroups, + getGlobalSettings, + getGroups, + getIntegrations, + getLatestReleaseInfo, + getOnboardingState, + getReleases, + getRoles, + getUserOrganization, + getUserSettings, + saveGlobalSettings, + saveUserSettings +} from './thunks'; +import { extractErrorMessage, getComparisonCompatibleVersion, stringToBoolean } from './utils'; + +const cookies = new Cookies(); +dayjs.extend(durationDayJs); + +const { setSnackbar, setDeviceListState, setFirstLoginAfterSignup, setTooltipsState, setShowStartupNotification } = storeActions; + +const featureFlags = [ + 'hasAuditlogs', + 'hasMultitenancy', + 'hasDeltaProgress', + 'hasDeviceConfig', + 'hasDeviceConnect', + 'hasReporting', + 'hasMonitor', + 'isEnterprise' +]; + +export const parseEnvironmentInfo = () => (dispatch, getState) => { + const state = getState(); + let onboardingComplete = state.onboarding.complete || !!JSON.parse(window.localStorage.getItem('onboardingComplete') ?? 'false'); + let demoArtifactPort = 85; + let environmentData = {}; + let environmentFeatures = {}; + let versionInfo = {}; + if (mender_environment) { + const { + features = {}, + demoArtifactPort: port, + disableOnboarding, + hostAddress, + hostedAnnouncement, + integrationVersion, + isDemoMode, + menderVersion, + menderArtifactVersion, + metaMenderVersion, + recaptchaSiteKey, + services = {}, + stripeAPIKey, + trackerCode + } = mender_environment; + onboardingComplete = stringToBoolean(features.isEnterprise) || stringToBoolean(disableOnboarding) || onboardingComplete; + demoArtifactPort = port || demoArtifactPort; + environmentData = { + hostedAnnouncement: hostedAnnouncement || state.app.hostedAnnouncement, + hostAddress: hostAddress || state.app.hostAddress, + recaptchaSiteKey: recaptchaSiteKey || state.app.recaptchaSiteKey, + stripeAPIKey: stripeAPIKey || state.app.stripeAPIKey, + trackerCode: trackerCode || state.app.trackerCode + }; + environmentFeatures = { + ...featureFlags.reduce((accu, flag) => ({ ...accu, [flag]: stringToBoolean(features[flag]) }), {}), + isHosted: features.isHosted || window.location.hostname.includes('hosted.mender.io'), + isDemoMode: stringToBoolean(isDemoMode || features.isDemoMode) + }; + versionInfo = { + docs: isNaN(integrationVersion.charAt(0)) ? '' : integrationVersion.split('.').slice(0, 2).join('.'), + remainder: { + Integration: getComparisonCompatibleVersion(integrationVersion), + 'Mender-Client': getComparisonCompatibleVersion(menderVersion), + 'Mender-Artifact': menderArtifactVersion, + 'Meta-Mender': metaMenderVersion, + Deployments: services.deploymentsVersion, + Deviceauth: services.deviceauthVersion, + Inventory: services.inventoryVersion, + GUI: services.guiVersion + } + }; + } + return Promise.all([ + dispatch(storeActions.successfullyLoggedIn(getSessionInfo())), + dispatch(storeActions.setOnboardingComplete(onboardingComplete)), + dispatch(storeActions.setDemoArtifactPort(demoArtifactPort)), + dispatch(storeActions.setFeatures(environmentFeatures)), + dispatch(storeActions.setVersionInformation({ docsVersion: versionInfo.docs, value: versionInfo.remainder })), + dispatch(storeActions.setEnvironmentData(environmentData)), + dispatch(getLatestReleaseInfo()) + ]); +}; + +const maybeAddOnboardingTasks = ({ devicesByStatus, dispatch, onboardingState, tasks }) => { + if (!onboardingState.showTips || onboardingState.complete) { + return tasks; + } + const welcomeTip = getOnboardingComponentFor(onboardingSteps.ONBOARDING_START, { + progress: onboardingState.progress, + complete: onboardingState.complete, + showTips: onboardingState.showTips + }); + if (welcomeTip) { + tasks.push(dispatch(setSnackbar({ message: 'open', autoHideDuration: TIMEOUTS.refreshDefault, children: welcomeTip, onClick: () => {}, onClose: true }))); + } + // try to retrieve full device details for onboarding devices to ensure ips etc. are available + // we only load the first few/ 20 devices, as it is possible the onboarding is left dangling + // and a lot of devices are present and we don't want to flood the backend for this + return devicesByStatus[DEVICE_STATES.accepted].deviceIds.reduce((accu, id) => { + accu.push(dispatch(getDeviceById(id))); + return accu; + }, tasks); +}; + +export const useAppInit = userId => { + const dispatch = useDispatch(); + const isEnterprise = useSelector(getIsEnterprise); + const { hasMultitenancy, isHosted } = useSelector(getFeatures); + // const user = useSelector(getCurrentUser); + const devicesByStatus = useSelector(getDevicesByStatusSelector); + const onboardingState = useSelector(getOnboardingStateSelector); + let { columnSelection = [], trackingConsentGiven: hasTrackingEnabled, tooltips = {} } = useSelector(getUserSettingsSelector); + const { canManageUsers } = useSelector(getUserCapabilities); + const { interval, intervalUnit } = useSelector(getOfflineThresholdSettings); + const { id_attribute } = useSelector(getGlobalSettingsSelector); + const { identityAttributes } = useSelector(getSortedFilteringAttributes); + const initRunning = useRef(false); + + const retrieveAppData = useCallback(() => { + let tasks = [ + dispatch(parseEnvironmentInfo()), + dispatch(getUserSettings()), + dispatch(getGlobalSettings()), + dispatch(getDeviceAttributes()), + dispatch(getDeploymentsByStatus({ status: DEPLOYMENT_STATES.finished, shouldSelect: false })), + dispatch(getDeploymentsByStatus({ status: DEPLOYMENT_STATES.inprogress })), + dispatch(getDevicesByStatus({ status: DEVICE_STATES.accepted })), + dispatch(getDevicesByStatus({ status: DEVICE_STATES.pending })), + dispatch(getDevicesByStatus({ status: DEVICE_STATES.preauth })), + dispatch(getDevicesByStatus({ status: DEVICE_STATES.rejected })), + dispatch(getDynamicGroups()), + dispatch(getGroups()), + dispatch(getIntegrations()), + dispatch(getReleases()), + dispatch(getDeviceLimit()), + dispatch(getRoles()), + dispatch(setFirstLoginAfterSignup(stringToBoolean(cookies.get('firstLoginAfterSignup')))) + ]; + const multitenancy = hasMultitenancy || isHosted || isEnterprise; + if (multitenancy) { + tasks.push(dispatch(getUserOrganization())); + } + return Promise.all(tasks); + }, [dispatch, hasMultitenancy, isHosted, isEnterprise]); + + const interpretAppData = useCallback(() => { + let settings = {}; + if (cookies.get('_ga') && typeof hasTrackingEnabled === 'undefined') { + settings.trackingConsentGiven = true; + } + let tasks = [ + dispatch(setDeviceListState({ selectedAttributes: columnSelection.map(column => ({ attribute: column.key, scope: column.scope })) })), + dispatch(setTooltipsState(tooltips)), // tooltips read state is primarily trusted from the redux store, except on app init - here user settings are the reference + dispatch(saveUserSettings(settings)) + ]; + // checks if user id is set and if cookie for helptips exists for that user + tasks = maybeAddOnboardingTasks({ devicesByStatus, dispatch, tasks, onboardingState }); + + if (canManageUsers && intervalUnit && intervalUnit !== timeUnits.days) { + const duration = dayjs.duration(interval, intervalUnit); + const days = duration.asDays(); + if (days < 1) { + tasks.push(Promise.resolve(setTimeout(() => dispatch(setShowStartupNotification(true)), TIMEOUTS.fiveSeconds))); + } else { + const roundedDays = Math.max(1, Math.round(days)); + tasks.push(dispatch(saveGlobalSettings({ offlineThreshold: { interval: roundedDays, intervalUnit: timeUnits.days } }))); + } + } + + // the following is used as a migration and initialization of the stored identity attribute + // changing the default device attribute to the first non-deviceId attribute, unless a stored + // id attribute setting exists + const identityOptions = identityAttributes.filter(attribute => !['id', 'Device ID', 'status'].includes(attribute)); + if (!id_attribute && identityOptions.length) { + tasks.push(dispatch(saveGlobalSettings({ id_attribute: { attribute: identityOptions[0], scope: 'identity' } }))); + } else if (typeof id_attribute === 'string') { + let attribute = id_attribute; + if (attribute === 'Device ID') { + attribute = 'id'; + } + tasks.push(dispatch(saveGlobalSettings({ id_attribute: { attribute, scope: 'identity' } }))); + } + return Promise.all(tasks); + }, [ + columnSelection, + dispatch, + identityAttributes, + hasTrackingEnabled, + canManageUsers, + devicesByStatus, + id_attribute, + interval, + intervalUnit, + onboardingState, + tooltips + ]); + + const initializeAppData = useCallback( + () => + retrieveAppData() + .then(interpretAppData) + // this is allowed to fail if no user information are available + .catch(err => console.log(extractErrorMessage(err))) + .then(() => dispatch(getOnboardingState())), + [dispatch, retrieveAppData, interpretAppData] + ); + + useEffect(() => { + if (!userId || initRunning.current) { + return; + } + initRunning.current = true; + initializeAppData(); + }, [userId, initializeAppData]); +}; diff --git a/frontend/src/js/store/thunks.ts b/frontend/src/js/store/thunks.ts new file mode 100644 index 00000000..9ab857a7 --- /dev/null +++ b/frontend/src/js/store/thunks.ts @@ -0,0 +1,21 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +export * from './appSlice/thunks'; +export * from './deploymentsSlice/thunks'; +export * from './devicesSlice/thunks'; +export * from './monitorSlice/thunks'; +export * from './onboardingSlice/thunks'; +export * from './organizationSlice/thunks'; +export * from './releasesSlice/thunks'; +export * from './usersSlice/thunks'; diff --git a/frontend/src/js/constants/userConstants.js b/frontend/src/js/store/usersSlice/constants.ts similarity index 90% rename from frontend/src/js/constants/userConstants.js rename to frontend/src/js/store/usersSlice/constants.ts index 96888189..bd35f433 100644 --- a/frontend/src/js/constants/userConstants.js +++ b/frontend/src/js/store/usersSlice/constants.ts @@ -13,9 +13,7 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { apiUrl } from '../api/general-api'; -import { ALL_DEVICES } from './deviceConstants'; -import { ALL_RELEASES } from './releaseConstants'; +import { ALL_DEVICES, ALL_RELEASES, apiUrl, emptyUiPermissions } from '@northern.tech/store/constants'; export const useradmApiUrlv1 = `${apiUrl.v1}/useradm`; export const useradmApiUrlv2 = `${apiUrl.v2}/useradm`; @@ -196,21 +194,6 @@ export const uiPermissionsByArea = { } }; -export const emptyUiPermissions = Object.freeze({ - auditlog: [], - deployments: [], - groups: Object.freeze({}), - releases: Object.freeze({}), - userManagement: [] -}); - -export const emptyRole = Object.freeze({ - name: undefined, - description: '', - permissions: [], - uiPermissions: Object.freeze({ ...emptyUiPermissions }) -}); - const permissionMapper = permission => permission.value; export const itemUiPermissionsReducer = (accu, { item, uiPermissions }) => (item ? { ...accu, [item]: uiPermissions } : accu); @@ -367,30 +350,7 @@ export const defaultPermissionSets = { } }; -export const RECEIVED_QR_CODE = 'RECEIVED_QR_CODE'; - -export const SUCCESSFULLY_LOGGED_IN = 'SUCCESSFULLY_LOGGED_IN'; export const USER_LOGOUT = 'USER_LOGOUT'; -export const RECEIVED_ACTIVATION_CODE = 'RECEIVED_ACTIVATION_CODE'; -export const RECEIVED_USER_LIST = 'RECEIVED_USER_LIST'; -export const RECEIVED_USER = 'RECEIVED_USER'; -export const CREATED_USER = 'CREATED_USER'; -export const REMOVED_USER = 'REMOVED_USER'; -export const UPDATED_USER = 'UPDATED_USER'; - -export const RECEIVED_PERMISSION_SETS = 'RECEIVED_PERMISSION_SETS'; -export const RECEIVED_ROLES = 'RECEIVED_ROLES'; -export const CREATED_ROLE = 'CREATED_ROLE'; -export const UPDATED_ROLE = 'UPDATED_ROLE'; -export const REMOVED_ROLE = 'REMOVED_ROLE'; - -export const SET_CUSTOM_COLUMNS = 'SET_CUSTOM_COLUMNS'; -export const SET_GLOBAL_SETTINGS = 'SET_GLOBAL_SETTINGS'; -export const SET_USER_SETTINGS = 'SET_USER_SETTINGS'; -export const SET_SHOW_CONNECT_DEVICE = 'SET_SHOW_CONNECT_DEVICE'; -export const SET_TOOLTIP_STATE = 'SET_TOOLTIP_STATE'; -export const SET_TOOLTIPS_STATE = 'SET_TOOLTIPS_STATE'; -export const SET_SHOW_STARTUP_NOTIFICATION = 'SET_SHOW_STARTUP_NOTIFICATION'; export const OWN_USER_ID = 'me'; diff --git a/frontend/src/js/store/usersSlice/index.ts b/frontend/src/js/store/usersSlice/index.ts new file mode 100644 index 00000000..58be74d9 --- /dev/null +++ b/frontend/src/js/store/usersSlice/index.ts @@ -0,0 +1,147 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { createSlice } from '@reduxjs/toolkit'; + +import { READ_STATES, defaultPermissionSets, rolesById } from './constants'; + +export const sliceName = 'users'; + +export const initialState = { + activationCode: undefined, + byId: {}, + currentUser: null, + currentSession: { + // { token: window.localStorage.getItem('JWT'), expiresAt: '2023-01-01T00:15:00.000Z' | undefined }, // expiresAt depending on the stay logged in setting + }, + customColumns: [], + qrCode: null, + globalSettings: { + id_attribute: undefined, + previousFilters: [], + previousPhases: [], + retries: 0 + }, + permissionSetsById: { + ...defaultPermissionSets + }, + rolesById: { + ...rolesById + }, + settingsInitialized: false, + showConnectDeviceDialog: false, + showStartupNotification: false, + tooltips: { + byId: { + // : { readState: } // this object is getting enhanced by the tooltip texts in the app constants + } + }, + userSettings: { + columnSelection: [], + onboarding: {} + }, + userSettingsInitialized: false +}; + +export const usersSlice = createSlice({ + name: sliceName, + initialState, + reducers: { + receivedQrCode: (state, action) => { + state.qrCode = action.payload; + }, + successfullyLoggedIn: (state, action) => { + state.currentSession = action.payload; + }, + receivedUserList: (state, action) => { + state.byId = action.payload; + }, + receivedActivationCode: (state, action) => { + state.activationCode = action.payload; + }, + receivedUser: (state, action) => { + state.byId[action.payload.id] = action.payload; + state.currentUser = action.payload.id; + }, + createdUser: (state, action) => { + // the new user gets a 0 as id, since this will be overwritten by the retrieved userlist anyway + there is no way to know the id before + state.byId[0] = action.payload; + }, + removedUser: (state, action) => { + // eslint-disable-next-line no-unused-vars + const { [action.payload]: removedUser, ...byId } = state.byId; + state.byId = byId; + state.currentUser = state.currentUser === action.payload ? null : state.currentUser; + }, + updatedUser: (state, action) => { + state.byId[action.payload.id] = { + ...state.byId[action.payload.id], + ...action.payload + }; + }, + receivedPermissionSets: (state, action) => { + state.permissionSetsById = action.payload; + }, + receivedRoles: (state, action) => { + state.rolesById = action.payload; + }, + createdRole: (state, action) => { + state.rolesById[action.payload.name] = { + ...state.rolesById[action.payload.name], + ...action.payload + }; + }, + removedRole: (state, action) => { + // eslint-disable-next-line no-unused-vars + const { [action.payload]: toBeRemoved, ...rolesById } = state.rolesById; + state.rolesById = rolesById; + }, + setCustomColumns: (state, action) => { + state.customColumns = action.payload; + }, + setGlobalSettings: (state, action) => { + state.settingsInitialized = true; + state.globalSettings = { + ...state.globalSettings, + ...action.payload + }; + }, + setUserSettings: (state, action) => { + state.userSettingsInitialized = true; + state.userSettings = { + ...state.userSettings, + ...action.payload + }; + }, + setTooltipState: (state, action) => { + const { id, readState = READ_STATES.read } = action.payload; + state.tooltips.byId[id].readState = readState; + }, + setTooltipsState: (state, action) => { + state.tooltips.byId = { + ...state.tooltips.byId, + ...action.payload + }; + }, + setShowConnectingDialog: (state, action) => { + state.showConnectDeviceDialog = action.payload; + }, + setShowStartupNotification: (state, action) => { + state.showStartupNotification = action.payload; + } + } +}); + +export const actions = usersSlice.actions; +export default usersSlice.reducer; diff --git a/frontend/src/js/store/usersSlice/reducer.test.ts b/frontend/src/js/store/usersSlice/reducer.test.ts new file mode 100644 index 00000000..194f8148 --- /dev/null +++ b/frontend/src/js/store/usersSlice/reducer.test.ts @@ -0,0 +1,141 @@ +// Copyright 2020 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import reducer, { actions, initialState } from '.'; +import { defaultState } from '../../../../tests/mockData'; + +const testUser = { + created_ts: '', + email: 'test@example.com', + id: '123', + roles: ['RBAC_ROLE_PERMIT_ALL'], + tfasecret: '', + updated_ts: '' +}; + +const newDescription = 'new description'; + +describe('user reducer', () => { + it('should return the initial state', async () => { + expect(reducer(undefined, {})).toEqual(initialState); + }); + + it('should handle RECEIVED_QR_CODE', async () => { + expect(reducer(undefined, { type: actions.receivedQrCode, payload: '123' }).qrCode).toEqual('123'); + expect(reducer(initialState, { type: actions.receivedQrCode, payload: '123' }).qrCode).toEqual('123'); + }); + + it('should handle SUCCESSFULLY_LOGGED_IN', async () => { + expect(reducer(undefined, { type: actions.successfullyLoggedIn, payload: '123' }).currentSession).toEqual('123'); + expect(reducer(initialState, { type: actions.successfullyLoggedIn, payload: '123' }).currentSession).toEqual('123'); + }); + + it('should handle RECEIVED_USER_LIST', async () => { + expect(reducer(undefined, { type: actions.receivedUserList, payload: { '123': testUser } }).byId).toEqual({ '123': testUser }); + expect(reducer({ ...initialState, byId: { '123': testUser } }, { type: actions.receivedUserList, payload: { '456': testUser } }).byId).toEqual({ + '456': testUser + }); + }); + + it('should handle RECEIVED_ACTIVATION_CODE', async () => { + expect(reducer(undefined, { type: actions.receivedActivationCode, payload: 'code' }).activationCode).toEqual('code'); + expect(reducer({ ...initialState }, { type: actions.receivedActivationCode, payload: 'code' }).activationCode).toEqual('code'); + }); + + it('should handle RECEIVED_USER', async () => { + expect(reducer(undefined, { type: actions.receivedUser, payload: testUser }).byId).toEqual({ '123': testUser }); + expect(reducer({ ...initialState, byId: { '123': testUser } }, { type: actions.receivedUser, payload: testUser }).byId).toEqual({ '123': testUser }); + }); + + it('should handle CREATED_USER', async () => { + expect(reducer(undefined, { type: actions.createdUser, payload: testUser }).byId).toEqual({ 0: testUser }); + expect(reducer({ ...initialState, byId: { '123': testUser } }, { type: actions.createdUser, payload: testUser }).byId).toEqual({ + '123': testUser, + 0: testUser + }); + }); + + it('should handle REMOVED_USER', async () => { + expect(reducer(undefined, { type: actions.removedUser, payload: '123' }).byId).toEqual({}); + expect(reducer({ ...initialState, byId: { '123': testUser, '456': testUser } }, { type: actions.removedUser, payload: '123' }).byId).toEqual({ + '456': testUser + }); + }); + + it('should handle UPDATED_USER', async () => { + expect(reducer(undefined, { type: actions.updatedUser, payload: testUser }).byId).toEqual({ '123': testUser }); + + expect( + reducer({ ...initialState, byId: { '123': testUser } }, { type: actions.updatedUser, payload: { ...testUser, email: 'test@mender.io' } }).byId['123'] + .email + ).toEqual('test@mender.io'); + }); + it('should handle RECEIVED_ROLES', async () => { + const roles = reducer(undefined, { type: actions.receivedRoles, payload: { ...defaultState.users.rolesById } }).rolesById; + Object.entries(defaultState.users.rolesById).forEach(([key, role]) => expect(roles[key]).toEqual(role)); + expect( + reducer( + { ...initialState, rolesById: { ...defaultState.users.rolesById, thingsRole: { test: 'test' } } }, + { type: actions.receivedRoles, payload: { ...defaultState.users.rolesById } } + ).rolesById.thingsRole + ).toBeFalsy(); + }); + it('should handle REMOVED_ROLE', async () => { + // eslint-disable-next-line no-unused-vars + const { [defaultState.users.rolesById.test.name]: removedRole, ...rolesById } = defaultState.users.rolesById; + expect(reducer(undefined, { type: actions.removedRole, payload: defaultState.users.rolesById.test.name }).rolesById.test).toBeFalsy(); + expect( + reducer( + { ...initialState, rolesById: { ...defaultState.users.rolesById } }, + { type: actions.removedRole, payload: defaultState.users.rolesById.test.name } + ).rolesById.test + ).toBeFalsy(); + }); + it('should handle CREATED_ROLE', async () => { + expect( + reducer(undefined, { type: actions.createdRole, payload: { name: 'newRole', description: newDescription, groups: ['123'] } }).rolesById.newRole + .description + ).toEqual(newDescription); + expect( + reducer({ ...initialState }, { type: actions.createdRole, payload: { name: 'newRole', description: newDescription, groups: ['123'] } }).rolesById.newRole + .description + ).toEqual(newDescription); + }); + it('should handle UPDATED_ROLE', async () => { + expect( + reducer(undefined, { type: actions.createdRole, payload: { name: 'RBAC_ROLE_CI', description: newDescription } }).rolesById.RBAC_ROLE_CI.name + ).toEqual('RBAC_ROLE_CI'); + expect( + reducer({ ...initialState }, { type: actions.createdRole, payload: { name: 'RBAC_ROLE_CI', description: newDescription } }).rolesById.RBAC_ROLE_CI.name + ).toEqual('RBAC_ROLE_CI'); + }); + it('should handle SET_CUSTOM_COLUMNS', async () => { + expect(reducer(undefined, { type: actions.setCustomColumns, payload: 'test' }).customColumns).toEqual('test'); + expect(reducer({ ...initialState }, { type: actions.setCustomColumns, payload: 'test' }).customColumns).toEqual('test'); + }); + it('should handle SET_GLOBAL_SETTINGS', async () => { + expect(reducer(undefined, { type: actions.setGlobalSettings, payload: { newSetting: 'test' } }).globalSettings).toEqual({ + ...initialState.globalSettings, + newSetting: 'test' + }); + expect(reducer({ ...initialState }, { type: actions.setGlobalSettings, payload: { newSetting: 'test' } }).globalSettings).toEqual({ + ...initialState.globalSettings, + newSetting: 'test' + }); + }); + it('should handle SET_SHOW_CONNECT_DEVICE', async () => { + expect(reducer(undefined, { type: actions.setShowConnectingDialog, payload: false }).showConnectDeviceDialog).toEqual(false); + expect(reducer({ ...initialState }, { type: actions.setShowConnectingDialog, payload: true }).showConnectDeviceDialog).toEqual(true); + }); +}); diff --git a/frontend/src/js/store/usersSlice/selectors.ts b/frontend/src/js/store/usersSlice/selectors.ts new file mode 100644 index 00000000..cda41865 --- /dev/null +++ b/frontend/src/js/store/usersSlice/selectors.ts @@ -0,0 +1,60 @@ +// Copyright 2023 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { DEVICE_ONLINE_CUTOFF, defaultIdAttribute } from '@northern.tech/store/constants'; +import { isDarkMode } from '@northern.tech/store/utils'; +import { createSelector } from '@reduxjs/toolkit'; + +import { READ_STATES, twoFAStates } from './constants'; + +export const getRolesById = state => state.users.rolesById; +export const getTooltipsById = state => state.users.tooltips.byId; +export const getGlobalSettings = state => state.users.globalSettings; + +const getCurrentUserId = state => state.users.currentUser; +const getUsersById = state => state.users.byId; +export const getCurrentUser = createSelector([getUsersById, getCurrentUserId], (usersById, userId) => usersById[userId] ?? {}); +export const getUserSettings = state => state.users.userSettings; + +export const getIsDarkMode = createSelector([getUserSettings], ({ mode }) => isDarkMode(mode)); + +export const getShowHelptips = createSelector([getTooltipsById], tooltips => + Object.values(tooltips).reduce((accu, { readState }) => accu || readState === READ_STATES.unread, false) +); + +export const getTooltipsState = createSelector([getTooltipsById, getUserSettings], (byId, { tooltips = {} }) => + Object.entries(byId).reduce( + (accu, [id, value]) => { + accu[id] = { ...accu[id], ...value }; + return accu; + }, + { ...tooltips } + ) +); + +export const getHas2FA = createSelector( + [getCurrentUser], + currentUser => currentUser.hasOwnProperty('tfa_status') && currentUser.tfa_status === twoFAStates.enabled +); + +export const getIdAttribute = createSelector([getGlobalSettings], ({ id_attribute = { ...defaultIdAttribute } }) => id_attribute); + +export const getOfflineThresholdSettings = createSelector([getGlobalSettings], ({ offlineThreshold }) => ({ + interval: offlineThreshold?.interval || DEVICE_ONLINE_CUTOFF.interval, + intervalUnit: offlineThreshold?.intervalUnit || DEVICE_ONLINE_CUTOFF.intervalName +})); + +export const getRolesList = createSelector([getRolesById], rolesById => Object.entries(rolesById).map(([id, role]) => ({ id, ...role }))); + +export const getCurrentSession = state => state.users.currentSession; diff --git a/frontend/src/js/actions/userActions.test.js b/frontend/src/js/store/usersSlice/thunks.test.ts similarity index 51% rename from frontend/src/js/actions/userActions.test.js rename to frontend/src/js/store/usersSlice/thunks.test.ts index 32ac32bc..eab99acd 100644 --- a/frontend/src/js/actions/userActions.test.js +++ b/frontend/src/js/store/usersSlice/thunks.test.ts @@ -11,76 +11,21 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck +import { getSessionInfo } from '@northern.tech/store/auth'; +import { emptyRole } from '@northern.tech/store/commonConstants'; +import { setOfflineThreshold } from '@northern.tech/store/thunks'; import { act } from '@testing-library/react'; import configureMockStore from 'redux-mock-store'; import { thunk } from 'redux-thunk'; import Cookies from 'universal-cookie'; -import { inventoryDevice } from '../../../tests/__mocks__/deviceHandlers'; -import { accessTokens, defaultPassword, defaultState, receivedPermissionSets, receivedRoles, testSsoId, token, userId } from '../../../tests/mockData'; -import { HELPTOOLTIPS } from '../components/helptips/helptooltips'; -import { - SET_ANNOUNCEMENT, - SET_ENVIRONMENT_DATA, - SET_FEATURES, - SET_FIRST_LOGIN_AFTER_SIGNUP, - SET_OFFLINE_THRESHOLD, - SET_SNACKBAR, - SET_VERSION_INFORMATION, - SORTING_OPTIONS -} from '../constants/appConstants'; -import { - RECEIVE_DEPLOYMENTS, - RECEIVE_FINISHED_DEPLOYMENTS, - RECEIVE_INPROGRESS_DEPLOYMENTS, - SELECT_INPROGRESS_DEPLOYMENTS -} from '../constants/deploymentConstants'; -import { - ADD_DYNAMIC_GROUP, - DEVICE_LIST_DEFAULTS, - DEVICE_STATES, - EXTERNAL_PROVIDER, - RECEIVE_DEVICES, - RECEIVE_DYNAMIC_GROUPS, - RECEIVE_GROUPS, - SET_ACCEPTED_DEVICES, - SET_DEVICE_LIMIT, - SET_DEVICE_LIST_STATE, - SET_FILTER_ATTRIBUTES, - SET_PENDING_DEVICES, - SET_PREAUTHORIZED_DEVICES, - SET_REJECTED_DEVICES, - UNGROUPED_GROUP -} from '../constants/deviceConstants'; -import { SET_DEMO_ARTIFACT_PORT, SET_ONBOARDING_COMPLETE } from '../constants/onboardingConstants'; -import { RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, getSamlStartUrl } from '../constants/organizationConstants'; -import { RECEIVE_RELEASES, SET_RELEASES_LIST_STATE } from '../constants/releaseConstants'; -import { - CREATED_ROLE, - CREATED_USER, - RECEIVED_ACTIVATION_CODE, - RECEIVED_PERMISSION_SETS, - RECEIVED_QR_CODE, - RECEIVED_ROLES, - RECEIVED_USER, - RECEIVED_USER_LIST, - REMOVED_ROLE, - REMOVED_USER, - SET_CUSTOM_COLUMNS, - SET_GLOBAL_SETTINGS, - SET_SHOW_CONNECT_DEVICE, - SET_TOOLTIPS_STATE, - SET_TOOLTIP_STATE, - SET_USER_SETTINGS, - SUCCESSFULLY_LOGGED_IN, - UPDATED_ROLE, - UPDATED_USER, - USER_LOGOUT, - emptyRole, - uiPermissionsById -} from '../constants/userConstants'; -import { attributeReducer, receivedInventoryDevice } from './appActions.test'; -import { expectedOnboardingActions } from './onboardingActions.test'; +import { actions } from '.'; +import { accessTokens, defaultPassword, defaultState, receivedPermissionSets, receivedRoles, testSsoId, userId } from '../../../../tests/mockData'; +import { HELPTOOLTIPS } from '../../components/helptips/helptooltips'; +import { actions as appActions } from '../appSlice'; +import { getSamlStartUrl } from '../organizationSlice/constants'; +import { USER_LOGOUT, uiPermissionsById } from './constants'; import { addUserToCurrentTenant, createRole, @@ -91,10 +36,13 @@ import { enableUser2fa, generateToken, get2FAQRCode, + getGlobalSettings, + getPermissionSets, getRoles, getTokens, getUser, getUserList, + getUserSettings, initializeSelf, loginUser, logoutUser, @@ -105,17 +53,15 @@ import { revokeToken, saveGlobalSettings, saveUserSettings, - setAccountActivationCode, setAllTooltipsReadState, setHideAnnouncement, - setShowConnectingDialog, setTooltipReadState, switchUserOrganization, updateUserColumnSettings, verify2FA, verifyEmailComplete, verifyEmailStart -} from './userActions'; +} from './thunks'; const middlewares = [thunk]; const mockStore = configureMockStore(middlewares); @@ -126,293 +72,45 @@ const settings = { test: true }; // eslint-disable-next-line no-unused-vars const { attributes, ...expectedDevice } = defaultState.devices.byId.a1; -const offlineThreshold = { type: SET_OFFLINE_THRESHOLD, value: '2019-01-12T13:00:06.900Z' }; -const appInitActions = [ - { type: RECEIVED_USER, user: defaultState.users.byId[userId] }, - { type: SET_CUSTOM_COLUMNS, value: [] }, - { - type: SUCCESSFULLY_LOGGED_IN, - value: { - token: undefined - } - }, - { type: SET_ONBOARDING_COMPLETE, complete: false }, - { type: SET_DEMO_ARTIFACT_PORT, value: 85 }, - { type: SET_FEATURES, value: { ...defaultState.app.features, hasMultitenancy: true } }, - { - type: SET_VERSION_INFORMATION, - docsVersion: '', - value: { - Deployments: '1.2.3', - Deviceauth: null, - GUI: undefined, - Integration: 'master', - Inventory: null, - 'Mender-Artifact': undefined, - 'Mender-Client': 'next', - 'Meta-Mender': 'saas-123.34' - } - }, - { type: SET_ENVIRONMENT_DATA, value: { hostAddress: null, hostedAnnouncement: '', recaptchaSiteKey: '', stripeAPIKey: '', trackerCode: '' } }, - { type: SET_FIRST_LOGIN_AFTER_SIGNUP, firstLoginAfterSignup: false }, - { type: RECEIVE_DEPLOYMENTS, deployments: defaultState.deployments.byId }, - { - type: RECEIVE_FINISHED_DEPLOYMENTS, - deploymentIds: Object.keys(defaultState.deployments.byId), - status: 'finished', - total: Object.keys(defaultState.deployments.byId).length - }, - { type: RECEIVE_DEPLOYMENTS, deployments: defaultState.deployments.byId }, - { - type: RECEIVE_INPROGRESS_DEPLOYMENTS, - deploymentIds: Object.keys(defaultState.deployments.byId), - status: 'inprogress', - total: Object.keys(defaultState.deployments.byId).length - }, - { - type: SELECT_INPROGRESS_DEPLOYMENTS, - deploymentIds: Object.keys(defaultState.deployments.byId), - status: 'inprogress' - }, - { type: SET_DEVICE_LIMIT, limit: 500 }, - { - type: RECEIVE_GROUPS, - groups: { - testGroup: defaultState.devices.groups.byId.testGroup, - testGroupDynamic: { - filters: [{ key: 'group', operator: '$eq', scope: 'system', value: 'things' }], - id: 'filter1' - } - } - }, - { - type: SET_FILTER_ATTRIBUTES, - attributes: { - identityAttributes: ['status', 'mac'], - inventoryAttributes: [ - 'artifact_name', - 'cpu_model', - 'device_type', - 'hostname', - 'ipv4_wlan0', - 'ipv6_wlan0', - 'kernel', - 'mac_eth0', - 'mac_wlan0', - 'mem_total_kB', - 'mender_bootloader_integration', - 'mender_client_version', - 'network_interfaces', - 'os', - 'rootfs_type' - ], - systemAttributes: ['created_ts', 'updated_ts', 'group'], - tagAttributes: [] - } - }, - { - type: RECEIVE_DYNAMIC_GROUPS, - groups: { - testGroup: defaultState.devices.groups.byId.testGroup, - testGroupDynamic: { - deviceIds: [], - filters: [ - { key: 'id', operator: '$in', scope: 'identity', value: [defaultState.devices.byId.a1.id] }, - { key: 'mac', operator: '$nexists', scope: 'identity', value: false }, - { key: 'kernel', operator: '$exists', scope: 'identity', value: true } - ], - id: 'filter1', - total: 0 - } - } - }, - { - type: RECEIVE_DEVICES, - devicesById: { [expectedDevice.id]: { ...defaultState.devices.byId.a1, isOffline: true, monitor: {}, tags: {} } } - }, - { - type: SET_ACCEPTED_DEVICES, - deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], - status: DEVICE_STATES.accepted, - total: defaultState.devices.byStatus.accepted.deviceIds.length - }, - { - type: RECEIVE_DEVICES, - devicesById: { - a1: { - ...defaultState.devices.byId.a1, - attributes: inventoryDevice.attributes.reduce(attributeReducer, {}), - group: 'test', - identity_data: { ...defaultState.devices.byId.a1.identity_data, status: DEVICE_STATES.accepted }, - isOffline: true, - status: DEVICE_STATES.pending, - monitor: {}, - tags: {}, - updated_ts: inventoryDevice.updated_ts - } - } - }, - { - type: SET_PENDING_DEVICES, - deviceIds: Array.from({ length: defaultState.devices.byStatus.pending.total }, () => defaultState.devices.byId.a1.id), - status: 'pending', - total: defaultState.devices.byStatus.pending.deviceIds.length - }, - { type: RECEIVE_DEVICES, devicesById: {} }, - { type: SET_PREAUTHORIZED_DEVICES, deviceIds: [], status: 'preauthorized', total: 0 }, - { type: RECEIVE_DEVICES, devicesById: {} }, - { type: SET_REJECTED_DEVICES, deviceIds: [], status: 'rejected', total: 0 }, - { - type: RECEIVE_EXTERNAL_DEVICE_INTEGRATIONS, - value: [ - { connection_string: 'something_else', id: 1, provider: EXTERNAL_PROVIDER['iot-hub'].provider }, - { id: 2, provider: EXTERNAL_PROVIDER['iot-core'].provider, something: 'new' } - ] - }, - { type: RECEIVE_RELEASES, releases: defaultState.releases.byId }, - { - type: SET_RELEASES_LIST_STATE, - value: { - ...defaultState.releases.releasesList, - releaseIds: [ - 'release-999', - 'release-998', - 'release-997', - 'release-996', - 'release-995', - 'release-994', - 'release-993', - 'release-992', - 'release-991', - 'release-990', - 'release-99', - 'release-989', - 'release-988', - 'release-987', - 'release-986', - 'release-985', - 'release-984', - 'release-983', - 'release-982', - 'release-981' - ], - page: 1, - total: 5000 - } - }, - { - type: RECEIVE_DEVICES, - devicesById: { [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isOffline: true, monitor: {}, tags: {} } } - }, - { - type: RECEIVE_DEVICES, - devicesById: { [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isOffline: true, monitor: {}, tags: {} } } - }, - { - type: RECEIVE_DEVICES, - devicesById: { - [expectedDevice.id]: { - ...receivedInventoryDevice, - attributes: defaultState.devices.byId.a1.attributes, - identity_data: defaultState.devices.byId.a1.identity_data - } - } - }, - { type: SET_GLOBAL_SETTINGS, settings: { '2fa': 'enabled', previousFilters: [] } }, - offlineThreshold, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, - { type: RECEIVED_PERMISSION_SETS, value: receivedPermissionSets }, - { type: RECEIVED_ROLES, value: receivedRoles }, - { - type: RECEIVE_DEVICES, - devicesById: { [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isOffline: true, monitor: {}, tags: {} } } - }, - { - type: ADD_DYNAMIC_GROUP, - groupName: UNGROUPED_GROUP.id, - group: { deviceIds: [], total: 0, filters: [{ key: 'group', value: ['testGroup'], operator: '$nin', scope: 'system' }] } - }, - { - type: SET_DEVICE_LIST_STATE, - state: { - ...DEVICE_LIST_DEFAULTS, - deviceIds: [], - isLoading: true, - selectedAttributes: [], - selectedIssues: [], - selection: [], - setOnly: false, - sort: { direction: SORTING_OPTIONS.desc }, - state: DEVICE_STATES.accepted, - total: 0 - } - }, - { type: SET_TOOLTIPS_STATE, value: {} }, - { - type: RECEIVE_DEVICES, - devicesById: { [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: 'test', isOffline: true, monitor: {}, tags: {} } } - }, - { - type: SET_ACCEPTED_DEVICES, - deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], - status: DEVICE_STATES.accepted, - total: defaultState.devices.byStatus.accepted.total - }, - { - type: RECEIVE_DEVICES, - devicesById: { [expectedDevice.id]: { ...defaultState.devices.byId.a1, group: undefined, isOffline: true, monitor: {}, tags: {} } } - }, - { - type: SET_DEVICE_LIST_STATE, - state: { - ...DEVICE_LIST_DEFAULTS, - deviceIds: [defaultState.devices.byId.a1.id, defaultState.devices.byId.b1.id], - isLoading: false, - selectedAttributes: [], - selectedIssues: [], - selection: [], - sort: { direction: SORTING_OPTIONS.desc }, - state: DEVICE_STATES.accepted, - total: 2 - } - }, - { type: SET_GLOBAL_SETTINGS, settings: { '2fa': 'enabled', previousFilters: [] } }, - offlineThreshold, - { type: SET_GLOBAL_SETTINGS, settings: { '2fa': 'enabled', previousFilters: [] } }, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, - ...expectedOnboardingActions +export const offlineThreshold = [ + { type: setOfflineThreshold.pending.type }, + { type: appActions.setOfflineThreshold.type, payload: '2019-01-12T13:00:00.900Z' }, + { type: setOfflineThreshold.fulfilled.type } ]; /* eslint-disable sonarjs/no-identical-functions */ describe('user actions', () => { - it('should forward connecting dialog visibility', async () => { - const store = mockStore({ ...defaultState }); - const expectedActions = [ - { - type: SET_SHOW_CONNECT_DEVICE, - show: true - } - ]; - await store.dispatch(setShowConnectingDialog(true)); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); it('should allow retrieving 2fa qr codes', async () => { jest.clearAllMocks(); - const expectedActions = [{ type: RECEIVED_QR_CODE, value: btoa('test') }]; + const expectedActions = [ + { type: get2FAQRCode.pending.type }, + { type: actions.receivedQrCode.type, payload: btoa('test') }, + { type: get2FAQRCode.fulfilled.type } + ]; const store = mockStore({ ...defaultState }); await store.dispatch(get2FAQRCode(true)); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); + + const commonUserRetrievalActions = [ + { type: setHideAnnouncement.pending.type }, + { type: updateUserColumnSettings.pending.type }, + { type: actions.setCustomColumns.type, payload: [] }, + { type: setHideAnnouncement.fulfilled.type }, + { type: updateUserColumnSettings.fulfilled.type }, + { type: getUser.fulfilled.type } + ]; + it('should verify 2fa codes during 2fa setup', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: RECEIVED_USER, user: defaultState.users.byId[userId] }, - { type: SET_CUSTOM_COLUMNS, value: [] } + { type: verify2FA.pending.type }, + { type: getUser.pending.type }, + { type: actions.receivedUser.type, payload: defaultState.users.byId[userId] }, + ...commonUserRetrievalActions, + { type: verify2FA.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(verify2FA({ token2fa: '123456' })); @@ -423,8 +121,11 @@ describe('user actions', () => { it('should allow enabling 2fa during 2fa setup', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: RECEIVED_USER, user: defaultState.users.byId.a1 }, - { type: SET_CUSTOM_COLUMNS, value: [] } + { type: enableUser2fa.pending.type }, + { type: getUser.pending.type }, + { type: actions.receivedUser.type, payload: defaultState.users.byId.a1 }, + ...commonUserRetrievalActions, + { type: enableUser2fa.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(enableUser2fa(defaultState.users.byId.a1.id)); @@ -435,8 +136,11 @@ describe('user actions', () => { it('should allow disabling 2fa during 2fa setup', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: RECEIVED_USER, user: defaultState.users.byId.a1 }, - { type: SET_CUSTOM_COLUMNS, value: [] } + { type: disableUser2fa.pending.type }, + { type: getUser.pending.type }, + { type: actions.receivedUser.type, payload: defaultState.users.byId.a1 }, + ...commonUserRetrievalActions, + { type: disableUser2fa.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(disableUser2fa(defaultState.users.byId.a1.id)); @@ -447,8 +151,11 @@ describe('user actions', () => { it('should allow beginning email verification', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: RECEIVED_USER, user: defaultState.users.byId[userId] }, - { type: SET_CUSTOM_COLUMNS, value: [] } + { type: verifyEmailStart.pending.type }, + { type: getUser.pending.type }, + { type: actions.receivedUser.type, payload: defaultState.users.byId[userId] }, + ...commonUserRetrievalActions, + { type: verifyEmailStart.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(verifyEmailStart()); @@ -456,35 +163,31 @@ describe('user actions', () => { expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); - it('should allow processing email verification codes', async () => { - jest.clearAllMocks(); - const expectedActions = [{ type: RECEIVED_ACTIVATION_CODE, code: 'code' }]; - const store = mockStore({ ...defaultState }); - await store.dispatch(setAccountActivationCode('code')); - const storeActions = store.getActions(); - expect(storeActions.length).toEqual(expectedActions.length); - expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - }); it('should allow completing email verification', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: RECEIVED_USER, user: defaultState.users.byId[userId] }, - { type: SET_CUSTOM_COLUMNS, value: [] } + { type: verifyEmailComplete.pending.type }, + { type: getUser.pending.type }, + { type: actions.receivedUser.type, payload: defaultState.users.byId[userId] }, + ...commonUserRetrievalActions, + { type: verifyEmailComplete.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(verifyEmailComplete('superSecret')); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - const result = store.dispatch(verifyEmailComplete('ohNo')); - expect(result).rejects.toBeTruthy(); + await expect(store.dispatch(verifyEmailComplete('ohNo')).unwrap()).rejects.toBeTruthy(); }); it('should allow logging in', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: RECEIVED_USER, user: defaultState.users.byId[userId] }, - { type: SET_CUSTOM_COLUMNS, value: [] }, - { type: SUCCESSFULLY_LOGGED_IN, value: { token } } + { type: loginUser.pending.type }, + { type: getUser.pending.type }, + { type: actions.receivedUser.type, payload: defaultState.users.byId[userId] }, + ...commonUserRetrievalActions, + { type: actions.successfullyLoggedIn.type, payload: getSessionInfo() }, + { type: loginUser.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(loginUser({ email: 'test@example.com', password: defaultPassword })); @@ -504,23 +207,37 @@ describe('user actions', () => { }); it('should prevent logging in with a limited user', async () => { jest.clearAllMocks(); - window.localStorage.getItem.mockReturnValue(JSON.stringify({ token: 'limitedToken' })); - const expectedActions = [{ type: SET_SNACKBAR, snackbar: { message: 'forbidden by role-based access control' } }]; + window.localStorage.getItem.mockReturnValueOnce(JSON.stringify({ token: 'limitedToken' })); + const expectedActions = [ + { type: loginUser.pending.type }, + { type: getUser.pending.type }, + { type: getUser.rejected.type }, + { type: appActions.setSnackbar.type, payload: 'forbidden by role-based access control' }, + // { + // type: appActions.setSnackbar.type, + // payload: 'There was a problem logging in. Please check your email and password. If you still have problems, contact an administrator.' + // }, + { type: loginUser.rejected.type } + ]; const store = mockStore({ ...defaultState }); try { await store.dispatch(loginUser({ email: 'test-limited@example.com', password: defaultPassword })); } catch (error) { - expect(error).toMatchObject(expectedActions[0]); + expect(error).toMatchObject(expectedActions[5]); } + await act(async () => { + jest.runOnlyPendingTimers(); + jest.runAllTicks(); + }); expect(window.localStorage.removeItem).toHaveBeenCalledWith('JWT'); + window.localStorage.getItem.mockReset(); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); - window.localStorage.getItem.mockReset(); }); it('should allow logging out', async () => { jest.clearAllMocks(); - const expectedActions = [{ type: USER_LOGOUT }]; + const expectedActions = [{ type: logoutUser.pending.type }, { type: USER_LOGOUT }, { type: logoutUser.fulfilled.type }]; const store = mockStore({ ...defaultState }); await store.dispatch(logoutUser()); const storeActions = store.getActions(); @@ -549,19 +266,27 @@ describe('user actions', () => { it('should allow single user retrieval', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: RECEIVED_USER, user: defaultState.users.byId.a1 }, - { type: SET_CUSTOM_COLUMNS } //, value: [] } <= we can't check for the correct value here as the localstorage is (ab)used by msw to track state during req/res cycles, thus the localStorage expectation further down + { type: getUser.pending.type }, + { type: actions.receivedUser.type, payload: defaultState.users.byId.a1 }, + ...commonUserRetrievalActions ]; const store = mockStore({ ...defaultState }); await store.dispatch(getUser('a1')); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); + // we can't check for the correct value here as the localstorage is (ab)used by msw to track state during req/res cycles, thus the localStorage expectation expect(window.localStorage.getItem).toHaveBeenCalledWith(`a1-column-widths`); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); }); it('should allow current user initialization', async () => { jest.clearAllMocks(); - const expectedActions = appInitActions; + const expectedActions = [ + { type: initializeSelf.pending.type }, + { type: getUser.pending.type }, + { type: actions.receivedUser.type, payload: defaultState.users.byId[userId] }, + ...commonUserRetrievalActions, + { type: initializeSelf.fulfilled.type } + ]; const store = mockStore({ ...defaultState }); await store.dispatch(initializeSelf()); const storeActions = store.getActions(); @@ -570,7 +295,11 @@ describe('user actions', () => { }); it('should allow user list retrieval', async () => { jest.clearAllMocks(); - const expectedActions = [{ type: RECEIVED_USER_LIST, users: defaultState.users.byId }]; + const expectedActions = [ + { type: getUserList.pending.type }, + { type: actions.receivedUserList.type, payload: defaultState.users.byId }, + { type: getUserList.fulfilled.type } + ]; const store = mockStore({ ...defaultState }); await store.dispatch(getUserList()); const storeActions = store.getActions(); @@ -581,9 +310,13 @@ describe('user actions', () => { jest.clearAllMocks(); const createdUser = { email: 'a@b.com', password: defaultPassword }; const expectedActions = [ - { type: CREATED_USER, user: createdUser }, - { type: SET_SNACKBAR, snackbar: { message: 'The user was created successfully.' } }, - { type: RECEIVED_USER_LIST, users: defaultState.users.byId } + { type: createUser.pending.type }, + { type: actions.createdUser.type, payload: createdUser }, + { type: getUserList.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The user was created successfully.' }, + { type: actions.receivedUserList.type, payload: defaultState.users.byId }, + { type: getUserList.fulfilled.type }, + { type: createUser.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(createUser(createdUser)); @@ -594,11 +327,13 @@ describe('user actions', () => { it('should allow single user edits', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: UPDATED_USER, userId: 'a1', user: { password: defaultPassword } }, - { type: SET_SNACKBAR, snackbar: { message: 'The user has been updated.' } } + { type: editUser.pending.type }, + { type: actions.updatedUser.type, payload: { id: 'a1', password: defaultPassword } }, + { type: appActions.setSnackbar.type, payload: 'The user has been updated.' }, + { type: editUser.fulfilled.type } ]; const store = mockStore({ ...defaultState }); - await store.dispatch(editUser('a1', { email: defaultState.users.byId.a1.email, password: defaultPassword })); + await store.dispatch(editUser({ id: 'a1', email: defaultState.users.byId.a1.email, password: defaultPassword })); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -606,15 +341,18 @@ describe('user actions', () => { it('should not allow current user edits without proper password', async () => { jest.clearAllMocks(); const store = mockStore({ ...defaultState }); - const result = store.dispatch(editUser('a1', { email: 'a@evil.com', password: 'mySecretPasswordNot' })); - expect(result).rejects.toBeTruthy(); + await expect(store.dispatch(editUser({ id: 'a1', email: 'a@evil.com', password: 'mySecretPasswordNot' })).unwrap()).rejects.toBeTruthy(); }); it('should allow single user removal', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: REMOVED_USER, userId: 'a1' }, - { type: SET_SNACKBAR, snackbar: { message: 'The user was removed from the system.' } }, - { type: RECEIVED_USER_LIST, users: defaultState.users.byId } + { type: removeUser.pending.type }, + { type: actions.removedUser.type, payload: 'a1' }, + { type: getUserList.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The user was removed from the system.' }, + { type: actions.receivedUserList.type, payload: defaultState.users.byId }, + { type: getUserList.fulfilled.type }, + { type: removeUser.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(removeUser('a1')); @@ -625,8 +363,12 @@ describe('user actions', () => { it('should allow single user removal', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: SET_SNACKBAR, snackbar: { message: 'The user was added successfully.' } }, - { type: RECEIVED_USER_LIST, users: defaultState.users.byId } + { type: addUserToCurrentTenant.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The user was added successfully.' }, + { type: getUserList.pending.type }, + { type: actions.receivedUserList.type, payload: defaultState.users.byId }, + { type: getUserList.fulfilled.type }, + { type: addUserToCurrentTenant.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(addUserToCurrentTenant('a1')); @@ -638,11 +380,19 @@ describe('user actions', () => { it('should allow role list retrieval', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: RECEIVED_PERMISSION_SETS, value: receivedPermissionSets }, - { type: RECEIVED_ROLES, value: receivedRoles } + { type: getRoles.pending.type }, + { type: getPermissionSets.pending.type }, + { type: actions.receivedPermissionSets.type, payload: receivedPermissionSets }, + { type: getPermissionSets.fulfilled.type }, + { type: actions.receivedRoles.type, payload: receivedRoles }, + { type: getRoles.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(getRoles()); + await act(async () => { + jest.runOnlyPendingTimers(); + jest.runAllTicks(); + }); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -650,10 +400,16 @@ describe('user actions', () => { it('should allow role creation', async () => { jest.clearAllMocks(); const expectedActions = [ - { type: CREATED_ROLE, role: defaultRole, roleId: defaultRole.name }, - { type: SET_SNACKBAR, snackbar: { message: 'The role was created successfully.' } }, - { type: RECEIVED_PERMISSION_SETS, value: receivedPermissionSets }, - { type: RECEIVED_ROLES, value: receivedRoles } + { type: createRole.pending.type }, + { type: actions.createdRole.type, payload: defaultRole }, + { type: getRoles.pending.type }, + { type: getPermissionSets.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The role was created successfully.' }, + { type: actions.receivedPermissionSets.type, payload: receivedPermissionSets }, + { type: getPermissionSets.fulfilled.type }, + { type: actions.receivedRoles.type, payload: receivedRoles }, + { type: getRoles.fulfilled.type }, + { type: createRole.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(createRole({ ...defaultRole, uiPermissions: { groups: [{ item: 'testGroup', uiPermissions: [uiPermissionsById.manage.value] }] } })); @@ -664,20 +420,26 @@ describe('user actions', () => { it('should allow role edits', async () => { jest.clearAllMocks(); const expectedActions = [ + { type: editRole.pending.type }, { - type: UPDATED_ROLE, - roleId: defaultRole.name, - role: { + type: actions.createdRole.type, + payload: { ...defaultRole, + name: defaultRole.name, uiPermissions: { ...defaultRole.uiPermissions, groups: { ...defaultRole.uiPermissions.groups, testGroup: [uiPermissionsById.manage.value] } } } }, - { type: SET_SNACKBAR, snackbar: { message: 'The role has been updated.' } }, - { type: RECEIVED_PERMISSION_SETS, value: receivedPermissionSets }, - { type: RECEIVED_ROLES, value: receivedRoles } + { type: getRoles.pending.type }, + { type: getPermissionSets.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The role has been updated.' }, + { type: actions.receivedPermissionSets.type, payload: receivedPermissionSets }, + { type: getPermissionSets.fulfilled.type }, + { type: actions.receivedRoles.type, payload: receivedRoles }, + { type: getRoles.fulfilled.type }, + { type: editRole.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch( @@ -689,13 +451,17 @@ describe('user actions', () => { }); it('should allow role removal', async () => { jest.clearAllMocks(); - // eslint-disable-next-line no-unused-vars - const { test, ...remainder } = defaultState.users.rolesById; const expectedActions = [ - { type: REMOVED_ROLE, value: remainder }, - { type: SET_SNACKBAR, snackbar: { message: 'The role was deleted successfully.' } }, - { type: RECEIVED_PERMISSION_SETS, value: receivedPermissionSets }, - { type: RECEIVED_ROLES, value: receivedRoles } + { type: removeRole.pending.type }, + { type: actions.removedRole.type, payload: 'test' }, + { type: getRoles.pending.type }, + { type: getPermissionSets.pending.type }, + { type: appActions.setSnackbar.type, payload: 'The role was deleted successfully.' }, + { type: actions.receivedPermissionSets.type, payload: receivedPermissionSets }, + { type: getPermissionSets.fulfilled.type }, + { type: actions.receivedRoles.type, payload: receivedRoles }, + { type: getRoles.fulfilled.type }, + { type: removeRole.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(removeRole('test')); @@ -705,20 +471,24 @@ describe('user actions', () => { }); it('should allow password reset - pt. 1', async () => { const store = mockStore({ ...defaultState }); - store.dispatch(passwordResetStart(defaultState.users.byId.a1.email)).then(() => expect(true).toEqual(true)); + await store.dispatch(passwordResetStart(defaultState.users.byId.a1.email)).then(() => expect(true).toEqual(true)); }); it('should allow password reset - pt. 2', async () => { const store = mockStore({ ...defaultState }); - store.dispatch(passwordResetComplete('secretHash', 'newPassword')).then(() => expect(true).toEqual(true)); + await store.dispatch(passwordResetComplete({ secretHash: 'secretHash', newPassword: 'newPassword' })).then(() => expect(true).toEqual(true)); }); it('should allow storing global settings without deletion', async () => { jest.clearAllMocks(); // eslint-disable-next-line no-unused-vars const { id_attribute, ...retrievedSettings } = defaultState.users.globalSettings; const expectedActions = [ - { type: SET_GLOBAL_SETTINGS, settings: { ...retrievedSettings } }, - offlineThreshold, - { type: SET_GLOBAL_SETTINGS, settings: { ...defaultState.users.globalSettings, ...settings } } + { type: saveGlobalSettings.pending.type }, + { type: getGlobalSettings.pending.type }, + { type: actions.setGlobalSettings.type, payload: { ...retrievedSettings } }, + ...offlineThreshold, + { type: getGlobalSettings.fulfilled.type }, + { type: actions.setGlobalSettings.type, payload: { ...defaultState.users.globalSettings, ...settings } }, + { type: saveGlobalSettings.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(saveGlobalSettings(settings)); @@ -731,13 +501,17 @@ describe('user actions', () => { // eslint-disable-next-line no-unused-vars const { id_attribute, ...retrievedSettings } = defaultState.users.globalSettings; const expectedActions = [ - { type: SET_GLOBAL_SETTINGS, settings: { ...retrievedSettings } }, - offlineThreshold, - { type: SET_GLOBAL_SETTINGS, settings: { ...defaultState.users.globalSettings, ...settings } }, - { type: SET_SNACKBAR, snackbar: { message: 'Settings saved successfully' } } + { type: saveGlobalSettings.pending.type }, + { type: getGlobalSettings.pending.type }, + { type: actions.setGlobalSettings.type, payload: { ...retrievedSettings } }, + ...offlineThreshold, + { type: getGlobalSettings.fulfilled.type }, + { type: actions.setGlobalSettings.type, payload: { ...defaultState.users.globalSettings, ...settings } }, + { type: appActions.setSnackbar.type, payload: 'Settings saved successfully' }, + { type: saveGlobalSettings.fulfilled.type } ]; const store = mockStore({ ...defaultState }); - await store.dispatch(saveGlobalSettings(settings, false, true)); + await store.dispatch(saveGlobalSettings({ ...settings, notify: true })); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -747,11 +521,12 @@ describe('user actions', () => { // eslint-disable-next-line no-unused-vars const { ...settings } = defaultState.users.userSettings; const expectedActions = [ - { type: SET_USER_SETTINGS, settings }, - { - type: SET_USER_SETTINGS, - settings: { ...settings, extra: 'this' } - } + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: actions.setUserSettings.type, payload: settings }, + { type: getUserSettings.fulfilled.type }, + { type: actions.setUserSettings.type, payload: { ...settings, extra: 'this' } }, + { type: saveUserSettings.fulfilled.type } ]; const store = mockStore({ ...defaultState }); await store.dispatch(saveUserSettings({ extra: 'this' })); @@ -762,9 +537,13 @@ describe('user actions', () => { it('should store the visibility of the announcement shown in the header in a cookie on dismissal', async () => { jest.clearAllMocks(); const cookies = new Cookies(); - const expectedActions = [{ type: SET_ANNOUNCEMENT, announcement: undefined }]; + const expectedActions = [ + { type: setHideAnnouncement.pending.type }, + { type: appActions.setAnnouncement.type, payload: undefined }, + { type: setHideAnnouncement.fulfilled.type } + ]; const store = mockStore({ ...defaultState, app: { ...defaultState.app, hostedAnnouncement: 'something' } }); - await store.dispatch(setHideAnnouncement(true)); + await store.dispatch(setHideAnnouncement({ shouldHide: true })); const storeActions = store.getActions(); expect(cookies.get).toHaveBeenCalledTimes(1); expect(cookies.set).toHaveBeenCalledTimes(1); @@ -773,9 +552,13 @@ describe('user actions', () => { }); it('should store the sizes of columns in local storage', async () => { jest.clearAllMocks(); - const expectedActions = [{ type: SET_CUSTOM_COLUMNS, value: [{ asd: 'asd' }] }]; + const expectedActions = [ + { type: updateUserColumnSettings.pending.type }, + { type: actions.setCustomColumns.type, payload: [{ asd: 'asd' }] }, + { type: updateUserColumnSettings.fulfilled.type } + ]; const store = mockStore({ ...defaultState, users: { ...defaultState.users, customColumns: [{ asd: 'asd' }] } }); - await store.dispatch(updateUserColumnSettings([{ asd: 'asd' }])); + await store.dispatch(updateUserColumnSettings({ columns: [{ asd: 'asd' }] })); const storeActions = store.getActions(); expect(localStorage.getItem).not.toHaveBeenCalled(); expect(localStorage.setItem).toHaveBeenCalled(); @@ -783,14 +566,18 @@ describe('user actions', () => { expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); jest.clearAllMocks(); - await store.dispatch(updateUserColumnSettings()); + await store.dispatch(updateUserColumnSettings({})); expect(localStorage.getItem).toHaveBeenCalledTimes(1); expect(localStorage.setItem).toHaveBeenCalledTimes(1); }); it('should allow token list retrieval', async () => { jest.clearAllMocks(); - const expectedActions = [{ type: UPDATED_USER, userId: 'a1', user: { tokens: accessTokens } }]; + const expectedActions = [ + { type: getTokens.pending.type }, + { type: actions.updatedUser.type, payload: { id: 'a1', tokens: accessTokens } }, + { type: getTokens.fulfilled.type } + ]; const store = mockStore({ ...defaultState }); await store.dispatch(getTokens()); const storeActions = store.getActions(); @@ -799,9 +586,15 @@ describe('user actions', () => { }); it('should allow token generation', async () => { jest.clearAllMocks(); - const expectedActions = [{ type: UPDATED_USER, userId: 'a1', user: { tokens: accessTokens } }]; + const expectedActions = [ + { type: generateToken.pending.type }, + { type: getTokens.pending.type }, + { type: actions.updatedUser.type, payload: { id: 'a1', tokens: accessTokens } }, + { type: getTokens.fulfilled.type }, + { type: generateToken.fulfilled.type } + ]; const store = mockStore({ ...defaultState }); - const result = await store.dispatch(generateToken({ name: 'name' })); + const result = await store.dispatch(generateToken({ name: 'name' })).unwrap(); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expect(result[result.length - 1]).toEqual('aNewToken'); @@ -810,7 +603,13 @@ describe('user actions', () => { it('should allow token removal', async () => { jest.clearAllMocks(); // eslint-disable-next-line no-unused-vars - const expectedActions = [{ type: UPDATED_USER, userId: 'a1', user: { tokens: accessTokens } }]; + const expectedActions = [ + { type: revokeToken.pending.type }, + { type: getTokens.pending.type }, + { type: actions.updatedUser.type, payload: { id: 'a1', tokens: accessTokens } }, + { type: getTokens.fulfilled.type }, + { type: revokeToken.fulfilled.type } + ]; const store = mockStore({ ...defaultState }); await store.dispatch(revokeToken({ id: 'some-id-1' })); const storeActions = store.getActions(); @@ -821,11 +620,17 @@ describe('user actions', () => { it('should handle setting single tooltip read state', async () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { type: SET_TOOLTIP_STATE, id: 'foo', value: { readState: 'testRead' } }, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } } + { type: setTooltipReadState.pending.type }, + { type: actions.setTooltipState.type, payload: { id: 'foo', readState: 'testRead' } }, + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: actions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getUserSettings.fulfilled.type }, + { type: actions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: saveUserSettings.fulfilled.type }, + { type: setTooltipReadState.fulfilled.type } ]; - await store.dispatch(setTooltipReadState('foo', 'testRead', true)); + await store.dispatch(setTooltipReadState({ id: 'foo', readState: 'testRead', persist: true })); const storeActions = store.getActions(); expect(storeActions.length).toEqual(expectedActions.length); expectedActions.map((action, index) => expect(storeActions[index]).toMatchObject(action)); @@ -834,9 +639,18 @@ describe('user actions', () => { const store = mockStore({ ...defaultState }); const expectedActions = [ - { type: SET_TOOLTIPS_STATE, value: { ...Object.values(HELPTOOLTIPS).reduce((accu, { id }) => ({ ...accu, [id]: { readState: 'testRead' } }), {}) } }, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } }, - { type: SET_USER_SETTINGS, settings: { ...defaultState.users.userSettings } } + { type: setAllTooltipsReadState.pending.type }, + { + type: actions.setTooltipsState.type, + payload: { ...Object.values(HELPTOOLTIPS).reduce((accu, { id }) => ({ ...accu, [id]: { readState: 'testRead' } }), {}) } + }, + { type: saveUserSettings.pending.type }, + { type: getUserSettings.pending.type }, + { type: actions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: getUserSettings.fulfilled.type }, + { type: actions.setUserSettings.type, payload: { ...defaultState.users.userSettings } }, + { type: saveUserSettings.fulfilled.type }, + { type: setAllTooltipsReadState.fulfilled.type } ]; await store.dispatch(setAllTooltipsReadState('testRead')); const storeActions = store.getActions(); diff --git a/frontend/src/js/actions/userActions.js b/frontend/src/js/store/usersSlice/thunks.ts similarity index 65% rename from frontend/src/js/actions/userActions.js rename to frontend/src/js/store/usersSlice/thunks.ts index 9a2e4a90..116d9799 100644 --- a/frontend/src/js/actions/userActions.js +++ b/frontend/src/js/store/usersSlice/thunks.ts @@ -13,47 +13,61 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +// @ts-nocheck +import storeActions from '@northern.tech/store/actions'; +import GeneralApi from '@northern.tech/store/api/general-api'; +import UsersApi from '@northern.tech/store/api/users-api'; +import { cleanUp, getSessionInfo, maxSessionAge, setSessionInfo } from '@northern.tech/store/auth'; +import { + ALL_RELEASES, + APPLICATION_JSON_CONTENT_TYPE, + APPLICATION_JWT_CONTENT_TYPE, + SSO_TYPES, + apiRoot, + emptyRole, + emptyUiPermissions +} from '@northern.tech/store/constants'; +import { getOnboardingState, getOrganization, getTooltipsState, getUserSettings as getUserSettingsSelector } from '@northern.tech/store/selectors'; +import { commonErrorFallback, commonErrorHandler } from '@northern.tech/store/store'; +import { setOfflineThreshold } from '@northern.tech/store/thunks'; +import { extractErrorMessage, mergePermissions, preformatWithRequestID } from '@northern.tech/store/utils'; +import { createAsyncThunk } from '@reduxjs/toolkit'; import hashString from 'md5'; import Cookies from 'universal-cookie'; -import GeneralApi, { apiRoot } from '../api/general-api'; -import UsersApi from '../api/users-api'; -import { cleanUp, getSessionInfo, maxSessionAge, setSessionInfo } from '../auth'; -import { HELPTOOLTIPS } from '../components/helptips/helptooltips'; -import * as AppConstants from '../constants/appConstants'; -import { APPLICATION_JSON_CONTENT_TYPE, APPLICATION_JWT_CONTENT_TYPE } from '../constants/appConstants'; -import { SSO_TYPES } from '../constants/organizationConstants.js'; -import { ALL_RELEASES } from '../constants/releaseConstants.js'; -import * as UserConstants from '../constants/userConstants'; -import { duplicateFilter, extractErrorMessage, isEmpty, preformatWithRequestID } from '../helpers'; -import { getCurrentUser, getOnboardingState, getOrganization, getTooltipsState, getUserSettings as getUserSettingsSelector } from '../selectors'; -import { clearAllRetryTimers } from '../utils/retrytimer'; -import { commonErrorFallback, commonErrorHandler, initializeAppData, setOfflineThreshold, setSnackbar } from './appActions'; - -const cookies = new Cookies(); -const { - defaultPermissionSets, - emptyRole, - emptyUiPermissions, - itemUiPermissionsReducer, +import { actions, sliceName } from '.'; +import { HELPTOOLTIPS } from '../../components/helptips/helptooltips'; +import { duplicateFilter, isEmpty } from '../../helpers'; +import { clearAllRetryTimers } from '../../utils/retrytimer'; +import { OWN_USER_ID, PermissionTypes, - rolesById: defaultRolesById, + READ_STATES, + USER_LOGOUT, + defaultPermissionSets, + rolesById as defaultRolesById, + itemUiPermissionsReducer, scopedPermissionAreas, + settingsKeys, twoFAStates, uiPermissionsByArea, uiPermissionsById, useradmApiUrl, useradmApiUrlv2 -} = UserConstants; +} from './constants'; +import { getCurrentUser, getRolesById } from './selectors'; + +const cookies = new Cookies(); + +const { setAnnouncement, setSnackbar } = storeActions; const handleLoginError = - (err, { token2fa: has2FA, password }) => + (err, { token2fa: has2FA, password }, rejectWithValue) => dispatch => { const errorText = extractErrorMessage(err); const is2FABackend = errorText.includes('2fa'); if (is2FABackend && !has2FA) { - return Promise.reject({ error: '2fa code missing' }); + return rejectWithValue({ error: '2fa code missing' }); } if (password === undefined) { // Enterprise supports two-steps login. On the first step you can enter only email @@ -71,11 +85,11 @@ const handleLoginError = /* User management */ -export const loginUser = (userData, stayLoggedIn) => dispatch => +export const loginUser = createAsyncThunk(`${sliceName}/loginUser`, ({ stayLoggedIn, ...userData }, { dispatch, rejectWithValue }) => UsersApi.postLogin(`${useradmApiUrl}/auth/login`, { ...userData, no_expiry: stayLoggedIn }) .catch(err => { cleanUp(); - return Promise.resolve(dispatch(handleLoginError(err, userData))); + return Promise.reject(dispatch(handleLoginError(err, userData, rejectWithValue))); }) .then(({ text: response, contentType }) => { // If the content type is application/json then backend returned SSO configuration. @@ -98,7 +112,8 @@ export const loginUser = (userData, stayLoggedIn) => dispatch => const expiresAt = stayLoggedIn ? undefined : now.toISOString(); setSessionInfo({ token, expiresAt }); cookies.remove('JWT', { path: '/' }); - return dispatch(getUser(OWN_USER_ID)) + return Promise.resolve(dispatch(getUser(OWN_USER_ID))) + .unwrap() .catch(e => { cleanUp(); return Promise.reject(dispatch(setSnackbar(extractErrorMessage(e)))); @@ -108,22 +123,23 @@ export const loginUser = (userData, stayLoggedIn) => dispatch => if (window.location.pathname !== '/ui/') { window.location.replace('/ui/'); } - return Promise.all([dispatch({ type: UserConstants.SUCCESSFULLY_LOGGED_IN, value: { expiresAt, token } })]); + return Promise.resolve(dispatch(actions.successfullyLoggedIn({ expiresAt, token }))); }); - }); + }) +); -export const logoutUser = () => (dispatch, getState) => { +export const logoutUser = createAsyncThunk(`${sliceName}/logoutUser`, (_, { dispatch, getState }) => { if (Object.keys(getState().app.uploadsById).length) { return Promise.reject(); } return GeneralApi.post(`${useradmApiUrl}/auth/logout`).finally(() => { cleanUp(); clearAllRetryTimers(setSnackbar); - return Promise.resolve(dispatch({ type: UserConstants.USER_LOGOUT })); + return Promise.resolve(dispatch({ type: USER_LOGOUT })); }); -}; +}); -export const switchUserOrganization = tenantId => (_, getState) => { +export const switchUserOrganization = createAsyncThunk(`${sliceName}/switchUserOrganization`, (tenantId, { getState }) => { if (Object.keys(getState().app.uploadsById).length) { return Promise.reject(); } @@ -131,14 +147,15 @@ export const switchUserOrganization = tenantId => (_, getState) => { setSessionInfo({ ...getSessionInfo(), token }); window.location.reload(); }); -}; +}); -export const passwordResetStart = email => dispatch => +export const passwordResetStart = createAsyncThunk(`${sliceName}/passwordResetStart`, (email, { dispatch }) => GeneralApi.post(`${useradmApiUrl}/auth/password-reset/start`, { email }).catch(err => commonErrorHandler(err, `The password reset request cannot be processed:`, dispatch, undefined, true) - ); + ) +); -export const passwordResetComplete = (secretHash, newPassword) => dispatch => +export const passwordResetComplete = createAsyncThunk(`${sliceName}/passwordResetComplete`, ({ secretHash, newPassword }, { dispatch }) => GeneralApi.post(`${useradmApiUrl}/auth/password-reset/complete`, { secret_hash: secretHash, password: newPassword }).catch((err = {}) => { const { error, response = {} } = err; let errorMsg = ''; @@ -149,51 +166,57 @@ export const passwordResetComplete = (secretHash, newPassword) => dispatch => } dispatch(setSnackbar('The password reset request cannot be processed: ' + errorMsg)); return Promise.reject(err); - }); + }) +); -export const verifyEmailStart = () => (dispatch, getState) => +export const verifyEmailStart = createAsyncThunk(`${sliceName}/verifyEmailStart`, (_, { dispatch, getState }) => GeneralApi.post(`${useradmApiUrl}/auth/verify-email/start`, { email: getCurrentUser(getState()).email }) .catch(err => commonErrorHandler(err, 'An error occured starting the email verification process:', dispatch)) - .finally(() => Promise.resolve(dispatch(getUser(OWN_USER_ID)))); + .finally(() => Promise.resolve(dispatch(getUser(OWN_USER_ID)))) +); -export const setAccountActivationCode = code => dispatch => Promise.resolve(dispatch({ type: UserConstants.RECEIVED_ACTIVATION_CODE, code })); - -export const verifyEmailComplete = secret => dispatch => - GeneralApi.post(`${useradmApiUrl}/auth/verify-email/complete`, { secret_hash: secret }) +export const verifyEmailComplete = createAsyncThunk(`${sliceName}/verifyEmailComplete`, (secret_hash, { dispatch }) => + GeneralApi.post(`${useradmApiUrl}/auth/verify-email/complete`, { secret_hash }) .catch(err => commonErrorHandler(err, 'An error occured completing the email verification process:', dispatch)) - .finally(() => Promise.resolve(dispatch(getUser(OWN_USER_ID)))); + .finally(() => Promise.resolve(dispatch(getUser(OWN_USER_ID)))) +); -export const verify2FA = tfaData => dispatch => +export const verify2FA = createAsyncThunk(`${sliceName}/verify2FA`, (tfaData, { dispatch }) => UsersApi.putVerifyTFA(`${useradmApiUrl}/2faverify`, tfaData) .then(() => Promise.resolve(dispatch(getUser(OWN_USER_ID)))) .catch(err => commonErrorHandler(err, 'An error occured validating the verification code: failed to verify token, please try again.', dispatch, undefined, true) - ); + ) +); -export const getUserList = () => dispatch => +export const getUserList = createAsyncThunk(`${sliceName}/getUserList`, (_, { dispatch }) => GeneralApi.get(`${useradmApiUrl}/users`) .then(res => { const users = res.data.reduce((accu, item) => { accu[item.id] = item; return accu; }, {}); - return dispatch({ type: UserConstants.RECEIVED_USER_LIST, users }); + return dispatch(actions.receivedUserList(users)); }) - .catch(err => commonErrorHandler(err, `Users couldn't be loaded.`, dispatch, commonErrorFallback)); + .catch(err => commonErrorHandler(err, `Users couldn't be loaded.`, dispatch, commonErrorFallback)) +); -export const getUser = id => dispatch => - GeneralApi.get(`${useradmApiUrl}/users/${id}`).then(({ data: user }) => - Promise.all([ - dispatch({ type: UserConstants.RECEIVED_USER, user }), - dispatch(setHideAnnouncement(false, user.id)), - dispatch(updateUserColumnSettings(undefined, user.id)), - user - ]) - ); +export const getUser = createAsyncThunk(`${sliceName}/getUser`, (id, { dispatch, rejectWithValue }) => + GeneralApi.get(`${useradmApiUrl}/users/${id}`) + .then(({ data: user }) => + Promise.all([ + dispatch(actions.receivedUser(user)), + dispatch(setHideAnnouncement({ shouldHide: false, userId: user.id })), + dispatch(updateUserColumnSettings({ currentUserId: user.id })), + user + ]) + ) + .catch(e => rejectWithValue(e)) +); -export const initializeSelf = () => dispatch => dispatch(getUser(UserConstants.OWN_USER_ID)).then(() => dispatch(initializeAppData())); +export const initializeSelf = createAsyncThunk(`${sliceName}/initializeSelf`, (_, { dispatch }) => dispatch(getUser(OWN_USER_ID))); -export const updateUserColumnSettings = (columns, currentUserId) => (dispatch, getState) => { +export const updateUserColumnSettings = createAsyncThunk(`${sliceName}/updateUserColumnSettings`, ({ columns, currentUserId }, { dispatch, getState }) => { const userId = currentUserId ?? getCurrentUser(getState()).id; const storageKey = `${userId}-column-widths`; let customColumns = []; @@ -207,10 +230,10 @@ export const updateUserColumnSettings = (columns, currentUserId) => (dispatch, g customColumns = columns; } window.localStorage.setItem(storageKey, JSON.stringify(customColumns)); - return Promise.resolve(dispatch({ type: UserConstants.SET_CUSTOM_COLUMNS, value: customColumns })); -}; + return Promise.resolve(dispatch(actions.setCustomColumns(customColumns))); +}); -const actions = { +const userActions = { add: { successMessage: 'The user was added successfully.', errorMessage: 'adding' @@ -229,78 +252,49 @@ const actions = { } }; -const userActionErrorHandler = (err, type, dispatch) => commonErrorHandler(err, `There was an error ${actions[type].errorMessage} the user.`, dispatch); - -export const createUser = - ({ shouldResetPassword, ...userData }) => - dispatch => - GeneralApi.post(`${useradmApiUrl}/users`, { ...userData, send_reset_password: shouldResetPassword }) - .then(() => - Promise.all([ - dispatch({ type: UserConstants.CREATED_USER, user: userData }), - dispatch(getUserList()), - dispatch(setSnackbar(actions.create.successMessage)) - ]) - ) - .catch(err => userActionErrorHandler(err, 'create', dispatch)); - -export const removeUser = userId => dispatch => +const userActionErrorHandler = (err, type, dispatch) => commonErrorHandler(err, `There was an error ${userActions[type].errorMessage} the user.`, dispatch); + +export const createUser = createAsyncThunk(`${sliceName}/createUser`, ({ shouldResetPassword, ...userData }, { dispatch }) => + GeneralApi.post(`${useradmApiUrl}/users`, { ...userData, send_reset_password: shouldResetPassword }) + .then(() => Promise.all([dispatch(actions.createdUser(userData)), dispatch(getUserList()), dispatch(setSnackbar(userActions.create.successMessage))])) + .catch(err => userActionErrorHandler(err, 'create', dispatch)) +); + +export const removeUser = createAsyncThunk(`${sliceName}/removeUser`, (userId, { dispatch }) => GeneralApi.delete(`${useradmApiUrl}/users/${userId}`) - .then(() => - Promise.all([dispatch({ type: UserConstants.REMOVED_USER, userId }), dispatch(getUserList()), dispatch(setSnackbar(actions.remove.successMessage))]) - ) - .catch(err => userActionErrorHandler(err, 'remove', dispatch)); + .then(() => Promise.all([dispatch(actions.removedUser(userId)), dispatch(getUserList()), dispatch(setSnackbar(userActions.remove.successMessage))])) + .catch(err => userActionErrorHandler(err, 'remove', dispatch)) +); -export const editUser = (userId, userData) => (dispatch, getState) => - GeneralApi.put(`${useradmApiUrl}/users/${userId}`, userData).then(() => +export const editUser = createAsyncThunk(`${sliceName}/editUser`, ({ id, ...userData }, { dispatch, getState }) => + GeneralApi.put(`${useradmApiUrl}/users/${id}`, userData).then(() => Promise.all([ - dispatch({ type: UserConstants.UPDATED_USER, userId: userId === UserConstants.OWN_USER_ID ? getState().users.currentUser : userId, user: userData }), - dispatch(setSnackbar(actions.edit.successMessage)) + dispatch(actions.updatedUser({ ...userData, id: id === OWN_USER_ID ? getCurrentUser(getState()).id : id })), + dispatch(setSnackbar(userActions.edit.successMessage)) ]) - ); + ) +); -export const addUserToCurrentTenant = userId => (dispatch, getState) => { +export const addUserToCurrentTenant = createAsyncThunk(`${sliceName}/addUserToTenant`, (userId, { dispatch, getState }) => { const { id } = getOrganization(getState()); return GeneralApi.post(`${useradmApiUrl}/users/${userId}/assign`, { tenant_ids: [id] }) .catch(err => commonErrorHandler(err, `There was an error adding the user to your organization:`, dispatch)) - .then(() => Promise.all([dispatch(setSnackbar(actions.add.successMessage)), dispatch(getUserList())])); -}; + .then(() => Promise.all([dispatch(setSnackbar(userActions.add.successMessage)), dispatch(getUserList())])); +}); -export const enableUser2fa = - (userId = OWN_USER_ID) => - dispatch => - GeneralApi.post(`${useradmApiUrl}/users/${userId}/2fa/enable`) - .catch(err => commonErrorHandler(err, `There was an error enabling Two Factor authentication for the user.`, dispatch)) - .then(() => Promise.resolve(dispatch(getUser(userId)))); +export const enableUser2fa = createAsyncThunk(`${sliceName}/enableUser2fa`, (userId = OWN_USER_ID, { dispatch }) => + GeneralApi.post(`${useradmApiUrl}/users/${userId}/2fa/enable`) + .catch(err => commonErrorHandler(err, `There was an error enabling Two Factor authentication for the user.`, dispatch)) + .then(() => Promise.resolve(dispatch(getUser(userId)))) +); -export const disableUser2fa = - (userId = OWN_USER_ID) => - dispatch => - GeneralApi.post(`${useradmApiUrl}/users/${userId}/2fa/disable`) - .catch(err => commonErrorHandler(err, `There was an error disabling Two Factor authentication for the user.`, dispatch)) - .then(() => Promise.resolve(dispatch(getUser(userId)))); +export const disableUser2fa = createAsyncThunk(`${sliceName}/disableUser2fa`, (userId = OWN_USER_ID, { dispatch }) => + GeneralApi.post(`${useradmApiUrl}/users/${userId}/2fa/disable`) + .catch(err => commonErrorHandler(err, `There was an error disabling Two Factor authentication for the user.`, dispatch)) + .then(() => Promise.resolve(dispatch(getUser(userId)))) +); /* RBAC related things follow: */ - -const mergePermissions = (existingPermissions = { ...emptyUiPermissions }, addedPermissions) => - Object.entries(existingPermissions).reduce( - (accu, [key, value]) => { - let values; - if (!accu[key]) { - accu[key] = value; - return accu; - } - if (Array.isArray(value)) { - values = [...value, ...accu[key]].filter(duplicateFilter); - } else { - values = mergePermissions(accu[key], { ...value }); - } - accu[key] = values; - return accu; - }, - { ...addedPermissions } - ); - const mapHttpPermission = permission => Object.entries(uiPermissionsByArea).reduce( (accu, [area, definition]) => { @@ -445,18 +439,7 @@ export const normalizeRbacRoles = (roles, rolesById, permissionSets) => { ...rolesById } ); -export const mapUserRolesToUiPermissions = (userRoles, roles) => - userRoles.reduce( - (accu, roleId) => { - if (!(roleId && roles[roleId])) { - return accu; - } - return mergePermissions(accu, roles[roleId].uiPermissions); - }, - { ...emptyUiPermissions } - ); - -export const getPermissionSets = () => (dispatch, getState) => +export const getPermissionSets = createAsyncThunk(`${sliceName}/getPermissionSets`, (_, { dispatch, getState }) => GeneralApi.get(`${useradmApiUrlv2}/permission_sets?per_page=500`) .then(({ data }) => { const permissionSets = data.reduce( @@ -491,21 +474,23 @@ export const getPermissionSets = () => (dispatch, getState) => }, { ...getState().users.permissionSetsById } ); - return Promise.all([dispatch({ type: UserConstants.RECEIVED_PERMISSION_SETS, value: permissionSets }), permissionSets]); + return Promise.all([dispatch(actions.receivedPermissionSets(permissionSets)), permissionSets]); }) - .catch(() => console.log('Permission set retrieval failed - likely accessing a non-RBAC backend')); + .catch(() => console.log('Permission set retrieval failed - likely accessing a non-RBAC backend')) +); -export const getRoles = () => (dispatch, getState) => +export const getRoles = createAsyncThunk(`${sliceName}/getRoles`, (_, { dispatch, getState }) => Promise.all([GeneralApi.get(`${useradmApiUrlv2}/roles?per_page=500`), dispatch(getPermissionSets())]) .then(results => { if (!results) { return Promise.resolve(); } - const [{ data: roles }, permissionSetTasks] = results; - const rolesById = normalizeRbacRoles(roles, getState().users.rolesById, permissionSetTasks[permissionSetTasks.length - 1]); - return Promise.resolve(dispatch({ type: UserConstants.RECEIVED_ROLES, value: rolesById })); + const [{ data: roles }, { payload: permissionSetTasks }] = results; + const rolesById = normalizeRbacRoles(roles, getRolesById(getState()), permissionSetTasks[permissionSetTasks.length - 1]); + return Promise.resolve(dispatch(actions.receivedRoles(rolesById))); }) - .catch(() => console.log('Role retrieval failed - likely accessing a non-RBAC backend')); + .catch(() => console.log('Role retrieval failed - likely accessing a non-RBAC backend')) +); const deriveImpliedAreaPermissions = (area, areaPermissions, skipPermissions = []) => { const highestAreaPermissionLevelSelected = areaPermissions.reduce( @@ -617,107 +602,93 @@ const roleActions = { const roleActionErrorHandler = (err, type, dispatch) => commonErrorHandler(err, `There was an error ${roleActions[type].errorMessage} the role.`, dispatch); -export const createRole = roleData => dispatch => { +export const createRole = createAsyncThunk(`${sliceName}/createRole`, (roleData, { dispatch }) => { const { permissionSetsWithScope, role } = transformRoleDataToRole(roleData); return GeneralApi.post(`${useradmApiUrlv2}/roles`, { name: role.name, description: role.description, permission_sets_with_scope: permissionSetsWithScope }) - .then(() => - Promise.all([ - dispatch({ type: UserConstants.CREATED_ROLE, role, roleId: role.name }), - dispatch(getRoles()), - dispatch(setSnackbar(roleActions.create.successMessage)) - ]) - ) + .then(() => Promise.all([dispatch(actions.createdRole(role)), dispatch(getRoles()), dispatch(setSnackbar(roleActions.create.successMessage))])) .catch(err => roleActionErrorHandler(err, 'create', dispatch)); -}; +}); -export const editRole = roleData => (dispatch, getState) => { - const { permissionSetsWithScope, role } = transformRoleDataToRole(roleData, getState().users.rolesById[roleData.name]); +export const editRole = createAsyncThunk(`${sliceName}/editRole`, (roleData, { dispatch, getState }) => { + const { permissionSetsWithScope, role } = transformRoleDataToRole(roleData, getRolesById(getState())[roleData.name]); return GeneralApi.put(`${useradmApiUrlv2}/roles/${role.name}`, { description: role.description, name: role.name, permission_sets_with_scope: permissionSetsWithScope }) - .then(() => - Promise.all([ - dispatch({ type: UserConstants.UPDATED_ROLE, role, roleId: role.name }), - dispatch(getRoles()), - dispatch(setSnackbar(roleActions.edit.successMessage)) - ]) - ) + .then(() => Promise.all([dispatch(actions.createdRole(role)), dispatch(getRoles()), dispatch(setSnackbar(roleActions.edit.successMessage))])) .catch(err => roleActionErrorHandler(err, 'edit', dispatch)); -}; +}); -export const removeRole = roleId => (dispatch, getState) => +export const removeRole = createAsyncThunk(`${sliceName}/removeRole`, (roleId, { dispatch }) => GeneralApi.delete(`${useradmApiUrlv2}/roles/${roleId}`) - .then(() => { - // eslint-disable-next-line no-unused-vars - const { [roleId]: toBeRemoved, ...rolesById } = getState().users.rolesById; - return Promise.all([ - dispatch({ type: UserConstants.REMOVED_ROLE, value: rolesById }), - dispatch(getRoles()), - dispatch(setSnackbar(roleActions.remove.successMessage)) - ]); - }) - .catch(err => roleActionErrorHandler(err, 'remove', dispatch)); + .then(() => Promise.all([dispatch(actions.removedRole(roleId)), dispatch(getRoles()), dispatch(setSnackbar(roleActions.remove.successMessage))])) + .catch(err => roleActionErrorHandler(err, 'remove', dispatch)) +); /* Global settings */ -export const getGlobalSettings = () => dispatch => +export const getGlobalSettings = createAsyncThunk(`${sliceName}/getGlobalSettings`, (_, { dispatch }) => GeneralApi.get(`${useradmApiUrl}/settings`).then(({ data: settings, headers: { etag } }) => { - window.sessionStorage.setItem(UserConstants.settingsKeys.initialized, true); - return Promise.all([dispatch({ type: UserConstants.SET_GLOBAL_SETTINGS, settings }), dispatch(setOfflineThreshold()), etag]); - }); + window.sessionStorage.setItem(settingsKeys.initialized, true); + return Promise.all([dispatch(actions.setGlobalSettings(settings)), dispatch(setOfflineThreshold()), etag]); + }) +); -export const saveGlobalSettings = - (settings, beOptimistic = false, notify = false) => - (dispatch, getState) => { - if (!window.sessionStorage.getItem(UserConstants.settingsKeys.initialized) && !beOptimistic) { +export const saveGlobalSettings = createAsyncThunk( + `${sliceName}/saveGlobalSettings`, + ({ beOptimistic = false, notify = false, ...settings }, { dispatch, getState }) => { + if (!window.sessionStorage.getItem(settingsKeys.initialized) && !beOptimistic) { return; } - return Promise.resolve(dispatch(getGlobalSettings())).then(result => { - let updatedSettings = { ...getState().users.globalSettings, ...settings }; - if (getCurrentUser(getState()).verified) { - updatedSettings['2fa'] = twoFAStates.enabled; - } else { - delete updatedSettings['2fa']; - } - let tasks = [dispatch({ type: UserConstants.SET_GLOBAL_SETTINGS, settings: updatedSettings })]; - const headers = result[result.length - 1] ? { 'If-Match': result[result.length - 1] } : {}; - return GeneralApi.post(`${useradmApiUrl}/settings`, updatedSettings, { headers }) - .then(() => { - if (notify) { - tasks.push(dispatch(setSnackbar('Settings saved successfully'))); - } - return Promise.all(tasks); - }) - .catch(err => { - if (beOptimistic) { - return Promise.all([tasks]); - } - console.log(err); - return commonErrorHandler(err, `The settings couldn't be saved.`, dispatch); - }); - }); - }; + return dispatch(getGlobalSettings()) + .unwrap() + .then(result => { + let updatedSettings = { ...getState().users.globalSettings, ...settings }; + if (getCurrentUser(getState()).verified) { + updatedSettings['2fa'] = twoFAStates.enabled; + } else { + delete updatedSettings['2fa']; + } + let tasks = [dispatch(actions.setGlobalSettings(updatedSettings))]; + const headers = result[result.length - 1] ? { 'If-Match': result[result.length - 1] } : {}; + return GeneralApi.post(`${useradmApiUrl}/settings`, updatedSettings, { headers }) + .then(() => { + if (notify) { + tasks.push(dispatch(setSnackbar('Settings saved successfully'))); + } + return Promise.all(tasks); + }) + .catch(err => { + if (beOptimistic) { + return Promise.all([tasks]); + } + console.log(err); + return commonErrorHandler(err, `The settings couldn't be saved.`, dispatch); + }); + }); + } +); -export const getUserSettings = () => dispatch => +export const getUserSettings = createAsyncThunk(`${sliceName}/getUserSettings`, (_, { dispatch }) => GeneralApi.get(`${useradmApiUrl}/settings/me`).then(({ data: settings, headers: { etag } }) => { - window.sessionStorage.setItem(UserConstants.settingsKeys.initialized, true); - return Promise.all([dispatch({ type: UserConstants.SET_USER_SETTINGS, settings }), etag]); - }); + window.sessionStorage.setItem(settingsKeys.initialized, true); + return Promise.all([dispatch(actions.setUserSettings(settings)), etag]); + }) +); -export const saveUserSettings = - (settings = { onboarding: {} }) => - (dispatch, getState) => { - if (!getState().users.currentUser) { - return Promise.resolve(); - } - return Promise.resolve(dispatch(getUserSettings())).then(result => { +export const saveUserSettings = createAsyncThunk(`${sliceName}/saveUserSettings`, (settings = { onboarding: {} }, { dispatch, getState }) => { + if (!getCurrentUser(getState()).id) { + return Promise.resolve(); + } + return dispatch(getUserSettings()) + .unwrap() + .then(result => { const userSettings = getUserSettingsSelector(getState()); const onboardingState = getOnboardingState(getState()); const tooltipState = getTooltipsState(getState()); @@ -732,68 +703,59 @@ export const saveUserSettings = }; const headers = result[result.length - 1] ? { 'If-Match': result[result.length - 1] } : {}; return Promise.all([ - Promise.resolve(dispatch({ type: UserConstants.SET_USER_SETTINGS, settings: updatedSettings })), + Promise.resolve(dispatch(actions.setUserSettings(updatedSettings))), GeneralApi.post(`${useradmApiUrl}/settings/me`, updatedSettings, { headers }) - ]).catch(() => dispatch({ type: UserConstants.SET_USER_SETTINGS, settings: userSettings })); + ]).catch(() => dispatch(actions.setUserSettings(userSettings))); }); - }; +}); -export const get2FAQRCode = () => dispatch => - GeneralApi.get(`${useradmApiUrl}/2faqr`).then(res => dispatch({ type: UserConstants.RECEIVED_QR_CODE, value: res.data.qr })); - -/* - Onboarding -*/ -export const setShowConnectingDialog = show => dispatch => dispatch({ type: UserConstants.SET_SHOW_CONNECT_DEVICE, show: Boolean(show) }); +export const get2FAQRCode = createAsyncThunk(`${sliceName}/get2FAQRCode`, (_, { dispatch }) => + GeneralApi.get(`${useradmApiUrl}/2faqr`).then(res => dispatch(actions.receivedQrCode(res.data.qr))) +); -export const setHideAnnouncement = (shouldHide, userId) => (dispatch, getState) => { +export const setHideAnnouncement = createAsyncThunk(`${sliceName}/setHideAnnouncement`, ({ shouldHide, userId }, { dispatch, getState }) => { const currentUserId = userId || getCurrentUser(getState()).id; const hash = getState().app.hostedAnnouncement ? hashString(getState().app.hostedAnnouncement) : ''; const announceCookie = cookies.get(`${currentUserId}${hash}`); if (shouldHide || (hash.length && typeof announceCookie !== 'undefined')) { cookies.set(`${currentUserId}${hash}`, true, { maxAge: 604800 }); - return Promise.resolve(dispatch({ type: AppConstants.SET_ANNOUNCEMENT, announcement: undefined })); + return Promise.resolve(dispatch(setAnnouncement())); } return Promise.resolve(); -}; - -export const setShowStartupNotification = show => dispatch => dispatch({ type: UserConstants.SET_SHOW_STARTUP_NOTIFICATION, value: Boolean(show) }); +}); -export const getTokens = () => (dispatch, getState) => +export const getTokens = createAsyncThunk(`${sliceName}/getTokens`, (_, { dispatch, getState }) => GeneralApi.get(`${useradmApiUrl}/settings/tokens`).then(({ data: tokens }) => { const user = getCurrentUser(getState()); const updatedUser = { ...user, tokens }; - return Promise.resolve(dispatch({ type: UserConstants.UPDATED_USER, user: updatedUser, userId: user.id })); - }); + return Promise.resolve(dispatch(actions.updatedUser(updatedUser))); + }) +); const ONE_YEAR = 31536000; -export const generateToken = - ({ expiresIn = ONE_YEAR, name }) => - dispatch => - GeneralApi.post(`${useradmApiUrl}/settings/tokens`, { name, expires_in: expiresIn }) - .then(({ data: token }) => Promise.all([dispatch(getTokens()), token])) - .catch(err => commonErrorHandler(err, 'There was an error creating the token:', dispatch)); - -export const revokeToken = token => dispatch => - GeneralApi.delete(`${useradmApiUrl}/settings/tokens/${token.id}`).then(() => Promise.resolve(dispatch(getTokens()))); - -export const setTooltipReadState = - (id, readState = UserConstants.READ_STATES.read, persist) => - dispatch => - Promise.resolve(dispatch({ type: UserConstants.SET_TOOLTIP_STATE, id, value: { readState } })).then(() => { - if (persist) { - return Promise.resolve(dispatch(saveUserSettings())); - } - return Promise.resolve(); - }); +export const generateToken = createAsyncThunk(`${sliceName}/generateToken`, ({ expiresIn = ONE_YEAR, name }, { dispatch }) => + GeneralApi.post(`${useradmApiUrl}/settings/tokens`, { name, expires_in: expiresIn }) + .then(({ data: token }) => Promise.all([dispatch(getTokens()), token])) + .catch(err => commonErrorHandler(err, 'There was an error creating the token:', dispatch)) +); -export const setAllTooltipsReadState = - (readState = UserConstants.READ_STATES.read) => - dispatch => { - const updatedTips = Object.keys(HELPTOOLTIPS).reduce((accu, id) => ({ ...accu, [id]: { readState } }), {}); - return Promise.resolve(dispatch({ type: UserConstants.SET_TOOLTIPS_STATE, value: updatedTips })).then(() => dispatch(saveUserSettings())); - }; +export const revokeToken = createAsyncThunk(`${sliceName}/revokeToken`, (token, { dispatch }) => + GeneralApi.delete(`${useradmApiUrl}/settings/tokens/${token.id}`).then(() => Promise.resolve(dispatch(getTokens()))) +); + +export const setTooltipReadState = createAsyncThunk(`${sliceName}/setTooltipReadState`, ({ persist, ...remainder }, { dispatch }) => { + let tasks = [dispatch(actions.setTooltipState(remainder))]; + if (persist) { + tasks.push(dispatch(saveUserSettings())); + } + return Promise.all(tasks); +}); + +export const setAllTooltipsReadState = createAsyncThunk(`${sliceName}/toggleHelptips`, (readState = READ_STATES.read, { dispatch }) => { + const updatedTips = Object.keys(HELPTOOLTIPS).reduce((accu, id) => ({ ...accu, [id]: { readState } }), {}); + return Promise.resolve(dispatch(actions.setTooltipsState(updatedTips))).then(() => dispatch(saveUserSettings())); +}); diff --git a/frontend/src/js/store/utils.test.ts b/frontend/src/js/store/utils.test.ts new file mode 100644 index 00000000..968e9380 --- /dev/null +++ b/frontend/src/js/store/utils.test.ts @@ -0,0 +1,187 @@ +// Copyright 2019 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { defaultState, token } from '../../../tests/mockData'; +import { DARK_MODE, LIGHT_MODE } from './constants'; +import { + generateDeploymentGroupDetails, + groupDeploymentDevicesStats, + groupDeploymentStats, + isDarkMode, + mapDeviceAttributes, + preformatWithRequestID +} from './utils'; + +describe('mapDeviceAttributes function', () => { + const defaultAttributes = { + inventory: { device_type: [], artifact_name: '' }, + identity: {}, + monitor: {}, + system: {}, + tags: {} + }; + it('works with empty attributes', async () => { + expect(mapDeviceAttributes()).toEqual(defaultAttributes); + expect(mapDeviceAttributes([])).toEqual(defaultAttributes); + }); + it('handles unscoped attributes', async () => { + const testAttributesObject1 = { name: 'this1', value: 'that1' }; + expect(mapDeviceAttributes([testAttributesObject1])).toEqual({ + ...defaultAttributes, + inventory: { + ...defaultAttributes.inventory, + this1: 'that1' + } + }); + const testAttributesObject2 = { name: 'this2', value: 'that2' }; + expect(mapDeviceAttributes([testAttributesObject1, testAttributesObject2])).toEqual({ + ...defaultAttributes, + inventory: { + ...defaultAttributes.inventory, + this1: 'that1', + this2: 'that2' + } + }); + expect(mapDeviceAttributes([testAttributesObject1, testAttributesObject2, testAttributesObject2])).toEqual({ + ...defaultAttributes, + inventory: { + ...defaultAttributes.inventory, + this1: 'that1', + this2: 'that2' + } + }); + }); + it('handles scoped attributes', async () => { + const testAttributesObject1 = { name: 'this1', value: 'that1', scope: 'inventory' }; + expect(mapDeviceAttributes([testAttributesObject1])).toEqual({ + ...defaultAttributes, + inventory: { + ...defaultAttributes.inventory, + this1: 'that1' + } + }); + const testAttributesObject2 = { name: 'this2', value: 'that2', scope: 'identity' }; + expect(mapDeviceAttributes([testAttributesObject1, testAttributesObject2])).toEqual({ + ...defaultAttributes, + identity: { + ...defaultAttributes.identity, + this2: 'that2' + }, + inventory: { + ...defaultAttributes.inventory, + this1: 'that1' + } + }); + expect(mapDeviceAttributes([testAttributesObject1, testAttributesObject2, testAttributesObject2])).toEqual({ + ...defaultAttributes, + identity: { + ...defaultAttributes.identity, + this2: 'that2' + }, + inventory: { + ...defaultAttributes.inventory, + this1: 'that1' + } + }); + }); +}); + +describe('preformatWithRequestID function', () => { + it('works as expected', async () => { + expect(preformatWithRequestID({ data: { request_id: 'someUuidLikeLongerText' } }, token)).toEqual( + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJjZTNkMGY4Yy1hZWRlLTQwMzAtYjM5MS03ZDUwMjBlYjg3M2UiLCJzdWIiOiJhMzBhNzgwYi1iODQzLTUzNDQtODBlMy0wZmQ5NWE0ZjZmYzMiLCJleHAiOjE2MDY4MTUzNjksImlhdCI6MTYwNjIxMDU2OSwibWVuZGVyLnRlbmF... [Request ID: someUuid]' + ); + expect(preformatWithRequestID({ data: {} }, token)).toEqual( + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJjZTNkMGY4Yy1hZWRlLTQwMzAtYjM5MS03ZDUwMjBlYjg3M2UiLCJzdWIiOiJhMzBhNzgwYi1iODQzLTUzNDQtODBlMy0wZmQ5NWE0ZjZmYzMiLCJleHAiOjE2MDY4MTUzNjksImlhdCI6MTYwNjIxMDU2OSwibWVuZGVyLnRlbmF...' + ); + expect(preformatWithRequestID(undefined, token)).toEqual( + 'eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJqdGkiOiJjZTNkMGY4Yy1hZWRlLTQwMzAtYjM5MS03ZDUwMjBlYjg3M2UiLCJzdWIiOiJhMzBhNzgwYi1iODQzLTUzNDQtODBlMy0wZmQ5NWE0ZjZmYzMiLCJleHAiOjE2MDY4MTUzNjksImlhdCI6MTYwNjIxMDU2OSwibWVuZGVyLnRlbmF...' + ); + const expectedText = 'short text'; + expect(preformatWithRequestID({ data: { request_id: 'someUuidLikeLongerText' } }, expectedText)).toEqual('short text [Request ID: someUuid]'); + expect(preformatWithRequestID(undefined, expectedText)).toEqual(expectedText); + }); +}); + +describe('generateDeploymentGroupDetails function', () => { + it('works as expected', async () => { + expect(generateDeploymentGroupDetails({ terms: defaultState.devices.groups.byId.testGroupDynamic.filters }, 'testGroupDynamic')).toEqual( + 'testGroupDynamic (group = things)' + ); + expect( + generateDeploymentGroupDetails( + { + terms: [ + { scope: 'system', key: 'group', operator: '$eq', value: 'things' }, + { scope: 'system', key: 'group', operator: '$nexists', value: 'otherThings' }, + { scope: 'system', key: 'group', operator: '$nin', value: 'a,small,list' } + ] + }, + 'testGroupDynamic' + ) + ).toEqual(`testGroupDynamic (group = things, group doesn't exist otherThings, group not in a,small,list)`); + expect(generateDeploymentGroupDetails({ terms: undefined }, 'testGroupDynamic')).toEqual('testGroupDynamic'); + }); +}); + +describe('deployment stats grouping functions', () => { + it('groups correctly based on deployment stats', async () => { + let deployment = { + statistics: { + status: { + aborted: 2, + 'already-installed': 1, + decommissioned: 1, + downloading: 3, + failure: 1, + installing: 1, + noartifact: 1, + pending: 2, + paused: 0, + rebooting: 1, + success: 1 + } + } + }; + expect(groupDeploymentStats(deployment)).toEqual({ inprogress: 5, paused: 0, pending: 2, successes: 3, failures: 4 }); + deployment = { ...deployment, max_devices: 100, device_count: 10 }; + expect(groupDeploymentStats(deployment)).toEqual({ inprogress: 5, paused: 0, pending: 92, successes: 3, failures: 4 }); + }); + it('groups correctly based on deployment devices states', async () => { + const deployment = { + devices: { + a: { status: 'aborted' }, + b: { status: 'already-installed' }, + c: { status: 'decommissioned' }, + d: { status: 'downloading' }, + e: { status: 'failure' }, + f: { status: 'installing' }, + g: { status: 'noartifact' }, + h: { status: 'pending' }, + i: { status: 'rebooting' }, + j: { status: 'success' } + } + }; + expect(groupDeploymentDevicesStats(deployment)).toEqual({ inprogress: 3, paused: 0, pending: 1, successes: 3, failures: 3 }); + }); +}); + +describe('isDarkMode function', () => { + it('should return `true` if DARK_MODE was passed in', () => { + expect(isDarkMode(DARK_MODE)).toEqual(true); + }); + it('should return `false` if LIGHT_MODE was passed in', () => { + expect(isDarkMode(LIGHT_MODE)).toEqual(false); + }); +}); diff --git a/frontend/src/js/store/utils.ts b/frontend/src/js/store/utils.ts new file mode 100644 index 00000000..50032b99 --- /dev/null +++ b/frontend/src/js/store/utils.ts @@ -0,0 +1,248 @@ +// Copyright 2024 Northern.tech AS +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// @ts-nocheck +import { duplicateFilter, yes } from '../helpers'; +import { ATTRIBUTE_SCOPES, DEVICE_FILTERING_OPTIONS, DEVICE_ISSUE_OPTIONS, DEVICE_LIST_MAXIMUM_LENGTH, emptyUiPermissions } from './commonConstants'; +import { + DARK_MODE, + DEPLOYMENT_STATES, + defaultStats, + deploymentDisplayStates, + deploymentStatesToSubstates, + deploymentStatesToSubstatesWithSkipped, + emptyFilter +} from './constants'; + +// for some reason these functions can not be stored in the deviceConstants... +const filterProcessors = { + $gt: val => Number(val) || val, + $gte: val => Number(val) || val, + $lt: val => Number(val) || val, + $lte: val => Number(val) || val, + $in: val => ('' + val).split(',').map(i => i.trim()), + $nin: val => ('' + val).split(',').map(i => i.trim()), + $exists: yes, + $nexists: () => false +}; +const filterAliases = { + $nexists: { alias: DEVICE_FILTERING_OPTIONS.$exists.key, value: false } +}; +export const mapFiltersToTerms = filters => + filters.map(filter => ({ + scope: filter.scope, + attribute: filter.key, + type: filterAliases[filter.operator]?.alias || filter.operator, + value: filterProcessors.hasOwnProperty(filter.operator) ? filterProcessors[filter.operator](filter.value) : filter.value + })); +export const mapTermsToFilters = terms => + terms.map(term => { + const aliasedFilter = Object.entries(filterAliases).find( + aliasDefinition => aliasDefinition[1].alias === term.type && aliasDefinition[1].value === term.value + ); + const operator = aliasedFilter ? aliasedFilter[0] : term.type; + return { scope: term.scope, key: term.attribute, operator, value: term.value }; + }); + +const convertIssueOptionsToFilters = (issuesSelection, filtersState = {}) => + issuesSelection.map(item => { + if (typeof DEVICE_ISSUE_OPTIONS[item].filterRule.value === 'function') { + return { ...DEVICE_ISSUE_OPTIONS[item].filterRule, value: DEVICE_ISSUE_OPTIONS[item].filterRule.value(filtersState) }; + } + return DEVICE_ISSUE_OPTIONS[item].filterRule; + }); + +export const convertDeviceListStateToFilters = ({ filters = [], group, groups = { byId: {} }, offlineThreshold, selectedIssues = [], status }) => { + let applicableFilters = [...filters]; + if (typeof group === 'string' && !(groups.byId[group]?.filters || applicableFilters).length) { + applicableFilters.push({ key: 'group', value: group, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'system' }); + } + const nonMonitorFilters = applicableFilters.filter( + filter => + !Object.values(DEVICE_ISSUE_OPTIONS).some( + ({ filterRule }) => filter.scope !== 'inventory' && filterRule.scope === filter.scope && filterRule.key === filter.key + ) + ); + const deviceIssueFilters = convertIssueOptionsToFilters(selectedIssues, { offlineThreshold }); + applicableFilters = [...nonMonitorFilters, ...deviceIssueFilters]; + const effectiveFilters = status + ? [...applicableFilters, { key: 'status', value: status, operator: DEVICE_FILTERING_OPTIONS.$eq.key, scope: 'identity' }] + : applicableFilters; + return { applicableFilters: nonMonitorFilters, filterTerms: mapFiltersToTerms(effectiveFilters) }; +}; + +const filterCompare = (filter, item) => Object.keys(emptyFilter).every(key => item[key].toString() === filter[key].toString()); + +export const filtersFilter = (item, index, array) => { + const firstIndex = array.findIndex(filter => filterCompare(filter, item)); + return firstIndex === index; +}; + +export const listItemMapper = (byId, ids, { defaultObject = {}, cutOffSize = DEVICE_LIST_MAXIMUM_LENGTH }) => { + return ids.slice(0, cutOffSize).reduce((accu, id) => { + if (id && byId[id]) { + accu.push({ ...defaultObject, ...byId[id] }); + } + return accu; + }, []); +}; + +export const mergePermissions = (existingPermissions = { ...emptyUiPermissions }, addedPermissions) => + Object.entries(existingPermissions).reduce( + (accu, [key, value]) => { + let values; + if (!accu[key]) { + accu[key] = value; + return accu; + } + if (Array.isArray(value)) { + values = [...value, ...accu[key]].filter(duplicateFilter); + } else { + values = mergePermissions(accu[key], { ...value }); + } + accu[key] = values; + return accu; + }, + { ...addedPermissions } + ); + +export const mapUserRolesToUiPermissions = (userRoles, roles) => + userRoles.reduce( + (accu, roleId) => { + if (!(roleId && roles[roleId])) { + return accu; + } + return mergePermissions(accu, roles[roleId].uiPermissions); + }, + { ...emptyUiPermissions } + ); + +export const progress = ({ loaded, total }) => { + let uploadProgress = (loaded / total) * 100; + return (uploadProgress = uploadProgress < 50 ? Math.ceil(uploadProgress) : Math.round(uploadProgress)); +}; + +export const extractErrorMessage = (err, fallback = '') => + err.response?.data?.error?.message || err.response?.data?.error || err.error || err.message || fallback; + +export const preformatWithRequestID = (res, failMsg) => { + // ellipsis line + if (failMsg.length > 100) failMsg = `${failMsg.substring(0, 220)}...`; + + try { + if (res?.data && Object.keys(res.data).includes('request_id')) { + let shortRequestUUID = res.data['request_id'].substring(0, 8); + return `${failMsg} [Request ID: ${shortRequestUUID}]`; + } + } catch (e) { + console.log('failed to extract request id:', e); + } + return failMsg; +}; + +export const getComparisonCompatibleVersion = version => (isNaN(version.charAt(0)) && version !== 'next' ? 'master' : version); + +export const stringToBoolean = content => { + if (!content) { + return false; + } + const string = content + ''; + switch (string.trim().toLowerCase()) { + case 'true': + case 'yes': + case '1': + return true; + case 'false': + case 'no': + case '0': + case null: + return false; + default: + return Boolean(string); + } +}; + +export const groupDeploymentDevicesStats = deployment => { + const deviceStatCollector = (deploymentStates, devices) => + Object.values(devices).reduce((accu, device) => (deploymentStates.includes(device.status) ? accu + 1 : accu), 0); + + const inprogress = deviceStatCollector(deploymentStatesToSubstates.inprogress, deployment.devices); + const pending = deviceStatCollector(deploymentStatesToSubstates.pending, deployment.devices); + const successes = deviceStatCollector(deploymentStatesToSubstates.successes, deployment.devices); + const failures = deviceStatCollector(deploymentStatesToSubstates.failures, deployment.devices); + const paused = deviceStatCollector(deploymentStatesToSubstates.paused, deployment.devices); + return { inprogress, paused, pending, successes, failures }; +}; + +export const statCollector = (items, statistics) => items.reduce((accu, property) => accu + Number(statistics[property] || 0), 0); +export const groupDeploymentStats = (deployment, withSkipped) => { + const { statistics = {} } = deployment; + const { status = {} } = statistics; + const stats = { ...defaultStats, ...status }; + let groupStates = deploymentStatesToSubstates; + let result = {}; + if (withSkipped) { + groupStates = deploymentStatesToSubstatesWithSkipped; + result.skipped = statCollector(groupStates.skipped, stats); + } + result = { + ...result, + // don't include 'pending' as inprogress, as all remaining devices will be pending - we don't discriminate based on phase membership + inprogress: statCollector(groupStates.inprogress, stats), + pending: (deployment.max_devices ? deployment.max_devices - deployment.device_count : 0) + statCollector(groupStates.pending, stats), + successes: statCollector(groupStates.successes, stats), + failures: statCollector(groupStates.failures, stats), + paused: statCollector(groupStates.paused, stats) + }; + return result; +}; + +export const getDeploymentState = deployment => { + const { status: deploymentStatus = DEPLOYMENT_STATES.pending } = deployment; + const { inprogress: currentProgressCount, paused } = groupDeploymentStats(deployment); + + let status = deploymentDisplayStates[deploymentStatus]; + if (deploymentStatus === DEPLOYMENT_STATES.pending && currentProgressCount === 0) { + status = 'queued'; + } else if (paused > 0) { + status = deploymentDisplayStates.paused; + } + return status; +}; + +export const generateDeploymentGroupDetails = (filter, groupName) => + filter && filter.terms?.length + ? `${groupName} (${filter.terms + .map(filter => `${filter.attribute || filter.key} ${DEVICE_FILTERING_OPTIONS[filter.type || filter.operator].shortform} ${filter.value}`) + .join(', ')})` + : groupName; + +export const mapDeviceAttributes = (attributes = []) => + attributes.reduce( + (accu, attribute) => { + if (!(attribute.value && attribute.name) && attribute.scope === ATTRIBUTE_SCOPES.inventory) { + return accu; + } + accu[attribute.scope || ATTRIBUTE_SCOPES.inventory] = { + ...accu[attribute.scope || ATTRIBUTE_SCOPES.inventory], + [attribute.name]: attribute.value + }; + if (attribute.name === 'device_type' && attribute.scope === ATTRIBUTE_SCOPES.inventory) { + accu.inventory.device_type = [].concat(attribute.value); + } + return accu; + }, + { inventory: { device_type: [], artifact_name: '' }, identity: {}, monitor: {}, system: {}, tags: {} } + ); + +export const isDarkMode = mode => mode === DARK_MODE; diff --git a/frontend/src/js/themes/Mender/light.js b/frontend/src/js/themes/Mender/light.js index c1f5e463..12900f9f 100644 --- a/frontend/src/js/themes/Mender/light.js +++ b/frontend/src/js/themes/Mender/light.js @@ -16,7 +16,8 @@ import { buttonClasses } from '@mui/material/Button'; import { formLabelClasses } from '@mui/material/FormLabel'; import { listItemTextClasses } from '@mui/material/ListItemText'; -import { LIGHT_MODE } from '../../constants/appConstants.js'; +import { LIGHT_MODE } from '@northern.tech/store/constants'; + import { palette as commonPalette, overrides, typography } from './common'; const grey = { diff --git a/frontend/src/js/utils/deploymentdevicehook.js b/frontend/src/js/utils/deploymentdevicehook.js index 3d71b924..9ead187c 100644 --- a/frontend/src/js/utils/deploymentdevicehook.js +++ b/frontend/src/js/utils/deploymentdevicehook.js @@ -14,11 +14,10 @@ import { useEffect, useRef } from 'react'; import { useDispatch, useSelector } from 'react-redux'; +import { getDevicesById } from '@northern.tech/store/selectors'; +import { getDeviceById } from '@northern.tech/store/thunks'; import isUUID from 'validator/lib/isUUID'; -import { getDeviceById } from '../actions/deviceActions'; -import { getDevicesById } from '../selectors'; - export const useDeploymentDevice = deploymentName => { const isLoading = useRef(false); const dispatch = useDispatch(); diff --git a/frontend/src/js/utils/locationutils.js b/frontend/src/js/utils/locationutils.js index c755ec9b..b3a5ecef 100644 --- a/frontend/src/js/utils/locationutils.js +++ b/frontend/src/js/utils/locationutils.js @@ -11,10 +11,20 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { + ALL_DEVICES, + ATTRIBUTE_SCOPES, + AUDIT_LOGS_TYPES, + DEPLOYMENT_ROUTES, + DEPLOYMENT_STATES, + DEPLOYMENT_TYPES, + DEVICE_FILTERING_OPTIONS, + DEVICE_LIST_DEFAULTS, + UNGROUPED_GROUP, + emptyFilter +} from '@northern.tech/store/constants'; + import { routes } from '../components/devices/base-devices'; -import { DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, DEPLOYMENT_TYPES } from '../constants/deploymentConstants'; -import { ALL_DEVICES, ATTRIBUTE_SCOPES, DEVICE_FILTERING_OPTIONS, DEVICE_LIST_DEFAULTS, UNGROUPED_GROUP, emptyFilter } from '../constants/deviceConstants'; -import { AUDIT_LOGS_TYPES } from '../constants/organizationConstants'; import { deepCompare, getISOStringBoundaries } from '../helpers'; const SEPARATOR = ':'; diff --git a/frontend/src/js/utils/locationutils.test.js b/frontend/src/js/utils/locationutils.test.js index c946a773..4a8fdced 100644 --- a/frontend/src/js/utils/locationutils.test.js +++ b/frontend/src/js/utils/locationutils.test.js @@ -11,9 +11,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, listDefaultsByState } from '../constants/deploymentConstants'; -import { DEVICE_STATES, UNGROUPED_GROUP } from '../constants/deviceConstants'; -import { AUDIT_LOGS_TYPES } from '../constants/organizationConstants'; +import { AUDIT_LOGS_TYPES, DEPLOYMENT_ROUTES, DEPLOYMENT_STATES, DEVICE_STATES, UNGROUPED_GROUP, listDefaultsByState } from '@northern.tech/store/constants'; + import { commonProcessor, formatAuditlogs, diff --git a/frontend/src/js/utils/onboardingmanager.js b/frontend/src/js/utils/onboardingmanager.js index 78dd889c..ebaed9bb 100644 --- a/frontend/src/js/utils/onboardingmanager.js +++ b/frontend/src/js/utils/onboardingmanager.js @@ -13,6 +13,8 @@ // limitations under the License. import React from 'react'; +import { DEPLOYMENT_STATES, onboardingSteps as stepNames, yes } from '@northern.tech/store/constants'; + import BaseOnboardingTip from '../components/helptips/baseonboardingtip'; import OnboardingCompleteTip from '../components/helptips/onboardingcompletetip'; import { @@ -32,9 +34,6 @@ import { SchedulingGroupSelection, SchedulingReleaseToDevices } from '../components/helptips/onboardingtips'; -import { yes } from '../constants/appConstants'; -import { DEPLOYMENT_STATES } from '../constants/deploymentConstants'; -import { onboardingSteps as stepNames } from '../constants/onboardingConstants'; export const onboardingSteps = { [stepNames.DASHBOARD_ONBOARDING_START]: { @@ -147,17 +146,8 @@ export const getOnboardingComponentFor = (id, componentProps, params = {}, previ return previousComponent; } if (step.specialComponent) { - // const Component = step.specialComponent return React.cloneElement(step.specialComponent, params); } const component = step.component(componentProps); return ; }; - -export const applyOnboardingFallbacks = progress => { - const step = onboardingSteps[progress]; - if (step && step.fallbackStep) { - return step.fallbackStep; - } - return progress; -}; diff --git a/frontend/src/js/utils/resizehook.js b/frontend/src/js/utils/resizehook.js index 050a8bae..4f08b876 100644 --- a/frontend/src/js/utils/resizehook.js +++ b/frontend/src/js/utils/resizehook.js @@ -13,7 +13,7 @@ // limitations under the License. import { useLayoutEffect, useRef, useState } from 'react'; -import { TIMEOUTS } from '../constants/appConstants'; +import { TIMEOUTS } from '@northern.tech/store/constants'; export const useWindowSize = () => { const [size, setSize] = useState({ height: window.innerHeight, width: window.innerWidth }); diff --git a/frontend/src/js/utils/retrytimer.js b/frontend/src/js/utils/retrytimer.js index 5cb50abe..52009180 100644 --- a/frontend/src/js/utils/retrytimer.js +++ b/frontend/src/js/utils/retrytimer.js @@ -11,8 +11,8 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { TIMEOUTS } from '../constants/appConstants'; -import { extractErrorMessage, preformatWithRequestID } from '../helpers'; +import { TIMEOUTS } from '@northern.tech/store/constants'; +import { extractErrorMessage, preformatWithRequestID } from '@northern.tech/store/utils'; let timers = {}; diff --git a/frontend/src/js/utils/sockethook.js b/frontend/src/js/utils/sockethook.js index d209f2bf..db181bd7 100644 --- a/frontend/src/js/utils/sockethook.js +++ b/frontend/src/js/utils/sockethook.js @@ -13,13 +13,10 @@ // limitations under the License. import { useCallback, useEffect, useRef, useState } from 'react'; +import { DEVICE_MESSAGE_PROTOCOLS as MessageProtocols, DEVICE_MESSAGE_TYPES as MessageTypes, TIMEOUTS, apiUrl } from '@northern.tech/store/constants'; import msgpack5 from 'msgpack5'; import Cookies from 'universal-cookie'; -import { apiUrl } from '../api/general-api'; -import { TIMEOUTS } from '../constants/appConstants'; -import { DEVICE_MESSAGE_PROTOCOLS as MessageProtocols, DEVICE_MESSAGE_TYPES as MessageTypes } from '../constants/deviceConstants'; - const cookies = new Cookies(); const MessagePack = msgpack5(); diff --git a/frontend/tests/__mocks__/deploymentHandlers.js b/frontend/tests/__mocks__/deploymentHandlers.js index 37fb9453..79b10329 100644 --- a/frontend/tests/__mocks__/deploymentHandlers.js +++ b/frontend/tests/__mocks__/deploymentHandlers.js @@ -11,11 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { deploymentsApiUrl, deploymentsApiUrlV2, headerNames, limitDefault } from '@northern.tech/store/constants'; import { HttpResponse, http } from 'msw'; -import { deploymentsApiUrl, deploymentsApiUrlV2 } from '../../src/js/actions/deploymentActions'; -import { headerNames } from '../../src/js/api/general-api'; -import { limitDefault } from '../../src/js/constants/deploymentConstants'; import { defaultState } from '../mockData'; const createdDeployment = { diff --git a/frontend/tests/__mocks__/deviceHandlers.js b/frontend/tests/__mocks__/deviceHandlers.js index 8e689cdb..96e75261 100644 --- a/frontend/tests/__mocks__/deviceHandlers.js +++ b/frontend/tests/__mocks__/deviceHandlers.js @@ -11,19 +11,20 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { HttpResponse, http } from 'msw'; - import { + DEVICE_FILTERING_OPTIONS, + DEVICE_STATES, deviceAuthV2, deviceConfig, deviceConnect, + headerNames, inventoryApiUrl, inventoryApiUrlV2, iotManagerBaseURL, reportingApiUrl -} from '../../src/js/actions/deviceActions'; -import { headerNames } from '../../src/js/api/general-api'; -import * as DeviceConstants from '../../src/js/constants/deviceConstants'; +} from '@northern.tech/store/constants'; +import { HttpResponse, http } from 'msw'; + import { defaultCreationDate, defaultMacAddress, defaultState } from '../mockData'; const deviceAuthDevice = { @@ -100,9 +101,7 @@ const searchHandler = async ({ request }) => { if ([page, per_page, filters].some(item => !item)) { return new HttpResponse(null, { status: 509 }); } - const filter = filters.find( - filter => filter.scope === 'identity' && filter.attribute === 'status' && Object.values(DeviceConstants.DEVICE_STATES).includes(filter.value) - ); + const filter = filters.find(filter => filter.scope === 'identity' && filter.attribute === 'status' && Object.values(DEVICE_STATES).includes(filter.value)); const status = filter?.value || ''; if (!status || filters.length > 1) { if (filters.find(filter => filter.attribute === 'group' && filter.value.includes(Object.keys(defaultState.devices.groups.byId)[0]))) { @@ -240,10 +239,7 @@ export const deviceHandlers = [ if ( [name, terms].some(item => !item) || defaultState.devices.groups[name] || - !terms.every( - term => - DeviceConstants.DEVICE_FILTERING_OPTIONS[term.type] && ['identity', 'inventory', 'system'].includes(term.scope) && !!term.value && !!term.attribute - ) + !terms.every(term => DEVICE_FILTERING_OPTIONS[term.type] && ['identity', 'inventory', 'system'].includes(term.scope) && !!term.value && !!term.attribute) ) { return new HttpResponse(null, { status: 510 }); } @@ -251,7 +247,7 @@ export const deviceHandlers = [ }), http.put(`${deviceAuthV2}/devices/:deviceId/auth/:authId/status`, async ({ params: { authId, deviceId }, request }) => { const { status } = await request.json(); - if (defaultState.devices.byId[deviceId].auth_sets.find(authSet => authSet.id === authId) && DeviceConstants.DEVICE_STATES[status]) { + if (defaultState.devices.byId[deviceId].auth_sets.find(authSet => authSet.id === authId) && DEVICE_STATES[status]) { return new HttpResponse(null, { status: 200 }); } return new HttpResponse(null, { status: 511 }); diff --git a/frontend/tests/__mocks__/monitorHandlers.js b/frontend/tests/__mocks__/monitorHandlers.js index 754a50be..59a00a92 100644 --- a/frontend/tests/__mocks__/monitorHandlers.js +++ b/frontend/tests/__mocks__/monitorHandlers.js @@ -11,11 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { alertChannels, headerNames, monitorApiUrlv1 } from '@northern.tech/store/constants'; import { HttpResponse, http } from 'msw'; -import { monitorApiUrlv1 } from '../../src/js/actions/monitorActions'; -import { headerNames } from '../../src/js/api/general-api'; -import { alertChannels } from '../../src/js/constants/monitorConstants'; import { defaultState } from '../mockData'; export const monitorHandlers = [ diff --git a/frontend/tests/__mocks__/organizationHandlers.js b/frontend/tests/__mocks__/organizationHandlers.js index f0ee11e9..0e730fb8 100644 --- a/frontend/tests/__mocks__/organizationHandlers.js +++ b/frontend/tests/__mocks__/organizationHandlers.js @@ -11,13 +11,18 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { + EXTERNAL_PROVIDER, + PLANS, + auditLogsApiUrl, + headerNames, + iotManagerBaseURL, + ssoIdpApiUrlv1, + tenantadmApiUrlv1, + tenantadmApiUrlv2 +} from '@northern.tech/store/constants'; import { HttpResponse, http } from 'msw'; -import { iotManagerBaseURL } from '../../src/js/actions/deviceActions'; -import { auditLogsApiUrl, ssoIdpApiUrlv1, tenantadmApiUrlv1, tenantadmApiUrlv2 } from '../../src/js/actions/organizationActions'; -import { headerNames } from '../../src/js/api/general-api'; -import { PLANS } from '../../src/js/constants/appConstants'; -import { EXTERNAL_PROVIDER } from '../../src/js/constants/deviceConstants'; import { defaultState, webhookEvents } from '../mockData'; const releasesSample = { diff --git a/frontend/tests/__mocks__/releaseHandlers.js b/frontend/tests/__mocks__/releaseHandlers.js index 967ecd9b..9929cb3a 100644 --- a/frontend/tests/__mocks__/releaseHandlers.js +++ b/frontend/tests/__mocks__/releaseHandlers.js @@ -11,11 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { SORTING_OPTIONS, deploymentsApiUrl, deploymentsApiUrlV2, headerNames } from '@northern.tech/store/constants'; import { HttpResponse, http } from 'msw'; -import { deploymentsApiUrl, deploymentsApiUrlV2 } from '../../src/js/actions/deploymentActions'; -import { headerNames } from '../../src/js/api/general-api'; -import { SORTING_OPTIONS } from '../../src/js/constants/appConstants'; import { customSort } from '../../src/js/helpers'; import { defaultState, releasesList } from '../mockData'; diff --git a/frontend/tests/__mocks__/userHandlers.js b/frontend/tests/__mocks__/userHandlers.js index 0fa3e92f..47565568 100644 --- a/frontend/tests/__mocks__/userHandlers.js +++ b/frontend/tests/__mocks__/userHandlers.js @@ -11,10 +11,9 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. +import { APPLICATION_JWT_CONTENT_TYPE, useradmApiUrl, useradmApiUrlv2 } from '@northern.tech/store/constants'; import { HttpResponse, http } from 'msw'; -import { APPLICATION_JWT_CONTENT_TYPE } from '../../src/js/constants/appConstants.js'; -import { useradmApiUrl, useradmApiUrlv2 } from '../../src/js/constants/userConstants'; import { accessTokens, defaultPassword, defaultState, userId as defaultUserId, permissionSets, rbacRoles, testSsoId, token } from '../mockData'; export const userHandlers = [ diff --git a/frontend/tests/licenses/directDependencies.csv b/frontend/tests/licenses/directDependencies.csv index 38fe5171..1461c67d 100644 --- a/frontend/tests/licenses/directDependencies.csv +++ b/frontend/tests/licenses/directDependencies.csv @@ -1,8 +1,5 @@ "module name","license","repository" "@babel/core","MIT","https://github.com/babel/babel" -"@babel/eslint-parser","MIT","https://github.com/babel/babel" -"@babel/plugin-syntax-dynamic-import","MIT","https://github.com/babel/babel.git#master" -"@babel/plugin-transform-runtime","MIT","https://github.com/babel/babel" "@babel/preset-env","MIT","https://github.com/babel/babel" "@babel/preset-react","MIT","https://github.com/babel/babel" "@babel/preset-typescript","MIT","https://github.com/babel/babel" @@ -47,6 +44,7 @@ "css-loader","MIT","https://github.com/webpack-contrib/css-loader" "dayjs","MIT","https://github.com/iamkun/dayjs" "esbuild-loader","MIT","https://github.com/privatenumber/esbuild-loader" +"eslint-import-resolver-typescript","ISC","https://github.com/import-js/eslint-import-resolver-typescript" "eslint-plugin-import","MIT","https://github.com/import-js/eslint-plugin-import" "eslint-plugin-prettier","MIT","https://github.com/prettier/eslint-plugin-prettier" "eslint-plugin-react-hooks","MIT","https://github.com/facebook/react" @@ -95,6 +93,8 @@ "redux-mock-store","MIT","https://github.com/arnaudbenard/redux-mock-store" "redux-thunk","MIT","https://github.com/reduxjs/redux-thunk" "stream-browserify","MIT","https://github.com/browserify/stream-browserify" +"ts-jest","MIT","https://github.com/kulshekhar/ts-jest" +"tsconfig-paths-webpack-plugin","MIT","https://github.com/dividab/tsconfig-paths-webpack-plugin" "tss-react","MIT","https://github.com/garronej/tss-react" "typescript","Apache-2.0","https://github.com/microsoft/TypeScript" "undici","MIT","https://github.com/nodejs/undici" diff --git a/frontend/tests/mockData.js b/frontend/tests/mockData.js index 33c1f08e..e4010b9d 100644 --- a/frontend/tests/mockData.js +++ b/frontend/tests/mockData.js @@ -11,11 +11,15 @@ // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. -import { maxSessionAge } from '../src/js/auth'; -import { SORTING_OPTIONS } from '../src/js/constants/appConstants'; -import * as DeviceConstants from '../src/js/constants/deviceConstants'; -import { ALL_RELEASES } from '../src/js/constants/releaseConstants'; +import { initialState as initialAppState } from '@northern.tech/store/appSlice'; +import { maxSessionAge } from '@northern.tech/store/auth'; import { + ALL_DEVICES, + ALL_RELEASES, + DEVICE_ISSUE_OPTIONS, + DEVICE_LIST_DEFAULTS, + DEVICE_STATES, + SORTING_OPTIONS, defaultPermissionSets, emptyRole, emptyUiPermissions, @@ -24,15 +28,14 @@ import { scopedPermissionAreas, twoFAStates, uiPermissionsById -} from '../src/js/constants/userConstants'; -import { initialState as initialAppState } from '../src/js/reducers/appReducer'; -import { initialState as initialDeploymentsState } from '../src/js/reducers/deploymentReducer'; -import { initialState as initialDevicesState } from '../src/js/reducers/deviceReducer'; -import { initialState as initialMonitorState } from '../src/js/reducers/monitorReducer'; -import { initialState as initialOnboardingState } from '../src/js/reducers/onboardingReducer'; -import { initialState as initialOrganizationState } from '../src/js/reducers/organizationReducer'; -import { initialState as initialReleasesState } from '../src/js/reducers/releaseReducer'; -import { initialState as initialUsersState } from '../src/js/reducers/userReducer'; +} from '@northern.tech/store/constants'; +import { initialState as initialDeploymentsState } from '@northern.tech/store/deploymentsSlice'; +import { initialState as initialDevicesState } from '@northern.tech/store/devicesSlice'; +import { initialState as initialMonitorState } from '@northern.tech/store/monitorSlice'; +import { initialState as initialOnboardingState } from '@northern.tech/store/onboardingSlice'; +import { initialState as initialOrganizationState } from '@northern.tech/store/organizationSlice'; +import { initialState as initialReleasesState } from '@northern.tech/store/releasesSlice'; +import { initialState as initialUsersState } from '@northern.tech/store/usersSlice'; export const undefineds = /undefined|\[object Object\]/; export const menderEnvironment = { @@ -204,16 +207,16 @@ export const defaultState = { selectedDeviceIds: [], selectionState: { finished: { - ...DeviceConstants.DEVICE_LIST_DEFAULTS, + ...DEVICE_LIST_DEFAULTS, selection: ['d1'], endDate: undefined, search: '', total: 1, type: '' }, - inprogress: { ...DeviceConstants.DEVICE_LIST_DEFAULTS, selection: ['d1'], total: 1 }, - pending: { ...DeviceConstants.DEVICE_LIST_DEFAULTS, selection: ['d2'], total: 1 }, - scheduled: { ...DeviceConstants.DEVICE_LIST_DEFAULTS, selection: ['d2'], total: 1 }, + inprogress: { ...DEVICE_LIST_DEFAULTS, selection: ['d1'], total: 1 }, + pending: { ...DEVICE_LIST_DEFAULTS, selection: ['d2'], total: 1 }, + scheduled: { ...DEVICE_LIST_DEFAULTS, selection: ['d2'], total: 1 }, general: { state: 'active', showCreationDialog: false, @@ -309,7 +312,7 @@ export const defaultState = { // key: null, // scope: null }, - state: DeviceConstants.DEVICE_STATES.accepted, + state: DEVICE_STATES.accepted, total: 0 }, filteringAttributes: { @@ -347,7 +350,7 @@ export const defaultState = { monitor: { ...initialMonitorState, alerts: { - alertList: { ...DeviceConstants.DEVICE_LIST_DEFAULTS, total: 0 }, + alertList: { ...DEVICE_LIST_DEFAULTS, total: 0 }, byDeviceId: { a1: { alerts: [ @@ -371,7 +374,7 @@ export const defaultState = { } }, issueCounts: { - byType: Object.values(DeviceConstants.DEVICE_ISSUE_OPTIONS).reduce( + byType: Object.values(DEVICE_ISSUE_OPTIONS).reduce( (accu, { isCategory, key }) => { if (isCategory) { return accu; @@ -381,9 +384,9 @@ export const defaultState = { return accu; }, { - [DeviceConstants.DEVICE_ISSUE_OPTIONS.authRequests.key]: { filtered: 0, total: 0 }, - [DeviceConstants.DEVICE_ISSUE_OPTIONS.monitoring.key]: { filtered: 3, total: 0 }, - [DeviceConstants.DEVICE_ISSUE_OPTIONS.offline.key]: { filtered: 0, total: 0 } + [DEVICE_ISSUE_OPTIONS.authRequests.key]: { filtered: 0, total: 0 }, + [DEVICE_ISSUE_OPTIONS.monitoring.key]: { filtered: 3, total: 0 }, + [DEVICE_ISSUE_OPTIONS.offline.key]: { filtered: 0, total: 0 } } ) }, @@ -457,7 +460,7 @@ export const defaultState = { } ], selectionState: { - ...DeviceConstants.DEVICE_LIST_DEFAULTS, + ...DEVICE_LIST_DEFAULTS, sort: {}, total: 3 } @@ -500,7 +503,7 @@ export const defaultState = { } }, releasesList: { - ...DeviceConstants.DEVICE_LIST_DEFAULTS, + ...DEVICE_LIST_DEFAULTS, searchedIds: [], isLoading: false, releaseIds: ['r1'], @@ -816,7 +819,7 @@ const expectedParsedRoles = { auditlog: [uiPermissionsById.read.value], deployments: [uiPermissionsById.manage.value, uiPermissionsById.deploy.value, uiPermissionsById.read.value], groups: { - [DeviceConstants.ALL_DEVICES]: [ + [ALL_DEVICES]: [ uiPermissionsById.read.value, // we can't assign deployment permissions to devices here, since the old path based rbac controls don't allow the scoped permissions // uiPermissionsById.deploy.value, @@ -839,7 +842,7 @@ const expectedParsedRoles = { auditlog: [uiPermissionsById.read.value], deployments: [uiPermissionsById.read.value, uiPermissionsById.deploy.value, uiPermissionsById.manage.value], groups: { - [DeviceConstants.ALL_DEVICES]: [ + [ALL_DEVICES]: [ uiPermissionsById.read.value, uiPermissionsById.manage.value, uiPermissionsById.deploy.value, diff --git a/frontend/tests/setupTests.js b/frontend/tests/setupTests.js index 431f7805..c0e736c7 100644 --- a/frontend/tests/setupTests.js +++ b/frontend/tests/setupTests.js @@ -18,14 +18,14 @@ import { MemoryRouter } from 'react-router-dom'; import { ThemeProvider, createTheme } from '@mui/material/styles'; +import { getSessionInfo } from '@northern.tech/store/auth'; +import { yes } from '@northern.tech/store/constants'; +import { getConfiguredStore } from '@northern.tech/store/store'; import '@testing-library/jest-dom'; import { act, cleanup, queryByRole, render, waitFor, within } from '@testing-library/react'; import { setupServer } from 'msw/node'; import { MessageChannel } from 'worker_threads'; -import { getSessionInfo } from '../src/js/auth'; -import { yes } from '../src/js/constants/appConstants'; -import { getConfiguredStore } from '../src/js/reducers'; import { light as lightTheme } from '../src/js/themes/Mender'; import handlers from './__mocks__/requestHandlers'; import { defaultState, menderEnvironment, mockDate, token as mockToken } from './mockData'; diff --git a/frontend/tsconfig-test.json b/frontend/tsconfig-test.json index e2cded1e..42763919 100644 --- a/frontend/tsconfig-test.json +++ b/frontend/tsconfig-test.json @@ -5,5 +5,5 @@ "noResolve": true }, "extends": "./tsconfig.json", - "exclude": ["src/js/api/types/Settings.ts"] + "exclude": ["src/js/store/api/types/Settings.ts"] } diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json index 2256b01e..50c5b10f 100644 --- a/frontend/tsconfig.json +++ b/frontend/tsconfig.json @@ -17,7 +17,12 @@ "skipLibCheck": true, "sourceMap": true, "strict": true, - "target": "esnext" + "target": "esnext", + "baseUrl": ".", + "paths": { + "@northern.tech/store/*": ["src/js/store/*"] + } }, - "include": ["./src/**/*.ts"] + "include": ["**/*.js", "**/*.ts", "**/*.tsx"], + "exclude": ["node_modules", ".nyc_output", "coverage", "jest-coverage"] } diff --git a/frontend/webpack.config.js b/frontend/webpack.config.js index 1bd32382..23e2cc87 100644 --- a/frontend/webpack.config.js +++ b/frontend/webpack.config.js @@ -6,6 +6,7 @@ import HtmlWebPackPlugin from 'html-webpack-plugin'; import MiniCssExtractPlugin from 'mini-css-extract-plugin'; import { createRequire } from 'module'; import path from 'path'; +import TsconfigPathsPlugin from 'tsconfig-paths-webpack-plugin'; import { fileURLToPath } from 'url'; import webpack from 'webpack'; @@ -127,7 +128,13 @@ export default (env, argv) => { stream: require.resolve('stream-browserify'), util: require.resolve('util/'), 'process/browser': require.resolve('process/browser') - } + }, + plugins: [ + new TsconfigPathsPlugin({ + configFile: 'tsconfig.json', + extensions: ['.ts', '.tsx', '.js', '.jsx'] + }) + ] }, target: 'web' };