diff --git a/jest.config.js b/jest.config.js index 3d9c6751..788cbff7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -4,7 +4,7 @@ module.exports = { roots: ['/packages/react-sdk-components/tests/unit/'], preset: 'ts-jest', transform: { - '^.+\\.(t|j)sx?$': 'ts-jest' + '^.+\\.(t|j)sx?$': ['ts-jest', { tsconfig: '/tsconfig.jest.json' }] }, setupFilesAfterEnv: ['/packages/react-sdk-components/tests/setupTests.js'], coverageDirectory: 'tests/coverage' diff --git a/package-lock.json b/package-lock.json index cb75bfcb..557ad5bf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@pega/auth": "~0.2.34", "@react-google-maps/api": "^2.20.7", "@tinymce/tinymce-react": "^6.3.0", + "@types/styled-components": "^5.1.36", "clsx": "^2.1.1", "dayjs": "^1.11.13", "downloadjs": "^1.4.7", @@ -32,6 +33,7 @@ "react-number-format": "^5.4.4", "react-redux": "^8.1.3", "react-router": "^7.8.0", + "styled-components": "^6.3.12", "throttle-debounce": "^5.0.2" }, "devDependencies": { @@ -7670,6 +7672,23 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/styled-components": { + "version": "5.1.36", + "resolved": "https://registry.npmjs.org/@types/styled-components/-/styled-components-5.1.36.tgz", + "integrity": "sha512-pGMRNY5G2rNDKEv2DOiFYa7Ft1r0jrhmgBwHhOMzPTgCjO76bCot0/4uEfqj7K0Jf1KdQmDtAuaDk9EAs9foSw==", + "license": "MIT", + "dependencies": { + "@types/hoist-non-react-statics": "*", + "@types/react": "*", + "csstype": "^3.2.2" + } + }, + "node_modules/@types/stylis": { + "version": "4.2.7", + "resolved": "https://registry.npmjs.org/@types/stylis/-/stylis-4.2.7.tgz", + "integrity": "sha512-VgDNokpBoKF+wrdvhAAfS55OMQpL6QRglwTwNC3kIgBrzZxA4WsFj+2eLfEA/uMUDzBcEhYmjSbwQakn/i3ajA==", + "license": "MIT" + }, "node_modules/@types/testing-library__jest-dom": { "version": "5.14.9", "resolved": "https://registry.npmjs.org/@types/testing-library__jest-dom/-/testing-library__jest-dom-5.14.9.tgz", @@ -10051,6 +10070,15 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/camelize": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/camelize/-/camelize-1.0.1.tgz", + "integrity": "sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001779", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001779.tgz", @@ -11698,6 +11726,15 @@ "node": ">=10" } }, + "node_modules/css-color-keywords": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz", + "integrity": "sha512-FyyrDHZKEjXDpNJYvVsV960FiqQyXc/LlYmsxl2BcdMb2WPx0OGRVgTg55rPSyLSNMqP52R9r8geSp7apN3Ofg==", + "license": "ISC", + "engines": { + "node": ">=4" + } + }, "node_modules/css-functions-list": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/css-functions-list/-/css-functions-list-3.2.3.tgz", @@ -11830,6 +11867,17 @@ "url": "https://github.com/fb55/entities?sponsor=1" } }, + "node_modules/css-to-react-native": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/css-to-react-native/-/css-to-react-native-3.2.0.tgz", + "integrity": "sha512-e8RKaLXMOFii+02mOlqwjbD00KSEKqblnpO9e++1aXS1fPQOpS1YoqdVHBqPjHNoxeF2mimzVqawm2KCbEdtHQ==", + "license": "MIT", + "dependencies": { + "camelize": "^1.0.0", + "css-color-keywords": "^1.0.0", + "postcss-value-parser": "^4.0.2" + } + }, "node_modules/css-tree": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.1.0.tgz", @@ -11902,9 +11950,9 @@ } }, "node_modules/csstype": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz", - "integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==", + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, "node_modules/damerau-levenshtein": { @@ -21411,7 +21459,6 @@ "version": "3.3.11", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", - "dev": true, "funding": [ { "type": "github", @@ -22727,7 +22774,6 @@ "version": "4.2.0", "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", - "dev": true, "license": "MIT" }, "node_modules/prelude-ls": { @@ -24357,6 +24403,12 @@ "node": ">=8" } }, + "node_modules/shallowequal": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/shallowequal/-/shallowequal-1.1.0.tgz", + "integrity": "sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", @@ -24736,7 +24788,6 @@ "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, "license": "BSD-3-Clause", "engines": { "node": ">=0.10.0" @@ -25260,6 +25311,73 @@ "webpack": "^5.27.0" } }, + "node_modules/styled-components": { + "version": "6.3.12", + "resolved": "https://registry.npmjs.org/styled-components/-/styled-components-6.3.12.tgz", + "integrity": "sha512-hFR6xsVkVYbsdcUlzPYFvFfoc6o2KlV0VvgRIQwSYMtdThM7SCxnjX9efh/cWce2kTq16I/Kl3xM98xiLptsXA==", + "license": "MIT", + "dependencies": { + "@emotion/is-prop-valid": "1.4.0", + "@emotion/unitless": "0.10.0", + "@types/stylis": "4.2.7", + "css-to-react-native": "3.2.0", + "csstype": "3.2.3", + "postcss": "8.4.49", + "shallowequal": "1.1.0", + "stylis": "4.3.6", + "tslib": "2.8.1" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/styled-components" + }, + "peerDependencies": { + "react": ">= 16.8.0", + "react-dom": ">= 16.8.0" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/styled-components/node_modules/postcss": { + "version": "8.4.49", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.49.tgz", + "integrity": "sha512-OCVPnIObs4N29kxTjzLfUryOkvZEq+pf8jTF0lg8E7uETuWHA+v7j3c/xJmiqpX450191LlmZfUKkXxkTry7nA==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.7", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/styled-components/node_modules/stylis": { + "version": "4.3.6", + "resolved": "https://registry.npmjs.org/stylis/-/stylis-4.3.6.tgz", + "integrity": "sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==", + "license": "MIT" + }, "node_modules/stylelint": { "version": "16.24.0", "resolved": "https://registry.npmjs.org/stylelint/-/stylelint-16.24.0.tgz", @@ -26238,7 +26356,6 @@ "version": "2.8.1", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", - "dev": true, "license": "0BSD" }, "node_modules/tsscmp": { diff --git a/package.json b/package.json index 57a7596f..59883459 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "@pega/auth": "~0.2.34", "@react-google-maps/api": "^2.20.7", "@tinymce/tinymce-react": "^6.3.0", + "@types/styled-components": "^5.1.36", "clsx": "^2.1.1", "dayjs": "^1.11.13", "downloadjs": "^1.4.7", @@ -73,6 +74,7 @@ "react-number-format": "^5.4.4", "react-redux": "^8.1.3", "react-router": "^7.8.0", + "styled-components": "^6.3.12", "throttle-debounce": "^5.0.2" }, "devDependencies": { diff --git a/packages/react-sdk-components/src/components/field/TextInput/TextInput.tsx b/packages/react-sdk-components/src/components/field/TextInput/TextInput.tsx index 990d0835..d599b4b3 100644 --- a/packages/react-sdk-components/src/components/field/TextInput/TextInput.tsx +++ b/packages/react-sdk-components/src/components/field/TextInput/TextInput.tsx @@ -1,10 +1,122 @@ import { useState, useEffect } from 'react'; -import { TextField } from '@mui/material'; +import styled from 'styled-components'; import handleEvent from '../../helpers/event-utils'; import { getComponentFromMap } from '../../../bridge/helpers/sdk_component_map'; import type { PConnFieldProps } from '../../../types/PConnProps'; +// --------------------------------------------------------------------------- +// Northwestern Mutual "Luna" design tokens +// Source: login.northwesternmutual.com/registration (luna design system CSS) +// --------------------------------------------------------------------------- +const NM = { + navy: '#1f2d46', + border: '#5c697f', + borderHover: '#1f2d46', + focusBlue: '#2d4dc5', + errorRed: '#c93939', + errorRedDark: '#b52828', + placeholder: '#9ba7bc', + labelColor: '#5c697f', + textColor: '#1f2d46', + surface: '#f7f9fc', + helperText: '#5c697f', + disabledOpacity: '0.5', + fontFamily: "system-ui, -apple-system, 'Segoe UI', sans-serif", + fontSize: '1rem', + labelFontSize: '0.875rem', + helperFontSize: '0.75rem', + lineHeight: '1.5', + borderRadius: '0', // Luna uses flat bottom-border inputs + transitionSpeed: '0.2s', +}; + +// --- Styled primitives ------------------------------------------------------- + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + width: 100%; + font-family: ${NM.fontFamily}; +`; + +const Label = styled.label<{ $required?: boolean; $hasError?: boolean }>` + font-size: ${NM.labelFontSize}; + font-weight: 500; + color: ${({ $hasError }) => ($hasError ? NM.errorRed : NM.labelColor)}; + margin-bottom: 0.375rem; + letter-spacing: 0.01em; + + ${({ $required }) => + $required && + ` + &::after { + content: ' *'; + color: ${NM.errorRed}; + } + `} +`; + +const InputWrapper = styled.div` + position: relative; + display: flex; + align-items: center; +`; + +// Luna inputs: no full border box — only a bottom border that animates on focus/error +const StyledInput = styled.input<{ $hasError?: boolean; $readOnly?: boolean }>` + width: 100%; + font-family: ${NM.fontFamily}; + font-size: ${NM.fontSize}; + line-height: ${NM.lineHeight}; + color: ${NM.textColor}; + background-color: ${NM.surface}; + border: none; + border-bottom: 1px solid ${({ $hasError }) => ($hasError ? NM.errorRed : NM.border)}; + border-radius: ${NM.borderRadius}; + padding: 0.625rem 0.75rem; + outline: none; + transition: + border-bottom-color ${NM.transitionSpeed} ease, + box-shadow ${NM.transitionSpeed} ease; + cursor: ${({ $readOnly }) => ($readOnly ? 'default' : 'text')}; + + &::placeholder { + color: ${NM.placeholder}; + opacity: 1; + } + + &:hover:not(:disabled):not([readonly]) { + border-bottom-color: ${({ $hasError }) => ($hasError ? NM.errorRedDark : NM.borderHover)}; + } + + &:focus:not([readonly]) { + border-bottom-color: ${({ $hasError }) => ($hasError ? NM.errorRed : NM.focusBlue)}; + box-shadow: 0 1px 0 0 ${({ $hasError }) => ($hasError ? NM.errorRed : NM.focusBlue)}; + } + + &:disabled { + opacity: ${NM.disabledOpacity}; + cursor: not-allowed; + background-color: ${NM.surface}; + } + + &[readonly] { + background-color: transparent; + border-bottom-style: dashed; + cursor: default; + } +`; + +const HelperText = styled.span<{ $hasError?: boolean }>` + font-size: ${NM.helperFontSize}; + color: ${({ $hasError }) => ($hasError ? NM.errorRed : NM.helperText)}; + margin-top: 0.25rem; + line-height: 1.4; +`; + +// --------------------------------------------------------------------------- + interface TextInputProps extends PConnFieldProps { // If any, enter additional props that only exist on TextInput here fieldMetadata?: any; @@ -22,7 +134,6 @@ export default function TextInput(props: TextInputProps) { value = '', validatemessage, status, - /* onChange, onBlur */ readOnly, testId, fieldMetadata, @@ -37,12 +148,11 @@ export default function TextInput(props: TextInputProps) { const propName = (pConn.getStateProps() as any).value; const helperTextToDisplay = validatemessage || helperText; + const hasError = status === 'error'; const [inputValue, setInputValue] = useState(''); const maxLength = fieldMetadata?.maxLength; - let readOnlyProp = {}; // Note: empty if NOT ReadOnly - useEffect(() => { setInputValue(value); }, [value]); @@ -55,38 +165,48 @@ export default function TextInput(props: TextInputProps) { return ; } - if (readOnly) { - readOnlyProp = { readOnly: true }; - } - - const testProps: any = { 'data-test-id': testId }; - - function handleChange(event) { - // update internal value - setInputValue(event?.target?.value); + function handleChange(event: React.ChangeEvent) { + setInputValue(event.target.value); } function handleBlur() { handleEvent(actions, 'changeNblur', propName, inputValue); } + const inputId = `nm-input-${testId ?? propName ?? label}`; + return ( - + + {!hideLabel && label && ( + + )} + + + + {helperTextToDisplay && ( + + {helperTextToDisplay} + + )} + ); } + diff --git a/packages/react-sdk-components/src/components/helpers/simpleTableHelpers.ts b/packages/react-sdk-components/src/components/helpers/simpleTableHelpers.ts index 88f44e75..5a95e13c 100644 --- a/packages/react-sdk-components/src/components/helpers/simpleTableHelpers.ts +++ b/packages/react-sdk-components/src/components/helpers/simpleTableHelpers.ts @@ -333,120 +333,72 @@ export function createPConnect(contextName, referenceList, pageReference): any { return getPConnect(); } +// Returns true if the item should be KEPT for a Date/DateTime/Time operator. +// Because filterValue is in milliseconds, equality uses a ±60 s window. +function applyDateTimeOperator(value: any, filterValue: any, operator: string): boolean { + switch (operator) { + case 'notequal': + if (value !== null && filterValue !== null) { + // strip milliseconds before comparing + const diff = value / 1000 - filterValue / 1000; + return diff < 0 || diff >= 60; + } + return true; + case 'equal': + if (value !== null && filterValue !== null) { + return value / 1000 === filterValue / 1000; + } + return true; + case 'after': + return value >= filterValue; + case 'before': + return value <= filterValue; + case 'null': + return value === null; + case 'notnull': + return value !== null; + default: + return true; + } +} + +// Returns true if the item should be KEPT for a plain-string operator. +function applyStringOperator(value: string, filterValue: string, operator: string): boolean { + switch (operator) { + case 'contains': + return value.includes(filterValue); + case 'equals': + return value === filterValue; + case 'startswith': + return value.startsWith(filterValue); + default: + return true; + } +} + +// Returns true if the item should be KEPT against a single filter descriptor. +function applyFilterObj(item: any, filterObj: any): boolean { + const { type, ref, containsFilter, containsFilterValue } = filterObj; + + if (type === 'Date' || type === 'DateTime' || type === 'Time') { + // NOTE: the || (not &&) in the value condition preserves original behaviour + const value = item[ref] !== null || item[ref] !== '' ? Utils.getSeconds(item[ref]) : null; + const fv = containsFilterValue !== null && containsFilterValue !== '' ? Utils.getSeconds(containsFilterValue) : null; + return applyDateTimeOperator(value, fv, containsFilter); + } + + return applyStringOperator(item[ref].toLowerCase(), containsFilterValue.toLowerCase(), containsFilter); +} + export const filterData = filterByColumns => { return function filteringData(item) { - let bKeep = true; for (const filterObj of filterByColumns) { - if (filterObj.containsFilterValue !== '' || filterObj.containsFilter === 'null' || filterObj.containsFilter === 'notnull') { - let value: any; - let filterValue: any; - - switch (filterObj.type) { - case 'Date': - case 'DateTime': - case 'Time': - value = item[filterObj.ref] !== null || item[filterObj.ref] !== '' ? Utils.getSeconds(item[filterObj.ref]) : null; - filterValue = - filterObj.containsFilterValue !== null && filterObj.containsFilterValue !== '' ? Utils.getSeconds(filterObj.containsFilterValue) : null; - - switch (filterObj.containsFilter) { - case 'notequal': - // becasue filterValue is in minutes, need to have a range of less than 60 secons - - if (value !== null && filterValue !== null) { - // get rid of milliseconds - value /= 1000; - filterValue /= 1000; - - const diff = value - filterValue; - if (diff >= 0 && diff < 60) { - bKeep = false; - } - } - - break; - - case 'equal': - // becasue filterValue is in minutes, need to have a range of less than 60 secons - - if (value !== null && filterValue !== null) { - // get rid of milliseconds - value /= 1000; - filterValue /= 1000; - - const diff = value - filterValue; - if (diff !== 0) { - bKeep = false; - } - } - - break; - - case 'after': - if (value < filterValue) { - bKeep = false; - } - break; - - case 'before': - if (value > filterValue) { - bKeep = false; - } - break; - - case 'null': - if (value !== null) { - bKeep = false; - } - break; - - case 'notnull': - if (value === null) { - bKeep = false; - } - break; - - default: - break; - } - break; - - default: - value = item[filterObj.ref].toLowerCase(); - filterValue = filterObj.containsFilterValue.toLowerCase(); - - switch (filterObj.containsFilter) { - case 'contains': - if (value.indexOf(filterValue) < 0) { - bKeep = false; - } - break; - - case 'equals': - if (value !== filterValue) { - bKeep = false; - } - break; - - case 'startswith': - if (value.indexOf(filterValue) !== 0) { - bKeep = false; - } - break; - - default: - break; - } - - break; - } - } - - // if don't keep stop filtering - if (!bKeep) { - break; + const { containsFilterValue, containsFilter } = filterObj; + const isActive = containsFilterValue !== '' || containsFilter === 'null' || containsFilter === 'notnull'; + if (isActive && !applyFilterObj(item, filterObj)) { + return false; } } - return bKeep; + return true; }; }; diff --git a/packages/react-sdk-components/src/components/widget/StatusBadge/StatusBadge.tsx b/packages/react-sdk-components/src/components/widget/StatusBadge/StatusBadge.tsx new file mode 100644 index 00000000..7e249fb0 --- /dev/null +++ b/packages/react-sdk-components/src/components/widget/StatusBadge/StatusBadge.tsx @@ -0,0 +1,83 @@ +import React from 'react'; +import styled, { css } from 'styled-components'; + +export type BadgeVariant = 'success' | 'warning' | 'error' | 'info' | 'neutral'; +export type BadgeSize = 'sm' | 'md' | 'lg'; + +export interface StatusBadgeProps { + /** The status text to display */ + label: string; + /** Visual variant controlling the color scheme */ + variant?: BadgeVariant; + /** Size of the badge */ + size?: BadgeSize; + /** Optional test identifier */ + testId?: string; +} + +// Color map keyed by variant — avoids runtime object lookups inside template literals +const variantStyles: Record> = { + success: css` + background-color: #e6f4ea; + color: #1e7e34; + border-color: #a8d5b5; + `, + warning: css` + background-color: #fff8e1; + color: #856404; + border-color: #ffd54f; + `, + error: css` + background-color: #fdecea; + color: #c62828; + border-color: #ef9a9a; + `, + info: css` + background-color: #e3f2fd; + color: #0d47a1; + border-color: #90caf9; + `, + neutral: css` + background-color: #f5f5f5; + color: #424242; + border-color: #bdbdbd; + ` +}; + +const sizeStyles: Record> = { + sm: css` + font-size: 0.65rem; + padding: 2px 6px; + `, + md: css` + font-size: 0.75rem; + padding: 4px 10px; + `, + lg: css` + font-size: 0.875rem; + padding: 6px 14px; + ` +}; + +// Use transient props ($ prefix) to prevent forwarding to the DOM element +const StyledBadge = styled.span<{ $variant: BadgeVariant; $size: BadgeSize }>` + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 12px; + border: 1px solid transparent; + font-weight: 600; + letter-spacing: 0.02em; + line-height: 1; + white-space: nowrap; + ${({ $variant }) => variantStyles[$variant]} + ${({ $size }) => sizeStyles[$size]} +`; + +export default function StatusBadge({ label, variant = 'neutral', size = 'md', testId }: StatusBadgeProps) { + return ( + + {label} + + ); +} diff --git a/packages/react-sdk-components/src/components/widget/StatusBadge/index.ts b/packages/react-sdk-components/src/components/widget/StatusBadge/index.ts new file mode 100644 index 00000000..ec2a6234 --- /dev/null +++ b/packages/react-sdk-components/src/components/widget/StatusBadge/index.ts @@ -0,0 +1,2 @@ +export { default } from './StatusBadge'; +export type { StatusBadgeProps, BadgeVariant, BadgeSize } from './StatusBadge'; diff --git a/packages/react-sdk-components/src/components/widget/SummaryItem/SummaryItem.tsx b/packages/react-sdk-components/src/components/widget/SummaryItem/SummaryItem.tsx index 899a69ad..01c41877 100644 --- a/packages/react-sdk-components/src/components/widget/SummaryItem/SummaryItem.tsx +++ b/packages/react-sdk-components/src/components/widget/SummaryItem/SummaryItem.tsx @@ -1,10 +1,101 @@ import { useState } from 'react'; +import styled from 'styled-components'; import { IconButton, Menu, MenuItem } from '@mui/material'; import MoreVertIcon from '@mui/icons-material/MoreVert'; import { Utils } from '../../helpers/utils'; import type { PConnProps } from '../../../types/PConnProps'; -import './SummaryItem.css'; +// --------------------------------------------------------------------------- +// Styled-components — replaces SummaryItem.css class-based styles. +// MUI components (IconButton, Menu, MenuItem) keep their own emotion-based +// styling, demonstrating cross-compatibility between the two systems. +// --------------------------------------------------------------------------- + +const Card = styled.div` + display: flex; + flex-direction: row; + padding: 0.25rem 0 0.25rem 0.25rem; + margin-bottom: 0.5rem; + align-items: center; + border: 0.0625rem solid var(--utility-card-border-color); + border-radius: 0.25rem; + min-height: 3rem; +`; + +const CardIcon = styled.div` + flex-grow: 1; + max-width: 2.813rem; + align-content: center; + display: flex; +`; + +const CardSvgIcon = styled.img` + width: 2.5rem; + display: inline-block; + filter: var(--svg-color); +`; + +const CardMain = styled.div` + flex-grow: 2; + margin-left: 5px; +`; + +const PrimaryLabel = styled.div` + font-weight: bold; + text-overflow: ellipsis; + overflow: hidden; + white-space: normal; +`; + +const PrimaryUrlWrapper = styled.div` + display: inline-flex; + align-items: center; +`; + +const LinkButton = styled.button` + background: none; + border: none; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 0.25rem; + padding: 0; +`; + +const ActionsIcon = styled.img` + width: 1rem; + display: inline-block; + filter: var(--svg-color); +`; + +// Transient prop ($hasError) prevents the boolean from being forwarded to the DOM +const SecondaryText = styled.div<{ $hasError: boolean }>` + color: ${({ $hasError }) => ($hasError ? 'red' : 'inherit')}; +`; + +const CardAction = styled.div` + flex-grow: 1; + text-align: right; +`; + +const ActionButton = styled.button` + background: none; + border: none; + cursor: pointer; +`; + +const ActionSvgIcon = styled.img` + width: 1.4rem; + display: inline-block; + filter: var(--svg-color); +`; + +// styled(MenuItem) shows that styled-components can wrap MUI/emotion components +const StyledMenuItem = styled(MenuItem)` + font-size: 0.875rem; +`; + +// --------------------------------------------------------------------------- interface SummaryItemProps extends PConnProps { // If any, enter additional props that only exist on this component @@ -39,27 +130,27 @@ export default function SummaryItem(props: SummaryItemProps) { }; return ( -
-
- -
-
- {item.primary.type !== 'URL' &&
{item.primary.name}
} + + + + + + {item.primary.type !== 'URL' && {item.primary.name}} {item.primary.type === 'URL' && ( -
- -
+ + + )} - {item.secondary.text &&
{item.secondary.text}
} -
-
+ {item.secondary.text && {item.secondary.text}} + + {menuIconOverride$ && ( - + + + )} {!menuIconOverride$ && (
@@ -76,14 +167,14 @@ export default function SummaryItem(props: SummaryItemProps) { {item.actions && item.actions.map(option => ( - + {option.text} - + ))}
)} -
-
+ + ); } diff --git a/packages/react-sdk-components/tests/unit/components/widget/StatusBadge/index.test.tsx b/packages/react-sdk-components/tests/unit/components/widget/StatusBadge/index.test.tsx new file mode 100644 index 00000000..c5eadc4a --- /dev/null +++ b/packages/react-sdk-components/tests/unit/components/widget/StatusBadge/index.test.tsx @@ -0,0 +1,91 @@ +import React from 'react'; +import { render, screen } from '@testing-library/react'; +import StatusBadge from '../../../../../src/components/widget/StatusBadge'; +import type { BadgeVariant, BadgeSize } from '../../../../../src/components/widget/StatusBadge'; + +describe('StatusBadge (styled-components)', () => { + // ─── Rendering ─────────────────────────────────────────────────────────────── + + test('renders the label text', () => { + render(); + expect(screen.getByText('Active')).toBeInTheDocument(); + }); + + test('exposes a status role for accessibility', () => { + render(); + expect(screen.getByRole('status')).toBeInTheDocument(); + }); + + test('applies the data-test-id attribute when provided', () => { + render(); + expect(screen.getByTestId('my-badge')).toBeInTheDocument(); + }); + + // ─── Defaults ──────────────────────────────────────────────────────────────── + + test('uses "neutral" variant and "md" size by default', () => { + render(); + const badge = screen.getByTestId('default-badge'); + // styled-components generates a class; we verify the element is in the DOM + // and that the baseline styles are applied via the generated className + expect(badge).toBeInTheDocument(); + expect(badge.tagName.toLowerCase()).toBe('span'); + }); + + // ─── Variant rendering ──────────────────────────────────────────────────────── + + const variants: BadgeVariant[] = ['success', 'warning', 'error', 'info', 'neutral']; + + test.each(variants)('renders "%s" variant without crashing', (variant) => { + render(); + expect(screen.getByTestId(`badge-${variant}`)).toBeInTheDocument(); + }); + + // ─── Size rendering ─────────────────────────────────────────────────────────── + + const sizes: BadgeSize[] = ['sm', 'md', 'lg']; + + test.each(sizes)('renders "%s" size without crashing', (size) => { + render(); + expect(screen.getByTestId(`badge-${size}`)).toBeInTheDocument(); + }); + + // ─── styled-components class injection ─────────────────────────────────────── + + test('styled-components injects a generated CSS class onto the element', () => { + render(); + const badge = screen.getByTestId('styled-badge'); + // styled-components v6 attaches one or more generated class names (sc-*) + const hasStyledClass = Array.from(badge.classList).some(cls => cls.startsWith('sc-')); + expect(hasStyledClass).toBe(true); + }); + + test('different variants produce different CSS classes', () => { + const { rerender } = render(); + const successClasses = screen.getByTestId('var-badge').className; + + rerender(); + const errorClasses = screen.getByTestId('var-badge').className; + + expect(successClasses).not.toBe(errorClasses); + }); + + test('different sizes produce different CSS classes', () => { + const { rerender } = render(); + const smClasses = screen.getByTestId('size-badge').className; + + rerender(); + const lgClasses = screen.getByTestId('size-badge').className; + + expect(smClasses).not.toBe(lgClasses); + }); + + // ─── Transient props ($) are NOT forwarded to DOM ──────────────────────────── + + test('transient props ($variant, $size) are not present as DOM attributes', () => { + render(); + const badge = screen.getByTestId('clean-badge'); + expect(badge).not.toHaveAttribute('$variant'); + expect(badge).not.toHaveAttribute('$size'); + }); +}); diff --git a/tsconfig.jest.json b/tsconfig.jest.json new file mode 100644 index 00000000..b75fa8fc --- /dev/null +++ b/tsconfig.jest.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "module": "CommonJS", + "moduleResolution": "Node", + "verbatimModuleSyntax": false, + "isolatedModules": true + } +}