diff --git a/.eslintrc.js b/.eslintrc.js index 237b4781c..ced39f8d4 100755 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-extraneous-dependencies const { getBaseConfig } = require('@openedx/frontend-build'); const config = getBaseConfig('eslint'); diff --git a/docs/template/edx/publish.js b/docs/template/edx/publish.js index 4c390f56c..3c684cfbf 100644 --- a/docs/template/edx/publish.js +++ b/docs/template/edx/publish.js @@ -629,7 +629,7 @@ exports.publish = (memberData, opts, tutorials) => { generateSourceFiles(sourceFiles, opts.encoding); } - // if (members.globals.length) { generate('Global', [{kind: 'globalobj'}], globalUrl); } + if (members.globals.length) { generate('Global', [{kind: 'globalobj'}], globalUrl); } // index page displays information from package.json and lists files files = find({kind: 'file'}); diff --git a/env.config.js b/env.config.js index f4585f66d..28aa85c0b 100644 --- a/env.config.js +++ b/env.config.js @@ -1,8 +1,41 @@ -// NOTE: This file is used by the example app. frontend-build expects the file +/* eslint-disable no-console */ + +// NOTE: This file is used by the example app. frontend-build expects the file // to be in the root of the repository. This is not used by the actual frontend-platform library. -// Also note that in an actual application this file would be added to .gitignore. +// Also note that in an actual application, this file would be added to .gitignore. const config = { JS_FILE_VAR: 'JS_FILE_VAR_VALUE_FOR_EXAMPLE_APP', + componentPropOverrides: { + targets: { + example: { + 'data-dd-privacy': 'mask', // Custom `data-*` attribute (e.g., Datadog) + 'data-hj-suppress': '', // Custom `data-*` attribute (e.g., Hotjar) + className: 'fs-mask', // Custom `className` attribute (e.g., Fullstory) + onClick: (e) => { // Custom `onClick` attribute + console.log('[env.config] onClick event for example', e); + }, + style: { // Custom `style` attribute + background: 'blue', + color: 'white', + }, + }, + example2: { + 'data-dd-privacy': 'mask', // Custom `data-*` attribute (e.g., Datadog) + 'data-hj-suppress': '', // Custom `data-*` attribute (e.g., Hotjar) + className: 'fs-mask', // Custom `className` attribute (e.g., Fullstory) + onClick: (e) => { // Custom `onClick` attribute + console.log('[env.config] onClick event for example2', e); + }, + style: { // Custom `style` attribute + background: 'blue', + color: 'white', + }, + }, + example3: { + 'data-dd-action-name': 'example name', // Custom `data-*` attribute (e.g., Datadog) + }, + }, + }, }; export default config; diff --git a/example/ComponentPropOverridesPage.jsx b/example/ComponentPropOverridesPage.jsx new file mode 100644 index 000000000..db12299a4 --- /dev/null +++ b/example/ComponentPropOverridesPage.jsx @@ -0,0 +1,152 @@ +import { + forwardRef, useContext, useEffect, useRef, useState, +} from 'react'; +import PropTypes from 'prop-types'; + +import { AppContext, useComponentPropOverrides, withComponentPropOverrides } from '@edx/frontend-platform/react'; + +// Example via `useComponentPropOverrides` (hook) +const ExampleComponentWithDefaultPropOverrides = forwardRef(({ children, ...rest }, ref) => { + const propOverrides = useComponentPropOverrides('example', rest); + return {children}; +}); +ExampleComponentWithDefaultPropOverrides.displayName = 'ExampleComponentWithDefaultPropOverrides'; +ExampleComponentWithDefaultPropOverrides.propTypes = { + children: PropTypes.node.isRequired, +}; + +const ExampleComponentWithAllowedPropOverrides = forwardRef(({ children, ...rest }, ref) => { + const propOverrides = useComponentPropOverrides('example2', rest, { + allowedPropNames: ['className', 'style', 'onClick'], + }); + return {children}; +}); +ExampleComponentWithAllowedPropOverrides.displayName = 'ExampleComponentWithAllowedPropOverrides'; +ExampleComponentWithAllowedPropOverrides.propTypes = { + children: PropTypes.node.isRequired, +}; + +// Example via `withComponentPropOverrides` (HOC) +const ExampleComponent = forwardRef(({ children, ...rest }, ref) => ( + {children} +)); +ExampleComponent.displayName = 'ExampleComponent'; +ExampleComponent.propTypes = { + children: PropTypes.node.isRequired, +}; +const ExampleComponentWithPropOverrides3 = withComponentPropOverrides('example3')(ExampleComponent); + +function jsonStringify(obj) { + const replacer = (key, value) => { + if (typeof value === 'function') { + return '[Function]'; + } + return value; + }; + return JSON.stringify(obj, replacer, 2); +} + +function useExample() { + const ref = useRef(null); + const [node, setNode] = useState(null); + + useEffect(() => { + if (ref.current) { + setNode(ref.current.outerHTML); + } + }, []); + + return { + ref, + node, + }; +} + +export default function ComponentPropOverridesPage() { + const { config } = useContext(AppContext); + const firstExample = useExample(); + const secondExample = useExample(); + const thirdExample = useExample(); + + const { componentPropOverrides } = config; + + return ( +
+

Example usage of componentPropOverrides from configuration

+ +

Current configuration

+ {componentPropOverrides ? ( +
+          {jsonStringify({ componentPropOverrides })}
+        
+ ) : ( +

+ No componentPropOverrides configuration found. Consider updating this + application's env.config.js to configure any custom props. +

+ )} + +

Examples

+

+ The following examples below demonstrate + using useComponentPropOverrides and withComponentPropOverrides to + extend any component's base props based on the application's configuration. Inspect the DOM + elements for the rendered example components below to observe the configured attributes/values. +

+ + {/* Example 1 (useComponentPropOverrides) */} +

useComponentPropOverrides (hook)

+

Default support prop overrides

+

+ By default, only data-* attributes and className props are + supported; other props will be ignored. You may opt-in to non-default prop + overrides by extending the allowedPropNames option. +

+

+ console.log('ExampleComponentWithPropOverrides clicked', e)} + style={{ borderBottom: '4px solid red' }} + className="example-class" + > + Example 1 + +

+ Result:{' '} +
+        {firstExample.node}
+      
+ + {/* Example 2 (useComponentPropOverrides) */} +

Opt-in to specific prop overrides with allowedPropNames

+

+ console.log('ExampleComponentWithPropOverrides clicked', e)} + style={{ borderBottom: '4px solid red' }} + className="example-class" + > + Example 2 + +

+ Result:{' '} +
+        {secondExample.node}
+      
+ + {/* Example 3 (withComponentPropOverrides) */} +

withComponentPropOverrides (HOC)

+

+ + Example 3 + +

+ Result:{' '} +
+        {thirdExample.node}
+      
+
+ ); +} diff --git a/example/ExamplePage.jsx b/example/ExamplePage.jsx index 1b513ded3..0071911aa 100644 --- a/example/ExamplePage.jsx +++ b/example/ExamplePage.jsx @@ -1,4 +1,4 @@ -import React, { Component } from 'react'; +import { Component } from 'react'; import { Link } from 'react-router-dom'; import { injectIntl, intlShape } from '@edx/frontend-platform/i18n'; @@ -48,6 +48,7 @@ class ExamplePage extends Component {

EXAMPLE_VAR env var came through: {getConfig().EXAMPLE_VAR}

JS_FILE_VAR var came through: {getConfig().JS_FILE_VAR}

Visit authenticated page.

+

Visit component prop overrides page.

Visit error page.

); diff --git a/example/index.jsx b/example/index.jsx index 52b37aec5..a82870015 100644 --- a/example/index.jsx +++ b/example/index.jsx @@ -1,7 +1,6 @@ import 'core-js/stable'; import 'regenerator-runtime/runtime'; -import React from 'react'; import ReactDOM from 'react-dom'; import { AppProvider, @@ -16,6 +15,7 @@ import { Routes, Route } from 'react-router-dom'; import './index.scss'; import ExamplePage from './ExamplePage'; import AuthenticatedPage from './AuthenticatedPage'; +import ComponentPropOverridesPage from './ComponentPropOverridesPage'; subscribe(APP_READY, () => { ReactDOM.render( @@ -27,6 +27,7 @@ subscribe(APP_READY, () => { element={} /> } /> + } /> , document.getElementById('root'), diff --git a/package-lock.json b/package-lock.json index 0ea97a193..7d7579e2f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,11 +36,12 @@ "devDependencies": { "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/browserslist-config": "1.2.0", - "@openedx/frontend-build": "14.0.10", + "@openedx/frontend-build": "14.0.15", "@openedx/paragon": "22.6.1", "@testing-library/jest-dom": "6.4.6", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", + "@testing-library/user-event": "14.5.2", "axios-mock-adapter": "^1.22.0", "core-js": "3.37.1", "husky": "8.0.3", @@ -96,11 +97,10 @@ } }, "node_modules/@babel/cli": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.24.7.tgz", - "integrity": "sha512-8dfPprJgV4O14WTx+AQyEA+opgUKPrsIXX/MdL50J1n06EQJ6m1T+CdsJe0qEC0B/Xl85i+Un5KVAxd/PACX9A==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/cli/-/cli-7.24.8.tgz", + "integrity": "sha512-isdp+G6DpRyKc+3Gqxy2rjzgF7Zj9K0mzLNnxz+E/fgeag8qT3vVulX4gY9dGO1q0y+0lUv6V3a+uhUzMzrwXg==", "dev": true, - "license": "MIT", "dependencies": { "@jridgewell/trace-mapping": "^0.3.25", "commander": "^6.2.0", @@ -217,31 +217,30 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.24.7.tgz", - "integrity": "sha512-qJzAIcv03PyaWqxRgO4mSU3lihncDT296vnyuE2O8uA4w3UHWI4S3hgeZd1L8W1Bft40w9JxJ2b412iDUFFRhw==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.25.2.tgz", + "integrity": "sha512-bYcppcpKBvX4znYaPEeFau03bp89ShqNMLs+rmdptMw+heSZh9+z84d2YG+K7cYLbWwzdjtDoW/uqZmPjulClQ==", "dev": true, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/core": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.7.tgz", - "integrity": "sha512-nykK+LEK86ahTkX/3TgauT0ikKoNCfKHEaZYTUVupJdTLzGNvrblu4u6fa7DhZONAltdf8e662t/abY8idrd/g==", + "version": "7.24.9", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.9.tgz", + "integrity": "sha512-5e3FI4Q3M3Pbr21+5xJwCv6ZT6KmGkI0vw3Tozy5ODAQFTIWe37iT8Cr7Ice2Ntb+M3iSKCEWMB1MBgKrW3whg==", "dev": true, - "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-compilation-targets": "^7.24.7", - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helpers": "^7.24.7", - "@babel/parser": "^7.24.7", + "@babel/generator": "^7.24.9", + "@babel/helper-compilation-targets": "^7.24.8", + "@babel/helper-module-transforms": "^7.24.9", + "@babel/helpers": "^7.24.8", + "@babel/parser": "^7.24.8", "@babel/template": "^7.24.7", - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/traverse": "^7.24.8", + "@babel/types": "^7.24.9", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", @@ -282,12 +281,12 @@ } }, "node_modules/@babel/generator": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.24.7.tgz", - "integrity": "sha512-oipXieGC3i45Y1A41t4tAqpnEZWgB/lC6Ehh6+rOviR5XWpTtMmLN+fGjz9vOiNRt0p6RtO6DtD0pdU3vpqdSA==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.25.0.tgz", + "integrity": "sha512-3LEEcj3PVW8pW2R1SR1M89g/qrYk/m/mB/tLqn7dn4sbBUQyTqnlod+II2U4dqiGtUmkcnAmkMDralTFZttRiw==", "dev": true, "dependencies": { - "@babel/types": "^7.24.7", + "@babel/types": "^7.25.0", "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.25", "jsesc": "^2.5.1" @@ -324,14 +323,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.24.7.tgz", - "integrity": "sha512-ctSdRHBi20qWOfy27RUb4Fhp07KSJ3sXcuSvTrXrc4aG8NSYDo1ici3Vhg9bg69y5bj0Mr1lh0aeEgTvc12rMg==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.25.2.tgz", + "integrity": "sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==", "dev": true, "dependencies": { - "@babel/compat-data": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", - "browserslist": "^4.22.2", + "@babel/compat-data": "^7.25.2", + "@babel/helper-validator-option": "^7.24.8", + "browserslist": "^4.23.1", "lru-cache": "^5.1.1", "semver": "^6.3.1" }, @@ -437,14 +436,13 @@ } }, "node_modules/@babel/helper-member-expression-to-functions": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.7.tgz", - "integrity": "sha512-LGeMaf5JN4hAT471eJdBs/GK1DoYIJ5GCtZN/EsL6KUiiDZOvO/eKE11AMZJa2zP4zk4qe9V2O/hxAmkRc8p6w==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-member-expression-to-functions/-/helper-member-expression-to-functions-7.24.8.tgz", + "integrity": "sha512-LABppdt+Lp/RlBxqrh4qgf1oEH/WxdzQNDJIu5gC/W1GyvPVrOBiItmmM8wan2fm4oYqFuFfkXmlGpLQhPY8CA==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/traverse": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/traverse": "^7.24.8", + "@babel/types": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -465,16 +463,15 @@ } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.24.7.tgz", - "integrity": "sha512-1fuJEwIrp+97rM4RWdO+qrRsZlAeL1lQJoPqtCYWv0NL115XM93hIH4CSRln2w52SqvmY5hqdtauB6QFCDiZNQ==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.25.2.tgz", + "integrity": "sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==", "dev": true, "dependencies": { - "@babel/helper-environment-visitor": "^7.24.7", "@babel/helper-module-imports": "^7.24.7", "@babel/helper-simple-access": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/helper-validator-identifier": "^7.24.7" + "@babel/helper-validator-identifier": "^7.24.7", + "@babel/traverse": "^7.25.2" }, "engines": { "node": ">=6.9.0" @@ -497,11 +494,10 @@ } }, "node_modules/@babel/helper-plugin-utils": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.7.tgz", - "integrity": "sha512-Rq76wjt7yz9AAc1KnlRKNAi/dMSVWgDRx43FHoJEbcYU6xOWaE2dVPwcdTukJrjxS65GITyfbvEYHvkirZ6uEg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.24.8.tgz", + "integrity": "sha512-FFWx5142D8h2Mgr/iPVGH5G7w6jDn4jUSpZTyDnQO0Yn7Ks2Kuz6Pci8H6MPCoUJegd/UZQ3tAvfLCxQSnWWwg==", "dev": true, - "license": "MIT", "engines": { "node": ">=6.9.0" } @@ -525,15 +521,14 @@ } }, "node_modules/@babel/helper-replace-supers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.24.7.tgz", - "integrity": "sha512-qTAxxBM81VEyoAY0TtLrx1oAEJc09ZK67Q9ljQToqCnA+55eNwCORaxlKyu+rNfX86o8OXRUSNUnrtsAZXM9sg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helper-replace-supers/-/helper-replace-supers-7.25.0.tgz", + "integrity": "sha512-q688zIvQVYtZu+i2PsdIu/uWGRpfxzr5WESsfpShfZECkO+d2o+WROWezCi/Q6kJ0tfPa5+pUGUlfx2HhrA3Bg==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-member-expression-to-functions": "^7.24.7", - "@babel/helper-optimise-call-expression": "^7.24.7" + "@babel/helper-member-expression-to-functions": "^7.24.8", + "@babel/helper-optimise-call-expression": "^7.24.7", + "@babel/traverse": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -584,9 +579,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.7.tgz", - "integrity": "sha512-7MbVt6xrwFQbunH2DNQsAP5sTGxfqQtErvBIvIMi6EQnbgUOuVYanvREcmFrOPhoXBrTtjhhP+lW+o5UfK+tDg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.24.8.tgz", + "integrity": "sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==", "dev": true, "engines": { "node": ">=6.9.0" @@ -603,9 +598,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.7.tgz", - "integrity": "sha512-yy1/KvjhV/ZCL+SM7hBrvnZJ3ZuT9OuZgIJAGpPEToANvc3iM6iDvBnRjtElWibHU6n8/LPR/EjX9EtIEYO3pw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.24.8.tgz", + "integrity": "sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==", "dev": true, "engines": { "node": ">=6.9.0" @@ -628,13 +623,13 @@ } }, "node_modules/@babel/helpers": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.24.7.tgz", - "integrity": "sha512-NlmJJtvcw72yRJRcnCmGvSi+3jDEg8qFu3z0AFoymmzLx5ERVWyzd9kVXr7Th9/8yIJi2Zc6av4Tqz3wFs8QWg==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.25.0.tgz", + "integrity": "sha512-MjgLZ42aCm0oGjJj8CtSM3DB8NOOf8h2l7DCTePJs29u+v7yO/RBX9nShlKMgFnRks/Q4tBAe7Hxnov9VkGwLw==", "dev": true, "dependencies": { - "@babel/template": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" @@ -735,10 +730,13 @@ } }, "node_modules/@babel/parser": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.7.tgz", - "integrity": "sha512-9uUYRm6OqQrCqQdG1iCBwBPZgN8ciDBro2nIOFaiRz1/BCxaI7CNvQbDHvsArAC7Tw9Hda/B3U+6ui9u4HWXPw==", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.25.3.tgz", + "integrity": "sha512-iLTJKDbJ4hMvFPgQwwsVoxtHyWpKKPBrxkANrSYewDPaPpT5py5yeVkgPIJ7XYXhndxJpaA3PyALSXQ7u8e/Dw==", "dev": true, + "dependencies": { + "@babel/types": "^7.25.2" + }, "bin": { "parser": "bin/babel-parser.js" }, @@ -1255,18 +1253,16 @@ } }, "node_modules/@babel/plugin-transform-classes": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.24.7.tgz", - "integrity": "sha512-CFbbBigp8ln4FU6Bpy6g7sE8B/WmCmzvivzUC6xDAdWVsjYTXijpuuGJmYkAaoWAzcItGKT3IOAbxRItZ5HTjw==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.25.0.tgz", + "integrity": "sha512-xyi6qjr/fYU304fiRwFbekzkqVJZ6A7hOjWZd+89FVcBqPV3S9Wuozz82xdpLspckeaafntbzglaW4pqpzvtSw==", "dev": true, "dependencies": { "@babel/helper-annotate-as-pure": "^7.24.7", - "@babel/helper-compilation-targets": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-replace-supers": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", + "@babel/helper-compilation-targets": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-replace-supers": "^7.25.0", + "@babel/traverse": "^7.25.0", "globals": "^11.1.0" }, "engines": { @@ -1293,12 +1289,12 @@ } }, "node_modules/@babel/plugin-transform-destructuring": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.7.tgz", - "integrity": "sha512-19eJO/8kdCQ9zISOf+SEUJM/bAUIsvY3YDnXZTupUCQ8LgrWnsG/gFB9dvXqdXnRXMAM8fvt7b0CBKQHNGy1mw==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-destructuring/-/plugin-transform-destructuring-7.24.8.tgz", + "integrity": "sha512-36e87mfY8TnRxc7yc6M9g9gOB7rKgSahqkIKwLpz4Ppk2+zC2Cy1is0uwtuSG6AE4zlTOUa+7JGz9jCJGLqQFQ==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -1498,13 +1494,13 @@ } }, "node_modules/@babel/plugin-transform-modules-commonjs": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.7.tgz", - "integrity": "sha512-iFI8GDxtevHJ/Z22J5xQpVqFLlMNstcLXh994xifFwxxGslr2ZXXLWgtBeLctOD63UFDArdvN6Tg8RFw+aEmjQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-modules-commonjs/-/plugin-transform-modules-commonjs-7.24.8.tgz", + "integrity": "sha512-WHsk9H8XxRs3JXKWFiqtQebdh9b/pTk4EgueygFzYlTKAg0Ud985mSevdNjdXdFBATSKVJGQXP1tv6aGbssLKA==", "dev": true, "dependencies": { - "@babel/helper-module-transforms": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-module-transforms": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", "@babel/helper-simple-access": "^7.24.7" }, "engines": { @@ -1663,12 +1659,12 @@ } }, "node_modules/@babel/plugin-transform-optional-chaining": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.7.tgz", - "integrity": "sha512-tK+0N9yd4j+x/4hxF3F0e0fu/VdcxU18y5SevtyM/PCFlQvXbR0Zmlo2eBrKtVipGNFzpq56o8WsIIKcJFUCRQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.24.8.tgz", + "integrity": "sha512-5cTOLSMs9eypEy8JUVvIKOu6NgvbJMnpG62VpIHrTmROdQ+L5mDAaI40g25k5vXti55JWNX5jCkq3HZxXBQANw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7", + "@babel/helper-plugin-utils": "^7.24.8", "@babel/helper-skip-transparent-expression-wrappers": "^7.24.7", "@babel/plugin-syntax-optional-chaining": "^7.8.3" }, @@ -1917,12 +1913,12 @@ } }, "node_modules/@babel/plugin-transform-typeof-symbol": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.7.tgz", - "integrity": "sha512-VtR8hDy7YLB7+Pet9IarXjg/zgCMSF+1mNS/EQEiEaUPoFXCVsHG64SIxcaaI2zJgRiv+YmgaQESUfWAdbjzgg==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.24.8.tgz", + "integrity": "sha512-adNTUpDCVnmAE58VEqKlAA6ZBlNkMnWD0ZcW76lyNFN3MJniyGFZfNwERVk8Ap56MCnXztmDr19T4mPTztcuaw==", "dev": true, "dependencies": { - "@babel/helper-plugin-utils": "^7.24.7" + "@babel/helper-plugin-utils": "^7.24.8" }, "engines": { "node": ">=6.9.0" @@ -2013,16 +2009,15 @@ } }, "node_modules/@babel/preset-env": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.7.tgz", - "integrity": "sha512-1YZNsc+y6cTvWlDHidMBsQZrZfEFjRIo/BZCT906PMdzOyXtSLTgqGdrpcuTDCXyd11Am5uQULtDIcCfnTc8fQ==", + "version": "7.24.8", + "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.8.tgz", + "integrity": "sha512-vObvMZB6hNWuDxhSaEPTKCwcqkAIuDtE+bQGn4XMXne1DSLzFVY8Vmj1bm+mUQXYNN8NmaQEO+r8MMbzPr1jBQ==", "dev": true, - "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.24.7", - "@babel/helper-compilation-targets": "^7.24.7", - "@babel/helper-plugin-utils": "^7.24.7", - "@babel/helper-validator-option": "^7.24.7", + "@babel/compat-data": "^7.24.8", + "@babel/helper-compilation-targets": "^7.24.8", + "@babel/helper-plugin-utils": "^7.24.8", + "@babel/helper-validator-option": "^7.24.8", "@babel/plugin-bugfix-firefox-class-in-computed-class-key": "^7.24.7", "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression": "^7.24.7", "@babel/plugin-bugfix-v8-spread-parameters-in-optional-chaining": "^7.24.7", @@ -2053,9 +2048,9 @@ "@babel/plugin-transform-block-scoping": "^7.24.7", "@babel/plugin-transform-class-properties": "^7.24.7", "@babel/plugin-transform-class-static-block": "^7.24.7", - "@babel/plugin-transform-classes": "^7.24.7", + "@babel/plugin-transform-classes": "^7.24.8", "@babel/plugin-transform-computed-properties": "^7.24.7", - "@babel/plugin-transform-destructuring": "^7.24.7", + "@babel/plugin-transform-destructuring": "^7.24.8", "@babel/plugin-transform-dotall-regex": "^7.24.7", "@babel/plugin-transform-duplicate-keys": "^7.24.7", "@babel/plugin-transform-dynamic-import": "^7.24.7", @@ -2068,7 +2063,7 @@ "@babel/plugin-transform-logical-assignment-operators": "^7.24.7", "@babel/plugin-transform-member-expression-literals": "^7.24.7", "@babel/plugin-transform-modules-amd": "^7.24.7", - "@babel/plugin-transform-modules-commonjs": "^7.24.7", + "@babel/plugin-transform-modules-commonjs": "^7.24.8", "@babel/plugin-transform-modules-systemjs": "^7.24.7", "@babel/plugin-transform-modules-umd": "^7.24.7", "@babel/plugin-transform-named-capturing-groups-regex": "^7.24.7", @@ -2078,7 +2073,7 @@ "@babel/plugin-transform-object-rest-spread": "^7.24.7", "@babel/plugin-transform-object-super": "^7.24.7", "@babel/plugin-transform-optional-catch-binding": "^7.24.7", - "@babel/plugin-transform-optional-chaining": "^7.24.7", + "@babel/plugin-transform-optional-chaining": "^7.24.8", "@babel/plugin-transform-parameters": "^7.24.7", "@babel/plugin-transform-private-methods": "^7.24.7", "@babel/plugin-transform-private-property-in-object": "^7.24.7", @@ -2089,7 +2084,7 @@ "@babel/plugin-transform-spread": "^7.24.7", "@babel/plugin-transform-sticky-regex": "^7.24.7", "@babel/plugin-transform-template-literals": "^7.24.7", - "@babel/plugin-transform-typeof-symbol": "^7.24.7", + "@babel/plugin-transform-typeof-symbol": "^7.24.8", "@babel/plugin-transform-unicode-escapes": "^7.24.7", "@babel/plugin-transform-unicode-property-regex": "^7.24.7", "@babel/plugin-transform-unicode-regex": "^7.24.7", @@ -2098,7 +2093,7 @@ "babel-plugin-polyfill-corejs2": "^0.4.10", "babel-plugin-polyfill-corejs3": "^0.10.4", "babel-plugin-polyfill-regenerator": "^0.6.1", - "core-js-compat": "^3.31.0", + "core-js-compat": "^3.37.1", "semver": "^6.3.1" }, "engines": { @@ -2194,34 +2189,30 @@ } }, "node_modules/@babel/template": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.24.7.tgz", - "integrity": "sha512-jYqfPrU9JTF0PmPy1tLYHW4Mp4KlgxJD9l2nP9fD6yT/ICi554DmrWBAEYpIelzjHf1msDP3PxJIRt/nFNfBig==", + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.0.tgz", + "integrity": "sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==", "dev": true, - "license": "MIT", "dependencies": { "@babel/code-frame": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7" + "@babel/parser": "^7.25.0", + "@babel/types": "^7.25.0" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.24.7.tgz", - "integrity": "sha512-yb65Ed5S/QAcewNPh0nZczy9JdYXkkAbIsEo+P7BE7yO3txAY30Y/oPa3QkQ5It3xVG2kpKMg9MsdxZaO31uKA==", + "version": "7.25.3", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.25.3.tgz", + "integrity": "sha512-HefgyP1x754oGCsKmV5reSmtV7IXj/kpaE1XYY+D9G5PvKKoFfSbiS4M77MdjuwlZKDIKFCffq9rPU+H/s3ZdQ==", "dev": true, "dependencies": { "@babel/code-frame": "^7.24.7", - "@babel/generator": "^7.24.7", - "@babel/helper-environment-visitor": "^7.24.7", - "@babel/helper-function-name": "^7.24.7", - "@babel/helper-hoist-variables": "^7.24.7", - "@babel/helper-split-export-declaration": "^7.24.7", - "@babel/parser": "^7.24.7", - "@babel/types": "^7.24.7", + "@babel/generator": "^7.25.0", + "@babel/parser": "^7.25.3", + "@babel/template": "^7.25.0", + "@babel/types": "^7.25.2", "debug": "^4.3.1", "globals": "^11.1.0" }, @@ -2230,12 +2221,12 @@ } }, "node_modules/@babel/types": { - "version": "7.24.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.24.7.tgz", - "integrity": "sha512-XEFXSlxiG5td2EJRe8vOmRbaXVgfcBlszKujvVmWIK/UpywWljQCfzAv3RQCGujWQ1RD4YYWEAqDXfuJiy8f5Q==", + "version": "7.25.2", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.25.2.tgz", + "integrity": "sha512-YTnYtra7W9e6/oAZEHj0bJehPRUlLH9/fbpT5LfB0NhQXyALCRkRs3zH9v07IYhkgpqX6Z78FnuccZr/l4Fs4Q==", "dev": true, "dependencies": { - "@babel/helper-string-parser": "^7.24.7", + "@babel/helper-string-parser": "^7.24.8", "@babel/helper-validator-identifier": "^7.24.7", "to-fast-properties": "^2.0.0" }, @@ -2258,9 +2249,9 @@ } }, "node_modules/@csstools/cascade-layer-name-parser": { - "version": "1.0.12", - "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.12.tgz", - "integrity": "sha512-iNCCOnaoycAfcIot3v/orjkTol+j8+Z5xgpqxUpZSdqeaxCADQZtldHhlvzDipmi7OoWdcJUO6DRZcnkMSBEIg==", + "version": "1.0.13", + "resolved": "https://registry.npmjs.org/@csstools/cascade-layer-name-parser/-/cascade-layer-name-parser-1.0.13.tgz", + "integrity": "sha512-MX0yLTwtZzr82sQ0zOjqimpZbzjMaK/h2pmlrLK7DCzlmiZLYFpoO94WmN1akRVo6ll/TdpHb53vihHLUMyvng==", "dev": true, "funding": [ { @@ -2276,14 +2267,14 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.7.0", - "@csstools/css-tokenizer": "^2.3.2" + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, "node_modules/@csstools/css-parser-algorithms": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.0.tgz", - "integrity": "sha512-qvBMcOU/uWFCH/VO0MYe0AMs0BGMWAt6FTryMbFIKYtZtVnqTZtT8ktv5o718llkaGZWomJezJZjq3vJDHeJNQ==", + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-2.7.1.tgz", + "integrity": "sha512-2SJS42gxmACHgikc1WGesXLIT8d/q2l0UFM7TaEeIzdFCE/FPMtTiizcPGGJtlPo2xuQzY09OhrLTzRxqJqwGw==", "dev": true, "funding": [ { @@ -2299,13 +2290,13 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-tokenizer": "^2.3.2" + "@csstools/css-tokenizer": "^2.4.1" } }, "node_modules/@csstools/css-tokenizer": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.3.2.tgz", - "integrity": "sha512-0xYOf4pQpAaE6Sm2Q0x3p25oRukzWQ/O8hWVvhIt9Iv98/uu053u2CGm/g3kJ+P0vOYTAYzoU8Evq2pg9ZPXtw==", + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-2.4.1.tgz", + "integrity": "sha512-eQ9DIktFJBhGjioABJRtUucoWR2mwllurfnM8LuNGAqX3ViZXaUchqk+1s7jjtkFiT9ySdACsFEA3etErkALUg==", "dev": true, "funding": [ { @@ -2322,9 +2313,9 @@ } }, "node_modules/@csstools/media-query-list-parser": { - "version": "2.1.12", - "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.12.tgz", - "integrity": "sha512-t1/CdyVJzOQUiGUcIBXRzTAkWTFPxiPnoKwowKW2z9Uj78c2bBWI/X94BeVfUwVq1xtCjD7dnO8kS6WONgp8Jw==", + "version": "2.1.13", + "resolved": "https://registry.npmjs.org/@csstools/media-query-list-parser/-/media-query-list-parser-2.1.13.tgz", + "integrity": "sha512-XaHr+16KRU9Gf8XLi3q8kDlI18d5vzKSKCY510Vrtc9iNR0NJzbY9hhTmwhzYZj/ZwGL4VmB3TA9hJW0Um2qFA==", "dev": true, "funding": [ { @@ -2340,8 +2331,8 @@ "node": "^14 || ^16 || >=18" }, "peerDependencies": { - "@csstools/css-parser-algorithms": "^2.7.0", - "@csstools/css-tokenizer": "^2.3.2" + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1" } }, "node_modules/@discoveryjs/json-ext": { @@ -2392,6 +2383,15 @@ "@newrelic/publish-sourcemap": "^5.0.1" } }, + "node_modules/@edx/typescript-config": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@edx/typescript-config/-/typescript-config-1.1.0.tgz", + "integrity": "sha512-HF+7dsSgA2YQ6f/qV4HnrEYBoIhIdxVQZgDyYk/YGvaVGqT6IFuaHnYUP7ImpCUMOUmx/Jl7EyuVeaMe2LrMcA==", + "dev": true, + "peerDependencies": { + "typescript": "^4.9.4" + } + }, "node_modules/@eslint-community/eslint-utils": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.0.tgz", @@ -2820,6 +2820,19 @@ "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", "dev": true }, + "node_modules/@formatjs/ts-transformer/node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/@fortawesome/fontawesome-common-types": { "version": "6.5.1", "resolved": "https://registry.npmjs.org/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.5.1.tgz", @@ -3772,22 +3785,22 @@ } }, "node_modules/@openedx/frontend-build": { - "version": "14.0.10", - "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.0.10.tgz", - "integrity": "sha512-yTn8C+WV7tsDBQWz4PYKHQPNLtSj3KqT4qjuo13CUFk5hqUMZRNBZqw2ihT2cI9jBMPufra34TqYRtdK/xNqlQ==", + "version": "14.0.15", + "resolved": "https://registry.npmjs.org/@openedx/frontend-build/-/frontend-build-14.0.15.tgz", + "integrity": "sha512-D4j2IGAKkwVUpJGGZ5k926A6vDVuLSnpWYW1KLTmLTeh4JtBtMJlJ0D1TGXbImkiNje+GPjIxXUE9sjCLQGJUg==", "dev": true, "dependencies": { - "@babel/cli": "7.24.7", - "@babel/core": "7.24.7", + "@babel/cli": "7.24.8", + "@babel/core": "7.24.9", "@babel/eslint-parser": "7.22.9", "@babel/plugin-proposal-class-properties": "7.18.6", "@babel/plugin-proposal-object-rest-spread": "7.20.7", "@babel/plugin-syntax-dynamic-import": "7.8.3", - "@babel/preset-env": "7.24.7", + "@babel/preset-env": "7.24.8", "@babel/preset-react": "7.24.7", "@edx/eslint-config": "4.1.0", "@edx/new-relic-source-map-webpack-plugin": "2.1.0", - "@edx/typescript-config": "1.0.1", + "@edx/typescript-config": "1.1.0", "@formatjs/cli": "^6.0.3", "@fullhuman/postcss-purgecss": "5.0.0", "@pmmmwh/react-refresh-webpack-plugin": "0.5.15", @@ -3823,8 +3836,8 @@ "jest": "29.6.1", "jest-environment-jsdom": "29.6.1", "mini-css-extract-plugin": "1.6.2", - "postcss": "8.4.38", - "postcss-custom-media": "10.0.6", + "postcss": "8.4.39", + "postcss-custom-media": "10.0.8", "postcss-loader": "7.3.4", "postcss-rtlcss": "5.1.2", "react-dev-utils": "12.0.1", @@ -3851,16 +3864,6 @@ "react": "^16.9.0 || ^17.0.0" } }, - "node_modules/@openedx/frontend-build/node_modules/@edx/typescript-config": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/@edx/typescript-config/-/typescript-config-1.0.1.tgz", - "integrity": "sha512-w0g3nIX9oEch8Rip8q8sb/nrurGEHA1BEjK/I1LAQwA44K4FPMWvyvabmZErrdTJ9sXcZL10aWD3bat1obV8Bg==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "typescript": "^4.9.4" - } - }, "node_modules/@openedx/frontend-build/node_modules/jest-environment-jsdom": { "version": "29.6.1", "resolved": "https://registry.npmjs.org/jest-environment-jsdom/-/jest-environment-jsdom-29.6.1.tgz", @@ -3888,20 +3891,6 @@ } } }, - "node_modules/@openedx/frontend-build/node_modules/typescript": { - "version": "4.9.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", - "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">= 10.14.2" - } - }, "node_modules/@openedx/paragon": { "version": "22.6.1", "resolved": "https://registry.npmjs.org/@openedx/paragon/-/paragon-22.6.1.tgz", @@ -4575,6 +4564,19 @@ } } }, + "node_modules/@testing-library/user-event": { + "version": "14.5.2", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.5.2.tgz", + "integrity": "sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==", + "dev": true, + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -6807,6 +6809,19 @@ "integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==", "dev": true }, + "node_modules/babel-plugin-formatjs/node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/babel-plugin-istanbul": { "version": "6.1.1", "resolved": "https://registry.npmjs.org/babel-plugin-istanbul/-/babel-plugin-istanbul-6.1.1.tgz", @@ -7175,9 +7190,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.23.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.3.tgz", + "integrity": "sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==", "dev": true, "funding": [ { @@ -7194,10 +7209,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.30001646", + "electron-to-chromium": "^1.5.4", + "node-releases": "^2.0.18", + "update-browserslist-db": "^1.1.0" }, "bin": { "browserslist": "cli.js" @@ -7334,9 +7349,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001612", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001612.tgz", - "integrity": "sha512-lFgnZ07UhaCcsSZgWW0K5j4e69dK1u/ltrL9lTUiFOwNHs12S3UMIEYgBV0Z6C6hRDev7iRnMzzYmKabYdXF9g==", + "version": "1.0.30001651", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001651.tgz", + "integrity": "sha512-9Cf+Xv1jJNe1xPZLGuUXLNkE1BoDkqRqYyFJ9TDYSqhduqA4hu4oR9HluGoWYQC/aj8WHjsGVV+bwkh0+tegRg==", "dev": true, "funding": [ { @@ -7700,7 +7715,6 @@ "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.1.tgz", "integrity": "sha512-U7VdrJFnJgo4xjrHpTzu0yrHPGImdsmD95ZlgYSEajAn2JKzDhDTPG9kBTefmObL2w/ngeZnilk+OV9CG3d7UA==", "dev": true, - "license": "MIT", "engines": { "node": ">= 6" } @@ -8788,9 +8802,9 @@ "dev": true }, "node_modules/electron-to-chromium": { - "version": "1.4.745", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.4.745.tgz", - "integrity": "sha512-tRbzkaRI5gbUn5DEvF0dV4TQbMZ5CLkWeTAXmpC9IrYT+GE+x76i9p+o3RJ5l9XmdQlI1pPhVtE9uNcJJ0G0EA==", + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.6.tgz", + "integrity": "sha512-jwXWsM5RPf6j9dPYzaorcBSUg6AiqocPEyMpkchkvntaH9HGfOOMZwxMJjDY/XEs3T5dM7uyH1VhRMkqUU9qVw==", "dev": true }, "node_modules/email-prop-type": { @@ -9353,6 +9367,19 @@ "integrity": "sha512-QpLs9D9v9kArv4lfDEgg1X/gN5XLnf/A6l9cs8SPZLRZR3ZkY9+kwIQTxm+fsSej5UMYGE8fdoaZVIBlqG0XTw==", "dev": true }, + "node_modules/eslint-plugin-formatjs/node_modules/typescript": { + "version": "5.5.4", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz", + "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, "node_modules/eslint-plugin-import": { "version": "2.27.5", "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.27.5.tgz", @@ -14615,9 +14642,9 @@ "dev": true }, "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/nodemon": { @@ -15196,9 +15223,9 @@ } }, "node_modules/picocolors": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.0.tgz", - "integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.0.1.tgz", + "integrity": "sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==", "dev": true }, "node_modules/picomatch": { @@ -15444,9 +15471,9 @@ } }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.39", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.39.tgz", + "integrity": "sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==", "dev": true, "funding": [ { @@ -15464,7 +15491,7 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "source-map-js": "^1.2.0" }, "engines": { @@ -15522,9 +15549,9 @@ } }, "node_modules/postcss-custom-media": { - "version": "10.0.6", - "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-10.0.6.tgz", - "integrity": "sha512-BjihQoIO4Wjqv9fQNExSJIim8UAmkhLxuJnhJsLTRFSba1y1MhxkJK5awsM//6JJ+/Tu5QUxf624RQAvKHv6SA==", + "version": "10.0.8", + "resolved": "https://registry.npmjs.org/postcss-custom-media/-/postcss-custom-media-10.0.8.tgz", + "integrity": "sha512-V1KgPcmvlGdxTel4/CyQtBJEFhMVpEmRGFrnVtgfGIHj5PJX9vO36eFBxKBeJn+aCDTed70cc+98Mz3J/uVdGQ==", "dev": true, "funding": [ { @@ -15536,12 +15563,11 @@ "url": "https://opencollective.com/csstools" } ], - "license": "MIT", "dependencies": { - "@csstools/cascade-layer-name-parser": "^1.0.11", - "@csstools/css-parser-algorithms": "^2.6.3", - "@csstools/css-tokenizer": "^2.3.1", - "@csstools/media-query-list-parser": "^2.1.11" + "@csstools/cascade-layer-name-parser": "^1.0.13", + "@csstools/css-parser-algorithms": "^2.7.1", + "@csstools/css-tokenizer": "^2.4.1", + "@csstools/media-query-list-parser": "^2.1.13" }, "engines": { "node": "^14 || ^16 || >=18" @@ -19415,16 +19441,16 @@ } }, "node_modules/typescript": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.3.3.tgz", - "integrity": "sha512-pXWcraxM0uxAS+tN0AG/BF2TyqmHO014Z070UsJ+pFvYuRSq8KH8DmWpnbXe0pEPDHXZV3FcAbJkijJ5oNEnWw==", + "version": "4.9.5", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-4.9.5.tgz", + "integrity": "sha512-1FXk9E2Hm+QzZQ7z+McJiHL4NW1F2EzMu9Nq9i3zAaGqibafqYwCVU6WyWAuyQRRzOlxou8xZSyXLEN8oKj24g==", "devOptional": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" }, "engines": { - "node": ">=14.17" + "node": ">=4.2.0" } }, "node_modules/uc.micro": { @@ -19572,9 +19598,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.0.13", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.13.tgz", - "integrity": "sha512-xebP81SNcPuNpPP3uzeW1NYXxI3rxyJzF3pD6sH4jE7o/IX+WtSpwnVU+qIsDPyk0d3hmFQ7mjqc6AtV604hbg==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.1.0.tgz", + "integrity": "sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==", "dev": true, "funding": [ { @@ -19591,8 +19617,8 @@ } ], "dependencies": { - "escalade": "^3.1.1", - "picocolors": "^1.0.0" + "escalade": "^3.1.2", + "picocolors": "^1.0.1" }, "bin": { "update-browserslist-db": "cli.js" diff --git a/package.json b/package.json index 71cde363b..880d5730b 100644 --- a/package.json +++ b/package.json @@ -35,11 +35,12 @@ "devDependencies": { "@edx/brand": "npm:@openedx/brand-openedx@^1.2.2", "@edx/browserslist-config": "1.2.0", - "@openedx/frontend-build": "14.0.10", + "@openedx/frontend-build": "14.0.15", "@openedx/paragon": "22.6.1", "@testing-library/jest-dom": "6.4.6", "@testing-library/react": "12.1.5", "@testing-library/react-hooks": "^8.0.1", + "@testing-library/user-event": "14.5.2", "axios-mock-adapter": "^1.22.0", "core-js": "3.37.1", "husky": "8.0.3", diff --git a/src/react/hooks.js b/src/react/hooks.js index b1d4e219e..06d50e332 100644 --- a/src/react/hooks.js +++ b/src/react/hooks.js @@ -1,7 +1,7 @@ -/* eslint-disable import/prefer-default-export */ import { useEffect } from 'react'; import { subscribe, unsubscribe } from '../pubSub'; import { sendTrackEvent } from '../analytics'; +import { getConfig } from '../config'; /** * A React hook that allows functional components to subscribe to application events. This should @@ -9,9 +9,10 @@ import { sendTrackEvent } from '../analytics'; * provide necessary data to a given component, rather than utilizing a non-React-like Pub/Sub * mechanism. * - * @memberof module:React * @param {string} type * @param {function} callback + * + * @memberof module:React */ export const useAppEvent = (type, callback) => { useEffect(() => { @@ -48,3 +49,110 @@ export const useTrackColorSchemeChoice = () => { }; }, []); }; + +/** + * @typedef {object} ComponentPropOverride + * @property {string|boolean|Record|Function} [key] - The custom prop value. + */ + +/** + * @typedef {object} ComponentPropOverrides + * @property {Record} targets - A mapping of component targets to custom props. + */ + +/** + * @typedef {object} AppConfigWithComponentPropOverrides + * @property {ComponentPropOverrides} componentPropOverrides - The component prop overrides configuration. + */ + +/** + * @typedef {object} ComponentPropOverridesOptions + * @property {string[]} [allowedPropNames=["className"]] - The list of prop names allowed to be overridden. + * @property {boolean} [allowsDataAttributes=true] - Whether to allow `data-*` attributes to be applied. + */ + +/** + * A React hook that processes the given `target` to extend/merge component props + * with any corresponding attributes/values based on configuration. + * + * This hook looks up the specified `target` in the `componentPropOverrides` configuration, + * and if a match is found, it merges the component's props with the mapped attributes/values + * per the configured component targets. + * + * @param {string} target - The component target used to identify custom props per configuration. + * @param {Record} props - The original props object passed to the component. + * @param {ComponentPropOverridesOptions} [options] - Optional configuration for the hook. + * @returns {Record} An updated props object with custom props merged in. + * + * @example + * // Given a configuration like: + * { + * componentPropOverrides: { + * targets: { + * example: { + * 'data-dd-privacy': 'mask', + * 'data-hj-suppress': '', + * className: 'fs-mask', + * }, + * }, + * }, + * } + * + * // and calling the hook as follows: + * const propOverrides = useComponentPropOverrides('example', { otherProp: 'value' }); + * + * // The resulting `propOverrides` will be: + * { otherProp: 'value', 'data-dd-privacy': 'mask', data-hj-suppress: '', className: 'fs-mask' } + * + * @memberof module:React + * @see module:React.withComponentPropOverrides + */ +export function useComponentPropOverrides(target, props, options = {}) { + /** @type {AppConfigWithComponentPropOverrides} */ + const { componentPropOverrides } = getConfig(); + const propOverridesForTarget = componentPropOverrides?.targets?.[target]; + if (!target || !propOverridesForTarget) { + return props; + } + const overrideOptions = { + // Allow for custom prop overrides to be applied to the component. These are + // separate from any `data-*` attributes, which are always supported. + allowedPropNames: ['className'], + // Allow for any `data-*` attributes to be applied to the component. + allowsDataAttributes: true, + ...options, + }; + + const updatedProps = { ...props }; + // Apply the configured attributes/values/classes for the matched target + Object.entries(propOverridesForTarget).forEach(([attributeName, attributeValue]) => { + const isAllowedPropName = !!overrideOptions.allowedPropNames?.includes(attributeName); + const isDataAttribute = !!overrideOptions.allowsDataAttributes && attributeName.startsWith('data-'); + const isAllowedPropOverride = isAllowedPropName || isDataAttribute; + if (!isAllowedPropOverride) { + // Skip applying the override prop if it's not allowed. + return; + } + + // Parse attributeValue as empty string if not provided, or falsey value is given (e.g., `undefined`). + let transformedAttributeValue = !attributeValue ? '' : attributeValue; + if (attributeName === 'className') { + // Append the `className` to the existing `className` prop value (if any) + transformedAttributeValue = [updatedProps.className, attributeValue].join(' ').trim(); + } else if (attributeName === 'style' && typeof attributeValue === 'object') { + // Merge the `style` object with the existing `style` prop object (if any) + transformedAttributeValue = { ...updatedProps.style, ...attributeValue }; + } else if (typeof attributeValue === 'function') { + // Merge the function with the existing prop's function + const oldFn = updatedProps[attributeName]; + transformedAttributeValue = oldFn ? (...args) => { + oldFn(...args); + attributeValue(...args); + } : attributeValue; + } + + // Update the props with the transformed attribute value + updatedProps[attributeName] = transformedAttributeValue; + }); + return updatedProps; +} diff --git a/src/react/index.js b/src/react/index.js index 0985d9271..171ce2301 100644 --- a/src/react/index.js +++ b/src/react/index.js @@ -13,4 +13,5 @@ export { default as ErrorBoundary } from './ErrorBoundary'; export { default as ErrorPage } from './ErrorPage'; export { default as LoginRedirect } from './LoginRedirect'; export { default as PageWrap } from './PageWrap'; -export { useAppEvent } from './hooks'; +export { default as withComponentPropOverrides } from './withComponentPropOverrides'; +export { useAppEvent, useComponentPropOverrides } from './hooks'; diff --git a/src/react/withComponentPropOverrides.jsx b/src/react/withComponentPropOverrides.jsx new file mode 100644 index 000000000..ee9552e7d --- /dev/null +++ b/src/react/withComponentPropOverrides.jsx @@ -0,0 +1,45 @@ +import React, { forwardRef } from 'react'; +import { useComponentPropOverrides } from './hooks'; + +/** + * A Higher-Order Component (HOC) that enhances the wrapped component with custom props via + * the `useComponentPropOverrides` hook to merge any custom props from configuration with the + * actual component props. + * + * @param {string} [target] - The target used to identify any custom props for a given element. + * @param {ComponentPropOverridesOptions} [options] - Optional configuration for the HOC. + * + * @example + * // Given a configuration like: + * { + * componentPropOverrides: { + * targets: { + * example: { + * 'data-dd-privacy': 'mask', + * 'data-hj-suppress': '', + * className: 'fs-mask', + * }, + * }, + * }, + * } + * + * // and calling the HOC as follows: + * const ComponentWithPropOverrides = withComponentPropOverrides('example')(MyComponent); + * + * + * // The resulting `ComponentWithPropOverrides` will render `MyComponent` with the following props: + * { otherProp: 'value', 'data-dd-privacy': 'mask', data-hj-suppress: '', className: 'fs-mask' } + * + * @see module:React.useComponentPropOverrides + * @memberof module:React + */ +const withComponentPropOverrides = (target, options = {}) => (WrappedComponent) => { + const WithComponentPropOverrides = forwardRef((props, ref) => { + const propOverrides = useComponentPropOverrides(target, props, options); + return ; + }); + WithComponentPropOverrides.displayName = `withComponentPropOverrides(${WrappedComponent.displayName || WrappedComponent.name || 'Component'})`; + return WithComponentPropOverrides; +}; + +export default withComponentPropOverrides; diff --git a/src/react/withComponentPropOverrides.test.jsx b/src/react/withComponentPropOverrides.test.jsx new file mode 100644 index 000000000..98db40f42 --- /dev/null +++ b/src/react/withComponentPropOverrides.test.jsx @@ -0,0 +1,174 @@ +/* eslint-disable react/prop-types */ +import React from 'react'; +import { Route, Routes, MemoryRouter } from 'react-router-dom'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; + +import withComponentPropOverrides from './withComponentPropOverrides'; +import AppContext from './AppContext'; +import { getConfig } from '../config'; + +jest.mock('../auth'); +jest.mock('../config'); + +const mockCustomComponentOnClick = jest.fn(); +const mockcomponentPropOverridesConfig = { + targets: { + example: { + 'data-dd-privacy': 'mask', // `data-*` (Datadog example) + 'data-hj-suppress': '', // `data-*` (Hotjar example) + className: 'fs-mask', // `className` (Fullstory example) + style: { + background: 'blue', + color: 'white', + }, + onClick: mockCustomComponentOnClick, + }, + example2: { + 'data-dd-action-name': 'example name', // `data-*` (Datadog example) + }, + }, +}; + +function ExampleComponent(props) { + return ( + hello world + ); +} + +describe('withComponentPropOverrides', () => { + beforeEach(() => { + jest.clearAllMocks(); + getConfig.mockReturnValue({}); + }); + + it.each([ + { + MockExampleComponent: withComponentPropOverrides('example')(ExampleComponent), + hasComponentPropOverridesConfigured: true, + exampleComponentHasOnClick: false, + hasDefaultComponentPropOverridesApplied: true, + hasSpecialComponentPropOverridesApplied: false, + }, + { + MockExampleComponent: withComponentPropOverrides('example', { allowedPropNames: ['className', 'style', 'onClick'] })(ExampleComponent), + hasComponentPropOverridesConfigured: true, + exampleComponentHasOnClick: false, + hasDefaultComponentPropOverridesApplied: true, + hasSpecialComponentPropOverridesApplied: true, + }, + { + MockExampleComponent: withComponentPropOverrides('example')(ExampleComponent), + hasComponentPropOverridesConfigured: true, + exampleComponentHasOnClick: true, + hasDefaultComponentPropOverridesApplied: true, + hasSpecialComponentPropOverridesApplied: false, + }, + { + MockExampleComponent: withComponentPropOverrides('example', { allowedPropNames: ['className', 'style', 'onClick'] })(ExampleComponent), + hasComponentPropOverridesConfigured: true, + exampleComponentHasOnClick: true, + hasDefaultComponentPropOverridesApplied: true, + hasSpecialComponentPropOverridesApplied: true, + }, + { + MockExampleComponent: withComponentPropOverrides('invalid')(ExampleComponent), + hasComponentPropOverridesConfigured: true, + exampleComponentHasOnClick: false, + hasDefaultComponentPropOverridesApplied: false, + hasSpecialComponentPropOverridesApplied: false, + }, + { + MockExampleComponent: withComponentPropOverrides(undefined)(ExampleComponent), + hasComponentPropOverridesConfigured: true, + exampleComponentHasOnClick: false, + hasDefaultComponentPropOverridesApplied: false, + hasSpecialComponentPropOverridesApplied: false, + }, + { + MockExampleComponent: withComponentPropOverrides('')(ExampleComponent), + hasComponentPropOverridesConfigured: true, + exampleComponentHasOnClick: false, + hasDefaultComponentPropOverridesApplied: false, + hasSpecialComponentPropOverridesApplied: false, + }, + { + MockExampleComponent: withComponentPropOverrides('example')(ExampleComponent), + hasComponentPropOverridesConfigured: false, + exampleComponentHasOnClick: false, + hasDefaultComponentPropOverridesApplied: false, + hasSpecialComponentPropOverridesApplied: false, + }, + { + MockExampleComponent: withComponentPropOverrides('example')(ExampleComponent), + hasComponentPropOverridesConfigured: false, + exampleComponentHasOnClick: true, + hasDefaultComponentPropOverridesApplied: false, + hasSpecialComponentPropOverridesApplied: false, + }, + ])('should return a component with the expected configured attributes/values, if any (%s)', async ({ + MockExampleComponent, + hasComponentPropOverridesConfigured, + exampleComponentHasOnClick, + hasDefaultComponentPropOverridesApplied, + hasSpecialComponentPropOverridesApplied, + }) => { + if (hasComponentPropOverridesConfigured) { + getConfig.mockReturnValue({ componentPropOverrides: mockcomponentPropOverridesConfig }); + } + const mockComponentOnClick = jest.fn(); + const baseProps = { + className: 'existing', + onClick: exampleComponentHasOnClick ? mockComponentOnClick : undefined, + style: { borderBottom: '4px solid red' }, + }; + const App = ( + + + + } /> + + + + ); + render(App); + const element = screen.getByTestId('component-prop-overrides-element'); + expect(element).toBeInTheDocument(); + + // verify base props + if (hasDefaultComponentPropOverridesApplied) { + expect(element).toHaveAttribute('data-dd-privacy', 'mask'); + expect(element).toHaveAttribute('data-hj-suppress', ''); + expect(element).toHaveClass('fs-mask'); + + // verify opt-in props + if (hasSpecialComponentPropOverridesApplied) { + expect(element).toHaveClass('existing'); // should still have base className prop + expect(element).toHaveStyle({ background: 'blue', color: 'white', borderBottom: '4px solid red' }); + } + } else { + expect(element).not.toHaveAttribute('data-dd-privacy'); + expect(element).not.toHaveAttribute('data-hj-suppress', ''); + expect(element).not.toHaveClass('fs-mask'); + expect(element).toHaveClass('existing'); // should still have base className prop + expect(element).toHaveStyle({ borderBottom: '4px solid red' }); + } + + // simulate click event + await userEvent.click(element); + + // verify onClick event + if (exampleComponentHasOnClick) { + await waitFor(() => { + expect(mockComponentOnClick).toHaveBeenCalledTimes(1); + expect(mockComponentOnClick).toHaveBeenCalledWith(expect.any(Object)); + }); + } + if (hasSpecialComponentPropOverridesApplied) { + expect(mockCustomComponentOnClick).toHaveBeenCalledTimes(1); + expect(mockCustomComponentOnClick).toHaveBeenCalledWith(expect.any(Object)); + } else { + expect(mockCustomComponentOnClick).not.toHaveBeenCalled(); + } + }); +});