diff --git a/config/fileMock.js b/config/fileMock.js new file mode 100644 index 00000000..86059f36 --- /dev/null +++ b/config/fileMock.js @@ -0,0 +1 @@ +module.exports = 'test-file-stub'; diff --git a/cypress/component/ProductNudgeDataModal.cy.tsx b/cypress/component/ProductNudgeDataModal.cy.tsx new file mode 100644 index 00000000..fc6834f2 --- /dev/null +++ b/cypress/component/ProductNudgeDataModal.cy.tsx @@ -0,0 +1,74 @@ +import { createElement } from 'react'; +import { ProductNudgeDataModal } from '../../packages/module/dist/dynamic/ProductNudge'; + +const props = { + isOpen: true, + onClose: () => undefined, + brand: 'lightwell' as const, + titleText: 'Lightwell Lens', + matchData: { exact: 118, partial: 195, noMatch: 534 }, + ecosystemData: [ + { name: 'Java', exact: 200, partial: 120, noMatch: 180 }, + { name: 'Python', exact: 50, partial: 80, noMatch: 170 }, + ], +}; + +const expectTextInsideChart = (tooltipText: string) => { + cy.contains('text', tooltipText).should('exist').then(($text) => { + const textBounds = $text[0].getBoundingClientRect(); + const svgBounds = $text[0].ownerSVGElement?.getBoundingClientRect(); + + if (!svgBounds) { + throw new Error('Expected the tooltip label to belong to an SVG chart'); + } + + expect(textBounds.left).to.be.at.least(svgBounds.left - 1); + expect(textBounds.right).to.be.at.most(svgBounds.right + 1); + expect(textBounds.top).to.be.at.least(svgBounds.top - 1); + expect(textBounds.bottom).to.be.at.most(svgBounds.bottom + 1); + }); +}; + +describe('ProductNudgeDataModal chart theming and tooltips', () => { + afterEach(() => { + cy.get('html').invoke('removeClass', 'pf-v6-theme-dark'); + }); + + it('resolves chart fills and legend text through PatternFly light/dark tokens', () => { + cy.mount(createElement(ProductNudgeDataModal, props)); + + const noMatchFill = 'svg path[style*="lightwell-chart-color-no-match"]'; + const legendLabel = '[aria-label="By ecosystem chart"] svg text tspan'; + const donutTitle = 'svg text tspan'; + let lightDonutTitleFill: string; + + cy.get(noMatchFill).first().should('have.css', 'fill', 'rgb(224, 224, 224)'); + cy.get(legendLabel).filter((_index, element) => element.textContent === 'Exact match') + .should('have.css', 'fill', 'rgb(31, 31, 31)'); + cy.get(donutTitle).filter((_index, element) => element.textContent === '313').then(($title) => { + lightDonutTitleFill = getComputedStyle($title[0]).fill; + }); + + cy.get('html').invoke('addClass', 'pf-v6-theme-dark'); + + cy.get(noMatchFill).first().should('have.css', 'fill', 'rgb(112, 112, 112)'); + cy.get(legendLabel).filter((_index, element) => element.textContent === 'Exact match') + .should('have.css', 'fill', 'rgb(255, 255, 255)'); + cy.get(donutTitle).filter((_index, element) => element.textContent === '313').should(($title) => { + expect(getComputedStyle($title[0]).fill).not.to.equal(lightDonutTitleFill); + }); + }); + + it('keeps donut and tall-bar tooltips inside their chart viewports', () => { + cy.mount(createElement(ProductNudgeDataModal, props)); + + cy.get('svg path[style*="lightwell-chart-color-partial"]') + .first() + .trigger('mouseover'); + expectTextInsideChart('partial matches: 195'); + + cy.get('[aria-label="By ecosystem chart"] svg path[style*="lightwell-chart-color-exact"][index="0"]') + .trigger('mouseover'); + expectTextInsideChart('Exact match: 200'); + }); +}); diff --git a/cypress/support/component.ts b/cypress/support/component.ts index 2f8db4c5..81c27b48 100644 --- a/cypress/support/component.ts +++ b/cypress/support/component.ts @@ -26,6 +26,8 @@ import { mount } from 'cypress/react18' import '@patternfly/patternfly/patternfly.css'; // Patternfly utilities import '@patternfly/patternfly/patternfly-addons.css'; +// PatternFly chart tokens, including dark-theme chart colors +import '@patternfly/patternfly/patternfly-charts.css'; // Global theme CSS import '@patternfly/documentation-framework/global.css'; @@ -46,4 +48,4 @@ Cypress.Commands.add('mount', mount) Cypress.on('uncaught:exception', (_err, _runnable) => false) // Example use: -// cy.mount() \ No newline at end of file +// cy.mount() diff --git a/jest.config.js b/jest.config.js index f5f860bc..8241d439 100644 --- a/jest.config.js +++ b/jest.config.js @@ -33,6 +33,7 @@ module.exports = { ], moduleNameMapper: { '\\.(css|scss)$': 'identity-obj-proxy', + '\\.(svg|png)$': '/config/fileMock.js', '^lodash-es$': 'lodash', customReact: 'react', reactRedux: 'react-redux', diff --git a/package-lock.json b/package-lock.json index efad1fcf..af8ebad2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,6 +12,7 @@ "packages/*" ], "dependencies": { + "@patternfly/patternfly": "^6.6.1", "@patternfly/react-drag-drop": "^6.6.2", "@patternfly/react-tokens": "^6.0.0", "sharp": "^0.34.0" @@ -68,6 +69,12 @@ "ts-jest": "29.1.5", "wait-on": "^7.2.0", "whatwg-fetch": "^3.6.20" + }, + "peerDependencies": { + "victory-area": "^37.3.6", + "victory-axis": "^37.3.6", + "victory-box-plot": "^37.3.6", + "victory-chart": "^37.3.6" } }, "node_modules/@adobe/css-tools": { @@ -5371,10 +5378,9 @@ } }, "node_modules/@patternfly/patternfly": { - "version": "6.2.3", - "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-6.2.3.tgz", - "integrity": "sha512-FR027W7JygcQpvlRU/Iom936Vm0apzfi2o5lvtlcWW6IaeZCCTtTaDxehoYuELHlemzkLziQAgu6LuCJEVayjw==", - "dev": true, + "version": "6.6.1", + "resolved": "https://registry.npmjs.org/@patternfly/patternfly/-/patternfly-6.6.1.tgz", + "integrity": "sha512-8P9C1TmFk5y5s2/1XVjmP9JmSyDXDiNBwPZewhPizULtqlaRu+Evljx/X1HTSFrCNY0qgI1uDKy+NXiorIniyA==", "license": "MIT" }, "node_modules/@patternfly/patternfly-a11y": { @@ -5421,6 +5427,98 @@ "node": ">=18" } }, + "node_modules/@patternfly/react-charts": { + "version": "8.6.1", + "resolved": "https://registry.npmjs.org/@patternfly/react-charts/-/react-charts-8.6.1.tgz", + "integrity": "sha512-HkYmBanA3KmP/+dw3sbCGITjCif+r1EDzYcfjzLUUshllp55zBMv4UraEpV+pSwSv3fz1SfMQm7Dt5UXL0CWzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@patternfly/react-styles": "^6.6.1", + "@patternfly/react-tokens": "^6.6.1", + "hoist-non-react-statics": "^3.3.2", + "lodash": "^4.17.23", + "tslib": "^2.8.1" + }, + "peerDependencies": { + "echarts": "^5.6.0 || ^6.0.0", + "react": "^17 || ^18 || ^19", + "react-dom": "^17 || ^18 || ^19", + "victory-area": "^37.3.6", + "victory-axis": "^37.3.6", + "victory-bar": "^37.3.6", + "victory-box-plot": "^37.3.6", + "victory-chart": "^37.3.6", + "victory-core": "^37.3.6", + "victory-create-container": "^37.3.6", + "victory-cursor-container": "^37.3.6", + "victory-group": "^37.3.6", + "victory-legend": "^37.3.6", + "victory-line": "^37.3.6", + "victory-pie": "^37.3.6", + "victory-scatter": "^37.3.6", + "victory-stack": "^37.3.6", + "victory-tooltip": "^37.3.6", + "victory-voronoi-container": "^37.3.6", + "victory-zoom-container": "^37.3.6" + }, + "peerDependenciesMeta": { + "echarts": { + "optional": true + }, + "victory-area": { + "optional": true + }, + "victory-axis": { + "optional": true + }, + "victory-bar": { + "optional": true + }, + "victory-box-plot": { + "optional": true + }, + "victory-chart": { + "optional": true + }, + "victory-core": { + "optional": true + }, + "victory-create-container": { + "optional": true + }, + "victory-cursor-container": { + "optional": true + }, + "victory-group": { + "optional": true + }, + "victory-legend": { + "optional": true + }, + "victory-line": { + "optional": true + }, + "victory-pie": { + "optional": true + }, + "victory-scatter": { + "optional": true + }, + "victory-stack": { + "optional": true + }, + "victory-tooltip": { + "optional": true + }, + "victory-voronoi-container": { + "optional": true + }, + "victory-zoom-container": { + "optional": true + } + } + }, "node_modules/@patternfly/react-code-editor": { "version": "6.6.2", "resolved": "https://registry.npmjs.org/@patternfly/react-code-editor/-/react-code-editor-6.6.2.tgz", @@ -6185,27 +6283,19 @@ "version": "3.2.2", "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/d3-color": { "version": "3.1.3", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/d3-ease": { "version": "3.0.2", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/d3-interpolate": { "version": "3.0.4", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/d3-color": "*" } @@ -6214,17 +6304,13 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/d3-scale": { "version": "4.0.9", "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/d3-time": "*" } @@ -6233,9 +6319,7 @@ "version": "3.1.7", "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.7.tgz", "integrity": "sha512-VLvUQ33C+3J+8p+Daf+nYSOsjB4GXp19/S/aGo60m9h1v6XaxjiT82lKVWJCfzhtuZ3yD7i/TPeC/fuKLLOSmg==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/d3-path": "*" } @@ -6244,15 +6328,11 @@ "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/d3-timer": { "version": "3.0.2", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@types/eslint": { "version": "9.6.0", @@ -11227,9 +11307,7 @@ }, "node_modules/d3-array": { "version": "3.2.4", - "dev": true, "license": "ISC", - "peer": true, "dependencies": { "internmap": "1 - 2" }, @@ -11239,36 +11317,28 @@ }, "node_modules/d3-color": { "version": "3.1.0", - "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } }, "node_modules/d3-ease": { "version": "3.0.1", - "dev": true, "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=12" } }, "node_modules/d3-format": { "version": "3.1.0", - "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } }, "node_modules/d3-interpolate": { "version": "3.0.1", - "dev": true, "license": "ISC", - "peer": true, "dependencies": { "d3-color": "1 - 3" }, @@ -11278,18 +11348,14 @@ }, "node_modules/d3-path": { "version": "3.1.0", - "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } }, "node_modules/d3-scale": { "version": "4.0.2", - "dev": true, "license": "ISC", - "peer": true, "dependencies": { "d3-array": "2.10.0 - 3", "d3-format": "1 - 3", @@ -11303,9 +11369,7 @@ }, "node_modules/d3-shape": { "version": "3.2.0", - "dev": true, "license": "ISC", - "peer": true, "dependencies": { "d3-path": "^3.1.0" }, @@ -11315,9 +11379,7 @@ }, "node_modules/d3-time": { "version": "3.1.0", - "dev": true, "license": "ISC", - "peer": true, "dependencies": { "d3-array": "2 - 3" }, @@ -11327,9 +11389,7 @@ }, "node_modules/d3-time-format": { "version": "4.1.0", - "dev": true, "license": "ISC", - "peer": true, "dependencies": { "d3-time": "1 - 3" }, @@ -11339,13 +11399,17 @@ }, "node_modules/d3-timer": { "version": "3.0.1", - "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } }, + "node_modules/d3-voronoi": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/d3-voronoi/-/d3-voronoi-1.1.4.tgz", + "integrity": "sha512-dArJ32hchFsrQ8uMiTBLq256MpnZjeuBtdHpaDlYuQyjU0CVzCJl/BVW+SkszaAeH95D/8gxqAhgx0ouAWAfRg==", + "license": "BSD-3-Clause" + }, "node_modules/dashdash": { "version": "1.14.1", "resolved": "https://registry.npmjs.org/dashdash/-/dashdash-1.14.1.tgz", @@ -11668,15 +11732,11 @@ }, "node_modules/delaunator": { "version": "4.0.1", - "dev": true, - "license": "ISC", - "peer": true + "license": "ISC" }, "node_modules/delaunay-find": { "version": "0.0.6", - "dev": true, "license": "ISC", - "peer": true, "dependencies": { "delaunator": "^4.0.0" } @@ -15545,9 +15605,7 @@ }, "node_modules/internmap": { "version": "2.0.3", - "dev": true, "license": "ISC", - "peer": true, "engines": { "node": ">=12" } @@ -19183,7 +19241,6 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", - "dev": true, "license": "ISC" }, "node_modules/json5": { @@ -22837,9 +22894,7 @@ }, "node_modules/react-fast-compare": { "version": "3.2.2", - "dev": true, - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/react-is": { "version": "16.13.1", @@ -27476,13 +27531,102 @@ "url": "https://opencollective.com/unified" } }, + "node_modules/victory": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory/-/victory-37.3.6.tgz", + "integrity": "sha512-CZ1vjvra0R1U3T2dMI4EsjI8Ng+JmQ2ox/EweSzjkTnHfW/Vn5ylryadawDiYjDMcBvABjO3uODsIlSEm4d/Sw==", + "license": "MIT", + "dependencies": { + "victory-area": "37.3.6", + "victory-axis": "37.3.6", + "victory-bar": "37.3.6", + "victory-box-plot": "37.3.6", + "victory-brush-container": "37.3.6", + "victory-brush-line": "37.3.6", + "victory-candlestick": "37.3.6", + "victory-canvas": "37.3.6", + "victory-chart": "37.3.6", + "victory-core": "37.3.6", + "victory-create-container": "37.3.6", + "victory-cursor-container": "37.3.6", + "victory-errorbar": "37.3.6", + "victory-group": "37.3.6", + "victory-histogram": "37.3.6", + "victory-legend": "37.3.6", + "victory-line": "37.3.6", + "victory-pie": "37.3.6", + "victory-polar-axis": "37.3.6", + "victory-scatter": "37.3.6", + "victory-selection-container": "37.3.6", + "victory-shared-events": "37.3.6", + "victory-stack": "37.3.6", + "victory-tooltip": "37.3.6", + "victory-voronoi": "37.3.6", + "victory-voronoi-container": "37.3.6", + "victory-zoom-container": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-area": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-area/-/victory-area-37.3.6.tgz", + "integrity": "sha512-wVC8LKrZJLiSySNuJLRCB449qZTsPiRyzLlNoJwe21y+XA/a2HJbmJSeywmo8P153aX8viKe1H8ygDsTFXQhHw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-axis": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-axis/-/victory-axis-37.3.6.tgz", + "integrity": "sha512-Vi0dZvgmXmnCdoqc49WckeG5cMXnl7FTtqVhXu9JweA9cgCnkZabBd5mRvAjblb3Lo4j0HZCSPKHYWUPW70qZg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, "node_modules/victory-bar": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-bar/-/victory-bar-37.3.6.tgz", "integrity": "sha512-jdATFRWL1LUW/yEpKWx/aId2BiU2o1pPF9+Kh1TFISBduJoI4ZqvZD90H1QK4f/z50PikqiqiDECaKoKM1jfOQ==", - "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-box-plot": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-box-plot/-/victory-box-plot-37.3.6.tgz", + "integrity": "sha512-GOucnD63h14ScBuISC/nd1GBTEx6gIZfLE+0P0gyeH1poBKq0trTTvpQDvAMuGR8zICfEETG3ltmUMCwRrFyUg==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.3.6", @@ -27499,9 +27643,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-brush-container/-/victory-brush-container-37.3.6.tgz", "integrity": "sha512-LfZ2CgX1cYAqCtYxcSB68OfZS2v0T2VLXoEArd0lCXfRBY1Gya7GacCUcuo7GoK9XOXeslx7S/U95aVutt1VLg==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "react-fast-compare": "^3.2.0", @@ -27514,13 +27656,81 @@ "react": ">=16.6.0" } }, + "node_modules/victory-brush-line": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-brush-line/-/victory-brush-line-37.3.6.tgz", + "integrity": "sha512-zsZJfF1fUj4F7mUoIMV+h73qoTClPA4bKM1terlYrDBD8l/c/f0KBbEotu3E1X+n4QMmDRruswaB/YUdqK5QLA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-candlestick": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-candlestick/-/victory-candlestick-37.3.6.tgz", + "integrity": "sha512-h/mOmkCrsWrirn4dFnpLxJPXpxT+uHxuYxnXGrAyH+YUOrVj3iKaDJlEiVlz5vy30syE5j5hzTQCMsZ/hzHNdg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-canvas": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-canvas/-/victory-canvas-37.3.6.tgz", + "integrity": "sha512-1CD4S0uZ92sUGGSIEQferEfSqd/z9EXw9G6zkzPIoJeTKFshpfqCjUkNRx9Iu9Upxt3fUpId8Qwl1YfchmbrFg==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-bar": "37.3.6", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-chart": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-chart/-/victory-chart-37.3.6.tgz", + "integrity": "sha512-IkPo/W4AJ7bPu902TGER09OseR9ODm+FQAKfOBw4JsdEhZZ7BiG9zgd/25+x0r5EsTLu81CYGQVkBa+ZazcOlA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-axis": "37.3.6", + "victory-core": "37.3.6", + "victory-polar-axis": "37.3.6", + "victory-shared-events": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, "node_modules/victory-core": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-core/-/victory-core-37.3.6.tgz", "integrity": "sha512-aFgO6KokxPbUCPznZP5UPhOdI22pMuwDXKDt6eoQOnkVim66Ia+K95TQar2nwVKGYV5j26aKVf/n9blwphGJRw==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.21", "react-fast-compare": "^3.2.0", @@ -27537,9 +27747,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-create-container/-/victory-create-container-37.3.6.tgz", "integrity": "sha512-Uf5bFQvqUsXCjqpvBW4LhrdrHkM6dBqxYgub6FCsBb86f84xZQ3vY7jFkg/JfvF0oGKMoWXYYrYLC1sk+fcWVA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "victory-brush-container": "37.3.6", @@ -27560,9 +27768,23 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-cursor-container/-/victory-cursor-container-37.3.6.tgz", "integrity": "sha512-+Oiw57d5nE+iq8As8RvepknzmNtKq1Gsc50u1X3IRd4jXtX8zqZrgXGlVZ+BP/tkLsWnGYVjKulwKBf2oaEUuw==", - "dev": true, "license": "MIT", - "peer": true, + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, + "node_modules/victory-errorbar": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-errorbar/-/victory-errorbar-37.3.6.tgz", + "integrity": "sha512-WGAv/qizOlfmwKv+Yfxr4q6pDgTfloNQwi3Z3M0h8povjMZt74tHYkvi/TASSRYr3zv5kjUqUJ28qAyGMWwryQ==", + "license": "MIT", "dependencies": { "lodash": "^4.17.19", "victory-core": "37.3.6" @@ -27578,9 +27800,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-group/-/victory-group-37.3.6.tgz", "integrity": "sha512-kgy/Azl5BxwlJAV0KDPGypv35TMrOD1J2ZxnJW2Wyyq+e8i0GGBIv5MoBzou64BRsDlS9V0CYRIjnkHgrBpB5w==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "react-fast-compare": "^3.2.0", @@ -27594,13 +27814,30 @@ "react": ">=16.6.0" } }, + "node_modules/victory-histogram": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-histogram/-/victory-histogram-37.3.6.tgz", + "integrity": "sha512-K4d43MpXHYnGCLEMzfRpJ+lCRRDKALPi/juxfMGVzBPzSMgjC8h9x6hKdxaejiTd/E04UdzNO7J24plL3Uz8rA==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "react-fast-compare": "^3.2.0", + "victory-bar": "37.3.6", + "victory-core": "37.3.6", + "victory-vendor": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, "node_modules/victory-legend": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-legend/-/victory-legend-37.3.6.tgz", "integrity": "sha512-vRRrhj3/ENqKVLdaBMzEmR83N6BOjox1bthYT1eJjN2H5SIK35bxn30IkiV/Pz3y627EqZe4TAWaxc0jiJlCiA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "victory-core": "37.3.6" @@ -27616,9 +27853,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-line/-/victory-line-37.3.6.tgz", "integrity": "sha512-Ke817uf/qFbN9jU7Dba7CrcHXYO5wAZuKKnyeHJmLDeQeFST0773xejnIuC+dBgZipjFr4KIbSd+VcUafFNE1g==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "victory-core": "37.3.6", @@ -27635,9 +27870,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-pie/-/victory-pie-37.3.6.tgz", "integrity": "sha512-tvdgAZ/HQWlo3KDDe0XAVbizHuaNMbgkkiF7zfA7Ww+3bHSs+0P9dsDtK2xP365D8gBCOv8pWmuzvKRhzNbqeA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "victory-core": "37.3.6", @@ -27650,13 +27883,27 @@ "react": ">=16.6.0" } }, + "node_modules/victory-polar-axis": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-polar-axis/-/victory-polar-axis-37.3.6.tgz", + "integrity": "sha512-RpFsCkzHezJq5P+C/wtVdjEHX25JIFsSgs6qYSnfr/hayaFbWgK5HhRFpriQm5hg61cx47WxAOLyHvzf0nasvw==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, "node_modules/victory-scatter": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-scatter/-/victory-scatter-37.3.6.tgz", "integrity": "sha512-fp95zMTPXgW1cmTowzDXhn+KxePMVDrzU0lotsHQMdBV7eB+ioXdu9hORlx4VHmMYg2ihsGwRTF+VAZ7rGxphA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "victory-core": "37.3.6" @@ -27672,9 +27919,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-selection-container/-/victory-selection-container-37.3.6.tgz", "integrity": "sha512-gd3qODDlBtLEJM7+2jCXk2YcLBUmIpYEEHswytMhwc6zihxXipGBUHRulhLj/I05mKay2gaOAg5ewiJHd4Awgw==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "victory-core": "37.3.6" @@ -27690,9 +27935,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-shared-events/-/victory-shared-events-37.3.6.tgz", "integrity": "sha512-ygrbOtzLUTbtKebacZKyQRekhSAROnAvMkVI/PKsAGsz0ClY9P7qDEJG7eTUUygjO6ax0tI6WNE6JogQzeD1gw==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "json-stringify-safe": "^5.0.1", "lodash": "^4.17.19", @@ -27710,9 +27953,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-stack/-/victory-stack-37.3.6.tgz", "integrity": "sha512-ldod04RdqGJGH5p5eWXCofdTkbhZqIp3iwW7NpxSbMDLs8zPQIVvDFVtuJgMwQiC5vnIpbhMmxVeFbr8m64ZKA==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "react-fast-compare": "^3.2.0", @@ -27730,9 +27971,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-tooltip/-/victory-tooltip-37.3.6.tgz", "integrity": "sha512-vqaJS9noauOqDDBBAV9Ln9duOY/i17h1DCfCPAqhwPFyvFbwKvAub9zPTeYWAm/14VvWX5O/0yekFCVbcC7hjg==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "victory-core": "37.3.6" @@ -27748,9 +27987,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-37.3.6.tgz", "integrity": "sha512-SbPDPdDBYp+5MJHhBCAyI7wKM3d5ivekigc2Dk2s7pgbZ9wIgIBYGVw4zGHBml/qTFbexrofXW6Gu4noGxrOwQ==", - "dev": true, "license": "MIT AND ISC", - "peer": true, "dependencies": { "@types/d3-array": "^3.0.3", "@types/d3-ease": "^3.0.0", @@ -27768,13 +28005,28 @@ "d3-timer": "^3.0.1" } }, + "node_modules/victory-voronoi": { + "version": "37.3.6", + "resolved": "https://registry.npmjs.org/victory-voronoi/-/victory-voronoi-37.3.6.tgz", + "integrity": "sha512-Q+1FWHp8IAbmDL9pGWS0y0N4Cb5qmD9OOgxoxCfIDsLlhGvd6LddhRoknWsN7WnreaK+XiwjSfQkdMTCZ4hdhQ==", + "license": "MIT", + "dependencies": { + "d3-voronoi": "^1.1.4", + "lodash": "^4.17.19", + "victory-core": "37.3.6" + }, + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "react": ">=16.6.0" + } + }, "node_modules/victory-voronoi-container": { "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-voronoi-container/-/victory-voronoi-container-37.3.6.tgz", "integrity": "sha512-qAAG0rMuK7A4EoJ4cyUk5wNdOW+HuCXNKPOko+hYK6wWOYXJvFhiglYyA85a695YyAXECc6JyJS/crm4IOEFag==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "delaunay-find": "0.0.6", "lodash": "^4.17.19", @@ -27793,9 +28045,7 @@ "version": "37.3.6", "resolved": "https://registry.npmjs.org/victory-zoom-container/-/victory-zoom-container-37.3.6.tgz", "integrity": "sha512-AGL+k20mI44OL5b0VgIxlmnNSefIoFmbbim5NraPmIxbtns9qQW/56ivIncJcYomBungIx99gUpsEpcQaMNHgQ==", - "dev": true, "license": "MIT", - "peer": true, "dependencies": { "lodash": "^4.17.19", "victory-core": "37.3.6" @@ -29028,12 +29278,14 @@ "@patternfly/react-icons": "^6.0.0", "@patternfly/react-styles": "^6.0.0", "@patternfly/react-table": "^6.6.2", - "react-jss": "^10.10.0" + "react-jss": "^10.10.0", + "victory": "^37.3.6" }, "devDependencies": { "@patternfly/documentation-framework": "^6.5.16", - "@patternfly/patternfly": "^6.0.0", + "@patternfly/patternfly": "^6.6.1", "@patternfly/patternfly-a11y": "^5.1.0", + "@patternfly/react-charts": "^8.2.1", "@patternfly/react-code-editor": "^6.6.2", "@types/react": "^18.2.33", "@types/react-dom": "^18.3.1", @@ -29042,6 +29294,7 @@ "typescript": "^5.8.3" }, "peerDependencies": { + "@patternfly/react-charts": "^8.0.0", "@patternfly/react-drag-drop": "^6.6.2", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" diff --git a/package.json b/package.json index 9ad47556..98ce1ba9 100644 --- a/package.json +++ b/package.json @@ -85,8 +85,15 @@ "whatwg-fetch": "^3.6.20" }, "dependencies": { + "@patternfly/patternfly": "^6.6.1", "@patternfly/react-drag-drop": "^6.6.2", "@patternfly/react-tokens": "^6.0.0", "sharp": "^0.34.0" + }, + "peerDependencies": { + "victory-area": "^37.3.6", + "victory-axis": "^37.3.6", + "victory-box-plot": "^37.3.6", + "victory-chart": "^37.3.6" } } diff --git a/packages/module/package.json b/packages/module/package.json index adc780b0..9baf3d1f 100644 --- a/packages/module/package.json +++ b/packages/module/package.json @@ -5,7 +5,8 @@ "main": "dist/cjs/index.js", "module": "dist/esm/index.js", "scripts": { - "build": "npm run build:index && npm run build:js && npm run build:esm && npm run build:fed:packages", + "build": "npm run build:index && npm run build:js && npm run build:esm && npm run build:fed:packages && npm run build:assets", + "build:assets": "mkdir -p dist/esm/ProductNudge/assets dist/cjs/ProductNudge/assets dist/dynamic/ProductNudge/assets && cp src/ProductNudge/assets/* dist/esm/ProductNudge/assets/ && cp src/ProductNudge/assets/* dist/cjs/ProductNudge/assets/ && cp src/ProductNudge/assets/* dist/dynamic/ProductNudge/assets/", "build:watch": "npm run build:js && npm run build:esm -- --watch && npm run build:fed:packages -- --watch", "build:esm": "tsc --build --verbose ./tsconfig.json", "build:fed:packages": "node generate-fed-package-json.js", @@ -35,17 +36,20 @@ "@patternfly/react-icons": "^6.0.0", "@patternfly/react-styles": "^6.0.0", "@patternfly/react-table": "^6.6.2", - "react-jss": "^10.10.0" + "react-jss": "^10.10.0", + "victory": "^37.3.6" }, "peerDependencies": { + "@patternfly/react-charts": "^8.0.0", "@patternfly/react-drag-drop": "^6.6.2", "react": "^17 || ^18 || ^19", "react-dom": "^17 || ^18 || ^19" }, "devDependencies": { "@patternfly/documentation-framework": "^6.5.16", - "@patternfly/patternfly": "^6.0.0", + "@patternfly/patternfly": "^6.6.1", "@patternfly/patternfly-a11y": "^5.1.0", + "@patternfly/react-charts": "^8.2.1", "@patternfly/react-code-editor": "^6.6.2", "@types/react": "^18.2.33", "@types/react-dom": "^18.3.1", diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx index a02f549d..df4a09e2 100644 --- a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderExample.tsx @@ -100,5 +100,3 @@ export const FieldBuilderExample: React.FunctionComponent = () => { ); }; - -export default FieldBuilderExample; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx index d7e55f17..8e733651 100644 --- a/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/FieldBuilder/FieldBuilderSelectExample.tsx @@ -240,5 +240,3 @@ export const FieldBuilderSelectExample: React.FunctionComponent = () => { ); }; - -export default FieldBuilderSelectExample; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudge.md b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudge.md new file mode 100644 index 00000000..8a172fae --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudge.md @@ -0,0 +1,119 @@ +--- +section: extensions +subsection: component-groups +id: Product nudge +source: react +propComponents: [ + 'ProductNudge', + 'ProductNudgeContactModal', + 'ProductNudgeStack', + 'ProductNudgeDescriptionItem', + 'ProductNudgeDataModal', + 'ProductNudgeModalFooter', + 'ProductNudgeBrandLogo', + 'NudgeContent', + 'NudgeContact', + 'NudgeMetric', + 'ProductNudgeImage', + 'ProductNudgeBrandAssets', + 'ProductNudgeContactFormField', + 'ProductNudgeAction', + 'ProductNudgeMatchData', + 'ProductNudgeEcosystemData' +] +sourceLink: https://github.com/patternfly/react-component-groups/blob/main/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudge.md +--- + +import ProductNudge from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; +import { ProductNudgeContactModal, ProductNudgeStack, ProductNudgeDescriptionItem, ProductNudgeDataModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; +import { lightwellBackgroundAssets } from '@patternfly/react-component-groups/dist/esm/ProductNudge/productNudgeLightwellBackgrounds'; +import { useState } from 'react'; +import { DescriptionList } from '@patternfly/react-core'; + +A **product nudge** surfaces a product offer or upsell in context. The `ProductNudge` component supports three layouts through `prominence`: `hero` for a prominent full-width placement, `alert` for an inline message, and `field` for a compact, in-context placement. + +To render a nudge, provide its `prominence`, an `isEligible` value, and `content` with a unique `id`, headline, body, and CTA. Set `isEligible` to `false` to render nothing. A link CTA uses an `href`; a contact CTA calls your `onAction` callback. The `behavior` prop controls whether the nudge is persistent (the default), dismissible, or collapsible. Optional callbacks let your application respond to impressions, dismissals, and CTA actions: `onImpression` fires once when at least half of the nudge is visible, while `onDismiss` and `onAction` run for their corresponding user actions. These callbacks do not persist dismissal state or implement a contact workflow; your application owns that behavior. + +Product copy is always supplied by the caller. Components that support `brand="lightwell"` use the included Lightwell visual assets and CTA styling; omit the prop for neutral PatternFly styling, or provide image props for custom branding. Explicit image props take precedence over Lightwell assets, and the brand option never supplies product copy. See [Custom brand](#custom-brand) for a nudge without the Lightwell preset. + +## Examples + +### Hero + +The `hero` prominence spans the available width and uses a large CTA. This example opts into the Lightwell background image; the extra import is optional. + +```js file="./ProductNudgeHeroExample.tsx" + +``` + +The Lightwell preset's small SVG logo, logomark, and partner-logo assets ship with the package by default. Its hero background images (~2 MB combined) are published separately and are not included in the default import. To use the Lightwell hero background, import `lightwellBackgroundAssets` from `@patternfly/react-component-groups/dist/esm/ProductNudge/productNudgeLightwellBackgrounds` and spread it into `content.assets`, as shown above. + +### Alert + +The `alert` prominence uses an inline PatternFly Alert, making it suitable for a sidebar or below a page heading. + +```js file="./ProductNudgeAlertExample.tsx" + +``` + +### Field prominence + +Use `prominence="field"` for a compact nudge near related content or data. It renders the same compact treatment as `ProductNudgeStack`; see [In-context stack](#in-context-stack) for the standalone component and its props. + +### Custom brand + +This example uses the Hero layout without the Lightwell preset. Supply your own copy and image, and optionally override the background, image sizing, and CTA styling. Brand image sizing (`logoSize`/`logoOffset` and `logoMarkSize`/`logoMarkOffset`) and the hero background color (`backgroundColor`) can also be overridden when using a preset. To define a custom CTA color scheme, set `ctaColorScheme="custom"` and provide `ctaStyle`. + +```js file="./ProductNudgeCustomBrandExample.tsx" + +``` + +### Contact modal + +Use `ProductNudgeContactModal` to provide a contact or lead-capture form. Your application controls when the modal opens and what happens after submission. Set `titleText`, `descriptionText`, and `submitText` for the modal content. By default, the form has name, email, and phone fields; pass `fields` to configure the fields, including their names, labels, input types, placeholders, required state, autocomplete, and help text. The `onSubmit` callback receives an object keyed by each field's `name`. + +```js file="./ProductNudgeContactModalExample.tsx" + +``` + +### Data modal + +`ProductNudgeDataModal` can show built-in match charts or caller-provided `customContent`. To show the built-in charts, pass both `matchData` and `ecosystemData`. Otherwise, pass `customContent` to replace the chart region with any content—not only another chart. Supply the title and any desired description, footer copy, and actions. Chart data, colors, brand, icon, and partner logo are customizable. Use `contentAriaLabel` to label the scrollable content region, which wraps either the charts or `customContent`; use `titleIconSize` to resize the title icon. + +When using the built-in charts, load `@patternfly/patternfly/patternfly-charts.css` alongside the standard PatternFly styles. It provides chart design tokens, including theme-aware colors for dark mode. + +```js file="./ProductNudgeDataModalExample.tsx" + +``` + +### Custom modal options + +This example opens two separate modals: a data modal with a custom chart palette, title icon, and partner logo, and a contact modal with custom fields. The contact modal submits an object keyed by each field's `name`. + +```js file="./ProductNudgeCustomOptionsExample.tsx" + +``` + +### Custom content + +`customContent` fully replaces the built-in chart region with any caller-supplied content — it doesn't have to be a chart. + +```js file="./ProductNudgeCustomContentExample.tsx" + +``` + +### In-context stack + +`ProductNudgeStack` renders a compact, self-contained block with an optional title icon, heading, value or headline, body note, and inline CTA. Use it on its own or use `ProductNudge` with `prominence="field"` for the equivalent placement within the main component. + +```js file="./ProductNudgeStackExample.tsx" + +``` + +### Description list item + +`ProductNudgeDescriptionItem` renders a `DescriptionListGroup`. Place it inside an existing `DescriptionList` alongside other items. Set `termText` and optionally `termIcon`; the description can include a headline/value, body, and optional link. + +```js file="./ProductNudgeDescriptionItemExample.tsx" + +``` diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeAlertExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeAlertExample.tsx new file mode 100644 index 00000000..9b3decc7 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeAlertExample.tsx @@ -0,0 +1,22 @@ +import React from 'react'; +import ProductNudge from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; + +export const ProductNudgeAlertExample: React.FunctionComponent = () => ( + alert('CTA clicked')} + content={{ + id: 'example.alert', + headline: 'Application dependency risk can increase remediation effort across your OpenShift estate', + body: 'Lightwell, a joint effort between Red Hat and IBM, provides another remediation approach for qualifying application dependencies.', + cta: { + label: 'Learn more about Lightwell', + action: 'link', + href: 'https://www.redhat.com/en/lightwell', + }, + }} + /> +); diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeContactModalExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeContactModalExample.tsx new file mode 100644 index 00000000..a5171b79 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeContactModalExample.tsx @@ -0,0 +1,33 @@ +import React, { useState } from 'react'; +import { Button } from '@patternfly/react-core'; +import { ProductNudgeContactModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; + +export const ProductNudgeContactModalExample: React.FunctionComponent = () => { + const [ isModalOpen, setIsModalOpen ] = useState(false); + return ( + <> + + setIsModalOpen(false)} + brand="lightwell" + titleText="Contact us" + descriptionText="Leave your details and a Red Hat representative will get in touch about how Lightwell can help secure open source dependencies in your environment." + onSubmit={async (values) => { + // eslint-disable-next-line no-console + console.log('Contact form submitted:', values); + setIsModalOpen(false); + alert(`Contact form submitted: + name: ${values.name} + email: ${values.email} + phone: ${values.phone} + `); + }} + submitText="Submit" + successMessage="Thanks — a Red Hat representative will be in touch." + /> + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomBrandExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomBrandExample.tsx new file mode 100644 index 00000000..b6c2cf70 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomBrandExample.tsx @@ -0,0 +1,37 @@ +import React from 'react'; +import ProductNudge from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; + +// A consumer would point this at their own hosted logo asset; this example uses an inline +// SVG data URI so the demo has no external dependency. +const acmeLogo = `data:image/svg+xml;utf8,${encodeURIComponent( + 'Acme', +)}`; + +// No `brand` prop here — this demonstrates a fully custom, non-Lightwell nudge built +// entirely from caller-supplied text, images, colors, and sizing overrides. +export const ProductNudgeCustomBrandExample: React.FunctionComponent = () => ( + alert('CTA clicked')} + ctaColorScheme="custom" + ctaStyle={{ + '--pf-v6-c-button--BackgroundColor': '#6753ac', + '--pf-v6-c-button--hover--BackgroundColor': '#52408a', + '--pf-v6-c-button--Color': '#ffffff', + } as React.CSSProperties} + backgroundColor="#eef1fd" + logoSize="3rem" + logoOffset="0" + content={{ + id: 'example.custom-brand', + headline: 'Unlock deeper insights with Acme Analytics', + body: 'Acme Analytics connects your existing tools into a single, actionable dashboard.', + cta: { label: 'Try it free', action: 'link', href: 'https://example.com' }, + assets: { + logo: { src: acmeLogo, alt: 'Acme Analytics' }, + }, + }} + /> +); diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomContentExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomContentExample.tsx new file mode 100644 index 00000000..274be149 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomContentExample.tsx @@ -0,0 +1,35 @@ +import React, { useState } from 'react'; +import { Button, Content, List, ListItem } from '@patternfly/react-core'; +import { ProductNudgeDataModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; + +// `customContent` replaces the built-in match/ecosystem charts entirely — it accepts any +// caller-supplied content, not just a different chart. +export const ProductNudgeCustomContentExample: React.FunctionComponent = () => { + const [ isOpen, setIsOpen ] = useState(false); + + return ( + <> + + setIsOpen(false)} + titleText="What's included with Acme Pro" + descriptionText="This modal renders whatever content you pass in — no built-in chart required." + contentAriaLabel="Acme Pro feature list" + customContent={( + +

Acme Pro includes:

+ + Unlimited dashboards + Priority support + Single sign-on + +
+ )} + primaryAction={{ label: 'Upgrade now', onClick: () => alert('Upgraded!') }} + /> + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomOptionsExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomOptionsExample.tsx new file mode 100644 index 00000000..23708c0f --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeCustomOptionsExample.tsx @@ -0,0 +1,104 @@ +import React, { useState } from 'react'; +import { Button, Flex, FlexItem } from '@patternfly/react-core'; +import { ProductNudgeContactModal, ProductNudgeDataModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; + +// An inline SVG keeps the custom partner logo self-contained in this example. +const partnerLogo = { + src: `data:image/svg+xml;utf8,${encodeURIComponent( + 'Acme + Red Hat', + )}`, + alt: 'Acme and Red Hat', +}; + +export const ProductNudgeCustomOptionsExample: React.FunctionComponent = () => { + const [ isDataModalOpen, setIsDataModalOpen ] = useState(false); + const [ isContactModalOpen, setIsContactModalOpen ] = useState(false); + + return ( + <> + + + + + + + + + setIsDataModalOpen(false)} + matchData={{ exact: 118, partial: 195, noMatch: 534 }} + ecosystemData={[ + { name: 'Java', exact: 70, partial: 120, noMatch: 180 }, + { name: 'Python', exact: 50, partial: 80, noMatch: 170 }, + ]} + titleText="Acme dependency analysis" + titleIcon={( + + )} + partnerLogo={partnerLogo} + chartColors={[ '#6753ac', '#39a5dc', '#c9190b' ]} + descriptionText="Review package matches with a palette and brand assets supplied by your application." + footerText="Contact our team to discuss your results." + /> + setIsContactModalOpen(false)} + titleText="Talk to an Acme specialist" + descriptionText="Tell us a little about your organization and we'll be in touch." + submitText="Request a consultation" + fields={[ + { + name: 'company', + label: 'Company', + placeholder: 'Acme, Inc.', + isRequired: true, + autoComplete: 'organization', + }, + { + name: 'workEmail', + label: 'Work email', + type: 'email', + placeholder: 'you@example.com', + isRequired: true, + autoComplete: 'email', + }, + { + name: 'packageCount', + label: 'Number of packages', + type: 'number', + placeholder: '100', + helpText: 'An estimate is fine.', + }, + ]} + partnerLogo={partnerLogo} + onSubmit={async (values) => { + // eslint-disable-next-line no-console + console.log('Custom contact form submitted:', values); + setIsContactModalOpen(false); + alert(`Contact form submitted:\n${JSON.stringify(values, null, 2)}`); + }} + successMessage="Thanks — our team will be in touch." + /> + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDataModalExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDataModalExample.tsx new file mode 100644 index 00000000..4db60c79 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDataModalExample.tsx @@ -0,0 +1,30 @@ +import React, { useState } from 'react'; +import { Button } from '@patternfly/react-core'; +import { ProductNudgeDataModal } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; + +export const ProductNudgeDataModalExample: React.FunctionComponent = () => { + const [ isOpen, setIsOpen ] = useState(false); + + return ( + <> + + setIsOpen(false)} + brand="lightwell" + matchData={{ exact: 118, partial: 195, noMatch: 534 }} + ecosystemData={[ + { name: 'Java', exact: 70, partial: 120, noMatch: 180 }, + { name: 'Python', exact: 50, partial: 80, noMatch: 170 }, + ]} + titleText="Lightwell Lens" + descriptionText="Lightwell helps secure open source dependencies at scale with validated fixes for the versions you already run." + footerText="Download a shareable report with match results and remediation guidance." + primaryAction={{ label: 'Download report', onClick: () => alert('Report downloaded') }} + secondaryAction={{ label: 'Learn more about Lightwell', href: 'https://www.redhat.com/en/lightwell' }} + /> + + ); +}; diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDescriptionItemExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDescriptionItemExample.tsx new file mode 100644 index 00000000..dda001a8 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeDescriptionItemExample.tsx @@ -0,0 +1,19 @@ +import React from 'react'; +import { + DescriptionList +} from '@patternfly/react-core'; +import { ProductNudgeDescriptionItem } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; + +export const ProductNudgeDescriptionItemExample: React.FunctionComponent = () => ( + + + +); diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeHeroExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeHeroExample.tsx new file mode 100644 index 00000000..2c0bf8e1 --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeHeroExample.tsx @@ -0,0 +1,24 @@ +import React from 'react'; +import ProductNudge from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; +// The Lightwell hero background images are ~2MB and are intentionally excluded from the +// default `ProductNudge` import graph so non-Lightwell consumers never pay for them. Hero +// nudges that want the Lightwell background opt in with this one extra import. See the +// "Bundle size" note on this page. +import { lightwellBackgroundAssets } from '@patternfly/react-component-groups/dist/esm/ProductNudge/productNudgeLightwellBackgrounds'; + +export const ProductNudgeHeroExample: React.FunctionComponent = () => ( + alert('CTA clicked')} + content={{ + id: 'example.hero', + headline: 'Accelerate open-source adoption with Lightwell', + body: 'Lightwell maps your package catalog to the Red Hat ecosystem, so your teams can modernize faster with supported, enterprise-grade alternatives.', + cta: { label: 'Get in touch', action: 'contact' }, + assets: lightwellBackgroundAssets, + }} + /> +); diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeStackExample.tsx b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeStackExample.tsx new file mode 100644 index 00000000..439d212d --- /dev/null +++ b/packages/module/patternfly-docs/content/extensions/component-groups/examples/ProductNudge/ProductNudgeStackExample.tsx @@ -0,0 +1,14 @@ +import React from 'react'; +import { ProductNudgeStack } from '@patternfly/react-component-groups/dist/dynamic/ProductNudge'; + +export const ProductNudgeStackExample: React.FunctionComponent = () => ( + +); diff --git a/packages/module/patternfly-docs/content/extensions/component-groups/gallery-data.json b/packages/module/patternfly-docs/content/extensions/component-groups/gallery-data.json index ba193e6d..c62bf581 100644 --- a/packages/module/patternfly-docs/content/extensions/component-groups/gallery-data.json +++ b/packages/module/patternfly-docs/content/extensions/component-groups/gallery-data.json @@ -41,6 +41,9 @@ "page-header": { "summary": "A page header with a title, subtitle and other optional content." }, + "product-nudge": { + "summary": "A promotional nudge component for surfacing product offers in context." + }, "responsive-actions": { "summary": "Displays actions in a responsive layout." }, diff --git a/packages/module/patternfly-docs/patternfly-docs.css.js b/packages/module/patternfly-docs/patternfly-docs.css.js index 240ac3b2..41897d99 100644 --- a/packages/module/patternfly-docs/patternfly-docs.css.js +++ b/packages/module/patternfly-docs/patternfly-docs.css.js @@ -2,6 +2,8 @@ import '@patternfly/patternfly/patternfly.css'; // Patternfly utilities import '@patternfly/patternfly/patternfly-addons.css'; +// PatternFly chart tokens, including dark-theme chart colors +import '@patternfly/patternfly/patternfly-charts.css'; // Global theme CSS import '@patternfly/documentation-framework/global.css'; diff --git a/packages/module/patternfly-docs/patternfly-docs.source.js b/packages/module/patternfly-docs/patternfly-docs.source.js index f0127391..6dab0a88 100644 --- a/packages/module/patternfly-docs/patternfly-docs.source.js +++ b/packages/module/patternfly-docs/patternfly-docs.source.js @@ -7,10 +7,14 @@ module.exports = (sourceMD, sourceProps) => { const extensionPath = path.join(__dirname, '../src'); sourceProps(toPosixPath(path.join(extensionPath, '/**/*.tsx')), propsIgnore); + // Parse types before markdown pages so their propComponents entries can resolve. + sourceProps(toPosixPath(path.join(extensionPath, '/**/*.types.ts')), propsIgnore); + // Parse md files const contentBase = path.join(__dirname, './content'); sourceMD(toPosixPath(path.join(contentBase, 'extensions/**/*.md')), 'extensions'); + /** If you want to parse content from node_modules instead of providing a relative/absolute path, you can do something similar to this: diff --git a/packages/module/src/ProductNudge/ProductNudge.test.tsx b/packages/module/src/ProductNudge/ProductNudge.test.tsx new file mode 100644 index 00000000..0532a445 --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudge.test.tsx @@ -0,0 +1,272 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import ProductNudge from './ProductNudge'; +import { NudgeContent } from './ProductNudge.types'; + +const content: NudgeContent = { + id: 'test.nudge', + headline: 'Test headline', + body: 'Test body copy.', + cta: { label: 'Get in touch', action: 'contact' }, +}; + +const linkContent: NudgeContent = { + ...content, + cta: { label: 'Learn more', action: 'link', href: 'https://example.com' }, +}; + +describe('ProductNudge component', () => { + it('renders nothing when not eligible', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders hero prominence', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('applies the Lightwell CTA color when the brand is selected', () => { + render(); + + expect(screen.getByRole('button', { name: 'Get in touch' })).toHaveStyle({ + '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)', + }); + }); + + it('uses neutral CTA styling and consumer assets without a brand preset', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Get in touch' })).not.toHaveStyle({ + '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)', + }); + expect(screen.getByRole('img', { name: 'Custom' })).toHaveAttribute('src', 'custom-logo.svg'); + }); + + it('renders alert prominence with link CTA', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders alert prominence dismissible', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders a caller-provided alert icon', () => { + render( + Icon }} + isEligible + onAction={jest.fn()} + />, + ); + + expect(screen.getByTestId('custom-alert-icon')).toBeInTheDocument(); + }); + + it('renders with metrics', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders with default CTA color scheme', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('keeps dismissible field nudges dismissible', () => { + const onDismiss = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Dismiss Test headline' })); + + expect(onDismiss).toHaveBeenCalledTimes(1); + expect(screen.queryByText('Test headline')).not.toBeInTheDocument(); + }); + + it('routes contact CTAs in field prominence to the action callback', () => { + const onAction = jest.fn(); + render( + , + ); + + fireEvent.click(screen.getByRole('button', { name: 'Get in touch' })); + + expect(onAction).toHaveBeenCalledTimes(1); + }); + + it('preserves prominence styling when content is collapsible', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.pf-v6-c-hero')).toBeInTheDocument(); + expect(screen.getByRole('button', { name: 'Test headline' })).toBeInTheDocument(); + }); + + it('lets consumers choose a heading level for the page context', () => { + render( + , + ); + + expect(screen.getByRole('heading', { level: 2, name: 'Test headline' })).toBeInTheDocument(); + }); + + it('renders the Lightwell alert mark at 28px', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.pf-v6-c-alert__icon img')).toHaveStyle({ width: '1.75rem', height: '1.75rem' }); + }); + + it('allows overriding the alert mark size and offset', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.pf-v6-c-alert__icon img')).toHaveStyle({ + width: '2rem', + height: '2rem', + marginInlineStart: '0', + }); + }); + + it('allows overriding the hero logo size and offset', () => { + render( + , + ); + + expect(screen.getByRole('img', { name: 'Lightwell' })).toHaveStyle({ + width: '4rem', + marginInlineStart: '0', + }); + }); + + it('allows overriding the hero background color', () => { + const { container } = render( + , + ); + + expect(container.querySelector('.pf-v6-c-hero')).toHaveStyle({ '--pn-nudge-background-color': '#123456' }); + }); + + it('allows overriding chrome copy for dismiss, show-less, and show-details labels', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Close Test headline' })).toBeInTheDocument(); + }); + + it('applies ctaColorScheme="custom" using the caller-supplied ctaStyle', () => { + render( + , + ); + + expect(screen.getByRole('button', { name: 'Get in touch' })).toHaveStyle({ + '--pf-v6-c-button--BackgroundColor': '#00ff00', + }); + }); +}); diff --git a/packages/module/src/ProductNudge/ProductNudge.tsx b/packages/module/src/ProductNudge/ProductNudge.tsx new file mode 100644 index 00000000..45ac2754 --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudge.tsx @@ -0,0 +1,473 @@ +import type { FunctionComponent } from 'react'; +import { useState } from 'react'; + +import { + Alert, + AlertActionCloseButton, + AlertActionLink, + Button, + Content, + ExpandableSection, + Flex, + FlexItem, + Hero, + Stack, + StackItem, + Title, +} from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; +import TimesIcon from '@patternfly/react-icons/dist/esm/icons/times-icon.js'; +import { createUseStyles } from 'react-jss'; + +import ErrorBoundary from '../ErrorBoundary'; +import { useImpressionTracking } from './useImpressionTracking'; +import { ProductNudgeStack } from './ProductNudgeStack'; +import { + createImageSizeStyle, + createSquareImageSizeStyle, + LIGHTWELL_BACKGROUND_COLOR, + lightwellCtaStyle, + nudgeModeStyles, +} from './nudgeStyles'; +import { ProductNudgeBrandLogo } from './ProductNudgeBrandLogo'; +import { lightwellBrandAssets } from './productNudgeDefaults'; +import { + ProductNudgeBrand, + ProductNudgeProminence, + ProductNudgeBehavior, + NudgeContent, + NudgeMetric, + ProductNudgeCtaColorScheme, + ProductNudgeHeadingLevel, +} from './ProductNudge.types'; + +export interface ProductNudgeProps { + /** Visual layout variant */ + prominence: ProductNudgeProminence; + /** Interaction behavior; defaults to 'persistent' */ + behavior?: ProductNudgeBehavior; + /** All displayable content */ + content: NudgeContent; + /** Metrics displayed in a row below the body text */ + metrics?: NudgeMetric[]; + /** false renders null and suppresses impression tracking */ + isEligible: boolean; + /** Shows a loading spinner and disables a non-link CTA while its action is in progress. */ + isLoading?: boolean; + /** Called when a non-link CTA is clicked */ + onAction: () => void; + /** Called when the user dismisses (behavior='dismissible') */ + onDismiss?: () => void; + /** Called once when the component is 50% visible in the viewport */ + onImpression?: () => void; + /** Applies a named brand preset; copy remains entirely caller-supplied. */ + brand?: ProductNudgeBrand; + /** CTA color scheme; defaults to the selected brand or PatternFly styling. */ + ctaColorScheme?: ProductNudgeCtaColorScheme; + /** CTA inline style applied when `ctaColorScheme="custom"`. */ + ctaStyle?: React.CSSProperties; + /** Heading level for the headline; defaults to the existing level for the selected prominence. */ + headingLevel?: ProductNudgeHeadingLevel; + /** Width of the full logo (hero/alert); defaults to '6rem'. */ + logoSize?: string; + /** Inline-start margin of the full logo, used to compensate for an asset's internal padding; defaults to '-10px' for the Lightwell logo, '0' for a caller-supplied logo. */ + logoOffset?: string; + /** Width/height of the small logo mark (alert icon); defaults to '1.75rem'. */ + logoMarkSize?: string; + /** Inline-start margin of the small logo mark; defaults to '-3px' for the Lightwell mark, '0' for a caller-supplied mark. */ + logoMarkOffset?: string; + /** Hero background color; defaults to the Lightwell tint when `brand="lightwell"`, otherwise unset. */ + backgroundColor?: string; + /** Additional CSS class forwarded to the root element */ + className?: string; + /** OUIA component ID */ + ouiaId?: string; + /** Prefix used in the dismiss button's aria-label, followed by the headline. */ + dismissAriaLabelPrefix?: string; + /** Text for the "show less" toggle when `behavior="collapsible"`. */ + showLessText?: string; + /** Prefix used in the alert's expand/collapse toggle aria-label, followed by the headline. */ + showDetailsAriaLabelPrefix?: string; +} + +const useStyles = createUseStyles({ + nudge: { + padding: 'var(--pf-t--global--spacer--lg)', + }, + alertIcon: { + display: 'block', + }, + logo: { + display: 'block', + }, + ...nudgeModeStyles, + heroBg: { + '--pf-v6-c-hero--BackgroundColor': 'var(--pn-nudge-background-color)', + '.pf-v6-theme-dark &': { + '--pf-v6-c-hero--BackgroundColor': 'var(--pf-t--color--black)', + }, + }, + heroContent: { + maxWidth: '75%', + }, + heroDismiss: { + position: 'absolute', + insetBlockStart: 'var(--pf-t--global--spacer--md)', + insetInlineEnd: 'var(--pf-t--global--spacer--md)', + }, + disclosure: { + display: 'block', + marginBlockStart: 'var(--pf-t--global--spacer--xs)', + }, + metricsRow: { + alignItems: 'flex-start', + }, +}); + +const formatMetricValue = (value: string | number, format: 'percentage' | 'count' | 'text') => { + if (format === 'percentage') { + return `${value}%`; + } + return value; +}; + +const ProductNudgeContent: FunctionComponent = ({ + prominence, + behavior = 'persistent', + content, + metrics = [], + isEligible, + isLoading = false, + onAction, + onDismiss, + onImpression, + ctaColorScheme, + ctaStyle: customCtaStyle, + headingLevel, + logoSize, + logoOffset, + logoMarkSize, + logoMarkOffset, + backgroundColor, + brand, + className, + ouiaId = 'ProductNudge', + dismissAriaLabelPrefix = 'Dismiss ', + showLessText = 'Show less', + showDetailsAriaLabelPrefix = 'Show details for ', +}: ProductNudgeProps) => { + const classes = useStyles(); + const [ isDismissed, setIsDismissed ] = useState(false); + const [ isExpanded, setIsExpanded ] = useState(false); + const impressionRef = useImpressionTracking(onImpression, isEligible && !isLoading); + + if (!isEligible || isDismissed) { + return null; + } + + const handleDismiss = () => { + setIsDismissed(true); + onDismiss?.(); + }; + + const assets = { + ...(brand === 'lightwell' ? lightwellBrandAssets : {}), + ...content.assets, + }; + const hasCustomLogo = Boolean(content.assets?.logo); + const resolvedLogoStyle = createImageSizeStyle( + logoSize ?? '6rem', + logoOffset ?? (hasCustomLogo ? '0' : '-10px'), + ); + const hasCustomLogoMark = Boolean(content.assets?.logo || content.assets?.logomark); + const resolvedLogoMarkStyle = createSquareImageSizeStyle( + logoMarkSize ?? '1.75rem', + logoMarkOffset ?? (hasCustomLogoMark ? '0' : '-3px'), + ); + const resolvedBackgroundColor = backgroundColor ?? (brand === 'lightwell' ? LIGHTWELL_BACKGROUND_COLOR : undefined); + const ctaHref = content.cta.action === 'link' ? content.cta.href : undefined; + const resolvedCtaColorScheme = ctaColorScheme ?? (brand === 'lightwell' ? 'lightwell' : 'default'); + const ctaStyleByScheme: Record = { + lightwell: lightwellCtaStyle, + custom: customCtaStyle, + default: undefined, + }; + const ctaStyle = ctaStyleByScheme[resolvedCtaColorScheme]; + + const metricsRow = metrics.length > 0 && ( + + {metrics.map((metric) => ( + + + {formatMetricValue(metric.value, metric.format)} {metric.label} + + + ))} + + ); + + const dismissControl = behavior === 'dismissible' && ( + + ) : ( + + ); + + const cta = ( + + {ctaButton} + {(assets.partnerLogo || assets.partnerLogoDark) && ( + + + + )} + + ); + + const body = ( + + {assets.logo && ( + + {assets.logo.alt} + {assets.logoDark && ( + {assets.logoDark.alt} + )} + + )} + + + {content.headline} + + + + + {content.body} + + {content.secondaryBody && {content.secondaryBody}} + {content.disclosure && ( + + {content.disclosure} + + )} + + {metricsRow && {metricsRow}} + {cta} + + ); + + const nudgePaddingClass = prominence === 'hero' || prominence === 'alert' ? undefined : classes.nudge; + + const rootClassName = css( + 'pf-v6-product-nudge', + `pf-v6-product-nudge--${prominence}`, + nudgePaddingClass, + className, + ); + + const withExpandableContent = (children: React.ReactNode) => behavior === 'collapsible' ? ( + setIsExpanded(expanded)} + isExpanded={isExpanded} + > + {children} + + ) : children; + + if (prominence === 'hero') { + return ( +
+ +
{withExpandableContent(body)}
+ {dismissControl && ( +
{dismissControl}
+ )} +
+
+ ); + } + + if (prominence === 'alert') { + const alertIconImage = content.assets?.logo ?? assets.logomark ?? assets.logo; + const alertIconDark = content.assets?.logoDark ?? assets.logomarkDark ?? assets.logoDark; + const alertIcon = content.icon ?? (alertIconImage ? ( + <> + + {alertIconDark && ( + + )} + + ) : undefined); + + const alertCta = + content.cta.action === 'link' ? ( + + {content.cta.label} + + ) : ( + + {content.cta.label} + + ); + + return ( +
+ + ) : undefined} + actionLinks={alertCta} + ouiaId={`${ouiaId}-alert`} + > + {content.body} + {content.secondaryBody && {content.secondaryBody}} + {content.disclosure && {content.disclosure}} + +
+ ); + } + + if (prominence === 'field') { + const fieldValue = metrics[0] + ? String(formatMetricValue(metrics[0].value, metrics[0].format)) + : undefined; + const fieldContent = ( + + ); + + return ( +
+ + {withExpandableContent(fieldContent)} + {dismissControl && {dismissControl}} + +
+ ); + } + + return null; +}; + +const ProductNudge: FunctionComponent = (props: ProductNudgeProps) => ( + + + +); + +export default ProductNudge; diff --git a/packages/module/src/ProductNudge/ProductNudge.types.ts b/packages/module/src/ProductNudge/ProductNudge.types.ts new file mode 100644 index 00000000..18d37bcb --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudge.types.ts @@ -0,0 +1,126 @@ +import React from 'react'; + +export type ProductNudgeBrand = 'lightwell'; + +export interface ProductNudgeImage { + src: string; + alt: string; +} + +/** Brand images that can be applied by a named brand preset or overridden by consumers. */ +export interface ProductNudgeBrandAssets { + logo?: ProductNudgeImage; + logoDark?: ProductNudgeImage; + logomark?: ProductNudgeImage; + logomarkDark?: ProductNudgeImage; + backgroundImageLight?: string; + backgroundImageDark?: string; + partnerLogo?: ProductNudgeImage; + partnerLogoDark?: ProductNudgeImage; +} + +export interface NudgeContact { + /** Modal title text (used for aria-label) */ + title: string; + /** Introductory paragraph shown above the form */ + intro: string | React.ReactNode; + /** Message shown in place of the form after a successful submit */ + successMessage: string | React.ReactNode; +} + +export interface NudgeContent { + /** Unique content identifier, e.g. 'product.overview' */ + id: string; + /** Primary heading text */ + headline: string; + /** Primary body paragraph */ + body: string | React.ReactNode; + /** Optional secondary body paragraph */ + secondaryBody?: string | React.ReactNode; + /** Optional hedged legal/disclaimer text rendered in a small element */ + disclosure?: string | React.ReactNode; + /** Call-to-action configuration */ + cta: + | { + /** Button label */ + label: string; + /** 'contact' fires onAction */ + action: 'contact'; + href?: never; + } + | { + /** Link label */ + label: string; + /** 'link' renders an anchor to href */ + action: 'link'; + /** Required when action is 'link' */ + href: string; + }; + /** Contact modal content; required when cta.action is 'contact' */ + contact?: NudgeContact; + /** Optional React icon rendered for icon-bearing prominence modes. */ + icon?: React.ReactNode; + /** Visual severity when prominence is `alert`; defaults to `info`. */ + alertVariant?: 'success' | 'warning' | 'danger' | 'info'; + /** Optional brand imagery */ + assets?: ProductNudgeBrandAssets; +} + +/** Format applied to a metric value for display */ +export type NudgeMetricFormat = 'percentage' | 'count' | 'text'; + +export interface NudgeMetric { + /** Display label, e.g. 'covered packages' */ + label: string; + /** Numeric or string value */ + value: string | number; + /** How to format the value for display */ + format: NudgeMetricFormat; + +} + +/** Visual weight / layout variant */ +export type ProductNudgeProminence = 'hero' | 'alert' | 'field'; + +/** Interaction pattern */ +export type ProductNudgeBehavior = 'persistent' | 'dismissible' | 'collapsible'; + +/** + * CTA color scheme; 'lightwell' applies the Lightwell red accent, 'default' uses PatternFly + * styling, 'custom' applies the caller-supplied `ctaStyle` prop. + */ +export type ProductNudgeCtaColorScheme = 'lightwell' | 'default' | 'custom'; + +/** Heading element used for the nudge headline. */ +export type ProductNudgeHeadingLevel = 'h1' | 'h2' | 'h3' | 'h4' | 'h5' | 'h6'; + +export interface ProductNudgeAction { + label: React.ReactNode; + href?: string; + onClick?: () => void; +} + +export interface ProductNudgeContactFormField { + /** Stable key used in the submitted values object. */ + name: string; + /** Accessible field label. */ + label: React.ReactNode; + /** Native input type. */ + type?: 'text' | 'date' | 'datetime-local' | 'email' | 'month' | 'number' | 'password' | 'search' | 'tel' | 'time' | 'url'; + placeholder?: string; + isRequired?: boolean; + autoComplete?: string; + helpText?: React.ReactNode; +} + +export type ContactFormValues = Record; + +export interface ProductNudgeMatchData { + exact: number; + partial: number; + noMatch: number; +} + +export interface ProductNudgeEcosystemData extends ProductNudgeMatchData { + name: string | React.ReactNode; +} diff --git a/packages/module/src/ProductNudge/ProductNudgeBrandLogo.tsx b/packages/module/src/ProductNudge/ProductNudgeBrandLogo.tsx new file mode 100644 index 00000000..a1b3a229 --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeBrandLogo.tsx @@ -0,0 +1,51 @@ +import { FunctionComponent } from 'react'; +import { css } from '@patternfly/react-styles'; +import { createUseStyles } from 'react-jss'; + +import { ProductNudgeImage } from './ProductNudge.types'; +import { nudgeModeStyles, partnerLogoStyles } from './nudgeStyles'; + +const useStyles = createUseStyles({ + partnerLogo: partnerLogoStyles, + ...nudgeModeStyles, +}); + +export interface ProductNudgeBrandLogoProps { + light?: ProductNudgeImage; + dark?: ProductNudgeImage; + className?: string; +} + +/** Shared light/dark-mode rendering for an optional partner logo. */ +export const ProductNudgeBrandLogo: FunctionComponent = ({ + light, + dark, + className, +}) => { + const classes = useStyles(); + + if (!light && !dark) { + return null; + } + + return ( + <> + {light && ( + {light.alt} + )} + {dark && ( + {dark.alt} + )} + + ); +}; + +export default ProductNudgeBrandLogo; diff --git a/packages/module/src/ProductNudge/ProductNudgeContactModal.test.tsx b/packages/module/src/ProductNudge/ProductNudgeContactModal.test.tsx new file mode 100644 index 00000000..b60fa64e --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeContactModal.test.tsx @@ -0,0 +1,169 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import ProductNudgeContactModal from './ProductNudgeContactModal'; + +describe('ProductNudgeContactModal component', () => { + it('renders when open', () => { + const { container } = render( + , + ); + const dialog = screen.getByRole('dialog', { name: 'Get in touch' }); + expect(dialog).toBeInTheDocument(); + + const logomarkImages = dialog.querySelectorAll('img[alt=""]'); + expect(logomarkImages).toHaveLength(2); + expect(logomarkImages[0].className).toContain('lightModeOnly'); + expect(logomarkImages[1].className).toContain('darkModeOnly'); + expect(logomarkImages[0].parentElement).toHaveStyle({ width: '1.5rem', height: '1.5rem' }); + expect(logomarkImages[0]).toHaveStyle({ width: '100%', height: '100%' }); + const partnerLogoImages = dialog.querySelectorAll('img[alt="Red Hat and IBM"]'); + expect(partnerLogoImages).toHaveLength(2); + expect(partnerLogoImages[0].className).toContain('lightModeOnly'); + expect(partnerLogoImages[1].className).toContain('darkModeOnly'); + const footer = dialog.querySelector('.pf-v6-c-modal-box__footer'); + expect(footer).toContainElement(screen.getByRole('button', { name: 'Send request' })); + expect(footer).toContainElement(partnerLogoImages[0]); + expect(screen.getByRole('button', { name: 'Send request' })).toHaveStyle({ + '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)' + }); + + expect(container).toMatchSnapshot(); + }); + + it('renders closed (nothing visible)', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders supplied field placeholders and submits ContactFormValues', async () => { + const onSubmit = jest.fn().mockResolvedValue(undefined); + const { container } = render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('Enter your name'), { target: { value: 'Jane Doe' } }); + fireEvent.change(screen.getByPlaceholderText('Enter your email'), { target: { value: 'jane@example.com' } }); + fireEvent.change(screen.getByPlaceholderText('Enter your phone'), { target: { value: '555-0100' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Send request' })); + }); + + expect(onSubmit).toHaveBeenCalledWith({ name: 'Jane Doe', email: 'jane@example.com', phone: '555-0100' }); + expect(container).toMatchSnapshot(); + }); + + it('renders configured fields and submits values using their names', async () => { + const onSubmit = jest.fn().mockResolvedValue(undefined); + render( + , + ); + + fireEvent.change(screen.getByPlaceholderText('name@example.com'), { target: { value: 'jane@example.com' } }); + fireEvent.change(screen.getByPlaceholderText('10+'), { target: { value: '12' } }); + await act(async () => { + fireEvent.click(screen.getByRole('button', { name: 'Send' })); + }); + + expect(onSubmit).toHaveBeenCalledWith({ workEmail: 'jane@example.com', teamSize: '12' }); + }); + + it('disables repeat submission and reports a failed submission', async () => { + let rejectSubmission: (error: Error) => void = () => undefined; + const onSubmit = jest.fn(() => new Promise((_resolve, reject) => { + rejectSubmission = reject; + })); + + render( + , + ); + + const submitButton = screen.getByRole('button', { name: 'Send request' }); + fireEvent.click(submitButton); + expect(submitButton).toBeDisabled(); + + fireEvent.click(submitButton); + expect(onSubmit).toHaveBeenCalledTimes(1); + + await act(async () => { + rejectSubmission(new Error('Request failed')); + }); + expect(await screen.findByText('Please try again. If the problem continues, contact support.')).toBeInTheDocument(); + expect(submitButton).toBeEnabled(); + }); + + it('allows overriding the title icon size, error copy, and content aria-label', async () => { + let rejectSubmission: (error: Error) => void = () => undefined; + const onSubmit = jest.fn(() => new Promise((_resolve, reject) => { + rejectSubmission = reject; + })); + + render( + , + ); + + const dialog = screen.getByRole('dialog', { name: 'Get in touch' }); + const logomarkImages = dialog.querySelectorAll('img[alt=""]'); + expect(logomarkImages[0].parentElement).toHaveStyle({ width: '2rem', height: '2rem' }); + expect(screen.getByRole('region', { name: 'Custom region label' })).toBeInTheDocument(); + + fireEvent.click(screen.getByRole('button', { name: 'Send request' })); + await act(async () => { + rejectSubmission(new Error('Request failed')); + }); + + expect(await screen.findByText('Custom error message')).toBeInTheDocument(); + expect(screen.getByText('Custom error title')).toBeInTheDocument(); + }); +}); diff --git a/packages/module/src/ProductNudge/ProductNudgeContactModal.tsx b/packages/module/src/ProductNudge/ProductNudgeContactModal.tsx new file mode 100644 index 00000000..a86ce793 --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeContactModal.tsx @@ -0,0 +1,281 @@ +import { useEffect, useRef, useState, type ComponentType, type CSSProperties, type FormEvent, type FunctionComponent, type ReactNode } from 'react'; +import { + Button, + Alert, + FlexItem, + Form, + FormGroup, + HelperText, + HelperTextItem, + Modal, + ModalBody, + ModalHeader, + ModalVariant, + TextInput +} from '@patternfly/react-core'; +import { createUseStyles } from 'react-jss'; +import { + type ContactFormValues, + type ProductNudgeBrand, + type ProductNudgeContactFormField, + type ProductNudgeCtaColorScheme, + type ProductNudgeImage, +} from './ProductNudge.types'; +import { createSquareImageSizeStyle, lightwellCtaStyle, nudgeModeStyles } from './nudgeStyles'; +import { lightwellBrandAssets, LightwellLogomark, LightwellLogomarkDark } from './productNudgeDefaults'; +import { ProductNudgeModalFooter } from './ProductNudgeModalFooter'; + +export interface ProductNudgeContactModalProps { + /** Whether the modal is open */ + isOpen: boolean; + /** Callback to close the modal */ + onClose: () => void; + /** Modal title */ + titleText: ReactNode; + /** Optional icon or component shown beside the modal title */ + titleIcon?: ComponentType; + /** Optional icon node shown beside the modal title; takes precedence over titleIcon. */ + headerIcon?: ReactNode; + /** Optional description shown below the modal title */ + descriptionText?: ReactNode; + /** Submit button label */ + submitText: ReactNode; + /** Placeholder for the name field */ + namePlaceholder?: string; + /** Placeholder for the email field */ + emailPlaceholder?: string; + /** Placeholder for the phone field */ + phonePlaceholder?: string; + /** Configurable fields. Defaults to the legacy name, email, and phone fields. */ + fields?: ProductNudgeContactFormField[]; + /** Optional message displayed after a successful submission. */ + successMessage?: ReactNode; + /** Receives values keyed by each configured field's name. */ + onSubmit: (values: ContactFormValues) => Promise; + /** Applies a named brand preset; copy remains entirely caller-supplied. */ + brand?: ProductNudgeBrand; + /** Footer partner logo override. */ + partnerLogo?: ProductNudgeImage; + /** Dark-mode footer partner logo override. */ + partnerLogoDark?: ProductNudgeImage; + /** Override the primary CTA visual scheme. */ + ctaColorScheme?: ProductNudgeCtaColorScheme; + /** CTA inline style applied when `ctaColorScheme="custom"`. */ + ctaStyle?: CSSProperties; + /** Width/height of the title icon container; defaults to '1.5rem'. */ + titleIconSize?: string; + /** Error alert title shown after a failed submission. */ + errorTitle?: ReactNode; + /** Error alert message shown after a failed submission. */ + errorMessage?: ReactNode; + /** aria-label applied to the scrollable modal body region. */ + contentAriaLabel?: string; + /** Prefix for generated form and accessibility IDs. */ + id?: string; +} + +const useStyles = createUseStyles({ + modal: { + '--pf-v6-c-modal-box__header--PaddingBlockStart': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__header--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__header--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__body--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__body--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__footer--PaddingBlockEnd': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__footer--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__footer--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)' + }, + modalBody: { + marginInlineEnd: 'var(--pf-v6-c-modal-box__close--sibling--MarginInlineEnd)' + }, + modalFooter: { + marginInlineEnd: 'var(--pf-v6-c-modal-box__close--sibling--MarginInlineEnd)' + }, + titleIcon: { + '& img': { + width: '100%', + height: '100%' + } + }, + ...nudgeModeStyles +}); + +let contactModalInstance = 0; + +const createLightwellContactTitleIcon = (titleIconSize: string): FunctionComponent => () => { + const classes = useStyles(); + + return ( +
+ + +
+ ); +}; + +export const ProductNudgeContactModal = ({ + isOpen, + namePlaceholder = '', + emailPlaceholder = '', + phonePlaceholder = '', + onClose, + onSubmit, + titleText, + titleIcon, + headerIcon, + descriptionText, + submitText, + fields, + successMessage, + brand, + partnerLogo, + partnerLogoDark, + ctaColorScheme, + ctaStyle: customCtaStyle, + titleIconSize = '1.5rem', + errorTitle = 'Unable to submit request', + errorMessage = 'Please try again. If the problem continues, contact support.', + contentAriaLabel = 'Contact request details', + id, +}: ProductNudgeContactModalProps) => { + const classes = useStyles(); + const idRef = useRef(undefined); + if (!idRef.current) { + idRef.current = id ?? `product-nudge-contact-${++contactModalInstance}`; + } + const idPrefix = id ?? idRef.current; + const resolvedFields: ProductNudgeContactFormField[] = fields ?? [ + { name: 'name', label: 'Name', type: 'text', placeholder: namePlaceholder, isRequired: true, autoComplete: 'name' }, + { name: 'email', label: 'E-mail', type: 'email', placeholder: emailPlaceholder, isRequired: true, autoComplete: 'email' }, + { name: 'phone', label: 'Phone', type: 'tel', placeholder: phonePlaceholder, autoComplete: 'tel' }, + ]; + const [ values, setValues ] = useState({}); + const [ isSubmitted, setIsSubmitted ] = useState(false); + const [ isSubmitting, setIsSubmitting ] = useState(false); + const [ hasSubmitError, setHasSubmitError ] = useState(false); + const resolvedLogo = partnerLogo ?? (brand === 'lightwell' ? lightwellBrandAssets.partnerLogo : undefined); + const resolvedLogoDark = partnerLogoDark ?? (brand === 'lightwell' && !partnerLogo ? lightwellBrandAssets.partnerLogoDark : undefined); + const resolvedCtaColorScheme = ctaColorScheme ?? (brand === 'lightwell' ? 'lightwell' : 'default'); + const ctaStyleByScheme: Record = { + lightwell: lightwellCtaStyle, + custom: customCtaStyle, + default: undefined, + }; + const ctaStyle = ctaStyleByScheme[resolvedCtaColorScheme]; + + const titleIconComponent = headerIcon + ? () => <>{headerIcon} + : titleIcon ?? (brand === 'lightwell' ? createLightwellContactTitleIcon(titleIconSize) : undefined); + + useEffect(() => { + if (isOpen) { + setValues({}); + setIsSubmitted(false); + setHasSubmitError(false); + } + }, [ isOpen ]); + + const handleSubmit = async (event: FormEvent) => { + event.preventDefault(); + if (isSubmitting) { + return; + } + + setIsSubmitting(true); + setHasSubmitError(false); + try { + await onSubmit(values); + if (successMessage) { + setIsSubmitted(true); + } + } catch { + setHasSubmitError(true); + } finally { + setIsSubmitting(false); + } + }; + + return ( + + + + {isSubmitted ? successMessage : ( +
+ {hasSubmitError && ( + + {errorMessage} + + )} + {resolvedFields.map((field) => { + const fieldId = `${idPrefix}-${field.name.replace(/[^a-zA-Z0-9_-]/g, '-')}`; + return ( + + { + setHasSubmitError(false); + setValues((current) => ({ ...current, [field.name]: value })); + }} + placeholder={field.placeholder} + autoComplete={field.autoComplete} + aria-describedby={field.helpText ? `${fieldId}-help` : undefined} + /> + {field.helpText && ( + + {field.helpText} + + )} + + ); + })} +
+ )} +
+ + + + )} + /> +
+ ); +}; + +export default ProductNudgeContactModal; diff --git a/packages/module/src/ProductNudge/ProductNudgeDataModal.test.tsx b/packages/module/src/ProductNudge/ProductNudgeDataModal.test.tsx new file mode 100644 index 00000000..648ac446 --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeDataModal.test.tsx @@ -0,0 +1,210 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import ProductNudgeDataModal from './ProductNudgeDataModal'; + +const lightwellProps = { + brand: 'lightwell' as const, + matchData: { exact: 118, partial: 195, noMatch: 534 }, + ecosystemData: [ + { name: 'Java', exact: 70, partial: 120, noMatch: 180 }, + { name: 'Python', exact: 50, partial: 80, noMatch: 170 }, + ], + titleText: 'Lightwell Lens', + descriptionText: 'Lightwell match analysis.', + footerText: 'Download a full, shareable report with detailed match results and remediation guidance.', + primaryAction: { label: 'Download report', onClick: jest.fn() }, + secondaryAction: { label: 'Learn more about Lightwell', href: 'https://www.redhat.com/en/lightwell' }, +}; + +describe('ProductNudgeDataModal component', () => { + it('renders when open', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('associates the dialog description and labels its scrollable body', () => { + render(); + + const dialog = screen.getByRole('dialog', { name: 'Lightwell Lens' }); + const descriptionId = dialog.getAttribute('aria-describedby'); + + expect(descriptionId).toBeTruthy(); + expect(document.getElementById(descriptionId ?? '')).toHaveTextContent('Lightwell match analysis.'); + expect(dialog.querySelector('[aria-label="Additional details"]')).toBeInTheDocument(); + }); + + it('renders closed', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders supplied match analysis data', () => { + render( + , + ); + + expect(screen.getByText('50%')).toBeInTheDocument(); + expect(screen.getByText('10', { selector: 'strong' })).toBeInTheDocument(); + }); + + it('renders the redesigned two-section analysis content', () => { + render(); + + const dialog = screen.getByRole('dialog', { name: 'Lightwell Lens' }); + const productTitle = screen.getByRole('heading', { name: 'Lightwell Lens' }); + expect(dialog).toContainElement(productTitle); + const titleLogo = productTitle.parentElement?.querySelector('img'); + expect(titleLogo).toBeInTheDocument(); + expect(titleLogo?.parentElement).toHaveStyle({ width: '1.5rem', height: '1.5rem' }); + expect(titleLogo).toHaveStyle({ width: '100%', height: '100%' }); + + const sectionsFlex = dialog.querySelector('.pf-v6-l-flex.pf-m-column.pf-m-row-on-md.pf-m-gap-2xl'); + expect(sectionsFlex?.children).toHaveLength(2); + expect(sectionsFlex?.children[0]).toHaveClass('pf-m-flex-1'); + expect(sectionsFlex?.children[1]).toHaveClass('pf-m-flex-1'); + const sectionStack = sectionsFlex?.children[0].firstElementChild; + expect(sectionStack).toHaveClass('pf-v6-l-stack', 'pf-m-gutter'); + expect(sectionStack?.firstElementChild?.getAttribute('class')).toBe(''); + expect(sectionStack?.firstElementChild?.firstElementChild).toHaveClass('pf-v6-l-stack', 'pf-m-gutter'); + + expect(screen.getByRole('heading', { name: 'Match analysis' })).toBeInTheDocument(); + expect(screen.getByRole('heading', { name: 'By ecosystem' })).toBeInTheDocument(); + expect(screen.getByText('See how packages map to supported ecosystems.')).toBeInTheDocument(); + expect(screen.getByText(/Download a full, shareable report/)).toBeInTheDocument(); + }); + + it('renders responsive chart dimensions, series legend, and ecosystem labels', () => { + render( + + ); + + const chartViewport = screen.getByRole('region', { name: 'By ecosystem chart' }); + const chart = chartViewport.querySelector('svg'); + expect(chart).toHaveAttribute('viewBox'); + expect(chart).toHaveAttribute('height', '158'); + expect(screen.getByText('Exact match')).toBeInTheDocument(); + expect(screen.getByText('Partial match')).toBeInTheDocument(); + expect(screen.getByText('No match')).toBeInTheDocument(); + expect(screen.getByText('Java')).toBeInTheDocument(); + expect(screen.getByText('Python')).toBeInTheDocument(); + + expect(chart?.querySelector('desc')).toHaveTextContent( + 'Java: 5 exact matches, 10 partial matches, 15 with no match', + ); + + expect(chart?.querySelector('path[style*="lightwell-chart-color-exact"]')).toBeInTheDocument(); + expect(chart?.querySelector('path[style*="lightwell-chart-color-partial"]')).toBeInTheDocument(); + const legendSwatches = Array.from(chart?.querySelectorAll('path') ?? []).slice(-3); + expect(legendSwatches[0]).toHaveAttribute('style', expect.stringContaining('lightwell-chart-color-exact')); + expect(legendSwatches[1]).toHaveAttribute('style', expect.stringContaining('lightwell-chart-color-partial')); + expect(legendSwatches[2]).toHaveAttribute( + 'style', + expect.stringContaining( + 'lightwell-chart-color-no-match, var(--pf-t--chart--global--fill--color--200, #e0e0e0)', + ), + ); + }); + + it('uses the caller-provided chart palette for ecosystem legend swatches', () => { + const chartColors = [ '#123456', '#234567', '#345678' ]; + render( + , + ); + + const chart = screen.getByRole('region', { name: 'By ecosystem chart' }).querySelector('svg'); + const legendSwatches = Array.from(chart?.querySelectorAll('path') ?? []).slice(-3); + expect(legendSwatches.map((swatch) => swatch.style.fill)).toEqual(chartColors); + }); + + it('shows match type and value in ecosystem bar tooltips', async () => { + render(); + + const chart = screen.getByRole('region', { name: 'By ecosystem chart' }).querySelector('svg'); + const exactBar = chart?.querySelector('path[style*="lightwell-chart-color-exact"]'); + + if (!exactBar) { + throw new Error('Expected to find the exact-match bar'); + } + fireEvent.mouseOver(exactBar); + + expect(await screen.findByText('Exact match: 70')).toBeInTheDocument(); + }); + + it('applies the Lightwell CTA color inline', () => { + render(); + + expect(screen.getByRole('button', { name: 'Download report' })).toHaveStyle({ + '--pf-v6-c-button--BackgroundColor': 'var(--pf-t--color--red--50)', + }); + }); + + it('supports caller content and actions without adding Lightwell copy', () => { + const onClick = jest.fn(); + render( + Custom analysis placement} + footerText="Your custom report is ready." + primaryAction={{ label: 'Open report', onClick }} + />, + ); + + expect(screen.getByRole('dialog', { name: 'Coverage overview' })).toBeInTheDocument(); + expect(screen.getByText('Custom analysis placement')).toBeInTheDocument(); + expect(screen.queryByRole('region', { name: 'By ecosystem chart' })).not.toBeInTheDocument(); + expect(screen.queryByText('Learn more about Lightwell')).not.toBeInTheDocument(); + fireEvent.click(screen.getByRole('button', { name: 'Open report' })); + expect(onClick).toHaveBeenCalledTimes(1); + }); + + it('does not supply product-specific chart data implicitly', () => { + render(); + + expect(screen.queryByRole('region', { name: 'By ecosystem chart' })).not.toBeInTheDocument(); + expect(screen.queryByText('Java')).not.toBeInTheDocument(); + }); + + it('allows overriding the title icon size and content aria-label', () => { + render( + , + ); + + const productTitle = screen.getByRole('heading', { name: 'Lightwell Lens' }); + const titleLogo = productTitle.parentElement?.querySelector('img'); + expect(titleLogo?.parentElement).toHaveStyle({ width: '2rem', height: '2rem' }); + expect(screen.getByRole('region', { name: 'Custom region label' })).toBeInTheDocument(); + }); +}); diff --git a/packages/module/src/ProductNudge/ProductNudgeDataModal.tsx b/packages/module/src/ProductNudge/ProductNudgeDataModal.tsx new file mode 100644 index 00000000..71c1130f --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeDataModal.tsx @@ -0,0 +1,411 @@ +import type { FunctionComponent } from 'react'; +import { useEffect, useRef, useState } from 'react'; + +import { + Button, + Content, + Flex, + FlexItem, + getResizeObserver, + Modal, + ModalBody, + ModalHeader, + Popover, + Stack, + Title, +} from '@patternfly/react-core'; +import { + Chart, + ChartAxis, + ChartBar, + ChartContainer, + ChartDonut, + ChartGroup, + ChartTooltip, +} from '@patternfly/react-charts/victory'; +import ArrowRightIcon from '@patternfly/react-icons/dist/esm/icons/arrow-right-icon.js'; +import RhUiQuestionMarkCircleIcon from '@patternfly/react-icons/dist/esm/icons/rh-ui-question-mark-circle-icon.js'; +import { createUseStyles } from 'react-jss'; + +import { + ProductNudgeAction, + ProductNudgeBrand, + ProductNudgeCtaColorScheme, + ProductNudgeEcosystemData, + ProductNudgeImage, + ProductNudgeMatchData, +} from './ProductNudge.types'; +import { createSquareImageSizeStyle, lightwellCtaStyle, nudgeModeStyles } from './nudgeStyles'; +import { lightwellBrandAssets, LightwellLogomark, LightwellLogomarkDark } from './productNudgeDefaults'; +import { ProductNudgeModalFooter } from './ProductNudgeModalFooter'; + +export interface ProductNudgeDataModalProps { + /** Whether the modal is open */ + isOpen: boolean; + /** Callback to close the modal */ + onClose: () => void; + /** Summary counts for the built-in match charts; omitted when using custom analysis content. */ + matchData?: ProductNudgeMatchData; + /** Per-ecosystem counts for the built-in charts; omitted when using custom analysis content. */ + ecosystemData?: ProductNudgeEcosystemData[]; + /** Applies a named brand preset; all copy remains caller-supplied. */ + brand?: ProductNudgeBrand; + /** Modal title. */ + titleText: React.ReactNode; + /** Optional icon displayed beside the modal title. */ + titleIcon?: React.ReactNode; + /** Optional description displayed below the modal title. */ + descriptionText?: React.ReactNode; + /** Replaces the default chart region with caller-provided content of any kind (not limited to analysis/charts). */ + customContent?: React.ReactNode; + /** Optional text displayed in the modal footer. */ + footerText?: React.ReactNode; + /** Optional primary action displayed in the modal footer. */ + primaryAction?: ProductNudgeAction; + /** Optional secondary action displayed in the modal footer. */ + secondaryAction?: ProductNudgeAction; + /** Footer partner logo override. */ + partnerLogo?: ProductNudgeImage; + /** Dark-mode footer partner logo override. */ + partnerLogoDark?: ProductNudgeImage; + /** Override the primary CTA visual scheme. */ + ctaColorScheme?: ProductNudgeCtaColorScheme; + /** CTA inline style applied when `ctaColorScheme="custom"`. */ + ctaStyle?: React.CSSProperties; + /** Width/height of the title icon container; defaults to '1.5rem'. */ + titleIconSize?: string; + /** aria-label applied to the scrollable modal body region (wraps both the built-in chart and `customContent`). */ + contentAriaLabel?: string; + /** Optional palette for the built-in charts. */ + chartColors?: string[]; + /** Prefix for generated modal and accessibility IDs. */ + id?: string; +} + +const useStyles = createUseStyles({ + modal: { + '--pf-v6-c-modal-box__header--PaddingBlockStart': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__header--PaddingBlockEnd': 'var(--pf-t--global--spacer--md)', + '--pf-v6-c-modal-box__header--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__header--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__body--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__body--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__footer--PaddingBlockEnd': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__footer--PaddingInlineStart': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-modal-box__footer--PaddingInlineEnd': 'var(--pf-t--global--spacer--xl)', + '--pf-v6-c-content--small--MarginBlockEnd': 0, + }, + ecosystemChartViewport: { + width: '100%', + minWidth: 0, + height: '158px', + overflowX: 'auto', + }, + modalTitleIcon: { + '& img': { + width: '100%', + height: '100%', + }, + }, + ...nudgeModeStyles, +}); + +const DEFAULT_ECOSYSTEM_CHART_WIDTH = 600; +const ECOSYSTEM_CHART_HEIGHT = 158; + +// Lightwell's chart palette is intentionally separate from PatternFly's palette; +// custom properties allow consumers to theme it without changing the defaults. +const LIGHTWELL_CHART_COLORS = [ + 'var(--lightwell-chart-color-exact, #f56e6e)', + 'var(--lightwell-chart-color-partial, #f8ae54)', + 'var(--lightwell-chart-color-no-match, var(--pf-t--chart--global--fill--color--200, #e0e0e0))', +]; + +const ECOSYSTEM_LEGEND_LABELS = [ 'Exact match', 'Partial match', 'No match' ]; + +const describeEcosystemData = (data: ProductNudgeEcosystemData[]) => data.map(({ name, exact, partial, noMatch }) => { + const ecosystemName = typeof name === 'string' || typeof name === 'number' ? String(name) : 'Ecosystem'; + return `${ecosystemName}: ${exact} exact matches, ${partial} partial matches, ${noMatch} with no match`; +}).join('. '); + +const createLightwellTitleIcon = (titleIconSize: string): FunctionComponent => () => { + const classes = useStyles(); + + return ( +
+ + +
+ ); +}; + +let analysisModalInstance = 0; + +const renderFooterAction = ( + action: ProductNudgeAction | undefined, + variant: 'primary' | 'link', + style?: React.CSSProperties, +) => { + if (!action) { + return null; + } + + return action.href ? ( + + ) : ( + + ); +}; + +export const ProductNudgeDataModal: FunctionComponent = ({ + isOpen, + onClose, + matchData, + ecosystemData, + brand, + titleText, + titleIcon, + descriptionText, + customContent, + footerText, + primaryAction, + secondaryAction, + partnerLogo, + partnerLogoDark, + ctaColorScheme, + ctaStyle: customCtaStyle, + titleIconSize = '1.5rem', + contentAriaLabel = 'Additional details', + chartColors: providedChartColors, + id, +}: ProductNudgeDataModalProps) => { + const classes = useStyles(); + const ecosystemChartViewportRef = useRef(null); + const [ ecosystemChartWidth, setEcosystemChartWidth ] = useState(DEFAULT_ECOSYSTEM_CHART_WIDTH); + const idRef = useRef(undefined); + if (!idRef.current) { + idRef.current = id ?? `product-nudge-analysis-${++analysisModalInstance}`; + } + const idPrefix = id ?? idRef.current; + const LightwellTitleIcon = brand === 'lightwell' ? createLightwellTitleIcon(titleIconSize) : undefined; + const resolvedTitleIcon = titleIcon ?? (LightwellTitleIcon ? : undefined); + const resolvedLogo = partnerLogo ?? (brand === 'lightwell' ? lightwellBrandAssets.partnerLogo : undefined); + const resolvedLogoDark = partnerLogoDark ?? (brand === 'lightwell' && !partnerLogo ? lightwellBrandAssets.partnerLogoDark : undefined); + const resolvedCtaColorScheme = ctaColorScheme ?? (brand === 'lightwell' ? 'lightwell' : 'default'); + const ctaStyleByScheme: Record = { + lightwell: lightwellCtaStyle, + custom: customCtaStyle, + default: undefined, + }; + const ctaStyle = ctaStyleByScheme[resolvedCtaColorScheme]; + const chartColors = providedChartColors ?? (brand === 'lightwell' ? LIGHTWELL_CHART_COLORS : undefined); + const legendData = ECOSYSTEM_LEGEND_LABELS.map((name, index) => ({ + name, + ...(chartColors?.length && { + symbol: { fill: chartColors[index % chartColors.length] }, + }), + })); + const titleIconVariant = resolvedTitleIcon ? () => <>{resolvedTitleIcon} : undefined; + const chartMatchData = matchData ?? { exact: 0, partial: 0, noMatch: 0 }; + const chartEcosystemData = ecosystemData ?? []; + const totalPackages = chartMatchData.exact + chartMatchData.partial + chartMatchData.noMatch; + const totalMatches = chartMatchData.exact + chartMatchData.partial; + const matchPercentage = totalPackages ? Math.round((totalMatches / totalPackages) * 100) : 0; + const matchItems = [ + { + label: 'exact matches', + value: chartMatchData.exact, + helpText: 'Packages with a direct version-matched equivalent in the catalog.', + }, + { + label: 'partial matches', + value: chartMatchData.partial, + helpText: 'Packages with a near-match or alternative available in the catalog.', + }, + { + label: 'no match', + value: chartMatchData.noMatch, + helpText: 'Packages with no equivalent found in the catalog.', + }, + ]; + + useEffect(() => { + const chartViewport = ecosystemChartViewportRef.current; + if (!chartViewport) { + return; + } + + const updateChartWidth = () => { + const width = chartViewport.clientWidth; + if (width > 0) { + setEcosystemChartWidth(width); + } + }; + + const unobserve = getResizeObserver(chartViewport, updateChartWidth); + updateChartWidth(); + + return unobserve; + }, [ ]); + + return ( + + + + + {customContent ?? (matchData && ecosystemData && ( + + + +
+ + Match analysis + + {matchPercentage}% of packages match the catalog. + + + + + ({ x: label, y: value }))} + labels={({ datum }) => `${datum.x}: ${datum.y}`} + labelComponent={} + title={`${totalMatches}`} + subTitle="matches" + colorScale={chartColors} + constrainToVisibleArea + height={160} + width={160} + padding={{ bottom: 0, left: 0, right: 0, top: 0 }} + radius={70} + innerRadius={52} + padAngle={1} + /> + + + + {matchItems.map(({ label, value, helpText }) => ( + + {value} + + + + + ))} + + + + +
+
+
+ + + +
+ + By ecosystem + See how packages map to supported ecosystems. + +
+ [ exact, partial, noMatch ])) ] }} + height={ECOSYSTEM_CHART_HEIGHT} + legendData={legendData} + legendOrientation="vertical" + legendPosition="right" + padding={{ bottom: 45, left: 58, right: 150, top: 12 }} + width={Math.max(chartEcosystemData.length * 75 + 250, ecosystemChartWidth)} + containerComponent={} + > + + name)} /> + + ({ x: name, y: exact, label: `Exact match: ${exact}` }))} + labels={({ datum }) => datum.label} + labelComponent={} + /> + ({ x: name, y: partial, label: `Partial match: ${partial}` }))} + labels={({ datum }) => datum.label} + labelComponent={} + /> + ({ x: name, y: noMatch, label: `No match: ${noMatch}` }))} + labels={({ datum }) => datum.label} + labelComponent={} + /> + + +
+
+
+
+
+
+ ))} +
+ + {primaryAction && {renderFooterAction(primaryAction, 'primary', ctaStyle)}} + {secondaryAction && {renderFooterAction(secondaryAction, 'link')}} + + )} + /> +
+ ); +}; + +export default ProductNudgeDataModal; diff --git a/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.test.tsx b/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.test.tsx new file mode 100644 index 00000000..1b6b58fe --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.test.tsx @@ -0,0 +1,158 @@ +import { act, render, screen } from '@testing-library/react'; + +import ProductNudgeDescriptionItem from './ProductNudgeDescriptionItem'; + +let restoreIntersectionObserver: () => void = jest.fn(); + +const installIntersectionObserverMock = () => { + let callback: IntersectionObserverCallback = () => undefined; + const observe = jest.fn(); + const disconnect = jest.fn(); + const originalDescriptor = Object.getOwnPropertyDescriptor(global, 'IntersectionObserver'); + const observerConstructor = jest.fn((nextCallback: IntersectionObserverCallback) => { + callback = nextCallback; + return { observe, disconnect } as unknown as IntersectionObserver; + }); + + Object.defineProperty(global, 'IntersectionObserver', { + configurable: true, + value: observerConstructor + }); + + const restore = () => { + if (originalDescriptor) { + Object.defineProperty(global, 'IntersectionObserver', originalDescriptor); + } else { + Reflect.deleteProperty(global, 'IntersectionObserver'); + } + }; + restoreIntersectionObserver = restore; + + return { + observe, + disconnect, + observerConstructor, + notify: (intersectionRatio: number) => + act(() => + callback([ { isIntersecting: true, intersectionRatio } as IntersectionObserverEntry ], {} as IntersectionObserver) + ), + }; +}; + +afterEach(() => restoreIntersectionObserver()); + +describe('ProductNudgeDescriptionItem component', () => { + it('calls onImpression once when at least half visible', () => { + const intersectionObserver = installIntersectionObserverMock(); + const onImpression = jest.fn(); + + render( +
+ +
, + ); + + expect(intersectionObserver.observe).toHaveBeenCalledTimes(1); + expect(intersectionObserver.observerConstructor).toHaveBeenCalledWith(expect.any(Function), { threshold: 0.5 }); + intersectionObserver.notify(0.25); + expect(onImpression).not.toHaveBeenCalled(); + intersectionObserver.notify(0.5); + intersectionObserver.notify(0.75); + + expect(onImpression).toHaveBeenCalledTimes(1); + expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(1); + }); + + it('does not observe when ineligible or when onImpression is omitted', () => { + const intersectionObserver = installIntersectionObserverMock(); + + const { rerender } = render( +
+ +
, + ); + expect(intersectionObserver.observerConstructor).not.toHaveBeenCalled(); + + rerender( +
+ +
, + ); + expect(intersectionObserver.observerConstructor).not.toHaveBeenCalled(); + }); + + it('renders caller-provided term, icon, description, and link content', () => { + render( +
+ Icon} + headline="12 issues addressed" + bodyText="A product-specific description." + ctaText="View the report" + ctaUrl="https://example.com/report" + /> +
, + ); + + expect(screen.getByText('Acme Security')).toBeInTheDocument(); + expect(screen.getByTestId('term-icon')).toBeInTheDocument(); + expect(screen.getByText('12 issues addressed')).toBeInTheDocument(); + expect(screen.getByRole('link', { name: 'View the report' })).toHaveAttribute('href', 'https://example.com/report'); + }); + + it('renders neutral term content when no brand or term text is supplied', () => { + render( +
+ +
, + ); + + expect(screen.queryByText('Lightwell')).not.toBeInTheDocument(); + }); + + it('renders the Lightwell inline mark at 16px', () => { + const { container } = render( +
+ +
, + ); + + expect(container.querySelector('img')).toHaveStyle({ width: '1rem' }); + }); + + it('allows overriding the term logo size', () => { + const { container } = render( +
+ +
, + ); + + expect(container.querySelector('img')).toHaveStyle({ width: '2rem' }); + }); +}); diff --git a/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.tsx b/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.tsx new file mode 100644 index 00000000..8a9a105e --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeDescriptionItem.tsx @@ -0,0 +1,161 @@ +import type { FunctionComponent, ReactNode } from 'react'; + +import { + Button, + DescriptionListDescription, + DescriptionListTerm, + Stack, + StackItem, +} from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; +import descriptionListStyles from '@patternfly/react-styles/css/components/DescriptionList/description-list'; +import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon.js'; +import { createUseStyles } from 'react-jss'; + +import { ProductNudgeBrand } from './ProductNudge.types'; +import { createImageSizeStyle, nudgeModeStyles } from './nudgeStyles'; +import { lightwellBrandAssets } from './productNudgeDefaults'; +import { useImpressionTracking } from './useImpressionTracking'; + +export interface ProductNudgeDescriptionItemProps { + /** false renders null */ + isEligible: boolean; + /** Called once when the component is at least 50% visible. */ + onImpression?: () => void; + /** Applies a named brand preset; copy remains entirely caller-supplied. */ + brand?: ProductNudgeBrand; + /** Visible text/content rendered in the description-list term. */ + termText?: string | ReactNode; + /** Icon displayed in the description-list term. */ + termIcon?: ReactNode; + /** Bold headline in the description */ + headline: string | ReactNode; + /** Body paragraph in the description */ + bodyText: string | ReactNode; + /** CTA link label */ + ctaText?: string; + /** CTA link href */ + ctaUrl?: string; + /** Custom term logo/icon image for light mode. */ + logo?: { src: string; alt: string }; + /** Custom term logo/icon image for dark mode. */ + logoDark?: { src: string; alt: string }; + /** Width of the term logo/icon; defaults to '1rem'. */ + logoMarkSize?: string; + /** Inline-start margin of the term logo/icon; defaults to unset. */ + logoMarkOffset?: string; + /** OUIA component ID */ + ouiaId?: string; + /** Additional CSS class on the DescriptionListGroup */ + className?: string; + /** data-testid forwarded to root */ + 'data-testid'?: string; +} + +const useStyles = createUseStyles({ + term: { + alignSelf: 'start', + }, + termIcon: { + display: 'block', + height: 'auto', + }, + ...nudgeModeStyles, +}); + +/** + * A DescriptionList item variant of ProductNudge. Renders as a DescriptionListGroup + * so it can be dropped directly inside an existing DescriptionList alongside other items. + * Term content and icon are caller supplied; the description renders headline, body, and link content. + */ +export const ProductNudgeDescriptionItem: FunctionComponent = ({ + isEligible, + onImpression, + brand, + termText, + termIcon, + headline, + bodyText, + ctaText, + ctaUrl, + logo, + logoDark, + logoMarkSize = '1rem', + logoMarkOffset, + ouiaId = 'ProductNudgeDescriptionItem', + className, + 'data-testid': dataTestId, +}: ProductNudgeDescriptionItemProps) => { + const classes = useStyles(); + const impressionRef = useImpressionTracking(onImpression, isEligible); + const lightTermLogo = logo ?? (brand === 'lightwell' ? lightwellBrandAssets.logomark : undefined); + const darkTermLogo = logoDark ?? (brand === 'lightwell' && !logo ? lightwellBrandAssets.logomarkDark : undefined); + const resolvedTermIconStyle = createImageSizeStyle(logoMarkSize, logoMarkOffset); + const resolvedTermIcon = termIcon ?? ((lightTermLogo || darkTermLogo) ? ( + <> + {lightTermLogo && ( + + )} + {darkTermLogo && ( + + )} + + ) : undefined); + + if (!isEligible) { + return null; + } + + return ( +
+ + {termText} + + + + {headline} + {bodyText && {bodyText}} + {ctaUrl && ( + + + + )} + + +
+ ); +}; + +export default ProductNudgeDescriptionItem; diff --git a/packages/module/src/ProductNudge/ProductNudgeModalFooter.tsx b/packages/module/src/ProductNudge/ProductNudgeModalFooter.tsx new file mode 100644 index 00000000..ca9b4b08 --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeModalFooter.tsx @@ -0,0 +1,50 @@ +import { FunctionComponent, ReactNode } from 'react'; +import { Content, ContentVariants, Flex, FlexItem, ModalFooter, Stack } from '@patternfly/react-core'; + +import { ProductNudgeImage } from './ProductNudge.types'; +import { ProductNudgeBrandLogo } from './ProductNudgeBrandLogo'; + +export interface ProductNudgeModalFooterProps { + actions?: ReactNode; + footerText?: ReactNode; + partnerLogo?: ProductNudgeImage; + partnerLogoDark?: ProductNudgeImage; + className?: string; + isCentered?: boolean; +} + +/** Shared modal footer for actions, optional supporting copy, and brand logo. */ +export const ProductNudgeModalFooter: FunctionComponent = ({ + actions, + footerText, + partnerLogo, + partnerLogoDark, + className, + isCentered = false, +}) => { + if (!actions && !footerText && !partnerLogo && !partnerLogoDark) { + return null; + } + + return ( + + + {footerText && {footerText}} + + {actions} + {(partnerLogo || partnerLogoDark) && ( + + + + )} + + + + ); +}; + +export default ProductNudgeModalFooter; diff --git a/packages/module/src/ProductNudge/ProductNudgeStack.test.tsx b/packages/module/src/ProductNudge/ProductNudgeStack.test.tsx new file mode 100644 index 00000000..9017224c --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeStack.test.tsx @@ -0,0 +1,142 @@ +import { act, render, screen } from '@testing-library/react'; +import ProductNudgeStack from './ProductNudgeStack'; + +let restoreIntersectionObserver: () => void = jest.fn(); + +const installIntersectionObserverMock = () => { + let callback: IntersectionObserverCallback = () => undefined; + const observe = jest.fn(); + const disconnect = jest.fn(); + const originalDescriptor = Object.getOwnPropertyDescriptor(global, 'IntersectionObserver'); + const observerConstructor = jest.fn((nextCallback: IntersectionObserverCallback) => { + callback = nextCallback; + return { observe, disconnect } as unknown as IntersectionObserver; + }); + + Object.defineProperty(global, 'IntersectionObserver', { + configurable: true, + value: observerConstructor + }); + + const restore = () => { + if (originalDescriptor) { + Object.defineProperty(global, 'IntersectionObserver', originalDescriptor); + } else { + Reflect.deleteProperty(global, 'IntersectionObserver'); + } + }; + restoreIntersectionObserver = restore; + + return { + observe, + disconnect, + observerConstructor, + notify: (intersectionRatio: number) => + act(() => + callback([ { isIntersecting: true, intersectionRatio } as IntersectionObserverEntry ], {} as IntersectionObserver) + ), + }; +}; + +afterEach(() => restoreIntersectionObserver()); + +describe('ProductNudgeStack component', () => { + it('renders nothing when not eligible', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders when eligible', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('renders without logo or CTA link', () => { + const { container } = render( + , + ); + expect(container).toMatchSnapshot(); + }); + + it('calls onImpression once when at least half visible', () => { + const intersectionObserver = installIntersectionObserverMock(); + const onImpression = jest.fn(); + + render(); + + expect(intersectionObserver.observe).toHaveBeenCalledTimes(1); + expect(intersectionObserver.observerConstructor).toHaveBeenCalledWith(expect.any(Function), { threshold: 0.5 }); + intersectionObserver.notify(0.25); + expect(onImpression).not.toHaveBeenCalled(); + intersectionObserver.notify(0.5); + intersectionObserver.notify(0.75); + + expect(onImpression).toHaveBeenCalledTimes(1); + expect(intersectionObserver.disconnect).toHaveBeenCalledTimes(1); + }); + + it('does not observe when ineligible or when onImpression is omitted', () => { + const intersectionObserver = installIntersectionObserverMock(); + + const { rerender } = render( + + ); + expect(intersectionObserver.observerConstructor).not.toHaveBeenCalled(); + + rerender(); + expect(intersectionObserver.observerConstructor).not.toHaveBeenCalled(); + }); + + it('renders the Lightwell inline mark at 16px', () => { + const { container } = render( + , + ); + + expect(container.querySelector('img')).toHaveStyle({ width: '1rem' }); + }); + + it('allows overriding the logomark size', () => { + const { container } = render( + , + ); + + expect(container.querySelector('img')).toHaveStyle({ width: '2rem' }); + }); + + it('uses the Lightwell mark only when the brand is selected and allows an icon override', () => { + render( + Custom icon} + bodyText="Custom body" + />, + ); + + expect(screen.getByTestId('field-icon')).toBeInTheDocument(); + expect(screen.queryByRole('img')).not.toBeInTheDocument(); + }); +}); diff --git a/packages/module/src/ProductNudge/ProductNudgeStack.tsx b/packages/module/src/ProductNudge/ProductNudgeStack.tsx new file mode 100644 index 00000000..75f39f2e --- /dev/null +++ b/packages/module/src/ProductNudge/ProductNudgeStack.tsx @@ -0,0 +1,174 @@ +import type { FunctionComponent, ReactNode } from 'react'; + +import { + Button, + Flex, + FlexItem, + StackItem, +} from '@patternfly/react-core'; +import { css } from '@patternfly/react-styles'; +import stackStyles from '@patternfly/react-styles/css/layouts/Stack/stack'; +import ExternalLinkAltIcon from '@patternfly/react-icons/dist/esm/icons/external-link-alt-icon.js'; +import { createUseStyles } from 'react-jss'; + +import { ProductNudgeBrand } from './ProductNudge.types'; +import { createImageSizeStyle, nudgeModeStyles } from './nudgeStyles'; +import { lightwellBrandAssets } from './productNudgeDefaults'; +import { useImpressionTracking } from './useImpressionTracking'; + +export interface ProductNudgeStackProps { + /** false renders null */ + isEligible: boolean; + /** Called once when the component is at least 50% visible. */ + onImpression?: () => void; + /** Applies a named brand preset; copy remains entirely caller-supplied. */ + brand?: ProductNudgeBrand; + /** Icon shown beside the title. */ + titleIcon?: ReactNode; + /** Heading label rendered beside the logo */ + titleText: string | ReactNode; + /** Primary value or metric rendered below the heading */ + value?: string | ReactNode; + /** Body / note text */ + bodyText: string | ReactNode; + /** CTA link label */ + ctaText?: string; + /** CTA link href; when omitted, onAction renders a button CTA instead. */ + ctaUrl?: string; + /** Action callback used instead of a link CTA. */ + onAction?: () => void; + /** Loading state for an action CTA. */ + isLoading?: boolean; + /** Logomark shown in the heading row (light mode) */ + logo?: { src: string; alt: string }; + /** Logomark shown in dark mode */ + logoDark?: { src: string; alt: string }; + /** Width of the logomark; defaults to '1rem'. */ + logoMarkSize?: string; + /** Inline-start margin of the logomark; defaults to unset. */ + logoMarkOffset?: string; + /** OUIA component ID */ + ouiaId?: string; + /** Additional CSS class */ + className?: string; + /** data-testid forwarded to the root element */ + 'data-testid'?: string; +} + +const useStyles = createUseStyles({ + logomark: { + display: 'block', + height: 'auto', + }, + ...nudgeModeStyles, + valueText: { + fontSize: 'var(--pf-t--global--font--size--lg)', + } +}); + +/** + * An in-context detail variant of ProductNudge. Renders a branded stack block + * with logomark, heading label, optional value, body note, and an inline CTA. + * Drop it anywhere — no DescriptionList wrapper required. + */ +export const ProductNudgeStack: FunctionComponent = ({ + isEligible, + onImpression, + brand, + titleIcon, + titleText, + value, + bodyText, + ctaText, + ctaUrl, + onAction, + isLoading = false, + logo, + logoDark, + logoMarkSize = '1rem', + logoMarkOffset, + ouiaId = 'ProductNudgeStack', + className, + 'data-testid': dataTestId, +}: ProductNudgeStackProps) => { + const classes = useStyles(); + const impressionRef = useImpressionTracking(onImpression, isEligible); + const resolvedLogo = logo ?? (brand === 'lightwell' ? lightwellBrandAssets.logomark : undefined); + const resolvedLogoDark = logoDark ?? (brand === 'lightwell' && !logo ? lightwellBrandAssets.logomarkDark : undefined); + const resolvedLogoMarkStyle = createImageSizeStyle(logoMarkSize, logoMarkOffset); + + if (!isEligible) { + return null; + } + + return ( +
+ + + {titleIcon ? ( + {titleIcon} + ) : resolvedLogo && ( + + + {resolvedLogoDark && ( + + )} + + )} + {titleText} + + + {value && {value}} + {bodyText && {bodyText}} + {(ctaUrl || onAction) && ( + + {ctaUrl ? ( + + ) : ( + + )} + + )} +
+ ); +}; + +export default ProductNudgeStack; diff --git a/packages/module/src/ProductNudge/__snapshots__/ProductNudge.test.tsx.snap b/packages/module/src/ProductNudge/__snapshots__/ProductNudge.test.tsx.snap new file mode 100644 index 00000000..78801600 --- /dev/null +++ b/packages/module/src/ProductNudge/__snapshots__/ProductNudge.test.tsx.snap @@ -0,0 +1,419 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProductNudge component renders alert prominence dismissible 1`] = ` +
+
+
+
+ +
+

+ + Info alert: + + Test headline +

+
+ +
+
+ Test body copy. +
+
+ +
+
+
+
+`; + +exports[`ProductNudge component renders alert prominence with link CTA 1`] = ` +
+
+
+
+ +
+

+ + Info alert: + + Test headline +

+
+ Test body copy. +
+ +
+
+
+`; + +exports[`ProductNudge component renders hero prominence 1`] = ` +
+
+
+
+
+
+
+

+ Test headline +

+
+
+

+ Test body copy. +

+
+
+
+
+ +
+
+
+
+
+
+
+
+
+`; + +exports[`ProductNudge component renders nothing when not eligible 1`] = `
`; + +exports[`ProductNudge component renders with default CTA color scheme 1`] = ` +
+
+
+
+
+
+
+

+ Test headline +

+
+
+

+ Test body copy. +

+
+
+
+
+ +
+
+
+
+
+
+
+
+
+`; + +exports[`ProductNudge component renders with metrics 1`] = ` +
+
+
+
+
+
+
+

+ Test headline +

+
+
+

+ Test body copy. +

+
+
+
+
+

+ + 42 + + + packages +

+
+
+
+
+
+
+ +
+
+
+
+
+
+
+
+
+`; diff --git a/packages/module/src/ProductNudge/__snapshots__/ProductNudgeContactModal.test.tsx.snap b/packages/module/src/ProductNudge/__snapshots__/ProductNudgeContactModal.test.tsx.snap new file mode 100644 index 00000000..7a7a0895 --- /dev/null +++ b/packages/module/src/ProductNudge/__snapshots__/ProductNudgeContactModal.test.tsx.snap @@ -0,0 +1,15 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ProductNudgeContactModal component renders closed (nothing visible) 1`] = `
`; + +exports[`ProductNudgeContactModal component renders supplied field placeholders and submits ContactFormValues 1`] = ` +