diff --git a/.github/workflows/clone-db.yaml b/.github/workflows/clone-db.yaml index 7989fa34f..3b006e3f9 100644 --- a/.github/workflows/clone-db.yaml +++ b/.github/workflows/clone-db.yaml @@ -7,8 +7,8 @@ name: Clone Stable Database on: schedule: - # Run daily at 2:00 PM EST - - cron: '0 * * * *' + # Run daily at 12:00 AM EST + - cron: '0 0 * * *' # Allow manual triggering workflow_dispatch: diff --git a/.gitignore b/.gitignore index 3092682c2..b49b30639 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ docs/research/data/** **/assets/* +!**/assets/src/ !**/assets/sass/ !**/assets/img/registrar/ public/ diff --git a/src/gulpfile.js b/src/gulpfile.js index c8d5ca261..53e58dbe8 100644 --- a/src/gulpfile.js +++ b/src/gulpfile.js @@ -1,37 +1,123 @@ /* gulpfile.js */ +// We need a hook into gulp for the JS jobs definitions +const gulp = require('gulp'); +// For bundling +const webpack = require('webpack-stream'); +// Out-of-the-box uswds compiler const uswds = require('@uswds/compile'); +// For minimizing and optimizing +const TerserPlugin = require('terser-webpack-plugin'); + +const ASSETS_DIR = './registrar/assets/'; +const JS_BUNDLE_DEST = ASSETS_DIR + 'js'; +const JS_SOURCES = [ + { src: ASSETS_DIR + 'src/js/getgov/*.js', output: 'getgov.min.js' }, + { src: ASSETS_DIR + 'src/js/getgov-admin/*.js', output: 'getgov-admin.min.js' }, +]; /** * USWDS version * Set the version of USWDS you're using (2 or 3) */ - uswds.settings.version = 3; /** * Path settings * Set as many as you need */ - -const ASSETS_DIR = './registrar/assets/'; - uswds.paths.dist.css = ASSETS_DIR + 'css'; uswds.paths.dist.sass = ASSETS_DIR + 'sass'; -uswds.paths.dist.theme = ASSETS_DIR + 'sass/_theme'; +uswds.paths.dist.theme = ASSETS_DIR + 'src/sass/_theme'; uswds.paths.dist.fonts = ASSETS_DIR + 'fonts'; uswds.paths.dist.js = ASSETS_DIR + 'js'; uswds.paths.dist.img = ASSETS_DIR + 'img'; +/** + * Function: Create Bundling Task + * + * This function generates a Gulp task for bundling JavaScript files. It accepts a source file pattern + * and an output filename, then processes the files using Webpack for tasks like transpilation, bundling, + * and optimization. The resulting task performs the following: + * + * 1. Reads the JavaScript source files specified by the `source` parameter. + * 2. Transforms the JavaScript using Webpack: + * - Runs in "production" mode by default for optimizations (use "development" mode for easier debugging). + * - Generates a source map for better debugging experience, linking the output to the original source. + * - Minifies the code using TerserPlugin while suppressing the generation of `.LICENSE.txt` files. + * - Processes the JavaScript with Babel to ensure compatibility with older browsers by using the `@babel/preset-env`. + * 3. Outputs the bundled and optimized JavaScript to the specified destination folder. + * + * Parameters: + * - `source`: A glob pattern or file path specifying the input JavaScript files. + * - `output`: The filename for the generated JavaScript bundle. + * + * Returns: + * - A function that can be executed as a Gulp task. + */ +function createBundleTask(source, output) { + return () => + gulp + .src(source) + .pipe( + webpack({ + mode: 'production', // Use 'development' if you want less minification during debugging + devtool: 'source-map', // Enable source map generation + optimization: { + minimize: true, + minimizer: [ + new TerserPlugin({ + extractComments: false, // Prevents generating .LICENSE.txt + }), + ], + }, + output: { filename: output }, + module: { + rules: [ + { + test: /\.js$/, + exclude: /node_modules/, + use: { + loader: 'babel-loader', + options: { presets: ['@babel/preset-env'] }, + }, + }, + ], + }, + }) + ) + .pipe(gulp.dest(JS_BUNDLE_DEST)); +} + +// Create tasks for JavaScript sources +JS_SOURCES.forEach(({ src, output }, index) => { + const taskName = `bundle-js-${index}`; + gulp.task(taskName, createBundleTask(src, output)); +}); + +/** + * Watch for changes in JavaScript modules + */ +gulp.task('watch-js', () => { + JS_SOURCES.forEach(({ src }, index) => { + gulp.watch(src, gulp.series(`bundle-js-${index}`)); + }); +}); + +/** + * Combine all watch tasks + */ +gulp.task('watch', gulp.parallel('watch-js', uswds.watch)); + /** * Exports * Add as many as you need + * Some tasks combine USWDS compilation and JavaScript precompilation. */ - -exports.default = uswds.compile; +exports.default = gulp.series(uswds.compile, ...JS_SOURCES.map((_, i) => `bundle-js-${i}`)); exports.init = uswds.init; -exports.compile = uswds.compile; +exports.compile = gulp.series(uswds.compile, ...JS_SOURCES.map((_, i) => `bundle-js-${i}`)); exports.watch = uswds.watch; +exports.watchAll = gulp.parallel('watch'); exports.copyAssets = uswds.copyAssets exports.updateUswds = uswds.updateUswds - \ No newline at end of file diff --git a/src/package-lock.json b/src/package-lock.json index 08e70dd51..db141cc12 100644 --- a/src/package-lock.json +++ b/src/package-lock.json @@ -9,18 +9,1474 @@ "version": "1.0.0", "license": "ISC", "dependencies": { - "@uswds/uswds": "^3.8.1", + "@uswds/uswds": "3.8.1", "pa11y-ci": "^3.0.1", "sass": "^1.54.8" }, "devDependencies": { - "@uswds/compile": "^1.0.0-beta.3" + "@babel/core": "^7.26.0", + "@babel/preset-env": "^7.26.0", + "@uswds/compile": "1.1.0", + "babel-loader": "^9.2.1", + "sass-loader": "^12.6.0", + "webpack": "^5.96.1", + "webpack-stream": "^7.0.0" + } + }, + "node_modules/@ampproject/remapping": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", + "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", + "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", + "dev": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.25.9", + "js-tokens": "^4.0.0", + "picocolors": "^1.0.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.26.2.tgz", + "integrity": "sha512-Z0WgzSEa+aUcdiJuCIqgujCshpMWgUpgOxXotrYPSA53hA3qopNaqcJpyr0hVb1FeWdnqFA35/fUtXgBK8srQg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.26.0.tgz", + "integrity": "sha512-i1SLeK+DzNnQ3LL/CswPCa/E5u4lh1k6IAEphON8F+cXt0t9euTshDru0q7/IqMa1PMPz5RnHuHscF8/ZJsStg==", + "dev": true, + "dependencies": { + "@ampproject/remapping": "^2.2.0", + "@babel/code-frame": "^7.26.0", + "@babel/generator": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-module-transforms": "^7.26.0", + "@babel/helpers": "^7.26.0", + "@babel/parser": "^7.26.0", + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.26.0", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.26.2.tgz", + "integrity": "sha512-zevQbhbau95nkoxSq3f/DC/SC+EEOUZd3DYqfSkMhY2/wfSeaHV1Ew4vk8e+x8lja31IbyuUa2uQ3JONqKbysw==", + "dev": true, + "dependencies": { + "@babel/parser": "^7.26.2", + "@babel/types": "^7.26.0", + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-annotate-as-pure": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.25.9.tgz", + "integrity": "sha512-gv7320KBUFJz1RnylIg5WWYPRXKZ884AGkYpgpWW02TH66Dl+HaC1t1CKd0z3R4b6hdYEcmrNZHUmfCP+1u3/g==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-builder-binary-assignment-operator-visitor": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-builder-binary-assignment-operator-visitor/-/helper-builder-binary-assignment-operator-visitor-7.25.9.tgz", + "integrity": "sha512-C47lC7LIDCnz0h4vai/tpNOI95tCd5ZT3iBt/DBH5lXKHZsyNQv18yf1wIIg2ntiQNgmAvA+DgZ82iW8Qdym8g==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.9.tgz", + "integrity": "sha512-j9Db8Suy6yV/VHa4qzrj9yZfZxhLWQdVnRlXxmKLYlhWUVB1sB2G5sxuWYXk/whHD9iW76PmNzxZ4UCnTQTVEQ==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-create-class-features-plugin": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-class-features-plugin/-/helper-create-class-features-plugin-7.25.9.tgz", + "integrity": "sha512-UTZQMvt0d/rSz6KI+qdu7GQze5TIajwTS++GUozlw8VBJDEOAqSXwm1WvmYEZwqdqSGQshRocPDqrt4HBZB3fQ==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-create-regexp-features-plugin": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-create-regexp-features-plugin/-/helper-create-regexp-features-plugin-7.25.9.tgz", + "integrity": "sha512-ORPNZ3h6ZRkOyAa/SaHU+XsLZr0UQzRwuDQ0cczIA17nAzZ+85G5cVkOJIj7QavLZGSe8QXUmNFxSZzjcZF9bw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "regexpu-core": "^6.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-define-polyfill-provider": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/@babel/helper-define-polyfill-provider/-/helper-define-polyfill-provider-0.6.3.tgz", + "integrity": "sha512-HK7Bi+Hj6H+VTHA3ZvBis7V/6hu9QuTrnMXNybfUf2iiuU/N97I8VjB+KbhFF8Rld/Lx5MzoCwPCpPjfK+n8Cg==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.22.6", + "@babel/helper-plugin-utils": "^7.22.5", + "debug": "^4.1.1", + "lodash.debounce": "^4.0.8", + "resolve": "^1.14.2" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/helper-member-expression-to-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.25.9.tgz", + "integrity": "sha512-wbfdZ9w5vk0C0oyHqAJbc62+vet5prjj01jjJ8sKn3j9h3MQQlflEdXYvuqRWjHnM12coDEqiC1IRCi0U/EKwQ==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.25.9.tgz", + "integrity": "sha512-tnUA4RsrmflIM6W6RFTLFSXITtl0wKjgpnLgXyowocVPrbYrLUXSBXDgTs8BlbmIzIdlBySRQjINYs2BAkiLtw==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.26.0.tgz", + "integrity": "sha512-xO+xu6B5K2czEnQye6BHA7DolFFmS3LB7stHZFaOLb1pAwO1HWLS8fXA+eh0A2yIvltPVmx3eNNDBJA2SLHXFw==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-optimise-call-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-optimise-call-expression/-/helper-optimise-call-expression-7.25.9.tgz", + "integrity": "sha512-FIpuNaz5ow8VyrYcnXQTDRGvV6tTjkNtCK/RYNDXGSLlUD6cBuQTSw43CShGxjvfBTfcUA/r6UhUCbtYqkhcuQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.25.9.tgz", + "integrity": "sha512-kSMlyUVdWe25rEsRGviIgOWnoT/nfABVWlqt9N19/dIPWViAOW2s9wznP5tURbs/IDuNk4gPy3YdYRgH3uxhBw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-remap-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-remap-async-to-generator/-/helper-remap-async-to-generator-7.25.9.tgz", + "integrity": "sha512-IZtukuUeBbhgOcaW2s06OXTzVNJR0ybm4W5xC1opWFFJMZbwRj5LCk+ByYH7WdZPZTt8KnFwA8pvjN2yqcPlgw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-wrap-function": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-replace-supers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.9.tgz", + "integrity": "sha512-IiDqTOTBQy0sWyeXyGSC5TBJpGFXBkRynjBeXsvbhQFKj2viwJC76Epz35YLU1fpe/Am6Vppb7W7zM4fPQzLsQ==", + "dev": true, + "dependencies": { + "@babel/helper-member-expression-to-functions": "^7.25.9", + "@babel/helper-optimise-call-expression": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-simple-access": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-simple-access/-/helper-simple-access-7.25.9.tgz", + "integrity": "sha512-c6WHXuiaRsJTyHYLJV75t9IqsmTbItYfdj99PnzYGQZkYKvan5/2jKJ7gu31J3/BJ/A18grImSPModuyG/Eo0Q==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-skip-transparent-expression-wrappers": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-skip-transparent-expression-wrappers/-/helper-skip-transparent-expression-wrappers-7.25.9.tgz", + "integrity": "sha512-K4Du3BFa3gvyhzgPcntrkDgZzQaq6uozzcpGbOO1OEJaI+EJdqWIMTLgFgQf6lrfiDFo5FU+BxKepI9RmZqahA==", + "dev": true, + "dependencies": { + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", + "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", + "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.25.9.tgz", + "integrity": "sha512-e/zv1co8pp55dNdEcCynfj9X7nyUKUXoUEwfXqaZt0omVOmDe9oOTdKStH4GmAw6zxMFs50ZayuMfHDKlO7Tfw==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-wrap-function": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/helper-wrap-function/-/helper-wrap-function-7.25.9.tgz", + "integrity": "sha512-ETzz9UTjQSTmw39GboatdymDq4XIQbR8ySgVrylRhPOFpsd+JrKHIuF0de7GCWmem+T4uC5z7EZguod7Wj4A4g==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/traverse": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.0.tgz", + "integrity": "sha512-tbhNuIxNcVb21pInl3ZSjksLCvgdZy9KwJ8brv993QtIVKJBBkYXz4q4ZbAv31GdnC+R90np23L5FbEBlthAEw==", + "dev": true, + "dependencies": { + "@babel/template": "^7.25.9", + "@babel/types": "^7.26.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.26.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.2.tgz", + "integrity": "sha512-DWMCZH9WA4Maitz2q21SRKHo9QXZxkDsbNZoVD62gusNtNBBqDg9i7uOhASfTfIGNzW+O+r7+jAlM8dwphcJKQ==", + "dev": true, + "dependencies": { + "@babel/types": "^7.26.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-firefox-class-in-computed-class-key": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-firefox-class-in-computed-class-key/-/plugin-bugfix-firefox-class-in-computed-class-key-7.25.9.tgz", + "integrity": "sha512-ZkRyVkThtxQ/J6nv3JFYv1RYY+JT5BvU0y3k5bWrmuG4woXypRa4PXmm9RhOwodRkYFWqC0C0cqcJ4OqR7kW+g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-class-field-initializer-scope": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-class-field-initializer-scope/-/plugin-bugfix-safari-class-field-initializer-scope-7.25.9.tgz", + "integrity": "sha512-MrGRLZxLD/Zjj0gdU15dfs+HH/OXvnw/U4jJD8vpcP2CJQapPEv1IWwjc/qMg7ItBlPwSv1hRBbb7LeuANdcnw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression/-/plugin-bugfix-safari-id-destructuring-collision-in-function-expression-7.25.9.tgz", + "integrity": "sha512-2qUwwfAFpJLZqxd02YW9btUCZHl+RFvdDkNfZwaIJrvB8Tesjsk8pEQkTvGwZXLqXUx/2oyY3ySRhm6HOXuCug==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining/-/plugin-bugfix-v8-spread-parameters-in-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6xWgLZTJXwilVjlnV7ospI3xi+sl8lN8rXXbBD6vYn3UYDlGsag8wrZkKcSI8G6KgqKP7vNFaDgeDnfAABq61g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.13.0" + } + }, + "node_modules/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly/-/plugin-bugfix-v8-static-class-fields-redefine-readonly-7.25.9.tgz", + "integrity": "sha512-aLnMXYPnzwwqhYSCyXfKkIkYgJ8zv9RK+roo9DkTXz38ynIhd9XCbN08s3MGvqL2MYGVUGdRQLL/JqBIeJhJBg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-proposal-private-property-in-object": { + "version": "7.21.0-placeholder-for-preset-env.2", + "resolved": "https://registry.npmjs.org/@babel/plugin-proposal-private-property-in-object/-/plugin-proposal-private-property-in-object-7.21.0-placeholder-for-preset-env.2.tgz", + "integrity": "sha512-SOSkfJDddaM7mak6cPEpswyTRnuRltl429hMraQEglW+OkovnCzsiszTmsrlY//qLFjCpQDFRvjdm2wA5pPm9w==", + "dev": true, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-assertions": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-assertions/-/plugin-syntax-import-assertions-7.26.0.tgz", + "integrity": "sha512-QCWT5Hh830hK5EQa7XzuqIkQU9tT/whqbDz7kuaZMHFl1inRRg7JnuAEOQ0Ur0QUl0NufCk1msK2BeY79Aj/eg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-import-attributes": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-import-attributes/-/plugin-syntax-import-attributes-7.26.0.tgz", + "integrity": "sha512-e2dttdsJ1ZTpi3B9UYGLw41hifAubg19AtCu/2I/F1QNVclOBr1dYpTdmdyZ84Xiz43BS/tCUkMAZNLv12Pi+A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-syntax-unicode-sets-regex": { + "version": "7.18.6", + "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-unicode-sets-regex/-/plugin-syntax-unicode-sets-regex-7.18.6.tgz", + "integrity": "sha512-727YkEAPwSIQTv5im8QHz3upqp92JTWhidIC81Tdx4VJYIte/VndKf1qKrfnnhPLiPghStWfvC/iFaMCQu7Nqg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.18.6", + "@babel/helper-plugin-utils": "^7.18.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-arrow-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.25.9.tgz", + "integrity": "sha512-6jmooXYIwn9ca5/RylZADJ+EnSxVUS5sjeJ9UPk6RWRzXCmOJCy6dqItPJFpw2cuCangPK4OYr5uhGKcmrm5Qg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-generator-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-generator-functions/-/plugin-transform-async-generator-functions-7.25.9.tgz", + "integrity": "sha512-RXV6QAzTBbhDMO9fWwOmwwTuYaiPbggWQ9INdZqAYeSHyG7FzQ+nOZaUUjNwKv9pV3aE4WFqFm1Hnbci5tBCAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-async-to-generator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-async-to-generator/-/plugin-transform-async-to-generator-7.25.9.tgz", + "integrity": "sha512-NT7Ejn7Z/LjUH0Gv5KsBCxh7BH3fbLTV0ptHvpeMvrt3cPThHfJfst9Wrb7S8EvJ7vRTFI7z+VAvFVEQn/m5zQ==", + "dev": true, + "dependencies": { + "@babel/helper-module-imports": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-remap-async-to-generator": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoped-functions": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoped-functions/-/plugin-transform-block-scoped-functions-7.25.9.tgz", + "integrity": "sha512-toHc9fzab0ZfenFpsyYinOX0J/5dgJVA2fm64xPewu7CoYHWEivIWKxkK2rMi4r3yQqLnVmheMXRdG+k239CgA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-block-scoping": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-block-scoping/-/plugin-transform-block-scoping-7.25.9.tgz", + "integrity": "sha512-1F05O7AYjymAtqbsFETboN1NvBdcnzMerO+zlMyJBEz6WkMdejvGWw9p05iTSjC85RLlBseHHQpYaM4gzJkBGg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.25.9.tgz", + "integrity": "sha512-bbMAII8GRSkcd0h0b4X+36GksxuheLFjP65ul9w6C3KgAamI3JqErNgSrosX6ZPj+Mpim5VvEbawXxJCyEUV3Q==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-class-static-block": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.26.0.tgz", + "integrity": "sha512-6J2APTs7BDDm+UMqP1useWqhcRAXo0WIoVj26N7kPFB6S73Lgvyka4KTZYIxtgYXiN5HTyRObA72N2iu628iTQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0" + } + }, + "node_modules/@babel/plugin-transform-classes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.9.tgz", + "integrity": "sha512-mD8APIXmseE7oZvZgGABDyM34GUmK45Um2TXiBUt7PnuAxrgoSVf123qUzPxEr/+/BHrRn5NMZCdE2m/1F8DGg==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9", + "@babel/traverse": "^7.25.9", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-computed-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.25.9.tgz", + "integrity": "sha512-HnBegGqXZR12xbcTHlJ9HGxw1OniltT26J5YpfruGqtUHlz/xKf/G2ak9e+t0rVqrjXa9WOhvYPz1ERfMj23AA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/template": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-destructuring": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.25.9.tgz", + "integrity": "sha512-WkCGb/3ZxXepmMiX101nnGiU+1CAdut8oHyEOHxkKuS1qKpU2SMXE2uSvfz8PBuLd49V6LEsbtyPhWC7fnkgvQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-dotall-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dotall-regex/-/plugin-transform-dotall-regex-7.25.9.tgz", + "integrity": "sha512-t7ZQ7g5trIgSRYhI9pIJtRl64KHotutUJsh4Eze5l7olJv+mRSg4/MmbZ0tv1eeqRbdvo/+trvJD/Oc5DmW2cA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-keys": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-keys/-/plugin-transform-duplicate-keys-7.25.9.tgz", + "integrity": "sha512-LZxhJ6dvBb/f3x8xwWIuyiAHy56nrRG3PeYTpBkkzkYRRQ6tJLu68lEF5VIqMUZiAV7a8+Tb78nEoMCMcqjXBw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-duplicate-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-duplicate-named-capturing-groups-regex/-/plugin-transform-duplicate-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-0UfuJS0EsXbRvKnwcLjFtJy/Sxc5J5jhLHnFhy7u4zih97Hz6tJkLU+O+FMMrNZrosUPxDi6sYxJ/EA8jDiAog==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-dynamic-import": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-dynamic-import/-/plugin-transform-dynamic-import-7.25.9.tgz", + "integrity": "sha512-GCggjexbmSLaFhqsojeugBpeaRIgWNTcgKVq/0qIteFEqY2A+b9QidYadrWlnbWQUrW5fn+mCvf3tr7OeBFTyg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-exponentiation-operator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-exponentiation-operator/-/plugin-transform-exponentiation-operator-7.25.9.tgz", + "integrity": "sha512-KRhdhlVk2nObA5AYa7QMgTMTVJdfHprfpAk4DjZVtllqRg9qarilstTKEhpVjyt+Npi8ThRyiV8176Am3CodPA==", + "dev": true, + "dependencies": { + "@babel/helper-builder-binary-assignment-operator-visitor": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-export-namespace-from": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-export-namespace-from/-/plugin-transform-export-namespace-from-7.25.9.tgz", + "integrity": "sha512-2NsEz+CxzJIVOPx2o9UsW1rXLqtChtLoVnwYHHiB04wS5sgn7mrV45fWMBX0Kk+ub9uXytVYfNP2HjbVbCB3Ww==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-for-of": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-for-of/-/plugin-transform-for-of-7.25.9.tgz", + "integrity": "sha512-LqHxduHoaGELJl2uhImHwRQudhCM50pT46rIBNvtT/Oql3nqiS3wOwP+5ten7NpYSXrrVLgtZU3DZmPtWZo16A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-function-name": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-function-name/-/plugin-transform-function-name-7.25.9.tgz", + "integrity": "sha512-8lP+Yxjv14Vc5MuWBpJsoUCd3hD6V9DgBon2FVYL4jJgbnVQ9fTgYmonchzZJOVNgzEgbxp4OwAf6xz6M/14XA==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-json-strings": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-json-strings/-/plugin-transform-json-strings-7.25.9.tgz", + "integrity": "sha512-xoTMk0WXceiiIvsaquQQUaLLXSW1KJ159KP87VilruQm0LNNGxWzahxSS6T6i4Zg3ezp4vA4zuwiNUR53qmQAw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-literals/-/plugin-transform-literals-7.25.9.tgz", + "integrity": "sha512-9N7+2lFziW8W9pBl2TzaNht3+pgMIRP74zizeCSrtnSKVdUl8mAjjOP2OOVQAfZ881P2cNjDj1uAMEdeD50nuQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-logical-assignment-operators": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-logical-assignment-operators/-/plugin-transform-logical-assignment-operators-7.25.9.tgz", + "integrity": "sha512-wI4wRAzGko551Y8eVf6iOY9EouIDTtPb0ByZx+ktDGHwv6bHFimrgJM/2T021txPZ2s4c7bqvHbd+vXG6K948Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-member-expression-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-member-expression-literals/-/plugin-transform-member-expression-literals-7.25.9.tgz", + "integrity": "sha512-PYazBVfofCQkkMzh2P6IdIUaCEWni3iYEerAsRWuVd8+jlM1S9S9cz1dF9hIzyoZ8IA3+OwVYIp9v9e+GbgZhA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-amd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-amd/-/plugin-transform-modules-amd-7.25.9.tgz", + "integrity": "sha512-g5T11tnI36jVClQlMlt4qKDLlWnG5pP9CSM4GhdRciTNMRgkfpo5cR6b4rGIOYPgRRuFAvwjPQ/Yk+ql4dyhbw==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-commonjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.25.9.tgz", + "integrity": "sha512-dwh2Ol1jWwL2MgkCzUSOvfmKElqQcuswAZypBSUsScMXvgdT8Ekq5YA6TtqpTVWH+4903NmboMuH1o9i8Rxlyg==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-simple-access": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-systemjs": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-systemjs/-/plugin-transform-modules-systemjs-7.25.9.tgz", + "integrity": "sha512-hyss7iIlH/zLHaehT+xwiymtPOpsiwIIRlCAOwBB04ta5Tt+lNItADdlXw3jAWZ96VJ2jlhl/c+PNIQPKNfvcA==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9", + "@babel/traverse": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-modules-umd": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-umd/-/plugin-transform-modules-umd-7.25.9.tgz", + "integrity": "sha512-bS9MVObUgE7ww36HEfwe6g9WakQ0KF07mQF74uuXdkoziUPfKyu/nIm663kz//e5O1nPInPFx36z7WJmJ4yNEw==", + "dev": true, + "dependencies": { + "@babel/helper-module-transforms": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-named-capturing-groups-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-named-capturing-groups-regex/-/plugin-transform-named-capturing-groups-regex-7.25.9.tgz", + "integrity": "sha512-oqB6WHdKTGl3q/ItQhpLSnWWOpjUJLsOCLVyeFgeTktkBSCiurvPOsyt93gibI9CmuKvTUEtWmG5VhZD+5T/KA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-new-target": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-new-target/-/plugin-transform-new-target-7.25.9.tgz", + "integrity": "sha512-U/3p8X1yCSoKyUj2eOBIx3FOn6pElFOKvAAGf8HTtItuPyB+ZeOqfn+mvTtg9ZlOAjsPdK3ayQEjqHjU/yLeVQ==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-nullish-coalescing-operator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.25.9.tgz", + "integrity": "sha512-ENfftpLZw5EItALAD4WsY/KUWvhUlZndm5GC7G3evUsVeSJB6p0pBeLQUnRnBCBx7zV0RKQjR9kCuwrsIrjWog==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-numeric-separator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.25.9.tgz", + "integrity": "sha512-TlprrJ1GBZ3r6s96Yq8gEQv82s8/5HnCVHtEJScUj90thHQbwe+E5MLhi2bbNHBEJuzrvltXSru+BUxHDoog7Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-rest-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-rest-spread/-/plugin-transform-object-rest-spread-7.25.9.tgz", + "integrity": "sha512-fSaXafEE9CVHPweLYw4J0emp1t8zYTXyzN3UuG+lylqkvYd7RMrsOQ8TYx5RF231be0vqtFC6jnx3UmpJmKBYg==", + "dev": true, + "dependencies": { + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-object-super": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-object-super/-/plugin-transform-object-super-7.25.9.tgz", + "integrity": "sha512-Kj/Gh+Rw2RNLbCK1VAWj2U48yxxqL2x0k10nPtSdRa0O2xnHXalD0s+o1A6a0W43gJ00ANo38jxkQreckOzv5A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-replace-supers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-catch-binding": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-catch-binding/-/plugin-transform-optional-catch-binding-7.25.9.tgz", + "integrity": "sha512-qM/6m6hQZzDcZF3onzIhZeDHDO43bkNNlOX0i8n3lR6zLbu0GN2d8qfM/IERJZYauhAHSLHy39NF0Ctdvcid7g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-optional-chaining": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.25.9.tgz", + "integrity": "sha512-6AvV0FsLULbpnXeBjrY4dmWF8F7gf8QnvTEoO/wX/5xm/xE1Xo8oPuD3MPS+KS9f9XBEAWN7X1aWr4z9HdOr7A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-parameters": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.25.9.tgz", + "integrity": "sha512-wzz6MKwpnshBAiRmn4jR8LYz/g8Ksg0o80XmwZDlordjwEk9SxBzTWC7F5ef1jhbrbOW2DJ5J6ayRukrJmnr0g==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-methods": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-methods/-/plugin-transform-private-methods-7.25.9.tgz", + "integrity": "sha512-D/JUozNpQLAPUVusvqMxyvjzllRaF8/nSrP1s2YGQT/W4LHK4xxsMcHjhOGTS01mp9Hda8nswb+FblLdJornQw==", + "dev": true, + "dependencies": { + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-private-property-in-object": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-private-property-in-object/-/plugin-transform-private-property-in-object-7.25.9.tgz", + "integrity": "sha512-Evf3kcMqzXA3xfYJmZ9Pg1OvKdtqsDMSWBDzZOPLvHiTt36E75jLDQo5w1gtRU95Q4E5PDttrTf25Fw8d/uWLw==", + "dev": true, + "dependencies": { + "@babel/helper-annotate-as-pure": "^7.25.9", + "@babel/helper-create-class-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-property-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-property-literals/-/plugin-transform-property-literals-7.25.9.tgz", + "integrity": "sha512-IvIUeV5KrS/VPavfSM/Iu+RE6llrHrYIKY1yfCzyO/lMXHQ+p7uGhonmGVisv6tSBSVgWzMBohTcvkC9vQcQFA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regenerator": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regenerator/-/plugin-transform-regenerator-7.25.9.tgz", + "integrity": "sha512-vwDcDNsgMPDGP0nMqzahDWE5/MLcX8sv96+wfX7as7LoF/kr97Bo/7fI00lXY4wUXYfVmwIIyG80fGZ1uvt2qg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "regenerator-transform": "^0.15.2" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-regexp-modifiers": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-regexp-modifiers/-/plugin-transform-regexp-modifiers-7.26.0.tgz", + "integrity": "sha512-vN6saax7lrA2yA/Pak3sCxuD6F5InBjn9IcrIKQPjpsLvuHYLVroTxjdlVRHjjBWxKOqIwpTXDkOssYT4BFdRw==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/plugin-transform-reserved-words": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-reserved-words/-/plugin-transform-reserved-words-7.25.9.tgz", + "integrity": "sha512-7DL7DKYjn5Su++4RXu8puKZm2XBPHyjWLUidaPEkCUBbE7IPcsrkRHggAOOKydH1dASWdcUBxrkOGNxUv5P3Jg==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-shorthand-properties": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.25.9.tgz", + "integrity": "sha512-MUv6t0FhO5qHnS/W8XCbHmiRWOphNufpE1IVxhK5kuN3Td9FT1x4rx4K42s3RYdMXCXpfWkGSbCSd0Z64xA7Ng==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-spread": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-spread/-/plugin-transform-spread-7.25.9.tgz", + "integrity": "sha512-oNknIB0TbURU5pqJFVbOOFspVlrpVwo2H1+HUIsVDvp5VauGGDP1ZEvO8Nn5xyMEs3dakajOxlmkNW7kNgSm6A==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-skip-transparent-expression-wrappers": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-sticky-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-sticky-regex/-/plugin-transform-sticky-regex-7.25.9.tgz", + "integrity": "sha512-WqBUSgeVwucYDP9U/xNRQam7xV8W5Zf+6Eo7T2SRVUFlhRiMNFdFz58u0KZmCVVqs2i7SHgpRnAhzRNmKfi2uA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-template-literals": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.25.9.tgz", + "integrity": "sha512-o97AE4syN71M/lxrCtQByzphAdlYluKPDBzDVzMmfCobUjjhAryZV0AIpRPrxN0eAkxXO6ZLEScmt+PNhj2OTw==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-typeof-symbol": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.25.9.tgz", + "integrity": "sha512-v61XqUMiueJROUv66BVIOi0Fv/CUuZuZMl5NkRoCVxLAnMexZ0A3kMe7vvZ0nulxMuMp0Mk6S5hNh48yki08ZA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-escapes": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-escapes/-/plugin-transform-unicode-escapes-7.25.9.tgz", + "integrity": "sha512-s5EDrE6bW97LtxOcGj1Khcx5AaXwiMmi4toFWRDP9/y0Woo6pXC+iyPu/KuhKtfSrNFd7jJB+/fkOtZy6aIC6Q==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-property-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-property-regex/-/plugin-transform-unicode-property-regex-7.25.9.tgz", + "integrity": "sha512-Jt2d8Ga+QwRluxRQ307Vlxa6dMrYEMZCgGxoPR8V52rxPyldHu3hdlHspxaqYmE7oID5+kB+UKUB/eWS+DkkWg==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.25.9.tgz", + "integrity": "sha512-yoxstj7Rg9dlNn9UQxzk4fcNivwv4nUYz7fYXBaKxvw/lnmPuOm/ikoELygbYq68Bls3D/D+NBPHiLwZdZZ4HA==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-unicode-sets-regex": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-unicode-sets-regex/-/plugin-transform-unicode-sets-regex-7.25.9.tgz", + "integrity": "sha512-8BYqO3GeVNHtx69fdPshN3fnzUNLrWdHhk/icSwigksJGczKSizZ+Z6SBCxTs723Fr5VSNorTIK7a+R2tISvwQ==", + "dev": true, + "dependencies": { + "@babel/helper-create-regexp-features-plugin": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/preset-env": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.26.0.tgz", + "integrity": "sha512-H84Fxq0CQJNdPFT2DrfnylZ3cf5K43rGfWK4LJGPpjKHiZlk0/RzwEus3PDDZZg+/Er7lCA03MVacueUuXdzfw==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.26.0", + "@babel/helper-compilation-targets": "^7.25.9", + "@babel/helper-plugin-utils": "^7.25.9", + "@babel/helper-validator-option": "^7.25.9", + "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.25.9", + "@babel/plugin-bugfix-safari-class-field-initializer-scope": "^7.25.9", + "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.25.9", + "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.25.9", + "@babel/plugin-bugfix-v8-static-class-fields-redefine-readonly": "^7.25.9", + "@babel/plugin-proposal-private-property-in-object": "7.21.0-placeholder-for-preset-env.2", + "@babel/plugin-syntax-import-assertions": "^7.26.0", + "@babel/plugin-syntax-import-attributes": "^7.26.0", + "@babel/plugin-syntax-unicode-sets-regex": "^7.18.6", + "@babel/plugin-transform-arrow-functions": "^7.25.9", + "@babel/plugin-transform-async-generator-functions": "^7.25.9", + "@babel/plugin-transform-async-to-generator": "^7.25.9", + "@babel/plugin-transform-block-scoped-functions": "^7.25.9", + "@babel/plugin-transform-block-scoping": "^7.25.9", + "@babel/plugin-transform-class-properties": "^7.25.9", + "@babel/plugin-transform-class-static-block": "^7.26.0", + "@babel/plugin-transform-classes": "^7.25.9", + "@babel/plugin-transform-computed-properties": "^7.25.9", + "@babel/plugin-transform-destructuring": "^7.25.9", + "@babel/plugin-transform-dotall-regex": "^7.25.9", + "@babel/plugin-transform-duplicate-keys": "^7.25.9", + "@babel/plugin-transform-duplicate-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-dynamic-import": "^7.25.9", + "@babel/plugin-transform-exponentiation-operator": "^7.25.9", + "@babel/plugin-transform-export-namespace-from": "^7.25.9", + "@babel/plugin-transform-for-of": "^7.25.9", + "@babel/plugin-transform-function-name": "^7.25.9", + "@babel/plugin-transform-json-strings": "^7.25.9", + "@babel/plugin-transform-literals": "^7.25.9", + "@babel/plugin-transform-logical-assignment-operators": "^7.25.9", + "@babel/plugin-transform-member-expression-literals": "^7.25.9", + "@babel/plugin-transform-modules-amd": "^7.25.9", + "@babel/plugin-transform-modules-commonjs": "^7.25.9", + "@babel/plugin-transform-modules-systemjs": "^7.25.9", + "@babel/plugin-transform-modules-umd": "^7.25.9", + "@babel/plugin-transform-named-capturing-groups-regex": "^7.25.9", + "@babel/plugin-transform-new-target": "^7.25.9", + "@babel/plugin-transform-nullish-coalescing-operator": "^7.25.9", + "@babel/plugin-transform-numeric-separator": "^7.25.9", + "@babel/plugin-transform-object-rest-spread": "^7.25.9", + "@babel/plugin-transform-object-super": "^7.25.9", + "@babel/plugin-transform-optional-catch-binding": "^7.25.9", + "@babel/plugin-transform-optional-chaining": "^7.25.9", + "@babel/plugin-transform-parameters": "^7.25.9", + "@babel/plugin-transform-private-methods": "^7.25.9", + "@babel/plugin-transform-private-property-in-object": "^7.25.9", + "@babel/plugin-transform-property-literals": "^7.25.9", + "@babel/plugin-transform-regenerator": "^7.25.9", + "@babel/plugin-transform-regexp-modifiers": "^7.26.0", + "@babel/plugin-transform-reserved-words": "^7.25.9", + "@babel/plugin-transform-shorthand-properties": "^7.25.9", + "@babel/plugin-transform-spread": "^7.25.9", + "@babel/plugin-transform-sticky-regex": "^7.25.9", + "@babel/plugin-transform-template-literals": "^7.25.9", + "@babel/plugin-transform-typeof-symbol": "^7.25.9", + "@babel/plugin-transform-unicode-escapes": "^7.25.9", + "@babel/plugin-transform-unicode-property-regex": "^7.25.9", + "@babel/plugin-transform-unicode-regex": "^7.25.9", + "@babel/plugin-transform-unicode-sets-regex": "^7.25.9", + "@babel/preset-modules": "0.1.6-no-external-plugins", + "babel-plugin-polyfill-corejs2": "^0.4.10", + "babel-plugin-polyfill-corejs3": "^0.10.6", + "babel-plugin-polyfill-regenerator": "^0.6.1", + "core-js-compat": "^3.38.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/preset-modules": { + "version": "0.1.6-no-external-plugins", + "resolved": "https://registry.npmjs.org/@babel/preset-modules/-/preset-modules-0.1.6-no-external-plugins.tgz", + "integrity": "sha512-HrcgcIESLm9aIR842yhJ5RWan/gebQUJ6E/E5+rf0y9o6oj7w0Br+sWuL6kEQ/o/AdfvR1Je9jG18/gnpwjEyA==", + "dev": true, + "dependencies": { + "@babel/helper-plugin-utils": "^7.0.0", + "@babel/types": "^7.4.4", + "esutils": "^2.0.2" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.0.tgz", + "integrity": "sha512-FDSOghenHTiToteC/QRlv2q3DhPZ/oOXTBoirfWNx1Cx3TMVcGWQtMMmQcSvb/JjpNeGzx8Pq/b4fKEJuWm1sw==", + "dev": true, + "dependencies": { + "regenerator-runtime": "^0.14.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", + "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/types": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.25.9", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.9.tgz", + "integrity": "sha512-ZCuvfwOwlz/bawvAuvcj8rrithP2/N55Tzz342AkTvq4qaWbGfmCk/tKhNaV2cthijKrPAA8SRJV5WWe7IBMJw==", + "dev": true, + "dependencies": { + "@babel/code-frame": "^7.25.9", + "@babel/generator": "^7.25.9", + "@babel/parser": "^7.25.9", + "@babel/template": "^7.25.9", + "@babel/types": "^7.25.9", + "debug": "^4.3.1", + "globals": "^11.1.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.26.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.0.tgz", + "integrity": "sha512-Z/yiTPj+lDVnF7lWeKCIJzaIkI0vYO87dMpZ4bg4TDrFe4XXLFWL1TbXU27gBP3QccxV9mZICCrnjnYlJjXHOA==", + "dev": true, + "dependencies": { + "@babel/helper-string-parser": "^7.25.9", + "@babel/helper-validator-identifier": "^7.25.9" + }, + "engines": { + "node": ">=6.9.0" } }, "node_modules/@bufbuild/protobuf": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.9.0.tgz", - "integrity": "sha512-W7gp8Q/v1NlCZLsv8pQ3Y0uCu/SHgXOVFK+eUluUKWXmsb6VHkpNx0apdOWWcDbB9sJoKeP8uPrjmehJz6xETQ==", + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@bufbuild/protobuf/-/protobuf-1.10.0.tgz", + "integrity": "sha512-QDdVFLoN93Zjg36NoQPZfsVH9tZew7wKDKyV5qRdj8ntT4wQCOradQjRaTdwMhWUYsgKsvCINKKm87FdEk96Ag==", "dev": true }, "node_modules/@gulp-sourcemaps/identity-map": { @@ -97,6 +1553,64 @@ "node": ">=0.10.0" } }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.5.tgz", + "integrity": "sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==", + "dev": true, + "dependencies": { + "@jridgewell/set-array": "^1.2.1", + "@jridgewell/sourcemap-codec": "^1.4.10", + "@jridgewell/trace-mapping": "^0.3.24" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/set-array": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@jridgewell/set-array/-/set-array-1.2.1.tgz", + "integrity": "sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==", + "dev": true, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.6", + "resolved": "https://registry.npmjs.org/@jridgewell/source-map/-/source-map-0.3.6.tgz", + "integrity": "sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==", + "dev": true, + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz", + "integrity": "sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==", + "dev": true + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.25", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.25.tgz", + "integrity": "sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==", + "dev": true, + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, "node_modules/@nodelib/fs.scandir": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", @@ -132,19 +1646,333 @@ "node": ">= 8" } }, + "node_modules/@parcel/watcher": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher/-/watcher-2.5.0.tgz", + "integrity": "sha512-i0GV1yJnm2n3Yq1qw6QrUrd/LI9bE8WEBOTtOkpCXHHdyN3TAGgqAK/DAT05z4fq2x04cARXt2pDmjWjL92iTQ==", + "hasInstallScript": true, + "optional": true, + "dependencies": { + "detect-libc": "^1.0.3", + "is-glob": "^4.0.3", + "micromatch": "^4.0.5", + "node-addon-api": "^7.0.0" + }, + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "@parcel/watcher-android-arm64": "2.5.0", + "@parcel/watcher-darwin-arm64": "2.5.0", + "@parcel/watcher-darwin-x64": "2.5.0", + "@parcel/watcher-freebsd-x64": "2.5.0", + "@parcel/watcher-linux-arm-glibc": "2.5.0", + "@parcel/watcher-linux-arm-musl": "2.5.0", + "@parcel/watcher-linux-arm64-glibc": "2.5.0", + "@parcel/watcher-linux-arm64-musl": "2.5.0", + "@parcel/watcher-linux-x64-glibc": "2.5.0", + "@parcel/watcher-linux-x64-musl": "2.5.0", + "@parcel/watcher-win32-arm64": "2.5.0", + "@parcel/watcher-win32-ia32": "2.5.0", + "@parcel/watcher-win32-x64": "2.5.0" + } + }, + "node_modules/@parcel/watcher-android-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.0.tgz", + "integrity": "sha512-qlX4eS28bUcQCdribHkg/herLe+0A9RyYC+mm2PXpncit8z5b3nSqGVzMNR3CmtAOgRutiZ02eIJJgP/b1iEFQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.0.tgz", + "integrity": "sha512-hyZ3TANnzGfLpRA2s/4U1kbw2ZI4qGxaRJbBH2DCSREFfubMswheh8TeiC1sGZ3z2jUf3s37P0BBlrD3sjVTUw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-darwin-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.0.tgz", + "integrity": "sha512-9rhlwd78saKf18fT869/poydQK8YqlU26TMiNg7AIu7eBp9adqbJZqmdFOsbZ5cnLp5XvRo9wcFmNHgHdWaGYA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-freebsd-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.0.tgz", + "integrity": "sha512-syvfhZzyM8kErg3VF0xpV8dixJ+RzbUaaGaeb7uDuz0D3FK97/mZ5AJQ3XNnDsXX7KkFNtyQyFrXZzQIcN49Tw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.0.tgz", + "integrity": "sha512-0VQY1K35DQET3dVYWpOaPFecqOT9dbuCfzjxoQyif1Wc574t3kOSkKevULddcR9znz1TcklCE7Ht6NIxjvTqLA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.0.tgz", + "integrity": "sha512-6uHywSIzz8+vi2lAzFeltnYbdHsDm3iIB57d4g5oaB9vKwjb6N6dRIgZMujw4nm5r6v9/BQH0noq6DzHrqr2pA==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.0.tgz", + "integrity": "sha512-BfNjXwZKxBy4WibDb/LDCriWSKLz+jJRL3cM/DllnHH5QUyoiUNEp3GmL80ZqxeumoADfCCP19+qiYiC8gUBjA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-arm64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.0.tgz", + "integrity": "sha512-S1qARKOphxfiBEkwLUbHjCY9BWPdWnW9j7f7Hb2jPplu8UZ3nes7zpPOW9bkLbHRvWM0WDTsjdOTUgW0xLBN1Q==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-glibc": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.0.tgz", + "integrity": "sha512-d9AOkusyXARkFD66S6zlGXyzx5RvY+chTP9Jp0ypSTC9d4lzyRs9ovGf/80VCxjKddcUvnsGwCHWuF2EoPgWjw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-linux-x64-musl": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.0.tgz", + "integrity": "sha512-iqOC+GoTDoFyk/VYSFHwjHhYrk8bljW6zOhPuhi5t9ulqiYq1togGJB5e3PwYVFFfeVgc6pbz3JdQyDoBszVaA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-arm64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.0.tgz", + "integrity": "sha512-twtft1d+JRNkM5YbmexfcH/N4znDtjgysFaV9zvZmmJezQsKpkfLYJ+JFV3uygugK6AtIM2oADPkB2AdhBrNig==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-ia32": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.0.tgz", + "integrity": "sha512-+rgpsNRKwo8A53elqbbHXdOMtY/tAtTzManTWShB5Kk54N8Q9mzNWV7tV+IbGueCbcj826MfWGU3mprWtuf1TA==", + "cpu": [ + "ia32" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@parcel/watcher-win32-x64": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.0.tgz", + "integrity": "sha512-lPrxve92zEHdgeff3aiu4gDOIt4u7sJYha6wbdEZDCDUhtjTsOMiaJzG5lMY4GkWH8p0fMmO2Ppq5G5XXG+DQw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "resolved": "https://registry.npmjs.org/@types/eslint/-/eslint-9.6.1.tgz", + "integrity": "sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==", + "dev": true, + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "resolved": "https://registry.npmjs.org/@types/eslint-scope/-/eslint-scope-3.7.7.tgz", + "integrity": "sha512-MzMFlSLBqNF2gcHWO0G1vP/YQyfvrxZ0bF+u7mzUdZ1/xK4A4sru+nraZz5i3iEIk1l1uyicaDVTB4QbbEkAYg==", + "dev": true, + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.6.tgz", + "integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==", + "dev": true + }, "node_modules/@types/expect": { "version": "1.20.4", "resolved": "https://registry.npmjs.org/@types/expect/-/expect-1.20.4.tgz", "integrity": "sha512-Q5Vn3yjTDyCMV50TB6VRIbQNxSE4OmZR86VSbGaNpfUolm0iePBB4KdEEHmxoY5sT2+2DIvXW0rvMDP2nHZ4Mg==", "dev": true }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true + }, "node_modules/@types/node": { - "version": "20.12.12", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.12.tgz", - "integrity": "sha512-eWLDGF/FOSPtAvEqeRAQ4C8LSA7M1I7i0ky1I8U7kD1J5ITyW3AsRhQrKVoWf5pFKZ2kILsEGJhsI9r93PYnOw==", + "version": "22.9.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.9.0.tgz", + "integrity": "sha512-vuyHg81vvWA1Z1ELfvLko2c8f34gyA0zaic0+Rllc5lbCnbSyuvb2Oxpm6TAUAC/2xZN3QGqxBNggD1nNR2AfQ==", "devOptional": true, "dependencies": { - "undici-types": "~5.26.4" + "undici-types": "~6.19.8" } }, "node_modules/@types/vinyl": { @@ -190,7 +2018,6 @@ "version": "3.8.1", "resolved": "https://registry.npmjs.org/@uswds/uswds/-/uswds-3.8.1.tgz", "integrity": "sha512-bKG/B9mJF1v0yoqth48wQDzST5Xyu3OxxpePIPDyhKWS84oDrCehnu3Z88JhSjdIAJMl8dtjtH8YvdO9kZUpAg==", - "license": "SEE LICENSE IN LICENSE.md", "dependencies": { "classlist-polyfill": "1.2.0", "object-assign": "4.1.1", @@ -201,6 +2028,164 @@ "node": ">= 4" } }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ast/-/ast-1.14.1.tgz", + "integrity": "sha512-nuBEDgQfm1ccRp/8bCQrx1frohyufl4JlbMMZ4P1wpeOfDhF6FQkxZJ1b/e+PLwr6X1Nhw6OLme5usuBWYBvuQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/floating-point-hex-parser/-/floating-point-hex-parser-1.13.2.tgz", + "integrity": "sha512-6oXyTOzbKxGH4steLbLNOu71Oj+C8Lg34n6CqRvqfS2O71BxY6ByfMDRhBytzknj9yGUPVJ1qIKhRlAwO1AovA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-api-error/-/helper-api-error-1.13.2.tgz", + "integrity": "sha512-U56GMYxy4ZQCbDZd6JuvvNV/WFildOjsaWD3Tzzvmw/mas3cXzRJPMjP83JqEsgSbyrmaGjBfDtV7KDXV9UzFQ==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-buffer/-/helper-buffer-1.14.1.tgz", + "integrity": "sha512-jyH7wtcHiKssDtFPRB+iQdxlDf96m0E39yb0k5uJVhFGleZFoNw1c4aeIcVUPPbXUVJ94wwnMOAqUHyzoEPVMA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-numbers/-/helper-numbers-1.13.2.tgz", + "integrity": "sha512-FE8aCmS5Q6eQYcV3gI35O4J789wlQA+7JrqTTpJqn5emA4U2hvwJmvFRC0HODS+3Ye6WioDklgd6scJ3+PLnEA==", + "dev": true, + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-bytecode/-/helper-wasm-bytecode-1.13.2.tgz", + "integrity": "sha512-3QbLKy93F0EAIXLh0ogEVR6rOubA9AoZ+WRYhNbFyuB70j3dRdwH9g+qXhLAO0kiYGlg3TxDV+I4rQTr/YNXkA==", + "dev": true + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/helper-wasm-section/-/helper-wasm-section-1.14.1.tgz", + "integrity": "sha512-ds5mXEqTJ6oxRoqjhWDU83OgzAYjwsCV8Lo/N+oRsNDmx/ZDpqalmrtgOMkHwxsG0iI//3BwWAErYRHtgn0dZw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/ieee754/-/ieee754-1.13.2.tgz", + "integrity": "sha512-4LtOzh58S/5lX4ITKxnAK2USuNEvpdVV9AlgGQb8rJDHaLeHciwG4zlGr0j/SNWlr7x3vO1lDEsuePvtcDNCkw==", + "dev": true, + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/leb128/-/leb128-1.13.2.tgz", + "integrity": "sha512-Lde1oNoIdzVzdkNEAWZ1dZ5orIbff80YPdHx20mrHwHrVNNTjNr8E3xz9BdpcGqRQbAEa+fkrCb+fRFTl/6sQw==", + "dev": true, + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "resolved": "https://registry.npmjs.org/@webassemblyjs/utf8/-/utf8-1.13.2.tgz", + "integrity": "sha512-3NQWGjKTASY1xV5m7Hr0iPeXD9+RDobLll3T9d2AO+g3my8xy5peVyjSag4I50mR1bBSN/Ct12lo+R9tJk0NZQ==", + "dev": true + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-edit/-/wasm-edit-1.14.1.tgz", + "integrity": "sha512-RNJUIQH/J8iA/1NzlE4N7KtyZNHi3w7at7hDjvRNm5rcUXa00z1vRz3glZoULfJ5mpvYhLybmVcwcjGrC1pRrQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-gen/-/wasm-gen-1.14.1.tgz", + "integrity": "sha512-AmomSIjP8ZbfGQhumkNvgC33AY7qtMCXnN6bL2u2Js4gVCg8fp735aEiMSBbDR7UQIj90n4wKAFUSEd0QN2Ukg==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-opt/-/wasm-opt-1.14.1.tgz", + "integrity": "sha512-PTcKLUNvBqnY2U6E5bdOQcSM+oVP/PmrDY9NzowJjislEjwP/C4an2303MCVS2Mg9d3AJpIGdUFIQQWbPds0Sw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wasm-parser/-/wasm-parser-1.14.1.tgz", + "integrity": "sha512-JLBl+KZ0R5qB7mCnud/yyX08jWFw5MsoalJ1pQ4EdFlgj9VdXKGuENGsiCIjegI1W7p91rUlcB/LB5yRJKNTcQ==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@webassemblyjs/wast-printer/-/wast-printer-1.14.1.tgz", + "integrity": "sha512-kPSSXE6De1XOR820C90RIo2ogvZG+c3KiHzqUoO/F34Y2shGzesfqv7o57xrxovZJH/MetF5UjroJ/R/3isoiw==", + "dev": true, + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@xtuc/ieee754/-/ieee754-1.2.0.tgz", + "integrity": "sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==", + "dev": true + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "resolved": "https://registry.npmjs.org/@xtuc/long/-/long-4.2.2.tgz", + "integrity": "sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==", + "dev": true + }, "node_modules/acorn": { "version": "6.4.2", "resolved": "https://registry.npmjs.org/acorn/-/acorn-6.4.2.tgz", @@ -224,27 +2209,6 @@ "node": ">= 6.0.0" } }, - "node_modules/agent-base/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/agent-base/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -258,6 +2222,51 @@ "node": ">=8" } }, + "node_modules/ajv": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-2.1.1.tgz", + "integrity": "sha512-Wx0Kx52hxE7C18hkMEggYlEifqWZtYaRgouJor+WMdPnQyEK13vgEWyVNup7SoeeoLMsr4kf5h6dOW11I15MUA==", + "dev": true, + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-5.1.0.tgz", + "integrity": "sha512-YCS/JNFAUyr5vAuhk1DWm1CBxRHW9LbJ2ozWeemrIqpbsqKjHVxYPyi5GC0rjZIT5JxJ3virVTS8wk4i/Z+krw==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, "node_modules/ansi-colors": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/ansi-colors/-/ansi-colors-1.1.0.tgz", @@ -701,6 +2710,62 @@ "node": ">=4" } }, + "node_modules/babel-loader": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/babel-loader/-/babel-loader-9.2.1.tgz", + "integrity": "sha512-fqe8naHt46e0yIdkjUZYqddSXfej3AHajX+CSO5X7oy0EmPc6o5Xh+RClNoHjnieWz9AW4kZxW9yyFMhVB1QLA==", + "dev": true, + "dependencies": { + "find-cache-dir": "^4.0.0", + "schema-utils": "^4.0.0" + }, + "engines": { + "node": ">= 14.15.0" + }, + "peerDependencies": { + "@babel/core": "^7.12.0", + "webpack": ">=5" + } + }, + "node_modules/babel-plugin-polyfill-corejs2": { + "version": "0.4.12", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.12.tgz", + "integrity": "sha512-CPWT6BwvhrTO2d8QVorhTCQw9Y43zOu7G9HigcfxvepOU6b8o3tcWad6oVgZIsZCTt42FFv97aA7ZJsbM4+8og==", + "dev": true, + "dependencies": { + "@babel/compat-data": "^7.22.6", + "@babel/helper-define-polyfill-provider": "^0.6.3", + "semver": "^6.3.1" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-corejs3": { + "version": "0.10.6", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.10.6.tgz", + "integrity": "sha512-b37+KR2i/khY5sKmWNVQAnitvquQbNdWy6lJdsr0kmquCKEEUgMKK4SboVM3HtfnZilfjr4MMQ7vY58FVWDtIA==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.2", + "core-js-compat": "^3.38.0" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, + "node_modules/babel-plugin-polyfill-regenerator": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-regenerator/-/babel-plugin-polyfill-regenerator-0.6.3.tgz", + "integrity": "sha512-LiWSbl4CRSIa5x/JAU6jZiG9eit9w6mz+yVMFwDE83LAWvt0AfGBoZ7HS/mkhrKuh2ZlzfVZYKoLjXdqw6Yt7Q==", + "dev": true, + "dependencies": { + "@babel/helper-define-polyfill-provider": "^0.6.3" + }, + "peerDependencies": { + "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" + } + }, "node_modules/bach": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/bach/-/bach-1.2.0.tgz", @@ -897,9 +2962,9 @@ } }, "node_modules/browserslist": { - "version": "4.23.0", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", - "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "version": "4.24.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.24.2.tgz", + "integrity": "sha512-ZIc+Q62revdMcqC6aChtW4jz3My3klmCO1fEmINZY/8J3EpBg5/A/D0AKmBveUh6pgoeycoMkVMko84tuYS+Gg==", "dev": true, "funding": [ { @@ -916,10 +2981,10 @@ } ], "dependencies": { - "caniuse-lite": "^1.0.30001587", - "electron-to-chromium": "^1.4.668", - "node-releases": "^2.0.14", - "update-browserslist-db": "^1.0.13" + "caniuse-lite": "^1.0.30001669", + "electron-to-chromium": "^1.5.41", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.1" }, "bin": { "browserslist": "cli.js" @@ -1032,9 +3097,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001620", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001620.tgz", - "integrity": "sha512-WJvYsOjd1/BYUY6SNGUosK9DUidBPDTnOARHp3fSmFO1ekdxaY6nKRttEVrfMmYi80ctS0kz1wiWmm14fVc3ew==", + "version": "1.0.30001680", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001680.tgz", + "integrity": "sha512-rPQy70G6AGUMnbwS1z6Xg+RkHYPAi18ihs47GH0jcxIG7wArmPgY3XbS2sRdBbxJljp3thdT8BIqv9ccCypiPA==", "dev": true, "funding": [ { @@ -1057,20 +3122,24 @@ "integrity": "sha512-+67P1GkJRaxQD6PKK0Et9DhwQB+vGg3PM5+aavopCpZT1lj9jeqfvpgTLAWErNj8qApkkmXlu/Ug74kmhagkXg==" }, "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">= 6" + "node": ">=18.17" }, "funding": { "url": "https://github.com/cheeriojs/cheerio?sponsor=1" @@ -1096,7 +3165,6 @@ "version": "2.1.8", "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-2.1.8.tgz", "integrity": "sha512-ZmZUazfOzf0Nve7duiCKD23PFSCs4JPoYyccjUFF3aQkQadqBhfzhjkwBH2mNOG9cTBwhamM37EIsIkZw3nRgg==", - "deprecated": "Chokidar 2 does not receive security updates since 2019. Upgrade to chokidar 3 with 15x fewer dependencies", "dev": true, "dependencies": { "anymatch": "^2.0.0", @@ -1142,6 +3210,15 @@ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz", "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==" }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/chrome-trace-event/-/chrome-trace-event-1.0.4.tgz", + "integrity": "sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==", + "dev": true, + "engines": { + "node": ">=6.0" + } + }, "node_modules/class-utils": { "version": "0.3.6", "resolved": "https://registry.npmjs.org/class-utils/-/class-utils-0.3.6.tgz", @@ -1291,6 +3368,12 @@ "node": ">= 6" } }, + "node_modules/common-path-prefix": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/common-path-prefix/-/common-path-prefix-3.0.0.tgz", + "integrity": "sha512-QE33hToZseCH3jS0qN96O/bSh3kaw/h+Tq7ngyY9eWDUnTlTNUyqfqvCXioLe5Na5jFsL78ra/wuBU4iuEgd4w==", + "dev": true + }, "node_modules/component-emitter": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.1.tgz", @@ -1321,9 +3404,9 @@ } }, "node_modules/convert-source-map": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", - "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", "dev": true }, "node_modules/copy-descriptor": { @@ -1345,6 +3428,19 @@ "is-plain-object": "^5.0.0" } }, + "node_modules/core-js-compat": { + "version": "3.39.0", + "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.39.0.tgz", + "integrity": "sha512-VgEUx3VwlExr5no0tXlBt+silBvhTryPwCXRI2Id1PN8WTKu7MreethvddqOubrYxkFdv/RnYrqlv1sFNAUelw==", + "dev": true, + "dependencies": { + "browserslist": "^4.24.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-util-is": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz", @@ -1429,12 +3525,19 @@ } }, "node_modules/debug": { - "version": "3.2.7", - "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", - "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", - "dev": true, + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", "dependencies": { - "ms": "^2.1.1" + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } } }, "node_modules/debug-fabulous": { @@ -1448,6 +3551,15 @@ "object-assign": "4.X" } }, + "node_modules/debug-fabulous/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "dependencies": { + "ms": "^2.1.1" + } + }, "node_modules/decamelize": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", @@ -1564,6 +3676,18 @@ "node": ">=0.10.0" } }, + "node_modules/detect-libc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-1.0.3.tgz", + "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==", + "optional": true, + "bin": { + "detect-libc": "bin/detect-libc.js" + }, + "engines": { + "node": ">=0.10" + } + }, "node_modules/detect-newline": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-2.1.0.tgz", @@ -1676,9 +3800,9 @@ } }, "node_modules/electron-to-chromium": { - "version": "1.4.773", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.773.tgz", - "integrity": "sha512-87eHF+h3PlCRwbxVEAw9KtK3v7lWfc/sUDr0W76955AdYTG4bV/k0zrl585Qnj/skRMH2qOSiE+kqMeOQ+LOpw==", + "version": "1.5.63", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.63.tgz", + "integrity": "sha512-ddeXKuY9BHo/mw145axlyWjlJ1UBt4WK3AlvkT7W2AbqfRQoacVoRUCF6wL3uIx/8wT9oLKXzI+rFqHHscByaA==", "dev": true }, "node_modules/element-closest": { @@ -1689,6 +3813,18 @@ "node": ">=4.0.0" } }, + "node_modules/encoding-sniffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", + "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, "node_modules/end-of-stream": { "version": "1.4.4", "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", @@ -1697,6 +3833,19 @@ "once": "^1.4.0" } }, + "node_modules/enhanced-resolve": { + "version": "5.17.1", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.17.1.tgz", + "integrity": "sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==", + "dev": true, + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.2.0" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/entities": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", @@ -1719,6 +3868,18 @@ "node": ">=4" } }, + "node_modules/errno": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/errno/-/errno-0.1.8.tgz", + "integrity": "sha512-dJ6oBr5SQ1VSd9qkk7ByRgb/1SH4JZjCHSW/mr63/QcXO9zLVxvJ6Oy13nio03rxpSnVDDjFor75SjVeZWPW/A==", + "dev": true, + "dependencies": { + "prr": "~1.0.1" + }, + "bin": { + "errno": "cli.js" + } + }, "node_modules/error-ex": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.2.tgz", @@ -1749,6 +3910,12 @@ "node": ">= 0.4" } }, + "node_modules/es-module-lexer": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.5.4.tgz", + "integrity": "sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==", + "dev": true + }, "node_modules/es5-ext": { "version": "0.10.64", "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", @@ -1802,9 +3969,9 @@ } }, "node_modules/escalade": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.1.2.tgz", - "integrity": "sha512-ErCHMCae19vR8vQGe50xIsVomy19rg6gFu3+r3jkEO46suLMWBksvVyoGgQV+jOfl84ZSOSlmv6Gxa89PmTGmA==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", "dev": true, "engines": { "node": ">=6" @@ -1819,6 +3986,19 @@ "node": ">=0.8.0" } }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-5.1.1.tgz", + "integrity": "sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==", + "dev": true, + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/esniff": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", @@ -1834,6 +4014,45 @@ "node": ">=0.10" } }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-4.3.0.tgz", + "integrity": "sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==", + "dev": true, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/event-emitter": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", @@ -1844,6 +4063,15 @@ "es5-ext": "~0.10.14" } }, + "node_modules/events": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", + "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", + "dev": true, + "engines": { + "node": ">=0.8.x" + } + }, "node_modules/expand-brackets": { "version": "2.1.4", "resolved": "https://registry.npmjs.org/expand-brackets/-/expand-brackets-2.1.4.tgz", @@ -1979,27 +4207,6 @@ "@types/yauzl": "^2.9.1" } }, - "node_modules/extract-zip/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", - "dependencies": { - "ms": "2.1.2" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/extract-zip/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, "node_modules/fancy-log": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/fancy-log/-/fancy-log-1.3.3.tgz", @@ -2015,6 +4222,12 @@ "node": ">= 0.10" } }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -2031,12 +4244,24 @@ "node": ">=8.6.0" } }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true + }, "node_modules/fast-levenshtein": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-1.1.4.tgz", "integrity": "sha512-Ia0sQNrMPXXkqVFt6w6M1n1oKo3NfKs+mvaV811Jwir7vAk9a6PVV9VPYf6X3BU97QiLEmuW3uXH9u87zDFfdw==", "dev": true }, + "node_modules/fast-uri": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.3.tgz", + "integrity": "sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==", + "dev": true + }, "node_modules/fastq": { "version": "1.17.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.17.1.tgz", @@ -2084,16 +4309,36 @@ "node": ">=0.10.0" } }, - "node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "node_modules/find-cache-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-4.0.0.tgz", + "integrity": "sha512-9ZonPT4ZAK4a+1pUPVPZJapbi7O5qbbJPdYw/NOQWZZbVLdDTYM3A4R9z/DpAM08IDaFGsvPgiGZ82WEwUDWjg==", + "dev": true, "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" + "common-path-prefix": "^3.0.0", + "pkg-dir": "^7.0.0" }, "engines": { - "node": ">=8" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/find-up": { + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-6.3.0.tgz", + "integrity": "sha512-v2ZsoEuVHYy8ZIlYqwPe/39Cy+cFDzp4dXPaxNvkEuouymu+2Jbz0PxpKarJHYJTmv2HWT3O382qY8l4jMWthw==", + "dev": true, + "dependencies": { + "locate-path": "^7.1.0", + "path-exists": "^5.0.0" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/findup-sync": { @@ -2327,7 +4572,7 @@ "version": "1.2.13", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-1.2.13.tgz", "integrity": "sha512-oWb1Z6mkHIskLzEJ/XWX0srkpkTQ7vaopMQkyaEIoq0fmtFVxOthb8cCxeT+p3ynTdkk/RZwbgG4brR5BeWECw==", - "deprecated": "The v1 package contains DANGEROUS / INSECURE binaries. Upgrade to safe fsevents v2", + "deprecated": "Upgrade to fsevents v2 to mitigate potential security issues", "dev": true, "hasInstallScript": true, "optional": true, @@ -2350,6 +4595,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/get-caller-file": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-1.0.3.tgz", @@ -2402,6 +4656,7 @@ "version": "7.2.3", "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", "dependencies": { "fs.realpath": "^1.0.0", "inflight": "^1.0.4", @@ -2421,6 +4676,7 @@ "version": "5.1.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, "dependencies": { "is-glob": "^4.0.1" }, @@ -2471,6 +4727,12 @@ "node": ">=0.10.0" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, "node_modules/glob-watcher": { "version": "5.0.5", "resolved": "https://registry.npmjs.org/glob-watcher/-/glob-watcher-5.0.5.tgz", @@ -2519,6 +4781,15 @@ "node": ">=0.10.0" } }, + "node_modules/globals": { + "version": "11.12.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", + "integrity": "sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/globby": { "version": "11.1.0", "resolved": "https://registry.npmjs.org/globby/-/globby-11.1.0.tgz", @@ -2701,6 +4972,12 @@ "node": ">= 6" } }, + "node_modules/gulp-sourcemaps/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, "node_modules/gulp-svgstore": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/gulp-svgstore/-/gulp-svgstore-9.0.0.tgz", @@ -2858,9 +5135,9 @@ } }, "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -2871,8 +5148,8 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" + "domutils": "^3.1.0", + "entities": "^4.5.0" } }, "node_modules/https-proxy-agent": { @@ -2887,27 +5164,17 @@ "node": ">= 6" } }, - "node_modules/https-proxy-agent/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", "dependencies": { - "ms": "2.1.2" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=0.10.0" } }, - "node_modules/https-proxy-agent/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" - }, "node_modules/ieee754": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", @@ -2928,18 +5195,18 @@ ] }, "node_modules/ignore": { - "version": "5.3.1", - "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz", - "integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==", + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", "dev": true, "engines": { "node": ">= 4" } }, "node_modules/immutable": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", - "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==" + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.2.tgz", + "integrity": "sha512-1NU7hWZDkV7hJ4PJ9dur9gTNQ4ePNPN4k9/0YhwjzykTi/+3Q5pF93YU5QoVj8BuOnhLgaY8gs0U2pj4kSYVcw==" }, "node_modules/indent-string": { "version": "4.0.0", @@ -2954,6 +5221,7 @@ "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", "dependencies": { "once": "^1.3.0", "wrappy": "1" @@ -3046,12 +5314,15 @@ "dev": true }, "node_modules/is-core-module": { - "version": "2.13.1", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", - "integrity": "sha512-hHrIjvZsftOsvKSn2TRYl63zvxsgE0K+0mYMoH6gD4omR5IWB2KynivBQczo3+wF1cCkjzvptnI9Q0sPU66ilw==", + "version": "2.15.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.15.1.tgz", + "integrity": "sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==", "dev": true, "dependencies": { - "hasown": "^2.0.0" + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -3095,6 +5366,7 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "devOptional": true, "engines": { "node": ">=0.10.0" } @@ -3115,6 +5387,7 @@ "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "devOptional": true, "dependencies": { "is-extglob": "^2.1.1" }, @@ -3273,12 +5546,68 @@ "url": "https://bevry.me/fund" } }, + "node_modules/jest-worker": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", + "integrity": "sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==", + "dev": true, + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true + }, + "node_modules/jsesc": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.0.2.tgz", + "integrity": "sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==", + "dev": true, + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "dev": true + }, "node_modules/json-stable-stringify-without-jsonify": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", "dev": true }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/just-debounce": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/just-debounce/-/just-debounce-1.1.0.tgz", @@ -3307,6 +5636,15 @@ "node": ">=6" } }, + "node_modules/klona": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/klona/-/klona-2.0.6.tgz", + "integrity": "sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/last-run": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/last-run/-/last-run-1.1.1.tgz", @@ -3412,15 +5750,28 @@ "node": ">=0.10.0" } }, + "node_modules/loader-runner": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/loader-runner/-/loader-runner-4.3.0.tgz", + "integrity": "sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==", + "dev": true, + "engines": { + "node": ">=6.11.5" + } + }, "node_modules/locate-path": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", - "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-7.2.0.tgz", + "integrity": "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA==", + "dev": true, "dependencies": { - "p-locate": "^4.1.0" + "p-locate": "^6.0.0" }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/lodash": { @@ -3428,21 +5779,37 @@ "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" }, + "node_modules/lodash.clone": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clone/-/lodash.clone-4.5.0.tgz", + "integrity": "sha512-GhrVeweiTD6uTmmn5hV/lzgCQhccwReIVRLHp7LT4SopOjqEZ5BbX8b5WWEtAKasjmy8hR7ZPwsYlxRCku5odg==", + "dev": true + }, "node_modules/lodash.clonedeep": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", "dev": true }, + "node_modules/lodash.debounce": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz", + "integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==", + "dev": true + }, + "node_modules/lodash.some": { + "version": "4.6.0", + "resolved": "https://registry.npmjs.org/lodash.some/-/lodash.some-4.6.0.tgz", + "integrity": "sha512-j7MJE+TuT51q9ggt4fSgVqro163BEFjAt3u97IqU+JA2DkWl80nFTrowzLpZ/BnpN7rrl0JA/593NAdd8p/scQ==", + "dev": true + }, "node_modules/lru-cache": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", - "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" + "yallist": "^3.0.2" } }, "node_modules/lru-queue": { @@ -3646,21 +6013,43 @@ "dev": true }, "node_modules/memoizee": { - "version": "0.4.15", - "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.15.tgz", - "integrity": "sha512-UBWmJpLZd5STPm7PMUlOw/TSy972M+z8gcyQ5veOnSDRREz/0bmpyTfKt3/51DhEBqCZQn1udM/5flcSPYhkdQ==", + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", + "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", "dev": true, "dependencies": { - "d": "^1.0.1", - "es5-ext": "^0.10.53", + "d": "^1.0.2", + "es5-ext": "^0.10.64", "es6-weak-map": "^2.0.3", "event-emitter": "^0.3.5", "is-promise": "^2.2.2", "lru-queue": "^0.1.0", "next-tick": "^1.1.0", "timers-ext": "^0.1.7" + }, + "engines": { + "node": ">=0.12" } }, + "node_modules/memory-fs": { + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.5.0.tgz", + "integrity": "sha512-jA0rdU5KoQMC0e6ppoNRtpp6vjFq6+NY7r8hywnC7V+1Xj/MtHwGIbB1QaK/dunyjWteJzmkpd7ooeWg10T7GA==", + "dev": true, + "dependencies": { + "errno": "^0.1.3", + "readable-stream": "^2.0.1" + }, + "engines": { + "node": ">=4.3.0 <5.0.0 || >=5.10" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-stream/-/merge-stream-2.0.0.tgz", + "integrity": "sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==", + "dev": true + }, "node_modules/merge2": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", @@ -3671,12 +6060,12 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", - "dev": true, + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "devOptional": true, "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -3684,22 +6073,22 @@ } }, "node_modules/micromatch/node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dev": true, + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "devOptional": true, "dependencies": { - "fill-range": "^7.0.1" + "fill-range": "^7.1.1" }, "engines": { "node": ">=8" } }, "node_modules/micromatch/node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dev": true, + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "devOptional": true, "dependencies": { "to-regex-range": "^5.0.1" }, @@ -3711,7 +6100,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "dev": true, + "devOptional": true, "engines": { "node": ">=0.12.0" } @@ -3720,7 +6109,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", - "dev": true, + "devOptional": true, "dependencies": { "is-number": "^7.0.0" }, @@ -3728,6 +6117,27 @@ "node": ">=8.0" } }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "dev": true, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "dev": true, + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, "node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -3784,8 +6194,7 @@ "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==" }, "node_modules/mustache": { "version": "4.2.0", @@ -3805,9 +6214,9 @@ } }, "node_modules/nan": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/nan/-/nan-2.19.0.tgz", - "integrity": "sha512-nO1xXxfh/RWNxfd/XPfbIfFk5vgLsAxUR9y5O0cHMJu/AW9U95JLXqthYHjEp+8gQ5p96K9jUp8nbVOxCdRbtw==", + "version": "2.22.0", + "resolved": "https://registry.npmjs.org/nan/-/nan-2.22.0.tgz", + "integrity": "sha512-nbajikzWTMwsW+eSsNm3QwlOs7het9gGJU5dDZzRTQGk03vyBOauxgI4VakDzE0PtsGTmXPsXTbbjVhRwR5mpw==", "dev": true, "optional": true }, @@ -3923,12 +6332,24 @@ "node": ">=0.10.0" } }, + "node_modules/neo-async": { + "version": "2.6.2", + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==", + "dev": true + }, "node_modules/next-tick": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", "dev": true }, + "node_modules/node-addon-api": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz", + "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==", + "optional": true + }, "node_modules/node-fetch": { "version": "2.6.13", "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.6.13.tgz", @@ -3949,9 +6370,9 @@ } }, "node_modules/node-releases": { - "version": "2.0.14", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.14.tgz", - "integrity": "sha512-y10wOWt8yZpqXmOgRo77WaHEmhYQYGNA6y421PKsKYWEK8aW+cqAphborZDhqfyKrbZEN92CN1X2KbafY2s7Yw==", + "version": "2.0.18", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.18.tgz", + "integrity": "sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==", "dev": true }, "node_modules/node.extend": { @@ -3991,6 +6412,7 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, "engines": { "node": ">=0.10.0" } @@ -4192,28 +6614,33 @@ } }, "node_modules/p-limit": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", - "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-4.0.0.tgz", + "integrity": "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ==", + "dev": true, "dependencies": { - "p-try": "^2.0.0" + "yocto-queue": "^1.0.0" }, "engines": { - "node": ">=6" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-locate": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", - "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-6.0.0.tgz", + "integrity": "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw==", + "dev": true, "dependencies": { - "p-limit": "^2.2.0" + "p-limit": "^4.0.0" }, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/p-map": { @@ -4329,6 +6756,36 @@ "node": ">= 12" } }, + "node_modules/pa11y/node_modules/lru-cache": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", + "integrity": "sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pa11y/node_modules/semver": { + "version": "7.3.8", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", + "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", + "dependencies": { + "lru-cache": "^6.0.0" + }, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/pa11y/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + }, "node_modules/parse-filepath": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/parse-filepath/-/parse-filepath-1.0.2.tgz", @@ -4374,22 +6831,33 @@ } }, "node_modules/parse5": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.1.2.tgz", - "integrity": "sha512-Czj1WaSVpaoj0wbhMzLmWD69anp2WH7FXMB9n1Sy8/ZFF9jolSQVMu1Ij5WIyGmcBmhk7EOndpO4mIpihVqAXw==", + "version": "7.2.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.2.1.tgz", + "integrity": "sha512-BuBYQYlv1ckiPdQi/ohiivi9Sagc9JG+Ozs0r7b/0iK3sKmrb0b9FdWdBbOdx6hBCM/F9Ir82ofnBhtZOjCRPQ==", "dependencies": { - "entities": "^4.4.0" + "entities": "^4.5.0" }, "funding": { "url": "https://github.com/inikulin/parse5?sponsor=1" } }, "node_modules/parse5-htmlparser2-tree-adapter": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.0.0.tgz", - "integrity": "sha512-B77tOZrqqfUfnVcOrUvfdLbz4pu4RopLD/4vmu3HUPswwTA8OH0EMW9BlWR2B0RCoiZRAHEUu7IxeP1Pd1UU+g==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", "dependencies": { - "domhandler": "^5.0.2", "parse5": "^7.0.0" }, "funding": { @@ -4412,11 +6880,12 @@ "dev": true }, "node_modules/path-exists": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", - "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-5.0.0.tgz", + "integrity": "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ==", + "dev": true, "engines": { - "node": ">=8" + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" } }, "node_modules/path-is-absolute": { @@ -4469,15 +6938,16 @@ "integrity": "sha512-F3asv42UuXchdzt+xXqfW1OGlVBe+mxa2mqI0pg5yAHZPvFmY3Y6drSf/GQ1A86WgWEN9Kzh/WrgKa6iGcHXLg==" }, "node_modules/picocolors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", - "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", "dev": true }, "node_modules/picomatch": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz", "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==", + "devOptional": true, "engines": { "node": ">=8.6" }, @@ -4513,14 +6983,18 @@ } }, "node_modules/pkg-dir": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", - "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-7.0.0.tgz", + "integrity": "sha512-Ie9z/WINcxxLp27BKOCHGde4ITq9UklYKDzVo1nhk5sqGEXU3FpkwP5GM2voTGJkGd9B3Otl+Q4uwSOeSUtOBA==", + "dev": true, "dependencies": { - "find-up": "^4.0.0" + "find-up": "^6.3.0" }, "engines": { - "node": ">=8" + "node": ">=14.16" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, "node_modules/plugin-error": { @@ -4711,10 +7185,16 @@ "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" }, + "node_modules/prr": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/prr/-/prr-1.0.1.tgz", + "integrity": "sha512-yPw4Sng1gWghHQWj0B3ZggWUm4qVbPwPFcRG8KyxiU7J2OHFSoEHKS+EZ3fv5l1t9CyCiop6l/ZYeWbrgoQejw==", + "dev": true + }, "node_modules/pump": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.0.tgz", - "integrity": "sha512-LwZy+p3SFs1Pytd/jYct4wpv49HiYCqd9Rlc5ZVdk0V+8Yzv6jR5Blk3TRmPL1ft69TxP0IMZGJ+WPFU2BFhww==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", "dependencies": { "end-of-stream": "^1.1.0", "once": "^1.3.1" @@ -4741,11 +7221,20 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/puppeteer": { "version": "9.1.1", "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-9.1.1.tgz", "integrity": "sha512-W+nOulP2tYd/ZG99WuZC/I5ljjQQ7EUw/jQGcIb9eu8mDlZxNY2SgcJXTLG9h5gRvqA3uJOe4hZXYsd3EqioMw==", - "deprecated": "< 22.5.0 is no longer supported", + "deprecated": "< 22.8.2 is no longer supported", "hasInstallScript": true, "dependencies": { "debug": "^4.1.0", @@ -4765,26 +7254,72 @@ "node": ">=10.18.1" } }, - "node_modules/puppeteer/node_modules/debug": { - "version": "4.3.4", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", - "integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==", + "node_modules/puppeteer/node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dependencies": { - "ms": "2.1.2" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } + "node": ">=8" } }, - "node_modules/puppeteer/node_modules/ms": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", - "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==" + "node_modules/puppeteer/node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/puppeteer/node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/puppeteer/node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/puppeteer/node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "engines": { + "node": ">=8" + } + }, + "node_modules/puppeteer/node_modules/pkg-dir": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/pkg-dir/-/pkg-dir-4.2.0.tgz", + "integrity": "sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==", + "dependencies": { + "find-up": "^4.0.0" + }, + "engines": { + "node": ">=8" + } }, "node_modules/queue-microtask": { "version": "1.2.3", @@ -4806,6 +7341,15 @@ } ] }, + "node_modules/randombytes": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz", + "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==", + "dev": true, + "dependencies": { + "safe-buffer": "^5.1.0" + } + }, "node_modules/read-pkg": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-1.1.0.tgz", @@ -5020,6 +7564,39 @@ "node": ">= 0.10" } }, + "node_modules/regenerate": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/regenerate/-/regenerate-1.4.2.tgz", + "integrity": "sha512-zrceR/XhGYU/d/opr2EKO7aRHUeiBI8qjtfHqADTwZd6Szfy16la6kqD0MIUs5z5hx6AaKa+PixpPrR289+I0A==", + "dev": true + }, + "node_modules/regenerate-unicode-properties": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/regenerate-unicode-properties/-/regenerate-unicode-properties-10.2.0.tgz", + "integrity": "sha512-DqHn3DwbmmPVzeKj9woBadqmXxLvQoQIwu7nopMc72ztvxVmVk2SBhSnx67zuye5TP+lJsb/TBQsjLKhnDf3MA==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.14.1", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", + "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", + "dev": true + }, + "node_modules/regenerator-transform": { + "version": "0.15.2", + "resolved": "https://registry.npmjs.org/regenerator-transform/-/regenerator-transform-0.15.2.tgz", + "integrity": "sha512-hfMp2BoF0qOk3uc5V20ALGDS2ddjQaLrdl7xrGXvAIow7qeWRM2VA2HuCHkUKk9slq3VwEwLNK3DFBqDfPGYtg==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.8.4" + } + }, "node_modules/regex-not": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/regex-not/-/regex-not-1.0.2.tgz", @@ -5070,6 +7647,41 @@ "node": ">=0.10.0" } }, + "node_modules/regexpu-core": { + "version": "6.1.1", + "resolved": "https://registry.npmjs.org/regexpu-core/-/regexpu-core-6.1.1.tgz", + "integrity": "sha512-k67Nb9jvwJcJmVpw0jPttR1/zVfnKf8Km0IPatrU/zJ5XeG3+Slx0xLXs9HByJSzXzrlz5EDvN6yLNMDc2qdnw==", + "dev": true, + "dependencies": { + "regenerate": "^1.4.2", + "regenerate-unicode-properties": "^10.2.0", + "regjsgen": "^0.8.0", + "regjsparser": "^0.11.0", + "unicode-match-property-ecmascript": "^2.0.0", + "unicode-match-property-value-ecmascript": "^2.1.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/regjsgen": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/regjsgen/-/regjsgen-0.8.0.tgz", + "integrity": "sha512-RvwtGe3d7LvWiDQXeQw8p5asZUmfU1G/l6WbUXeHta7Y2PEIvBTwH6E2EfmYUK8pxcxEdEmaomqyp0vZZ7C+3Q==", + "dev": true + }, + "node_modules/regjsparser": { + "version": "0.11.2", + "resolved": "https://registry.npmjs.org/regjsparser/-/regjsparser-0.11.2.tgz", + "integrity": "sha512-3OGZZ4HoLJkkAZx/48mTXJNlmqTGOzc0o9OWQPuWpkOlXXPbyN6OafCcoXUnBqE2D3f/T5L+pWc1kdEmnfnRsA==", + "dev": true, + "dependencies": { + "jsesc": "~3.0.2" + }, + "bin": { + "regjsparser": "bin/parser" + } + }, "node_modules/remove-bom-buffer": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/remove-bom-buffer/-/remove-bom-buffer-3.0.0.tgz", @@ -5164,6 +7776,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-main-filename": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-1.0.1.tgz", @@ -5247,6 +7868,7 @@ "version": "3.0.2", "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dependencies": { "glob": "^7.1.3" }, @@ -5303,13 +7925,18 @@ "ret": "~0.1.10" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, "node_modules/sass": { - "version": "1.77.1", - "resolved": "https://registry.npmjs.org/sass/-/sass-1.77.1.tgz", - "integrity": "sha512-OMEyfirt9XEfyvocduUIOlUSkWOXS/LAt6oblR/ISXCTukyavjex+zQNm51pPCOiFKY1QpWvEH1EeCkgyV3I6w==", + "version": "1.81.0", + "resolved": "https://registry.npmjs.org/sass/-/sass-1.81.0.tgz", + "integrity": "sha512-Q4fOxRfhmv3sqCLoGfvrC9pRV8btc0UtqL9mN6Yrv6Qi9ScL55CVH1vlPP863ISLEEMNLLuu9P+enCeGHlnzhA==", "dependencies": { - "chokidar": ">=3.0.0 <4.0.0", - "immutable": "^4.0.0", + "chokidar": "^4.0.0", + "immutable": "^5.0.2", "source-map-js": ">=0.6.2 <2.0.0" }, "bin": { @@ -5317,6 +7944,9 @@ }, "engines": { "node": ">=14.0.0" + }, + "optionalDependencies": { + "@parcel/watcher": "^2.4.1" } }, "node_modules/sass-embedded": { @@ -5498,140 +8128,102 @@ "node": ">=14.0.0" } }, - "node_modules/sass/node_modules/anymatch": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", - "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "node_modules/sass-embedded/node_modules/immutable": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.7.tgz", + "integrity": "sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==", + "dev": true + }, + "node_modules/sass-loader": { + "version": "12.6.0", + "resolved": "https://registry.npmjs.org/sass-loader/-/sass-loader-12.6.0.tgz", + "integrity": "sha512-oLTaH0YCtX4cfnJZxKSLAyglED0naiYfNG1iXfU5w1LNZ+ukoA5DtyDIN5zmKVZwYNJP4KRc5Y3hkWga+7tYfA==", + "dev": true, "dependencies": { - "normalize-path": "^3.0.0", - "picomatch": "^2.0.4" + "klona": "^2.0.4", + "neo-async": "^2.6.2" }, "engines": { - "node": ">= 8" - } - }, - "node_modules/sass/node_modules/binary-extensions": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", - "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", - "engines": { - "node": ">=8" + "node": ">= 12.13.0" }, "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/sass/node_modules/braces": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.2.tgz", - "integrity": "sha512-b8um+L1RzM3WDSzvhm6gIz1yfTbBt6YTlcEKAvsmqCZZFw46z626lVj9j1yEPW33H5H+lBQpZMP1k8l+78Ha0A==", - "dependencies": { - "fill-range": "^7.0.1" + "type": "opencollective", + "url": "https://opencollective.com/webpack" }, - "engines": { - "node": ">=8" + "peerDependencies": { + "fibers": ">= 3.1.0", + "node-sass": "^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0", + "sass": "^1.3.0", + "sass-embedded": "*", + "webpack": "^5.0.0" + }, + "peerDependenciesMeta": { + "fibers": { + "optional": true + }, + "node-sass": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + } } }, "node_modules/sass/node_modules/chokidar": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", - "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.1.tgz", + "integrity": "sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==", "dependencies": { - "anymatch": "~3.1.2", - "braces": "~3.0.2", - "glob-parent": "~5.1.2", - "is-binary-path": "~2.1.0", - "is-glob": "~4.0.1", - "normalize-path": "~3.0.0", - "readdirp": "~3.6.0" + "readdirp": "^4.0.1" }, "engines": { - "node": ">= 8.10.0" + "node": ">= 14.16.0" }, "funding": { "url": "https://paulmillr.com/funding/" - }, - "optionalDependencies": { - "fsevents": "~2.3.2" - } - }, - "node_modules/sass/node_modules/fill-range": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.0.1.tgz", - "integrity": "sha512-qOo9F+dMUmC2Lcb4BbVvnKJxTPjCm+RRpe4gDuGrzkL7mEVl/djYSu2OdQ2Pa302N4oqkSg9ir6jaLWJ2USVpQ==", - "dependencies": { - "to-regex-range": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sass/node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "hasInstallScript": true, - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/sass/node_modules/is-binary-path": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", - "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", - "dependencies": { - "binary-extensions": "^2.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/sass/node_modules/is-number": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", - "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", - "engines": { - "node": ">=0.12.0" } }, "node_modules/sass/node_modules/readdirp": { - "version": "3.6.0", - "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", - "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", - "dependencies": { - "picomatch": "^2.2.1" - }, + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-4.0.2.tgz", + "integrity": "sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==", "engines": { - "node": ">=8.10.0" + "node": ">= 14.16.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" } }, - "node_modules/sass/node_modules/to-regex-range": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", - "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "node_modules/schema-utils": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.2.0.tgz", + "integrity": "sha512-L0jRsrPpjdckP3oPug3/VxNKt2trR8TcabrM6FOAAlvC/9Phcmm+cuAgTlxBqdBR1WJx7Naj9WHw+aOmheSVbw==", + "dev": true, "dependencies": { - "is-number": "^7.0.0" + "@types/json-schema": "^7.0.9", + "ajv": "^8.9.0", + "ajv-formats": "^2.1.1", + "ajv-keywords": "^5.1.0" }, "engines": { - "node": ">=8.0" + "node": ">= 12.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" } }, "node_modules/semver": { - "version": "7.3.8", - "resolved": "https://registry.npmjs.org/semver/-/semver-7.3.8.tgz", - "integrity": "sha512-NB1ctGL5rlHrPJtFDVIVzTyQylMLu9N9VICA6HSFJo8MCGVTMW6gfpicwKmmK/dAjTOrqu5l63JJOpDSrAis3A==", - "dependencies": { - "lru-cache": "^6.0.0" - }, + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, "bin": { "semver": "bin/semver.js" - }, - "engines": { - "node": ">=10" } }, "node_modules/semver-greatest-satisfied-range": { @@ -5646,6 +8238,15 @@ "node": ">= 0.10" } }, + "node_modules/serialize-javascript": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/serialize-javascript/-/serialize-javascript-6.0.2.tgz", + "integrity": "sha512-Saa1xPByTTq2gdeFZYLLo+RFE35NHZkAbqZeWNd3BpzppeVisAqpDjcp8dyf6uIvEqJRd46jemmyA4iFIeVk8g==", + "dev": true, + "dependencies": { + "randombytes": "^2.1.0" + } + }, "node_modules/set-blocking": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", @@ -5835,9 +8436,9 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } @@ -5853,6 +8454,16 @@ "decode-uri-component": "^0.2.0" } }, + "node_modules/source-map-support": { + "version": "0.5.21", + "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", + "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", + "dev": true, + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, "node_modules/source-map-url": { "version": "0.4.1", "resolved": "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.1.tgz", @@ -5896,9 +8507,9 @@ } }, "node_modules/spdx-license-ids": { - "version": "3.0.17", - "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.17.tgz", - "integrity": "sha512-sh8PWc/ftMqAAdFiBu6Fy6JUOYjqDJBJvIhpfDMyHrr0Rbp5liZqd4TjtQ/RgfLjKFZb+LMx5hpml5qOWy0qvg==", + "version": "3.0.20", + "resolved": "https://registry.npmjs.org/spdx-license-ids/-/spdx-license-ids-3.0.20.tgz", + "integrity": "sha512-jg25NiDV/1fLtSgEgyvVyDunvaNHbuwF9lfNV17gSmPFAlYzdfNBlLtLzXTevwkPj7DhGbmN9VnmJIgLnhvaBw==", "dev": true }, "node_modules/split-string": { @@ -6097,6 +8708,15 @@ "es6-symbol": "^3.1.1" } }, + "node_modules/tapable": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.2.1.tgz", + "integrity": "sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==", + "dev": true, + "engines": { + "node": ">=6" + } + }, "node_modules/tar-fs": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.1.tgz", @@ -6136,6 +8756,125 @@ "node": ">= 6" } }, + "node_modules/terser": { + "version": "5.36.0", + "resolved": "https://registry.npmjs.org/terser/-/terser-5.36.0.tgz", + "integrity": "sha512-IYV9eNMuFAV4THUspIRXkLakHnV6XO7FEdtKjf/mDyrnqUg9LnlOn6/RwRvM9SZjR4GUq8Nk8zj67FzVARr74w==", + "dev": true, + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.8.2", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.3.10", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.10.tgz", + "integrity": "sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==", + "dev": true, + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.20", + "jest-worker": "^27.4.5", + "schema-utils": "^3.1.1", + "serialize-javascript": "^6.0.1", + "terser": "^5.26.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@swc/core": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/terser-webpack-plugin/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true + }, "node_modules/textextensions": { "version": "3.3.0", "resolved": "https://registry.npmjs.org/textextensions/-/textextensions-3.3.0.tgz", @@ -6183,13 +8922,16 @@ } }, "node_modules/timers-ext": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.7.tgz", - "integrity": "sha512-b85NUNzTSdodShTIbky6ZF02e8STtVVfD+fu4aXXShEELpozH+bCpJLYMPZbsABN2wDH7fJpqIoXxJpzbf0NqQ==", + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", + "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", "dev": true, "dependencies": { - "es5-ext": "~0.10.46", - "next-tick": "1" + "es5-ext": "^0.10.64", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.12" } }, "node_modules/to-absolute-glob": { @@ -6343,15 +9085,15 @@ "integrity": "sha512-c3zayb8/kWWpycWYg87P71E1S1ZL6b6IJxfb5fvsUgsf0S2MVGaDhDXXjDMpdCpfWXqptc+4mXwmiy1ypXqRAA==" }, "node_modules/tslib": { - "version": "2.6.2", - "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", - "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", "dev": true }, "node_modules/type": { - "version": "2.7.2", - "resolved": "https://registry.npmjs.org/type/-/type-2.7.2.tgz", - "integrity": "sha512-dzlvlNlt6AXU7EBSfpAscydQ7gXB+pPGsPnfJnZpiNJBDj7IaJzQlBZYGdEi4R9HmPdBv2XmWJ6YUtoTa7lmCw==", + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", "dev": true }, "node_modules/typedarray": { @@ -6408,12 +9150,60 @@ "node": ">= 0.10" } }, + "node_modules/undici": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.21.0.tgz", + "integrity": "sha512-BUgJXc752Kou3oOIuU1i+yZZypyZRqNPW0vqoMPl8VaoalSfeR0D8/t4iAS3yirs79SSMTxTag+ZC86uswv+Cw==", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { - "version": "5.26.5", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", - "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.19.8.tgz", + "integrity": "sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==", "devOptional": true }, + "node_modules/unicode-canonical-property-names-ecmascript": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/unicode-canonical-property-names-ecmascript/-/unicode-canonical-property-names-ecmascript-2.0.1.tgz", + "integrity": "sha512-dA8WbNeb2a6oQzAQ55YlT5vQAWGV9WXOsi3SskE3bcCdM0P4SDd+24zS/OCacdRq5BkdsRj9q3Pg6YyQoxIGqg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-ecmascript": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-ecmascript/-/unicode-match-property-ecmascript-2.0.0.tgz", + "integrity": "sha512-5kaZCrbp5mmbz5ulBkDkbY0SsPOjKqVS35VpL9ulMPfSl0J0Xsm+9Evphv9CoIZFwre7aJoa94AY6seMKGVN5Q==", + "dev": true, + "dependencies": { + "unicode-canonical-property-names-ecmascript": "^2.0.0", + "unicode-property-aliases-ecmascript": "^2.0.0" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-match-property-value-ecmascript": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/unicode-match-property-value-ecmascript/-/unicode-match-property-value-ecmascript-2.2.0.tgz", + "integrity": "sha512-4IehN3V/+kkr5YeSSDDQG8QLqO26XpL2XP3GQtqwlT/QYSECAwFztxVHjlbh0+gjJ3XmNLS0zDsbgs9jWKExLg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/unicode-property-aliases-ecmascript": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/unicode-property-aliases-ecmascript/-/unicode-property-aliases-ecmascript-2.1.0.tgz", + "integrity": "sha512-6t3foTQI9qne+OZoVQB/8x8rk2k1eVy1gRXhV3oFQ5T6R1dqQ1xtin3XqSlx3+ATBkliTaR/hHyJBm+LVPNM8w==", + "dev": true, + "engines": { + "node": ">=4" + } + }, "node_modules/union-value": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/union-value/-/union-value-1.0.1.tgz", @@ -6498,9 +9288,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.16", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", - "integrity": "sha512-KVbTxlBYlckhF5wgfyZXTWnMn7MMZjMu9XG8bPlliUOP9ThaF4QnhP8qrjrH7DRzHfSk0oQv1wToW+iA5GajEQ==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.1.tgz", + "integrity": "sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==", "dev": true, "funding": [ { @@ -6517,8 +9307,8 @@ } ], "dependencies": { - "escalade": "^3.1.2", - "picocolors": "^1.0.1" + "escalade": "^3.2.0", + "picocolors": "^1.1.0" }, "bin": { "update-browserslist-db": "cli.js" @@ -6527,6 +9317,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/urix": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/urix/-/urix-0.1.0.tgz", @@ -6648,6 +9447,12 @@ "node": ">= 0.10" } }, + "node_modules/vinyl-sourcemap/node_modules/convert-source-map": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-1.9.0.tgz", + "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", + "dev": true + }, "node_modules/vinyl-sourcemap/node_modules/normalize-path": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-2.1.1.tgz", @@ -6687,11 +9492,181 @@ "node": ">= 0.10" } }, + "node_modules/watchpack": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/watchpack/-/watchpack-2.4.2.tgz", + "integrity": "sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==", + "dev": true, + "dependencies": { + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" }, + "node_modules/webpack": { + "version": "5.96.1", + "resolved": "https://registry.npmjs.org/webpack/-/webpack-5.96.1.tgz", + "integrity": "sha512-l2LlBSvVZGhL4ZrPwyr8+37AunkcYj5qh8o6u2/2rzoPc8gxFJkLj1WxNgooi9pnoc06jh0BjuXnamM4qlujZA==", + "dev": true, + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.6", + "@webassemblyjs/ast": "^1.12.1", + "@webassemblyjs/wasm-edit": "^1.12.1", + "@webassemblyjs/wasm-parser": "^1.12.1", + "acorn": "^8.14.0", + "browserslist": "^4.24.0", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.17.1", + "es-module-lexer": "^1.2.1", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "json-parse-even-better-errors": "^2.3.1", + "loader-runner": "^4.2.0", + "mime-types": "^2.1.27", + "neo-async": "^2.6.2", + "schema-utils": "^3.2.0", + "tapable": "^2.1.1", + "terser-webpack-plugin": "^5.3.10", + "watchpack": "^2.4.1", + "webpack-sources": "^3.2.3" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-sources": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/webpack-sources/-/webpack-sources-3.2.3.tgz", + "integrity": "sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==", + "dev": true, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack-stream": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webpack-stream/-/webpack-stream-7.0.0.tgz", + "integrity": "sha512-XoAQTHyCaYMo6TS7Atv1HYhtmBgKiVLONJbzLBl2V3eibXQ2IT/MCRM841RW/r3vToKD5ivrTJFWgd/ghoxoRg==", + "dev": true, + "dependencies": { + "fancy-log": "^1.3.3", + "lodash.clone": "^4.3.2", + "lodash.some": "^4.2.2", + "memory-fs": "^0.5.0", + "plugin-error": "^1.0.1", + "supports-color": "^8.1.1", + "through": "^2.3.8", + "vinyl": "^2.2.1" + }, + "engines": { + "node": ">= 10.0.0" + }, + "peerDependencies": { + "webpack": "^5.21.2" + } + }, + "node_modules/webpack/node_modules/acorn": { + "version": "8.14.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", + "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", + "dev": true, + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "6.12.6", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.12.6.tgz", + "integrity": "sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==", + "dev": true, + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/ajv-keywords": { + "version": "3.5.2", + "resolved": "https://registry.npmjs.org/ajv-keywords/-/ajv-keywords-3.5.2.tgz", + "integrity": "sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==", + "dev": true, + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/webpack/node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-3.3.0.tgz", + "integrity": "sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==", + "dev": true, + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", @@ -6764,9 +9739,9 @@ "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, "node_modules/ws": { - "version": "7.5.9", - "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.9.tgz", - "integrity": "sha512-F+P9Jil7UiSKSkppIiD94dN07AwvFixvLIj1Og1Rl9GGMuNipJnV9JzjD6XuqmAeiswGvUmNLjr5cFuXwNS77Q==", + "version": "7.5.10", + "resolved": "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz", + "integrity": "sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ==", "engines": { "node": ">=8.3.0" }, @@ -6799,9 +9774,10 @@ "dev": true }, "node_modules/yallist": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", - "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true }, "node_modules/yaml": { "version": "1.10.2", @@ -6860,6 +9836,18 @@ "buffer-crc32": "~0.2.3", "fd-slicer": "~1.1.0" } + }, + "node_modules/yocto-queue": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-1.1.1.tgz", + "integrity": "sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==", + "dev": true, + "engines": { + "node": ">=12.20" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } } } } diff --git a/src/package.json b/src/package.json index e16bc8198..e433d0126 100644 --- a/src/package.json +++ b/src/package.json @@ -10,11 +10,17 @@ "author": "", "license": "ISC", "dependencies": { - "@uswds/uswds": "^3.8.1", + "@uswds/uswds": "3.8.1", "pa11y-ci": "^3.0.1", "sass": "^1.54.8" }, "devDependencies": { - "@uswds/compile": "^1.0.0-beta.3" + "@babel/core": "^7.26.0", + "@babel/preset-env": "^7.26.0", + "@uswds/compile": "1.1.0", + "babel-loader": "^9.2.1", + "sass-loader": "^12.6.0", + "webpack": "^5.96.1", + "webpack-stream": "^7.0.0" } } diff --git a/src/registrar/admin.py b/src/registrar/admin.py index 042666619..40d4befb5 100644 --- a/src/registrar/admin.py +++ b/src/registrar/admin.py @@ -1570,7 +1570,7 @@ class DomainInformationAdmin(ListHeaderAdmin, ImportExportModelAdmin): "is_policy_acknowledged", ] - # For each filter_horizontal, init in admin js extendFilterHorizontalWidgets + # For each filter_horizontal, init in admin js initFilterHorizontalWidget # to activate the edit/delete/view buttons filter_horizontal = ("other_contacts",) diff --git a/src/registrar/assets/js/get-gov-admin.js b/src/registrar/assets/js/get-gov-admin.js deleted file mode 100644 index 9fd15b9f9..000000000 --- a/src/registrar/assets/js/get-gov-admin.js +++ /dev/null @@ -1,1797 +0,0 @@ -/** - * @file get-gov-admin.js includes custom code for the .gov registrar admin portal. - * - * Constants and helper functions are at the top. - * Event handlers are in the middle. - * Initialization (run-on-load) stuff goes at the bottom. - */ - -// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> -// Helper functions. - -/** - * Hide element - * -*/ -const hideElement = (element) => { - if (element && !element.classList.contains("display-none")) - element.classList.add('display-none'); -}; - -/** - * Show element - * - */ -const showElement = (element) => { - if (element && element.classList.contains("display-none")) - element.classList.remove('display-none'); -}; - -/** Either sets attribute target="_blank" to a given element, or removes it */ -function openInNewTab(el, removeAttribute = false){ - if(removeAttribute){ - el.setAttribute("target", "_blank"); - }else{ - el.removeAttribute("target", "_blank"); - } -}; - -// Adds or removes a boolean from our session -function addOrRemoveSessionBoolean(name, add){ - if (add) { - sessionStorage.setItem(name, "true"); - }else { - sessionStorage.removeItem(name); - } -} - -// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> -// Event handlers. -/** Helper function that handles business logic for the suborganization field. - * Can be used anywhere the suborganization dropdown exists -*/ -function handleSuborganizationFields( - portfolioDropdownSelector="#id_portfolio", - suborgDropdownSelector="#id_sub_organization", - requestedSuborgFieldSelector=".field-requested_suborganization", - suborgCitySelector=".field-suborganization_city", - suborgStateTerritorySelector=".field-suborganization_state_territory" -) { - // These dropdown are select2 fields so they must be interacted with via jquery - const portfolioDropdown = django.jQuery(portfolioDropdownSelector) - const suborganizationDropdown = django.jQuery(suborgDropdownSelector) - const requestedSuborgField = document.querySelector(requestedSuborgFieldSelector); - const suborgCity = document.querySelector(suborgCitySelector); - const suborgStateTerritory = document.querySelector(suborgStateTerritorySelector); - if (!suborganizationDropdown || !requestedSuborgField || !suborgCity || !suborgStateTerritory) { - console.error("Requested suborg fields not found."); - return; - } - - function toggleSuborganizationFields() { - if (portfolioDropdown.val() && !suborganizationDropdown.val()) { - showElement(requestedSuborgField); - showElement(suborgCity); - showElement(suborgStateTerritory); - }else { - hideElement(requestedSuborgField); - hideElement(suborgCity); - hideElement(suborgStateTerritory); - } - } - - // Run the function once on page startup, then attach an event listener - toggleSuborganizationFields(); - suborganizationDropdown.on("change", toggleSuborganizationFields); - portfolioDropdown.on("change", toggleSuborganizationFields); -} - - -/** - * This function handles the portfolio selection as well as display of - * portfolio-related fields in the DomainRequest Form. - * - * IMPORTANT NOTE: The logic in this method is paired dynamicPortfolioFields -*/ -function handlePortfolioSelection() { - // These dropdown are select2 fields so they must be interacted with via jquery - const portfolioDropdown = django.jQuery("#id_portfolio"); - const suborganizationDropdown = django.jQuery("#id_sub_organization"); - const suborganizationField = document.querySelector(".field-sub_organization"); - const requestedSuborganizationField = document.querySelector(".field-requested_suborganization"); - const suborganizationCity = document.querySelector(".field-suborganization_city"); - const suborganizationStateTerritory = document.querySelector(".field-suborganization_state_territory"); - const seniorOfficialField = document.querySelector(".field-senior_official"); - const otherEmployeesField = document.querySelector(".field-other_contacts"); - const noOtherContactsRationaleField = document.querySelector(".field-no_other_contacts_rationale"); - const cisaRepresentativeFirstNameField = document.querySelector(".field-cisa_representative_first_name"); - const cisaRepresentativeLastNameField = document.querySelector(".field-cisa_representative_last_name"); - const cisaRepresentativeEmailField = document.querySelector(".field-cisa_representative_email"); - const orgTypeFieldSet = document.querySelector(".field-is_election_board").parentElement; - const orgTypeFieldSetDetails = orgTypeFieldSet.nextElementSibling; - const orgNameFieldSet = document.querySelector(".field-organization_name").parentElement; - const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling; - const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official"); - const portfolioSeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly"); - const portfolioSeniorOfficialAddress = portfolioSeniorOfficialField.querySelector(".dja-address-contact-list"); - const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement; - const portfolioOrgType = document.querySelector(".field-portfolio_organization_type .readonly"); - const portfolioFederalTypeField = document.querySelector(".field-portfolio_federal_type"); - const portfolioFederalType = portfolioFederalTypeField.querySelector(".readonly"); - const portfolioOrgNameField = document.querySelector(".field-portfolio_organization_name") - const portfolioOrgName = portfolioOrgNameField.querySelector(".readonly"); - const portfolioOrgNameFieldSet = portfolioOrgNameField.parentElement; - const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling; - const portfolioFederalAgencyField = document.querySelector(".field-portfolio_federal_agency"); - const portfolioFederalAgency = portfolioFederalAgencyField.querySelector(".readonly"); - const portfolioStateTerritory = document.querySelector(".field-portfolio_state_territory .readonly"); - const portfolioAddressLine1 = document.querySelector(".field-portfolio_address_line1 .readonly"); - const portfolioAddressLine2 = document.querySelector(".field-portfolio_address_line2 .readonly"); - const portfolioCity = document.querySelector(".field-portfolio_city .readonly"); - const portfolioZipcode = document.querySelector(".field-portfolio_zipcode .readonly"); - const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization"); - const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly"); - const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; - let isPageLoading = true; - - /** - * Fetches portfolio data by ID using an AJAX call. - * - * @param {number|string} portfolio_id - The ID of the portfolio to retrieve. - * @returns {Promise} - A promise that resolves to the portfolio data object if successful, - * or null if there was an error. - * - * This function performs an asynchronous fetch request to retrieve portfolio data. - * If the request is successful, it returns the portfolio data as an object. - * If an error occurs during the request or the data contains an error, it logs the error - * to the console and returns null. - */ - function getPortfolio(portfolio_id) { - return fetch(`${portfolioJsonUrl}?id=${portfolio_id}`) - .then(response => response.json()) - .then(data => { - if (data.error) { - console.error("Error in AJAX call: " + data.error); - return null; - } else { - return data; - } - }) - .catch(error => { - console.error("Error retrieving portfolio", error); - return null; - }); - } - - /** - * Updates various UI elements with the data from a given portfolio object. - * - * @param {Object} portfolio - The portfolio data object containing values to populate in the UI. - * - * This function updates multiple fields in the UI to reflect data in the `portfolio` object: - * - Clears and replaces selections in the `suborganizationDropdown` with values from `portfolio.suborganizations`. - * - Calls `updatePortfolioSeniorOfficial` to set the senior official information. - * - Sets the portfolio organization type, federal type, name, federal agency, and other address-related fields. - * - * The function expects that elements like `portfolioOrgType`, `portfolioFederalAgency`, etc., - * are already defined and accessible in the global scope. - */ - function updatePortfolioFieldsData(portfolio) { - // replace selections in suborganizationDropdown with - // values in portfolio.suborganizations - suborganizationDropdown.empty(); - // update portfolio senior official - updatePortfolioSeniorOfficial(portfolio.senior_official); - // update portfolio organization type - portfolioOrgType.innerText = portfolio.organization_type; - // update portfolio federal type - portfolioFederalType.innerText = portfolio.federal_type - // update portfolio organization name - portfolioOrgName.innerText = portfolio.organization_name; - // update portfolio federal agency - portfolioFederalAgency.innerText = portfolio.federal_agency ? portfolio.federal_agency.agency : ''; - // update portfolio state - portfolioStateTerritory.innerText = portfolio.state_territory; - // update portfolio address line 1 - portfolioAddressLine1.innerText = portfolio.address_line1; - // update portfolio address line 2 - portfolioAddressLine2.innerText = portfolio.address_line2; - // update portfolio city - portfolioCity.innerText = portfolio.city; - // update portfolio zip code - portfolioZipcode.innerText = portfolio.zipcode - // update portfolio urbanization - portfolioUrbanization.innerText = portfolio.urbanization; - } - - /** - * Updates the UI to display the senior official information from a given object. - * - * @param {Object} senior_official - The senior official's data object, containing details like - * first name, last name, and ID. If `senior_official` is null, displays a default message. - * - * This function: - * - Displays the senior official's name as a link (if available) in the `portfolioSeniorOfficial` element. - * - If a senior official exists, it sets `portfolioSeniorOfficialAddress` to show the official's contact info - * and displays it by calling `updateSeniorOfficialContactInfo`. - * - If no senior official is provided, it hides `portfolioSeniorOfficialAddress` and shows a "No senior official found." message. - * - * Dependencies: - * - Expects the `portfolioSeniorOfficial` and `portfolioSeniorOfficialAddress` elements to be available globally. - * - Uses `showElement` and `hideElement` for visibility control. - */ - function updatePortfolioSeniorOfficial(senior_official) { - if (senior_official) { - let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(' '); - let seniorOfficialLink = `${seniorOfficialName}` - portfolioSeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; - updateSeniorOfficialContactInfo(portfolioSeniorOfficialAddress, senior_official); - showElement(portfolioSeniorOfficialAddress); - } else { - portfolioSeniorOfficial.innerText = "No senior official found."; - hideElement(portfolioSeniorOfficialAddress); - } - } - - /** - * Populates and displays contact information for a senior official within a specified address field element. - * - * @param {HTMLElement} addressField - The DOM element containing contact info fields for the senior official. - * @param {Object} senior_official - The senior official's data object, containing properties like title, email, and phone. - * - * This function: - * - Sets the `title`, `email`, and `phone` fields in `addressField` to display the senior official's data. - * - Updates the `titleSpan` with the official's title, or "None" if unavailable. - * - Updates the `emailSpan` with the official's email, or "None" if unavailable. - * - If an email is provided, populates `hiddenInput` with the email for copying and shows the `copyButton`. - * - If no email is provided, hides the `copyButton`. - * - Updates the `phoneSpan` with the official's phone number, or "None" if unavailable. - * - * Dependencies: - * - Uses `showElement` and `hideElement` to control visibility of the `copyButton`. - * - Expects `addressField` to have specific classes (.contact_info_title, .contact_info_email, etc.) for query selectors to work. - */ - function updateSeniorOfficialContactInfo(addressField, senior_official) { - const titleSpan = addressField.querySelector(".contact_info_title"); - const emailSpan = addressField.querySelector(".contact_info_email"); - const phoneSpan = addressField.querySelector(".contact_info_phone"); - const hiddenInput = addressField.querySelector("input"); - const copyButton = addressField.querySelector(".admin-icon-group"); - if (titleSpan) { - titleSpan.textContent = senior_official.title || "None"; - }; - if (emailSpan) { - emailSpan.textContent = senior_official.email || "None"; - if (senior_official.email) { - hiddenInput.value = senior_official.email; - showElement(copyButton); - }else { - hideElement(copyButton); - } - } - if (phoneSpan) { - phoneSpan.textContent = senior_official.phone || "None"; - }; - } - - /** - * Dynamically updates the visibility of certain portfolio fields based on specific conditions. - * - * This function adjusts the display of fields within the portfolio UI based on: - * - The presence of a senior official's contact information. - * - The selected state or territory, affecting the visibility of the urbanization field. - * - The organization type (Federal vs. non-Federal), toggling the visibility of related fields. - * - * Functionality: - * 1. **Senior Official Contact Info Display**: - * - If `portfolioSeniorOfficial` contains "No additional contact information found", - * hides `portfolioSeniorOfficialAddress`; otherwise, shows it. - * - * 2. **Urbanization Field Display**: - * - Displays `portfolioUrbanizationField` only when the `portfolioStateTerritory` value is "PR" (Puerto Rico). - * - * 3. **Federal Organization Type Display**: - * - If `portfolioOrgType` is "Federal", hides `portfolioOrgNameField` and shows both `portfolioFederalAgencyField` - * and `portfolioFederalTypeField`. - * - If not Federal, shows `portfolioOrgNameField` and hides `portfolioFederalAgencyField` and `portfolioFederalTypeField`. - * - Certain text fields (Organization Type, Organization Name, Federal Type, Federal Agency) updated to links - * to edit the portfolio - * - * Dependencies: - * - Expects specific elements to be defined globally (`portfolioSeniorOfficial`, `portfolioUrbanizationField`, etc.). - * - Uses `showElement` and `hideElement` functions to control element visibility. - */ - function updatePortfolioFieldsDataDynamicDisplay() { - - // Handle visibility of senior official's contact information - if (portfolioSeniorOfficial.innerText.includes("No senior official found.")) { - hideElement(portfolioSeniorOfficialAddress); - } else { - showElement(portfolioSeniorOfficialAddress); - } - - // Handle visibility of urbanization field based on state/territory value - let portfolioStateTerritoryValue = portfolioStateTerritory.innerText; - if (portfolioStateTerritoryValue === "PR") { - showElement(portfolioUrbanizationField); - } else { - hideElement(portfolioUrbanizationField); - } - - // Handle visibility of fields based on organization type (Federal vs. others) - if (portfolioOrgType.innerText === "Federal") { - hideElement(portfolioOrgNameField); - showElement(portfolioFederalAgencyField); - showElement(portfolioFederalTypeField); - } else { - showElement(portfolioOrgNameField); - hideElement(portfolioFederalAgencyField); - hideElement(portfolioFederalTypeField); - } - - // Modify the display of certain fields to convert them from text to links - // to edit the portfolio - let portfolio_id = portfolioDropdown.val(); - let portfolioEditUrl = `/admin/registrar/portfolio/${portfolio_id}/change/`; - let portfolioOrgTypeValue = portfolioOrgType.innerText; - portfolioOrgType.innerHTML = `${portfolioOrgTypeValue}`; - let portfolioOrgNameValue = portfolioOrgName.innerText; - portfolioOrgName.innerHTML = `${portfolioOrgNameValue}`; - let portfolioFederalAgencyValue = portfolioFederalAgency.innerText; - portfolioFederalAgency.innerHTML = `${portfolioFederalAgencyValue}`; - let portfolioFederalTypeValue = portfolioFederalType.innerText; - if (portfolioFederalTypeValue !== '-') - portfolioFederalType.innerHTML = `${portfolioFederalTypeValue}`; - - } - - /** - * Asynchronously updates portfolio fields in the UI based on the selected portfolio. - * - * This function first checks if the page is loading or if a portfolio selection is available - * in the `portfolioDropdown`. If a portfolio is selected, it retrieves the portfolio data, - * then updates the UI fields to display relevant data. If no portfolio is selected, it simply - * refreshes the UI field display without new data. The `isPageLoading` flag prevents - * updates during page load. - * - * Workflow: - * 1. **Check Page Loading**: - * - If `isPageLoading` is `true`, set it to `false` and exit to prevent redundant updates. - * - If `isPageLoading` is `false`, proceed with portfolio field updates. - * - * 2. **Portfolio Selection**: - * - If a portfolio is selected (`portfolioDropdown.val()`), fetch the portfolio data. - * - Once data is fetched, run three update functions: - * - `updatePortfolioFieldsData`: Populates specific portfolio-related fields. - * - `updatePortfolioFieldsDisplay`: Handles the visibility of general portfolio fields. - * - `updatePortfolioFieldsDataDynamicDisplay`: Manages conditional display based on portfolio data. - * - If no portfolio is selected, only refreshes the field display using `updatePortfolioFieldsDisplay`. - * - * Dependencies: - * - Expects global elements (`portfolioDropdown`, etc.) and `isPageLoading` flag to be defined. - * - Assumes `getPortfolio`, `updatePortfolioFieldsData`, `updatePortfolioFieldsDisplay`, and `updatePortfolioFieldsDataDynamicDisplay` are available as functions. - */ - async function updatePortfolioFields() { - if (!isPageLoading) { - if (portfolioDropdown.val()) { - getPortfolio(portfolioDropdown.val()).then((portfolio) => { - updatePortfolioFieldsData(portfolio); - updatePortfolioFieldsDisplay(); - updatePortfolioFieldsDataDynamicDisplay(); - }); - } else { - updatePortfolioFieldsDisplay(); - } - } else { - isPageLoading = false; - } - } - - /** - * Updates the Suborganization Dropdown with new data based on the provided portfolio ID. - * - * This function uses the Select2 jQuery plugin to update the dropdown by fetching suborganization - * data relevant to the selected portfolio. Upon invocation, it checks if Select2 is already initialized - * on `suborganizationDropdown` and destroys the existing instance to avoid duplication. - * It then reinitializes Select2 with customized options for an AJAX request, allowing the user to search - * and select suborganizations dynamically, with results filtered based on `portfolio_id`. - * - * Key workflow: - * 1. **Document Ready**: Ensures that the function runs only once the DOM is fully loaded. - * 2. **Check and Reinitialize Select2**: - * - If Select2 is already initialized, it’s destroyed to refresh with new options. - * - Select2 is reinitialized with AJAX settings for dynamic data fetching. - * 3. **AJAX Options**: - * - **Data Function**: Prepares the query by capturing the user's search term (`params.term`) - * and the provided `portfolio_id` to filter relevant suborganizations. - * - **Data Type**: Ensures responses are returned as JSON. - * - **Delay**: Introduces a 250ms delay to prevent excessive requests on fast typing. - * - **Cache**: Enables caching to improve performance. - * 4. **Theme and Placeholder**: - * - Sets the dropdown theme to ‘admin-autocomplete’ for consistent styling. - * - Allows clearing of the dropdown and displays a placeholder as defined in the HTML. - * - * Dependencies: - * - Requires `suborganizationDropdown` element, the jQuery library, and the Select2 plugin. - * - `portfolio_id` is passed to filter results relevant to a specific portfolio. - */ - function updateSubOrganizationDropdown(portfolio_id) { - django.jQuery(document).ready(function() { - if (suborganizationDropdown.data('select2')) { - suborganizationDropdown.select2('destroy'); - } - // Reinitialize Select2 with the updated URL - suborganizationDropdown.select2({ - ajax: { - data: function (params) { - var query = { - search: params.term, - portfolio_id: portfolio_id - } - return query; - }, - dataType: 'json', - delay: 250, - cache: true - }, - theme: 'admin-autocomplete', - allowClear: true, - placeholder: suborganizationDropdown.attr('data-placeholder') - }); - }); - } - - /** - * Updates the display of portfolio-related fields based on whether a portfolio is selected. - * - * This function controls the visibility of specific fields by showing or hiding them - * depending on the presence of a selected portfolio ID in the dropdown. When a portfolio - * is selected, certain fields are shown (like suborganizations and portfolio-related fields), - * while others are hidden (like senior official and other employee-related fields). - * - * Workflow: - * 1. **Retrieve Portfolio ID**: - * - Fetches the selected value from `portfolioDropdown` to check if a portfolio is selected. - * - * 2. **Display Fields for Selected Portfolio**: - * - If a `portfolio_id` exists, it updates the `suborganizationDropdown` for the specific portfolio. - * - Shows or hides various fields to display only relevant portfolio information: - * - Shows `suborganizationField`, `portfolioSeniorOfficialField`, and fields related to the portfolio organization. - * - Hides fields that are not applicable when a portfolio is selected, such as `seniorOfficialField` and `otherEmployeesField`. - * - * 3. **Display Fields for No Portfolio Selected**: - * - If no portfolio is selected (i.e., `portfolio_id` is falsy), it reverses the visibility: - * - Hides `suborganizationField` and other portfolio-specific fields. - * - Shows fields that are applicable when no portfolio is selected, such as the `seniorOfficialField`. - * - * Dependencies: - * - `portfolioDropdown` is assumed to be a dropdown element containing portfolio IDs. - * - `showElement` and `hideElement` utility functions are used to control element visibility. - * - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used. - */ - function updatePortfolioFieldsDisplay() { - // Retrieve the selected portfolio ID - let portfolio_id = portfolioDropdown.val(); - - if (portfolio_id) { - // A portfolio is selected - update suborganization dropdown and show/hide relevant fields - - // Update suborganization dropdown for the selected portfolio - updateSubOrganizationDropdown(portfolio_id); - - // Show fields relevant to a selected portfolio - showElement(suborganizationField); - hideElement(seniorOfficialField); - showElement(portfolioSeniorOfficialField); - - // Hide fields not applicable when a portfolio is selected - hideElement(otherEmployeesField); - hideElement(noOtherContactsRationaleField); - hideElement(cisaRepresentativeFirstNameField); - hideElement(cisaRepresentativeLastNameField); - hideElement(cisaRepresentativeEmailField); - hideElement(orgTypeFieldSet); - hideElement(orgTypeFieldSetDetails); - hideElement(orgNameFieldSet); - hideElement(orgNameFieldSetDetails); - - // Show portfolio-specific fields - showElement(portfolioOrgTypeFieldSet); - showElement(portfolioOrgNameFieldSet); - showElement(portfolioOrgNameFieldSetDetails); - } else { - // No portfolio is selected - reverse visibility of fields - - // Hide suborganization field as no portfolio is selected - hideElement(suborganizationField); - - // Show fields that are relevant when no portfolio is selected - showElement(seniorOfficialField); - hideElement(portfolioSeniorOfficialField); - showElement(otherEmployeesField); - showElement(noOtherContactsRationaleField); - showElement(cisaRepresentativeFirstNameField); - showElement(cisaRepresentativeLastNameField); - showElement(cisaRepresentativeEmailField); - - // Show organization type and name fields - showElement(orgTypeFieldSet); - showElement(orgTypeFieldSetDetails); - showElement(orgNameFieldSet); - showElement(orgNameFieldSetDetails); - - // Hide portfolio-specific fields that aren’t applicable - hideElement(portfolioOrgTypeFieldSet); - hideElement(portfolioOrgNameFieldSet); - hideElement(portfolioOrgNameFieldSetDetails); - } - - updateSuborganizationFieldsDisplay(); - - } - - /** - * Updates the visibility of suborganization-related fields based on the selected value in the suborganization dropdown. - * - * If a suborganization is selected: - * - Hides the fields related to requesting a new suborganization (`requestedSuborganizationField`). - * - Hides the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields for the suborganization. - * - * If no suborganization is selected: - * - Shows the fields for requesting a new suborganization (`requestedSuborganizationField`). - * - Displays the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields. - * - * This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested. - */ - function updateSuborganizationFieldsDisplay() { - let portfolio_id = portfolioDropdown.val(); - let suborganization_id = suborganizationDropdown.val(); - - if (portfolio_id && !suborganization_id) { - // Show suborganization request fields - showElement(requestedSuborganizationField); - showElement(suborganizationCity); - showElement(suborganizationStateTerritory); - } else { - // Hide suborganization request fields if suborganization is selected - hideElement(requestedSuborganizationField); - hideElement(suborganizationCity); - hideElement(suborganizationStateTerritory); - } - } - - /** - * Initializes necessary data and display configurations for the portfolio fields. - */ - function initializePortfolioSettings() { - // Update the visibility of portfolio-related fields based on current dropdown selection. - updatePortfolioFieldsDisplay(); - - // Dynamically adjust the display of certain fields based on the selected portfolio's characteristics. - updatePortfolioFieldsDataDynamicDisplay(); - } - - /** - * Sets event listeners for key UI elements. - */ - function setEventListeners() { - // When the `portfolioDropdown` selection changes, refresh the displayed portfolio fields. - portfolioDropdown.on("change", updatePortfolioFields); - // When the 'suborganizationDropdown' selection changes - suborganizationDropdown.on("change", updateSuborganizationFieldsDisplay); - } - - // Run initial setup functions - initializePortfolioSettings(); - setEventListeners(); -} - -// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> -// Initialization code. - - -/** An IIFE for pages in DjangoAdmin that use modals. - * Dja strips out form elements, and modals generate their content outside - * of the current form scope, so we need to "inject" these inputs. -*/ -(function (){ - function createPhantomModalFormButtons(){ - let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"].dja-form-placeholder'); - form = document.querySelector("form") - submitButtons.forEach((button) => { - - let input = document.createElement("input"); - input.type = "submit"; - - if(button.name){ - input.name = button.name; - } - - if(button.value){ - input.value = button.value; - } - - input.style.display = "none" - - // Add the hidden input to the form - form.appendChild(input); - button.addEventListener("click", () => { - input.click(); - }) - }) - } - - createPhantomModalFormButtons(); -})(); - - -/** An IIFE for DomainRequest to hook a modal to a dropdown option. - * This intentionally does not interact with createPhantomModalFormButtons() -*/ -(function (){ - function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){ - - // If these exist all at the same time, we're on the right page - if (linkClickedDisplaysModal && statusDropdown && statusDropdown.value){ - - // Set the previous value in the event the user cancels. - let previousValue = statusDropdown.value; - if (actionButton){ - - // Otherwise, if the confirmation buttion is pressed, set it to that - actionButton.addEventListener('click', function() { - // Revert the dropdown to its previous value - statusDropdown.value = valueToCheck; - }); - }else { - console.log("displayModalOnDropdownClick() -> Cancel button was null") - } - - // Add a change event listener to the dropdown. - statusDropdown.addEventListener('change', function() { - // Check if "Ineligible" is selected - if (this.value && this.value.toLowerCase() === valueToCheck) { - // Set the old value in the event the user cancels, - // or otherwise exists the dropdown. - statusDropdown.value = previousValue - - // Display the modal. - linkClickedDisplaysModal.click() - } - }); - } - } - - // When the status dropdown is clicked and is set to "ineligible", toggle a confirmation dropdown. - function hookModalToIneligibleStatus(){ - // Grab the invisible element that will hook to the modal. - // This doesn't technically need to be done with one, but this is simpler to manage. - let modalButton = document.getElementById("invisible-ineligible-modal-toggler") - let statusDropdown = document.getElementById("id_status") - - // Because the modal button does not have the class "dja-form-placeholder", - // it will not be affected by the createPhantomModalFormButtons() function. - let actionButton = document.querySelector('button[name="_set_domain_request_ineligible"]'); - let valueToCheck = "ineligible" - displayModalOnDropdownClick(modalButton, statusDropdown, actionButton, valueToCheck); - } - - hookModalToIneligibleStatus() -})(); - -/** An IIFE for pages in DjangoAdmin which may need custom JS implementation. - * Currently only appends target="_blank" to the domain_form object, - * but this can be expanded. -*/ -(function (){ - /* - On mouseover, appends target="_blank" on domain_form under the Domain page. - The reason for this is that the template has a form that contains multiple buttons. - The structure of that template complicates seperating those buttons - out of the form (while maintaining the same position on the page). - However, if we want to open one of those submit actions to a new tab - - such as the manage domain button - we need to dynamically append target. - As there is no built-in django method which handles this, we do it here. - */ - function prepareDjangoAdmin() { - let domainFormElement = document.getElementById("domain_form"); - let domainSubmitButton = document.getElementById("manageDomainSubmitButton"); - if(domainSubmitButton && domainFormElement){ - domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true)); - domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false)); - } - } - - prepareDjangoAdmin(); -})(); - - -/** An IIFE for the "Assign to me" button under the investigator field in DomainRequests. -** This field uses the "select2" selector, rather than the default. -** To perform data operations on this - we need to use jQuery rather than vanilla js. -*/ -(function (){ - if (document.getElementById("id_investigator") && django && django.jQuery) { - let selector = django.jQuery("#id_investigator") - let assignSelfButton = document.querySelector("#investigator__assign_self"); - if (!selector || !assignSelfButton) { - return; - } - - let currentUserId = assignSelfButton.getAttribute("data-user-id"); - let currentUserName = assignSelfButton.getAttribute("data-user-name"); - if (!currentUserId || !currentUserName){ - console.error("Could not assign current user: no values found.") - return; - } - - // Hook a click listener to the "Assign to me" button. - // Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists - assignSelfButton.addEventListener("click", function() { - if (selector.find(`option[value='${currentUserId}']`).length) { - // Select the value that is associated with the current user. - selector.val(currentUserId).trigger("change"); - } else { - // Create a DOM Option that matches the desired user. Then append it and select it. - let userOption = new Option(currentUserName, currentUserId, true, true); - selector.append(userOption).trigger("change"); - } - }); - - // Listen to any change events, and hide the parent container if investigator has a value. - selector.on('change', function() { - // The parent container has display type flex. - assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex"; - }); - } -})(); - -/** An IIFE for pages in DjangoAdmin that use a clipboard button -*/ -(function (){ - - function copyToClipboardAndChangeIcon(button) { - // Assuming the input is the previous sibling of the button - let input = button.previousElementSibling; - // Copy input value to clipboard - if (input) { - navigator.clipboard.writeText(input.value).then(function() { - // Change the icon to a checkmark on successful copy - let buttonIcon = button.querySelector('.copy-to-clipboard use'); - if (buttonIcon) { - let currentHref = buttonIcon.getAttribute('xlink:href'); - let baseHref = currentHref.split('#')[0]; - - // Append the new icon reference - buttonIcon.setAttribute('xlink:href', baseHref + '#check'); - - // Change the button text - let nearestSpan = button.querySelector("span") - let original_text = nearestSpan.innerText - nearestSpan.innerText = "Copied to clipboard" - - setTimeout(function() { - // Change back to the copy icon - buttonIcon.setAttribute('xlink:href', currentHref); - nearestSpan.innerText = original_text; - }, 2000); - - } - }).catch(function(error) { - console.error('Clipboard copy failed', error); - }); - } - } - - function handleClipboardButtons() { - clipboardButtons = document.querySelectorAll(".copy-to-clipboard") - clipboardButtons.forEach((button) => { - - // Handle copying the text to your clipboard, - // and changing the icon. - button.addEventListener("click", ()=>{ - copyToClipboardAndChangeIcon(button); - }); - - // Add a class that adds the outline style on click - button.addEventListener("mousedown", function() { - this.classList.add("no-outline-on-click"); - }); - - // But add it back in after the user clicked, - // for accessibility reasons (so we can still tab, etc) - button.addEventListener("blur", function() { - this.classList.remove("no-outline-on-click"); - }); - - }); - } - - handleClipboardButtons(); -})(); - - -/** - * An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable - * - */ -(function extendFilterHorizontalWidgets() { - // Initialize custom filter_horizontal widgets; each widget has a "from" select list - // and a "to" select list; initialization is based off of the presence of the - // "to" select list - checkToListThenInitWidget('id_groups_to', 0); - checkToListThenInitWidget('id_user_permissions_to', 0); - checkToListThenInitWidget('id_portfolio_roles_to', 0); - checkToListThenInitWidget('id_portfolio_additional_permissions_to', 0); -})(); - -// Function to check for the existence of the "to" select list element in the DOM, and if and when found, -// initialize the associated widget -function checkToListThenInitWidget(toListId, attempts) { - let toList = document.getElementById(toListId); - attempts++; - - if (attempts < 12) { - if (toList) { - // toList found, handle it - // Then get fromList and handle it - initializeWidgetOnList(toList, ".selector-chosen"); - let fromList = toList.closest('.selector').querySelector(".selector-available select"); - initializeWidgetOnList(fromList, ".selector-available"); - } else { - // Element not found, check again after a delay - setTimeout(() => checkToListThenInitWidget(toListId, attempts), 300); // Check every 300 milliseconds - } - } -} - -// Initialize the widget: -// Replace h2 with more semantic h3 -function initializeWidgetOnList(list, parentId) { - if (list) { - // Get h2 and its container - const parentElement = list.closest(parentId); - const h2Element = parentElement.querySelector('h2'); - - // One last check - if (parentElement && h2Element) { - // Create a new

element - const h3Element = document.createElement('h3'); - - // Copy the text content from the

element to the

element - h3Element.textContent = h2Element.textContent; - - // Find the nested element inside the

- const nestedSpan = h2Element.querySelector('span[class][title]'); - - // If the nested element exists - if (nestedSpan) { - // Create a new element - const newSpan = document.createElement('span'); - - // Copy the class and title attributes from the nested element - newSpan.className = nestedSpan.className; - newSpan.title = nestedSpan.title; - - // Append the new element to the

element - h3Element.appendChild(newSpan); - } - - // Replace the

element with the new

element - parentElement.replaceChild(h3Element, h2Element); - } - } -} - - -/** An IIFE for toggling the submit bar on domain request forms -*/ -(function (){ - // Get a reference to the button element - const toggleButton = document.getElementById('submitRowToggle'); - const submitRowWrapper = document.querySelector('.submit-row-wrapper'); - - if (toggleButton) { - // Add event listener to toggle the class and update content on click - toggleButton.addEventListener('click', function() { - // Toggle the 'collapsed' class on the bar - submitRowWrapper.classList.toggle('submit-row-wrapper--collapsed'); - - // Get a reference to the span element inside the button - const spanElement = this.querySelector('span'); - - // Get a reference to the use element inside the button - const useElement = this.querySelector('use'); - - // Check if the span element text is 'Hide' - if (spanElement.textContent.trim() === 'Hide') { - // Update the span element text to 'Show' - spanElement.textContent = 'Show'; - - // Update the xlink:href attribute to expand_more - useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); - } else { - // Update the span element text to 'Hide' - spanElement.textContent = 'Hide'; - - // Update the xlink:href attribute to expand_less - useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); - } - }); - - // We have a scroll indicator at the end of the page. - // Observe it. Once it gets on screen, test to see if the row is collapsed. - // If it is, expand it. - const targetElement = document.querySelector(".scroll-indicator"); - const options = { - threshold: 1 - }; - // Create a new Intersection Observer - const observer = new IntersectionObserver((entries, observer) => { - entries.forEach(entry => { - if (entry.isIntersecting) { - // Refresh reference to submit row wrapper and check if it's collapsed - if (document.querySelector('.submit-row-wrapper').classList.contains('submit-row-wrapper--collapsed')) { - toggleButton.click(); - } - } - }); - }, options); - observer.observe(targetElement); - } -})(); - -/** An IIFE for toggling the overflow styles on django-admin__model-description (the show more / show less button) */ -(function () { - function handleShowMoreButton(toggleButton, descriptionDiv){ - // Check the length of the text content in the description div - if (descriptionDiv.textContent.length < 200) { - // Hide the toggle button if text content is less than 200 characters - // This is a little over 160 characters to give us some wiggle room if we - // change the font size marginally. - toggleButton.classList.add('display-none'); - } else { - toggleButton.addEventListener('click', function() { - toggleShowMoreButton(toggleButton, descriptionDiv, 'dja__model-description--no-overflow') - }); - } - } - - function toggleShowMoreButton(toggleButton, descriptionDiv, showMoreClassName){ - // Toggle the class on the description div - descriptionDiv.classList.toggle(showMoreClassName); - - // Change the button text based on the presence of the class - if (descriptionDiv.classList.contains(showMoreClassName)) { - toggleButton.textContent = 'Show less'; - } else { - toggleButton.textContent = 'Show more'; - } - } - - let toggleButton = document.getElementById('dja-show-more-model-description'); - let descriptionDiv = document.querySelector('.dja__model-description'); - if (toggleButton && descriptionDiv) { - handleShowMoreButton(toggleButton, descriptionDiv) - } -})(); - - -class CustomizableEmailBase { - - /** - * @param {Object} config - must contain the following: - * @property {HTMLElement} dropdown - The dropdown element. - * @property {HTMLElement} textarea - The textarea element. - * @property {HTMLElement} lastSentEmailContent - The last sent email content element. - * @property {HTMLElement} textAreaFormGroup - The form group for the textarea. - * @property {HTMLElement} dropdownFormGroup - The form group for the dropdown. - * @property {HTMLElement} modalConfirm - The confirm button in the modal. - * @property {string} apiUrl - The API URL for fetching email content. - * @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup. - * @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup. - * @property {string} apiErrorMessage - The error message that the ajax call returns. - */ - constructor(config) { - this.config = config; - this.dropdown = config.dropdown; - this.textarea = config.textarea; - this.lastSentEmailContent = config.lastSentEmailContent; - this.apiUrl = config.apiUrl; - this.apiErrorMessage = config.apiErrorMessage; - this.modalConfirm = config.modalConfirm; - - // These fields are hidden/shown on pageload depending on the current status - this.textAreaFormGroup = config.textAreaFormGroup; - this.dropdownFormGroup = config.dropdownFormGroup; - this.statusToCheck = config.statusToCheck; - this.sessionVariableName = config.sessionVariableName; - - // Non-configurable variables - this.statusSelect = document.getElementById("id_status"); - this.domainRequestId = this.dropdown ? document.getElementById("domain_request_id").value : null - this.initialDropdownValue = this.dropdown ? this.dropdown.value : null; - this.initialEmailValue = this.textarea ? this.textarea.value : null; - - // Find other fields near the textarea - const parentDiv = this.textarea ? this.textarea.closest(".flex-container") : null; - this.directEditButton = parentDiv ? parentDiv.querySelector(".edit-email-button") : null; - this.modalTrigger = parentDiv ? parentDiv.querySelector(".edit-button-modal-trigger") : null; - - this.textareaPlaceholder = parentDiv ? parentDiv.querySelector(".custom-email-placeholder") : null; - this.formLabel = this.textarea ? document.querySelector(`label[for="${this.textarea.id}"]`) : null; - - this.isEmailAlreadySentConst; - if (this.lastSentEmailContent && this.textarea) { - this.isEmailAlreadySentConst = this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, ''); - } - - } - - // Handle showing/hiding the related fields on page load. - initializeFormGroups() { - let isStatus = this.statusSelect.value == this.statusToCheck; - - // Initial handling of these groups. - this.updateFormGroupVisibility(isStatus); - - // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage - this.statusSelect.addEventListener('change', () => { - // Show the action needed field if the status is what we expect. - // Then track if its shown or hidden in our session cache. - isStatus = this.statusSelect.value == this.statusToCheck; - this.updateFormGroupVisibility(isStatus); - addOrRemoveSessionBoolean(this.sessionVariableName, isStatus); - }); - - // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage - // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the - // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide - // accurately for this edge case, we use cache and test for the back/forward navigation. - const observer = new PerformanceObserver((list) => { - list.getEntries().forEach((entry) => { - if (entry.type === "back_forward") { - let showTextAreaFormGroup = sessionStorage.getItem(this.sessionVariableName) !== null; - this.updateFormGroupVisibility(showTextAreaFormGroup); - } - }); - }); - observer.observe({ type: "navigation" }); - } - - updateFormGroupVisibility(showFormGroups) { - if (showFormGroups) { - showElement(this.textAreaFormGroup); - showElement(this.dropdownFormGroup); - }else { - hideElement(this.textAreaFormGroup); - hideElement(this.dropdownFormGroup); - } - } - - initializeDropdown() { - this.dropdown.addEventListener("change", () => { - let reason = this.dropdown.value; - if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) { - let searchParams = new URLSearchParams( - { - "reason": reason, - "domain_request_id": this.domainRequestId, - } - ); - // Replace the email content - fetch(`${this.apiUrl}?${searchParams.toString()}`) - .then(response => { - return response.json().then(data => data); - }) - .then(data => { - if (data.error) { - console.error("Error in AJAX call: " + data.error); - }else { - this.textarea.value = data.email; - } - this.updateUserInterface(reason); - }) - .catch(error => { - console.error(this.apiErrorMessage, error) - }); - } - }); - } - - initializeModalConfirm() { - this.modalConfirm.addEventListener("click", () => { - this.textarea.removeAttribute('readonly'); - this.textarea.focus(); - hideElement(this.directEditButton); - hideElement(this.modalTrigger); - }); - } - - initializeDirectEditButton() { - this.directEditButton.addEventListener("click", () => { - this.textarea.removeAttribute('readonly'); - this.textarea.focus(); - hideElement(this.directEditButton); - hideElement(this.modalTrigger); - }); - } - - isEmailAlreadySent() { - return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, ''); - } - - updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) { - if (!reason) { - // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text - this.showPlaceholderNoReason(); - } else if (excluded_reasons.includes(reason)) { - // 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text - this.showPlaceholderOtherReason(); - } else { - this.showReadonlyTextarea(); - } - } - - // Helper function that makes overriding the readonly textarea easy - showReadonlyTextarea() { - // A triggering selection is selected, all hands on board: - this.textarea.setAttribute('readonly', true); - showElement(this.textarea); - hideElement(this.textareaPlaceholder); - - if (this.isEmailAlreadySentConst) { - hideElement(this.directEditButton); - showElement(this.modalTrigger); - } else { - showElement(this.directEditButton); - hideElement(this.modalTrigger); - } - - if (this.isEmailAlreadySent()) { - this.formLabel.innerHTML = "Email sent to creator:"; - } else { - this.formLabel.innerHTML = "Email:"; - } - } - - // Helper function that makes overriding the placeholder reason easy - showPlaceholderNoReason() { - this.showPlaceholder("Email:", "Select a reason to see email"); - } - - // Helper function that makes overriding the placeholder reason easy - showPlaceholderOtherReason() { - this.showPlaceholder("Email:", "No email will be sent"); - } - - showPlaceholder(formLabelText, placeholderText) { - this.formLabel.innerHTML = formLabelText; - this.textareaPlaceholder.innerHTML = placeholderText; - showElement(this.textareaPlaceholder); - hideElement(this.directEditButton); - hideElement(this.modalTrigger); - hideElement(this.textarea); - } -} - - - -class customActionNeededEmail extends CustomizableEmailBase { - constructor() { - const emailConfig = { - dropdown: document.getElementById("id_action_needed_reason"), - textarea: document.getElementById("id_action_needed_reason_email"), - lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"), - modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"), - apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null, - textAreaFormGroup: document.querySelector('.field-action_needed_reason'), - dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'), - statusToCheck: "action needed", - sessionVariableName: "showActionNeededReason", - apiErrorMessage: "Error when attempting to grab action needed email: " - } - super(emailConfig); - } - - loadActionNeededEmail() { - // Hide/show the email fields depending on the current status - this.initializeFormGroups(); - // Setup the textarea, edit button, helper text - this.updateUserInterface(); - this.initializeDropdown(); - this.initializeModalConfirm(); - this.initializeDirectEditButton(); - } - - // Overrides the placeholder text when no reason is selected - showPlaceholderNoReason() { - this.showPlaceholder("Email:", "Select an action needed reason to see email"); - } - - // Overrides the placeholder text when the reason other is selected - showPlaceholderOtherReason() { - this.showPlaceholder("Email:", "No email will be sent"); - } -} - -/** An IIFE that hooks to the show/hide button underneath action needed reason. - * This shows the auto generated email on action needed reason. -*/ -document.addEventListener('DOMContentLoaded', function() { - const domainRequestForm = document.getElementById("domainrequest_form"); - if (!domainRequestForm) { - return; - } - - // Initialize UI - const customEmail = new customActionNeededEmail(); - - // Check that every variable was setup correctly - const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); - if (nullItems.length > 0) { - console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`) - return; - } - customEmail.loadActionNeededEmail() -}); - - -class customRejectedEmail extends CustomizableEmailBase { - constructor() { - const emailConfig = { - dropdown: document.getElementById("id_rejection_reason"), - textarea: document.getElementById("id_rejection_reason_email"), - lastSentEmailContent: document.getElementById("last-sent-rejection-email-content"), - modalConfirm: document.getElementById("rejection-reason__confirm-edit-email"), - apiUrl: document.getElementById("get-rejection-email-for-user-json")?.value || null, - textAreaFormGroup: document.querySelector('.field-rejection_reason'), - dropdownFormGroup: document.querySelector('.field-rejection_reason_email'), - statusToCheck: "rejected", - sessionVariableName: "showRejectionReason", - errorMessage: "Error when attempting to grab rejected email: " - }; - super(emailConfig); - } - - loadRejectedEmail() { - this.initializeFormGroups(); - this.updateUserInterface(); - this.initializeDropdown(); - this.initializeModalConfirm(); - this.initializeDirectEditButton(); - } - - // Overrides the placeholder text when no reason is selected - showPlaceholderNoReason() { - this.showPlaceholder("Email:", "Select a rejection reason to see email"); - } - - updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) { - super.updateUserInterface(reason, excluded_reasons); - } - // Overrides the placeholder text when the reason other is selected - // showPlaceholderOtherReason() { - // this.showPlaceholder("Email:", "No email will be sent"); - // } -} - - -/** An IIFE that hooks to the show/hide button underneath rejected reason. - * This shows the auto generated email on action needed reason. -*/ -document.addEventListener('DOMContentLoaded', function() { - const domainRequestForm = document.getElementById("domainrequest_form"); - if (!domainRequestForm) { - return; - } - - // Initialize UI - const customEmail = new customRejectedEmail(); - // Check that every variable was setup correctly - const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); - if (nullItems.length > 0) { - console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`) - return; - } - customEmail.loadRejectedEmail() -}); - -/** An IIFE that hides and shows approved domain select2 row in domain request - * conditionally based on the Status field selection. If Approved, show. If not Approved, - * don't show. - */ -document.addEventListener('DOMContentLoaded', function() { - const domainRequestForm = document.getElementById("domainrequest_form"); - if (!domainRequestForm) { - return; - } - - const statusToCheck = "approved"; - const statusSelect = document.getElementById("id_status"); - const sessionVariableName = "showApprovedDomain"; - let approvedDomainFormGroup = document.querySelector(".field-approved_domain"); - - function updateFormGroupVisibility(showFormGroups) { - if (showFormGroups) { - showElement(approvedDomainFormGroup); - }else { - hideElement(approvedDomainFormGroup); - } - } - - // Handle showing/hiding the related fields on page load. - function initializeFormGroups() { - let isStatus = statusSelect.value == statusToCheck; - - // Initial handling of these groups. - updateFormGroupVisibility(isStatus); - - // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage - statusSelect.addEventListener('change', () => { - // Show the approved if the status is what we expect. - isStatus = statusSelect.value == statusToCheck; - updateFormGroupVisibility(isStatus); - addOrRemoveSessionBoolean(sessionVariableName, isStatus); - }); - - // Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage - // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the - // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide - // accurately for this edge case, we use cache and test for the back/forward navigation. - const observer = new PerformanceObserver((list) => { - list.getEntries().forEach((entry) => { - if (entry.type === "back_forward") { - let showTextAreaFormGroup = sessionStorage.getItem(sessionVariableName) !== null; - updateFormGroupVisibility(showTextAreaFormGroup); - } - }); - }); - observer.observe({ type: "navigation" }); - } - - initializeFormGroups(); - -}); - - -/** An IIFE for copy summary button (appears in DomainRegistry models) -*/ -(function (){ - const copyButton = document.getElementById('id-copy-to-clipboard-summary'); - - if (copyButton) { - copyButton.addEventListener('click', function() { - /// Generate a rich HTML summary text and copy to clipboard - - //------ Organization Type - const organizationTypeElement = document.getElementById('id_organization_type'); - const organizationType = organizationTypeElement.options[organizationTypeElement.selectedIndex].text; - - //------ Alternative Domains - const alternativeDomainsDiv = document.querySelector('.form-row.field-alternative_domains .readonly'); - const alternativeDomainslinks = alternativeDomainsDiv.querySelectorAll('a'); - const alternativeDomains = Array.from(alternativeDomainslinks).map(link => link.textContent); - - //------ Existing Websites - const existingWebsitesDiv = document.querySelector('.form-row.field-current_websites .readonly'); - const existingWebsiteslinks = existingWebsitesDiv.querySelectorAll('a'); - const existingWebsites = Array.from(existingWebsiteslinks).map(link => link.textContent); - - //------ Additional Contacts - // 1 - Create a hyperlinks map so we can display contact details and also link to the contact - const otherContactsDiv = document.querySelector('.form-row.field-other_contacts .readonly'); - let otherContactLinks = []; - const nameToUrlMap = {}; - if (otherContactsDiv) { - otherContactLinks = otherContactsDiv.querySelectorAll('a'); - otherContactLinks.forEach(link => { - const name = link.textContent.trim(); - const url = link.href; - nameToUrlMap[name] = url; - }); - } - - // 2 - Iterate through contact details and assemble html for summary - let otherContactsSummary = "" - const bulletList = document.createElement('ul'); - - // CASE 1 - Contacts are not in a table (this happens if there is only one or two other contacts) - const contacts = document.querySelectorAll('.field-other_contacts .dja-detail-list dd'); - if (contacts) { - contacts.forEach(contact => { - // Check if the
element is not empty - const name = contact.querySelector('a.contact_info_name')?.innerText; - const title = contact.querySelector('span.contact_info_title')?.innerText; - const email = contact.querySelector('span.contact_info_email')?.innerText; - const phone = contact.querySelector('span.contact_info_phone')?.innerText; - const url = nameToUrlMap[name] || '#'; - // Format the contact information - const listItem = document.createElement('li'); - listItem.innerHTML = `${name}, ${title}, ${email}, ${phone}`; - bulletList.appendChild(listItem); - }); - - } - - // CASE 2 - Contacts are in a table (this happens if there is more than 2 contacts) - const otherContactsTable = document.querySelector('.form-row.field-other_contacts table tbody'); - if (otherContactsTable) { - const otherContactsRows = otherContactsTable.querySelectorAll('tr'); - otherContactsRows.forEach(contactRow => { - // Extract the contact details - const name = contactRow.querySelector('th').textContent.trim(); - const title = contactRow.querySelectorAll('td')[0].textContent.trim(); - const email = contactRow.querySelectorAll('td')[1].textContent.trim(); - const phone = contactRow.querySelectorAll('td')[2].textContent.trim(); - const url = nameToUrlMap[name] || '#'; - // Format the contact information - const listItem = document.createElement('li'); - listItem.innerHTML = `${name}, ${title}, ${email}, ${phone}`; - bulletList.appendChild(listItem); - }); - } - otherContactsSummary += bulletList.outerHTML - - - //------ Requested Domains - const requestedDomainElement = document.getElementById('id_requested_domain'); - // We have to account for different superuser and analyst markups - const requestedDomain = requestedDomainElement.options - ? requestedDomainElement.options[requestedDomainElement.selectedIndex].text - : requestedDomainElement.text; - - //------ Submitter - // Function to extract text by ID and handle missing elements - function extractTextById(id, divElement) { - if (divElement) { - const element = divElement.querySelector(`#${id}`); - return element ? ", " + element.textContent.trim() : ''; - } - return ''; - } - - //------ Senior Official - const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); - const seniorOfficialElement = document.getElementById('id_senior_official'); - const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text; - const seniorOfficialTitle = seniorOfficialDiv.querySelector('.contact_info_title'); - const seniorOfficialEmail = seniorOfficialDiv.querySelector('.contact_info_email'); - const seniorOfficialPhone = seniorOfficialDiv.querySelector('.contact_info_phone'); - let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`; - - const html_summary = `Recommendation:
` + - `Organization Type: ${organizationType}
` + - `Requested Domain: ${requestedDomain}
` + - `Current Websites: ${existingWebsites.join(', ')}
` + - `Rationale:
` + - `Alternative Domains: ${alternativeDomains.join(', ')}
` + - `Senior Official: ${seniorOfficialInfo}
` + - `Other Employees: ${otherContactsSummary}
`; - - //Replace
with \n, then strip out all remaining html tags (replace <...> with '') - const plain_summary = html_summary.replace(/<\/br>|
/g, '\n').replace(/<\/?[^>]+(>|$)/g, ''); - - // Create Blobs with the summary content - const html_blob = new Blob([html_summary], { type: 'text/html' }); - const plain_blob = new Blob([plain_summary], { type: 'text/plain' }); - - // Create a ClipboardItem with the Blobs - const clipboardItem = new ClipboardItem({ - 'text/html': html_blob, - 'text/plain': plain_blob - }); - - // Write the ClipboardItem to the clipboard - navigator.clipboard.write([clipboardItem]).then(() => { - // Change the icon to a checkmark on successful copy - let buttonIcon = copyButton.querySelector('use'); - if (buttonIcon) { - let currentHref = buttonIcon.getAttribute('xlink:href'); - let baseHref = currentHref.split('#')[0]; - - // Append the new icon reference - buttonIcon.setAttribute('xlink:href', baseHref + '#check'); - - // Change the button text - nearestSpan = copyButton.querySelector("span") - original_text = nearestSpan.innerText - nearestSpan.innerText = "Copied to clipboard" - - setTimeout(function() { - // Change back to the copy icon - buttonIcon.setAttribute('xlink:href', currentHref); - nearestSpan.innerText = original_text - }, 2000); - - } - console.log('Summary copied to clipboard successfully!'); - }).catch(err => { - console.error('Failed to copy text: ', err); - }); - }); - } -})(); - - -/** An IIFE for dynamically changing some fields on the portfolio admin model - * IMPORTANT NOTE: The logic in this IIFE is paired handlePortfolioSelection -*/ -(function dynamicPortfolioFields(){ - - // the federal agency change listener fires on page load, which we don't want. - var isInitialPageLoad = true - - // This is the additional information that exists beneath the SO element. - var contactList = document.querySelector(".field-senior_official .dja-address-contact-list"); - const federalAgencyContainer = document.querySelector(".field-federal_agency"); - document.addEventListener('DOMContentLoaded', function() { - - let isPortfolioPage = document.getElementById("portfolio_form"); - if (!isPortfolioPage) { - return; - } - - // $ symbolically denotes that this is using jQuery - let $federalAgency = django.jQuery("#id_federal_agency"); - let organizationType = document.getElementById("id_organization_type"); - let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly"); - - let organizationNameContainer = document.querySelector(".field-organization_name"); - let federalType = document.querySelector(".field-federal_type"); - - if ($federalAgency && (organizationType || readonlyOrganizationType)) { - // Attach the change event listener - $federalAgency.on("change", function() { - handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType); - }); - } - - // Handle dynamically hiding the urbanization field - let urbanizationField = document.querySelector(".field-urbanization"); - let stateTerritory = document.getElementById("id_state_territory"); - if (urbanizationField && stateTerritory) { - // Execute this function once on load - handleStateTerritoryChange(stateTerritory, urbanizationField); - - // Attach the change event listener for state/territory - stateTerritory.addEventListener("change", function() { - handleStateTerritoryChange(stateTerritory, urbanizationField); - }); - } - - // Handle hiding the organization name field when the organization_type is federal. - // Run this first one page load, then secondly on a change event. - handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); - organizationType.addEventListener("change", function() { - handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); - }); - }); - - function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) { - if (organizationType && organizationNameContainer) { - let selectedValue = organizationType.value; - if (selectedValue === "federal") { - hideElement(organizationNameContainer); - showElement(federalAgencyContainer); - if (federalType) { - showElement(federalType); - } - } else { - showElement(organizationNameContainer); - hideElement(federalAgencyContainer); - if (federalType) { - hideElement(federalType); - } - } - } - } - - function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) { - // Don't do anything on page load - if (isInitialPageLoad) { - isInitialPageLoad = false; - return; - } - - // Set the org type to federal if an agency is selected - let selectedText = federalAgency.find("option:selected").text(); - - // There isn't a federal senior official associated with null records - if (!selectedText) { - return; - } - - let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase(); - if (selectedText !== "Non-Federal Agency") { - if (organizationTypeValue !== "federal") { - if (organizationType){ - organizationType.value = "federal"; - }else { - readonlyOrganizationType.innerText = "Federal" - } - } - }else { - if (organizationTypeValue === "federal") { - if (organizationType){ - organizationType.value = ""; - }else { - readonlyOrganizationType.innerText = "-" - } - } - } - - handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); - - // Determine if any changes are necessary to the display of portfolio type or federal type - // based on changes to the Federal Agency - let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; - fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`) - .then(response => { - const statusCode = response.status; - return response.json().then(data => ({ statusCode, data })); - }) - .then(({ statusCode, data }) => { - if (data.error) { - console.error("Error in AJAX call: " + data.error); - return; - } - updateReadOnly(data.federal_type, '.field-federal_type'); - }) - .catch(error => console.error("Error fetching federal and portfolio types: ", error)); - - // Hide the contactList initially. - // If we can update the contact information, it'll be shown again. - hideElement(contactList.parentElement); - - let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; - let $seniorOfficial = django.jQuery("#id_senior_official"); - let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly"); - let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; - fetch(`${seniorOfficialApi}?agency_name=${selectedText}`) - .then(response => { - const statusCode = response.status; - return response.json().then(data => ({ statusCode, data })); - }) - .then(({ statusCode, data }) => { - if (data.error) { - // Clear the field if the SO doesn't exist. - if (statusCode === 404) { - if ($seniorOfficial && $seniorOfficial.length > 0) { - $seniorOfficial.val("").trigger("change"); - }else { - // Show the "create one now" text if this field is none in readonly mode. - readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; - } - console.warn("Record not found: " + data.error); - }else { - console.error("Error in AJAX call: " + data.error); - } - return; - } - - // Update the "contact details" blurb beneath senior official - updateContactInfo(data); - showElement(contactList.parentElement); - - // Get the associated senior official with this federal agency - let seniorOfficialId = data.id; - let seniorOfficialName = [data.first_name, data.last_name].join(" "); - if ($seniorOfficial && $seniorOfficial.length > 0) { - // If the senior official is a dropdown field, edit that - updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); - }else { - if (readonlySeniorOfficial) { - let seniorOfficialLink = `${seniorOfficialName}` - readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; - } - } - }) - .catch(error => console.error("Error fetching senior official: ", error)); - - } - - function updateSeniorOfficialDropdown(dropdown, seniorOfficialId, seniorOfficialName) { - if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){ - // Clear the field if the SO doesn't exist - dropdown.val("").trigger("change"); - return; - } - - // Add the senior official to the dropdown. - // This format supports select2 - if we decide to convert this field in the future. - if (dropdown.find(`option[value='${seniorOfficialId}']`).length) { - // Select the value that is associated with the current Senior Official. - dropdown.val(seniorOfficialId).trigger("change"); - } else { - // Create a DOM Option that matches the desired Senior Official. Then append it and select it. - let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true); - dropdown.append(userOption).trigger("change"); - } - } - - function handleStateTerritoryChange(stateTerritory, urbanizationField) { - let selectedValue = stateTerritory.value; - if (selectedValue === "PR") { - showElement(urbanizationField) - } else { - hideElement(urbanizationField) - } - } - - /** - * Utility that selects a div from the DOM using selectorString, - * and updates a div within that div which has class of 'readonly' - * so that the text of the div is updated to updateText - * @param {*} updateText - * @param {*} selectorString - */ - function updateReadOnly(updateText, selectorString) { - // find the div by selectorString - const selectedDiv = document.querySelector(selectorString); - if (selectedDiv) { - // find the nested div with class 'readonly' inside the selectorString div - const readonlyDiv = selectedDiv.querySelector('.readonly'); - if (readonlyDiv) { - // Update the text content of the readonly div - readonlyDiv.textContent = updateText !== null ? updateText : '-'; - } - } - } - - function updateContactInfo(data) { - if (!contactList) return; - - const titleSpan = contactList.querySelector(".contact_info_title"); - const emailSpan = contactList.querySelector(".contact_info_email"); - const phoneSpan = contactList.querySelector(".contact_info_phone"); - - if (titleSpan) { - titleSpan.textContent = data.title || "None"; - }; - - // Update the email field and the content for the clipboard - if (emailSpan) { - let copyButton = contactList.querySelector(".admin-icon-group"); - emailSpan.textContent = data.email || "None"; - if (data.email) { - const clipboardInput = contactList.querySelector(".admin-icon-group input"); - if (clipboardInput) { - clipboardInput.value = data.email; - }; - showElement(copyButton); - }else { - hideElement(copyButton); - } - } - - if (phoneSpan) { - phoneSpan.textContent = data.phone || "None"; - }; - } -})(); - -/** An IIFE for dynamic DomainRequest fields -*/ -(function dynamicDomainRequestFields(){ - const domainRequestPage = document.getElementById("domainrequest_form"); - if (domainRequestPage) { - handlePortfolioSelection(); - } -})(); - - -/** An IIFE for dynamic DomainInformation fields -*/ -(function dynamicDomainInformationFields(){ - const domainInformationPage = document.getElementById("domaininformation_form"); - // DomainInformation is embedded inside domain so this should fire there too - const domainPage = document.getElementById("domain_form"); - if (domainInformationPage) { - handleSuborganizationFields(); - } - - if (domainPage) { - handleSuborganizationFields(portfolioDropdownSelector="#id_domain_info-0-portfolio", suborgDropdownSelector="#id_domain_info-0-sub_organization"); - } -})(); diff --git a/src/registrar/assets/js/get-gov.js b/src/registrar/assets/js/get-gov.js deleted file mode 100644 index 5188c7312..000000000 --- a/src/registrar/assets/js/get-gov.js +++ /dev/null @@ -1,2962 +0,0 @@ -/** - * @file get-gov.js includes custom code for the .gov registrar. - * - * Constants and helper functions are at the top. - * Event handlers are in the middle. - * Initialization (run-on-load) stuff goes at the bottom. - */ - - -var DEFAULT_ERROR = "Please check this field for errors."; - -var INFORMATIVE = "info"; -var WARNING = "warning"; -var ERROR = "error"; -var SUCCESS = "success"; - -// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> -// Helper functions. - -/** - * Hide element - * -*/ -const hideElement = (element) => { - element.classList.add('display-none'); -}; - -/** -* Show element -* -*/ -const showElement = (element) => { - element.classList.remove('display-none'); -}; - -/** - * Helper function to get the CSRF token from the cookie - * -*/ -function getCsrfToken() { - return document.querySelector('input[name="csrfmiddlewaretoken"]').value; -} - -/** - * Helper function that scrolls to an element - * @param {string} attributeName - The string "class" or "id" - * @param {string} attributeValue - The class or id name - */ -function ScrollToElement(attributeName, attributeValue) { - let targetEl = null; - - if (attributeName === 'class') { - targetEl = document.getElementsByClassName(attributeValue)[0]; - } else if (attributeName === 'id') { - targetEl = document.getElementById(attributeValue); - } else { - console.error('Error: unknown attribute name provided.'); - return; // Exit the function if an invalid attributeName is provided - } - - if (targetEl) { - const rect = targetEl.getBoundingClientRect(); - const scrollTop = window.scrollY || document.documentElement.scrollTop; - window.scrollTo({ - top: rect.top + scrollTop, - behavior: 'smooth' // Optional: for smooth scrolling - }); - } -} - -/** Makes an element invisible. */ -function makeHidden(el) { - el.style.position = "absolute"; - el.style.left = "-100vw"; - // The choice of `visiblity: hidden` - // over `display: none` is due to - // UX: the former will allow CSS - // transitions when the elements appear. - el.style.visibility = "hidden"; -} - -/** Makes visible a perviously hidden element. */ -function makeVisible(el) { - el.style.position = "relative"; - el.style.left = "unset"; - el.style.visibility = "visible"; -} - -/** - * Creates and adds a modal dialog to the DOM with customizable attributes and content. - * - * @param {string} id - A unique identifier for the modal, appended to the action for uniqueness. - * @param {string} ariaLabelledby - The ID of the element that labels the modal, for accessibility. - * @param {string} ariaDescribedby - The ID of the element that describes the modal, for accessibility. - * @param {string} modalHeading - The heading text displayed at the top of the modal. - * @param {string} modalDescription - The main descriptive text displayed within the modal. - * @param {string} modalSubmit - The HTML content for the submit button, allowing customization. - * @param {HTMLElement} wrapper_element - Optional. The element to which the modal is appended. If not provided, defaults to `document.body`. - * @param {boolean} forceAction - Optional. If true, adds a `data-force-action` attribute to the modal for additional control. - * - * The modal includes a heading, description, submit button, and a cancel button, along with a close button. - * The `data-close-modal` attribute is added to cancel and close buttons to enable closing functionality. - */ -function addModal(id, ariaLabelledby, ariaDescribedby, modalHeading, modalDescription, modalSubmit, wrapper_element, forceAction) { - - const modal = document.createElement('div'); - modal.setAttribute('class', 'usa-modal'); - modal.setAttribute('id', id); - modal.setAttribute('aria-labelledby', ariaLabelledby); - modal.setAttribute('aria-describedby', ariaDescribedby); - if (forceAction) - modal.setAttribute('data-force-action', ''); - - modal.innerHTML = ` -
-
-

- ${modalHeading} -

-
-

- ${modalDescription} -

-
- -
- -
- ` - if (wrapper_element) { - wrapper_element.appendChild(modal); - } else { - document.body.appendChild(modal); - } -} - -/** - * Helper function that creates a dynamic accordion navigation - * @param {string} action - The action type or identifier used to create a unique DOM IDs. - * @param {string} unique_id - An ID that when combined with action makes a unique identifier - * @param {string} modal_button_text - The action button's text - * @param {string} screen_reader_text - A screen reader helper - */ -function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text) { - - const generateModalButton = (mobileOnly = false) => ` - - ${mobileOnly ? `` : ''} - ${modal_button_text} - ${screen_reader_text} - - `; - - // Main kebab structure - const kebab = ` - ${generateModalButton(true)} - -
-
- -
- -
- `; - - return kebab; -} - - -/** - * Toggles expand_more / expand_more svgs in buttons or anchors - * @param {Element} element - DOM element - */ -function toggleCaret(element) { - // Get a reference to the use element inside the button - const useElement = element.querySelector('use'); - // Check if the span element text is 'Hide' - if (useElement.getAttribute('xlink:href') === '/public/img/sprite.svg#expand_more') { - // Update the xlink:href attribute to expand_more - useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); - } else { - // Update the xlink:href attribute to expand_less - useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); - } -} - -/** - * Helper function that scrolls to an element, identified by a class or an id. - * @param {string} attributeName - The string "class" or "id" - * @param {string} attributeValue - The class or id used name to identify the element - */ -function ScrollToElement(attributeName, attributeValue) { - let targetEl = null; - - if (attributeName === 'class') { - targetEl = document.getElementsByClassName(attributeValue)[0]; - } else if (attributeName === 'id') { - targetEl = document.getElementById(attributeValue); - } else { - console.error('Error: unknown attribute name provided.'); - return; // Exit the function if an invalid attributeName is provided - } - - if (targetEl) { - const rect = targetEl.getBoundingClientRect(); - const scrollTop = window.scrollY || document.documentElement.scrollTop; - window.scrollTo({ - top: rect.top + scrollTop, - behavior: 'smooth' // Optional: for smooth scrolling - }); - } -} - -/** Creates and returns a live region element. */ -function createLiveRegion(id) { - const liveRegion = document.createElement("div"); - liveRegion.setAttribute("role", "region"); - liveRegion.setAttribute("aria-live", "polite"); - liveRegion.setAttribute("id", id + "-live-region"); - liveRegion.classList.add("usa-sr-only"); - document.body.appendChild(liveRegion); - return liveRegion; -} - -/** Announces changes to assistive technology users. */ -function announce(id, text) { - let liveRegion = document.getElementById(id + "-live-region"); - if (!liveRegion) liveRegion = createLiveRegion(id); - liveRegion.innerHTML = text; -} - -/** - * Slow down event handlers by limiting how frequently they fire. - * - * A wait period must occur with no activity (activity means "this - * debounce function being called") before the handler is invoked. - * - * @param {Function} handler - any JS function - * @param {number} cooldown - the wait period, in milliseconds - */ -function debounce(handler, cooldown=600) { - let timeout; - return function(...args) { - const context = this; - clearTimeout(timeout); - timeout = setTimeout(() => handler.apply(context, args), cooldown); - } -} - -/** Asyncronously fetches JSON. No error handling. */ -function fetchJSON(endpoint, callback, url="/api/v1/") { - const xhr = new XMLHttpRequest(); - xhr.open('GET', url + endpoint); - xhr.send(); - xhr.onload = function() { - if (xhr.status != 200) return; - callback(JSON.parse(xhr.response)); - }; - // nothing, don't care - // xhr.onerror = function() { }; -} - -/** Modifies CSS and HTML when an input is valid/invalid. */ -function toggleInputValidity(el, valid, msg=DEFAULT_ERROR) { - if (valid) { - el.setCustomValidity(""); - el.removeAttribute("aria-invalid"); - el.classList.remove('usa-input--error'); - } else { - el.classList.remove('usa-input--success'); - el.setAttribute("aria-invalid", "true"); - el.setCustomValidity(msg); - el.classList.add('usa-input--error'); - } -} - -/** Display (or hide) a message beneath an element. */ -function inlineToast(el, id, style, msg) { - if (!el.id && !id) { - console.error("Elements must have an `id` to show an inline toast."); - return; - } - let toast = document.getElementById((el.id || id) + "--toast"); - if (style) { - if (!toast) { - // create and insert the message div - toast = document.createElement("div"); - const toastBody = document.createElement("div"); - const p = document.createElement("p"); - toast.setAttribute("id", (el.id || id) + "--toast"); - toast.className = `usa-alert usa-alert--${style} usa-alert--slim`; - toastBody.classList.add("usa-alert__body"); - p.classList.add("usa-alert__text"); - p.innerHTML = msg; - toastBody.appendChild(p); - toast.appendChild(toastBody); - el.parentNode.insertBefore(toast, el.nextSibling); - } else { - // update and show the existing message div - toast.className = `usa-alert usa-alert--${style} usa-alert--slim`; - toast.querySelector("div p").innerHTML = msg; - makeVisible(toast); - } - } else { - if (toast) makeHidden(toast); - } -} - -function checkDomainAvailability(el) { - const callback = (response) => { - toggleInputValidity(el, (response && response.available), msg=response.message); - announce(el.id, response.message); - - // Determines if we ignore the field if it is just blank - ignore_blank = el.classList.contains("blank-ok") - if (el.validity.valid) { - el.classList.add('usa-input--success'); - // use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration - inlineToast(el.parentElement, el.id, SUCCESS, response.message); - } else if (ignore_blank && response.code == "required"){ - // Visually remove the error - error = "usa-input--error" - if (el.classList.contains(error)){ - el.classList.remove(error) - } - } else { - inlineToast(el.parentElement, el.id, ERROR, response.message); - } - } - fetchJSON(`available/?domain=${el.value}`, callback); -} - -/** Hides the toast message and clears the aira live region. */ -function clearDomainAvailability(el) { - el.classList.remove('usa-input--success'); - announce(el.id, ""); - // use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration - inlineToast(el.parentElement, el.id); -} - -/** Runs all the validators associated with this element. */ -function runValidators(el) { - const attribute = el.getAttribute("validate") || ""; - if (!attribute.length) return; - const validators = attribute.split(" "); - let isInvalid = false; - for (const validator of validators) { - switch (validator) { - case "domain": - checkDomainAvailability(el); - break; - } - } - toggleInputValidity(el, !isInvalid); -} - -/** Clears all the validators associated with this element. */ -function clearValidators(el) { - const attribute = el.getAttribute("validate") || ""; - if (!attribute.length) return; - const validators = attribute.split(" "); - for (const validator of validators) { - switch (validator) { - case "domain": - clearDomainAvailability(el); - break; - } - } - toggleInputValidity(el, true); -} - -/** Hookup listeners for yes/no togglers for form fields - * Parameters: - * - radioButtonName: The "name=" value for the radio buttons being used as togglers - * - elementIdToShowIfYes: The Id of the element (eg. a div) to show if selected value of the given - * radio button is true (hides this element if false) - * - elementIdToShowIfNo: The Id of the element (eg. a div) to show if selected value of the given - * radio button is false (hides this element if true) - * **/ -function HookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToShowIfNo) { - HookupRadioTogglerListener(radioButtonName, { - 'True': elementIdToShowIfYes, - 'False': elementIdToShowIfNo - }); -} - -/** - * Hookup listeners for radio togglers in form fields. - * - * Parameters: - * - radioButtonName: The "name=" value for the radio buttons being used as togglers - * - valueToElementMap: An object where keys are the values of the radio buttons, - * and values are the corresponding DOM element IDs to show. All other elements will be hidden. - * - * Usage Example: - * Assuming you have radio buttons with values 'option1', 'option2', and 'option3', - * and corresponding DOM IDs 'section1', 'section2', 'section3'. - * - * HookupValueBasedListener('exampleRadioGroup', { - * 'option1': 'section1', - * 'option2': 'section2', - * 'option3': 'section3' - * }); - **/ -function HookupRadioTogglerListener(radioButtonName, valueToElementMap) { - // Get the radio buttons - let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); - - // Extract the list of all element IDs from the valueToElementMap - let allElementIds = Object.values(valueToElementMap); - - function handleRadioButtonChange() { - // Find the checked radio button - let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); - let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; - - // Hide all elements by default - allElementIds.forEach(function (elementId) { - let element = document.getElementById(elementId); - if (element) { - hideElement(element); - } - }); - - // Show the relevant element for the selected value - if (selectedValue && valueToElementMap[selectedValue]) { - let elementToShow = document.getElementById(valueToElementMap[selectedValue]); - if (elementToShow) { - showElement(elementToShow); - } - } - } - - if (radioButtons.length) { - // Add event listener to each radio button - radioButtons.forEach(function (radioButton) { - radioButton.addEventListener('change', handleRadioButtonChange); - }); - - // Initialize by checking the current state - handleRadioButtonChange(); - } -} - - -// A generic display none/block toggle function that takes an integer param to indicate how the elements toggle -function toggleTwoDomElements(ele1, ele2, index) { - let element1 = document.getElementById(ele1); - let element2 = document.getElementById(ele2); - if (element1 || element2) { - // Toggle display based on the index - if (element1) {element1.style.display = index === 1 ? 'block' : 'none';} - if (element2) {element2.style.display = index === 2 ? 'block' : 'none';} - } - else { - console.error('Unable to find elements to toggle'); - } -} - -// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> -// Event handlers. - -/** On input change, handles running any associated validators. */ -function handleInputValidation(e) { - clearValidators(e.target); - if (e.target.hasAttribute("auto-validate")) runValidators(e.target); -} - -/** On button click, handles running any associated validators. */ -function validateFieldInput(e) { - const attribute = e.target.getAttribute("validate-for") || ""; - if (!attribute.length) return; - const input = document.getElementById(attribute); - removeFormErrors(input, true); - runValidators(input); -} - - -function validateFormsetInputs(e, availabilityButton) { - - // Collect input IDs from the repeatable forms - let inputs = Array.from(document.querySelectorAll('.repeatable-form input')) - - // Run validators for each input - inputs.forEach(input => { - removeFormErrors(input, true); - runValidators(input); - }); - - // Set the validate-for attribute on the button with the collected input IDs - // Not needed for functionality but nice for accessibility - inputs = inputs.map(input => input.id).join(', '); - availabilityButton.setAttribute('validate-for', inputs); - -} - -// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> -// Initialization code. - -/** - * An IIFE that will attach validators to inputs. - * - * It looks for elements with `validate=" "` and adds change handlers. - * - * These handlers know about two other attributes: - * - `validate-for=""` creates a button which will run the validator(s) on - * - `auto-validate` will run validator(s) when the user stops typing (otherwise, - * they will only run when a user clicks the button with `validate-for`) - */ - (function validatorsInit() { - "use strict"; - const needsValidation = document.querySelectorAll('[validate]'); - for(const input of needsValidation) { - input.addEventListener('input', handleInputValidation); - } - const alternativeDomainsAvailability = document.getElementById('validate-alt-domains-availability'); - const activatesValidation = document.querySelectorAll('[validate-for]'); - - for(const button of activatesValidation) { - // Adds multi-field validation for alternative domains - if (button === alternativeDomainsAvailability) { - button.addEventListener('click', (e) => { - validateFormsetInputs(e, alternativeDomainsAvailability) - }); - } else { - button.addEventListener('click', validateFieldInput); - } - } -})(); - -/** - * Removes form errors surrounding a form input - */ -function removeFormErrors(input, removeStaleAlerts=false){ - // Remove error message - let errorMessage = document.getElementById(`${input.id}__error-message`); - if (errorMessage) { - errorMessage.remove(); - }else{ - return - } - - // Remove error classes - if (input.classList.contains('usa-input--error')) { - input.classList.remove('usa-input--error'); - } - - // Get the form label - let label = document.querySelector(`label[for="${input.id}"]`); - if (label) { - label.classList.remove('usa-label--error'); - - // Remove error classes from parent div - let parentDiv = label.parentElement; - if (parentDiv) { - parentDiv.classList.remove('usa-form-group--error'); - } - } - - if (removeStaleAlerts){ - let staleAlerts = document.querySelectorAll(".usa-alert--error") - for (let alert of staleAlerts){ - // Don't remove the error associated with the input - if (alert.id !== `${input.id}--toast`) { - alert.remove() - } - } - } -} - -/** - * Prepare the namerservers and DS data forms delete buttons - * We will call this on the forms init, and also every time we add a form - * - */ -function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ - let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); - let formToRemove = e.target.closest(".repeatable-form"); - formToRemove.remove(); - let forms = document.querySelectorAll(".repeatable-form"); - totalForms.setAttribute('value', `${forms.length}`); - - let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g'); - let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); - // For the example on Nameservers - let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g'); - - forms.forEach((form, index) => { - // Iterate over child nodes of the current element - Array.from(form.querySelectorAll('label, input, select')).forEach((node) => { - // Iterate through the attributes of the current node - Array.from(node.attributes).forEach((attr) => { - // Check if the attribute value matches the regex - if (formNumberRegex.test(attr.value)) { - // Replace the attribute value with the updated value - attr.value = attr.value.replace(formNumberRegex, `form-${index}-`); - } - }); - }); - - // h2 and legend for DS form, label for nameservers - Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => { - - let innerSpan = node.querySelector('span') - if (innerSpan) { - innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); - } else { - node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); - node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`); - } - - // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required) - // inject the USWDS required markup and make sure the INPUT is required - if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) { - - // Remove the word optional - innerSpan.textContent = innerSpan.textContent.replace(/\s*\(\s*optional\s*\)\s*/, ''); - - // Create a new element - const newElement = document.createElement('abbr'); - newElement.textContent = '*'; - newElement.setAttribute("title", "required"); - newElement.classList.add("usa-hint", "usa-hint--required"); - - // Append the new element to the label - node.appendChild(newElement); - // Find the next sibling that is an input element - let nextInputElement = node.nextElementSibling; - - while (nextInputElement) { - if (nextInputElement.tagName === 'INPUT') { - // Found the next input element - nextInputElement.setAttribute("required", "") - break; - } - nextInputElement = nextInputElement.nextElementSibling; - } - nextInputElement.required = true; - } - - - - }); - - // Display the add more button if we have less than 13 forms - if (isNameserversForm && forms.length <= 13) { - addButton.removeAttribute("disabled"); - } - - if (isNameserversForm && forms.length < 3) { - // Hide the delete buttons on the remaining nameservers - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.setAttribute("disabled", "true"); - }); - } - - }); -} - -/** - * Delete method for formsets using the DJANGO DELETE widget (Other Contacts) - * - */ -function markForm(e, formLabel){ - // Unlike removeForm, we only work with the visible forms when using DJANGO's DELETE widget - let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; - - if (totalShownForms == 1) { - // toggle the radio buttons - let radioButton = document.querySelector('input[name="other_contacts-has_other_contacts"][value="False"]'); - radioButton.checked = true; - // Trigger the change event - let event = new Event('change'); - radioButton.dispatchEvent(event); - } else { - - // Grab the hidden delete input and assign a value DJANGO will look for - let formToRemove = e.target.closest(".repeatable-form"); - if (formToRemove) { - let deleteInput = formToRemove.querySelector('input[class="deletion"]'); - if (deleteInput) { - deleteInput.value = 'on'; - } - } - - // Set display to 'none' - formToRemove.style.display = 'none'; - } - - // Update h2s on the visible forms only. We won't worry about the forms' identifiers - let shownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`); - let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); - shownForms.forEach((form, index) => { - // Iterate over child nodes of the current element - Array.from(form.querySelectorAll('h2')).forEach((node) => { - node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); - }); - }); -} - -/** - * Prepare the namerservers, DS data and Other Contacts formsets' delete button - * for the last added form. We call this from the Add function - * - */ -function prepareNewDeleteButton(btn, formLabel) { - let formIdentifier = "form" - let isNameserversForm = document.querySelector(".nameservers-form"); - let isOtherContactsForm = document.querySelector(".other-contacts-form"); - let addButton = document.querySelector("#add-form"); - - if (isOtherContactsForm) { - formIdentifier = "other_contacts"; - // We will mark the forms for deletion - btn.addEventListener('click', function(e) { - markForm(e, formLabel); - }); - } else { - // We will remove the forms and re-order the formset - btn.addEventListener('click', function(e) { - removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); - }); - } -} - -/** - * Prepare the namerservers, DS data and Other Contacts formsets' delete buttons - * We will call this on the forms init - * - */ -function prepareDeleteButtons(formLabel) { - let formIdentifier = "form" - let deleteButtons = document.querySelectorAll(".delete-record"); - let isNameserversForm = document.querySelector(".nameservers-form"); - let isOtherContactsForm = document.querySelector(".other-contacts-form"); - let addButton = document.querySelector("#add-form"); - if (isOtherContactsForm) { - formIdentifier = "other_contacts"; - } - - // Loop through each delete button and attach the click event listener - deleteButtons.forEach((deleteButton) => { - if (isOtherContactsForm) { - // We will mark the forms for deletion - deleteButton.addEventListener('click', function(e) { - markForm(e, formLabel); - }); - } else { - // We will remove the forms and re-order the formset - deleteButton.addEventListener('click', function(e) { - removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); - }); - } - }); -} - -/** - * DJANGO formset's DELETE widget - * On form load, hide deleted forms, ie. those forms with hidden input of class 'deletion' - * with value='on' - */ -function hideDeletedForms() { - let hiddenDeleteButtonsWithValueOn = document.querySelectorAll('input[type="hidden"].deletion[value="on"]'); - - // Iterating over the NodeList of hidden inputs - hiddenDeleteButtonsWithValueOn.forEach(function(hiddenInput) { - // Finding the closest parent element with class "repeatable-form" for each hidden input - var repeatableFormToHide = hiddenInput.closest('.repeatable-form'); - - // Checking if a matching parent element is found for each hidden input - if (repeatableFormToHide) { - // Setting the display property to "none" for each matching parent element - repeatableFormToHide.style.display = 'none'; - } - }); -} - -// Checks for if we want to display Urbanization or not -document.addEventListener('DOMContentLoaded', function() { - var stateTerritoryField = document.querySelector('select[name="organization_contact-state_territory"]'); - - if (!stateTerritoryField) { - return; // Exit if the field not found - } - - setupUrbanizationToggle(stateTerritoryField); -}); - -function setupUrbanizationToggle(stateTerritoryField) { - var urbanizationField = document.getElementById('urbanization-field'); - - function toggleUrbanizationField() { - // Checking specifically for Puerto Rico only - if (stateTerritoryField.value === 'PR') { - urbanizationField.style.display = 'block'; - } else { - urbanizationField.style.display = 'none'; - } - } - - toggleUrbanizationField(); - - stateTerritoryField.addEventListener('change', toggleUrbanizationField); -} - -/** - * An IIFE that attaches a click handler for our dynamic formsets - * - * Only does something on a few pages, but it should be fast enough to run - * it everywhere. - */ -(function prepareFormsetsForms() { - let formIdentifier = "form" - let repeatableForm = document.querySelectorAll(".repeatable-form"); - let container = document.querySelector("#form-container"); - let addButton = document.querySelector("#add-form"); - let cloneIndex = 0; - let formLabel = ''; - let isNameserversForm = document.querySelector(".nameservers-form"); - let isOtherContactsForm = document.querySelector(".other-contacts-form"); - let isDsDataForm = document.querySelector(".ds-data-form"); - let isDotgovDomain = document.querySelector(".dotgov-domain-form"); - // The Nameservers formset features 2 required and 11 optionals - if (isNameserversForm) { - // cloneIndex = 2; - formLabel = "Name server"; - // DNSSEC: DS Data - } else if (isDsDataForm) { - formLabel = "DS data record"; - // The Other Contacts form - } else if (isOtherContactsForm) { - formLabel = "Organization contact"; - container = document.querySelector("#other-employees"); - formIdentifier = "other_contacts" - } else if (isDotgovDomain) { - formIdentifier = "dotgov_domain" - } - let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); - - // On load: Disable the add more button if we have 13 forms - if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) { - addButton.setAttribute("disabled", "true"); - } - - // Hide forms which have previously been deleted - hideDeletedForms() - - // Attach click event listener on the delete buttons of the existing forms - prepareDeleteButtons(formLabel); - - if (addButton) - addButton.addEventListener('click', addForm); - - function addForm(e){ - let forms = document.querySelectorAll(".repeatable-form"); - let formNum = forms.length; - let newForm = repeatableForm[cloneIndex].cloneNode(true); - let formNumberRegex = RegExp(`${formIdentifier}-(\\d){1}-`,'g'); - let formLabelRegex = RegExp(`${formLabel} (\\d){1}`, 'g'); - // For the eample on Nameservers - let formExampleRegex = RegExp(`ns(\\d){1}`, 'g'); - - // Some Nameserver form checks since the delete can mess up the source object we're copying - // in regards to required fields and hidden delete buttons - if (isNameserversForm) { - - // If the source element we're copying has required on an input, - // reset that input - let formRequiredNeedsCleanUp = newForm.innerHTML.includes('*'); - if (formRequiredNeedsCleanUp) { - newForm.querySelector('label abbr').remove(); - // Get all input elements within the container - const inputElements = newForm.querySelectorAll("input"); - // Loop through each input element and remove the 'required' attribute - inputElements.forEach((input) => { - if (input.hasAttribute("required")) { - input.removeAttribute("required"); - } - }); - } - - // If the source element we're copying has an disabled delete button, - // enable that button - let deleteButton= newForm.querySelector('.delete-record'); - if (deleteButton.hasAttribute("disabled")) { - deleteButton.removeAttribute("disabled"); - } - } - - formNum++; - - newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`); - if (isOtherContactsForm) { - // For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden, - // since the form on the backend employs Django's DELETE widget. - let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`); - } else { - // Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional - // if indices 0 or 1 have been deleted - let containsOptional = newForm.innerHTML.includes('(optional)'); - if (isNameserversForm && !containsOptional) { - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum} (optional)`); - } else { - newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); - } - } - newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`); - newForm.innerHTML = newForm.innerHTML.replace(/\n/g, ''); // Remove newline characters - newForm.innerHTML = newForm.innerHTML.replace(/>\s*<'); // Remove spaces between tags - container.insertBefore(newForm, addButton); - - newForm.style.display = 'block'; - - let inputs = newForm.querySelectorAll("input"); - // Reset the values of each input to blank - inputs.forEach((input) => { - input.classList.remove("usa-input--error"); - input.classList.remove("usa-input--success"); - if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") { - input.value = ""; // Set the value to an empty string - - } else if (input.type === "checkbox" || input.type === "radio") { - input.checked = false; // Uncheck checkboxes and radios - } - }); - - // Reset any existing validation classes - let selects = newForm.querySelectorAll("select"); - selects.forEach((select) => { - select.classList.remove("usa-input--error"); - select.classList.remove("usa-input--success"); - select.selectedIndex = 0; // Set the value to an empty string - }); - - let labels = newForm.querySelectorAll("label"); - labels.forEach((label) => { - label.classList.remove("usa-label--error"); - label.classList.remove("usa-label--success"); - }); - - let usaFormGroups = newForm.querySelectorAll(".usa-form-group"); - usaFormGroups.forEach((usaFormGroup) => { - usaFormGroup.classList.remove("usa-form-group--error"); - usaFormGroup.classList.remove("usa-form-group--success"); - }); - - // Remove any existing error and success messages - let usaMessages = newForm.querySelectorAll(".usa-error-message, .usa-alert"); - usaMessages.forEach((usaErrorMessage) => { - let parentDiv = usaErrorMessage.closest('div'); - if (parentDiv) { - parentDiv.remove(); // Remove the parent div if it exists - } - }); - - totalForms.setAttribute('value', `${formNum}`); - - // Attach click event listener on the delete buttons of the new form - let newDeleteButton = newForm.querySelector(".delete-record"); - if (newDeleteButton) - prepareNewDeleteButton(newDeleteButton, formLabel); - - // Disable the add more button if we have 13 forms - if (isNameserversForm && formNum == 13) { - addButton.setAttribute("disabled", "true"); - } - - if (isNameserversForm && forms.length >= 2) { - // Enable the delete buttons on the nameservers - forms.forEach((form, index) => { - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.removeAttribute("disabled"); - }); - }); - } - } -})(); - -/** - * An IIFE that triggers a modal on the DS Data Form under certain conditions - * - */ -(function triggerModalOnDsDataForm() { - let saveButon = document.querySelector("#save-ds-data"); - - // The view context will cause a hitherto hidden modal trigger to - // show up. On save, we'll test for that modal trigger appearing. We'll - // run that test once every 100 ms for 5 secs, which should balance performance - // while accounting for network or lag issues. - if (saveButon) { - let i = 0; - var tryToTriggerModal = setInterval(function() { - i++; - if (i > 100) { - clearInterval(tryToTriggerModal); - } - let modalTrigger = document.querySelector("#ds-toggle-dnssec-alert"); - if (modalTrigger) { - modalTrigger.click() - clearInterval(tryToTriggerModal); - } - }, 50); - } -})(); - - -/** - * An IIFE that listens to the other contacts radio form on DAs and toggles the contacts/no other contacts forms - * - */ -(function otherContactsFormListener() { - HookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees') -})(); - - -/** - * An IIFE that listens to the yes/no radio buttons on the anything else form and toggles form field visibility accordingly - * - */ -(function anythingElseFormListener() { - HookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null) -})(); - - -/** - * An IIFE that listens to the yes/no radio buttons on the anything else form and toggles form field visibility accordingly - * - */ -(function newMemberFormListener() { - HookupRadioTogglerListener('member_access_level', { - 'admin': 'new-member-admin-permissions', - 'basic': 'new-member-basic-permissions' - }); -})(); - -/** - * An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms - * - */ -(function nameserversFormListener() { - let isNameserversForm = document.querySelector(".nameservers-form"); - if (isNameserversForm) { - let forms = document.querySelectorAll(".repeatable-form"); - if (forms.length < 3) { - // Hide the delete buttons on the 2 nameservers - forms.forEach((form) => { - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.setAttribute("disabled", "true"); - }); - }); - } - } -})(); - -/** - * An IIFE that disables the delete buttons on nameserver forms on page load if < 3 forms - * - */ -(function nameserversFormListener() { - let isNameserversForm = document.querySelector(".nameservers-form"); - if (isNameserversForm) { - let forms = document.querySelectorAll(".repeatable-form"); - if (forms.length < 3) { - // Hide the delete buttons on the 2 nameservers - forms.forEach((form) => { - Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { - deleteButton.setAttribute("disabled", "true"); - }); - }); - } - } -})(); - -/** - * An IIFE that listens to the yes/no radio buttons on the CISA representatives form and toggles form field visibility accordingly - * - */ -(function cisaRepresentativesFormListener() { - HookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null) -})(); - -/** - * Initialize USWDS tooltips by calling initialization method. Requires that uswds-edited.js - * be loaded before get-gov.js. uswds-edited.js adds the tooltip module to the window to be - * accessible directly in get-gov.js - * - */ -function initializeTooltips() { - function checkTooltip() { - // Check that the tooltip library is loaded, and if not, wait and retry - if (window.tooltip && typeof window.tooltip.init === 'function') { - window.tooltip.init(); - } else { - // Retry after a short delay - setTimeout(checkTooltip, 100); - } - } - checkTooltip(); -} - -/** - * Initialize USWDS modals by calling on method. Requires that uswds-edited.js be loaded - * before get-gov.js. uswds-edited.js adds the modal module to the window to be accessible - * directly in get-gov.js. - * load Modals adds modal-related DOM elements, based on other DOM elements existing in - * the page. It needs to be called only once for any particular DOM element; otherwise, it - * will initialize improperly. Therefore, if DOM elements change dynamically and include - * DOM elements with modal classes, uswdsUnloadModals needs to be called before loadModals. - * - */ -function uswdsInitializeModals() { - window.modal.on(); - -} - -/** - * Unload existing USWDS modals by calling off method. Requires that uswds-edited.js be - * loaded before get-gov.js. uswds-edited.js adds the modal module to the window to be - * accessible directly in get-gov.js. - * See note above with regards to calling this method relative to loadModals. - * - */ -function uswdsUnloadModals() { - window.modal.off(); -} - -/** - * Base table class which handles search, retrieval, rendering and interaction with results. - * Classes can extend the basic behavior of this class to customize display and interaction. - * NOTE: PLEASE notice that whatever itemName is coming in will have an "s" added to it (ie domain -> domains) - */ -class BaseTable { - constructor(itemName) { - this.itemName = itemName; - this.sectionSelector = itemName + 's'; - this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`); - this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`); - this.currentSortBy = 'id'; - this.currentOrder = 'asc'; - this.currentStatus = []; - this.currentSearchTerm = ''; - this.scrollToTable = false; - this.searchInput = document.getElementById(`${this.sectionSelector}__search-field`); - this.searchSubmit = document.getElementById(`${this.sectionSelector}__search-field-submit`); - this.tableAnnouncementRegion = document.getElementById(`${this.sectionSelector}__usa-table__announcement-region`); - this.resetSearchButton = document.getElementById(`${this.sectionSelector}__reset-search`); - this.resetFiltersButton = document.getElementById(`${this.sectionSelector}__reset-filters`); - this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`); - this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`); - this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`); - this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`); - this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`); - this.portfolioElement = document.getElementById('portfolio-js-value'); - this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null; - this.initializeTableHeaders(); - this.initializeSearchHandler(); - this.initializeStatusToggleHandler(); - this.initializeFilterCheckboxes(); - this.initializeResetSearchButton(); - this.initializeResetFiltersButton(); - this.initializeAccordionAccessibilityListeners(); - } - - /** - * Generalized function to update pagination for a list. - * @param {number} currentPage - The current page number (starting with 1). - * @param {number} numPages - The total number of pages. - * @param {boolean} hasPrevious - Whether there is a page before the current page. - * @param {boolean} hasNext - Whether there is a page after the current page. - * @param {number} total - The total number of items. - */ - updatePagination( - currentPage, - numPages, - hasPrevious, - hasNext, - totalItems - ) { - const paginationButtons = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__list`); - const counterSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__counter`); - const paginationSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination`); - const parentTableSelector = `#${this.sectionSelector}`; - counterSelectorEl.innerHTML = ''; - paginationButtons.innerHTML = ''; - - // Buttons should only be displayed if there are more than one pages of results - paginationButtons.classList.toggle('display-none', numPages <= 1); - - // Counter should only be displayed if there is more than 1 item - paginationSelectorEl.classList.toggle('display-none', totalItems < 1); - - counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; - - // Helper function to create a pagination item, such as a - const createPaginationItem = (page) => { - const paginationItem = document.createElement('li'); - paginationItem.classList.add('usa-pagination__item', 'usa-pagination__page-no'); - paginationItem.innerHTML = ` - ${page} - `; - if (page === currentPage) { - paginationItem.querySelector('a').classList.add('usa-current'); - paginationItem.querySelector('a').setAttribute('aria-current', 'page'); - } - paginationItem.querySelector('a').addEventListener('click', (event) => { - event.preventDefault(); - this.loadTable(page); - }); - return paginationItem; - }; - - if (hasPrevious) { - const prevPaginationItem = document.createElement('li'); - prevPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; - prevPaginationItem.innerHTML = ` - - - Previous - - `; - prevPaginationItem.querySelector('a').addEventListener('click', (event) => { - event.preventDefault(); - this.loadTable(currentPage - 1); - }); - paginationButtons.appendChild(prevPaginationItem); - } - - // Add first page and ellipsis if necessary - if (currentPage > 2) { - paginationButtons.appendChild(createPaginationItem(1)); - if (currentPage > 3) { - const ellipsis = document.createElement('li'); - ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; - ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); - ellipsis.innerHTML = ''; - paginationButtons.appendChild(ellipsis); - } - } - - // Add pages around the current page - for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) { - paginationButtons.appendChild(createPaginationItem(i)); - } - - // Add last page and ellipsis if necessary - if (currentPage < numPages - 1) { - if (currentPage < numPages - 2) { - const ellipsis = document.createElement('li'); - ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; - ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); - ellipsis.innerHTML = ''; - paginationButtons.appendChild(ellipsis); - } - paginationButtons.appendChild(createPaginationItem(numPages)); - } - - if (hasNext) { - const nextPaginationItem = document.createElement('li'); - nextPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; - nextPaginationItem.innerHTML = ` - - Next - - - `; - nextPaginationItem.querySelector('a').addEventListener('click', (event) => { - event.preventDefault(); - this.loadTable(currentPage + 1); - }); - paginationButtons.appendChild(nextPaginationItem); - } - } - - /** - * A helper that toggles content/ no content/ no search results based on results in data. - * @param {Object} data - Data representing current page of results data. - * @param {HTMLElement} dataWrapper - The DOM element to show if there are results on the current page. - * @param {HTMLElement} noDataWrapper - The DOM element to show if there are no results period. - * @param {HTMLElement} noSearchResultsWrapper - The DOM element to show if there are no results in the current filtered search. - */ - updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => { - const { unfiltered_total, total } = data; - if (unfiltered_total) { - if (total) { - showElement(dataWrapper); - hideElement(noSearchResultsWrapper); - hideElement(noDataWrapper); - } else { - hideElement(dataWrapper); - showElement(noSearchResultsWrapper); - hideElement(noDataWrapper); - } - } else { - hideElement(dataWrapper); - hideElement(noSearchResultsWrapper); - showElement(noDataWrapper); - } - }; - - /** - * A helper that resets sortable table headers - * - */ - unsetHeader = (header) => { - header.removeAttribute('aria-sort'); - let headerName = header.innerText; - const headerLabel = `${headerName}, sortable column, currently unsorted"`; - const headerButtonLabel = `Click to sort by ascending order.`; - header.setAttribute("aria-label", headerLabel); - header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel); - }; - - - /** - * Generates search params for filtering and sorting - * @param {number} page - The current page number for pagination (starting with 1) - * @param {*} sortBy - The sort column option - * @param {*} order - The order of sorting {asc, desc} - * @param {string} searchTerm - The search term used to filter results for a specific keyword - * @param {*} status - The status filter applied {ready, dns_needed, etc} - * @param {string} portfolio - The portfolio id - */ - getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { - let searchParams = new URLSearchParams( - { - "page": page, - "sort_by": sortBy, - "order": order, - "search_term": searchTerm, - } - ); - - let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null; - let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null; - let memberOnly = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-only') : null; - - if (portfolio) - searchParams.append("portfolio", portfolio); - if (emailValue) - searchParams.append("email", emailValue); - if (memberIdValue) - searchParams.append("member_id", memberIdValue); - if (memberOnly) - searchParams.append("member_only", memberOnly); - if (status) - searchParams.append("status", status); - return searchParams; - } - - /** - * Gets the base URL of API requests - * Placeholder function in a parent class - method should be implemented by child class for specifics - * Throws an error if called directly from the parent class - */ - getBaseUrl() { - throw new Error('getBaseUrl must be defined'); - } - - /** - * Calls "uswdsUnloadModals" to remove any existing modal element to make sure theres no unintended consequences - * from leftover event listeners + can be properly re-initialized - */ - unloadModals(){} - - /** - * Loads modals + sets up event listeners for the modal submit actions - * "Activates" the modals after the DOM updates - * Utilizes "uswdsInitializeModals" - * Adds click event listeners to each modal's submit button so we can handle a user's actions - * - * When the submit button is clicked: - * - Triggers the close button to reset modal classes - * - Determines if the page needs refreshing if the last item is deleted - * @param {number} page - The current page number for pagination - * @param {number} total - The total # of items on the current page - * @param {number} unfiltered_total - The total # of items across all pages - */ - loadModals(page, total, unfiltered_total) {} - - /** - * Allows us to customize the table display based on specific conditions and a user's permissions - * Dynamically manages the visibility set up of columns, adding/removing headers - * (ie if a domain request is deleteable, we include the kebab column or if a user has edit permissions - * for a member, they will also see the kebab column) - * @param {Object} dataObjects - Data which contains info on domain requests or a user's permission - * Currently returns a dictionary of either: - * - "needsAdditionalColumn": If a new column should be displayed - * - "UserPortfolioPermissionChoices": A user's portfolio permission choices - */ - customizeTable(dataObjects){ return {}; } - - /** - * Retrieves specific data objects - * Placeholder function in a parent class - method should be implemented by child class for specifics - * Throws an error if called directly from the parent class - * Returns either: data.members, data.domains or data.domain_requests - * @param {Object} data - The full data set from which a subset of objects is extracted. - */ - getDataObjects(data) { - throw new Error('getDataObjects must be defined'); - } - - /** - * Creates + appends a row to a tbody element - * Tailored structure set up for each data object (domain, domain_request, member, etc) - * Placeholder function in a parent class - method should be implemented by child class for specifics - * Throws an error if called directly from the parent class - * Returns either: data.members, data.domains or data.domain_requests - * @param {Object} dataObject - The data used to populate the row content - * @param {HTMLElement} tbody - The table body to which the new row is appended to - * @param {Object} customTableOptions - Additional options for customizing row appearance (ie needsAdditionalColumn) - */ - addRow(dataObject, tbody, customTableOptions) { - throw new Error('addRow must be defined'); - } - - /** - * See function for more details - */ - initShowMoreButtons(){} - - /** - * Loads rows in the members list, as well as updates pagination around the members list - * based on the supplied attributes. - * @param {*} page - The page number of the results (starts with 1) - * @param {*} sortBy - The sort column option - * @param {*} order - The sort order {asc, desc} - * @param {*} scroll - The control for the scrollToElement functionality - * @param {*} searchTerm - The search term - * @param {*} portfolio - The portfolio id - */ - loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { - // --------- SEARCH - let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); - - // --------- FETCH DATA - // fetch json of page of domains, given params - const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; - if (!baseUrlValue) return; - - let url = `${baseUrlValue}?${searchParams.toString()}` - fetch(url) - .then(response => response.json()) - .then(data => { - if (data.error) { - console.error('Error in AJAX call: ' + data.error); - return; - } - - // handle the display of proper messaging in the event that no members exist in the list or search returns no results - this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); - - // identify the DOM element where the list of results will be inserted into the DOM - const tbody = this.tableWrapper.querySelector('tbody'); - tbody.innerHTML = ''; - - // remove any existing modal elements from the DOM so they can be properly re-initialized - // after the DOM content changes and there are new delete modal buttons added - this.unloadModals(); - - let dataObjects = this.getDataObjects(data); - let customTableOptions = this.customizeTable(data); - - dataObjects.forEach(dataObject => { - this.addRow(dataObject, tbody, customTableOptions); - }); - - this.initShowMoreButtons(); - - this.loadModals(data.page, data.total, data.unfiltered_total); - - // Do not scroll on first page load - if (scroll) - ScrollToElement('class', this.sectionSelector); - this.scrollToTable = true; - - // update pagination - this.updatePagination( - data.page, - data.num_pages, - data.has_previous, - data.has_next, - data.total, - ); - this.currentSortBy = sortBy; - this.currentOrder = order; - this.currentSearchTerm = searchTerm; - }) - .catch(error => console.error('Error fetching objects:', error)); - } - - - // Add event listeners to table headers for sorting - initializeTableHeaders() { - this.tableHeaders.forEach(header => { - header.addEventListener('click', () => { - const sortBy = header.getAttribute('data-sortable'); - let order = 'asc'; - // sort order will be ascending, unless the currently sorted column is ascending, and the user - // is selecting the same column to sort in descending order - if (sortBy === this.currentSortBy) { - order = this.currentOrder === 'asc' ? 'desc' : 'asc'; - } - // load the results with the updated sort - this.loadTable(1, sortBy, order); - }); - }); - } - - initializeSearchHandler() { - this.searchSubmit.addEventListener('click', (e) => { - e.preventDefault(); - this.currentSearchTerm = this.searchInput.value; - // If the search is blank, we match the resetSearch functionality - if (this.currentSearchTerm) { - showElement(this.resetSearchButton); - } else { - hideElement(this.resetSearchButton); - } - this.loadTable(1, 'id', 'asc'); - this.resetHeaders(); - }); - } - - initializeStatusToggleHandler() { - if (this.statusToggle) { - this.statusToggle.addEventListener('click', () => { - toggleCaret(this.statusToggle); - }); - } - } - - // Add event listeners to status filter checkboxes - initializeFilterCheckboxes() { - this.statusCheckboxes.forEach(checkbox => { - checkbox.addEventListener('change', () => { - const checkboxValue = checkbox.value; - - // Update currentStatus array based on checkbox state - if (checkbox.checked) { - this.currentStatus.push(checkboxValue); - } else { - const index = this.currentStatus.indexOf(checkboxValue); - if (index > -1) { - this.currentStatus.splice(index, 1); - } - } - - // Manage visibility of reset filters button - if (this.currentStatus.length == 0) { - hideElement(this.resetFiltersButton); - } else { - showElement(this.resetFiltersButton); - } - - // Disable the auto scroll - this.scrollToTable = false; - - // Call loadTable with updated status - this.loadTable(1, 'id', 'asc'); - this.resetHeaders(); - this.updateStatusIndicator(); - }); - }); - } - - // Reset UI and accessibility - resetHeaders() { - this.tableHeaders.forEach(header => { - // Unset sort UI in headers - this.unsetHeader(header); - }); - // Reset the announcement region - this.tableAnnouncementRegion.innerHTML = ''; - } - - resetSearch() { - this.searchInput.value = ''; - this.currentSearchTerm = ''; - hideElement(this.resetSearchButton); - this.loadTable(1, 'id', 'asc'); - this.resetHeaders(); - } - - initializeResetSearchButton() { - if (this.resetSearchButton) { - this.resetSearchButton.addEventListener('click', () => { - this.resetSearch(); - }); - } - } - - resetFilters() { - this.currentStatus = []; - this.statusCheckboxes.forEach(checkbox => { - checkbox.checked = false; - }); - hideElement(this.resetFiltersButton); - - // Disable the auto scroll - this.scrollToTable = false; - - this.loadTable(1, 'id', 'asc'); - this.resetHeaders(); - this.updateStatusIndicator(); - // No need to toggle close the filters. The focus shift will trigger that for us. - } - - initializeResetFiltersButton() { - if (this.resetFiltersButton) { - this.resetFiltersButton.addEventListener('click', () => { - this.resetFilters(); - }); - } - } - - updateStatusIndicator() { - this.statusIndicator.innerHTML = ''; - // Even if the element is empty, it'll mess up the flex layout unless we set display none - hideElement(this.statusIndicator); - if (this.currentStatus.length) - this.statusIndicator.innerHTML = '(' + this.currentStatus.length + ')'; - showElement(this.statusIndicator); - } - - closeFilters() { - if (this.statusToggle.getAttribute("aria-expanded") === "true") { - this.statusToggle.click(); - } - } - - initializeAccordionAccessibilityListeners() { - // Instead of managing the toggle/close on the filter buttons in all edge cases (user clicks on search, user clicks on ANOTHER filter, - // user clicks on main nav...) we add a listener and close the filters whenever the focus shifts out of the dropdown menu/filter button. - // NOTE: We may need to evolve this as we add more filters. - document.addEventListener('focusin', (event) => { - const accordion = document.querySelector('.usa-accordion--select'); - const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); - - if (accordionThatIsOpen && !accordion.contains(event.target)) { - this.closeFilters(); - } - }); - - // Close when user clicks outside - // NOTE: We may need to evolve this as we add more filters. - document.addEventListener('click', (event) => { - const accordion = document.querySelector('.usa-accordion--select'); - const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); - - if (accordionThatIsOpen && !accordion.contains(event.target)) { - this.closeFilters(); - } - }); - } -} - -class DomainsTable extends BaseTable { - - constructor() { - super('domain'); - } - getBaseUrl() { - return document.getElementById("get_domains_json_url"); - } - getDataObjects(data) { - return data.domains; - } - addRow(dataObject, tbody, customTableOptions) { - const domain = dataObject; - const options = { year: 'numeric', month: 'short', day: 'numeric' }; - const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; - const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; - const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; - const actionUrl = domain.action_url; - const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; - - const row = document.createElement('tr'); - - let markupForSuborganizationRow = ''; - - if (this.portfolioValue) { - markupForSuborganizationRow = ` - - ${suborganization} - - ` - } - - row.innerHTML = ` - - ${domain.name} - - - ${expirationDateFormatted} - - - ${domain.state_display} - - - - - ${markupForSuborganizationRow} - - - - ${domain.action_label} ${domain.name} - - - `; - tbody.appendChild(row); - } -} - -class DomainRequestsTable extends BaseTable { - - constructor() { - super('domain-request'); - } - - getBaseUrl() { - return document.getElementById("get_domain_requests_json_url"); - } - - toggleExportButton(requests) { - const exportButton = document.getElementById('export-csv'); - if (exportButton) { - if (requests.length > 0) { - showElement(exportButton); - } else { - hideElement(exportButton); - } - } - } - - getDataObjects(data) { - return data.domain_requests; - } - unloadModals() { - uswdsUnloadModals(); - } - customizeTable(data) { - - // Manage "export as CSV" visibility for domain requests - this.toggleExportButton(data.domain_requests); - - let needsDeleteColumn = data.domain_requests.some(request => request.is_deletable); - - // Remove existing delete th and td if they exist - let existingDeleteTh = document.querySelector('.delete-header'); - if (!needsDeleteColumn) { - if (existingDeleteTh) - existingDeleteTh.remove(); - } else { - if (!existingDeleteTh) { - const delheader = document.createElement('th'); - delheader.setAttribute('scope', 'col'); - delheader.setAttribute('role', 'columnheader'); - delheader.setAttribute('class', 'delete-header width-5'); - delheader.innerHTML = ` - Delete Action`; - let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); - tableHeaderRow.appendChild(delheader); - } - } - return { 'needsAdditionalColumn': needsDeleteColumn }; - } - - addRow(dataObject, tbody, customTableOptions) { - const request = dataObject; - const options = { year: 'numeric', month: 'short', day: 'numeric' }; - const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; - const actionUrl = request.action_url; - const actionLabel = request.action_label; - const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; - - // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page) - // If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user - let modalTrigger = ` - Domain request cannot be deleted now. Edit the request for more information.`; - - let markupCreatorRow = ''; - - if (this.portfolioValue) { - markupCreatorRow = ` - - ${request.creator ? request.creator : ''} - - ` - } - - if (request.is_deletable) { - // 1st path: Just a modal trigger in any screen size for non-org users - modalTrigger = ` - - Delete ${domainName} - ` - - // Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly - if (this.portfolioValue) { - - // 2nd path: Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users - modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', domainName); - } - } - - const row = document.createElement('tr'); - row.innerHTML = ` - - ${domainName} - - - ${submissionDate} - - ${markupCreatorRow} - - ${request.status} - - - - - ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} - - - ${customTableOptions.needsAdditionalColumn ? ''+modalTrigger+'' : ''} - `; - tbody.appendChild(row); - if (request.is_deletable) DomainRequestsTable.addDomainRequestsModal(request.requested_domain, request.id, request.created_at, tbody); - } - - loadModals(page, total, unfiltered_total) { - // initialize modals immediately after the DOM content is updated - uswdsInitializeModals(); - - // Now the DOM and modals are ready, add listeners to the submit buttons - const modals = document.querySelectorAll('.usa-modal__content'); - - modals.forEach(modal => { - const submitButton = modal.querySelector('.usa-modal__submit'); - const closeButton = modal.querySelector('.usa-modal__close'); - submitButton.addEventListener('click', () => { - let pk = submitButton.getAttribute('data-pk'); - // Workaround: Close the modal to remove the USWDS UI local classes - closeButton.click(); - // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page - let pageToDisplay = page; - if (total == 1 && unfiltered_total > 1) { - pageToDisplay--; - } - this.deleteDomainRequest(pk, pageToDisplay); - }); - }); - } - - /** - * Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input. - * @param {*} domainRequestPk - the identifier for the request that we're deleting - * @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page - */ - deleteDomainRequest(domainRequestPk, pageToDisplay) { - // Use to debug uswds modal issues - //console.log('deleteDomainRequest') - - // Get csrf token - const csrfToken = getCsrfToken(); - // Create FormData object and append the CSRF token - const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}&delete-domain-request=`; - - fetch(`/domain-request/${domainRequestPk}/delete`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-CSRFToken': csrfToken, - }, - body: formData - }) - .then(response => { - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`); - } - // Update data and UI - this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); - }) - .catch(error => console.error('Error fetching domain requests:', error)); - } - - /** - * Modal that displays when deleting a domain request - * @param {string} requested_domain - The requested domain URL - * @param {string} id - The request's ID - * @param {string}} created_at - When the request was created at - * @param {HTMLElement} wrapper_element - The element to which the modal is appended - */ - static addDomainRequestsModal(requested_domain, id, created_at, wrapper_element) { - // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages - let modalHeading = ''; - let modalDescription = ''; - - if (requested_domain) { - modalHeading = `Are you sure you want to delete ${requested_domain}?`; - modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; - } else { - if (created_at) { - modalHeading = 'Are you sure you want to delete this domain request?'; - modalDescription = `This will remove the domain request (created ${utcDateString(created_at)}) from the .gov registrar. This action cannot be undone`; - } else { - modalHeading = 'Are you sure you want to delete New domain request?'; - modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; - } - } - - const modalSubmit = ` - - ` - - addModal(`toggle-delete-domain-${id}`, 'Are you sure you want to continue?', 'Domain will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); - - } -} - -class MembersTable extends BaseTable { - - constructor() { - super('member'); - } - - getBaseUrl() { - return document.getElementById("get_members_json_url"); - } - - // Abstract method (to be implemented in the child class) - getDataObjects(data) { - return data.members; - } - unloadModals() { - uswdsUnloadModals(); - } - loadModals(page, total, unfiltered_total) { - // initialize modals immediately after the DOM content is updated - uswdsInitializeModals(); - - // Now the DOM and modals are ready, add listeners to the submit buttons - const modals = document.querySelectorAll('.usa-modal__content'); - - modals.forEach(modal => { - const submitButton = modal.querySelector('.usa-modal__submit'); - const closeButton = modal.querySelector('.usa-modal__close'); - submitButton.addEventListener('click', () => { - let pk = submitButton.getAttribute('data-pk'); - // Close the modal to remove the USWDS UI local classes - closeButton.click(); - // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page - let pageToDisplay = page; - if (total == 1 && unfiltered_total > 1) { - pageToDisplay--; - } - - this.deleteMember(pk, pageToDisplay); - }); - }); - } - - customizeTable(data) { - // Get whether the logged in user has edit members permission - const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null; - - let existingExtraActionsHeader = document.querySelector('.extra-actions-header'); - - if (hasEditPermission && !existingExtraActionsHeader) { - const extraActionsHeader = document.createElement('th'); - extraActionsHeader.setAttribute('id', 'extra-actions'); - extraActionsHeader.setAttribute('role', 'columnheader'); - extraActionsHeader.setAttribute('class', 'extra-actions-header width-5'); - extraActionsHeader.innerHTML = ` - Extra Actions`; - let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); - tableHeaderRow.appendChild(extraActionsHeader); - } - return { - 'needsAdditionalColumn': hasEditPermission, - 'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices - }; - } - - addRow(dataObject, tbody, customTableOptions) { - const member = dataObject; - // member is based on either a UserPortfolioPermission or a PortfolioInvitation - // and also includes information from related domains; the 'id' of the org_member - // is the id of the UserPorfolioPermission or PortfolioInvitation, it is not a user id - // member.type is either invitedmember or member - const unique_id = member.type + member.id; // unique string for use in dom, this is - // not the id of the associated user - const member_delete_url = member.action_url + "/delete"; - const num_domains = member.domain_urls.length; - const last_active = this.handleLastActive(member.last_active); - let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member"; - const kebabHTML = customTableOptions.needsAdditionalColumn ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): ''; - - const row = document.createElement('tr'); - - let admin_tagHTML = ``; - if (member.is_admin) - admin_tagHTML = `Admin` - - // generate html blocks for domains and permissions for the member - let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url); - let permissionsHTML = this.generatePermissionsHTML(member.permissions, customTableOptions.UserPortfolioPermissionChoices); - - // domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand - let showMoreButton = ''; - const showMoreRow = document.createElement('tr'); - if (domainsHTML || permissionsHTML) { - showMoreButton = ` - - `; - - showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`; - showMoreRow.classList.add('show-more-content'); - showMoreRow.classList.add('display-none'); - showMoreRow.id = unique_id; - } - - row.innerHTML = ` - - ${member.member_display} ${admin_tagHTML} ${showMoreButton} - - - ${last_active.display_value} - - - - - ${member.action_label} ${member.name} - - - ${customTableOptions.needsAdditionalColumn ? ''+kebabHTML+'' : ''} - `; - tbody.appendChild(row); - if (domainsHTML || permissionsHTML) { - tbody.appendChild(showMoreRow); - } - // This easter egg is only for fixtures that dont have names as we are displaying their emails - // All prod users will have emails linked to their account - if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row); - } - - /** - * Initializes "Show More" buttons on the page, enabling toggle functionality to show or hide content. - * - * The function finds elements with "Show More" buttons and sets up a click event listener to toggle the visibility - * of a corresponding content div. When clicked, the button updates its visual state (e.g., text/icon change), - * and the associated content is shown or hidden based on its current visibility status. - * - * @function initShowMoreButtons - */ - initShowMoreButtons() { - /** - * Toggles the visibility of a content section when the "Show More" button is clicked. - * Updates the button text/icon based on whether the content is shown or hidden. - * - * @param {HTMLElement} toggleButton - The button that toggles the content visibility. - * @param {HTMLElement} contentDiv - The content div whose visibility is toggled. - * @param {HTMLElement} buttonParentRow - The parent row element containing the button. - */ - function toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow) { - const spanElement = toggleButton.querySelector('span'); - const useElement = toggleButton.querySelector('use'); - if (contentDiv.classList.contains('display-none')) { - showElement(contentDiv); - spanElement.textContent = 'Close'; - useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); - buttonParentRow.classList.add('hide-td-borders'); - toggleButton.setAttribute('aria-label', 'Close additional information'); - } else { - hideElement(contentDiv); - spanElement.textContent = 'Expand'; - useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); - buttonParentRow.classList.remove('hide-td-borders'); - toggleButton.setAttribute('aria-label', 'Expand for additional information'); - } - } - - let toggleButtons = document.querySelectorAll('.usa-button--show-more-button'); - toggleButtons.forEach((toggleButton) => { - - // get contentDiv for element specified in data-for attribute of toggleButton - let dataFor = toggleButton.dataset.for; - let contentDiv = document.getElementById(dataFor); - let buttonParentRow = toggleButton.parentElement.parentElement; - if (contentDiv && contentDiv.tagName.toLowerCase() === 'tr' && contentDiv.classList.contains('show-more-content') && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') { - toggleButton.addEventListener('click', function() { - toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow); - }); - } else { - console.warn('Found a toggle button with no associated toggleable content or parent row'); - } - - }); - } - - /** - * Converts a given `last_active` value into a display value and a numeric sort value. - * The input can be a UTC date, the strings "Invited", "Invalid date", or null/undefined. - * - * @param {string} last_active - UTC date string or special status like "Invited" or "Invalid date". - * @returns {Object} - An object containing `display_value` (formatted date or status string) - * and `sort_value` (numeric value for sorting). - */ - handleLastActive(last_active) { - const invited = 'Invited'; - const invalid_date = 'Invalid date'; - const options = { year: 'numeric', month: 'long', day: 'numeric' }; // Date display format - - let display_value = invalid_date; // Default display value for invalid or null dates - let sort_value = -1; // Default sort value for invalid or null dates - - if (last_active === invited) { - // Handle "Invited" status: special case with 0 sort value - display_value = invited; - sort_value = 0; - } else if (last_active && last_active !== invalid_date) { - // Parse and format valid UTC date strings - const parsedDate = new Date(last_active); - - if (!isNaN(parsedDate.getTime())) { - // Valid date - display_value = parsedDate.toLocaleDateString('en-US', options); - sort_value = parsedDate.getTime(); // Use timestamp for sorting - } else { - console.error(`Error: Invalid date string provided: ${last_active}`); - } - } - - return { display_value, sort_value }; - } - - /** - * Generates HTML for the list of domains assigned to a member. - * - * @param {number} num_domains - The number of domains the member is assigned to. - * @param {Array} domain_names - An array of domain names. - * @param {Array} domain_urls - An array of corresponding domain URLs. - * @returns {string} - A string of HTML displaying the domains assigned to the member. - */ - generateDomainsHTML(num_domains, domain_names, domain_urls, action_url) { - // Initialize an empty string for the HTML - let domainsHTML = ''; - - // Only generate HTML if the member has one or more assigned domains - if (num_domains > 0) { - domainsHTML += "
"; - domainsHTML += "

Domains assigned

"; - domainsHTML += `

This member is assigned to ${num_domains} domains:

`; - domainsHTML += "
    "; - - // Display up to 6 domains with their URLs - for (let i = 0; i < num_domains && i < 6; i++) { - domainsHTML += `
  • ${domain_names[i]}
  • `; - } - - domainsHTML += "
"; - - // If there are more than 6 domains, display a "View assigned domains" link - if (num_domains >= 6) { - domainsHTML += `

View assigned domains

`; - } - - domainsHTML += "
"; - } - - return domainsHTML; - } - - /** - * The POST call for deleting a Member and which error or success message it should return - * and redirection if necessary - * - * @param {string} member_delete_url - The URL for deletion ie `${member_type}-${member_id}/delete`` - * @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page - * Note: X-Request-With is used for security reasons to present CSRF attacks, the server checks that this header is present - * (consent via CORS) so it knows it's not from a random request attempt - */ - deleteMember(member_delete_url, pageToDisplay) { - // Get CSRF token - const csrfToken = getCsrfToken(); - // Create FormData object and append the CSRF token - const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}`; - - fetch(`${member_delete_url}`, { - method: 'POST', - headers: { - 'Content-Type': 'application/x-www-form-urlencoded', - 'X-Requested-With': 'XMLHttpRequest', - 'X-CSRFToken': csrfToken, - }, - body: formData - }) - .then(response => { - if (response.status === 200) { - response.json().then(data => { - if (data.success) { - this.addAlert("success", data.success); - } - this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); - }); - } else { - response.json().then(data => { - if (data.error) { - // This should display the error given from backend for - // either only admin OR in progress requests - this.addAlert("error", data.error); - } else { - throw new Error(`Unexpected status: ${response.status}`); - } - }); - } - }) - .catch(error => { - console.error('Error deleting member:', error); - }); - } - - - /** - * Adds an alert message to the page with an alert class. - * - * @param {string} alertClass - {error, warning, info, success} - * @param {string} alertMessage - The text that will be displayed - * - */ - addAlert(alertClass, alertMessage) { - let toggleableAlertDiv = document.getElementById("toggleable-alert"); - this.resetAlerts(); - toggleableAlertDiv.classList.add(`usa-alert--${alertClass}`); - let alertParagraph = toggleableAlertDiv.querySelector(".usa-alert__text"); - alertParagraph.innerHTML = alertMessage - showElement(toggleableAlertDiv); - } - - /** - * Resets the reusable alert message - */ - resetAlerts() { - // Create a list of any alert that's leftover and remove - document.querySelectorAll(".usa-alert:not(#toggleable-alert)").forEach(alert => { - alert.remove(); - }); - let toggleableAlertDiv = document.getElementById("toggleable-alert"); - toggleableAlertDiv.classList.remove('usa-alert--error'); - toggleableAlertDiv.classList.remove('usa-alert--success'); - hideElement(toggleableAlertDiv); - } - - /** - * Generates an HTML string summarizing a user's additional permissions within a portfolio, - * based on the user's permissions and predefined permission choices. - * - * @param {Array} member_permissions - An array of permission strings that the member has. - * @param {Object} UserPortfolioPermissionChoices - An object containing predefined permission choice constants. - * Expected keys include: - * - VIEW_ALL_DOMAINS - * - VIEW_MANAGED_DOMAINS - * - EDIT_REQUESTS - * - VIEW_ALL_REQUESTS - * - EDIT_MEMBERS - * - VIEW_MEMBERS - * - * @returns {string} - A string of HTML representing the user's additional permissions. - * If the user has no specific permissions, it returns a default message - * indicating no additional permissions. - * - * Behavior: - * - The function checks the user's permissions (`member_permissions`) and generates - * corresponding HTML sections based on the permission choices defined in `UserPortfolioPermissionChoices`. - * - Permissions are categorized into domains, requests, and members: - * - Domains: Determines whether the user can view or manage all or assigned domains. - * - Requests: Differentiates between users who can edit requests, view all requests, or have no request privileges. - * - Members: Distinguishes between members who can manage or only view other members. - * - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions. - * - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions. - */ - generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) { - let permissionsHTML = ''; - - // Check domain-related permissions - if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) { - permissionsHTML += "

Domains: Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).

"; - } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) { - permissionsHTML += "

Domains: Can manage domains they are assigned to and edit information about the domain (including DNS settings).

"; - } - - // Check request-related permissions - if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) { - permissionsHTML += "

Domain requests: Can view all organization domain requests. Can create domain requests and modify their own requests.

"; - } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) { - permissionsHTML += "

Domain requests (view-only): Can view all organization domain requests. Can't create or modify any domain requests.

"; - } - - // Check member-related permissions - if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) { - permissionsHTML += "

Members: Can manage members including inviting new members, removing current members, and assigning domains to members.

"; - } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) { - permissionsHTML += "

Members (view-only): Can view all organizational members. Can't manage any members.

"; - } - - // If no specific permissions are assigned, display a message indicating no additional permissions - if (!permissionsHTML) { - permissionsHTML += "

No additional permissions: There are no additional permissions for this member.

"; - } - - // Add a permissions header and wrap the entire output in a container - permissionsHTML = "

Additional permissions for this member

" + permissionsHTML + "
"; - - return permissionsHTML; - } - - /** - * Modal that displays when deleting a domain request - * @param {string} num_domains - Number of domain a user has within the org - * @param {string} member_email - The member's email - * @param {string} submit_delete_url - `${member_type}-${member_id}/delete` - * @param {HTMLElement} wrapper_element - The element to which the modal is appended - */ - static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) { - let modalHeading = ''; - let modalDescription = ''; - - if (num_domains == 0){ - modalHeading = `Are you sure you want to delete ${member_email}?`; - modalDescription = `They will no longer be able to access this organization. - This action cannot be undone.`; - } else if (num_domains == 1) { - modalHeading = `Are you sure you want to delete ${member_email}?`; - modalDescription = `${member_email} currently manages ${num_domains} domain in the organization. - Removing them from the organization will remove all of their domains. They will no longer be able to - access this organization. This action cannot be undone.`; - } else if (num_domains > 1) { - modalHeading = `Are you sure you want to delete ${member_email}?`; - modalDescription = `${member_email} currently manages ${num_domains} domains in the organization. - Removing them from the organization will remove all of their domains. They will no longer be able to - access this organization. This action cannot be undone.`; - } - - const modalSubmit = ` - - ` - - addModal(`toggle-remove-member-${id}`, 'Are you sure you want to continue?', 'Member will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); - } -} - -class MemberDomainsTable extends BaseTable { - - constructor() { - super('member-domain'); - this.currentSortBy = 'name'; - } - getBaseUrl() { - return document.getElementById("get_member_domains_json_url"); - } - getDataObjects(data) { - return data.domains; - } - addRow(dataObject, tbody, customTableOptions) { - const domain = dataObject; - const row = document.createElement('tr'); - - row.innerHTML = ` - - ${domain.name} - - `; - tbody.appendChild(row); - } - -} - - -/** - * An IIFE that listens for DOM Content to be loaded, then executes. This function - * initializes the domains list and associated functionality. - * - */ -document.addEventListener('DOMContentLoaded', function() { - const isDomainsPage = document.getElementById("domains") - if (isDomainsPage){ - const domainsTable = new DomainsTable(); - if (domainsTable.tableWrapper) { - // Initial load - domainsTable.loadTable(1); - } - } -}); - -/** - * An IIFE that listens for DOM Content to be loaded, then executes. This function - * initializes the domain requests list and associated functionality. - * - */ -document.addEventListener('DOMContentLoaded', function() { - const domainRequestsSectionWrapper = document.getElementById('domain-requests'); - if (domainRequestsSectionWrapper) { - const domainRequestsTable = new DomainRequestsTable(); - if (domainRequestsTable.tableWrapper) { - domainRequestsTable.loadTable(1); - } - } - - document.addEventListener('focusin', function(event) { - closeOpenAccordions(event); - }); - - document.addEventListener('click', function(event) { - closeOpenAccordions(event); - }); - - function closeMoreActionMenu(accordionThatIsOpen) { - if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") { - accordionThatIsOpen.click(); - } - } - - function closeOpenAccordions(event) { - const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]'); - openAccordions.forEach((openAccordionButton) => { - // Find the corresponding accordion - const accordion = openAccordionButton.closest('.usa-accordion--more-actions'); - if (accordion && !accordion.contains(event.target)) { - // Close the accordion if the click is outside - closeMoreActionMenu(openAccordionButton); - } - }); - } -}); - -const utcDateString = (dateString) => { - const date = new Date(dateString); - const utcYear = date.getUTCFullYear(); - const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }); - const utcDay = date.getUTCDate().toString().padStart(2, '0'); - let utcHours = date.getUTCHours(); - const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0'); - - const ampm = utcHours >= 12 ? 'PM' : 'AM'; - utcHours = utcHours % 12 || 12; // Convert to 12-hour format, '0' hours should be '12' - - return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} ${ampm} UTC`; -}; - - - -/** - * An IIFE that listens for DOM Content to be loaded, then executes. This function - * initializes the members list and associated functionality. - * - */ -document.addEventListener('DOMContentLoaded', function() { - const isMembersPage = document.getElementById("members") - if (isMembersPage){ - const membersTable = new MembersTable(); - if (membersTable.tableWrapper) { - // Initial load - membersTable.loadTable(1); - } - } -}); - -/** - * An IIFE that listens for DOM Content to be loaded, then executes. This function - * initializes the member domains list and associated functionality. - * - */ -document.addEventListener('DOMContentLoaded', function() { - const isMemberDomainsPage = document.getElementById("member-domains") - if (isMemberDomainsPage){ - const memberDomainsTable = new MemberDomainsTable(); - if (memberDomainsTable.tableWrapper) { - // Initial load - memberDomainsTable.loadTable(1); - } - } -}); - -/** - * An IIFE that displays confirmation modal on the user profile page - */ -(function userProfileListener() { - - const showConfirmationModalTrigger = document.querySelector('.show-confirmation-modal'); - if (showConfirmationModalTrigger) { - showConfirmationModalTrigger.click(); - } -} -)(); - -/** - * An IIFE that hooks up the edit buttons on the finish-user-setup page - */ -(function finishUserSetupListener() { - - function getInputField(fieldName){ - return document.querySelector(`#id_${fieldName}`) - } - - // Shows the hidden input field and hides the readonly one - function showInputFieldHideReadonlyField(fieldName, button) { - let inputField = getInputField(fieldName) - let readonlyField = document.querySelector(`#${fieldName}__edit-button-readonly`) - - readonlyField.classList.toggle('display-none'); - inputField.classList.toggle('display-none'); - - // Toggle the bold style on the grid row - let gridRow = button.closest(".grid-col-2").closest(".grid-row") - if (gridRow){ - gridRow.classList.toggle("bold-usa-label") - } - } - - function handleFullNameField(fieldName = "full_name") { - // Remove the display-none class from the nearest parent div - let nameFieldset = document.querySelector("#profile-name-group"); - if (nameFieldset){ - nameFieldset.classList.remove("display-none"); - } - - // Hide the "full_name" field - let inputField = getInputField(fieldName); - if (inputField) { - inputFieldParentDiv = inputField.closest("div"); - if (inputFieldParentDiv) { - inputFieldParentDiv.classList.add("display-none"); - } - } - } - - function handleEditButtonClick(fieldName, button){ - button.addEventListener('click', function() { - // Lock the edit button while this operation occurs - button.disabled = true - - if (fieldName == "full_name"){ - handleFullNameField(); - }else { - showInputFieldHideReadonlyField(fieldName, button); - } - - // Hide the button itself - button.classList.add("display-none"); - - // Unlock after it completes - button.disabled = false - }); - } - - function setupListener(){ - - - - document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { - // Get the "{field_name}" and "edit-button" - let fieldIdParts = button.id.split("__") - if (fieldIdParts && fieldIdParts.length > 0){ - let fieldName = fieldIdParts[0] - - // When the edit button is clicked, show the input field under it - handleEditButtonClick(fieldName, button); - - let editableFormGroup = button.parentElement.parentElement.parentElement; - if (editableFormGroup){ - let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field") - let inputField = document.getElementById(`id_${fieldName}`); - if (!inputField || !readonlyField) { - return; - } - - let inputFieldValue = inputField.value - if (inputFieldValue || fieldName == "full_name"){ - if (fieldName == "full_name"){ - let firstName = document.querySelector("#id_first_name"); - let middleName = document.querySelector("#id_middle_name"); - let lastName = document.querySelector("#id_last_name"); - if (firstName && lastName && firstName.value && lastName.value) { - let values = [firstName.value, middleName.value, lastName.value] - readonlyField.innerHTML = values.join(" "); - }else { - let fullNameField = document.querySelector('#full_name__edit-button-readonly'); - let svg = fullNameField.querySelector("svg use") - if (svg) { - const currentHref = svg.getAttribute('xlink:href'); - if (currentHref) { - const parts = currentHref.split('#'); - if (parts.length === 2) { - // Keep the path before '#' and replace the part after '#' with 'invalid' - const newHref = parts[0] + '#error'; - svg.setAttribute('xlink:href', newHref); - fullNameField.classList.add("toggleable_input__error") - label = fullNameField.querySelector(".toggleable_input__readonly-field") - label.innerHTML = "Unknown"; - } - } - } - } - - // Technically, the full_name field is optional, but we want to display it as required. - // This style is applied to readonly fields (gray text). This just removes it, as - // this is difficult to achieve otherwise by modifying the .readonly property. - if (readonlyField.classList.contains("text-base")) { - readonlyField.classList.remove("text-base") - } - }else { - readonlyField.innerHTML = inputFieldValue - } - } - } - } - }); - } - - function showInputOnErrorFields(){ - document.addEventListener('DOMContentLoaded', function() { - - // Get all input elements within the form - let form = document.querySelector("#finish-profile-setup-form"); - let inputs = form ? form.querySelectorAll("input") : null; - if (!inputs) { - return null; - } - - let fullNameButtonClicked = false - inputs.forEach(function(input) { - let fieldName = input.name; - let errorMessage = document.querySelector(`#id_${fieldName}__error-message`); - - // If no error message is found, do nothing - if (!fieldName || !errorMessage) { - return null; - } - - let editButton = document.querySelector(`#${fieldName}__edit-button`); - if (editButton){ - // Show the input field of the field that errored out - editButton.click(); - } - - // If either the full_name field errors out, - // or if any of its associated fields do - show all name related fields. - let nameFields = ["first_name", "middle_name", "last_name"]; - if (nameFields.includes(fieldName) && !fullNameButtonClicked){ - // Click the full name button if any of its related fields error out - fullNameButton = document.querySelector("#full_name__edit-button"); - if (fullNameButton) { - fullNameButton.click(); - fullNameButtonClicked = true; - } - } - }); - }); - }; - - setupListener(); - - // Show the input fields if an error exists - showInputOnErrorFields(); - -})(); - - -/** - * An IIFE that changes the default clear behavior on comboboxes to the input field. - * We want the search bar to act soley as a search bar. - */ -(function loadInitialValuesForComboBoxes() { - var overrideDefaultClearButton = true; - var isTyping = false; - - document.addEventListener('DOMContentLoaded', (event) => { - handleAllComboBoxElements(); - }); - - function handleAllComboBoxElements() { - const comboBoxElements = document.querySelectorAll(".usa-combo-box"); - comboBoxElements.forEach(comboBox => { - const input = comboBox.querySelector("input"); - const select = comboBox.querySelector("select"); - if (!input || !select) { - console.warn("No combobox element found"); - return; - } - // Set the initial value of the combobox - let initialValue = select.getAttribute("data-default-value"); - let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input"); - if (!clearInputButton) { - console.warn("No clear element found"); - return; - } - - // Override the default clear button behavior such that it no longer clears the input, - // it just resets to the data-initial-value. - - // Due to the nature of how uswds works, this is slightly hacky. - - // Use a MutationObserver to watch for changes in the dropdown list - const dropdownList = comboBox.querySelector(`#${input.id}--list`); - const observer = new MutationObserver(function(mutations) { - mutations.forEach(function(mutation) { - if (mutation.type === "childList") { - addBlankOption(clearInputButton, dropdownList, initialValue); - } - }); - }); - - // Configure the observer to watch for changes in the dropdown list - const config = { childList: true, subtree: true }; - observer.observe(dropdownList, config); - - // Input event listener to detect typing - input.addEventListener("input", () => { - isTyping = true; - }); - - // Blur event listener to reset typing state - input.addEventListener("blur", () => { - isTyping = false; - }); - - // Hide the reset button when there is nothing to reset. - // Do this once on init, then everytime a change occurs. - updateClearButtonVisibility(select, initialValue, clearInputButton) - select.addEventListener("change", () => { - updateClearButtonVisibility(select, initialValue, clearInputButton) - }); - - // Change the default input behaviour - have it reset to the data default instead - clearInputButton.addEventListener("click", (e) => { - if (overrideDefaultClearButton && initialValue) { - e.preventDefault(); - e.stopPropagation(); - input.click(); - // Find the dropdown option with the desired value - const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option"); - if (dropdownOptions) { - dropdownOptions.forEach(option => { - if (option.getAttribute("data-value") === initialValue) { - // Simulate a click event on the dropdown option - option.click(); - } - }); - } - } - }); - }); - } - - function updateClearButtonVisibility(select, initialValue, clearInputButton) { - if (select.value === initialValue) { - hideElement(clearInputButton); - }else { - showElement(clearInputButton) - } - } - - function addBlankOption(clearInputButton, dropdownList, initialValue) { - if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) { - const blankOption = document.createElement("li"); - blankOption.setAttribute("role", "option"); - blankOption.setAttribute("data-value", ""); - blankOption.classList.add("usa-combo-box__list-option"); - if (!initialValue){ - blankOption.classList.add("usa-combo-box__list-option--selected") - } - blankOption.textContent = "⎯"; - - dropdownList.insertBefore(blankOption, dropdownList.firstChild); - blankOption.addEventListener("click", (e) => { - e.preventDefault(); - e.stopPropagation(); - overrideDefaultClearButton = false; - // Trigger the default clear behavior - clearInputButton.click(); - overrideDefaultClearButton = true; - }); - } - } -})(); - -// This is specifically for the Member Profile (Manage Member) Page member/invitation removal -document.addEventListener("DOMContentLoaded", () => { - (function portfolioMemberPageToggle() { - const wrapperDeleteAction = document.getElementById("wrapper-delete-action") - if (wrapperDeleteAction) { - const member_type = wrapperDeleteAction.getAttribute("data-member-type"); - const member_id = wrapperDeleteAction.getAttribute("data-member-id"); - const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); - const member_name = wrapperDeleteAction.getAttribute("data-member-name"); - const member_email = wrapperDeleteAction.getAttribute("data-member-email"); - const member_delete_url = `${member_type}-${member_id}/delete`; - const unique_id = `${member_type}-${member_id}`; - - let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; - wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); - - // This easter egg is only for fixtures that dont have names as we are displaying their emails - // All prod users will have emails linked to their account - MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); - - uswdsInitializeModals(); - - // Now the DOM and modals are ready, add listeners to the submit buttons - const modals = document.querySelectorAll('.usa-modal__content'); - - modals.forEach(modal => { - const submitButton = modal.querySelector('.usa-modal__submit'); - const closeButton = modal.querySelector('.usa-modal__close'); - submitButton.addEventListener('click', () => { - closeButton.click(); - let delete_member_form = document.getElementById("member-delete-form"); - if (delete_member_form) { - delete_member_form.submit(); - } - }); - }); - } - })(); -}); - -/** An IIFE that intializes the requesting entity page. - * This page has a radio button that dynamically toggles some fields - * Within that, the dropdown also toggles some additional form elements. -*/ -(function handleRequestingEntityFieldset() { - // Sadly, these ugly ids are the auto generated with this prefix - const formPrefix = "portfolio_requesting_entity" - const radioFieldset = document.getElementById(`id_${formPrefix}-requesting_entity_is_suborganization__fieldset`); - const radios = radioFieldset?.querySelectorAll(`input[name="${formPrefix}-requesting_entity_is_suborganization"]`); - const select = document.getElementById(`id_${formPrefix}-sub_organization`); - const selectParent = select?.parentElement; - const suborgContainer = document.getElementById("suborganization-container"); - const suborgDetailsContainer = document.getElementById("suborganization-container__details"); - const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value; - // Make sure all crucial page elements exist before proceeding. - // This more or less ensures that we are on the Requesting Entity page, and not elsewhere. - if (!radios || !select || !selectParent || !suborgContainer || !suborgDetailsContainer) return; - - // requestingSuborganization: This just broadly determines if they're requesting a suborg at all - // requestingNewSuborganization: This variable determines if the user is trying to *create* a new suborganization or not. - var requestingSuborganization = Array.from(radios).find(radio => radio.checked)?.value === "True"; - var requestingNewSuborganization = document.getElementById(`id_${formPrefix}-is_requesting_new_suborganization`); - - function toggleSuborganization(radio=null) { - if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True"; - requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer); - requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False"; - requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer); - } - - // Add fake "other" option to sub_organization select - if (select && !Array.from(select.options).some(option => option.value === "other")) { - select.add(new Option(subOrgCreateNewOption, "other")); - } - - if (requestingNewSuborganization.value === "True") { - select.value = "other"; - } - - // Add event listener to is_suborganization radio buttons, and run for initial display - toggleSuborganization(); - radios.forEach(radio => { - radio.addEventListener("click", () => toggleSuborganization(radio)); - }); - - // Add event listener to the suborg dropdown to show/hide the suborg details section - select.addEventListener("change", () => toggleSuborganization()); -})(); \ No newline at end of file diff --git a/src/registrar/assets/src/js/getgov-admin/copy-to-clipboard.js b/src/registrar/assets/src/js/getgov-admin/copy-to-clipboard.js new file mode 100644 index 000000000..e4eafd778 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/copy-to-clipboard.js @@ -0,0 +1,59 @@ +function copyToClipboardAndChangeIcon(button) { + // Assuming the input is the previous sibling of the button + let input = button.previousElementSibling; + // Copy input value to clipboard + if (input) { + navigator.clipboard.writeText(input.value).then(function() { + // Change the icon to a checkmark on successful copy + let buttonIcon = button.querySelector('.copy-to-clipboard use'); + if (buttonIcon) { + let currentHref = buttonIcon.getAttribute('xlink:href'); + let baseHref = currentHref.split('#')[0]; + + // Append the new icon reference + buttonIcon.setAttribute('xlink:href', baseHref + '#check'); + + // Change the button text + let nearestSpan = button.querySelector("span") + let original_text = nearestSpan.innerText + nearestSpan.innerText = "Copied to clipboard" + + setTimeout(function() { + // Change back to the copy icon + buttonIcon.setAttribute('xlink:href', currentHref); + nearestSpan.innerText = original_text; + }, 2000); + + } + }).catch(function(error) { + console.error('Clipboard copy failed', error); + }); + } +} + +/** + * A function for pages in DjangoAdmin that use a clipboard button +*/ +export function initCopyToClipboard() { + let clipboardButtons = document.querySelectorAll(".copy-to-clipboard") + clipboardButtons.forEach((button) => { + + // Handle copying the text to your clipboard, + // and changing the icon. + button.addEventListener("click", ()=>{ + copyToClipboardAndChangeIcon(button); + }); + + // Add a class that adds the outline style on click + button.addEventListener("mousedown", function() { + this.classList.add("no-outline-on-click"); + }); + + // But add it back in after the user clicked, + // for accessibility reasons (so we can still tab, etc) + button.addEventListener("blur", function() { + this.classList.remove("no-outline-on-click"); + }); + + }); +} diff --git a/src/registrar/assets/src/js/getgov-admin/domain-form.js b/src/registrar/assets/src/js/getgov-admin/domain-form.js new file mode 100644 index 000000000..474e2822c --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/domain-form.js @@ -0,0 +1,30 @@ +/** + * A function that appends target="_blank" to the domain_form buttons +*/ + +/** Either sets attribute target="_blank" to a given element, or removes it */ +function openInNewTab(el, removeAttribute = false){ + if(removeAttribute){ + el.setAttribute("target", "_blank"); + }else{ + el.removeAttribute("target", "_blank"); + } +}; + +/** +On mouseover, appends target="_blank" on domain_form under the Domain page. +The reason for this is that the template has a form that contains multiple buttons. +The structure of that template complicates seperating those buttons +out of the form (while maintaining the same position on the page). +However, if we want to open one of those submit actions to a new tab - +such as the manage domain button - we need to dynamically append target. +As there is no built-in django method which handles this, we do it here. +*/ +export function initDomainFormTargetBlankButtons() { + let domainFormElement = document.getElementById("domain_form"); + let domainSubmitButton = document.getElementById("manageDomainSubmitButton"); + if(domainSubmitButton && domainFormElement){ + domainSubmitButton.addEventListener("mouseover", () => openInNewTab(domainFormElement, true)); + domainSubmitButton.addEventListener("mouseout", () => openInNewTab(domainFormElement, false)); + } +} diff --git a/src/registrar/assets/src/js/getgov-admin/domain-information-form.js b/src/registrar/assets/src/js/getgov-admin/domain-information-form.js new file mode 100644 index 000000000..8139c752f --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/domain-information-form.js @@ -0,0 +1,17 @@ +import { handleSuborganizationFields } from './helpers-portfolio-dynamic-fields.js'; + +/** + * A function for dynamic DomainInformation fields +*/ +export function initDynamicDomainInformationFields(){ + const domainInformationPage = document.getElementById("domaininformation_form"); + if (domainInformationPage) { + handleSuborganizationFields(); + } + + // DomainInformation is embedded inside domain so this should fire there too + const domainPage = document.getElementById("domain_form"); + if (domainPage) { + handleSuborganizationFields(portfolioDropdownSelector="#id_domain_info-0-portfolio", suborgDropdownSelector="#id_domain_info-0-sub_organization"); + } +} diff --git a/src/registrar/assets/src/js/getgov-admin/domain-request-form.js b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js new file mode 100644 index 000000000..4621c5ac5 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/domain-request-form.js @@ -0,0 +1,640 @@ +import { hideElement, showElement, addOrRemoveSessionBoolean } from './helpers-admin.js'; +import { handlePortfolioSelection } from './helpers-portfolio-dynamic-fields.js'; + +function displayModalOnDropdownClick(linkClickedDisplaysModal, statusDropdown, actionButton, valueToCheck){ + + // If these exist all at the same time, we're on the right page + if (linkClickedDisplaysModal && statusDropdown && statusDropdown.value){ + + // Set the previous value in the event the user cancels. + let previousValue = statusDropdown.value; + if (actionButton){ + + // Otherwise, if the confirmation buttion is pressed, set it to that + actionButton.addEventListener('click', function() { + // Revert the dropdown to its previous value + statusDropdown.value = valueToCheck; + }); + }else { + console.log("displayModalOnDropdownClick() -> Cancel button was null"); + } + + // Add a change event listener to the dropdown. + statusDropdown.addEventListener('change', function() { + // Check if "Ineligible" is selected + if (this.value && this.value.toLowerCase() === valueToCheck) { + // Set the old value in the event the user cancels, + // or otherwise exists the dropdown. + statusDropdown.value = previousValue; + + // Display the modal. + linkClickedDisplaysModal.click(); + } + }); + } +} + +/** + * A function for DomainRequest to hook a modal to a dropdown option. + * This intentionally does not interact with createPhantomModalFormButtons() + * When the status dropdown is clicked and is set to "ineligible", toggle a confirmation dropdown. +*/ +export function initIneligibleModal(){ + // Grab the invisible element that will hook to the modal. + // This doesn't technically need to be done with one, but this is simpler to manage. + let modalButton = document.getElementById("invisible-ineligible-modal-toggler"); + let statusDropdown = document.getElementById("id_status"); + + // Because the modal button does not have the class "dja-form-placeholder", + // it will not be affected by the createPhantomModalFormButtons() function. + let actionButton = document.querySelector('button[name="_set_domain_request_ineligible"]'); + let valueToCheck = "ineligible"; + displayModalOnDropdownClick(modalButton, statusDropdown, actionButton, valueToCheck); +} + +/** + * A function for the "Assign to me" button under the investigator field in DomainRequests. + * This field uses the "select2" selector, rather than the default. + * To perform data operations on this - we need to use jQuery rather than vanilla js. +*/ +export function initAssignToMe() { + if (document.getElementById("id_investigator") && django && django.jQuery) { + let selector = django.jQuery("#id_investigator"); + let assignSelfButton = document.querySelector("#investigator__assign_self"); + if (!selector || !assignSelfButton) { + return; + } + + let currentUserId = assignSelfButton.getAttribute("data-user-id"); + let currentUserName = assignSelfButton.getAttribute("data-user-name"); + if (!currentUserId || !currentUserName){ + console.error("Could not assign current user: no values found."); + return; + } + + // Hook a click listener to the "Assign to me" button. + // Logic borrowed from here: https://select2.org/programmatic-control/add-select-clear-items#create-if-not-exists + assignSelfButton.addEventListener("click", function() { + if (selector.find(`option[value='${currentUserId}']`).length) { + // Select the value that is associated with the current user. + selector.val(currentUserId).trigger("change"); + } else { + // Create a DOM Option that matches the desired user. Then append it and select it. + let userOption = new Option(currentUserName, currentUserId, true, true); + selector.append(userOption).trigger("change"); + } + }); + + // Listen to any change events, and hide the parent container if investigator has a value. + selector.on('change', function() { + // The parent container has display type flex. + assignSelfButton.parentElement.style.display = this.value === currentUserId ? "none" : "flex"; + }); + } +} + +/** + * A function that hides and shows approved domain select2 row in domain request + * conditionally based on the Status field selection. If Approved, show. If not Approved, + * don't show. +*/ +export function initApprovedDomain() { + document.addEventListener('DOMContentLoaded', function() { + const domainRequestForm = document.getElementById("domainrequest_form"); + if (!domainRequestForm) { + return; + } + + const statusToCheck = "approved"; + const statusSelect = document.getElementById("id_status"); + const sessionVariableName = "showApprovedDomain"; + let approvedDomainFormGroup = document.querySelector(".field-approved_domain"); + + function updateFormGroupVisibility(showFormGroups) { + if (showFormGroups) { + showElement(approvedDomainFormGroup); + } else { + hideElement(approvedDomainFormGroup); + } + } + + // Handle showing/hiding the related fields on page load. + function initializeFormGroups() { + let isStatus = statusSelect.value == statusToCheck; + + // Initial handling of these groups. + updateFormGroupVisibility(isStatus); + + // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage + statusSelect.addEventListener('change', () => { + // Show the approved if the status is what we expect. + isStatus = statusSelect.value == statusToCheck; + updateFormGroupVisibility(isStatus); + addOrRemoveSessionBoolean(sessionVariableName, isStatus); + }); + + // Listen to Back/Forward button navigation and handle approvedDomainFormGroup display based on session storage + // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the + // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide + // accurately for this edge case, we use cache and test for the back/forward navigation. + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.type === "back_forward") { + let showTextAreaFormGroup = sessionStorage.getItem(sessionVariableName) !== null; + updateFormGroupVisibility(showTextAreaFormGroup); + } + }); + }); + observer.observe({ type: "navigation" }); + } + + initializeFormGroups(); + }); +} + +/** + * A function for copy summary button +*/ +export function initCopyRequestSummary() { + const copyButton = document.getElementById('id-copy-to-clipboard-summary'); + + if (copyButton) { + copyButton.addEventListener('click', function() { + /// Generate a rich HTML summary text and copy to clipboard + + //------ Organization Type + const organizationTypeElement = document.getElementById('id_organization_type'); + const organizationType = organizationTypeElement.options[organizationTypeElement.selectedIndex].text; + + //------ Alternative Domains + const alternativeDomainsDiv = document.querySelector('.form-row.field-alternative_domains .readonly'); + const alternativeDomainslinks = alternativeDomainsDiv.querySelectorAll('a'); + const alternativeDomains = Array.from(alternativeDomainslinks).map(link => link.textContent); + + //------ Existing Websites + const existingWebsitesDiv = document.querySelector('.form-row.field-current_websites .readonly'); + const existingWebsiteslinks = existingWebsitesDiv.querySelectorAll('a'); + const existingWebsites = Array.from(existingWebsiteslinks).map(link => link.textContent); + + //------ Additional Contacts + // 1 - Create a hyperlinks map so we can display contact details and also link to the contact + const otherContactsDiv = document.querySelector('.form-row.field-other_contacts .readonly'); + let otherContactLinks = []; + const nameToUrlMap = {}; + if (otherContactsDiv) { + otherContactLinks = otherContactsDiv.querySelectorAll('a'); + otherContactLinks.forEach(link => { + const name = link.textContent.trim(); + const url = link.href; + nameToUrlMap[name] = url; + }); + } + + // 2 - Iterate through contact details and assemble html for summary + let otherContactsSummary = "" + const bulletList = document.createElement('ul'); + + // CASE 1 - Contacts are not in a table (this happens if there is only one or two other contacts) + const contacts = document.querySelectorAll('.field-other_contacts .dja-detail-list dd'); + if (contacts) { + contacts.forEach(contact => { + // Check if the
element is not empty + const name = contact.querySelector('a.contact_info_name')?.innerText; + const title = contact.querySelector('span.contact_info_title')?.innerText; + const email = contact.querySelector('span.contact_info_email')?.innerText; + const phone = contact.querySelector('span.contact_info_phone')?.innerText; + const url = nameToUrlMap[name] || '#'; + // Format the contact information + const listItem = document.createElement('li'); + listItem.innerHTML = `${name}, ${title}, ${email}, ${phone}`; + bulletList.appendChild(listItem); + }); + + } + + // CASE 2 - Contacts are in a table (this happens if there is more than 2 contacts) + const otherContactsTable = document.querySelector('.form-row.field-other_contacts table tbody'); + if (otherContactsTable) { + const otherContactsRows = otherContactsTable.querySelectorAll('tr'); + otherContactsRows.forEach(contactRow => { + // Extract the contact details + const name = contactRow.querySelector('th').textContent.trim(); + const title = contactRow.querySelectorAll('td')[0].textContent.trim(); + const email = contactRow.querySelectorAll('td')[1].textContent.trim(); + const phone = contactRow.querySelectorAll('td')[2].textContent.trim(); + const url = nameToUrlMap[name] || '#'; + // Format the contact information + const listItem = document.createElement('li'); + listItem.innerHTML = `${name}, ${title}, ${email}, ${phone}`; + bulletList.appendChild(listItem); + }); + } + otherContactsSummary += bulletList.outerHTML; + + + //------ Requested Domains + const requestedDomainElement = document.getElementById('id_requested_domain'); + // We have to account for different superuser and analyst markups + const requestedDomain = requestedDomainElement.options + ? requestedDomainElement.options[requestedDomainElement.selectedIndex].text + : requestedDomainElement.text; + + //------ Submitter + // Function to extract text by ID and handle missing elements + function extractTextById(id, divElement) { + if (divElement) { + const element = divElement.querySelector(`#${id}`); + return element ? ", " + element.textContent.trim() : ''; + } + return ''; + } + + //------ Senior Official + const seniorOfficialDiv = document.querySelector('.form-row.field-senior_official'); + const seniorOfficialElement = document.getElementById('id_senior_official'); + const seniorOfficialName = seniorOfficialElement.options[seniorOfficialElement.selectedIndex].text; + const seniorOfficialTitle = seniorOfficialDiv.querySelector('.contact_info_title'); + const seniorOfficialEmail = seniorOfficialDiv.querySelector('.contact_info_email'); + const seniorOfficialPhone = seniorOfficialDiv.querySelector('.contact_info_phone'); + let seniorOfficialInfo = `${seniorOfficialName}${seniorOfficialTitle}${seniorOfficialEmail}${seniorOfficialPhone}`; + + const html_summary = `Recommendation:
` + + `Organization Type: ${organizationType}
` + + `Requested Domain: ${requestedDomain}
` + + `Current Websites: ${existingWebsites.join(', ')}
` + + `Rationale:
` + + `Alternative Domains: ${alternativeDomains.join(', ')}
` + + `Senior Official: ${seniorOfficialInfo}
` + + `Other Employees: ${otherContactsSummary}
`; + + //Replace
with \n, then strip out all remaining html tags (replace <...> with '') + const plain_summary = html_summary.replace(/<\/br>|
/g, '\n').replace(/<\/?[^>]+(>|$)/g, ''); + + // Create Blobs with the summary content + const html_blob = new Blob([html_summary], { type: 'text/html' }); + const plain_blob = new Blob([plain_summary], { type: 'text/plain' }); + + // Create a ClipboardItem with the Blobs + const clipboardItem = new ClipboardItem({ + 'text/html': html_blob, + 'text/plain': plain_blob + }); + + // Write the ClipboardItem to the clipboard + navigator.clipboard.write([clipboardItem]).then(() => { + // Change the icon to a checkmark on successful copy + let buttonIcon = copyButton.querySelector('use'); + if (buttonIcon) { + let currentHref = buttonIcon.getAttribute('xlink:href'); + let baseHref = currentHref.split('#')[0]; + + // Append the new icon reference + buttonIcon.setAttribute('xlink:href', baseHref + '#check'); + + // Change the button text + let nearestSpan = copyButton.querySelector("span"); + let original_text = nearestSpan.innerText; + nearestSpan.innerText = "Copied to clipboard"; + + setTimeout(function() { + // Change back to the copy icon + buttonIcon.setAttribute('xlink:href', currentHref); + nearestSpan.innerText = original_text; + }, 2000); + + } + console.log('Summary copied to clipboard successfully!'); + }).catch(err => { + console.error('Failed to copy text: ', err); + }); + }); + } +} + +class CustomizableEmailBase { + /** + * @param {Object} config - must contain the following: + * @property {HTMLElement} dropdown - The dropdown element. + * @property {HTMLElement} textarea - The textarea element. + * @property {HTMLElement} lastSentEmailContent - The last sent email content element. + * @property {HTMLElement} textAreaFormGroup - The form group for the textarea. + * @property {HTMLElement} dropdownFormGroup - The form group for the dropdown. + * @property {HTMLElement} modalConfirm - The confirm button in the modal. + * @property {string} apiUrl - The API URL for fetching email content. + * @property {string} statusToCheck - The status to check against. Used for show/hide on textAreaFormGroup/dropdownFormGroup. + * @property {string} sessionVariableName - The session variable name. Used for show/hide on textAreaFormGroup/dropdownFormGroup. + * @property {string} apiErrorMessage - The error message that the ajax call returns. + */ + constructor(config) { + this.config = config; + this.dropdown = config.dropdown; + this.textarea = config.textarea; + this.lastSentEmailContent = config.lastSentEmailContent; + this.apiUrl = config.apiUrl; + this.apiErrorMessage = config.apiErrorMessage; + this.modalConfirm = config.modalConfirm; + + // These fields are hidden/shown on pageload depending on the current status + this.textAreaFormGroup = config.textAreaFormGroup; + this.dropdownFormGroup = config.dropdownFormGroup; + this.statusToCheck = config.statusToCheck; + this.sessionVariableName = config.sessionVariableName; + + // Non-configurable variables + this.statusSelect = document.getElementById("id_status"); + this.domainRequestId = this.dropdown ? document.getElementById("domain_request_id").value : null + this.initialDropdownValue = this.dropdown ? this.dropdown.value : null; + this.initialEmailValue = this.textarea ? this.textarea.value : null; + + // Find other fields near the textarea + const parentDiv = this.textarea ? this.textarea.closest(".flex-container") : null; + this.directEditButton = parentDiv ? parentDiv.querySelector(".edit-email-button") : null; + this.modalTrigger = parentDiv ? parentDiv.querySelector(".edit-button-modal-trigger") : null; + + this.textareaPlaceholder = parentDiv ? parentDiv.querySelector(".custom-email-placeholder") : null; + this.formLabel = this.textarea ? document.querySelector(`label[for="${this.textarea.id}"]`) : null; + + this.isEmailAlreadySentConst; + if (this.lastSentEmailContent && this.textarea) { + this.isEmailAlreadySentConst = this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, ''); + } + + } + + // Handle showing/hiding the related fields on page load. + initializeFormGroups() { + let isStatus = this.statusSelect.value == this.statusToCheck; + + // Initial handling of these groups. + this.updateFormGroupVisibility(isStatus); + + // Listen to change events and handle rejectionReasonFormGroup display, then save status to session storage + this.statusSelect.addEventListener('change', () => { + // Show the action needed field if the status is what we expect. + // Then track if its shown or hidden in our session cache. + isStatus = this.statusSelect.value == this.statusToCheck; + this.updateFormGroupVisibility(isStatus); + addOrRemoveSessionBoolean(this.sessionVariableName, isStatus); + }); + + // Listen to Back/Forward button navigation and handle rejectionReasonFormGroup display based on session storage + // When you navigate using forward/back after changing status but not saving, when you land back on the DA page the + // status select will say (for example) Rejected but the selected option can be something else. To manage the show/hide + // accurately for this edge case, we use cache and test for the back/forward navigation. + const observer = new PerformanceObserver((list) => { + list.getEntries().forEach((entry) => { + if (entry.type === "back_forward") { + let showTextAreaFormGroup = sessionStorage.getItem(this.sessionVariableName) !== null; + this.updateFormGroupVisibility(showTextAreaFormGroup); + } + }); + }); + observer.observe({ type: "navigation" }); + } + + updateFormGroupVisibility(showFormGroups) { + if (showFormGroups) { + showElement(this.textAreaFormGroup); + showElement(this.dropdownFormGroup); + }else { + hideElement(this.textAreaFormGroup); + hideElement(this.dropdownFormGroup); + } + } + + initializeDropdown() { + this.dropdown.addEventListener("change", () => { + let reason = this.dropdown.value; + if (this.initialDropdownValue !== this.dropdown.value || this.initialEmailValue !== this.textarea.value) { + let searchParams = new URLSearchParams( + { + "reason": reason, + "domain_request_id": this.domainRequestId, + } + ); + // Replace the email content + fetch(`${this.apiUrl}?${searchParams.toString()}`) + .then(response => { + return response.json().then(data => data); + }) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + }else { + this.textarea.value = data.email; + } + this.updateUserInterface(reason); + }) + .catch(error => { + console.error(this.apiErrorMessage, error) + }); + } + }); + } + + initializeModalConfirm() { + this.modalConfirm.addEventListener("click", () => { + this.textarea.removeAttribute('readonly'); + this.textarea.focus(); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); + }); + } + + initializeDirectEditButton() { + this.directEditButton.addEventListener("click", () => { + this.textarea.removeAttribute('readonly'); + this.textarea.focus(); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); + }); + } + + isEmailAlreadySent() { + return this.lastSentEmailContent.value.replace(/\s+/g, '') === this.textarea.value.replace(/\s+/g, ''); + } + + updateUserInterface(reason=this.dropdown.value, excluded_reasons=["other"]) { + if (!reason) { + // No reason selected, we will set the label to "Email", show the "Make a selection" placeholder, hide the trigger, textarea, hide the help text + this.showPlaceholderNoReason(); + } else if (excluded_reasons.includes(reason)) { + // 'Other' selected, we will set the label to "Email", show the "No email will be sent" placeholder, hide the trigger, textarea, hide the help text + this.showPlaceholderOtherReason(); + } else { + this.showReadonlyTextarea(); + } + } + + // Helper function that makes overriding the readonly textarea easy + showReadonlyTextarea() { + // A triggering selection is selected, all hands on board: + this.textarea.setAttribute('readonly', true); + showElement(this.textarea); + hideElement(this.textareaPlaceholder); + + if (this.isEmailAlreadySentConst) { + hideElement(this.directEditButton); + showElement(this.modalTrigger); + } else { + showElement(this.directEditButton); + hideElement(this.modalTrigger); + } + + if (this.isEmailAlreadySent()) { + this.formLabel.innerHTML = "Email sent to creator:"; + } else { + this.formLabel.innerHTML = "Email:"; + } + } + + // Helper function that makes overriding the placeholder reason easy + showPlaceholderNoReason() { + this.showPlaceholder("Email:", "Select a reason to see email"); + } + + // Helper function that makes overriding the placeholder reason easy + showPlaceholderOtherReason() { + this.showPlaceholder("Email:", "No email will be sent"); + } + + showPlaceholder(formLabelText, placeholderText) { + this.formLabel.innerHTML = formLabelText; + this.textareaPlaceholder.innerHTML = placeholderText; + showElement(this.textareaPlaceholder); + hideElement(this.directEditButton); + hideElement(this.modalTrigger); + hideElement(this.textarea); + } +} + +class customActionNeededEmail extends CustomizableEmailBase { + constructor() { + const emailConfig = { + dropdown: document.getElementById("id_action_needed_reason"), + textarea: document.getElementById("id_action_needed_reason_email"), + lastSentEmailContent: document.getElementById("last-sent-action-needed-email-content"), + modalConfirm: document.getElementById("action-needed-reason__confirm-edit-email"), + apiUrl: document.getElementById("get-action-needed-email-for-user-json")?.value || null, + textAreaFormGroup: document.querySelector('.field-action_needed_reason'), + dropdownFormGroup: document.querySelector('.field-action_needed_reason_email'), + statusToCheck: "action needed", + sessionVariableName: "showActionNeededReason", + apiErrorMessage: "Error when attempting to grab action needed email: " + } + super(emailConfig); + } + + loadActionNeededEmail() { + // Hide/show the email fields depending on the current status + this.initializeFormGroups(); + // Setup the textarea, edit button, helper text + this.updateUserInterface(); + this.initializeDropdown(); + this.initializeModalConfirm(); + this.initializeDirectEditButton(); + } + + // Overrides the placeholder text when no reason is selected + showPlaceholderNoReason() { + this.showPlaceholder("Email:", "Select an action needed reason to see email"); + } + + // Overrides the placeholder text when the reason other is selected + showPlaceholderOtherReason() { + this.showPlaceholder("Email:", "No email will be sent"); + } +} + +/** + * A function that hooks to the show/hide button underneath action needed reason. + * This shows the auto generated email on action needed reason. +*/ +export function initActionNeededEmail() { + document.addEventListener('DOMContentLoaded', function() { + const domainRequestForm = document.getElementById("domainrequest_form"); + if (!domainRequestForm) { + return; + } + + // Initialize UI + const customEmail = new customActionNeededEmail(); + + // Check that every variable was setup correctly + const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); + if (nullItems.length > 0) { + console.error(`Failed to load customActionNeededEmail(). Some variables were null: ${nullItems.join(", ")}`) + return; + } + customEmail.loadActionNeededEmail() + }); +} + +class customRejectedEmail extends CustomizableEmailBase { + constructor() { + const emailConfig = { + dropdown: document.getElementById("id_rejection_reason"), + textarea: document.getElementById("id_rejection_reason_email"), + lastSentEmailContent: document.getElementById("last-sent-rejection-email-content"), + modalConfirm: document.getElementById("rejection-reason__confirm-edit-email"), + apiUrl: document.getElementById("get-rejection-email-for-user-json")?.value || null, + textAreaFormGroup: document.querySelector('.field-rejection_reason'), + dropdownFormGroup: document.querySelector('.field-rejection_reason_email'), + statusToCheck: "rejected", + sessionVariableName: "showRejectionReason", + errorMessage: "Error when attempting to grab rejected email: " + }; + super(emailConfig); + } + + loadRejectedEmail() { + this.initializeFormGroups(); + this.updateUserInterface(); + this.initializeDropdown(); + this.initializeModalConfirm(); + this.initializeDirectEditButton(); + } + + // Overrides the placeholder text when no reason is selected + showPlaceholderNoReason() { + this.showPlaceholder("Email:", "Select a rejection reason to see email"); + } + + updateUserInterface(reason=this.dropdown.value, excluded_reasons=[]) { + super.updateUserInterface(reason, excluded_reasons); + } +} + + +/** + * A function that hooks to the show/hide button underneath rejected reason. + * This shows the auto generated email on action needed reason. +*/ +export function initRejectedEmail() { + document.addEventListener('DOMContentLoaded', function() { + const domainRequestForm = document.getElementById("domainrequest_form"); + if (!domainRequestForm) { + return; + } + + // Initialize UI + const customEmail = new customRejectedEmail(); + // Check that every variable was setup correctly + const nullItems = Object.entries(customEmail.config).filter(([key, value]) => value === null).map(([key]) => key); + if (nullItems.length > 0) { + console.error(`Failed to load customRejectedEmail(). Some variables were null: ${nullItems.join(", ")}`) + return; + } + customEmail.loadRejectedEmail() + }); +} + +/** + * A function for dynamic DomainRequest fields +*/ +export function initDynamicDomainRequestFields(){ + const domainRequestPage = document.getElementById("domainrequest_form"); + if (domainRequestPage) { + handlePortfolioSelection(); + } +} diff --git a/src/registrar/assets/src/js/getgov-admin/filter-horizontal.js b/src/registrar/assets/src/js/getgov-admin/filter-horizontal.js new file mode 100644 index 000000000..5ab93d7e3 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/filter-horizontal.js @@ -0,0 +1,72 @@ +// Function to check for the existence of the "to" select list element in the DOM, and if and when found, +// initialize the associated widget +function checkToListThenInitWidget(toListId, attempts) { + let toList = document.getElementById(toListId); + attempts++; + + if (attempts < 12) { + if (toList) { + // toList found, handle it + // Then get fromList and handle it + initializeWidgetOnList(toList, ".selector-chosen"); + let fromList = toList.closest('.selector').querySelector(".selector-available select"); + initializeWidgetOnList(fromList, ".selector-available"); + } else { + // Element not found, check again after a delay + setTimeout(() => checkToListThenInitWidget(toListId, attempts), 300); // Check every 300 milliseconds + } + } +} + +// Initialize the widget: +// Replace h2 with more semantic h3 +function initializeWidgetOnList(list, parentId) { + if (list) { + // Get h2 and its container + const parentElement = list.closest(parentId); + const h2Element = parentElement.querySelector('h2'); + + // One last check + if (parentElement && h2Element) { + // Create a new

element + const h3Element = document.createElement('h3'); + + // Copy the text content from the

element to the

element + h3Element.textContent = h2Element.textContent; + + // Find the nested element inside the

+ const nestedSpan = h2Element.querySelector('span[class][title]'); + + // If the nested element exists + if (nestedSpan) { + // Create a new element + const newSpan = document.createElement('span'); + + // Copy the class and title attributes from the nested element + newSpan.className = nestedSpan.className; + newSpan.title = nestedSpan.title; + + // Append the new element to the

element + h3Element.appendChild(newSpan); + } + + // Replace the

element with the new

element + parentElement.replaceChild(h3Element, h2Element); + } + } +} + +/** + * + * An IIFE to listen to changes on filter_horizontal and enable or disable the change/delete/view buttons as applicable + * + */ +export function initFilterHorizontalWidget() { + // Initialize custom filter_horizontal widgets; each widget has a "from" select list + // and a "to" select list; initialization is based off of the presence of the + // "to" select list + checkToListThenInitWidget('id_groups_to', 0); + checkToListThenInitWidget('id_user_permissions_to', 0); + checkToListThenInitWidget('id_portfolio_roles_to', 0); + checkToListThenInitWidget('id_portfolio_additional_permissions_to', 0); +} diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-admin.js b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js new file mode 100644 index 000000000..ff618a67d --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/helpers-admin.js @@ -0,0 +1,24 @@ +export function hideElement(element) { + if (element) { + element.classList.add('display-none'); + } else { + console.warn('Called hideElement on a null or undefined element'); + } +}; + +export function showElement(element) { + if (element) { + element.classList.remove('display-none'); + } else { + console.warn('Called showElement on a null or undefined element'); + } +}; + +// Adds or removes a boolean from our session +export function addOrRemoveSessionBoolean(name, add){ + if (add) { + sessionStorage.setItem(name, "true"); + } else { + sessionStorage.removeItem(name); + } +} diff --git a/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js new file mode 100644 index 000000000..39f30b87f --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/helpers-portfolio-dynamic-fields.js @@ -0,0 +1,542 @@ +import { hideElement, showElement } from './helpers-admin.js'; + +/** + * Helper function that handles business logic for the suborganization field. + * Can be used anywhere the suborganization dropdown exists +*/ +export function handleSuborganizationFields( + portfolioDropdownSelector="#id_portfolio", + suborgDropdownSelector="#id_sub_organization", + requestedSuborgFieldSelector=".field-requested_suborganization", + suborgCitySelector=".field-suborganization_city", + suborgStateTerritorySelector=".field-suborganization_state_territory" +) { + // These dropdown are select2 fields so they must be interacted with via jquery + const portfolioDropdown = django.jQuery(portfolioDropdownSelector) + const suborganizationDropdown = django.jQuery(suborgDropdownSelector) + const requestedSuborgField = document.querySelector(requestedSuborgFieldSelector); + const suborgCity = document.querySelector(suborgCitySelector); + const suborgStateTerritory = document.querySelector(suborgStateTerritorySelector); + if (!suborganizationDropdown || !requestedSuborgField || !suborgCity || !suborgStateTerritory) { + console.error("Requested suborg fields not found."); + return; + } + + function toggleSuborganizationFields() { + if (portfolioDropdown.val() && !suborganizationDropdown.val()) { + showElement(requestedSuborgField); + showElement(suborgCity); + showElement(suborgStateTerritory); + }else { + hideElement(requestedSuborgField); + hideElement(suborgCity); + hideElement(suborgStateTerritory); + } + } + + // Run the function once on page startup, then attach an event listener + toggleSuborganizationFields(); + suborganizationDropdown.on("change", toggleSuborganizationFields); + portfolioDropdown.on("change", toggleSuborganizationFields); +} + + +/** + * + * This function handles the portfolio selection as well as display of + * portfolio-related fields in the DomainRequest Form. + * + * IMPORTANT NOTE: The logic in this method is paired dynamicPortfolioFields +*/ +export function handlePortfolioSelection() { + // These dropdown are select2 fields so they must be interacted with via jquery + const portfolioDropdown = django.jQuery("#id_portfolio"); + const suborganizationDropdown = django.jQuery("#id_sub_organization"); + const suborganizationField = document.querySelector(".field-sub_organization"); + const requestedSuborganizationField = document.querySelector(".field-requested_suborganization"); + const suborganizationCity = document.querySelector(".field-suborganization_city"); + const suborganizationStateTerritory = document.querySelector(".field-suborganization_state_territory"); + const seniorOfficialField = document.querySelector(".field-senior_official"); + const otherEmployeesField = document.querySelector(".field-other_contacts"); + const noOtherContactsRationaleField = document.querySelector(".field-no_other_contacts_rationale"); + const cisaRepresentativeFirstNameField = document.querySelector(".field-cisa_representative_first_name"); + const cisaRepresentativeLastNameField = document.querySelector(".field-cisa_representative_last_name"); + const cisaRepresentativeEmailField = document.querySelector(".field-cisa_representative_email"); + const orgTypeFieldSet = document.querySelector(".field-is_election_board").parentElement; + const orgTypeFieldSetDetails = orgTypeFieldSet.nextElementSibling; + const orgNameFieldSet = document.querySelector(".field-organization_name").parentElement; + const orgNameFieldSetDetails = orgNameFieldSet.nextElementSibling; + const portfolioSeniorOfficialField = document.querySelector(".field-portfolio_senior_official"); + const portfolioSeniorOfficial = portfolioSeniorOfficialField.querySelector(".readonly"); + const portfolioSeniorOfficialAddress = portfolioSeniorOfficialField.querySelector(".dja-address-contact-list"); + const portfolioOrgTypeFieldSet = document.querySelector(".field-portfolio_organization_type").parentElement; + const portfolioOrgType = document.querySelector(".field-portfolio_organization_type .readonly"); + const portfolioFederalTypeField = document.querySelector(".field-portfolio_federal_type"); + const portfolioFederalType = portfolioFederalTypeField.querySelector(".readonly"); + const portfolioOrgNameField = document.querySelector(".field-portfolio_organization_name") + const portfolioOrgName = portfolioOrgNameField.querySelector(".readonly"); + const portfolioOrgNameFieldSet = portfolioOrgNameField.parentElement; + const portfolioOrgNameFieldSetDetails = portfolioOrgNameFieldSet.nextElementSibling; + const portfolioFederalAgencyField = document.querySelector(".field-portfolio_federal_agency"); + const portfolioFederalAgency = portfolioFederalAgencyField.querySelector(".readonly"); + const portfolioStateTerritory = document.querySelector(".field-portfolio_state_territory .readonly"); + const portfolioAddressLine1 = document.querySelector(".field-portfolio_address_line1 .readonly"); + const portfolioAddressLine2 = document.querySelector(".field-portfolio_address_line2 .readonly"); + const portfolioCity = document.querySelector(".field-portfolio_city .readonly"); + const portfolioZipcode = document.querySelector(".field-portfolio_zipcode .readonly"); + const portfolioUrbanizationField = document.querySelector(".field-portfolio_urbanization"); + const portfolioUrbanization = portfolioUrbanizationField.querySelector(".readonly"); + const portfolioJsonUrl = document.getElementById("portfolio_json_url")?.value || null; + let isPageLoading = true; + + /** + * Fetches portfolio data by ID using an AJAX call. + * + * @param {number|string} portfolio_id - The ID of the portfolio to retrieve. + * @returns {Promise} - A promise that resolves to the portfolio data object if successful, + * or null if there was an error. + * + * This function performs an asynchronous fetch request to retrieve portfolio data. + * If the request is successful, it returns the portfolio data as an object. + * If an error occurs during the request or the data contains an error, it logs the error + * to the console and returns null. + */ + function getPortfolio(portfolio_id) { + return fetch(`${portfolioJsonUrl}?id=${portfolio_id}`) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + return null; + } else { + return data; + } + }) + .catch(error => { + console.error("Error retrieving portfolio", error); + return null; + }); + } + + /** + * Updates various UI elements with the data from a given portfolio object. + * + * @param {Object} portfolio - The portfolio data object containing values to populate in the UI. + * + * This function updates multiple fields in the UI to reflect data in the `portfolio` object: + * - Clears and replaces selections in the `suborganizationDropdown` with values from `portfolio.suborganizations`. + * - Calls `updatePortfolioSeniorOfficial` to set the senior official information. + * - Sets the portfolio organization type, federal type, name, federal agency, and other address-related fields. + * + * The function expects that elements like `portfolioOrgType`, `portfolioFederalAgency`, etc., + * are already defined and accessible in the global scope. + */ + function updatePortfolioFieldsData(portfolio) { + // replace selections in suborganizationDropdown with + // values in portfolio.suborganizations + suborganizationDropdown.empty(); + // update portfolio senior official + updatePortfolioSeniorOfficial(portfolio.senior_official); + // update portfolio organization type + portfolioOrgType.innerText = portfolio.organization_type; + // update portfolio federal type + portfolioFederalType.innerText = portfolio.federal_type + // update portfolio organization name + portfolioOrgName.innerText = portfolio.organization_name; + // update portfolio federal agency + portfolioFederalAgency.innerText = portfolio.federal_agency ? portfolio.federal_agency.agency : ''; + // update portfolio state + portfolioStateTerritory.innerText = portfolio.state_territory; + // update portfolio address line 1 + portfolioAddressLine1.innerText = portfolio.address_line1; + // update portfolio address line 2 + portfolioAddressLine2.innerText = portfolio.address_line2; + // update portfolio city + portfolioCity.innerText = portfolio.city; + // update portfolio zip code + portfolioZipcode.innerText = portfolio.zipcode + // update portfolio urbanization + portfolioUrbanization.innerText = portfolio.urbanization; + } + + /** + * Updates the UI to display the senior official information from a given object. + * + * @param {Object} senior_official - The senior official's data object, containing details like + * first name, last name, and ID. If `senior_official` is null, displays a default message. + * + * This function: + * - Displays the senior official's name as a link (if available) in the `portfolioSeniorOfficial` element. + * - If a senior official exists, it sets `portfolioSeniorOfficialAddress` to show the official's contact info + * and displays it by calling `updateSeniorOfficialContactInfo`. + * - If no senior official is provided, it hides `portfolioSeniorOfficialAddress` and shows a "No senior official found." message. + * + * Dependencies: + * - Expects the `portfolioSeniorOfficial` and `portfolioSeniorOfficialAddress` elements to be available globally. + * - Uses `showElement` and `hideElement` for visibility control. + */ + function updatePortfolioSeniorOfficial(senior_official) { + if (senior_official) { + let seniorOfficialName = [senior_official.first_name, senior_official.last_name].join(' '); + let seniorOfficialLink = `${seniorOfficialName}` + portfolioSeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + updateSeniorOfficialContactInfo(portfolioSeniorOfficialAddress, senior_official); + showElement(portfolioSeniorOfficialAddress); + } else { + portfolioSeniorOfficial.innerText = "No senior official found."; + hideElement(portfolioSeniorOfficialAddress); + } + } + + /** + * Populates and displays contact information for a senior official within a specified address field element. + * + * @param {HTMLElement} addressField - The DOM element containing contact info fields for the senior official. + * @param {Object} senior_official - The senior official's data object, containing properties like title, email, and phone. + * + * This function: + * - Sets the `title`, `email`, and `phone` fields in `addressField` to display the senior official's data. + * - Updates the `titleSpan` with the official's title, or "None" if unavailable. + * - Updates the `emailSpan` with the official's email, or "None" if unavailable. + * - If an email is provided, populates `hiddenInput` with the email for copying and shows the `copyButton`. + * - If no email is provided, hides the `copyButton`. + * - Updates the `phoneSpan` with the official's phone number, or "None" if unavailable. + * + * Dependencies: + * - Uses `showElement` and `hideElement` to control visibility of the `copyButton`. + * - Expects `addressField` to have specific classes (.contact_info_title, .contact_info_email, etc.) for query selectors to work. + */ + function updateSeniorOfficialContactInfo(addressField, senior_official) { + const titleSpan = addressField.querySelector(".contact_info_title"); + const emailSpan = addressField.querySelector(".contact_info_email"); + const phoneSpan = addressField.querySelector(".contact_info_phone"); + const hiddenInput = addressField.querySelector("input"); + const copyButton = addressField.querySelector(".admin-icon-group"); + if (titleSpan) { + titleSpan.textContent = senior_official.title || "None"; + }; + if (emailSpan) { + emailSpan.textContent = senior_official.email || "None"; + if (senior_official.email) { + hiddenInput.value = senior_official.email; + showElement(copyButton); + }else { + hideElement(copyButton); + } + } + if (phoneSpan) { + phoneSpan.textContent = senior_official.phone || "None"; + }; + } + + /** + * Dynamically updates the visibility of certain portfolio fields based on specific conditions. + * + * This function adjusts the display of fields within the portfolio UI based on: + * - The presence of a senior official's contact information. + * - The selected state or territory, affecting the visibility of the urbanization field. + * - The organization type (Federal vs. non-Federal), toggling the visibility of related fields. + * + * Functionality: + * 1. **Senior Official Contact Info Display**: + * - If `portfolioSeniorOfficial` contains "No additional contact information found", + * hides `portfolioSeniorOfficialAddress`; otherwise, shows it. + * + * 2. **Urbanization Field Display**: + * - Displays `portfolioUrbanizationField` only when the `portfolioStateTerritory` value is "PR" (Puerto Rico). + * + * 3. **Federal Organization Type Display**: + * - If `portfolioOrgType` is "Federal", hides `portfolioOrgNameField` and shows both `portfolioFederalAgencyField` + * and `portfolioFederalTypeField`. + * - If not Federal, shows `portfolioOrgNameField` and hides `portfolioFederalAgencyField` and `portfolioFederalTypeField`. + * - Certain text fields (Organization Type, Organization Name, Federal Type, Federal Agency) updated to links + * to edit the portfolio + * + * Dependencies: + * - Expects specific elements to be defined globally (`portfolioSeniorOfficial`, `portfolioUrbanizationField`, etc.). + * - Uses `showElement` and `hideElement` functions to control element visibility. + */ + function updatePortfolioFieldsDataDynamicDisplay() { + + // Handle visibility of senior official's contact information + if (portfolioSeniorOfficial.innerText.includes("No senior official found.")) { + hideElement(portfolioSeniorOfficialAddress); + } else { + showElement(portfolioSeniorOfficialAddress); + } + + // Handle visibility of urbanization field based on state/territory value + let portfolioStateTerritoryValue = portfolioStateTerritory.innerText; + if (portfolioStateTerritoryValue === "PR") { + showElement(portfolioUrbanizationField); + } else { + hideElement(portfolioUrbanizationField); + } + + // Handle visibility of fields based on organization type (Federal vs. others) + if (portfolioOrgType.innerText === "Federal") { + hideElement(portfolioOrgNameField); + showElement(portfolioFederalAgencyField); + showElement(portfolioFederalTypeField); + } else { + showElement(portfolioOrgNameField); + hideElement(portfolioFederalAgencyField); + hideElement(portfolioFederalTypeField); + } + + // Modify the display of certain fields to convert them from text to links + // to edit the portfolio + let portfolio_id = portfolioDropdown.val(); + let portfolioEditUrl = `/admin/registrar/portfolio/${portfolio_id}/change/`; + let portfolioOrgTypeValue = portfolioOrgType.innerText; + portfolioOrgType.innerHTML = `${portfolioOrgTypeValue}`; + let portfolioOrgNameValue = portfolioOrgName.innerText; + portfolioOrgName.innerHTML = `${portfolioOrgNameValue}`; + let portfolioFederalAgencyValue = portfolioFederalAgency.innerText; + portfolioFederalAgency.innerHTML = `${portfolioFederalAgencyValue}`; + let portfolioFederalTypeValue = portfolioFederalType.innerText; + if (portfolioFederalTypeValue !== '-') + portfolioFederalType.innerHTML = `${portfolioFederalTypeValue}`; + + } + + /** + * Asynchronously updates portfolio fields in the UI based on the selected portfolio. + * + * This function first checks if the page is loading or if a portfolio selection is available + * in the `portfolioDropdown`. If a portfolio is selected, it retrieves the portfolio data, + * then updates the UI fields to display relevant data. If no portfolio is selected, it simply + * refreshes the UI field display without new data. The `isPageLoading` flag prevents + * updates during page load. + * + * Workflow: + * 1. **Check Page Loading**: + * - If `isPageLoading` is `true`, set it to `false` and exit to prevent redundant updates. + * - If `isPageLoading` is `false`, proceed with portfolio field updates. + * + * 2. **Portfolio Selection**: + * - If a portfolio is selected (`portfolioDropdown.val()`), fetch the portfolio data. + * - Once data is fetched, run three update functions: + * - `updatePortfolioFieldsData`: Populates specific portfolio-related fields. + * - `updatePortfolioFieldsDisplay`: Handles the visibility of general portfolio fields. + * - `updatePortfolioFieldsDataDynamicDisplay`: Manages conditional display based on portfolio data. + * - If no portfolio is selected, only refreshes the field display using `updatePortfolioFieldsDisplay`. + * + * Dependencies: + * - Expects global elements (`portfolioDropdown`, etc.) and `isPageLoading` flag to be defined. + * - Assumes `getPortfolio`, `updatePortfolioFieldsData`, `updatePortfolioFieldsDisplay`, and `updatePortfolioFieldsDataDynamicDisplay` are available as functions. + */ + async function updatePortfolioFields() { + if (!isPageLoading) { + if (portfolioDropdown.val()) { + getPortfolio(portfolioDropdown.val()).then((portfolio) => { + updatePortfolioFieldsData(portfolio); + updatePortfolioFieldsDisplay(); + updatePortfolioFieldsDataDynamicDisplay(); + }); + } else { + updatePortfolioFieldsDisplay(); + } + } else { + isPageLoading = false; + } + } + + /** + * Updates the Suborganization Dropdown with new data based on the provided portfolio ID. + * + * This function uses the Select2 jQuery plugin to update the dropdown by fetching suborganization + * data relevant to the selected portfolio. Upon invocation, it checks if Select2 is already initialized + * on `suborganizationDropdown` and destroys the existing instance to avoid duplication. + * It then reinitializes Select2 with customized options for an AJAX request, allowing the user to search + * and select suborganizations dynamically, with results filtered based on `portfolio_id`. + * + * Key workflow: + * 1. **Document Ready**: Ensures that the function runs only once the DOM is fully loaded. + * 2. **Check and Reinitialize Select2**: + * - If Select2 is already initialized, it’s destroyed to refresh with new options. + * - Select2 is reinitialized with AJAX settings for dynamic data fetching. + * 3. **AJAX Options**: + * - **Data Function**: Prepares the query by capturing the user's search term (`params.term`) + * and the provided `portfolio_id` to filter relevant suborganizations. + * - **Data Type**: Ensures responses are returned as JSON. + * - **Delay**: Introduces a 250ms delay to prevent excessive requests on fast typing. + * - **Cache**: Enables caching to improve performance. + * 4. **Theme and Placeholder**: + * - Sets the dropdown theme to ‘admin-autocomplete’ for consistent styling. + * - Allows clearing of the dropdown and displays a placeholder as defined in the HTML. + * + * Dependencies: + * - Requires `suborganizationDropdown` element, the jQuery library, and the Select2 plugin. + * - `portfolio_id` is passed to filter results relevant to a specific portfolio. + */ + function updateSubOrganizationDropdown(portfolio_id) { + django.jQuery(document).ready(function() { + if (suborganizationDropdown.data('select2')) { + suborganizationDropdown.select2('destroy'); + } + // Reinitialize Select2 with the updated URL + suborganizationDropdown.select2({ + ajax: { + data: function (params) { + var query = { + search: params.term, + portfolio_id: portfolio_id + } + return query; + }, + dataType: 'json', + delay: 250, + cache: true + }, + theme: 'admin-autocomplete', + allowClear: true, + placeholder: suborganizationDropdown.attr('data-placeholder') + }); + }); + } + + /** + * Updates the display of portfolio-related fields based on whether a portfolio is selected. + * + * This function controls the visibility of specific fields by showing or hiding them + * depending on the presence of a selected portfolio ID in the dropdown. When a portfolio + * is selected, certain fields are shown (like suborganizations and portfolio-related fields), + * while others are hidden (like senior official and other employee-related fields). + * + * Workflow: + * 1. **Retrieve Portfolio ID**: + * - Fetches the selected value from `portfolioDropdown` to check if a portfolio is selected. + * + * 2. **Display Fields for Selected Portfolio**: + * - If a `portfolio_id` exists, it updates the `suborganizationDropdown` for the specific portfolio. + * - Shows or hides various fields to display only relevant portfolio information: + * - Shows `suborganizationField`, `portfolioSeniorOfficialField`, and fields related to the portfolio organization. + * - Hides fields that are not applicable when a portfolio is selected, such as `seniorOfficialField` and `otherEmployeesField`. + * + * 3. **Display Fields for No Portfolio Selected**: + * - If no portfolio is selected (i.e., `portfolio_id` is falsy), it reverses the visibility: + * - Hides `suborganizationField` and other portfolio-specific fields. + * - Shows fields that are applicable when no portfolio is selected, such as the `seniorOfficialField`. + * + * Dependencies: + * - `portfolioDropdown` is assumed to be a dropdown element containing portfolio IDs. + * - `showElement` and `hideElement` utility functions are used to control element visibility. + * - Various global field elements (e.g., `suborganizationField`, `seniorOfficialField`, `portfolioOrgTypeFieldSet`) are used. + */ + function updatePortfolioFieldsDisplay() { + // Retrieve the selected portfolio ID + let portfolio_id = portfolioDropdown.val(); + + if (portfolio_id) { + // A portfolio is selected - update suborganization dropdown and show/hide relevant fields + + // Update suborganization dropdown for the selected portfolio + updateSubOrganizationDropdown(portfolio_id); + + // Show fields relevant to a selected portfolio + showElement(suborganizationField); + hideElement(seniorOfficialField); + showElement(portfolioSeniorOfficialField); + + // Hide fields not applicable when a portfolio is selected + hideElement(otherEmployeesField); + hideElement(noOtherContactsRationaleField); + hideElement(cisaRepresentativeFirstNameField); + hideElement(cisaRepresentativeLastNameField); + hideElement(cisaRepresentativeEmailField); + hideElement(orgTypeFieldSet); + hideElement(orgTypeFieldSetDetails); + hideElement(orgNameFieldSet); + hideElement(orgNameFieldSetDetails); + + // Show portfolio-specific fields + showElement(portfolioOrgTypeFieldSet); + showElement(portfolioOrgNameFieldSet); + showElement(portfolioOrgNameFieldSetDetails); + } else { + // No portfolio is selected - reverse visibility of fields + + // Hide suborganization field as no portfolio is selected + hideElement(suborganizationField); + + // Show fields that are relevant when no portfolio is selected + showElement(seniorOfficialField); + hideElement(portfolioSeniorOfficialField); + showElement(otherEmployeesField); + showElement(noOtherContactsRationaleField); + showElement(cisaRepresentativeFirstNameField); + showElement(cisaRepresentativeLastNameField); + showElement(cisaRepresentativeEmailField); + + // Show organization type and name fields + showElement(orgTypeFieldSet); + showElement(orgTypeFieldSetDetails); + showElement(orgNameFieldSet); + showElement(orgNameFieldSetDetails); + + // Hide portfolio-specific fields that aren’t applicable + hideElement(portfolioOrgTypeFieldSet); + hideElement(portfolioOrgNameFieldSet); + hideElement(portfolioOrgNameFieldSetDetails); + } + + updateSuborganizationFieldsDisplay(); + + } + + /** + * Updates the visibility of suborganization-related fields based on the selected value in the suborganization dropdown. + * + * If a suborganization is selected: + * - Hides the fields related to requesting a new suborganization (`requestedSuborganizationField`). + * - Hides the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields for the suborganization. + * + * If no suborganization is selected: + * - Shows the fields for requesting a new suborganization (`requestedSuborganizationField`). + * - Displays the city (`suborganizationCity`) and state/territory (`suborganizationStateTerritory`) fields. + * + * This function ensures the form dynamically reflects whether a specific suborganization is being selected or requested. + */ + function updateSuborganizationFieldsDisplay() { + let portfolio_id = portfolioDropdown.val(); + let suborganization_id = suborganizationDropdown.val(); + + if (portfolio_id && !suborganization_id) { + // Show suborganization request fields + showElement(requestedSuborganizationField); + showElement(suborganizationCity); + showElement(suborganizationStateTerritory); + } else { + // Hide suborganization request fields if suborganization is selected + hideElement(requestedSuborganizationField); + hideElement(suborganizationCity); + hideElement(suborganizationStateTerritory); + } + } + + /** + * Initializes necessary data and display configurations for the portfolio fields. + */ + function initializePortfolioSettings() { + // Update the visibility of portfolio-related fields based on current dropdown selection. + updatePortfolioFieldsDisplay(); + + // Dynamically adjust the display of certain fields based on the selected portfolio's characteristics. + updatePortfolioFieldsDataDynamicDisplay(); + } + + /** + * Sets event listeners for key UI elements. + */ + function setEventListeners() { + // When the `portfolioDropdown` selection changes, refresh the displayed portfolio fields. + portfolioDropdown.on("change", updatePortfolioFields); + // When the 'suborganizationDropdown' selection changes + suborganizationDropdown.on("change", updateSuborganizationFieldsDisplay); + } + + // Run initial setup functions + initializePortfolioSettings(); + setEventListeners(); +} diff --git a/src/registrar/assets/src/js/getgov-admin/main.js b/src/registrar/assets/src/js/getgov-admin/main.js new file mode 100644 index 000000000..ec9aeeedf --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/main.js @@ -0,0 +1,41 @@ +import { initModals } from './modals.js'; +import { initCopyToClipboard } from './copy-to-clipboard.js'; +import { initFilterHorizontalWidget } from './filter-horizontal.js'; +import { initDescriptions } from './show-more-description.js'; +import { initSubmitBar } from './submit-bar.js'; +import { + initIneligibleModal, + initAssignToMe, + initActionNeededEmail, + initRejectedEmail, + initApprovedDomain, + initCopyRequestSummary, + initDynamicDomainRequestFields } from './domain-request-form.js'; +import { initDomainFormTargetBlankButtons } from './domain-form.js'; +import { initDynamicPortfolioFields } from './portfolio-form.js'; +import { initDynamicDomainInformationFields } from './domain-information-form.js'; + +// General +initModals(); +initCopyToClipboard(); +initFilterHorizontalWidget(); +initDescriptions(); +initSubmitBar(); + +// Domain request +initIneligibleModal(); +initAssignToMe(); +initActionNeededEmail(); +initRejectedEmail(); +initApprovedDomain(); +initCopyRequestSummary(); +initDynamicDomainRequestFields(); + +// Domain +initDomainFormTargetBlankButtons(); + +// Portfolio +initDynamicPortfolioFields(); + +// Domain information +initDynamicDomainInformationFields(); diff --git a/src/registrar/assets/src/js/getgov-admin/modals.js b/src/registrar/assets/src/js/getgov-admin/modals.js new file mode 100644 index 000000000..73fbbeb0c --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/modals.js @@ -0,0 +1,30 @@ +/** + * A function for pages in DjangoAdmin that use modals. + * Dja strips out form elements, and modals generate their content outside + * of the current form scope, so we need to "inject" these inputs. +*/ +export function initModals(){ + let submitButtons = document.querySelectorAll('.usa-modal button[type="submit"].dja-form-placeholder'); + let form = document.querySelector("form") + submitButtons.forEach((button) => { + + let input = document.createElement("input"); + input.type = "submit"; + + if(button.name){ + input.name = button.name; + } + + if(button.value){ + input.value = button.value; + } + + input.style.display = "none" + + // Add the hidden input to the form + form.appendChild(input); + button.addEventListener("click", () => { + input.click(); + }) + }) +} diff --git a/src/registrar/assets/src/js/getgov-admin/portfolio-form.js b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js new file mode 100644 index 000000000..f001bf39b --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/portfolio-form.js @@ -0,0 +1,259 @@ +import { hideElement, showElement } from './helpers-admin.js'; + +/** + * A function for dynamically changing some fields on the portfolio admin model + * IMPORTANT NOTE: The logic in this function is paired handlePortfolioSelection and should be refactored once we solidify our requirements. +*/ +export function initDynamicPortfolioFields(){ + + // the federal agency change listener fires on page load, which we don't want. + var isInitialPageLoad = true + + // This is the additional information that exists beneath the SO element. + var contactList = document.querySelector(".field-senior_official .dja-address-contact-list"); + const federalAgencyContainer = document.querySelector(".field-federal_agency"); + document.addEventListener('DOMContentLoaded', function() { + + let isPortfolioPage = document.getElementById("portfolio_form"); + if (!isPortfolioPage) { + return; + } + + // $ symbolically denotes that this is using jQuery + let $federalAgency = django.jQuery("#id_federal_agency"); + let organizationType = document.getElementById("id_organization_type"); + let readonlyOrganizationType = document.querySelector(".field-organization_type .readonly"); + + let organizationNameContainer = document.querySelector(".field-organization_name"); + let federalType = document.querySelector(".field-federal_type"); + + if ($federalAgency && (organizationType || readonlyOrganizationType)) { + // Attach the change event listener + $federalAgency.on("change", function() { + handleFederalAgencyChange($federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType); + }); + } + + // Handle dynamically hiding the urbanization field + let urbanizationField = document.querySelector(".field-urbanization"); + let stateTerritory = document.getElementById("id_state_territory"); + if (urbanizationField && stateTerritory) { + // Execute this function once on load + handleStateTerritoryChange(stateTerritory, urbanizationField); + + // Attach the change event listener for state/territory + stateTerritory.addEventListener("change", function() { + handleStateTerritoryChange(stateTerritory, urbanizationField); + }); + } + + // Handle hiding the organization name field when the organization_type is federal. + // Run this first one page load, then secondly on a change event. + handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); + organizationType.addEventListener("change", function() { + handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); + }); + }); + + function handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType) { + if (organizationType && organizationNameContainer) { + let selectedValue = organizationType.value; + if (selectedValue === "federal") { + hideElement(organizationNameContainer); + showElement(federalAgencyContainer); + if (federalType) { + showElement(federalType); + } + } else { + showElement(organizationNameContainer); + hideElement(federalAgencyContainer); + if (federalType) { + hideElement(federalType); + } + } + } + } + + function handleFederalAgencyChange(federalAgency, organizationType, readonlyOrganizationType, organizationNameContainer, federalType) { + // Don't do anything on page load + if (isInitialPageLoad) { + isInitialPageLoad = false; + return; + } + + // Set the org type to federal if an agency is selected + let selectedText = federalAgency.find("option:selected").text(); + + // There isn't a federal senior official associated with null records + if (!selectedText) { + return; + } + + let organizationTypeValue = organizationType ? organizationType.value : readonlyOrganizationType.innerText.toLowerCase(); + if (selectedText !== "Non-Federal Agency") { + if (organizationTypeValue !== "federal") { + if (organizationType){ + organizationType.value = "federal"; + }else { + readonlyOrganizationType.innerText = "Federal" + } + } + }else { + if (organizationTypeValue === "federal") { + if (organizationType){ + organizationType.value = ""; + }else { + readonlyOrganizationType.innerText = "-" + } + } + } + + handleOrganizationTypeChange(organizationType, organizationNameContainer, federalType); + + // Determine if any changes are necessary to the display of portfolio type or federal type + // based on changes to the Federal Agency + let federalPortfolioApi = document.getElementById("federal_and_portfolio_types_from_agency_json_url").value; + fetch(`${federalPortfolioApi}?&agency_name=${selectedText}`) + .then(response => { + const statusCode = response.status; + return response.json().then(data => ({ statusCode, data })); + }) + .then(({ statusCode, data }) => { + if (data.error) { + console.error("Error in AJAX call: " + data.error); + return; + } + updateReadOnly(data.federal_type, '.field-federal_type'); + }) + .catch(error => console.error("Error fetching federal and portfolio types: ", error)); + + // Hide the contactList initially. + // If we can update the contact information, it'll be shown again. + hideElement(contactList.parentElement); + + let seniorOfficialAddUrl = document.getElementById("senior-official-add-url").value; + let $seniorOfficial = django.jQuery("#id_senior_official"); + let readonlySeniorOfficial = document.querySelector(".field-senior_official .readonly"); + let seniorOfficialApi = document.getElementById("senior_official_from_agency_json_url").value; + fetch(`${seniorOfficialApi}?agency_name=${selectedText}`) + .then(response => { + const statusCode = response.status; + return response.json().then(data => ({ statusCode, data })); + }) + .then(({ statusCode, data }) => { + if (data.error) { + // Clear the field if the SO doesn't exist. + if (statusCode === 404) { + if ($seniorOfficial && $seniorOfficial.length > 0) { + $seniorOfficial.val("").trigger("change"); + }else { + // Show the "create one now" text if this field is none in readonly mode. + readonlySeniorOfficial.innerHTML = `No senior official found. Create one now.`; + } + console.warn("Record not found: " + data.error); + }else { + console.error("Error in AJAX call: " + data.error); + } + return; + } + + // Update the "contact details" blurb beneath senior official + updateContactInfo(data); + showElement(contactList.parentElement); + + // Get the associated senior official with this federal agency + let seniorOfficialId = data.id; + let seniorOfficialName = [data.first_name, data.last_name].join(" "); + if ($seniorOfficial && $seniorOfficial.length > 0) { + // If the senior official is a dropdown field, edit that + updateSeniorOfficialDropdown($seniorOfficial, seniorOfficialId, seniorOfficialName); + }else { + if (readonlySeniorOfficial) { + let seniorOfficialLink = `${seniorOfficialName}` + readonlySeniorOfficial.innerHTML = seniorOfficialName ? seniorOfficialLink : "-"; + } + } + }) + .catch(error => console.error("Error fetching senior official: ", error)); + + } + + function updateSeniorOfficialDropdown(dropdown, seniorOfficialId, seniorOfficialName) { + if (!seniorOfficialId || !seniorOfficialName || !seniorOfficialName.trim()){ + // Clear the field if the SO doesn't exist + dropdown.val("").trigger("change"); + return; + } + + // Add the senior official to the dropdown. + // This format supports select2 - if we decide to convert this field in the future. + if (dropdown.find(`option[value='${seniorOfficialId}']`).length) { + // Select the value that is associated with the current Senior Official. + dropdown.val(seniorOfficialId).trigger("change"); + } else { + // Create a DOM Option that matches the desired Senior Official. Then append it and select it. + let userOption = new Option(seniorOfficialName, seniorOfficialId, true, true); + dropdown.append(userOption).trigger("change"); + } + } + + function handleStateTerritoryChange(stateTerritory, urbanizationField) { + let selectedValue = stateTerritory.value; + if (selectedValue === "PR") { + showElement(urbanizationField) + } else { + hideElement(urbanizationField) + } + } + + /** + * Utility that selects a div from the DOM using selectorString, + * and updates a div within that div which has class of 'readonly' + * so that the text of the div is updated to updateText + * @param {*} updateText + * @param {*} selectorString + */ + function updateReadOnly(updateText, selectorString) { + // find the div by selectorString + const selectedDiv = document.querySelector(selectorString); + if (selectedDiv) { + // find the nested div with class 'readonly' inside the selectorString div + const readonlyDiv = selectedDiv.querySelector('.readonly'); + if (readonlyDiv) { + // Update the text content of the readonly div + readonlyDiv.textContent = updateText !== null ? updateText : '-'; + } + } + } + + function updateContactInfo(data) { + if (!contactList) return; + + const titleSpan = contactList.querySelector(".contact_info_title"); + const emailSpan = contactList.querySelector(".contact_info_email"); + const phoneSpan = contactList.querySelector(".contact_info_phone"); + + if (titleSpan) { + titleSpan.textContent = data.title || "None"; + }; + + // Update the email field and the content for the clipboard + if (emailSpan) { + let copyButton = contactList.querySelector(".admin-icon-group"); + emailSpan.textContent = data.email || "None"; + if (data.email) { + const clipboardInput = contactList.querySelector(".admin-icon-group input"); + if (clipboardInput) { + clipboardInput.value = data.email; + }; + showElement(copyButton); + }else { + hideElement(copyButton); + } + } + + if (phoneSpan) { + phoneSpan.textContent = data.phone || "None"; + }; + } +} diff --git a/src/registrar/assets/src/js/getgov-admin/show-more-description.js b/src/registrar/assets/src/js/getgov-admin/show-more-description.js new file mode 100644 index 000000000..a66c771a7 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/show-more-description.js @@ -0,0 +1,37 @@ +import { hideElement } from './helpers-admin.js'; + +/** An IIFE for toggling the overflow styles on django-admin__model-description (the show more / show less button) */ +export function initDescriptions() { + function handleShowMoreButton(toggleButton, descriptionDiv){ + // Check the length of the text content in the description div + if (descriptionDiv.textContent.length < 200) { + // Hide the toggle button if text content is less than 200 characters + // This is a little over 160 characters to give us some wiggle room if we + // change the font size marginally. + if (toggleButton) + hideElement(toggleButton); + } else { + toggleButton.addEventListener('click', function() { + toggleShowMoreButton(toggleButton, descriptionDiv, 'dja__model-description--no-overflow'); + }); + } + } + + function toggleShowMoreButton(toggleButton, descriptionDiv, showMoreClassName){ + // Toggle the class on the description div + descriptionDiv.classList.toggle(showMoreClassName); + + // Change the button text based on the presence of the class + if (descriptionDiv.classList.contains(showMoreClassName)) { + toggleButton.textContent = 'Show less'; + } else { + toggleButton.textContent = 'Show more'; + } + } + + let toggleButton = document.getElementById('dja-show-more-model-description'); + let descriptionDiv = document.querySelector('.dja__model-description'); + if (toggleButton && descriptionDiv) { + handleShowMoreButton(toggleButton, descriptionDiv); + } +} diff --git a/src/registrar/assets/src/js/getgov-admin/submit-bar.js b/src/registrar/assets/src/js/getgov-admin/submit-bar.js new file mode 100644 index 000000000..3d24edd52 --- /dev/null +++ b/src/registrar/assets/src/js/getgov-admin/submit-bar.js @@ -0,0 +1,57 @@ +/** + * A function for toggling the submit bar on domain request forms +*/ +export function initSubmitBar(){ + // Get a reference to the button element + const toggleButton = document.getElementById('submitRowToggle'); + const submitRowWrapper = document.querySelector('.submit-row-wrapper'); + + if (toggleButton) { + // Add event listener to toggle the class and update content on click + toggleButton.addEventListener('click', function() { + // Toggle the 'collapsed' class on the bar + submitRowWrapper.classList.toggle('submit-row-wrapper--collapsed'); + + // Get a reference to the span element inside the button + const spanElement = this.querySelector('span'); + + // Get a reference to the use element inside the button + const useElement = this.querySelector('use'); + + // Check if the span element text is 'Hide' + if (spanElement.textContent.trim() === 'Hide') { + // Update the span element text to 'Show' + spanElement.textContent = 'Show'; + + // Update the xlink:href attribute to expand_more + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); + } else { + // Update the span element text to 'Hide' + spanElement.textContent = 'Hide'; + + // Update the xlink:href attribute to expand_less + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); + } + }); + + // We have a scroll indicator at the end of the page. + // Observe it. Once it gets on screen, test to see if the row is collapsed. + // If it is, expand it. + const targetElement = document.querySelector(".scroll-indicator"); + const options = { + threshold: 1 + }; + // Create a new Intersection Observer + const observer = new IntersectionObserver((entries, observer) => { + entries.forEach(entry => { + if (entry.isIntersecting) { + // Refresh reference to submit row wrapper and check if it's collapsed + if (document.querySelector('.submit-row-wrapper').classList.contains('submit-row-wrapper--collapsed')) { + toggleButton.click(); + } + } + }); + }, options); + observer.observe(targetElement); + } +} diff --git a/src/registrar/assets/src/js/getgov/combobox.js b/src/registrar/assets/src/js/getgov/combobox.js new file mode 100644 index 000000000..36b7aa0ad --- /dev/null +++ b/src/registrar/assets/src/js/getgov/combobox.js @@ -0,0 +1,113 @@ +import { hideElement, showElement } from './helpers.js'; + +export function loadInitialValuesForComboBoxes() { + var overrideDefaultClearButton = true; + var isTyping = false; + + document.addEventListener('DOMContentLoaded', (event) => { + handleAllComboBoxElements(); + }); + + function handleAllComboBoxElements() { + const comboBoxElements = document.querySelectorAll(".usa-combo-box"); + comboBoxElements.forEach(comboBox => { + const input = comboBox.querySelector("input"); + const select = comboBox.querySelector("select"); + if (!input || !select) { + console.warn("No combobox element found"); + return; + } + // Set the initial value of the combobox + let initialValue = select.getAttribute("data-default-value"); + let clearInputButton = comboBox.querySelector(".usa-combo-box__clear-input"); + if (!clearInputButton) { + console.warn("No clear element found"); + return; + } + + // Override the default clear button behavior such that it no longer clears the input, + // it just resets to the data-initial-value. + // Due to the nature of how uswds works, this is slightly hacky. + // Use a MutationObserver to watch for changes in the dropdown list + const dropdownList = comboBox.querySelector(`#${input.id}--list`); + const observer = new MutationObserver(function(mutations) { + mutations.forEach(function(mutation) { + if (mutation.type === "childList") { + addBlankOption(clearInputButton, dropdownList, initialValue); + } + }); + }); + + // Configure the observer to watch for changes in the dropdown list + const config = { childList: true, subtree: true }; + observer.observe(dropdownList, config); + + // Input event listener to detect typing + input.addEventListener("input", () => { + isTyping = true; + }); + + // Blur event listener to reset typing state + input.addEventListener("blur", () => { + isTyping = false; + }); + + // Hide the reset button when there is nothing to reset. + // Do this once on init, then everytime a change occurs. + updateClearButtonVisibility(select, initialValue, clearInputButton) + select.addEventListener("change", () => { + updateClearButtonVisibility(select, initialValue, clearInputButton) + }); + + // Change the default input behaviour - have it reset to the data default instead + clearInputButton.addEventListener("click", (e) => { + if (overrideDefaultClearButton && initialValue) { + e.preventDefault(); + e.stopPropagation(); + input.click(); + // Find the dropdown option with the desired value + const dropdownOptions = document.querySelectorAll(".usa-combo-box__list-option"); + if (dropdownOptions) { + dropdownOptions.forEach(option => { + if (option.getAttribute("data-value") === initialValue) { + // Simulate a click event on the dropdown option + option.click(); + } + }); + } + } + }); + }); + } + + function updateClearButtonVisibility(select, initialValue, clearInputButton) { + if (select.value === initialValue) { + hideElement(clearInputButton); + }else { + showElement(clearInputButton) + } + } + + function addBlankOption(clearInputButton, dropdownList, initialValue) { + if (dropdownList && !dropdownList.querySelector('[data-value=""]') && !isTyping) { + const blankOption = document.createElement("li"); + blankOption.setAttribute("role", "option"); + blankOption.setAttribute("data-value", ""); + blankOption.classList.add("usa-combo-box__list-option"); + if (!initialValue){ + blankOption.classList.add("usa-combo-box__list-option--selected") + } + blankOption.textContent = "⎯"; + + dropdownList.insertBefore(blankOption, dropdownList.firstChild); + blankOption.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + overrideDefaultClearButton = false; + // Trigger the default clear behavior + clearInputButton.click(); + overrideDefaultClearButton = true; + }); + } + } +} diff --git a/src/registrar/assets/src/js/getgov/domain-validators.js b/src/registrar/assets/src/js/getgov/domain-validators.js new file mode 100644 index 000000000..f47a18433 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/domain-validators.js @@ -0,0 +1,253 @@ +var DEFAULT_ERROR = "Please check this field for errors."; +var ERROR = "error"; +var SUCCESS = "success"; + +/** Makes an element invisible. */ +function makeHidden(el) { + el.style.position = "absolute"; + el.style.left = "-100vw"; + // The choice of `visiblity: hidden` + // over `display: none` is due to + // UX: the former will allow CSS + // transitions when the elements appear. + el.style.visibility = "hidden"; +} + +/** Makes visible a perviously hidden element. */ +function makeVisible(el) { + el.style.position = "relative"; + el.style.left = "unset"; + el.style.visibility = "visible"; +} + +/** Creates and returns a live region element. */ +function createLiveRegion(id) { + const liveRegion = document.createElement("div"); + liveRegion.setAttribute("role", "region"); + liveRegion.setAttribute("aria-live", "polite"); + liveRegion.setAttribute("id", id + "-live-region"); + liveRegion.classList.add("usa-sr-only"); + document.body.appendChild(liveRegion); + return liveRegion; +} + +/** Announces changes to assistive technology users. */ +function announce(id, text) { + let liveRegion = document.getElementById(id + "-live-region"); + if (!liveRegion) liveRegion = createLiveRegion(id); + liveRegion.innerHTML = text; +} + +/** Asyncronously fetches JSON. No error handling. */ +function fetchJSON(endpoint, callback, url="/api/v1/") { + const xhr = new XMLHttpRequest(); + xhr.open('GET', url + endpoint); + xhr.send(); + xhr.onload = function() { + if (xhr.status != 200) return; + callback(JSON.parse(xhr.response)); + }; +} + +/** Modifies CSS and HTML when an input is valid/invalid. */ +function toggleInputValidity(el, valid, msg=DEFAULT_ERROR) { + if (valid) { + el.setCustomValidity(""); + el.removeAttribute("aria-invalid"); + el.classList.remove('usa-input--error'); + } else { + el.classList.remove('usa-input--success'); + el.setAttribute("aria-invalid", "true"); + el.setCustomValidity(msg); + el.classList.add('usa-input--error'); + } +} + +/** Display (or hide) a message beneath an element. */ +function inlineToast(el, id, style, msg) { + if (!el.id && !id) { + console.error("Elements must have an `id` to show an inline toast."); + return; + } + let toast = document.getElementById((el.id || id) + "--toast"); + if (style) { + if (!toast) { + // create and insert the message div + toast = document.createElement("div"); + const toastBody = document.createElement("div"); + const p = document.createElement("p"); + toast.setAttribute("id", (el.id || id) + "--toast"); + toast.className = `usa-alert usa-alert--${style} usa-alert--slim`; + toastBody.classList.add("usa-alert__body"); + p.classList.add("usa-alert__text"); + p.innerHTML = msg; + toastBody.appendChild(p); + toast.appendChild(toastBody); + el.parentNode.insertBefore(toast, el.nextSibling); + } else { + // update and show the existing message div + toast.className = `usa-alert usa-alert--${style} usa-alert--slim`; + toast.querySelector("div p").innerHTML = msg; + makeVisible(toast); + } + } else { + if (toast) makeHidden(toast); + } +} + +function checkDomainAvailability(el) { + const callback = (response) => { + toggleInputValidity(el, (response && response.available), response.message); + announce(el.id, response.message); + + // Determines if we ignore the field if it is just blank + let ignore_blank = el.classList.contains("blank-ok") + if (el.validity.valid) { + el.classList.add('usa-input--success'); + // use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration + inlineToast(el.parentElement, el.id, SUCCESS, response.message); + } else if (ignore_blank && response.code == "required"){ + // Visually remove the error + error = "usa-input--error" + if (el.classList.contains(error)){ + el.classList.remove(error) + } + } else { + inlineToast(el.parentElement, el.id, ERROR, response.message); + } + } + fetchJSON(`available/?domain=${el.value}`, callback); +} + +/** Hides the toast message and clears the aira live region. */ +function clearDomainAvailability(el) { + el.classList.remove('usa-input--success'); + announce(el.id, ""); + // use of `parentElement` due to .gov inputs being wrapped in www/.gov decoration + inlineToast(el.parentElement, el.id); +} + +/** Runs all the validators associated with this element. */ +function runValidators(el) { + const attribute = el.getAttribute("validate") || ""; + if (!attribute.length) return; + const validators = attribute.split(" "); + let isInvalid = false; + for (const validator of validators) { + switch (validator) { + case "domain": + checkDomainAvailability(el); + break; + } + } + toggleInputValidity(el, !isInvalid); +} + +/** Clears all the validators associated with this element. */ +function clearValidators(el) { + const attribute = el.getAttribute("validate") || ""; + if (!attribute.length) return; + const validators = attribute.split(" "); + for (const validator of validators) { + switch (validator) { + case "domain": + clearDomainAvailability(el); + break; + } + } + toggleInputValidity(el, true); +} + +/** On input change, handles running any associated validators. */ +function handleInputValidation(e) { + clearValidators(e.target); + if (e.target.hasAttribute("auto-validate")) runValidators(e.target); +} + +/** On button click, handles running any associated validators. */ +function validateFieldInput(e) { + const attribute = e.target.getAttribute("validate-for") || ""; + if (!attribute.length) return; + const input = document.getElementById(attribute); + removeFormErrors(input, true); + runValidators(input); +} + +function validateFormsetInputs(e, availabilityButton) { + +// Collect input IDs from the repeatable forms +let inputs = Array.from(document.querySelectorAll('.repeatable-form input')); + +// Run validators for each input +inputs.forEach(input => { + removeFormErrors(input, true); + runValidators(input); +}); + +// Set the validate-for attribute on the button with the collected input IDs +// Not needed for functionality but nice for accessibility +inputs = inputs.map(input => input.id).join(', '); +availabilityButton.setAttribute('validate-for', inputs); + +} + +/** + * Removes form errors surrounding a form input + */ +function removeFormErrors(input, removeStaleAlerts=false){ + // Remove error message + let errorMessage = document.getElementById(`${input.id}__error-message`); + if (errorMessage) { + errorMessage.remove(); + } else{ + return; + } + + // Remove error classes + if (input.classList.contains('usa-input--error')) { + input.classList.remove('usa-input--error'); + } + + // Get the form label + let label = document.querySelector(`label[for="${input.id}"]`); + if (label) { + label.classList.remove('usa-label--error'); + + // Remove error classes from parent div + let parentDiv = label.parentElement; + if (parentDiv) { + parentDiv.classList.remove('usa-form-group--error'); + } + } + + if (removeStaleAlerts){ + let staleAlerts = document.querySelectorAll(".usa-alert--error"); + for (let alert of staleAlerts) { + // Don't remove the error associated with the input + if (alert.id !== `${input.id}--toast`) { + alert.remove(); + } + } + } +} + +export function initDomainValidators() { + "use strict"; + const needsValidation = document.querySelectorAll('[validate]'); + for (const input of needsValidation) { + input.addEventListener('input', handleInputValidation); + } + + const alternativeDomainsAvailability = document.getElementById('validate-alt-domains-availability'); + const activatesValidation = document.querySelectorAll('[validate-for]'); + + for (const button of activatesValidation) { + if (button === alternativeDomainsAvailability) { + button.addEventListener('click', (e) => { + validateFormsetInputs(e, alternativeDomainsAvailability); + }); + } else { + button.addEventListener('click', validateFieldInput); + } + } +} diff --git a/src/registrar/assets/src/js/getgov/formset-forms.js b/src/registrar/assets/src/js/getgov/formset-forms.js new file mode 100644 index 000000000..27b85212e --- /dev/null +++ b/src/registrar/assets/src/js/getgov/formset-forms.js @@ -0,0 +1,418 @@ +/** + * Prepare the namerservers and DS data forms delete buttons + * We will call this on the forms init, and also every time we add a form + * + */ +function removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier){ + let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); + let formToRemove = e.target.closest(".repeatable-form"); + formToRemove.remove(); + let forms = document.querySelectorAll(".repeatable-form"); + totalForms.setAttribute('value', `${forms.length}`); + + let formNumberRegex = RegExp(`form-(\\d){1}-`, 'g'); + let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); + // For the example on Nameservers + let formExampleRegex = RegExp(`ns(\\d+){1}`, 'g'); + + forms.forEach((form, index) => { + // Iterate over child nodes of the current element + Array.from(form.querySelectorAll('label, input, select')).forEach((node) => { + // Iterate through the attributes of the current node + Array.from(node.attributes).forEach((attr) => { + // Check if the attribute value matches the regex + if (formNumberRegex.test(attr.value)) { + // Replace the attribute value with the updated value + attr.value = attr.value.replace(formNumberRegex, `form-${index}-`); + } + }); + }); + + // h2 and legend for DS form, label for nameservers + Array.from(form.querySelectorAll('h2, legend, label, p')).forEach((node) => { + + let innerSpan = node.querySelector('span') + if (innerSpan) { + innerSpan.textContent = innerSpan.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); + } else { + node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); + node.textContent = node.textContent.replace(formExampleRegex, `ns${index + 1}`); + } + + // If the node is a nameserver label, one of the first 2 which was previously 3 and up (not required) + // inject the USWDS required markup and make sure the INPUT is required + if (isNameserversForm && index <= 1 && node.innerHTML.includes('server') && !node.innerHTML.includes('*')) { + + // Remove the word optional + innerSpan.textContent = innerSpan.textContent.replace(/\s*\(\s*optional\s*\)\s*/, ''); + + // Create a new element + const newElement = document.createElement('abbr'); + newElement.textContent = '*'; + newElement.setAttribute("title", "required"); + newElement.classList.add("usa-hint", "usa-hint--required"); + + // Append the new element to the label + node.appendChild(newElement); + // Find the next sibling that is an input element + let nextInputElement = node.nextElementSibling; + + while (nextInputElement) { + if (nextInputElement.tagName === 'INPUT') { + // Found the next input element + nextInputElement.setAttribute("required", "") + break; + } + nextInputElement = nextInputElement.nextElementSibling; + } + nextInputElement.required = true; + } + }); + + // Display the add more button if we have less than 13 forms + if (isNameserversForm && forms.length <= 13) { + addButton.removeAttribute("disabled"); + } + + if (isNameserversForm && forms.length < 3) { + // Hide the delete buttons on the remaining nameservers + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.setAttribute("disabled", "true"); + }); + } + }); +} + +/** + * Delete method for formsets using the DJANGO DELETE widget (Other Contacts) + * + */ +function markForm(e, formLabel){ + // Unlike removeForm, we only work with the visible forms when using DJANGO's DELETE widget + let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; + + if (totalShownForms == 1) { + // toggle the radio buttons + let radioButton = document.querySelector('input[name="other_contacts-has_other_contacts"][value="False"]'); + radioButton.checked = true; + // Trigger the change event + let event = new Event('change'); + radioButton.dispatchEvent(event); + } else { + + // Grab the hidden delete input and assign a value DJANGO will look for + let formToRemove = e.target.closest(".repeatable-form"); + if (formToRemove) { + let deleteInput = formToRemove.querySelector('input[class="deletion"]'); + if (deleteInput) { + deleteInput.value = 'on'; + } + } + + // Set display to 'none' + formToRemove.style.display = 'none'; + } + + // Update h2s on the visible forms only. We won't worry about the forms' identifiers + let shownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`); + let formLabelRegex = RegExp(`${formLabel} (\\d+){1}`, 'g'); + shownForms.forEach((form, index) => { + // Iterate over child nodes of the current element + Array.from(form.querySelectorAll('h2')).forEach((node) => { + node.textContent = node.textContent.replace(formLabelRegex, `${formLabel} ${index + 1}`); + }); + }); +} + +/** + * Prepare the namerservers, DS data and Other Contacts formsets' delete button + * for the last added form. We call this from the Add function + * + */ +function prepareNewDeleteButton(btn, formLabel) { + let formIdentifier = "form" + let isNameserversForm = document.querySelector(".nameservers-form"); + let isOtherContactsForm = document.querySelector(".other-contacts-form"); + let addButton = document.querySelector("#add-form"); + + if (isOtherContactsForm) { + formIdentifier = "other_contacts"; + // We will mark the forms for deletion + btn.addEventListener('click', function(e) { + markForm(e, formLabel); + }); + } else { + // We will remove the forms and re-order the formset + btn.addEventListener('click', function(e) { + removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + }); + } +} + +/** + * Prepare the namerservers, DS data and Other Contacts formsets' delete buttons + * We will call this on the forms init + * + */ +function prepareDeleteButtons(formLabel) { + let formIdentifier = "form" + let deleteButtons = document.querySelectorAll(".delete-record"); + let isNameserversForm = document.querySelector(".nameservers-form"); + let isOtherContactsForm = document.querySelector(".other-contacts-form"); + let addButton = document.querySelector("#add-form"); + if (isOtherContactsForm) { + formIdentifier = "other_contacts"; + } + + // Loop through each delete button and attach the click event listener + deleteButtons.forEach((deleteButton) => { + if (isOtherContactsForm) { + // We will mark the forms for deletion + deleteButton.addEventListener('click', function(e) { + markForm(e, formLabel); + }); + } else { + // We will remove the forms and re-order the formset + deleteButton.addEventListener('click', function(e) { + removeForm(e, formLabel, isNameserversForm, addButton, formIdentifier); + }); + } + }); +} + +/** + * DJANGO formset's DELETE widget + * On form load, hide deleted forms, ie. those forms with hidden input of class 'deletion' + * with value='on' + */ +function hideDeletedForms() { + let hiddenDeleteButtonsWithValueOn = document.querySelectorAll('input[type="hidden"].deletion[value="on"]'); + + // Iterating over the NodeList of hidden inputs + hiddenDeleteButtonsWithValueOn.forEach(function(hiddenInput) { + // Finding the closest parent element with class "repeatable-form" for each hidden input + var repeatableFormToHide = hiddenInput.closest('.repeatable-form'); + + // Checking if a matching parent element is found for each hidden input + if (repeatableFormToHide) { + // Setting the display property to "none" for each matching parent element + repeatableFormToHide.style.display = 'none'; + } + }); +} + +/** + * A function that attaches a click handler for our dynamic formsets + * + * Only does something on a few pages, but it should be fast enough to run + * it everywhere. + */ +export function initFormsetsForms() { + let formIdentifier = "form" + let repeatableForm = document.querySelectorAll(".repeatable-form"); + let container = document.querySelector("#form-container"); + let addButton = document.querySelector("#add-form"); + let cloneIndex = 0; + let formLabel = ''; + let isNameserversForm = document.querySelector(".nameservers-form"); + let isOtherContactsForm = document.querySelector(".other-contacts-form"); + let isDsDataForm = document.querySelector(".ds-data-form"); + let isDotgovDomain = document.querySelector(".dotgov-domain-form"); + // The Nameservers formset features 2 required and 11 optionals + if (isNameserversForm) { + // cloneIndex = 2; + formLabel = "Name server"; + // DNSSEC: DS Data + } else if (isDsDataForm) { + formLabel = "DS data record"; + // The Other Contacts form + } else if (isOtherContactsForm) { + formLabel = "Organization contact"; + container = document.querySelector("#other-employees"); + formIdentifier = "other_contacts" + } else if (isDotgovDomain) { + formIdentifier = "dotgov_domain" + } + let totalForms = document.querySelector(`#id_${formIdentifier}-TOTAL_FORMS`); + + // On load: Disable the add more button if we have 13 forms + if (isNameserversForm && document.querySelectorAll(".repeatable-form").length == 13) { + addButton.setAttribute("disabled", "true"); + } + + // Hide forms which have previously been deleted + hideDeletedForms() + + // Attach click event listener on the delete buttons of the existing forms + prepareDeleteButtons(formLabel); + + if (addButton) + addButton.addEventListener('click', addForm); + + function addForm(e){ + let forms = document.querySelectorAll(".repeatable-form"); + let formNum = forms.length; + let newForm = repeatableForm[cloneIndex].cloneNode(true); + let formNumberRegex = RegExp(`${formIdentifier}-(\\d){1}-`,'g'); + let formLabelRegex = RegExp(`${formLabel} (\\d){1}`, 'g'); + // For the eample on Nameservers + let formExampleRegex = RegExp(`ns(\\d){1}`, 'g'); + + // Some Nameserver form checks since the delete can mess up the source object we're copying + // in regards to required fields and hidden delete buttons + if (isNameserversForm) { + + // If the source element we're copying has required on an input, + // reset that input + let formRequiredNeedsCleanUp = newForm.innerHTML.includes('*'); + if (formRequiredNeedsCleanUp) { + newForm.querySelector('label abbr').remove(); + // Get all input elements within the container + const inputElements = newForm.querySelectorAll("input"); + // Loop through each input element and remove the 'required' attribute + inputElements.forEach((input) => { + if (input.hasAttribute("required")) { + input.removeAttribute("required"); + } + }); + } + + // If the source element we're copying has an disabled delete button, + // enable that button + let deleteButton= newForm.querySelector('.delete-record'); + if (deleteButton.hasAttribute("disabled")) { + deleteButton.removeAttribute("disabled"); + } + } + + formNum++; + + newForm.innerHTML = newForm.innerHTML.replace(formNumberRegex, `${formIdentifier}-${formNum-1}-`); + if (isOtherContactsForm) { + // For the other contacts form, we need to update the fieldset headers based on what's visible vs hidden, + // since the form on the backend employs Django's DELETE widget. + let totalShownForms = document.querySelectorAll(`.repeatable-form:not([style*="display: none"])`).length; + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${totalShownForms + 1}`); + } else { + // Nameservers form is cloned from index 2 which has the word optional on init, does not have the word optional + // if indices 0 or 1 have been deleted + let containsOptional = newForm.innerHTML.includes('(optional)'); + if (isNameserversForm && !containsOptional) { + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum} (optional)`); + } else { + newForm.innerHTML = newForm.innerHTML.replace(formLabelRegex, `${formLabel} ${formNum}`); + } + } + newForm.innerHTML = newForm.innerHTML.replace(formExampleRegex, `ns${formNum}`); + newForm.innerHTML = newForm.innerHTML.replace(/\n/g, ''); // Remove newline characters + newForm.innerHTML = newForm.innerHTML.replace(/>\s*<'); // Remove spaces between tags + container.insertBefore(newForm, addButton); + + newForm.style.display = 'block'; + + let inputs = newForm.querySelectorAll("input"); + // Reset the values of each input to blank + inputs.forEach((input) => { + input.classList.remove("usa-input--error"); + input.classList.remove("usa-input--success"); + if (input.type === "text" || input.type === "number" || input.type === "password" || input.type === "email" || input.type === "tel") { + input.value = ""; // Set the value to an empty string + + } else if (input.type === "checkbox" || input.type === "radio") { + input.checked = false; // Uncheck checkboxes and radios + } + }); + + // Reset any existing validation classes + let selects = newForm.querySelectorAll("select"); + selects.forEach((select) => { + select.classList.remove("usa-input--error"); + select.classList.remove("usa-input--success"); + select.selectedIndex = 0; // Set the value to an empty string + }); + + let labels = newForm.querySelectorAll("label"); + labels.forEach((label) => { + label.classList.remove("usa-label--error"); + label.classList.remove("usa-label--success"); + }); + + let usaFormGroups = newForm.querySelectorAll(".usa-form-group"); + usaFormGroups.forEach((usaFormGroup) => { + usaFormGroup.classList.remove("usa-form-group--error"); + usaFormGroup.classList.remove("usa-form-group--success"); + }); + + // Remove any existing error and success messages + let usaMessages = newForm.querySelectorAll(".usa-error-message, .usa-alert"); + usaMessages.forEach((usaErrorMessage) => { + let parentDiv = usaErrorMessage.closest('div'); + if (parentDiv) { + parentDiv.remove(); // Remove the parent div if it exists + } + }); + + totalForms.setAttribute('value', `${formNum}`); + + // Attach click event listener on the delete buttons of the new form + let newDeleteButton = newForm.querySelector(".delete-record"); + if (newDeleteButton) + prepareNewDeleteButton(newDeleteButton, formLabel); + + // Disable the add more button if we have 13 forms + if (isNameserversForm && formNum == 13) { + addButton.setAttribute("disabled", "true"); + } + + if (isNameserversForm && forms.length >= 2) { + // Enable the delete buttons on the nameservers + forms.forEach((form, index) => { + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.removeAttribute("disabled"); + }); + }); + } + } +} + +export function triggerModalOnDsDataForm() { + let saveButon = document.querySelector("#save-ds-data"); + + // The view context will cause a hitherto hidden modal trigger to + // show up. On save, we'll test for that modal trigger appearing. We'll + // run that test once every 100 ms for 5 secs, which should balance performance + // while accounting for network or lag issues. + if (saveButon) { + let i = 0; + var tryToTriggerModal = setInterval(function() { + i++; + if (i > 100) { + clearInterval(tryToTriggerModal); + } + let modalTrigger = document.querySelector("#ds-toggle-dnssec-alert"); + if (modalTrigger) { + modalTrigger.click() + clearInterval(tryToTriggerModal); + } + }, 50); + } +} + +/** + * Disable the delete buttons on nameserver forms on page load if < 3 forms + * + */ +export function nameserversFormListener() { + let isNameserversForm = document.querySelector(".nameservers-form"); + if (isNameserversForm) { + let forms = document.querySelectorAll(".repeatable-form"); + if (forms.length < 3) { + // Hide the delete buttons on the 2 nameservers + forms.forEach((form) => { + Array.from(form.querySelectorAll('.delete-record')).forEach((deleteButton) => { + deleteButton.setAttribute("disabled", "true"); + }); + }); + } + } +} diff --git a/src/registrar/assets/src/js/getgov/helpers-uswds.js b/src/registrar/assets/src/js/getgov/helpers-uswds.js new file mode 100644 index 000000000..129d578b6 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/helpers-uswds.js @@ -0,0 +1,43 @@ +/** + * Initialize USWDS tooltips by calling initialization method. Requires that uswds-edited.js + * be loaded before getgov.min.js. uswds-edited.js adds the tooltip module to the window to be + * accessible directly in getgov.min.js + * + */ +export function initializeTooltips() { + function checkTooltip() { + // Check that the tooltip library is loaded, and if not, wait and retry + if (window.tooltip && typeof window.tooltip.init === 'function') { + window.tooltip.init(); + } else { + // Retry after a short delay + setTimeout(checkTooltip, 100); + } + } + checkTooltip(); + } + +/** + * Initialize USWDS modals by calling on method. Requires that uswds-edited.js be loaded + * before getgov.min.js. uswds-edited.js adds the modal module to the window to be accessible + * directly in getgov.min.js. + * uswdsInitializeModals adds modal-related DOM elements, based on other DOM elements existing in + * the page. It needs to be called only once for any particular DOM element; otherwise, it + * will initialize improperly. Therefore, if DOM elements change dynamically and include + * DOM elements with modal classes, uswdsUnloadModals needs to be called before uswdsInitializeModals. + * + */ +export function uswdsInitializeModals() { + window.modal.on(); +} + +/** + * Unload existing USWDS modals by calling off method. Requires that uswds-edited.js be + * loaded before getgov.min.js. uswds-edited.js adds the modal module to the window to be + * accessible directly in getgov.min.js. + * See note above with regards to calling this method relative to uswdsInitializeModals. + * + */ +export function uswdsUnloadModals() { + window.modal.off(); +} diff --git a/src/registrar/assets/src/js/getgov/helpers.js b/src/registrar/assets/src/js/getgov/helpers.js new file mode 100644 index 000000000..1afd84520 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/helpers.js @@ -0,0 +1,77 @@ +export function hideElement(element) { + element.classList.add('display-none'); +}; + +export function showElement(element) { + element.classList.remove('display-none'); +}; + +/** + * Helper function that scrolls to an element identified by a class or an id. + * @param {string} attributeName - The string "class" or "id" + * @param {string} attributeValue - The class or id name + */ +export function scrollToElement(attributeName, attributeValue) { + let targetEl = null; + + if (attributeName === 'class') { + targetEl = document.getElementsByClassName(attributeValue)[0]; + } else if (attributeName === 'id') { + targetEl = document.getElementById(attributeValue); + } else { + console.error('Error: unknown attribute name provided.'); + return; // Exit the function if an invalid attributeName is provided + } + + if (targetEl) { + const rect = targetEl.getBoundingClientRect(); + const scrollTop = window.scrollY || document.documentElement.scrollTop; + window.scrollTo({ + top: rect.top + scrollTop, + behavior: 'smooth' // Optional: for smooth scrolling + }); + } +} + +/** + * Toggles expand_more / expand_more svgs in buttons or anchors + * @param {Element} element - DOM element + */ +export function toggleCaret(element) { + // Get a reference to the use element inside the button + const useElement = element.querySelector('use'); + // Check if the span element text is 'Hide' + if (useElement.getAttribute('xlink:href') === '/public/img/sprite.svg#expand_more') { + // Update the xlink:href attribute to expand_more + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); + } else { + // Update the xlink:href attribute to expand_less + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); + } +} + +/** + * Slow down event handlers by limiting how frequently they fire. + * + * A wait period must occur with no activity (activity means "this + * debounce function being called") before the handler is invoked. + * + * @param {Function} handler - any JS function + * @param {number} cooldown - the wait period, in milliseconds + */ +export function debounce(handler, cooldown=600) { + let timeout; + return function(...args) { + const context = this; + clearTimeout(timeout); + timeout = setTimeout(() => handler.apply(context, args), cooldown); + } +} + +/** + * Helper function to get the CSRF token from the cookie + * +*/ +export function getCsrfToken() { + return document.querySelector('input[name="csrfmiddlewaretoken"]').value; +} diff --git a/src/registrar/assets/src/js/getgov/main.js b/src/registrar/assets/src/js/getgov/main.js new file mode 100644 index 000000000..2e1e9c4d1 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/main.js @@ -0,0 +1,44 @@ +import { hookupYesNoListener, hookupRadioTogglerListener } from './radios.js'; +import { initDomainValidators } from './domain-validators.js'; +import { initFormsetsForms, triggerModalOnDsDataForm, nameserversFormListener } from './formset-forms.js'; +import { initializeUrbanizationToggle } from './urbanization.js'; +import { userProfileListener, finishUserSetupListener } from './user-profile.js'; +import { loadInitialValuesForComboBoxes } from './combobox.js'; +import { handleRequestingEntityFieldset } from './requesting-entity.js'; +import { initDomainsTable } from './table-domains.js'; +import { initDomainRequestsTable } from './table-domain-requests.js'; +import { initMembersTable } from './table-members.js'; +import { initMemberDomainsTable } from './table-member-domains.js'; +import { initPortfolioMemberPageToggle } from './portfolio-member-page.js'; + +initDomainValidators(); + +initFormsetsForms(); +triggerModalOnDsDataForm(); +nameserversFormListener(); + +hookupYesNoListener("other_contacts-has_other_contacts",'other-employees', 'no-other-employees'); +hookupYesNoListener("additional_details-has_anything_else_text",'anything-else', null); +hookupRadioTogglerListener( + 'member_access_level', + { + 'admin': 'new-member-admin-permissions', + 'basic': 'new-member-basic-permissions' + } +); +hookupYesNoListener("additional_details-has_cisa_representative",'cisa-representative', null); +initializeUrbanizationToggle(); + +userProfileListener(); +finishUserSetupListener(); + +loadInitialValuesForComboBoxes(); + +handleRequestingEntityFieldset(); + +initDomainsTable(); +initDomainRequestsTable(); +initMembersTable(); +initMemberDomainsTable(); + +initPortfolioMemberPageToggle(); diff --git a/src/registrar/assets/src/js/getgov/portfolio-member-page.js b/src/registrar/assets/src/js/getgov/portfolio-member-page.js new file mode 100644 index 000000000..211d751d4 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/portfolio-member-page.js @@ -0,0 +1,43 @@ +import { uswdsInitializeModals } from './helpers-uswds.js'; +import { generateKebabHTML } from './table-base.js'; +import { MembersTable } from './table-members.js'; + +// This is specifically for the Member Profile (Manage Member) Page member/invitation removal +export function initPortfolioMemberPageToggle() { + document.addEventListener("DOMContentLoaded", () => { + const wrapperDeleteAction = document.getElementById("wrapper-delete-action") + if (wrapperDeleteAction) { + const member_type = wrapperDeleteAction.getAttribute("data-member-type"); + const member_id = wrapperDeleteAction.getAttribute("data-member-id"); + const num_domains = wrapperDeleteAction.getAttribute("data-num-domains"); + const member_name = wrapperDeleteAction.getAttribute("data-member-name"); + const member_email = wrapperDeleteAction.getAttribute("data-member-email"); + const member_delete_url = `${member_type}-${member_id}/delete`; + const unique_id = `${member_type}-${member_id}`; + + let cancelInvitationButton = member_type === "invitedmember" ? "Cancel invitation" : "Remove member"; + wrapperDeleteAction.innerHTML = generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member_name}`); + + // This easter egg is only for fixtures that dont have names as we are displaying their emails + // All prod users will have emails linked to their account + MembersTable.addMemberModal(num_domains, member_email || "Samwise Gamgee", member_delete_url, unique_id, wrapperDeleteAction); + + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + closeButton.click(); + let delete_member_form = document.getElementById("member-delete-form"); + if (delete_member_form) { + delete_member_form.submit(); + } + }); + }); + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/radios.js b/src/registrar/assets/src/js/getgov/radios.js new file mode 100644 index 000000000..248865e8b --- /dev/null +++ b/src/registrar/assets/src/js/getgov/radios.js @@ -0,0 +1,77 @@ +import { hideElement, showElement } from './helpers.js'; + +// <<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>><<>> +// Helper functions. + +/** Hookup listeners for yes/no togglers for form fields + * Parameters: + * - radioButtonName: The "name=" value for the radio buttons being used as togglers + * - elementIdToShowIfYes: The Id of the element (eg. a div) to show if selected value of the given + * radio button is true (hides this element if false) + * - elementIdToShowIfNo: The Id of the element (eg. a div) to show if selected value of the given + * radio button is false (hides this element if true) + * **/ +export function hookupYesNoListener(radioButtonName, elementIdToShowIfYes, elementIdToShowIfNo) { + hookupRadioTogglerListener(radioButtonName, { + 'True': elementIdToShowIfYes, + 'False': elementIdToShowIfNo + }); +} + +/** + * Hookup listeners for radio togglers in form fields. + * + * Parameters: + * - radioButtonName: The "name=" value for the radio buttons being used as togglers + * - valueToElementMap: An object where keys are the values of the radio buttons, + * and values are the corresponding DOM element IDs to show. All other elements will be hidden. + * + * Usage Example: + * Assuming you have radio buttons with values 'option1', 'option2', and 'option3', + * and corresponding DOM IDs 'section1', 'section2', 'section3'. + * + * HookupValueBasedListener('exampleRadioGroup', { + * 'option1': 'section1', + * 'option2': 'section2', + * 'option3': 'section3' + * }); + **/ +export function hookupRadioTogglerListener(radioButtonName, valueToElementMap) { + // Get the radio buttons + let radioButtons = document.querySelectorAll('input[name="'+radioButtonName+'"]'); + + // Extract the list of all element IDs from the valueToElementMap + let allElementIds = Object.values(valueToElementMap); + + function handleRadioButtonChange() { + // Find the checked radio button + let radioButtonChecked = document.querySelector('input[name="'+radioButtonName+'"]:checked'); + let selectedValue = radioButtonChecked ? radioButtonChecked.value : null; + + // Hide all elements by default + allElementIds.forEach(function (elementId) { + let element = document.getElementById(elementId); + if (element) { + hideElement(element); + } + }); + + // Show the relevant element for the selected value + if (selectedValue && valueToElementMap[selectedValue]) { + let elementToShow = document.getElementById(valueToElementMap[selectedValue]); + if (elementToShow) { + showElement(elementToShow); + } + } + } + + if (radioButtons.length) { + // Add event listener to each radio button + radioButtons.forEach(function (radioButton) { + radioButton.addEventListener('change', handleRadioButtonChange); + }); + + // Initialize by checking the current state + handleRadioButtonChange(); + } +} diff --git a/src/registrar/assets/src/js/getgov/requesting-entity.js b/src/registrar/assets/src/js/getgov/requesting-entity.js new file mode 100644 index 000000000..4e7cf8276 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/requesting-entity.js @@ -0,0 +1,50 @@ +import { hideElement, showElement } from './helpers.js'; + +/** A function that intializes the requesting entity page. + * This page has a radio button that dynamically toggles some fields + * Within that, the dropdown also toggles some additional form elements. +*/ +export function handleRequestingEntityFieldset() { + // Sadly, these ugly ids are the auto generated with this prefix + const formPrefix = "portfolio_requesting_entity"; + const radioFieldset = document.getElementById(`id_${formPrefix}-requesting_entity_is_suborganization__fieldset`); + const radios = radioFieldset?.querySelectorAll(`input[name="${formPrefix}-requesting_entity_is_suborganization"]`); + const select = document.getElementById(`id_${formPrefix}-sub_organization`); + const selectParent = select?.parentElement; + const suborgContainer = document.getElementById("suborganization-container"); + const suborgDetailsContainer = document.getElementById("suborganization-container__details"); + const subOrgCreateNewOption = document.getElementById("option-to-add-suborg")?.value; + // Make sure all crucial page elements exist before proceeding. + // This more or less ensures that we are on the Requesting Entity page, and not elsewhere. + if (!radios || !select || !selectParent || !suborgContainer || !suborgDetailsContainer) return; + + // requestingSuborganization: This just broadly determines if they're requesting a suborg at all + // requestingNewSuborganization: This variable determines if the user is trying to *create* a new suborganization or not. + var requestingSuborganization = Array.from(radios).find(radio => radio.checked)?.value === "True"; + var requestingNewSuborganization = document.getElementById(`id_${formPrefix}-is_requesting_new_suborganization`); + + function toggleSuborganization(radio=null) { + if (radio != null) requestingSuborganization = radio?.checked && radio.value === "True"; + requestingSuborganization ? showElement(suborgContainer) : hideElement(suborgContainer); + requestingNewSuborganization.value = requestingSuborganization && select.value === "other" ? "True" : "False"; + requestingNewSuborganization.value === "True" ? showElement(suborgDetailsContainer) : hideElement(suborgDetailsContainer); + } + + // Add fake "other" option to sub_organization select + if (select && !Array.from(select.options).some(option => option.value === "other")) { + select.add(new Option(subOrgCreateNewOption, "other")); + } + + if (requestingNewSuborganization.value === "True") { + select.value = "other"; + } + + // Add event listener to is_suborganization radio buttons, and run for initial display + toggleSuborganization(); + radios.forEach(radio => { + radio.addEventListener("click", () => toggleSuborganization(radio)); + }); + + // Add event listener to the suborg dropdown to show/hide the suborg details section + select.addEventListener("change", () => toggleSuborganization()); +} diff --git a/src/registrar/assets/src/js/getgov/table-base.js b/src/registrar/assets/src/js/getgov/table-base.js new file mode 100644 index 000000000..07b7cff5e --- /dev/null +++ b/src/registrar/assets/src/js/getgov/table-base.js @@ -0,0 +1,651 @@ +import { hideElement, showElement, toggleCaret, scrollToElement } from './helpers.js'; + +/** +* Creates and adds a modal dialog to the DOM with customizable attributes and content. +* +* @param {string} id - A unique identifier for the modal, appended to the action for uniqueness. +* @param {string} ariaLabelledby - The ID of the element that labels the modal, for accessibility. +* @param {string} ariaDescribedby - The ID of the element that describes the modal, for accessibility. +* @param {string} modalHeading - The heading text displayed at the top of the modal. +* @param {string} modalDescription - The main descriptive text displayed within the modal. +* @param {string} modalSubmit - The HTML content for the submit button, allowing customization. +* @param {HTMLElement} wrapper_element - Optional. The element to which the modal is appended. If not provided, defaults to `document.body`. +* @param {boolean} forceAction - Optional. If true, adds a `data-force-action` attribute to the modal for additional control. +* +* The modal includes a heading, description, submit button, and a cancel button, along with a close button. +* The `data-close-modal` attribute is added to cancel and close buttons to enable closing functionality. +*/ +export function addModal(id, ariaLabelledby, ariaDescribedby, modalHeading, modalDescription, modalSubmit, wrapper_element, forceAction) { + + const modal = document.createElement('div'); + modal.setAttribute('class', 'usa-modal'); + modal.setAttribute('id', id); + modal.setAttribute('aria-labelledby', ariaLabelledby); + modal.setAttribute('aria-describedby', ariaDescribedby); + if (forceAction) + modal.setAttribute('data-force-action', ''); + + modal.innerHTML = ` +
+
+

+ ${modalHeading} +

+
+

+ ${modalDescription} +

+
+ +
+ +
+ ` + if (wrapper_element) { + wrapper_element.appendChild(modal); + } else { + document.body.appendChild(modal); + } +} + +/** + * Helper function that creates a dynamic accordion navigation + * @param {string} action - The action type or identifier used to create a unique DOM IDs. + * @param {string} unique_id - An ID that when combined with action makes a unique identifier + * @param {string} modal_button_text - The action button's text + * @param {string} screen_reader_text - A screen reader helper + */ +export function generateKebabHTML(action, unique_id, modal_button_text, screen_reader_text) { + const generateModalButton = (mobileOnly = false) => ` + + ${mobileOnly ? `` : ''} + ${modal_button_text} + ${screen_reader_text} + + `; + + // Main kebab structure + const kebab = ` + ${generateModalButton(true)} +
+
+ +
+ +
+ `; + + return kebab; +} + +export class BaseTable { + constructor(itemName) { + this.itemName = itemName; + this.sectionSelector = itemName + 's'; + this.tableWrapper = document.getElementById(`${this.sectionSelector}__table-wrapper`); + this.tableHeaders = document.querySelectorAll(`#${this.sectionSelector} th[data-sortable]`); + this.currentSortBy = 'id'; + this.currentOrder = 'asc'; + this.currentStatus = []; + this.currentSearchTerm = ''; + this.scrollToTable = false; + this.searchInput = document.getElementById(`${this.sectionSelector}__search-field`); + this.searchSubmit = document.getElementById(`${this.sectionSelector}__search-field-submit`); + this.tableAnnouncementRegion = document.getElementById(`${this.sectionSelector}__usa-table__announcement-region`); + this.resetSearchButton = document.getElementById(`${this.sectionSelector}__reset-search`); + this.resetFiltersButton = document.getElementById(`${this.sectionSelector}__reset-filters`); + this.statusCheckboxes = document.querySelectorAll(`.${this.sectionSelector} input[name="filter-status"]`); + this.statusIndicator = document.getElementById(`${this.sectionSelector}__filter-indicator`); + this.statusToggle = document.getElementById(`${this.sectionSelector}__usa-button--filter`); + this.noTableWrapper = document.getElementById(`${this.sectionSelector}__no-data`); + this.noSearchResultsWrapper = document.getElementById(`${this.sectionSelector}__no-search-results`); + this.portfolioElement = document.getElementById('portfolio-js-value'); + this.portfolioValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-portfolio') : null; + this.initializeTableHeaders(); + this.initializeSearchHandler(); + this.initializeStatusToggleHandler(); + this.initializeFilterCheckboxes(); + this.initializeResetSearchButton(); + this.initializeResetFiltersButton(); + this.initializeAccordionAccessibilityListeners(); + } + + /** + * Generalized function to update pagination for a list. + * @param {number} currentPage - The current page number (starting with 1). + * @param {number} numPages - The total number of pages. + * @param {boolean} hasPrevious - Whether there is a page before the current page. + * @param {boolean} hasNext - Whether there is a page after the current page. + * @param {number} total - The total number of items. + */ + updatePagination( + currentPage, + numPages, + hasPrevious, + hasNext, + totalItems + ) { + const paginationButtons = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__list`); + const counterSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination .usa-pagination__counter`); + const paginationSelectorEl = document.querySelector(`#${this.sectionSelector}-pagination`); + const parentTableSelector = `#${this.sectionSelector}`; + counterSelectorEl.innerHTML = ''; + paginationButtons.innerHTML = ''; + + // Buttons should only be displayed if there are more than one pages of results + paginationButtons.classList.toggle('display-none', numPages <= 1); + + // Counter should only be displayed if there is more than 1 item + paginationSelectorEl.classList.toggle('display-none', totalItems < 1); + + counterSelectorEl.innerHTML = `${totalItems} ${this.itemName}${totalItems > 1 ? 's' : ''}${this.currentSearchTerm ? ' for ' + '"' + this.currentSearchTerm + '"' : ''}`; + + // Helper function to create a pagination item + const createPaginationItem = (page) => { + const paginationItem = document.createElement('li'); + paginationItem.classList.add('usa-pagination__item', 'usa-pagination__page-no'); + paginationItem.innerHTML = ` + ${page} + `; + if (page === currentPage) { + paginationItem.querySelector('a').classList.add('usa-current'); + paginationItem.querySelector('a').setAttribute('aria-current', 'page'); + } + paginationItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + this.loadTable(page); + }); + return paginationItem; + }; + + if (hasPrevious) { + const prevPaginationItem = document.createElement('li'); + prevPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; + prevPaginationItem.innerHTML = ` + + + Previous + + `; + prevPaginationItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + this.loadTable(currentPage - 1); + }); + paginationButtons.appendChild(prevPaginationItem); + } + + // Add first page and ellipsis if necessary + if (currentPage > 2) { + paginationButtons.appendChild(createPaginationItem(1)); + if (currentPage > 3) { + const ellipsis = document.createElement('li'); + ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; + ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); + ellipsis.innerHTML = ''; + paginationButtons.appendChild(ellipsis); + } + } + + // Add pages around the current page + for (let i = Math.max(1, currentPage - 1); i <= Math.min(numPages, currentPage + 1); i++) { + paginationButtons.appendChild(createPaginationItem(i)); + } + + // Add last page and ellipsis if necessary + if (currentPage < numPages - 1) { + if (currentPage < numPages - 2) { + const ellipsis = document.createElement('li'); + ellipsis.className = 'usa-pagination__item usa-pagination__overflow'; + ellipsis.setAttribute('aria-label', 'ellipsis indicating non-visible pages'); + ellipsis.innerHTML = ''; + paginationButtons.appendChild(ellipsis); + } + paginationButtons.appendChild(createPaginationItem(numPages)); + } + + if (hasNext) { + const nextPaginationItem = document.createElement('li'); + nextPaginationItem.className = 'usa-pagination__item usa-pagination__arrow'; + nextPaginationItem.innerHTML = ` + + Next + + + `; + nextPaginationItem.querySelector('a').addEventListener('click', (event) => { + event.preventDefault(); + this.loadTable(currentPage + 1); + }); + paginationButtons.appendChild(nextPaginationItem); + } + } + + /** + * A helper that toggles content/ no content/ no search results based on results in data. + * @param {Object} data - Data representing current page of results data. + * @param {HTMLElement} dataWrapper - The DOM element to show if there are results on the current page. + * @param {HTMLElement} noDataWrapper - The DOM element to show if there are no results period. + * @param {HTMLElement} noSearchResultsWrapper - The DOM element to show if there are no results in the current filtered search. + */ + updateDisplay = (data, dataWrapper, noDataWrapper, noSearchResultsWrapper) => { + const { unfiltered_total, total } = data; + if (unfiltered_total) { + if (total) { + showElement(dataWrapper); + hideElement(noSearchResultsWrapper); + hideElement(noDataWrapper); + } else { + hideElement(dataWrapper); + showElement(noSearchResultsWrapper); + hideElement(noDataWrapper); + } + } else { + hideElement(dataWrapper); + hideElement(noSearchResultsWrapper); + showElement(noDataWrapper); + } + }; + + /** + * A helper that resets sortable table headers + * + */ + unsetHeader = (header) => { + header.removeAttribute('aria-sort'); + let headerName = header.innerText; + const headerLabel = `${headerName}, sortable column, currently unsorted"`; + const headerButtonLabel = `Click to sort by ascending order.`; + header.setAttribute("aria-label", headerLabel); + header.querySelector('.usa-table__header__button').setAttribute("title", headerButtonLabel); + }; + + /** + * Generates search params for filtering and sorting + * @param {number} page - The current page number for pagination (starting with 1) + * @param {*} sortBy - The sort column option + * @param {*} order - The order of sorting {asc, desc} + * @param {string} searchTerm - The search term used to filter results for a specific keyword + * @param {*} status - The status filter applied {ready, dns_needed, etc} + * @param {string} portfolio - The portfolio id + */ + getSearchParams(page, sortBy, order, searchTerm, status, portfolio) { + let searchParams = new URLSearchParams( + { + "page": page, + "sort_by": sortBy, + "order": order, + "search_term": searchTerm, + } + ); + + let emailValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-email') : null; + let memberIdValue = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-id') : null; + let memberOnly = this.portfolioElement ? this.portfolioElement.getAttribute('data-member-only') : null; + + if (portfolio) + searchParams.append("portfolio", portfolio); + if (emailValue) + searchParams.append("email", emailValue); + if (memberIdValue) + searchParams.append("member_id", memberIdValue); + if (memberOnly) + searchParams.append("member_only", memberOnly); + if (status) + searchParams.append("status", status); + return searchParams; + } + + /** + * Gets the base URL of API requests + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + */ + getBaseUrl() { + throw new Error('getBaseUrl must be defined'); + } + + /** + * Calls "uswdsUnloadModals" to remove any existing modal element to make sure theres no unintended consequences + * from leftover event listeners + can be properly re-initialized + */ + unloadModals(){} + + /** + * Loads modals + sets up event listeners for the modal submit actions + * "Activates" the modals after the DOM updates + * Utilizes "uswdsInitializeModals" + * Adds click event listeners to each modal's submit button so we can handle a user's actions + * + * When the submit button is clicked: + * - Triggers the close button to reset modal classes + * - Determines if the page needs refreshing if the last item is deleted + * @param {number} page - The current page number for pagination + * @param {number} total - The total # of items on the current page + * @param {number} unfiltered_total - The total # of items across all pages + */ + loadModals(page, total, unfiltered_total) {} + + /** + * Allows us to customize the table display based on specific conditions and a user's permissions + * Dynamically manages the visibility set up of columns, adding/removing headers + * (ie if a domain request is deleteable, we include the kebab column or if a user has edit permissions + * for a member, they will also see the kebab column) + * @param {Object} dataObjects - Data which contains info on domain requests or a user's permission + * Currently returns a dictionary of either: + * - "needsAdditionalColumn": If a new column should be displayed + * - "UserPortfolioPermissionChoices": A user's portfolio permission choices + */ + customizeTable(dataObjects){ return {}; } + + /** + * Retrieves specific data objects + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + * Returns either: data.members, data.domains or data.domain_requests + * @param {Object} data - The full data set from which a subset of objects is extracted. + */ + getDataObjects(data) { + throw new Error('getDataObjects must be defined'); + } + + /** + * Creates + appends a row to a tbody element + * Tailored structure set up for each data object (domain, domain_request, member, etc) + * Placeholder function in a parent class - method should be implemented by child class for specifics + * Throws an error if called directly from the parent class + * Returns either: data.members, data.domains or data.domain_requests + * @param {Object} dataObject - The data used to populate the row content + * @param {HTMLElement} tbody - The table body to which the new row is appended to + * @param {Object} customTableOptions - Additional options for customizing row appearance (ie needsAdditionalColumn) + */ + addRow(dataObject, tbody, customTableOptions) { + throw new Error('addRow must be defined'); + } + + /** + * See function for more details + */ + initShowMoreButtons(){} + + /** + * Loads rows in the members list, as well as updates pagination around the members list + * based on the supplied attributes. + * @param {*} page - The page number of the results (starts with 1) + * @param {*} sortBy - The sort column option + * @param {*} order - The sort order {asc, desc} + * @param {*} scroll - The control for the scrollToElement functionality + * @param {*} searchTerm - The search term + * @param {*} portfolio - The portfolio id + */ + loadTable(page, sortBy = this.currentSortBy, order = this.currentOrder, scroll = this.scrollToTable, status = this.currentStatus, searchTerm =this.currentSearchTerm, portfolio = this.portfolioValue) { + // --------- SEARCH + let searchParams = this.getSearchParams(page, sortBy, order, searchTerm, status, portfolio); + + // --------- FETCH DATA + // fetch json of page of domains, given params + const baseUrlValue = this.getBaseUrl()?.innerHTML ?? null; + if (!baseUrlValue) return; + + let url = `${baseUrlValue}?${searchParams.toString()}` + fetch(url) + .then(response => response.json()) + .then(data => { + if (data.error) { + console.error('Error in AJAX call: ' + data.error); + return; + } + + // handle the display of proper messaging in the event that no members exist in the list or search returns no results + this.updateDisplay(data, this.tableWrapper, this.noTableWrapper, this.noSearchResultsWrapper, this.currentSearchTerm); + // identify the DOM element where the list of results will be inserted into the DOM + const tbody = this.tableWrapper.querySelector('tbody'); + tbody.innerHTML = ''; + + // remove any existing modal elements from the DOM so they can be properly re-initialized + // after the DOM content changes and there are new delete modal buttons added + this.unloadModals(); + + let dataObjects = this.getDataObjects(data); + let customTableOptions = this.customizeTable(data); + + dataObjects.forEach(dataObject => { + this.addRow(dataObject, tbody, customTableOptions); + }); + + this.initShowMoreButtons(); + + this.loadModals(data.page, data.total, data.unfiltered_total); + + // Do not scroll on first page load + if (scroll) + scrollToElement('class', this.sectionSelector); + this.scrollToTable = true; + + // update pagination + this.updatePagination( + data.page, + data.num_pages, + data.has_previous, + data.has_next, + data.total, + ); + this.currentSortBy = sortBy; + this.currentOrder = order; + this.currentSearchTerm = searchTerm; + }) + .catch(error => console.error('Error fetching objects:', error)); + } + + // Add event listeners to table headers for sorting + initializeTableHeaders() { + this.tableHeaders.forEach(header => { + header.addEventListener('click', () => { + const sortBy = header.getAttribute('data-sortable'); + let order = 'asc'; + // sort order will be ascending, unless the currently sorted column is ascending, and the user + // is selecting the same column to sort in descending order + if (sortBy === this.currentSortBy) { + order = this.currentOrder === 'asc' ? 'desc' : 'asc'; + } + // load the results with the updated sort + this.loadTable(1, sortBy, order); + }); + }); + } + + initializeSearchHandler() { + this.searchSubmit.addEventListener('click', (e) => { + e.preventDefault(); + this.currentSearchTerm = this.searchInput.value; + // If the search is blank, we match the resetSearch functionality + if (this.currentSearchTerm) { + showElement(this.resetSearchButton); + } else { + hideElement(this.resetSearchButton); + } + this.loadTable(1, 'id', 'asc'); + this.resetHeaders(); + }); + } + + initializeStatusToggleHandler() { + if (this.statusToggle) { + this.statusToggle.addEventListener('click', () => { + toggleCaret(this.statusToggle); + }); + } + } + + // Add event listeners to status filter checkboxes + initializeFilterCheckboxes() { + this.statusCheckboxes.forEach(checkbox => { + checkbox.addEventListener('change', () => { + const checkboxValue = checkbox.value; + + // Update currentStatus array based on checkbox state + if (checkbox.checked) { + this.currentStatus.push(checkboxValue); + } else { + const index = this.currentStatus.indexOf(checkboxValue); + if (index > -1) { + this.currentStatus.splice(index, 1); + } + } + + // Manage visibility of reset filters button + if (this.currentStatus.length == 0) { + hideElement(this.resetFiltersButton); + } else { + showElement(this.resetFiltersButton); + } + + // Disable the auto scroll + this.scrollToTable = false; + + // Call loadTable with updated status + this.loadTable(1, 'id', 'asc'); + this.resetHeaders(); + this.updateStatusIndicator(); + }); + }); + } + + // Reset UI and accessibility + resetHeaders() { + this.tableHeaders.forEach(header => { + // Unset sort UI in headers + this.unsetHeader(header); + }); + // Reset the announcement region + this.tableAnnouncementRegion.innerHTML = ''; + } + + resetSearch() { + this.searchInput.value = ''; + this.currentSearchTerm = ''; + hideElement(this.resetSearchButton); + this.loadTable(1, 'id', 'asc'); + this.resetHeaders(); + } + + initializeResetSearchButton() { + if (this.resetSearchButton) { + this.resetSearchButton.addEventListener('click', () => { + this.resetSearch(); + }); + } + } + + resetFilters() { + this.currentStatus = []; + this.statusCheckboxes.forEach(checkbox => { + checkbox.checked = false; + }); + hideElement(this.resetFiltersButton); + + // Disable the auto scroll + this.scrollToTable = false; + + this.loadTable(1, 'id', 'asc'); + this.resetHeaders(); + this.updateStatusIndicator(); + // No need to toggle close the filters. The focus shift will trigger that for us. + } + + initializeResetFiltersButton() { + if (this.resetFiltersButton) { + this.resetFiltersButton.addEventListener('click', () => { + this.resetFilters(); + }); + } + } + + updateStatusIndicator() { + this.statusIndicator.innerHTML = ''; + // Even if the element is empty, it'll mess up the flex layout unless we set display none + hideElement(this.statusIndicator); + if (this.currentStatus.length) + this.statusIndicator.innerHTML = '(' + this.currentStatus.length + ')'; + showElement(this.statusIndicator); + } + + closeFilters() { + if (this.statusToggle.getAttribute("aria-expanded") === "true") { + this.statusToggle.click(); + } + } + + initializeAccordionAccessibilityListeners() { + // Instead of managing the toggle/close on the filter buttons in all edge cases (user clicks on search, user clicks on ANOTHER filter, + // user clicks on main nav...) we add a listener and close the filters whenever the focus shifts out of the dropdown menu/filter button. + // NOTE: We may need to evolve this as we add more filters. + document.addEventListener('focusin', (event) => { + const accordion = document.querySelector('.usa-accordion--select'); + const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); + + if (accordionThatIsOpen && !accordion.contains(event.target)) { + this.closeFilters(); + } + }); + + // Close when user clicks outside + // NOTE: We may need to evolve this as we add more filters. + document.addEventListener('click', (event) => { + const accordion = document.querySelector('.usa-accordion--select'); + const accordionThatIsOpen = document.querySelector('.usa-button--filter[aria-expanded="true"]'); + + if (accordionThatIsOpen && !accordion.contains(event.target)) { + this.closeFilters(); + } + }); + } +} diff --git a/src/registrar/assets/src/js/getgov/table-domain-requests.js b/src/registrar/assets/src/js/getgov/table-domain-requests.js new file mode 100644 index 000000000..c005ed891 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/table-domain-requests.js @@ -0,0 +1,279 @@ +import { hideElement, showElement, getCsrfToken } from './helpers.js'; +import { uswdsInitializeModals, uswdsUnloadModals } from './helpers-uswds.js'; + +import { BaseTable, addModal, generateKebabHTML } from './table-base.js'; + + +const utcDateString = (dateString) => { + const date = new Date(dateString); + const utcYear = date.getUTCFullYear(); + const utcMonth = date.toLocaleString('en-US', { month: 'short', timeZone: 'UTC' }); + const utcDay = date.getUTCDate().toString().padStart(2, '0'); + let utcHours = date.getUTCHours(); + const utcMinutes = date.getUTCMinutes().toString().padStart(2, '0'); + + const ampm = utcHours >= 12 ? 'PM' : 'AM'; + utcHours = utcHours % 12 || 12; // Convert to 12-hour format, '0' hours should be '12' + + return `${utcMonth} ${utcDay}, ${utcYear}, ${utcHours}:${utcMinutes} ${ampm} UTC`; +}; + + +export class DomainRequestsTable extends BaseTable { + + constructor() { + super('domain-request'); + } + + getBaseUrl() { + return document.getElementById("get_domain_requests_json_url"); + } + + toggleExportButton(requests) { + const exportButton = document.getElementById('export-csv'); + if (exportButton) { + if (requests.length > 0) { + showElement(exportButton); + } else { + hideElement(exportButton); + } + } + } + + getDataObjects(data) { + return data.domain_requests; + } + unloadModals() { + uswdsUnloadModals(); + } + customizeTable(data) { + + // Manage "export as CSV" visibility for domain requests + this.toggleExportButton(data.domain_requests); + + let needsDeleteColumn = data.domain_requests.some(request => request.is_deletable); + + // Remove existing delete th and td if they exist + let existingDeleteTh = document.querySelector('.delete-header'); + if (!needsDeleteColumn) { + if (existingDeleteTh) + existingDeleteTh.remove(); + } else { + if (!existingDeleteTh) { + const delheader = document.createElement('th'); + delheader.setAttribute('scope', 'col'); + delheader.setAttribute('role', 'columnheader'); + delheader.setAttribute('class', 'delete-header width-5'); + delheader.innerHTML = ` + Delete Action`; + let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); + tableHeaderRow.appendChild(delheader); + } + } + return { 'needsAdditionalColumn': needsDeleteColumn }; + } + + addRow(dataObject, tbody, customTableOptions) { + const request = dataObject; + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const domainName = request.requested_domain ? request.requested_domain : `New domain request
(${utcDateString(request.created_at)})`; + const actionUrl = request.action_url; + const actionLabel = request.action_label; + const submissionDate = request.last_submitted_date ? new Date(request.last_submitted_date).toLocaleDateString('en-US', options) : `Not submitted`; + + // The markup for the delete function either be a simple trigger or a 3 dots menu with a hidden trigger (in the case of portfolio requests page) + // If the request is not deletable, use the following (hidden) span for ANDI screenreaders to indicate this state to the end user + let modalTrigger = ` + Domain request cannot be deleted now. Edit the request for more information.`; + + let markupCreatorRow = ''; + + if (this.portfolioValue) { + markupCreatorRow = ` + + ${request.creator ? request.creator : ''} + + ` + } + + if (request.is_deletable) { + // 1st path: Just a modal trigger in any screen size for non-org users + modalTrigger = ` + + Delete ${domainName} + ` + + // Request is deletable, modal and modalTrigger are built. Now check if we are on the portfolio requests page (by seeing if there is a portfolio value) and enhance the modalTrigger accordingly + if (this.portfolioValue) { + + // 2nd path: Just a modal trigger on mobile for org users or kebab + accordion with nested modal trigger on desktop for org users + modalTrigger = generateKebabHTML('delete-domain', request.id, 'Delete', domainName); + } + } + + const row = document.createElement('tr'); + row.innerHTML = ` + + ${domainName} + + + ${submissionDate} + + ${markupCreatorRow} + + ${request.status} + + + + + ${actionLabel} ${request.requested_domain ? request.requested_domain : 'New domain request'} + + + ${customTableOptions.needsAdditionalColumn ? ''+modalTrigger+'' : ''} + `; + tbody.appendChild(row); + if (request.is_deletable) DomainRequestsTable.addDomainRequestsModal(request.requested_domain, request.id, request.created_at, tbody); + } + + loadModals(page, total, unfiltered_total) { + // initialize modals immediately after the DOM content is updated + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + let pk = submitButton.getAttribute('data-pk'); + // Workaround: Close the modal to remove the USWDS UI local classes + closeButton.click(); + // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page + let pageToDisplay = page; + if (total == 1 && unfiltered_total > 1) { + pageToDisplay--; + } + + this.deleteDomainRequest(pk, pageToDisplay); + }); + }); + } + + /** + * Delete is actually a POST API that requires a csrf token. The token will be waiting for us in the template as a hidden input. + * @param {*} domainRequestPk - the identifier for the request that we're deleting + * @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page + */ + deleteDomainRequest(domainRequestPk, pageToDisplay) { + // Use to debug uswds modal issues + //console.log('deleteDomainRequest') + + // Get csrf token + const csrfToken = getCsrfToken(); + // Create FormData object and append the CSRF token + const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}&delete-domain-request=`; + + fetch(`/domain-request/${domainRequestPk}/delete`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRFToken': csrfToken, + }, + body: formData + }) + .then(response => { + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + // Update data and UI + this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); + }) + .catch(error => console.error('Error fetching domain requests:', error)); + } + + /** + * Modal that displays when deleting a domain request + * @param {string} requested_domain - The requested domain URL + * @param {string} id - The request's ID + * @param {string}} created_at - When the request was created at + * @param {HTMLElement} wrapper_element - The element to which the modal is appended + */ + static addDomainRequestsModal(requested_domain, id, created_at, wrapper_element) { + // If the request is deletable, create modal body and insert it. This is true for both requests and portfolio requests pages + let modalHeading = ''; + let modalDescription = ''; + + if (requested_domain) { + modalHeading = `Are you sure you want to delete ${requested_domain}?`; + modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; + } else { + if (created_at) { + modalHeading = 'Are you sure you want to delete this domain request?'; + modalDescription = `This will remove the domain request (created ${utcDateString(created_at)}) from the .gov registrar. This action cannot be undone`; + } else { + modalHeading = 'Are you sure you want to delete New domain request?'; + modalDescription = 'This will remove the domain request from the .gov registrar. This action cannot be undone.'; + } + } + + const modalSubmit = ` + + ` + + addModal(`toggle-delete-domain-${id}`, 'Are you sure you want to continue?', 'Domain will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); + + } +} + +export function initDomainRequestsTable() { + document.addEventListener('DOMContentLoaded', function() { + const domainRequestsSectionWrapper = document.getElementById('domain-requests'); + if (domainRequestsSectionWrapper) { + const domainRequestsTable = new DomainRequestsTable(); + if (domainRequestsTable.tableWrapper) { + domainRequestsTable.loadTable(1); + } + } + + document.addEventListener('focusin', function(event) { + closeOpenAccordions(event); + }); + + document.addEventListener('click', function(event) { + closeOpenAccordions(event); + }); + + function closeMoreActionMenu(accordionThatIsOpen) { + if (accordionThatIsOpen.getAttribute("aria-expanded") === "true") { + accordionThatIsOpen.click(); + } + } + + function closeOpenAccordions(event) { + const openAccordions = document.querySelectorAll('.usa-button--more-actions[aria-expanded="true"]'); + openAccordions.forEach((openAccordionButton) => { + // Find the corresponding accordion + const accordion = openAccordionButton.closest('.usa-accordion--more-actions'); + if (accordion && !accordion.contains(event.target)) { + // Close the accordion if the click is outside + closeMoreActionMenu(openAccordionButton); + } + }); + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/table-domains.js b/src/registrar/assets/src/js/getgov/table-domains.js new file mode 100644 index 000000000..20d9ef7de --- /dev/null +++ b/src/registrar/assets/src/js/getgov/table-domains.js @@ -0,0 +1,79 @@ +import { BaseTable } from './table-base.js'; + +export class DomainsTable extends BaseTable { + + constructor() { + super('domain'); + } + getBaseUrl() { + return document.getElementById("get_domains_json_url"); + } + getDataObjects(data) { + return data.domains; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const options = { year: 'numeric', month: 'short', day: 'numeric' }; + const expirationDate = domain.expiration_date ? new Date(domain.expiration_date) : null; + const expirationDateFormatted = expirationDate ? expirationDate.toLocaleDateString('en-US', options) : ''; + const expirationDateSortValue = expirationDate ? expirationDate.getTime() : ''; + const actionUrl = domain.action_url; + const suborganization = domain.domain_info__sub_organization ? domain.domain_info__sub_organization : '⎯'; + + const row = document.createElement('tr'); + + let markupForSuborganizationRow = ''; + + if (this.portfolioValue) { + markupForSuborganizationRow = ` + + ${suborganization} + + ` + } + row.innerHTML = ` + + ${domain.name} + + + ${expirationDateFormatted} + + + ${domain.state_display} + + + + + ${markupForSuborganizationRow} + + + + ${domain.action_label} ${domain.name} + + + `; + tbody.appendChild(row); + } +} + +export function initDomainsTable() { + document.addEventListener('DOMContentLoaded', function() { + const isDomainsPage = document.getElementById("domains") + if (isDomainsPage){ + const domainsTable = new DomainsTable(); + if (domainsTable.tableWrapper) { + // Initial load + domainsTable.loadTable(1); + } + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/table-member-domains.js b/src/registrar/assets/src/js/getgov/table-member-domains.js new file mode 100644 index 000000000..7d235f6e5 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/table-member-domains.js @@ -0,0 +1,40 @@ + +import { BaseTable } from './table-base.js'; + +export class MemberDomainsTable extends BaseTable { + + constructor() { + super('member-domain'); + this.currentSortBy = 'name'; + } + getBaseUrl() { + return document.getElementById("get_member_domains_json_url"); + } + getDataObjects(data) { + return data.domains; + } + addRow(dataObject, tbody, customTableOptions) { + const domain = dataObject; + const row = document.createElement('tr'); + row.innerHTML = ` + + ${domain.name} + + `; + tbody.appendChild(row); + } + +} + +export function initMemberDomainsTable() { + document.addEventListener('DOMContentLoaded', function() { + const isMemberDomainsPage = document.getElementById("member-domains") + if (isMemberDomainsPage){ + const memberDomainsTable = new MemberDomainsTable(); + if (memberDomainsTable.tableWrapper) { + // Initial load + memberDomainsTable.loadTable(1); + } + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/table-members.js b/src/registrar/assets/src/js/getgov/table-members.js new file mode 100644 index 000000000..0b311751d --- /dev/null +++ b/src/registrar/assets/src/js/getgov/table-members.js @@ -0,0 +1,462 @@ +import { hideElement, showElement, getCsrfToken } from './helpers.js'; +import { uswdsInitializeModals, uswdsUnloadModals } from './helpers-uswds.js'; +import { BaseTable, addModal, generateKebabHTML } from './table-base.js'; + +export class MembersTable extends BaseTable { + + constructor() { + super('member'); + } + + getBaseUrl() { + return document.getElementById("get_members_json_url"); + } + + // Abstract method (to be implemented in the child class) + getDataObjects(data) { + return data.members; + } + unloadModals() { + uswdsUnloadModals(); + } + loadModals(page, total, unfiltered_total) { + // initialize modals immediately after the DOM content is updated + uswdsInitializeModals(); + + // Now the DOM and modals are ready, add listeners to the submit buttons + const modals = document.querySelectorAll('.usa-modal__content'); + + modals.forEach(modal => { + const submitButton = modal.querySelector('.usa-modal__submit'); + const closeButton = modal.querySelector('.usa-modal__close'); + submitButton.addEventListener('click', () => { + let pk = submitButton.getAttribute('data-pk'); + // Close the modal to remove the USWDS UI local classes + closeButton.click(); + // If we're deleting the last item on a page that is not page 1, we'll need to refresh the display to the previous page + let pageToDisplay = page; + if (total == 1 && unfiltered_total > 1) { + pageToDisplay--; + } + + this.deleteMember(pk, pageToDisplay); + }); + }); + } + + customizeTable(data) { + // Get whether the logged in user has edit members permission + const hasEditPermission = this.portfolioElement ? this.portfolioElement.getAttribute('data-has-edit-permission')==='True' : null; + + let existingExtraActionsHeader = document.querySelector('.extra-actions-header'); + + if (hasEditPermission && !existingExtraActionsHeader) { + const extraActionsHeader = document.createElement('th'); + extraActionsHeader.setAttribute('id', 'extra-actions'); + extraActionsHeader.setAttribute('role', 'columnheader'); + extraActionsHeader.setAttribute('class', 'extra-actions-header width-5'); + extraActionsHeader.innerHTML = ` + Extra Actions`; + let tableHeaderRow = this.tableWrapper.querySelector('thead tr'); + tableHeaderRow.appendChild(extraActionsHeader); + } + return { + 'needsAdditionalColumn': hasEditPermission, + 'UserPortfolioPermissionChoices' : data.UserPortfolioPermissionChoices + }; + } + + addRow(dataObject, tbody, customTableOptions) { + const member = dataObject; + // member is based on either a UserPortfolioPermission or a PortfolioInvitation + // and also includes information from related domains; the 'id' of the org_member + // is the id of the UserPorfolioPermission or PortfolioInvitation, it is not a user id + // member.type is either invitedmember or member + const unique_id = member.type + member.id; // unique string for use in dom, this is + // not the id of the associated user + const member_delete_url = member.action_url + "/delete"; + const num_domains = member.domain_urls.length; + const last_active = this.handleLastActive(member.last_active); + let cancelInvitationButton = member.type === "invitedmember" ? "Cancel invitation" : "Remove member"; + const kebabHTML = customTableOptions.needsAdditionalColumn ? generateKebabHTML('remove-member', unique_id, cancelInvitationButton, `for ${member.name}`): ''; + + const row = document.createElement('tr'); + + let admin_tagHTML = ``; + if (member.is_admin) + admin_tagHTML = `Admin` + + // generate html blocks for domains and permissions for the member + let domainsHTML = this.generateDomainsHTML(num_domains, member.domain_names, member.domain_urls, member.action_url); + let permissionsHTML = this.generatePermissionsHTML(member.permissions, customTableOptions.UserPortfolioPermissionChoices); + + // domainsHTML block and permissionsHTML block need to be wrapped with hide/show toggle, Expand + let showMoreButton = ''; + const showMoreRow = document.createElement('tr'); + if (domainsHTML || permissionsHTML) { + showMoreButton = ` + + `; + + showMoreRow.innerHTML = `
${domainsHTML} ${permissionsHTML}
`; + showMoreRow.classList.add('show-more-content'); + showMoreRow.classList.add('display-none'); + showMoreRow.id = unique_id; + } + + row.innerHTML = ` + + ${member.member_display} ${admin_tagHTML} ${showMoreButton} + + + ${last_active.display_value} + + + + + ${member.action_label} ${member.name} + + + ${customTableOptions.needsAdditionalColumn ? ''+kebabHTML+'' : ''} + `; + tbody.appendChild(row); + if (domainsHTML || permissionsHTML) { + tbody.appendChild(showMoreRow); + } + // This easter egg is only for fixtures that dont have names as we are displaying their emails + // All prod users will have emails linked to their account + if (customTableOptions.needsAdditionalColumn) MembersTable.addMemberModal(num_domains, member.email || "Samwise Gamgee", member_delete_url, unique_id, row); + } + + /** + * Initializes "Show More" buttons on the page, enabling toggle functionality to show or hide content. + * + * The function finds elements with "Show More" buttons and sets up a click event listener to toggle the visibility + * of a corresponding content div. When clicked, the button updates its visual state (e.g., text/icon change), + * and the associated content is shown or hidden based on its current visibility status. + * + * @function initShowMoreButtons + */ + initShowMoreButtons() { + /** + * Toggles the visibility of a content section when the "Show More" button is clicked. + * Updates the button text/icon based on whether the content is shown or hidden. + * + * @param {HTMLElement} toggleButton - The button that toggles the content visibility. + * @param {HTMLElement} contentDiv - The content div whose visibility is toggled. + * @param {HTMLElement} buttonParentRow - The parent row element containing the button. + */ + function toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow) { + const spanElement = toggleButton.querySelector('span'); + const useElement = toggleButton.querySelector('use'); + if (contentDiv.classList.contains('display-none')) { + showElement(contentDiv); + spanElement.textContent = 'Close'; + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_less'); + buttonParentRow.classList.add('hide-td-borders'); + toggleButton.setAttribute('aria-label', 'Close additional information'); + } else { + hideElement(contentDiv); + spanElement.textContent = 'Expand'; + useElement.setAttribute('xlink:href', '/public/img/sprite.svg#expand_more'); + buttonParentRow.classList.remove('hide-td-borders'); + toggleButton.setAttribute('aria-label', 'Expand for additional information'); + } + } + + let toggleButtons = document.querySelectorAll('.usa-button--show-more-button'); + toggleButtons.forEach((toggleButton) => { + + // get contentDiv for element specified in data-for attribute of toggleButton + let dataFor = toggleButton.dataset.for; + let contentDiv = document.getElementById(dataFor); + let buttonParentRow = toggleButton.parentElement.parentElement; + if (contentDiv && contentDiv.tagName.toLowerCase() === 'tr' && contentDiv.classList.contains('show-more-content') && buttonParentRow && buttonParentRow.tagName.toLowerCase() === 'tr') { + toggleButton.addEventListener('click', function() { + toggleShowMoreButton(toggleButton, contentDiv, buttonParentRow); + }); + } else { + console.warn('Found a toggle button with no associated toggleable content or parent row'); + } + + }); + } + + /** + * Converts a given `last_active` value into a display value and a numeric sort value. + * The input can be a UTC date, the strings "Invited", "Invalid date", or null/undefined. + * + * @param {string} last_active - UTC date string or special status like "Invited" or "Invalid date". + * @returns {Object} - An object containing `display_value` (formatted date or status string) + * and `sort_value` (numeric value for sorting). + */ + handleLastActive(last_active) { + const invited = 'Invited'; + const invalid_date = 'Invalid date'; + const options = { year: 'numeric', month: 'long', day: 'numeric' }; // Date display format + + let display_value = invalid_date; // Default display value for invalid or null dates + let sort_value = -1; // Default sort value for invalid or null dates + + if (last_active === invited) { + // Handle "Invited" status: special case with 0 sort value + display_value = invited; + sort_value = 0; + } else if (last_active && last_active !== invalid_date) { + // Parse and format valid UTC date strings + const parsedDate = new Date(last_active); + + if (!isNaN(parsedDate.getTime())) { + // Valid date + display_value = parsedDate.toLocaleDateString('en-US', options); + sort_value = parsedDate.getTime(); // Use timestamp for sorting + } else { + console.error(`Error: Invalid date string provided: ${last_active}`); + } + } + + return { display_value, sort_value }; + } + + /** + * Generates HTML for the list of domains assigned to a member. + * + * @param {number} num_domains - The number of domains the member is assigned to. + * @param {Array} domain_names - An array of domain names. + * @param {Array} domain_urls - An array of corresponding domain URLs. + * @returns {string} - A string of HTML displaying the domains assigned to the member. + */ + generateDomainsHTML(num_domains, domain_names, domain_urls, action_url) { + // Initialize an empty string for the HTML + let domainsHTML = ''; + + // Only generate HTML if the member has one or more assigned domains + if (num_domains > 0) { + domainsHTML += "
"; + domainsHTML += "

Domains assigned

"; + domainsHTML += `

This member is assigned to ${num_domains} domains:

`; + domainsHTML += "
    "; + + // Display up to 6 domains with their URLs + for (let i = 0; i < num_domains && i < 6; i++) { + domainsHTML += `
  • ${domain_names[i]}
  • `; + } + + domainsHTML += "
"; + + // If there are more than 6 domains, display a "View assigned domains" link + if (num_domains >= 6) { + domainsHTML += `

View assigned domains

`; + } + + domainsHTML += "
"; + } + + return domainsHTML; + } + + /** + * The POST call for deleting a Member and which error or success message it should return + * and redirection if necessary + * + * @param {string} member_delete_url - The URL for deletion ie `${member_type}-${member_id}/delete`` + * @param {*} pageToDisplay - If we're deleting the last item on a page that is not page 1, we'll need to display the previous page + * Note: X-Request-With is used for security reasons to present CSRF attacks, the server checks that this header is present + * (consent via CORS) so it knows it's not from a random request attempt + */ + deleteMember(member_delete_url, pageToDisplay) { + // Get CSRF token + const csrfToken = getCsrfToken(); + // Create FormData object and append the CSRF token + const formData = `csrfmiddlewaretoken=${encodeURIComponent(csrfToken)}`; + + fetch(`${member_delete_url}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-Requested-With': 'XMLHttpRequest', + 'X-CSRFToken': csrfToken, + }, + body: formData + }) + .then(response => { + if (response.status === 200) { + response.json().then(data => { + if (data.success) { + this.addAlert("success", data.success); + } + this.loadTable(pageToDisplay, this.currentSortBy, this.currentOrder, this.scrollToTable, this.currentStatus, this.currentSearchTerm); + }); + } else { + response.json().then(data => { + if (data.error) { + // This should display the error given from backend for + // either only admin OR in progress requests + this.addAlert("error", data.error); + } else { + throw new Error(`Unexpected status: ${response.status}`); + } + }); + } + }) + .catch(error => { + console.error('Error deleting member:', error); + }); + } + + + /** + * Adds an alert message to the page with an alert class. + * + * @param {string} alertClass - {error, warning, info, success} + * @param {string} alertMessage - The text that will be displayed + * + */ + addAlert(alertClass, alertMessage) { + let toggleableAlertDiv = document.getElementById("toggleable-alert"); + this.resetAlerts(); + toggleableAlertDiv.classList.add(`usa-alert--${alertClass}`); + let alertParagraph = toggleableAlertDiv.querySelector(".usa-alert__text"); + alertParagraph.innerHTML = alertMessage + showElement(toggleableAlertDiv); + } + + /** + * Resets the reusable alert message + */ + resetAlerts() { + // Create a list of any alert that's leftover and remove + document.querySelectorAll(".usa-alert:not(#toggleable-alert)").forEach(alert => { + alert.remove(); + }); + let toggleableAlertDiv = document.getElementById("toggleable-alert"); + toggleableAlertDiv.classList.remove('usa-alert--error'); + toggleableAlertDiv.classList.remove('usa-alert--success'); + hideElement(toggleableAlertDiv); + } + + /** + * Generates an HTML string summarizing a user's additional permissions within a portfolio, + * based on the user's permissions and predefined permission choices. + * + * @param {Array} member_permissions - An array of permission strings that the member has. + * @param {Object} UserPortfolioPermissionChoices - An object containing predefined permission choice constants. + * Expected keys include: + * - VIEW_ALL_DOMAINS + * - VIEW_MANAGED_DOMAINS + * - EDIT_REQUESTS + * - VIEW_ALL_REQUESTS + * - EDIT_MEMBERS + * - VIEW_MEMBERS + * + * @returns {string} - A string of HTML representing the user's additional permissions. + * If the user has no specific permissions, it returns a default message + * indicating no additional permissions. + * + * Behavior: + * - The function checks the user's permissions (`member_permissions`) and generates + * corresponding HTML sections based on the permission choices defined in `UserPortfolioPermissionChoices`. + * - Permissions are categorized into domains, requests, and members: + * - Domains: Determines whether the user can view or manage all or assigned domains. + * - Requests: Differentiates between users who can edit requests, view all requests, or have no request privileges. + * - Members: Distinguishes between members who can manage or only view other members. + * - If no relevant permissions are found, the function returns a message stating that the user has no additional permissions. + * - The resulting HTML always includes a header "Additional permissions for this member" and appends the relevant permission descriptions. + */ + generatePermissionsHTML(member_permissions, UserPortfolioPermissionChoices) { + let permissionsHTML = ''; + + // Check domain-related permissions + if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS)) { + permissionsHTML += "

Domains: Can view all organization domains. Can manage domains they are assigned to and edit information about the domain (including DNS settings).

"; + } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MANAGED_DOMAINS)) { + permissionsHTML += "

Domains: Can manage domains they are assigned to and edit information about the domain (including DNS settings).

"; + } + + // Check request-related permissions + if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_REQUESTS)) { + permissionsHTML += "

Domain requests: Can view all organization domain requests. Can create domain requests and modify their own requests.

"; + } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_ALL_REQUESTS)) { + permissionsHTML += "

Domain requests (view-only): Can view all organization domain requests. Can't create or modify any domain requests.

"; + } + + // Check member-related permissions + if (member_permissions.includes(UserPortfolioPermissionChoices.EDIT_MEMBERS)) { + permissionsHTML += "

Members: Can manage members including inviting new members, removing current members, and assigning domains to members.

"; + } else if (member_permissions.includes(UserPortfolioPermissionChoices.VIEW_MEMBERS)) { + permissionsHTML += "

Members (view-only): Can view all organizational members. Can't manage any members.

"; + } + + // If no specific permissions are assigned, display a message indicating no additional permissions + if (!permissionsHTML) { + permissionsHTML += "

No additional permissions: There are no additional permissions for this member.

"; + } + + // Add a permissions header and wrap the entire output in a container + permissionsHTML = "

Additional permissions for this member

" + permissionsHTML + "
"; + + return permissionsHTML; + } + + /** + * Modal that displays when deleting a domain request + * @param {string} num_domains - Number of domain a user has within the org + * @param {string} member_email - The member's email + * @param {string} submit_delete_url - `${member_type}-${member_id}/delete` + * @param {HTMLElement} wrapper_element - The element to which the modal is appended + */ + static addMemberModal(num_domains, member_email, submit_delete_url, id, wrapper_element) { + let modalHeading = ''; + let modalDescription = ''; + + if (num_domains == 0){ + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `They will no longer be able to access this organization. + This action cannot be undone.`; + } else if (num_domains == 1) { + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `${member_email} currently manages ${num_domains} domain in the organization. + Removing them from the organization will remove all of their domains. They will no longer be able to + access this organization. This action cannot be undone.`; + } else if (num_domains > 1) { + modalHeading = `Are you sure you want to delete ${member_email}?`; + modalDescription = `${member_email} currently manages ${num_domains} domains in the organization. + Removing them from the organization will remove all of their domains. They will no longer be able to + access this organization. This action cannot be undone.`; + } + + const modalSubmit = ` + + ` + + addModal(`toggle-remove-member-${id}`, 'Are you sure you want to continue?', 'Member will be removed', modalHeading, modalDescription, modalSubmit, wrapper_element, true); + } +} + +export function initMembersTable() { + document.addEventListener('DOMContentLoaded', function() { + const isMembersPage = document.getElementById("members") + if (isMembersPage){ + const membersTable = new MembersTable(); + if (membersTable.tableWrapper) { + // Initial load + membersTable.loadTable(1); + } + } + }); +} diff --git a/src/registrar/assets/src/js/getgov/urbanization.js b/src/registrar/assets/src/js/getgov/urbanization.js new file mode 100644 index 000000000..ea38a31e7 --- /dev/null +++ b/src/registrar/assets/src/js/getgov/urbanization.js @@ -0,0 +1,28 @@ +function setupUrbanizationToggle(stateTerritoryField) { + var urbanizationField = document.getElementById('urbanization-field'); + + function toggleUrbanizationField() { + // Checking specifically for Puerto Rico only + if (stateTerritoryField.value === 'PR') { + urbanizationField.style.display = 'block'; + } else { + urbanizationField.style.display = 'none'; + } + } + + toggleUrbanizationField(); + + stateTerritoryField.addEventListener('change', toggleUrbanizationField); +} + +export function initializeUrbanizationToggle() { + document.addEventListener('DOMContentLoaded', function() { + let stateTerritoryField = document.querySelector('select[name="organization_contact-state_territory"]'); + + if (!stateTerritoryField) { + return; // Exit if the field not found + } + + setupUrbanizationToggle(stateTerritoryField); + }); +} diff --git a/src/registrar/assets/src/js/getgov/user-profile.js b/src/registrar/assets/src/js/getgov/user-profile.js new file mode 100644 index 000000000..ca45dc13c --- /dev/null +++ b/src/registrar/assets/src/js/getgov/user-profile.js @@ -0,0 +1,171 @@ +export function userProfileListener() { + const showConfirmationModalTrigger = document.querySelector('.show-confirmation-modal'); + if (showConfirmationModalTrigger) { + showConfirmationModalTrigger.click(); + } +} + +export function finishUserSetupListener() { + + function getInputField(fieldName){ + return document.querySelector(`#id_${fieldName}`) + } + + // Shows the hidden input field and hides the readonly one + function showInputFieldHideReadonlyField(fieldName, button) { + let inputField = getInputField(fieldName) + let readonlyField = document.querySelector(`#${fieldName}__edit-button-readonly`) + + readonlyField.classList.toggle('display-none'); + inputField.classList.toggle('display-none'); + + // Toggle the bold style on the grid row + let gridRow = button.closest(".grid-col-2").closest(".grid-row") + if (gridRow){ + gridRow.classList.toggle("bold-usa-label") + } + } + + function handleFullNameField(fieldName = "full_name") { + // Remove the display-none class from the nearest parent div + let nameFieldset = document.querySelector("#profile-name-group"); + if (nameFieldset){ + nameFieldset.classList.remove("display-none"); + } + + // Hide the "full_name" field + let inputField = getInputField(fieldName); + if (inputField) { + let inputFieldParentDiv = inputField.closest("div"); + if (inputFieldParentDiv) { + inputFieldParentDiv.classList.add("display-none"); + } + } + } + + function handleEditButtonClick(fieldName, button){ + button.addEventListener('click', function() { + // Lock the edit button while this operation occurs + button.disabled = true + + if (fieldName == "full_name"){ + handleFullNameField(); + }else { + showInputFieldHideReadonlyField(fieldName, button); + } + + // Hide the button itself + button.classList.add("display-none"); + + // Unlock after it completes + button.disabled = false + }); + } + + function setupListener(){ + document.querySelectorAll('[id$="__edit-button"]').forEach(function(button) { + // Get the "{field_name}" and "edit-button" + let fieldIdParts = button.id.split("__") + if (fieldIdParts && fieldIdParts.length > 0){ + let fieldName = fieldIdParts[0] + + // When the edit button is clicked, show the input field under it + handleEditButtonClick(fieldName, button); + + let editableFormGroup = button.parentElement.parentElement.parentElement; + if (editableFormGroup){ + let readonlyField = editableFormGroup.querySelector(".toggleable_input__readonly-field") + let inputField = document.getElementById(`id_${fieldName}`); + if (!inputField || !readonlyField) { + return; + } + + let inputFieldValue = inputField.value + if (inputFieldValue || fieldName == "full_name"){ + if (fieldName == "full_name"){ + let firstName = document.querySelector("#id_first_name"); + let middleName = document.querySelector("#id_middle_name"); + let lastName = document.querySelector("#id_last_name"); + if (firstName && lastName && firstName.value && lastName.value) { + let values = [firstName.value, middleName.value, lastName.value] + readonlyField.innerHTML = values.join(" "); + }else { + let fullNameField = document.querySelector('#full_name__edit-button-readonly'); + let svg = fullNameField.querySelector("svg use") + if (svg) { + const currentHref = svg.getAttribute('xlink:href'); + if (currentHref) { + const parts = currentHref.split('#'); + if (parts.length === 2) { + // Keep the path before '#' and replace the part after '#' with 'invalid' + const newHref = parts[0] + '#error'; + svg.setAttribute('xlink:href', newHref); + fullNameField.classList.add("toggleable_input__error") + label = fullNameField.querySelector(".toggleable_input__readonly-field") + label.innerHTML = "Unknown"; + } + } + } + } + + // Technically, the full_name field is optional, but we want to display it as required. + // This style is applied to readonly fields (gray text). This just removes it, as + // this is difficult to achieve otherwise by modifying the .readonly property. + if (readonlyField.classList.contains("text-base")) { + readonlyField.classList.remove("text-base") + } + }else { + readonlyField.innerHTML = inputFieldValue + } + } + } + } + }); + } + + function showInputOnErrorFields(){ + document.addEventListener('DOMContentLoaded', function() { + + // Get all input elements within the form + let form = document.querySelector("#finish-profile-setup-form"); + let inputs = form ? form.querySelectorAll("input") : null; + if (!inputs) { + return null; + } + + let fullNameButtonClicked = false + inputs.forEach(function(input) { + let fieldName = input.name; + let errorMessage = document.querySelector(`#id_${fieldName}__error-message`); + + // If no error message is found, do nothing + if (!fieldName || !errorMessage) { + return null; + } + + let editButton = document.querySelector(`#${fieldName}__edit-button`); + if (editButton){ + // Show the input field of the field that errored out + editButton.click(); + } + + // If either the full_name field errors out, + // or if any of its associated fields do - show all name related fields. + let nameFields = ["first_name", "middle_name", "last_name"]; + if (nameFields.includes(fieldName) && !fullNameButtonClicked){ + // Click the full name button if any of its related fields error out + fullNameButton = document.querySelector("#full_name__edit-button"); + if (fullNameButton) { + fullNameButton.click(); + fullNameButtonClicked = true; + } + } + }); + }); + }; + + setupListener(); + + // Show the input fields if an error exists + showInputOnErrorFields(); +} diff --git a/src/registrar/assets/sass/_theme/_accordions.scss b/src/registrar/assets/src/sass/_theme/_accordions.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_accordions.scss rename to src/registrar/assets/src/sass/_theme/_accordions.scss diff --git a/src/registrar/assets/sass/_theme/_admin.scss b/src/registrar/assets/src/sass/_theme/_admin.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_admin.scss rename to src/registrar/assets/src/sass/_theme/_admin.scss diff --git a/src/registrar/assets/sass/_theme/_alerts.scss b/src/registrar/assets/src/sass/_theme/_alerts.scss similarity index 89% rename from src/registrar/assets/sass/_theme/_alerts.scss rename to src/registrar/assets/src/sass/_theme/_alerts.scss index 3cfa768fe..9579cc057 100644 --- a/src/registrar/assets/sass/_theme/_alerts.scss +++ b/src/registrar/assets/src/sass/_theme/_alerts.scss @@ -47,7 +47,3 @@ background-color: color('base-darkest'); } } - -.usa-site-alert--hot-pink .usa-alert .usa-alert__body::before { - background-image: url('../img/usa-icons-bg/error.svg'); -} diff --git a/src/registrar/assets/sass/_theme/_base.scss b/src/registrar/assets/src/sass/_theme/_base.scss similarity index 97% rename from src/registrar/assets/sass/_theme/_base.scss rename to src/registrar/assets/src/sass/_theme/_base.scss index b919a5587..8d475270b 100644 --- a/src/registrar/assets/sass/_theme/_base.scss +++ b/src/registrar/assets/src/sass/_theme/_base.scss @@ -253,4 +253,11 @@ abbr[title] { .break-word { word-break: break-word; +} + +//Icon size adjustment used by buttons and form errors +.usa-icon.usa-icon--large { + margin: 0; + height: 1.5em; + width: 1.5em; } \ No newline at end of file diff --git a/src/registrar/assets/sass/_theme/_buttons.scss b/src/registrar/assets/src/sass/_theme/_buttons.scss similarity index 98% rename from src/registrar/assets/sass/_theme/_buttons.scss rename to src/registrar/assets/src/sass/_theme/_buttons.scss index c58242fe7..3342f5f7d 100644 --- a/src/registrar/assets/sass/_theme/_buttons.scss +++ b/src/registrar/assets/src/sass/_theme/_buttons.scss @@ -243,12 +243,6 @@ a .usa-icon, width: 1.3em; } -.usa-icon.usa-icon--big { - margin: 0; - height: 1.5em; - width: 1.5em; -} - // Red, for delete buttons // Used on: All delete buttons // Note: Can be simplified by adding text-secondary to delete anchors in tables diff --git a/src/registrar/assets/sass/_theme/_cisa_colors.scss b/src/registrar/assets/src/sass/_theme/_cisa_colors.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_cisa_colors.scss rename to src/registrar/assets/src/sass/_theme/_cisa_colors.scss diff --git a/src/registrar/assets/sass/_theme/_containers.scss b/src/registrar/assets/src/sass/_theme/_containers.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_containers.scss rename to src/registrar/assets/src/sass/_theme/_containers.scss diff --git a/src/registrar/assets/sass/_theme/_fieldsets.scss b/src/registrar/assets/src/sass/_theme/_fieldsets.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_fieldsets.scss rename to src/registrar/assets/src/sass/_theme/_fieldsets.scss diff --git a/src/registrar/assets/sass/_theme/_forms.scss b/src/registrar/assets/src/sass/_theme/_forms.scss similarity index 94% rename from src/registrar/assets/sass/_theme/_forms.scss rename to src/registrar/assets/src/sass/_theme/_forms.scss index 08e35b19f..9158de174 100644 --- a/src/registrar/assets/sass/_theme/_forms.scss +++ b/src/registrar/assets/src/sass/_theme/_forms.scss @@ -1,5 +1,6 @@ @use "uswds-core" as *; @use "cisa_colors" as *; +@use "typography" as *; .usa-form .usa-button { margin-top: units(3); @@ -69,9 +70,9 @@ legend.float-left-tablet + button.float-right-tablet { } .read-only-label { - font-size: size('body', 'sm'); + @extend .h4--sm-05; + font-weight: bold; color: color('primary-dark'); - margin-bottom: units(0.5); } .read-only-value { diff --git a/src/registrar/assets/sass/_theme/_header.scss b/src/registrar/assets/src/sass/_theme/_header.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_header.scss rename to src/registrar/assets/src/sass/_theme/_header.scss diff --git a/src/registrar/assets/sass/_theme/_identifier.scss b/src/registrar/assets/src/sass/_theme/_identifier.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_identifier.scss rename to src/registrar/assets/src/sass/_theme/_identifier.scss diff --git a/src/registrar/assets/sass/_theme/_lists.scss b/src/registrar/assets/src/sass/_theme/_lists.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_lists.scss rename to src/registrar/assets/src/sass/_theme/_lists.scss diff --git a/src/registrar/assets/sass/_theme/_pagination.scss b/src/registrar/assets/src/sass/_theme/_pagination.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_pagination.scss rename to src/registrar/assets/src/sass/_theme/_pagination.scss diff --git a/src/registrar/assets/sass/_theme/_register-form.scss b/src/registrar/assets/src/sass/_theme/_register-form.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_register-form.scss rename to src/registrar/assets/src/sass/_theme/_register-form.scss diff --git a/src/registrar/assets/sass/_theme/_search.scss b/src/registrar/assets/src/sass/_theme/_search.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_search.scss rename to src/registrar/assets/src/sass/_theme/_search.scss diff --git a/src/registrar/assets/sass/_theme/_sidenav.scss b/src/registrar/assets/src/sass/_theme/_sidenav.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_sidenav.scss rename to src/registrar/assets/src/sass/_theme/_sidenav.scss diff --git a/src/registrar/assets/sass/_theme/_tables.scss b/src/registrar/assets/src/sass/_theme/_tables.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_tables.scss rename to src/registrar/assets/src/sass/_theme/_tables.scss diff --git a/src/registrar/assets/sass/_theme/_tooltips.scss b/src/registrar/assets/src/sass/_theme/_tooltips.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_tooltips.scss rename to src/registrar/assets/src/sass/_theme/_tooltips.scss diff --git a/src/registrar/assets/sass/_theme/_typography.scss b/src/registrar/assets/src/sass/_theme/_typography.scss similarity index 83% rename from src/registrar/assets/sass/_theme/_typography.scss rename to src/registrar/assets/src/sass/_theme/_typography.scss index d815ef6dd..466b6f975 100644 --- a/src/registrar/assets/sass/_theme/_typography.scss +++ b/src/registrar/assets/src/sass/_theme/_typography.scss @@ -23,6 +23,13 @@ h2 { color: color('primary-darker'); } +.h4--sm-05 { + font-size: size('body', 'sm'); + font-weight: normal; + color: color('primary'); + margin-bottom: units(0.5); +} + // Normalize typography in forms .usa-form, .usa-form fieldset { diff --git a/src/registrar/assets/sass/_theme/_uswds-theme.scss b/src/registrar/assets/src/sass/_theme/_uswds-theme.scss similarity index 100% rename from src/registrar/assets/sass/_theme/_uswds-theme.scss rename to src/registrar/assets/src/sass/_theme/_uswds-theme.scss diff --git a/src/registrar/assets/sass/_theme/styles.scss b/src/registrar/assets/src/sass/_theme/styles.scss similarity index 100% rename from src/registrar/assets/sass/_theme/styles.scss rename to src/registrar/assets/src/sass/_theme/styles.scss diff --git a/src/registrar/context_processors.py b/src/registrar/context_processors.py index ae35a8865..c1547ad88 100644 --- a/src/registrar/context_processors.py +++ b/src/registrar/context_processors.py @@ -68,6 +68,7 @@ def portfolio_permissions(request): "has_organization_feature_flag": False, "has_organization_requests_flag": False, "has_organization_members_flag": False, + "is_portfolio_admin": False, } try: portfolio = request.session.get("portfolio") @@ -88,6 +89,7 @@ def portfolio_permissions(request): "has_organization_feature_flag": True, "has_organization_requests_flag": request.user.has_organization_requests_flag(), "has_organization_members_flag": request.user.has_organization_members_flag(), + "is_portfolio_admin": request.user.is_portfolio_admin(portfolio), } return portfolio_context diff --git a/src/registrar/forms/domain_request_wizard.py b/src/registrar/forms/domain_request_wizard.py index da82cb831..e55c40858 100644 --- a/src/registrar/forms/domain_request_wizard.py +++ b/src/registrar/forms/domain_request_wizard.py @@ -35,7 +35,7 @@ class RequestingEntityForm(RegistrarForm): # If this selection is made on the form (tracked by js), then it will toggle the form value of this. # In other words, this essentially tracks if the suborganization field == "Other". # "Other" is just an imaginary value that is otherwise invalid. - # Note the logic in `def clean` and `handleRequestingEntityFieldset` in get-gov.js + # Note the logic in `def clean` and `handleRequestingEntityFieldset` in getgov.min.js is_requesting_new_suborganization = forms.BooleanField(required=False, widget=forms.HiddenInput()) sub_organization = forms.ModelChoiceField( diff --git a/src/registrar/models/user.py b/src/registrar/models/user.py index 2d65aa02e..6c9c37c92 100644 --- a/src/registrar/models/user.py +++ b/src/registrar/models/user.py @@ -258,6 +258,9 @@ class User(AbstractUser): def has_edit_suborganization_portfolio_permission(self, portfolio): return self._has_portfolio_permission(portfolio, UserPortfolioPermissionChoices.EDIT_SUBORGANIZATION) + def is_portfolio_admin(self, portfolio): + return "Admin" in self.portfolio_role_summary(portfolio) + def get_first_portfolio(self): permission = self.portfolio_permissions.first() if permission: diff --git a/src/registrar/templates/admin/base_site.html b/src/registrar/templates/admin/base_site.html index 5ca5edffc..b80917bb2 100644 --- a/src/registrar/templates/admin/base_site.html +++ b/src/registrar/templates/admin/base_site.html @@ -21,7 +21,7 @@ - + {% endblock %} diff --git a/src/registrar/templates/base.html b/src/registrar/templates/base.html index b8e78bcd3..de257f52c 100644 --- a/src/registrar/templates/base.html +++ b/src/registrar/templates/base.html @@ -47,7 +47,7 @@ - + {% endblock %} {% block canonical %} diff --git a/src/registrar/templates/domain_add_user.html b/src/registrar/templates/domain_add_user.html index fa3f8e821..1429127e6 100644 --- a/src/registrar/templates/domain_add_user.html +++ b/src/registrar/templates/domain_add_user.html @@ -5,6 +5,25 @@ {% block domain_content %} {% block breadcrumb %} + {% if portfolio %} + + + {% else %} {% url 'domain-users' pk=domain.id as url %} + {% endif %} {% endblock breadcrumb %}

Add a domain manager

{% if has_organization_feature_flag %} diff --git a/src/registrar/templates/domain_detail.html b/src/registrar/templates/domain_detail.html index 96ec4c5b6..add7ca725 100644 --- a/src/registrar/templates/domain_detail.html +++ b/src/registrar/templates/domain_detail.html @@ -3,6 +3,22 @@ {% load custom_filters %} {% block domain_content %} + {% block breadcrumb %} + {% if portfolio %} + + + {% endif %} + {% endblock breadcrumb %} + {{ block.super }}

{{ domain.name }}

@@ -74,13 +90,17 @@ {% include "includes/summary_item.html" with title='DNSSEC' value='Not Enabled' edit_link=url editable=is_editable %} {% endif %} - {% if portfolio and has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %} - {% url 'domain-suborganization' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %} + {% if portfolio %} + {% if has_any_domains_portfolio_permission and has_edit_suborganization_portfolio_permission %} + {% url 'domain-suborganization' pk=domain.id as url %} + {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_edit_suborganization_portfolio_permission %} + {% elif has_any_domains_portfolio_permission and has_view_suborganization_portfolio_permission %} + {% url 'domain-suborganization' pk=domain.id as url %} + {% include "includes/summary_item.html" with title='Suborganization' value=domain.domain_info.sub_organization edit_link=url editable=is_editable|and:has_view_suborganization_portfolio_permission view_button=True %} + {% endif %} {% else %} {% url 'domain-org-name-address' pk=domain.id as url %} {% include "includes/summary_item.html" with title='Organization' value=domain.domain_info address='true' edit_link=url editable=is_editable %} - {% url 'domain-senior-official' pk=domain.id as url %} {% include "includes/summary_item.html" with title='Senior official' value=domain.domain_info.senior_official contact='true' edit_link=url editable=is_editable %} {% endif %} @@ -92,7 +112,11 @@ {% include "includes/summary_item.html" with title='Security email' value='None provided' edit_link=url editable=is_editable %} {% endif %} {% url 'domain-users' pk=domain.id as url %} - {% include "includes/summary_item.html" with title='Domain managers' users='true' list=True value=domain.permissions.all edit_link=url editable=is_editable %} + {% if portfolio %} + {% include "includes/summary_item.html" with title='Domain managers' domain_permissions=True value=domain edit_link=url editable=is_editable %} + {% else %} + {% include "includes/summary_item.html" with title='Domain managers' list=True users=True value=domain.permissions.all edit_link=url editable=is_editable %} + {% endif %}
{% endblock %} {# domain_content #} diff --git a/src/registrar/templates/domain_dns.html b/src/registrar/templates/domain_dns.html index 291319a59..9a2070c64 100644 --- a/src/registrar/templates/domain_dns.html +++ b/src/registrar/templates/domain_dns.html @@ -4,6 +4,24 @@ {% block title %}DNS | {{ domain.name }} | {% endblock %} {% block domain_content %} + {% block breadcrumb %} + {% if portfolio %} + + + {% endif %} + {% endblock breadcrumb %}

DNS

diff --git a/src/registrar/templates/domain_dnssec.html b/src/registrar/templates/domain_dnssec.html index 7742a329b..cfec053c2 100644 --- a/src/registrar/templates/domain_dnssec.html +++ b/src/registrar/templates/domain_dnssec.html @@ -5,6 +5,28 @@ {% block domain_content %} + {% block breadcrumb %} + {% if portfolio %} + + + {% endif %} + {% endblock breadcrumb %} +

DNSSEC

DNSSEC, or DNS Security Extensions, is an additional security layer to protect your website. Enabling DNSSEC ensures that when someone visits your domain, they can be certain that it’s connecting to the correct server, preventing potential hijacking or tampering with your domain's records.

diff --git a/src/registrar/templates/domain_dsdata.html b/src/registrar/templates/domain_dsdata.html index ba742ab09..0f60235e1 100644 --- a/src/registrar/templates/domain_dsdata.html +++ b/src/registrar/templates/domain_dsdata.html @@ -4,6 +4,32 @@ {% block title %}DS data | {{ domain.name }} | {% endblock %} {% block domain_content %} + + {% block breadcrumb %} + {% if portfolio %} + + + {% endif %} + {% endblock breadcrumb %} + {% if domain.dnssecdata is None %}
diff --git a/src/registrar/templates/domain_nameservers.html b/src/registrar/templates/domain_nameservers.html index cc1fc0164..a5fd171a2 100644 --- a/src/registrar/templates/domain_nameservers.html +++ b/src/registrar/templates/domain_nameservers.html @@ -4,6 +4,28 @@ {% block title %}DNS name servers | {{ domain.name }} | {% endblock %} {% block domain_content %} + {% block breadcrumb %} + {% if portfolio %} + + + {% endif %} + {% endblock breadcrumb %} + {# this is right after the messages block in the parent template #} {% for form in formset %} {% include "includes/form_errors.html" with form=form %} diff --git a/src/registrar/templates/domain_request_dotgov_domain.html b/src/registrar/templates/domain_request_dotgov_domain.html index 18e04f305..6c62c6497 100644 --- a/src/registrar/templates/domain_request_dotgov_domain.html +++ b/src/registrar/templates/domain_request_dotgov_domain.html @@ -49,7 +49,7 @@

After you enter your domain, we’ll make sure it’s available and that it meets some of our naming requirements. If your domain passes these initial checks, we’ll verify that it meets all our requirements after you complete the rest of this form.

{% with attr_aria_describedby="domain_instructions domain_instructions2" %} - {# attr_validate / validate="domain" invokes code in get-gov.js #} + {# attr_validate / validate="domain" invokes code in getgov.min.js #} {% with append_gov=True attr_validate="domain" add_label_class="usa-sr-only" %} {% input_with_errors forms.0.requested_domain %} {% endwith %} diff --git a/src/registrar/templates/domain_request_org_contact.html b/src/registrar/templates/domain_request_org_contact.html index 44b404bbf..d39fb9f78 100644 --- a/src/registrar/templates/domain_request_org_contact.html +++ b/src/registrar/templates/domain_request_org_contact.html @@ -44,5 +44,5 @@ {% endblock %} - + diff --git a/src/registrar/templates/domain_security_email.html b/src/registrar/templates/domain_security_email.html index e1755f85e..f5a58eb5d 100644 --- a/src/registrar/templates/domain_security_email.html +++ b/src/registrar/templates/domain_security_email.html @@ -4,6 +4,25 @@ {% block title %}Security email | {{ domain.name }} | {% endblock %} {% block domain_content %} + {% block breadcrumb %} + {% if portfolio %} + + + {% endif %} + {% endblock breadcrumb %} + {% include "includes/form_errors.html" with form=form %}

Security email

diff --git a/src/registrar/templates/domain_suborganization.html b/src/registrar/templates/domain_suborganization.html index 67726e9d5..648563d58 100644 --- a/src/registrar/templates/domain_suborganization.html +++ b/src/registrar/templates/domain_suborganization.html @@ -4,9 +4,30 @@ {% block title %}Suborganization{% if suborganization_name %} | suborganization_name{% endif %} | {% endblock %} {% block domain_content %} + + {% block breadcrumb %} + {% if portfolio %} + + + {% endif %} + {% endblock breadcrumb %} + {# this is right after the messages block in the parent template #} {% include "includes/form_errors.html" with form=form %} +

Suborganization

diff --git a/src/registrar/templates/domain_users.html b/src/registrar/templates/domain_users.html index b8a622455..af292d9d5 100644 --- a/src/registrar/templates/domain_users.html +++ b/src/registrar/templates/domain_users.html @@ -4,6 +4,25 @@ {% block title %}Domain managers | {{ domain.name }} | {% endblock %} {% block domain_content %} + {% block breadcrumb %} + {% if portfolio %} + +

+ {% endif %} + {% endblock breadcrumb %} +

Domain managers

{% comment %}Copy below differs depending on whether view is in portfolio mode.{% endcomment %} diff --git a/src/registrar/templates/includes/domain_requests_table.html b/src/registrar/templates/includes/domain_requests_table.html index 5142131af..8a919e795 100644 --- a/src/registrar/templates/includes/domain_requests_table.html +++ b/src/registrar/templates/includes/domain_requests_table.html @@ -55,7 +55,7 @@
- diff --git a/src/registrar/templates/includes/domains_table.html b/src/registrar/templates/includes/domains_table.html index 0a04af8ca..42b4a186d 100644 --- a/src/registrar/templates/includes/domains_table.html +++ b/src/registrar/templates/includes/domains_table.html @@ -45,7 +45,7 @@
- diff --git a/src/registrar/templates/includes/input_with_errors.html b/src/registrar/templates/includes/input_with_errors.html index d1e53968e..31900ac57 100644 --- a/src/registrar/templates/includes/input_with_errors.html +++ b/src/registrar/templates/includes/input_with_errors.html @@ -52,9 +52,12 @@ error messages, if necessary. {% if field.errors %}
{% for error in field.errors %} - - {{ error }} - + {% endfor %}
{% endif %} diff --git a/src/registrar/templates/includes/members_table.html b/src/registrar/templates/includes/members_table.html index 6733c8d95..d141dbaa6 100644 --- a/src/registrar/templates/includes/members_table.html +++ b/src/registrar/templates/includes/members_table.html @@ -39,7 +39,7 @@
- diff --git a/src/registrar/templates/includes/summary_item.html b/src/registrar/templates/includes/summary_item.html index 0600d7ea7..15cc0f67f 100644 --- a/src/registrar/templates/includes/summary_item.html +++ b/src/registrar/templates/includes/summary_item.html @@ -106,6 +106,26 @@ {% endfor %} {% endif %} + {% elif domain_permissions %} + {% if value.permissions.all %} + {% if value.permissions|length == 1 %} +

{{ value.permissions.0.user.email }}

+ {% else %} +
    + {% for item in value.permissions.all %} +
  • {{ item.user.email }}
  • + {% endfor %} +
+ {% endif %} + {% endif %} + {% if value.invitations.all %} +

Invited domain managers

+
    + {% for item in value.invitations.all %} +
  • {{ item.email }}
  • + {% endfor %} +
+ {% endif %} {% else %}

{% if value %} diff --git a/src/registrar/tests/test_admin.py b/src/registrar/tests/test_admin.py index 2677462df..84b85e412 100644 --- a/src/registrar/tests/test_admin.py +++ b/src/registrar/tests/test_admin.py @@ -654,7 +654,9 @@ class TestDomainInformationAdmin(TestCase): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "copy-to-clipboard", count=3) + # We expect 3 in the form + 2 from the js module copy-to-clipboard.js + # that gets pulled in the test in django.contrib.staticfiles.finders.FileSystemFinder + self.assertContains(response, "copy-to-clipboard", count=5) # cleanup this test domain_info.delete() diff --git a/src/registrar/tests/test_admin_request.py b/src/registrar/tests/test_admin_request.py index 9244fffcd..a903188f3 100644 --- a/src/registrar/tests/test_admin_request.py +++ b/src/registrar/tests/test_admin_request.py @@ -1526,7 +1526,9 @@ class TestDomainRequestAdmin(MockEppLib): self.test_helper.assert_response_contains_distinct_values(response, expected_other_employees_fields) # Test for the copy link - self.assertContains(response, "copy-to-clipboard", count=5) + # We expect 5 in the form + 2 from the js module copy-to-clipboard.js + # that gets pulled in the test in django.contrib.staticfiles.finders.FileSystemFinder + self.assertContains(response, "copy-to-clipboard", count=7) # Test that Creator counts display properly self.assertNotContains(response, "Approved domains") diff --git a/src/registrar/tests/test_models.py b/src/registrar/tests/test_models.py index 0c1bdec2a..46604a44a 100644 --- a/src/registrar/tests/test_models.py +++ b/src/registrar/tests/test_models.py @@ -824,6 +824,15 @@ class TestUser(TestCase): cm.exception.message, "When portfolio roles or additional permissions are assigned, portfolio is required." ) + @less_console_noise_decorator + def test_user_with_admin_portfolio_role(self): + portfolio, _ = Portfolio.objects.get_or_create(creator=self.user, organization_name="Hotel California") + self.assertFalse(self.user.is_portfolio_admin(portfolio)) + UserPortfolioPermission.objects.get_or_create( + portfolio=portfolio, user=self.user, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + self.assertTrue(self.user.is_portfolio_admin(portfolio)) + @less_console_noise_decorator def test_get_active_requests_count_in_portfolio_returns_zero_if_no_portfolio(self): # There is no portfolio referenced in session so should return 0 diff --git a/src/registrar/tests/test_views_domain.py b/src/registrar/tests/test_views_domain.py index 678d5be82..25e8b0fb6 100644 --- a/src/registrar/tests/test_views_domain.py +++ b/src/registrar/tests/test_views_domain.py @@ -6,7 +6,7 @@ from django.urls import reverse from django.contrib.auth import get_user_model from waffle.testutils import override_flag from api.tests.common import less_console_noise_decorator -from registrar.models.utility.portfolio_helper import UserPortfolioRoleChoices +from registrar.models.utility.portfolio_helper import UserPortfolioPermissionChoices, UserPortfolioRoleChoices from .common import MockEppLib, MockSESClient, create_user # type: ignore from django_webtest import WebTest # type: ignore import boto3_mocking # type: ignore @@ -142,6 +142,7 @@ class TestWithDomainPermissions(TestWithUser): def tearDown(self): try: UserDomainRole.objects.all().delete() + DomainInvitation.objects.all().delete() if hasattr(self.domain, "contacts"): self.domain.contacts.all().delete() DomainRequest.objects.all().delete() @@ -341,7 +342,7 @@ class TestDomainDetail(TestDomainOverview): detail_page = self.client.get(reverse("domain", kwargs={"pk": self.domain.id})) self.assertNotContains( - detail_page, "To manage information for this domain, you must add yourself as a domain manager." + detail_page, "If you need to make updates, contact one of the listed domain managers." ) @less_console_noise_decorator @@ -363,7 +364,12 @@ class TestDomainDetail(TestDomainOverview): DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio) UserPortfolioPermission.objects.get_or_create( - user=user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + user=user, + portfolio=portfolio, + roles=[UserPortfolioRoleChoices.ORGANIZATION_MEMBER], + additional_permissions=[ + UserPortfolioPermissionChoices.VIEW_ALL_DOMAINS, + ], ) user.refresh_from_db() self.client.force_login(user) @@ -377,6 +383,45 @@ class TestDomainDetail(TestDomainOverview): ) # Check that user does not have option to Edit domain self.assertNotContains(detail_page, "Edit") + # Check that invited domain manager section not displayed when no invited domain managers + self.assertNotContains(detail_page, "Invited domain managers") + + @less_console_noise_decorator + @override_flag("organization_feature", active=True) + def test_domain_readonly_on_detail_page_for_org_admin_not_manager(self): + """Test that a domain, which is part of a portfolio, but for which the user is not a domain manager, + properly displays read only""" + + portfolio, _ = Portfolio.objects.get_or_create(organization_name="Test org", creator=self.user) + # need to create a different user than self.user because the user needs permission assignments + user = get_user_model().objects.create( + first_name="Test", + last_name="User", + email="bogus@example.gov", + phone="8003111234", + title="test title", + ) + domain, _ = Domain.objects.get_or_create(name="bogusdomain.gov") + DomainInformation.objects.get_or_create(creator=user, domain=domain, portfolio=portfolio) + + UserPortfolioPermission.objects.get_or_create( + user=user, portfolio=portfolio, roles=[UserPortfolioRoleChoices.ORGANIZATION_ADMIN] + ) + # add a domain invitation + DomainInvitation.objects.get_or_create(email="invited@example.com", domain=domain) + user.refresh_from_db() + self.client.force_login(user) + detail_page = self.client.get(f"/domain/{domain.id}") + # Check that alert message displays properly + self.assertContains( + detail_page, + "If you need to make updates, contact one of the listed domain managers.", + ) + # Check that user does not have option to Edit domain + self.assertNotContains(detail_page, "Edit") + # Check that invited domain manager is displayed + self.assertContains(detail_page, "Invited domain managers") + self.assertContains(detail_page, "invited@example.com") class TestDomainManagers(TestDomainOverview): diff --git a/src/run_node_watch.sh b/src/run_node_watch.sh index b793f229c..c45aa72f1 100755 --- a/src/run_node_watch.sh +++ b/src/run_node_watch.sh @@ -12,4 +12,4 @@ else npx gulp init npx gulp compile fi -npx gulp watch +npx gulp watchAll diff --git a/src/zap.conf b/src/zap.conf index 1f0548f2d..7b878fb90 100644 --- a/src/zap.conf +++ b/src/zap.conf @@ -29,8 +29,8 @@ 10027 OUTOFSCOPE http://app:8080/public/js/uswds.min.js # UNCLEAR WHY THIS ONE IS FAILING. Giving 404 error. 10027 OUTOFSCOPE http://app:8080/public/js/uswds-init.min.js -# get-gov.js contains suspicious word "from" as in `Array.from()` -10027 OUTOFSCOPE http://app:8080/public/js/get-gov.js +# getgov.min.js contains suspicious word "from" as in `Array.from()` +10027 OUTOFSCOPE http://app:8080/public/js/getgov.min.js # Ignores suspicious word "TODO" 10027 OUTOFSCOPE http://app:8080.*$ 10028 FAIL (Open Redirect - Passive/beta)