diff --git a/CHANGELOG.md b/CHANGELOG.md index 65e925d270..380b532bbc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ### Features - Export `robot`, `tabs` and `plugs` icon to Teams theme @codepretty ([#2026](https://github.com/stardust-ui/react/pull/2026)) +- Add CSSinJS debug panel @levithomason @miroslavstastny @mnajdova ([#1974](https://github.com/stardust-ui/react/pull/1974)) ### Fixes - Correctly handle RTL in `Alert` component @miroslavstastny ([#2018](https://github.com/stardust-ui/react/pull/2018)) diff --git a/docs/src/app.tsx b/docs/src/app.tsx index 98daa78545..8a13feb4e5 100644 --- a/docs/src/app.tsx +++ b/docs/src/app.tsx @@ -1,6 +1,6 @@ import * as React from 'react' import { hot } from 'react-hot-loader/root' -import { Provider, themes } from '@stardust-ui/react' +import { Provider, Debug, themes } from '@stardust-ui/react' import { mergeThemes } from 'src/lib' import { ThemeContext, ThemeContextData, themeContextDefaults } from './context/ThemeContext' @@ -40,7 +40,10 @@ class App extends React.Component { })} > - +
+ + +
diff --git a/docs/src/components/DocsLayout.tsx b/docs/src/components/DocsLayout.tsx index cd950400f4..4d9f57341a 100644 --- a/docs/src/components/DocsLayout.tsx +++ b/docs/src/components/DocsLayout.tsx @@ -1,4 +1,4 @@ -import { Provider, themes, pxToRem } from '@stardust-ui/react' +import { Provider, themes, pxToRem, createTheme } from '@stardust-ui/react' import AnchorJS from 'anchor-js' import * as PropTypes from 'prop-types' import * as React from 'react' @@ -93,36 +93,42 @@ class DocsLayout extends React.Component { return ( <> ({ - ...(!p.items && treeItemStyle), - ...(p.items && treeSectionStyle), - }), - }, - HierarchicalTreeTitle: { - root: { - display: 'block', - width: '100%', + componentStyles: { + HierarchicalTreeItem: { + root: ({ variables: v, props: p }) => ({ + ...(!p.items && treeItemStyle), + ...(p.items && treeSectionStyle), + }), + }, + HierarchicalTreeTitle: { + root: { + display: 'block', + width: '100%', + }, + }, }, }, - }, - })} + 'DocsLayout', + ), + )} > diff --git a/docs/src/components/Sidebar/Sidebar.tsx b/docs/src/components/Sidebar/Sidebar.tsx index 3156830144..4edccd801a 100644 --- a/docs/src/components/Sidebar/Sidebar.tsx +++ b/docs/src/components/Sidebar/Sidebar.tsx @@ -28,6 +28,8 @@ const pkg = require('../../../../packages/react/package.json') const componentMenu: ComponentMenuItem[] = require('docs/src/componentMenu') const behaviorMenu: ComponentMenuItem[] = require('docs/src/behaviorMenu') +const componentsBlackList = ['Debug', 'Design'] + class Sidebar extends React.Component { static propTypes = { match: PropTypes.object.isRequired, @@ -258,7 +260,7 @@ class Sidebar extends React.Component { const treeItemsByType = _.map(constants.typeOrder, nextType => { const items = _.chain([...componentMenu, ...behaviorMenu]) .filter(({ type }) => type === nextType) - .filter(({ displayName }) => displayName !== 'Design') + .filter(({ displayName }) => !_.includes(componentsBlackList, displayName)) .map(info => ({ key: info.displayName.concat(nextType), title: { content: info.displayName, as: NavLink, to: getComponentPathname(info) }, diff --git a/docs/src/examples/components/Provider/Performance/ProviderMergeThemes.perf.tsx b/docs/src/examples/components/Provider/Performance/ProviderMergeThemes.perf.tsx new file mode 100644 index 0000000000..58eace2796 --- /dev/null +++ b/docs/src/examples/components/Provider/Performance/ProviderMergeThemes.perf.tsx @@ -0,0 +1,43 @@ +import * as React from 'react' +import { mergeThemes, callable, ComponentStyleFunctionParam, themes } from '@stardust-ui/react' +import * as _ from 'lodash' + +/** + * Not a real performance test, just a temporary POC + */ +const providerMergeThemesPerf = () => { + const merged = mergeThemes(..._.times(100, n => themes.teams)) + const resolvedStyles = _.mapValues(merged.componentStyles, (componentStyle, componentName) => { + const compVariables = _.get(merged.componentVariables, componentName, callable({}))( + merged.siteVariables, + ) + const styleParam: ComponentStyleFunctionParam = { + displayName: componentName, + props: {}, + variables: compVariables, + theme: merged, + rtl: false, + disableAnimations: false, + } + return _.mapValues(componentStyle, (partStyle, partName) => { + if (partName === '_debug') { + // TODO: fix in code, happens only with mergeThemes(singleTheme) + return undefined + } + if (typeof partStyle !== 'function') { + console.log(componentName, partStyle, partName) + } + return partStyle(styleParam) + }) + }) + + return resolvedStyles +} + +const MergeThemesPerf = () => { + const resolvedStyles = providerMergeThemesPerf() + delete resolvedStyles.Button.root._debug + return
{JSON.stringify(resolvedStyles.Button.root, null, 2)}
+} + +export default MergeThemesPerf diff --git a/docs/src/examples/components/Provider/Performance/index.tsx b/docs/src/examples/components/Provider/Performance/index.tsx new file mode 100644 index 0000000000..89119eb6e7 --- /dev/null +++ b/docs/src/examples/components/Provider/Performance/index.tsx @@ -0,0 +1,16 @@ +import * as React from 'react' + +import ComponentPerfExample from 'docs/src/components/ComponentDoc/ComponentPerfExample' +import NonPublicSection from 'docs/src/components/ComponentDoc/NonPublicSection' + +const Performance = () => ( + + + +) + +export default Performance diff --git a/docs/src/examples/components/Provider/index.tsx b/docs/src/examples/components/Provider/index.tsx index 21105833cf..2f2aa224bc 100644 --- a/docs/src/examples/components/Provider/index.tsx +++ b/docs/src/examples/components/Provider/index.tsx @@ -2,11 +2,13 @@ import * as React from 'react' import Types from './Types' import Usage from './Usage' +import Performance from './Performance' const ProviderExamples = () => ( <> + ) diff --git a/packages/react/src/components/Debug/Debug.tsx b/packages/react/src/components/Debug/Debug.tsx new file mode 100644 index 0000000000..c61f47634d --- /dev/null +++ b/packages/react/src/components/Debug/Debug.tsx @@ -0,0 +1,193 @@ +import keyboardKey from 'keyboard-key' +import * as PropTypes from 'prop-types' +import * as React from 'react' +import { toRefObject } from '@stardust-ui/react-component-ref' +import { EventListener } from '@stardust-ui/react-component-event-listener' + +import { isBrowser } from '../../lib' +import { isEnabled as isDebugEnabled } from '../../lib/debug/debugEnabled' + +import DebugPanel from './DebugPanel' +import FiberNavigator from './FiberNavigator' +import DebugRect from './DebugRect' + +type DebugProps = { + /** Existing document the popup should add listeners. */ + mountDocument?: Document +} + +type DebugState = { + debugPanelPosition?: 'left' | 'right' + fiberNav: FiberNavigator + selectedFiberNav: FiberNavigator + isSelecting: boolean +} + +const INITIAL_STATE: DebugState = { + fiberNav: null, + selectedFiberNav: null, + isSelecting: false, +} + +class Debug extends React.Component { + state = INITIAL_STATE + + static defaultProps = { + // eslint-disable-next-line no-undef + mountDocument: isBrowser() ? window.document : null, + } + + static propTypes = { + mountDocument: PropTypes.object.isRequired, + } + + constructor(p, s) { + super(p, s) + if (process.env.NODE_ENV !== 'production' && isDebugEnabled && isBrowser()) { + // eslint-disable-next-line no-undef + ;(window as any).openDebugPanel = () => { + // eslint-disable-next-line no-undef + this.debugReactComponent((window as any).$r) + } + } + } + + debugReactComponent = r => { + if (!r) { + console.error( + "No React component selected. Please select a Stardust component from the React's Component panel.", + ) + return + } + if (!r._reactInternalFiber) { + console.error( + 'React does not provide data for debugging for this component. Try selecting some Stardust component.', + ) + return + } + if (!r.stardustDebug) { + console.error('Not a debuggable component. Try selecting some Stardust component.') + return + } + + const fiberNav = FiberNavigator.fromFiber(r._reactInternalFiber) + this.setState({ fiberNav, isSelecting: false, selectedFiberNav: null }) + } + + debugDOMNode = domNode => { + let fiberNav = FiberNavigator.fromDOMNode(domNode) + + if (!fiberNav) { + console.error('No fiber for dom node', domNode) + return + } + + fiberNav = fiberNav.findOwner(fiber => fiber.stardustDebug) + + if (fiberNav !== this.state.fiberNav) { + this.setState({ fiberNav }) + } + } + + handleKeyDown = e => { + const code = keyboardKey.getCode(e) + + switch (code) { + case keyboardKey.Escape: + this.stopSelecting() + break + + case keyboardKey.d: + if (e.altKey && e.shiftKey) { + this.startSelecting() + } + break + } + } + + handleMouseMove = e => { + this.debugDOMNode(e.target) + } + + handleStardustDOMNodeClick = e => { + e.preventDefault() + e.stopPropagation() + + this.setState({ isSelecting: false }) + } + + startSelecting = () => { + const isSelecting = !this.state.isSelecting + + this.setState({ + ...(!isSelecting && INITIAL_STATE), + isSelecting, + }) + } + + stopSelecting = () => { + this.setState(INITIAL_STATE) + } + + selectFiber = selectedFiberNav => this.setState({ selectedFiberNav }) + + changeFiber = fiberNav => this.setState({ fiberNav }) + + positionRight = () => this.setState({ debugPanelPosition: 'right' }) + + positionLeft = () => this.setState({ debugPanelPosition: 'left' }) + + close = () => this.setState(INITIAL_STATE) + + render() { + const { mountDocument } = this.props + const { fiberNav, selectedFiberNav, isSelecting, debugPanelPosition } = this.state + + if (process.env.NODE_ENV !== 'production' && isDebugEnabled) { + return ( + <> + + {isSelecting && ( + + )} + {isSelecting && fiberNav && fiberNav.domNode && ( + + )} + {isSelecting && fiberNav && } + {selectedFiberNav && } + {!isSelecting && fiberNav && fiberNav.instance && ( + + )} + + ) + } + + return null + } +} + +export default Debug diff --git a/packages/react/src/components/Debug/DebugComponentViewer.tsx b/packages/react/src/components/Debug/DebugComponentViewer.tsx new file mode 100644 index 0000000000..65cf04080d --- /dev/null +++ b/packages/react/src/components/Debug/DebugComponentViewer.tsx @@ -0,0 +1,93 @@ +import * as React from 'react' +import FiberNavigator from './FiberNavigator' +import DebugLine from './DebugLine' +import ScrollToBottom from './ScrollToBottom' + +export type DebugComponentViewerProps = { + fiberNav: FiberNavigator + onFiberChanged: (fiberNav: FiberNavigator) => void + onFiberSelected: (fiberNav: FiberNavigator) => void +} + +const style: React.CSSProperties = { + padding: '8px', + whiteSpace: 'pre', + lineHeight: 1.4, + background: '#222', + overflowY: 'auto', + color: '#CCC', + fontFamily: 'monospace', + fontWeight: 'bold', +} + +const DebugComponentViewer: React.FC = props => { + const { fiberNav, onFiberChanged, onFiberSelected } = props + + const ownerNav = fiberNav.owner + + const parentNavs = [] + let parentNav = fiberNav.parent + + while (parentNav && !parentNav.isEqual(ownerNav)) { + if (parentNav.stardustDebug) parentNavs.unshift(parentNav) + parentNav = parentNav.parent + } + + const component = fiberNav.name && {fiberNav.jsxString} + + return ( + + { + e.preventDefault() + onFiberChanged(ownerNav) + }, + onMouseEnter: e => onFiberSelected(ownerNav), + onMouseLeave: e => onFiberSelected(null), + })} + > + {ownerNav.jsxString} + + + render() + + {parentNavs.map((parent, i) => ( + { + e.preventDefault() + onFiberChanged(parent) + }} + onMouseEnter={e => onFiberSelected(parent)} + onMouseLeave={e => onFiberSelected(null)} + > + {parent.jsxString} + + ))} + { + e.preventDefault() + onFiberChanged(fiberNav) + }} + onMouseEnter={e => onFiberSelected(fiberNav)} + onMouseLeave={e => onFiberSelected(null)} + > + {component} + + + ) +} + +export default DebugComponentViewer diff --git a/packages/react/src/components/Debug/DebugLine.tsx b/packages/react/src/components/Debug/DebugLine.tsx new file mode 100644 index 0000000000..969abe1cc7 --- /dev/null +++ b/packages/react/src/components/Debug/DebugLine.tsx @@ -0,0 +1,35 @@ +import * as React from 'react' + +const DebugLine: React.FC<{ + [key: string]: any + children: React.ReactNode + active?: boolean + indent?: number + style?: React.CSSProperties + badge?: string + actionable?: boolean +}> = ({ active, indent = 0, actionable, children, style, badge, ...rest }) => ( + + {children} + {badge && {badge}} + +) + +export default DebugLine diff --git a/packages/react/src/components/Debug/DebugPanel.tsx b/packages/react/src/components/Debug/DebugPanel.tsx new file mode 100644 index 0000000000..ba9d73c2a3 --- /dev/null +++ b/packages/react/src/components/Debug/DebugPanel.tsx @@ -0,0 +1,292 @@ +import * as React from 'react' +import * as _ from 'lodash' +import DebugPanelItem from './DebugPanelItem' +import FiberNavigator from './FiberNavigator' +import { getValues, removeNulls } from './utils' +import DebugComponentViewer from './DebugComponentViewer' + +export type DebugPanelProps = { + cssStyles?: string[] + fiberNav: FiberNavigator + debugData: { + componentStyles: { [key: string]: { styles: any; debugId: string } } + componentVariables: { + input: { [key: string]: any } + resolved: { [key: string]: any } + }[] + siteVariables: object[] + } + onActivateDebugSelectorClick: (e) => void + onClose: (e) => void + onPositionLeft: (e) => void + onPositionRight: (e) => void + position: 'left' | 'right' + onFiberChanged: (fiberNav: FiberNavigator) => void + onFiberSelected: (fiberNav: FiberNavigator) => void +} + +const DebugPanel: React.FC = props => { + const { + cssStyles, + debugData: inputDebugData, + fiberNav, + onActivateDebugSelectorClick, + onClose, + position, + onPositionLeft, + onPositionRight, + onFiberChanged, + onFiberSelected, + } = props + + const [slot, setSlot] = React.useState('root') + + const left = position === 'left' + + const debugData = + _.isNil(inputDebugData) || _.isEmpty(inputDebugData) + ? { + componentStyles: {}, + componentVariables: [], + siteVariables: [], + } + : inputDebugData + + debugData.componentStyles = debugData.componentStyles || {} + debugData.componentVariables = debugData.componentVariables || [] + debugData.siteVariables = debugData.siteVariables || [] + + const styleSlots = Object.keys(debugData.componentStyles) + let siteVariablesUsedInComponentVariables = [] + + debugData.componentVariables + .map(val => val.input) + .forEach( + val => + (siteVariablesUsedInComponentVariables = _.concat( + siteVariablesUsedInComponentVariables, + getValues(val, val => val.indexOf('siteVariables.') > -1), + )), + ) + + const uniqUsedSiteVariables = _.uniq(siteVariablesUsedInComponentVariables) + const siteVariablesDataWithNulls = debugData.siteVariables.map(val => ({ + ...val, + resolved: uniqUsedSiteVariables.reduce((acc, next) => { + const key = _.replace(next, 'siteVariables.', '') + _.set(acc, key, _.get(val['resolved'], key)) + return acc + }, {}), + })) + + const siteVariablesData = siteVariablesDataWithNulls.map(val => ({ + ...val, + resolved: removeNulls(val.resolved), + })) + + return ( +
+
+
+
+ ⇱ +
+
+
+
+
+ ✕ +
+
+
+ + + +
+ {/* Styles */} + +
+
+
Styles
+ {!_.isEmpty(debugData.componentStyles) && ( +
+ +
+ )} +
+ {!_.isEmpty(debugData.componentStyles) ? ( + + ) : ( +
None in use
+ )} +
+ + {/* Component Variables */} + +
+
+
Variables
+
+ {!_.isEmpty(debugData.componentVariables) ? ( + + typeof val === 'string' && val.indexOf('siteVariables.') > -1 + } + /> + ) : ( +
None in use
+ )} +
+ + {/* Site Variables */} + +
+
+
Site variables
+
+ {!_.isEmpty(siteVariablesData) && !_.isEmpty(uniqUsedSiteVariables) ? ( + + ) : ( +
None in use
+ )} +
+
+ + {!_.isEmpty(cssStyles) && ( +
+
HTML Styles
+
+ {cssStyles.map(l => ( +
{l}
+ ))} +
+
+ )} + +
+
+
+ ) +} + +const debugPanelHeader: React.CSSProperties = { + position: 'sticky', + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '2px 2px 4px', + top: '0', + background: '#f3f3f3', + zIndex: 1, +} + +const commonIconStyle: React.CSSProperties = { + display: 'inline-block', + cursor: 'pointer', + color: '#555', + lineHeight: 1, + margin: '0 4px', +} + +const debugPanelCloseIcon: React.CSSProperties = { + ...commonIconStyle, + fontSize: '20px', + outline: '0', + cursor: 'pointer', +} + +const debugPanelArrowIcon: React.CSSProperties = { + ...commonIconStyle, + fontSize: '24px', + marginTop: '-4px', + outline: '0', +} + +const debugPanelIcon = (left, isLeftActive): React.CSSProperties => ({ + ...commonIconStyle, + borderWidth: '2px', + borderStyle: 'solid ', + borderColor: '#555', + [left ? 'borderLeftWidth' : 'borderRightWidth']: '6px', + width: '16px', + height: '14px', + ...(left === isLeftActive && { + borderColor: '#6495ed', + }), +}) + +const debugPanelRoot = (left): React.CSSProperties => ({ + position: 'fixed', + [left ? 'left' : 'right']: 0, + top: 0, + zIndex: 999999999, + width: '350px', + height: '100vh', + color: '#313941', + background: '#fff', + lineHeight: 1.1, + fontSize: '12px', + overflowY: 'scroll', + [left ? 'borderRight' : 'borderLeft']: '1px solid rgba(0, 0, 0, 0.2)', + boxShadow: '0 0 8px rgba(0, 0, 0, .1)', +}) + +const debugHeaderContainer = (): React.CSSProperties => ({ + display: 'flex', + justifyContent: 'space-between', + alignItems: 'center', + padding: '8px', + margin: '0 -4px 4px', + overflow: 'hidden', + background: '#f3f3f3', + borderTop: '1px solid #d0d0d0', + borderBottom: '1px solid #d0d0d0', +}) + +const debugHeader = (): React.CSSProperties => ({ + fontSize: '14px', + fontWeight: 'bold', +}) + +const debugNoData = (): React.CSSProperties => ({ + padding: '8px', + color: 'rgba(0, 0, 0, 0.75)', + textAlign: 'center', + background: 'rgba(0, 0, 0, 0.05)', + marginBottom: '4px', +}) + +const debugPanelSelectContainer = (): React.CSSProperties => ({ + width: 'auto', +}) + +const debugPanelBody: React.CSSProperties = { + overflowWrap: 'break-word', + wordWrap: 'break-word', + wordBreak: 'break-all', + hyphens: 'auto', +} + +const debugPanel: React.CSSProperties = { + padding: '0 4px', +} + +export default DebugPanel diff --git a/packages/react/src/components/Debug/DebugPanelData.tsx b/packages/react/src/components/Debug/DebugPanelData.tsx new file mode 100644 index 0000000000..5c40673059 --- /dev/null +++ b/packages/react/src/components/Debug/DebugPanelData.tsx @@ -0,0 +1,68 @@ +import * as React from 'react' +import { find, isOverridden } from './utils' + +interface DebugPanelDataProps { + data: any + overrides?: any + comments?: any + indent?: number + highlightKey?: string + commentKeyPredicate?: (val: any) => boolean +} + +const DebugPanelData: React.FC = props => { + const { data, indent = 2, highlightKey, overrides, comments, commentKeyPredicate } = props + + const isValidComment = + typeof comments === 'string' && commentKeyPredicate && commentKeyPredicate(comments) + + if (typeof data === 'undefined') { + return isValidComment ? undefined : undefined + } + + if (data === null || typeof data !== 'object') { + return isValidComment ? ( + {JSON.stringify(data)} + ) : ( + {JSON.stringify(data)} + ) + } + + return ( + <> + {'{'} + {Object.keys(data).map((key, idx) => { + const value = data[key] + + const comment = comments && comments[key] + + const highlight = find(data, key, highlightKey) + const overridden = isOverridden(data, key, overrides) + + return ( +
+ + {' '.repeat(indent)} + + {key} + {': '} + + + {','} + +
+ ) + })} + {`${indent > 2 ? ' '.repeat(indent - 2) : ''}}`} + + ) +} + +export default DebugPanelData diff --git a/packages/react/src/components/Debug/DebugPanelItem.tsx b/packages/react/src/components/Debug/DebugPanelItem.tsx new file mode 100644 index 0000000000..5a3291a1a8 --- /dev/null +++ b/packages/react/src/components/Debug/DebugPanelItem.tsx @@ -0,0 +1,74 @@ +import * as React from 'react' +import DebugPanelData from './DebugPanelData' +import { filter } from './utils' +import deepmerge from '../../lib/deepmerge' + +interface DebugPanelItemProps { + data: any + valueKey?: string + commentKey?: string + idKey?: string + commentKeyPredicate?: (val: any) => boolean +} + +const DebugPanelItem: React.FC = props => { + const [value, setValue] = React.useState('') + const { data: propData, valueKey, commentKey, commentKeyPredicate, idKey } = props + + const reversedData = JSON.parse(JSON.stringify(propData)).reverse() + + const data = valueKey ? reversedData.map(v => v[valueKey]) : reversedData + const comments = commentKey ? reversedData.map(v => v[commentKey]) : [] + const ids = idKey ? reversedData.map(v => v[idKey]) : [] + + const mergedThemes = [] + + mergedThemes.push({}) // init + + for (let i = 1; i < data.length; i++) { + mergedThemes.push(deepmerge(mergedThemes[i - 1], data[i - 1])) + } + + return ( + <> + setValue(e.target.value)} + style={{ + padding: '2px 4px', + marginBottom: '4px', + width: '100%', + border: '1px solid #ccc', + background: 'none', + }} + placeholder="Filter" + /> + {data.map((theme, idx) => { + const filteredTheme = value === '' ? theme : filter(theme, value) + + return ( +
 0 ? '1px solid #ddd' : 'none',
+            }}
+          >
+            {ids && ids[idx] && (
+              
{ids[idx]}
+ )} + +
+ ) + })} + + ) +} + +export default DebugPanelItem diff --git a/packages/react/src/components/Debug/DebugRect.tsx b/packages/react/src/components/Debug/DebugRect.tsx new file mode 100644 index 0000000000..82fcfd8de2 --- /dev/null +++ b/packages/react/src/components/Debug/DebugRect.tsx @@ -0,0 +1,103 @@ +import * as React from 'react' +import FiberNavigator from './FiberNavigator' + +interface DebugRectProps { + fiberNav: FiberNavigator +} + +class DebugRect extends React.Component { + selectorRef = React.createRef() + + componentDidMount() { + this.setDebugSelectorPosition() + } + + componentDidUpdate(prevProps, prevState, snapshot) { + this.setDebugSelectorPosition() + } + + setDebugSelectorPosition = () => { + const { fiberNav } = this.props + + if ( + fiberNav && + fiberNav.domNode && + fiberNav.domNode.getBoundingClientRect && + typeof fiberNav.domNode.getBoundingClientRect === 'function' && + this.selectorRef.current + ) { + const rect = fiberNav.domNode.getBoundingClientRect() + + this.selectorRef.current.style.top = `${rect.top}px` + this.selectorRef.current.style.left = `${rect.left}px` + this.selectorRef.current.style.width = `${rect.width}px` + this.selectorRef.current.style.height = `${rect.height}px` + + requestAnimationFrame(this.setDebugSelectorPosition) + } + } + + render() { + const { fiberNav } = this.props + + if (!fiberNav) { + return null + } + + return ( +
+        
+ {`<${fiberNav.name} />`} +
+ {fiberNav.domNode && ( +
+ + {fiberNav.domNode.tagName && fiberNav.domNode.tagName.toLowerCase()} + + {fiberNav.domNode.hasAttribute && + typeof fiberNav.domNode.hasAttribute === 'function' && + fiberNav.domNode.hasAttribute('class') && ( + + .{(fiberNav.domNode.getAttribute('class') || '').replace(/ +/g, '.')} + + )} +
+ )} +
+ ) + } +} + +export default DebugRect diff --git a/packages/react/src/components/Debug/FiberNavigator.ts b/packages/react/src/components/Debug/FiberNavigator.ts new file mode 100644 index 0000000000..fc677079fa --- /dev/null +++ b/packages/react/src/components/Debug/FiberNavigator.ts @@ -0,0 +1,422 @@ +// ======================================================== +// react/packages/shared/ReactTypes.js +// ======================================================== + +type ReactEventResponder = { + $$typeof: Symbol | number + displayName: string + targetEventTypes: null | string[] + rootEventTypes: null | string[] + getInitialState: null | ((props: Object) => Object) + onEvent: null | ((event: E, context: C, props: Object, state: Object) => void) + onRootEvent: null | ((event: E, context: C, props: Object, state: Object) => void) + onMount: null | ((context: C, props: Object, state: Object) => void) + onUnmount: null | ((context: C, props: Object, state: Object) => void) +} + +type ReactEventResponderInstance = { + fiber: Object + props: Object + responder: ReactEventResponder + rootEventTypes: null | Set + state: Object +} + +// ======================================================== +// react/packages/react-reconciler/src/ReactFiberHooks.js +// ======================================================== + +export type HookType = + | 'useState' + | 'useReducer' + | 'useContext' + | 'useRef' + | 'useEffect' + | 'useLayoutEffect' + | 'useCallback' + | 'useMemo' + | 'useImperativeHandle' + | 'useDebugValue' + | 'useResponder' + +type ReactProviderType = { + $$typeof: Symbol | number + _context: ReactContext +} + +type ReactContext = { + $$typeof: Symbol | number + Consumer: ReactContext + Provider: ReactProviderType + + _calculateChangedBits: ((a: T, b: T) => number) | null + + _currentValue: T + _currentValue2: T + _threadCount: number + + // DEV only + _currentRenderer?: Object | null + _currentRenderer2?: Object | null +} + +type ContextDependency = { + context: ReactContext + observedBits: number + next: ContextDependency | null +} + +enum WorkTag { + FunctionComponent = 0, + ClassComponent = 1, + IndeterminateComponent = 2, // Before we know whether it is function or class + HostRoot = 3, // Root of a host tree. Could be nested inside another node. + HostPortal = 4, // A subtree. Could be an entry point to a different renderer. + HostComponent = 5, + HostText = 6, + Fragment = 7, + Mode = 8, + ContextConsumer = 9, + ContextProvider = 10, + ForwardRef = 11, + Profiler = 12, + SuspenseComponent = 13, + MemoComponent = 14, + SimpleMemoComponent = 15, + LazyComponent = 16, + IncompleteClassComponent = 17, + DehydratedFragment = 18, + SuspenseListComponent = 19, + FundamentalComponent = 20, + ScopeComponent = 21, +} + +type Source = { + fileName: string + lineNumber: number +} + +type ExpirationTime = number + +type Dependencies = { + expirationTime: ExpirationTime + firstContext: ContextDependency | null + responders: Map, ReactEventResponderInstance> | null +} + +// ======================================================== +// react/packages/react-reconciler/src/ReactFiber.js +// ======================================================== + +// A Fiber is work on a Component that needs to be done or was done. There can +// be more than one per component. +type Fiber = { + // These first fields are conceptually members of an Instance. This used to + // be split into a separate type and intersected with the other Fiber fields, + // but until Flow fixes its intersection bugs, we've merged them into a + // single type. + + // An Instance is shared between all versions of a component. We can easily + // break this out into a separate object to avoid copying so much to the + // alternate versions of the tree. We put this on a single object for now to + // minimize the number of objects created during the initial render. + + // Tag identifying the type of fiber. + tag: WorkTag + + // Unique identifier of this child. + key: null | string + + // The value of element.type which is used to preserve the identity during + // reconciliation of this child. + elementType: any + + // The resolved function/class/ associated with this fiber. + type: any + + // The local state associated with this fiber. + stateNode: any + + // Conceptual aliases + // parent : Instance -> return The parent happens to be the same as the + // return fiber since we've merged the fiber and instance. + + // Remaining fields belong to Fiber + + // The Fiber to return to after finishing processing this one. + // This is effectively the parent, but there can be multiple parents (two) + // so this is only the parent of the thing we're currently processing. + // It is conceptually the same as the return address of a stack frame. + return: Fiber | null + + // Singly Linked List Tree Structure. + child: Fiber | null + sibling: Fiber | null + index: number + + // The ref last used to attach this node. + // I'll avoid adding an owner field for prod and model that as functions. + ref: React.Ref + + // Input is the data coming into process this fiber. Arguments. Props. + pendingProps: any // This type will be more specific once we overload the tag. + memoizedProps: any // The props used to create the output. + + // A queue of state updates and callbacks. + // updateQueue: UpdateQueue | null, + + // The state used to create the output + memoizedState: any + + // Dependencies (contexts, events) for this fiber, if it has any + dependencies: Dependencies | null + + // // Bitfield that describes properties about the fiber and its subtree. E.g. + // // the ConcurrentMode flag indicates whether the subtree should be async-by- + // // default. When a fiber is created, it inherits the mode of its + // // parent. Additional flags can be set at creation time, but after that the + // // value should remain unchanged throughout the fiber's lifetime, particularly + // // before its child fibers are created. + // mode: TypeOfMode + // + // // Effect + // effectTag: SideEffectTag + + // Singly linked list fast path to the next fiber with side-effects. + nextEffect: Fiber | null + + // The first and last fiber with side-effect within this subtree. This allows + // us to reuse a slice of the linked list when we reuse the work done within + // this fiber. + firstEffect: Fiber | null + lastEffect: Fiber | null + + // Represents a time in the future by which this work should be completed. + // Does not include work found in its subtree. + expirationTime: ExpirationTime + + // This is used to quickly determine if a subtree has no pending changes. + childExpirationTime: ExpirationTime + + // This is a pooled version of a Fiber. Every fiber that gets updated will + // eventually have a pair. There are cases when we can clean up pairs to save + // memory if we need to. + alternate: Fiber | null + + // Time spent rendering this Fiber and its descendants for the current update. + // This tells us how well the tree makes use of sCU for memoization. + // It is reset to 0 each time we render and only updated when we don't bailout. + // This field is only set when the enableProfilerTimer flag is enabled. + actualDuration?: number + + // If the Fiber is currently active in the "render" phase, + // This marks the time at which the work began. + // This field is only set when the enableProfilerTimer flag is enabled. + actualStartTime?: number + + // Duration of the most recent render time for this Fiber. + // This value is not updated when we bailout for memoization purposes. + // This field is only set when the enableProfilerTimer flag is enabled. + selfBaseDuration?: number + + // Sum of base times for all descendants of this Fiber. + // This value bubbles up during the "complete" phase. + // This field is only set when the enableProfilerTimer flag is enabled. + treeBaseDuration?: number + + // Conceptual aliases + // workInProgress : Fiber -> alternate The alternate used for reuse happens + // to be the same as work in progress. + // __DEV__ only + _debugID?: number + _debugSource?: Source | null + _debugOwner?: Fiber | null + _debugIsCurrentlyTiming?: boolean + _debugNeedsRemount?: boolean + + // Used to verify that the order of hooks does not change between renders. + _debugHookTypes?: HookType[] | null +} + +const isDOMNode = e => e && typeof e.tagName === 'string' && e.nodeType === Node.ELEMENT_NODE + +class FiberNavigator { + __fiber: Fiber + + static domNodeToReactFiber = (elm: HTMLElement): Fiber => { + if (!elm) return null + + for (const k in elm) { + if (k.startsWith('__reactInternalInstance$')) { + return elm[k] + } + } + + return null + } + + // TODO: Fibers can become stale. + // The only current fiber is the one found on the DOM node. + // There is no way to start at a React Component fiber, go the DOM node, + // get the current fiber, and find your way back to the React Component fiber. + // Probably need to remove fromFiber and re-implement using only DOM node weak map. + static fromFiber = fiber => { + if (!fiber) return null + + const fiberNavigator = new FiberNavigator() + + Object.defineProperty(fiberNavigator, '__fiber', { + value: fiber, + enumerable: false, + writable: false, + configurable: false, + }) + + return fiberNavigator + } + + static fromDOMNode = domNode => { + const fiber = FiberNavigator.domNodeToReactFiber(domNode) + + if (!fiber) return null + + const fiberNavigator = new FiberNavigator() + + Object.defineProperty(fiberNavigator, '__fiber', { + value: fiber, + enumerable: false, + writable: false, + configurable: false, + }) + + return fiberNavigator + } + + get name() { + return this.isClassComponent || this.isFunctionComponent + ? this.__fiber.type.displayName || this.__fiber.type.name + : this.isHostComponent + ? this.__fiber.stateNode.constructor.name + : null + } + + get parent(): FiberNavigator { + return FiberNavigator.fromFiber(this.__fiber.return) + } + + get owner() { + return FiberNavigator.fromFiber(this.__fiber._debugOwner) + } + + get domNode() { + let fiber = this.__fiber + + do { + if (isDOMNode(fiber.stateNode)) { + return fiber.stateNode + } + fiber = fiber.child + } while (fiber) + + return null + } + + get instance() { + return this.isClassComponent + ? this.__fiber.stateNode + : this.isFunctionComponent // TODO: assumes functional component w/useRef + ? this.__fiber.memoizedState && + this.__fiber.memoizedState.memoizedState && + this.__fiber.memoizedState.memoizedState.current + : null + } + + get reactComponent() { + return this.isHostComponent ? this.owner.elementType : this.elementType + } + + get elementType() { + return this.__fiber.elementType + } + + get stardustDebug() { + return this.instance && this.instance.stardustDebug ? this.instance.stardustDebug : null + } + + get jsxString() { + return `<${this.name} />` + } + + // + // Methods + // + + isEqual(fiberNav: FiberNavigator) { + // TODO: do equality check on __fiber instead, however, see fromFiber TODO :/ + return !!fiberNav && fiberNav.instance === this.instance + } + + usesHook(name) { + return this.__fiber._debugHookTypes.some(hook => hook === name) + } + + find(condition, move) { + let fiber: FiberNavigator = FiberNavigator.fromFiber(this.__fiber) + + while (fiber) { + if (condition(fiber)) { + return fiber + } + fiber = move(fiber) + } + + return null + } + + findOwner(condition) { + return this.find(condition, fiber => fiber.owner) + } + + findParent(condition) { + return this.find(condition, fiber => fiber.parent) + } + + // + // Component Types + // + + get isClassComponent() { + // React.Component subclasses have this flag + // https://reactjs.org/docs/implementation-notes.html + return typeof this.__fiber.type === 'function' && !!this.__fiber.type.prototype.isReactComponent + } + + get isFunctionComponent() { + // React.Component subclasses have this flag + // https://reactjs.org/docs/implementation-notes.html + return typeof this.__fiber.type === 'function' && !this.__fiber.type.prototype.isReactComponent + } + + get isHostComponent() { + // Host components are platform components (i.e. 'div' on web) + // https://github.com/acdlite/react-fiber-architecture#type-and-key + return typeof this.__fiber.type === 'string' + } + + // + // What this fiber component renders + // + + get isDOMComponent() { + return !!this.__fiber.child && FiberNavigator.fromFiber(this.__fiber.child).isHostComponent + } + + // https://github.com/facebook/react/blob/16.8.6/packages/react-dom/src/test-utils/ReactTestUtils.js#L193 + get isCompositeComponent() { + return this.isDOMComponent + ? false + : !!this.instance && !!this.instance.render && !!this.instance.setState + } +} + +export default FiberNavigator diff --git a/packages/react/src/components/Debug/ScrollToBottom.tsx b/packages/react/src/components/Debug/ScrollToBottom.tsx new file mode 100644 index 0000000000..0dce699e09 --- /dev/null +++ b/packages/react/src/components/Debug/ScrollToBottom.tsx @@ -0,0 +1,19 @@ +import * as React from 'react' + +class ScrollToBottom extends React.Component { + ref = React.createRef() + + componentDidMount() { + this.scrollToBottom() + } + + scrollToBottom() { + this.ref.current.scrollTo({ behavior: 'smooth', top: 999999 }) + } + + render() { + return
+ } +} + +export default ScrollToBottom diff --git a/packages/react/src/components/Debug/utils.ts b/packages/react/src/components/Debug/utils.ts new file mode 100644 index 0000000000..844eaac092 --- /dev/null +++ b/packages/react/src/components/Debug/utils.ts @@ -0,0 +1,150 @@ +import * as _ from 'lodash' + +/** + * Check whether source includes target ignoring case. + * @param {string} source + * @param {string} target + * @returns {boolean} + */ +export const includes = (source: string, target: string): boolean => + _.toLower(source).indexOf(_.toLower(target)) !== -1 + +/** + * Checks whether the key or the value of data[key] contains the search string. + * @param {object} data + * @param {string} key + * @param {string} search + * @returns {boolean} + */ +export const find = (data: object, key: string, search: string): boolean => { + const value = data[key] + return ( + search !== '' && + (includes(key, search) || + (typeof value !== 'object' && !_.isNil(value) && includes(value, search))) + ) +} + +/** + * Checks if the data[key] is primitive and override in the overrides object. + * @param {object} data + * @param {string} key + * @param {object} overrides + * @returns {boolean} + */ +export const isOverridden = (data: object, key: string, overrides: object): boolean => { + return ( + typeof data[key] !== 'object' && + !!overrides && + overrides[key] !== null && + overrides[key] !== undefined + ) +} + +/** + * Helper recursive function for the filter method. + * @param {string} search + * @param {object} data + * @returns {boolean} + */ +const filterR = (search: string, data: object): boolean => { + let result = false + + Object.keys(data).forEach(key => { + const value = data[key] + + if (find(data, key, search)) { + result = true + } + + // If the value is object invoke again + if (typeof value === 'object' && filterR(search, value)) { + result = true + } + }) + + return result +} + +/** + * Filters the data for the value string (if it appears in the key or value). Considers nested objects. + * @param {object} data + * @param {string} value + * @returns {any} + */ +export const filter = (data: object, value: string) => { + return Object.keys(data) + .filter(key => { + if (find(data, key, value)) { + return true + } + + // if the value is object invoke again + if (typeof data[key] === 'object' && data[key] !== null) { + return filterR(value, data[key]) + } + + return false + }) + .reduce((obj, key) => { + obj[key] = data[key] + return obj + }, {}) +} + +/** + * Returns array of values that matches the predicate. Considers nested objects. + * @param value + * @param {(string) => boolean} predicate + * @returns {string[]} + */ +export const getValues = (value: any, predicate: (string) => boolean): string[] => { + if (_.isNil(value)) { + return [] + } + + if (typeof value === 'string') { + if (predicate(value)) { + return [value] + } + } + + if (typeof value === 'object') { + let arr: string[] = [] + Object.keys(value).forEach(key => { + arr = _.concat(arr, getValues(value[key], predicate)) + }) + return arr + } + + return [] +} + +/** + * Removes null values from an object. Considers nested objects. + * @param o + * @returns {any} + */ +export const removeNulls = (o: any): any => { + if (typeof o !== 'object' && o !== null) { + return o + } + const result = {} + + Object.keys(o).forEach(k => { + if (!o[k] || typeof o[k] !== 'object') { + if (o[k]) { + result[k] = o[k] // If not null or not an object, copy value + } + } else { + // The property is an object + const val = removeNulls(o[k]) + + if (typeof val === 'object' && val != null && Object.keys(val).length > 0) { + result[k] = val + } + } + }) + + return result +} diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index a196e7358d..41dec2cc4d 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -6,6 +6,7 @@ import * as themes from './themes' export { themes } export * from './themes/types' export * from './themes/colorUtils' +export * from './themes/createTheme' // // Teams theme @@ -49,6 +50,9 @@ export { default as ChatMessage } from './components/Chat/ChatMessage' export * from './components/Checkbox/Checkbox' export { default as Checkbox } from './components/Checkbox/Checkbox' +export * from './components/Debug/Debug' +export { default as Debug } from './components/Debug/Debug' + export * from './components/Design/Design' export { default as Design } from './components/Design/Design' diff --git a/packages/react/src/lib/createComponent.ts b/packages/react/src/lib/createComponent.ts index b64d2eecad..1fd9b5efc1 100644 --- a/packages/react/src/lib/createComponent.ts +++ b/packages/react/src/lib/createComponent.ts @@ -42,7 +42,7 @@ const createComponent =

= any>({ const StardustComponent: CreateComponentReturnType

= (props): React.ReactElement

=> { // Stores debug information for component. // Note that this ref should go as the first one, to be discoverable by debug utils. - const stardustDebug = React.useRef(null) + const ref = React.useRef(null) const context: ProviderContextPrepared = React.useContext(ThemeContext) @@ -55,7 +55,7 @@ const createComponent =

= any>({ state: {}, actionHandlers, render: config => render(config, props), - saveDebug: updatedDebug => (stardustDebug.current = updatedDebug), + saveDebug: stardustDebug => (ref.current = { stardustDebug }), }, context, ) diff --git a/packages/react/src/lib/debug/debugApi.ts b/packages/react/src/lib/debug/debugApi.ts deleted file mode 100644 index 76cfce802a..0000000000 --- a/packages/react/src/lib/debug/debugApi.ts +++ /dev/null @@ -1,89 +0,0 @@ -const getFirstRefHookValue = fiber => { - const stateHooks = fiber.memoizedState - const refHooks = stateHooks && stateHooks.memoizedState - - return refHooks && refHooks.current -} - -const getFunctionComponentDebugData = component => { - const fiberAccessorPropName = Object.keys(component).filter(key => - key.startsWith('__reactInternalInstance'), - )[0] - - if (!fiberAccessorPropName) { - throw new Error('Debug info was not found: fiber accessor prop is not detected.') - } - - const domElementFiber = component[fiberAccessorPropName] - if (!domElementFiber) { - throw new Error( - 'Debug info was not found: fiber element is not defined. Ensure that Stardust component is selected.', - ) - } - - return getFirstRefHookValue(domElementFiber._debugOwner) -} - -const debugApi = (inputComponent?) => { - // eslint-disable-next-line no-undef - const component = inputComponent || (window as any).$r - - if (!component) { - throw new Error('Debug info was not found: component is not provided as an input.') - } - - const debug = component.renderComponent - ? component.stardustDebug - : getFunctionComponentDebugData(component) - - if (debug === null) { - console.warn( - [ - 'Debug data collection is disabled.', - 'To enable it, paste `window.localStorage.stardustDebug = true` to your browser console and reload the page.', - ].join(' '), - ) - return undefined - } - - if (!debug) { - console.error( - 'No debug data available. Ensure that you have selected Stardust component to debug.', - ) - return undefined - } - - return debug.resolve() -} - -debugApi.whosProp = (...args) => debugApi().whosProp(...args) -debugApi.whosPropContains = (...args) => debugApi().whosPropContains(...args) -debugApi.whosValue = (...args) => debugApi().whosValue(...args) -debugApi.whosValueContains = (...args) => debugApi().whosValueContains(...args) - -const isDebugEnabled = () => { - let enabled = false - try { - const isProduction = process.env.NODE_ENV === 'production' - // eslint-disable-next-line no-undef - const isEnabledBrowserOverride = !!window.localStorage.stardustDebug - - if (isEnabledBrowserOverride) { - console.warn( - [ - '@stardust-ui/react:', - `Debug data collection is overriden to be enabled.`, - 'To remove this override paste `delete window.localStorage.stardustDebug` to your browser console and reload the page.', - ].join(' '), - ) - } - - enabled = isEnabledBrowserOverride || !isProduction - } catch {} - - return enabled -} - -export const isEnabled = isDebugEnabled() - -export default debugApi diff --git a/packages/react/src/lib/debug/debugData.ts b/packages/react/src/lib/debug/debugData.ts index bd1dffd07b..00726afdd2 100644 --- a/packages/react/src/lib/debug/debugData.ts +++ b/packages/react/src/lib/debug/debugData.ts @@ -1,55 +1,6 @@ -import { deepPick, deepPickBy, containsSubstring } from './utils' -import traverse from './debugDataTraversal' - -import { SiteVariablesDebugData, VariablesDebugData, StylesDebugData, IDebugData } from './types' - -export default class DebugData implements IDebugData { - constructor( - public readonly componentName: string, - public readonly siteVariables: SiteVariablesDebugData, - public readonly variables: VariablesDebugData, - public readonly styles: StylesDebugData, - ) {} - - public whosProp(propNameOrPredicate: string | ((propName: string) => boolean)) { - if (typeof propNameOrPredicate === 'function') { - return traverse(this, data => - deepPickBy(data, currentPropName => propNameOrPredicate(currentPropName)), - ) - } - - return traverse(this, data => deepPick(data, propNameOrPredicate)) - } - - public whosPropContains(substring: string) { - return this.whosProp(propName => containsSubstring(propName, substring)) - } - - public whosValue(valueOrPredicate: object | ((value: object) => boolean)) { - if (typeof valueOrPredicate === 'function') { - return traverse(this, data => - deepPickBy(data, (currentPropName, currentPropValue) => valueOrPredicate(currentPropValue)), - ) - } - - return traverse( - this, - data => - // This loose comparison (with two equal signs) is necessary - // so that provided prop value of, say, number 400 for font weight - // would trigger match for the values defined as string '400'. - - // tslint:disable:triple-equals - deepPickBy( - data, - // eslint-disable-next-line eqeqeq - (currentPropName, currentPropValue) => currentPropValue == valueOrPredicate, - ), - // tslint:enable:triple-equals - ) - } - - public whosValueContains(substring: string) { - return this.whosValue(value => containsSubstring(value, substring)) - } +export type DebugData = { + componentName: string + siteVariables: Object[] + componentVariables: Object[] + componentStyles: Record } diff --git a/packages/react/src/lib/debug/debugDataProvider.ts b/packages/react/src/lib/debug/debugDataProvider.ts deleted file mode 100644 index d22108171e..0000000000 --- a/packages/react/src/lib/debug/debugDataProvider.ts +++ /dev/null @@ -1,151 +0,0 @@ -import { mergeSiteVariables, mergeComponentVariables, mergeComponentStyles } from '../mergeThemes' -import DebugData from './debugData' -import { getLastOf } from './utils' - -import { - ThemeInput, - ComponentSlotStylesPrepared, - ComponentSlotStylesInput, - ComponentVariablesInput, - ComponentVariablesPrepared, - SiteVariablesPrepared, - SiteVariablesInput, -} from '../../themes/types' -import { DebugDataProviderArgs, DebugCategory } from './types' - -type CreateDebugCategoryArgs = { - getDataFromTheme: (theme: ThemeInput) => InputData - args: any - resolve: (input: InputData) => ResolvedData - merge: (...inputs: ResolvedData[]) => InputData - defaultValue: InputData - instanceOverrides?: InputData -} - -export default class Debug { - private readonly componentName: DebugDataProviderArgs['componentName'] - private readonly themes: DebugDataProviderArgs['themes'] - - private readonly instanceStylesOverrides: DebugDataProviderArgs['instanceStylesOverrides'] - private readonly instanceVariablesOverrides: DebugDataProviderArgs['instanceVariablesOverrides'] - - private readonly resolveStyles: DebugDataProviderArgs['resolveStyles'] - private readonly resolveVariables: DebugDataProviderArgs['resolveVariables'] - - constructor({ - componentName, - themes, - instanceStylesOverrides, - instanceVariablesOverrides, - resolveStyles, - resolveVariables, - }: DebugDataProviderArgs) { - this.componentName = componentName - this.themes = themes - this.instanceStylesOverrides = instanceStylesOverrides - this.instanceVariablesOverrides = instanceVariablesOverrides - this.resolveStyles = resolveStyles - this.resolveVariables = resolveVariables - } - - public resolve() { - const siteVariablesDebugData = this.createDebugCategory< - SiteVariablesPrepared, - SiteVariablesInput - >({ - getDataFromTheme: theme => theme && theme.siteVariables, - args: {}, - resolve: it => it as any, - merge: mergeSiteVariables, - defaultValue: {}, - }) - - const variablesDebugData = this.createDebugCategory< - ComponentVariablesPrepared, - ComponentVariablesInput - >({ - getDataFromTheme: theme => - theme.componentVariables && theme.componentVariables[this.componentName], - args: { siteVariables: siteVariablesDebugData.result }, - resolve: this.resolveVariables, - merge: mergeComponentVariables, - defaultValue: {}, - instanceOverrides: this.instanceVariablesOverrides, - }) - - const stylesDebugData = this.createDebugCategory< - ComponentSlotStylesPrepared, - ComponentSlotStylesInput - >({ - getDataFromTheme: theme => - theme && theme.componentStyles && theme.componentStyles[this.componentName], - args: { siteVariables: siteVariablesDebugData.result, variables: variablesDebugData.result }, - resolve: this.resolveStyles, - merge: mergeComponentStyles, - defaultValue: {}, - instanceOverrides: this.instanceStylesOverrides - ? { root: this.instanceStylesOverrides } - : undefined, - }) - - return new DebugData( - this.componentName, - siteVariablesDebugData, - variablesDebugData, - stylesDebugData, - ) - } - - private createDebugCategory({ - getDataFromTheme, - args, - resolve, - merge, - defaultValue, - instanceOverrides, - }: CreateDebugCategoryArgs): DebugCategory { - const themeSources = this.themes.map(theme => getDataFromTheme(theme) || defaultValue) - const themeResolved = themeSources.map(resolve) - const themeMerged = this.cumulativeMergeAndResolve( - themeResolved, - merge, - resolve, - ) - const themeResult = getLastOf(themeMerged, resolve(defaultValue)) - - const instance = instanceOverrides - ? { - src: instanceOverrides, - args, - resolved: resolve(instanceOverrides), - merged: merge(themeResult, resolve(instanceOverrides)), - } - : ({} as any) - - const result = instance.src - ? resolve(merge(themeResult, resolve(instanceOverrides))) - : themeResult - - return { - result, - themes: themeSources.map((src, index) => ({ - src, - args, - resolved: themeResolved[index], - merged: themeMerged[index], - })), - instanceOverrides: instance, - } - } - - private cumulativeMergeAndResolve( - resolved: ResolvedData[], - merge: (...args: ResolvedData[]) => InputData, - resolve: (input: InputData) => ResolvedData, - ): ResolvedData[] { - return resolved.map((item, index) => { - const merged = merge(...resolved.slice(0, index + 1)) - return resolve(merged) - }) - } -} diff --git a/packages/react/src/lib/debug/debugDataTraversal.ts b/packages/react/src/lib/debug/debugDataTraversal.ts deleted file mode 100644 index 1596ea926c..0000000000 --- a/packages/react/src/lib/debug/debugDataTraversal.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { isNotNullOrEmpty, isNotEmptyObjectsArray } from './utils' - -import { IDebugData, DebugCategory } from './types' -import { ComponentSlotStylesPrepared } from '../../themes/types' - -const traverseComponentThemeStyles = ( - componentStyles: ComponentSlotStylesPrepared, - filterData: (data: any) => any, -) => { - if (!componentStyles) return {} - - return Object.keys(componentStyles).reduce((acc, slotName) => { - const slotStyles = componentStyles[slotName] - const filteredSlotStyles = filterData(slotStyles) - - return isNotNullOrEmpty(filteredSlotStyles) ? { ...acc, [slotName]: filteredSlotStyles } : acc - }, {}) -} - -const traverseComponentStyles = ( - stylesDebugOutput: IDebugData['styles'], - filterData: (data: any) => any, -) => { - const filteredThemes = stylesDebugOutput.themes.map(theme => - traverseComponentThemeStyles(theme.resolved, filterData), - ) - - const filteredInstance = filterData( - (stylesDebugOutput.instanceOverrides && stylesDebugOutput.instanceOverrides.resolved) || {}, - ) - const filteredResult = traverseComponentThemeStyles(stylesDebugOutput.result, filterData) - - return { - ...(isNotEmptyObjectsArray(filteredThemes) && { themes: filteredThemes }), - ...(isNotNullOrEmpty(filteredInstance) && { instanceOverrides: filteredInstance }), - ...(isNotNullOrEmpty(filteredResult) && { result: filteredResult }), - } -} - -const traverseComponentVariables = ( - variablesDebugOutput: DebugCategory, - filterData: (data: any) => any, -) => { - const filteredThemes = variablesDebugOutput.themes.map(theme => filterData(theme.resolved)) - - const filteredInstance = filterData( - (variablesDebugOutput.instanceOverrides && variablesDebugOutput.instanceOverrides.resolved) || - {}, - ) - const filteredResult = filterData(variablesDebugOutput.result) - - return { - ...(isNotEmptyObjectsArray(filteredThemes) && { themes: filteredThemes }), - ...(isNotNullOrEmpty(filteredInstance) && { instanceOverrides: filteredInstance }), - ...(isNotNullOrEmpty(filteredResult) && { result: filteredResult }), - } -} - -const traverse = (debugOutput: IDebugData, filterData: (data: any) => any) => { - const stylesResult = traverseComponentStyles(debugOutput.styles, filterData) - const variablesResult = traverseComponentVariables(debugOutput.variables, filterData) - const siteVariablesResult = traverseComponentVariables(debugOutput.siteVariables, filterData) - - const result = { - ...(isNotNullOrEmpty(stylesResult) && { styles: stylesResult }), - ...(isNotNullOrEmpty(variablesResult) && { variables: variablesResult }), - ...(isNotNullOrEmpty(siteVariablesResult) && { siteVariables: siteVariablesResult }), - } - - return isNotNullOrEmpty(result) - ? { - componentName: debugOutput.componentName, - ...result, - } - : {} -} - -export default traverse diff --git a/packages/react/src/lib/debug/debugEnabled.ts b/packages/react/src/lib/debug/debugEnabled.ts new file mode 100644 index 0000000000..8d4396f3d3 --- /dev/null +++ b/packages/react/src/lib/debug/debugEnabled.ts @@ -0,0 +1,37 @@ +const isDebugEnabled = () => { + let enabled = false + if (process.env.NODE_ENV !== 'production') { + try { + // eslint-disable-next-line no-undef + const stardustDebugEnabled = !!window.localStorage.stardustDebug + + if (process.env.NODE_ENV !== 'test') { + if (stardustDebugEnabled) { + /* eslint-disable-next-line no-console */ + console.warn( + [ + '@stardust-ui/react:', + `CSSinJS Debug data collection is enabled.`, + 'To remove this override paste `delete window.localStorage.stardustDebug` to your browser console and reload the page.', + ].join(' '), + ) + } else { + /* eslint-disable-next-line no-console */ + console.warn( + [ + '@stardust-ui/react:', + `CSSinJS Debug data collection is disabled.`, + 'To enable data collection paste `window.localStorage.stardustDebug = true` to your browser console and reload the page.', + ].join(' '), + ) + } + } + + enabled = stardustDebugEnabled + } catch {} + } + + return enabled +} + +export const isEnabled = isDebugEnabled() diff --git a/packages/react/src/lib/debug/index.ts b/packages/react/src/lib/debug/index.ts deleted file mode 100644 index 1cbc0661c9..0000000000 --- a/packages/react/src/lib/debug/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -import { default as DebugDataProvider } from './debugDataProvider' - -export * from './types' - -import debugApi from './debugApi' - -// expose debug API as $stardust object -if (typeof window !== 'undefined') { - // eslint-disable-next-line no-undef - ;(window as any).$stardust = debugApi -} - -export { isEnabled } from './debugApi' -export default DebugDataProvider diff --git a/packages/react/src/lib/debug/types.ts b/packages/react/src/lib/debug/types.ts deleted file mode 100644 index 2b67722481..0000000000 --- a/packages/react/src/lib/debug/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import { - SiteVariablesInput, - SiteVariablesPrepared, - ComponentVariablesInput, - ComponentVariablesPrepared, - ComponentSlotStylesInput, - ComponentSlotStyle, - ComponentSlotStylesPrepared, - ThemeInput, -} from '../../themes/types' - -export type DebugDataProviderArgs = { - componentName: string - themes: (ThemeInput | undefined)[] - instanceStylesOverrides: ComponentSlotStyle - instanceVariablesOverrides: ComponentVariablesInput - resolveStyles: (componentStyles: ComponentSlotStylesInput) => ComponentSlotStylesPrepared - resolveVariables: (componentVariables: ComponentVariablesInput) => ComponentVariablesPrepared -} - -export type DebugCategory = { - instanceOverrides: DebugEntry - themes: DebugEntry[] - result: Result -} - -export type DebugEntry = { - src: Input - args: any - resolved: Result - merged: Result -} - -export type SiteVariablesDebugData = DebugCategory -export type VariablesDebugData = DebugCategory -export type StylesDebugData = DebugCategory< - ComponentSlotStylesPrepared, - ComponentSlotStylesInput, - ComponentSlotStyle -> - -export interface IDebugData { - componentName: string - siteVariables: SiteVariablesDebugData - variables: VariablesDebugData - styles: StylesDebugData -} diff --git a/packages/react/src/lib/debug/utils.ts b/packages/react/src/lib/debug/utils.ts deleted file mode 100644 index 16a39bbe5b..0000000000 --- a/packages/react/src/lib/debug/utils.ts +++ /dev/null @@ -1,59 +0,0 @@ -export const containsSubstring = (arg: any, substring: string) => { - return typeof arg === 'string' && arg.indexOf(substring) >= 0 -} - -export const getLastOf = function(array: Item[], fallback: Item) { - return array.length > 0 ? array[array.length - 1] : fallback -} - -export const isNotNullOrEmpty = object => { - if (object == null) { - return false - } - - return Object.keys(object).length > 0 -} - -export const isNotEmptyObjectsArray = array => { - if (array == null) { - return false - } - - return array.some(isNotNullOrEmpty) -} - -export const deepPickBy = ( - object: any, - predicate: (propName: string, propValue: any) => boolean, - seenObjects = [], -) => { - if (!object) { - return {} - } - - if (seenObjects.some(seenObject => seenObject === object)) { - throw new Error(`Circular dependency detected.`) - } - - return Object.keys(object).reduce((acc, currentPropName) => { - if (predicate(currentPropName, object[currentPropName])) { - return { ...acc, [currentPropName]: object[currentPropName] } - } - - if (typeof object[currentPropName] === 'object') { - const deepPickResult = deepPickBy(object[currentPropName], predicate, [ - ...seenObjects, - object, - ]) - - if (isNotNullOrEmpty(deepPickResult)) { - return { ...acc, [currentPropName]: deepPickResult } - } - } - - return acc - }, {}) -} - -export const deepPick = (object, propName) => - deepPickBy(object, currentPropName => propName === currentPropName) diff --git a/packages/react/src/lib/getClasses.ts b/packages/react/src/lib/getClasses.ts new file mode 100644 index 0000000000..e69de29bb2 diff --git a/packages/react/src/lib/index.ts b/packages/react/src/lib/index.ts index f715d25368..eb238a869b 100644 --- a/packages/react/src/lib/index.ts +++ b/packages/react/src/lib/index.ts @@ -39,3 +39,5 @@ export * from './whatInput' export * from './commonPropInterfaces' export { commonPropTypes } + +export { default as withDebugId } from './withDebugId' diff --git a/packages/react/src/lib/mergeThemes.ts b/packages/react/src/lib/mergeThemes.ts index 0648000785..848ecacdc1 100644 --- a/packages/react/src/lib/mergeThemes.ts +++ b/packages/react/src/lib/mergeThemes.ts @@ -21,6 +21,10 @@ import { } from '../themes/types' import toCompactArray from './toCompactArray' import deepmerge from './deepmerge' +import objectKeyToValues from './objectKeysToValues' + +import { isEnabled as isDebugEnabled } from './debug/debugEnabled' +import withDebugId from './withDebugId' export const emptyTheme: ThemePrepared = { siteVariables: { @@ -41,7 +45,7 @@ export const emptyTheme: ThemePrepared = { /** * Merges a single component's styles (keyed by component part) with another component's styles. */ -export const mergeComponentStyles = ( +export const mergeComponentStyles__PROD = ( ...sources: (ComponentSlotStylesInput | null | undefined)[] ): ComponentSlotStylesPrepared => { const initial: ComponentSlotStylesPrepared = {} @@ -62,10 +66,46 @@ export const mergeComponentStyles = ( }, initial) } +export const mergeComponentStyles__DEV = ( + ...sources: (ComponentSlotStylesInput | null | undefined)[] +): ComponentSlotStylesPrepared => { + if (!isDebugEnabled) { + return mergeComponentStyles__PROD(...sources) + } + const initial: ComponentSlotStylesPrepared = {} + + return sources.reduce((partStylesPrepared, stylesByPart) => { + _.forEach(stylesByPart, (partStyle, partName) => { + // Break references to avoid an infinite loop. + // We are replacing functions with a new ones that calls the originals. + const originalTarget = partStylesPrepared[partName] + const originalSource = partStyle + + partStylesPrepared[partName] = styleParam => { + const { _debug: targetDebug = [], ...targetStyles } = + callable(originalTarget)(styleParam) || {} + const { _debug: sourceDebug = undefined, ...sourceStyles } = + callable(originalSource)(styleParam) || {} + + const merged = _.merge(targetStyles, sourceStyles) + merged._debug = targetDebug.concat( + sourceDebug || { styles: sourceStyles, debugId: stylesByPart._debugId }, + ) + return merged + } + }) + + return partStylesPrepared + }, initial) +} + +export const mergeComponentStyles = + process.env.NODE_ENV === 'production' ? mergeComponentStyles__PROD : mergeComponentStyles__DEV + /** * Merges a single component's variables with another component's variables. */ -export const mergeComponentVariables = ( +export const mergeComponentVariables__PROD = ( ...sources: ComponentVariablesInput[] ): ComponentVariablesPrepared => { const initial = () => ({}) @@ -80,6 +120,44 @@ export const mergeComponentVariables = ( }, initial) } +export const mergeComponentVariables__DEV = ( + ...sources: ComponentVariablesInput[] +): ComponentVariablesPrepared => { + if (!isDebugEnabled) { + return mergeComponentVariables__PROD(...sources) + } + const initial = () => ({}) + + return sources.reduce((acc, next) => { + return siteVariables => { + const { _debug = [], ...accumulatedVariables } = acc(siteVariables) + const { + _debug: computedDebug = undefined, + _debugId = undefined, + ...computedComponentVariables + } = callable(next)(siteVariables) || {} + + const merged = deepmerge(accumulatedVariables, computedComponentVariables) + + merged._debug = _debug.concat( + computedDebug || { + resolved: computedComponentVariables, + debugId: _debugId, + input: siteVariables + ? siteVariables._invertedKeys && callable(next)(siteVariables._invertedKeys) + : callable(next)(), + }, + ) + return merged + } + }, initial) +} + +export const mergeComponentVariables = + process.env.NODE_ENV === 'production' + ? mergeComponentVariables__PROD + : mergeComponentVariables__DEV + // ---------------------------------------- // Theme level merge functions // ---------------------------------------- @@ -88,7 +166,7 @@ export const mergeComponentVariables = ( * Site variables can safely be merged at each Provider in the tree. * They are flat objects and do not depend on render-time values, such as props. */ -export const mergeSiteVariables = ( +export const mergeSiteVariables__PROD = ( ...sources: (SiteVariablesInput | null | undefined)[] ): SiteVariablesPrepared => { const initial: SiteVariablesPrepared = { @@ -97,6 +175,41 @@ export const mergeSiteVariables = ( return deepmerge(initial, ...sources) } +export const mergeSiteVariables__DEV = ( + ...sources: (SiteVariablesInput | null | undefined)[] +): SiteVariablesPrepared => { + if (!isDebugEnabled) { + return mergeSiteVariables__PROD(...sources) + } + + const initial: SiteVariablesPrepared = { + fontSizes: {}, + } + + return sources.reduce((acc, next) => { + const { _debug = [], ...accumulatedSiteVariables } = acc + const { + _debug: computedDebug = undefined, + _invertedKeys = undefined, + _debugId = undefined, + ...nextSiteVariables + } = next || {} + + const merged = deepmerge( + { ...accumulatedSiteVariables, _invertedKeys: undefined }, + nextSiteVariables, + ) + merged._debug = _debug.concat( + computedDebug || { resolved: nextSiteVariables, debugId: _debugId }, + ) + merged._invertedKeys = _invertedKeys || objectKeyToValues(merged, key => `siteVariables.${key}`) + return merged + }, initial) +} + +export const mergeSiteVariables = + process.env.NODE_ENV === 'production' ? mergeSiteVariables__PROD : mergeSiteVariables__DEV + /** * Component variables can be objects, functions, or an array of these. * The functions must be called with the final result of siteVariables, otherwise @@ -105,7 +218,7 @@ export const mergeSiteVariables = ( * We instead pass down call stack of component variable functions to be resolved later. */ -export const mergeThemeVariables = ( +export const mergeThemeVariables__PROD = ( ...sources: (ThemeComponentVariablesInput | null | undefined)[] ): ThemeComponentVariablesPrepared => { const displayNames = _.union(..._.map(sources, _.keys)) @@ -115,6 +228,25 @@ export const mergeThemeVariables = ( }, {}) } +export const mergeThemeVariables__DEV = ( + ...sources: (ThemeComponentVariablesInput | null | undefined)[] +): ThemeComponentVariablesPrepared => { + if (!isDebugEnabled) { + return mergeThemeVariables__PROD(...sources) + } + + const displayNames = _.union(..._.map(sources, _.keys)) + return displayNames.reduce((componentVariables, displayName) => { + componentVariables[displayName] = mergeComponentVariables( + ..._.map(sources, source => source && withDebugId(source[displayName], source._debugId)), + ) + return componentVariables + }, {}) +} + +export const mergeThemeVariables = + process.env.NODE_ENV === 'production' ? mergeThemeVariables__PROD : mergeThemeVariables__DEV + /** * See mergeThemeVariables() description. * Component styles adhere to the same pattern as component variables, except @@ -129,7 +261,10 @@ export const mergeThemeStyles = ( _.forEach(next, (stylesByPart, displayName) => { themeComponentStyles[displayName] = mergeComponentStyles( themeComponentStyles[displayName], - stylesByPart, + withDebugId( + stylesByPart, + (next as ThemeComponentStylesPrepared & { _debugId: string })._debugId, + ), ) }) @@ -167,12 +302,22 @@ const mergeThemes = (...themes: ThemeInput[]): ThemePrepared => { return themes.reduce( (acc: ThemePrepared, next: ThemeInput) => { if (!next) return acc + const nextDebugId = next['_debugId'] - acc.siteVariables = mergeSiteVariables(acc.siteVariables, next.siteVariables) + acc.siteVariables = mergeSiteVariables( + acc.siteVariables, + withDebugId(next.siteVariables, nextDebugId), + ) - acc.componentVariables = mergeThemeVariables(acc.componentVariables, next.componentVariables) + acc.componentVariables = mergeThemeVariables( + acc.componentVariables, + withDebugId(next.componentVariables, nextDebugId), + ) - acc.componentStyles = mergeThemeStyles(acc.componentStyles, next.componentStyles) + acc.componentStyles = mergeThemeStyles( + acc.componentStyles, + withDebugId(next.componentStyles, nextDebugId), + ) // Merge icons set, last one wins in case of collisions acc.icons = mergeIcons(acc.icons, next.icons) diff --git a/packages/react/src/lib/objectKeysToValues.ts b/packages/react/src/lib/objectKeysToValues.ts new file mode 100644 index 0000000000..1e967b7237 --- /dev/null +++ b/packages/react/src/lib/objectKeysToValues.ts @@ -0,0 +1,23 @@ +const isObject = o => o !== null && typeof o === 'object' && !Array.isArray(o) + +const objectKeyToValues = (input: Object, formatter: (string) => string = input => input) => { + if (!isObject(input)) { + return input + } + const inner = (result, obj, prefix) => { + Object.keys(obj).forEach(k => { + if (isObject(obj[k])) { + result[k] = {} + inner(result[k], obj[k], `${prefix}${k}.`) + } else { + result[k] = formatter(`${prefix}${k}`) + } + }) + + return result + } + + return inner({}, input, '') +} + +export default objectKeyToValues diff --git a/packages/react/src/lib/renderComponent.tsx b/packages/react/src/lib/renderComponent.tsx index 3710d783e3..23a39242af 100644 --- a/packages/react/src/lib/renderComponent.tsx +++ b/packages/react/src/lib/renderComponent.tsx @@ -25,14 +25,15 @@ import { PropsWithVarsAndStyles, State, ThemePrepared, - ComponentSlotStylesInput, } from '../themes/types' import { Props, ProviderContextPrepared } from '../types' import { ReactAccessibilityBehavior, AccessibilityActionHandlers } from './accessibility/reactTypes' import getKeyDownHandlers from './getKeyDownHandlers' import { emptyTheme, mergeComponentStyles, mergeComponentVariables } from './mergeThemes' import createAnimationStyles from './createAnimationStyles' -import Debug, { isEnabled as isDebugEnabled } from './debug' +import { isEnabled as isDebugEnabled } from './debug/debugEnabled' +import { DebugData } from './debug/debugData' +import withDebugId from './withDebugId' export interface RenderResultConfig

{ ElementType: React.ElementType

@@ -55,7 +56,7 @@ export interface RenderConfig

{ state: State actionHandlers: AccessibilityActionHandlers render: RenderComponentCallback

- saveDebug: (debug: Debug | null) => void + saveDebug: (debug: DebugData | null) => void } const emptyBehavior: ReactAccessibilityBehavior = { @@ -146,16 +147,6 @@ const renderWithFocusZone =

( return render(config) } -const resolveStyles = ( - styles: ComponentSlotStylesInput, - styleParam: ComponentStyleFunctionParam, -): ComponentSlotStylesPrepared => { - return Object.keys(styles).reduce( - (acc, next) => ({ ...acc, [next]: callable(styles[next])(styleParam) }), - {}, - ) -} - const renderComponent =

( config: RenderConfig

, context?: ProviderContextPrepared, @@ -184,7 +175,7 @@ const renderComponent =

( // Resolve variables for this component, allow props.variables to override const resolvedVariables: ComponentVariablesObject = mergeComponentVariables( theme.componentVariables[displayName], - props.variables, + props.variables && withDebugId(props.variables, 'props.variables'), )(theme.siteVariables) const animationCSSProp = props.animation @@ -194,9 +185,9 @@ const renderComponent =

( // Resolve styles using resolved variables, merge results, allow props.styles to override const mergedStyles: ComponentSlotStylesPrepared = mergeComponentStyles( theme.componentStyles[displayName], - { root: props.design }, - { root: props.styles }, - { root: animationCSSProp }, + withDebugId({ root: props.design }, 'props.design'), + withDebugId({ root: props.styles }, 'props.styles'), + withDebugId({ root: animationCSSProp }, 'props.animation'), ) const accessibility: ReactAccessibilityBehavior = getAccessibility( @@ -227,11 +218,17 @@ const renderComponent =

( } const resolvedStyles: ComponentSlotStylesPrepared = {} + const resolvedStylesDebug: { [key: string]: { styles: Object }[] } = {} const classes: ComponentSlotClasses = {} Object.keys(mergedStyles).forEach(slotName => { resolvedStyles[slotName] = callable(mergedStyles[slotName])(styleParam) + if (process.env.NODE_ENV !== 'production' && isDebugEnabled) { + resolvedStylesDebug[slotName] = resolvedStyles[slotName]['_debug'] + delete resolvedStyles[slotName]['_debug'] + } + if (renderer) { classes[slotName] = renderer.renderRule(callable(resolvedStyles[slotName]), felaParam) } @@ -250,22 +247,40 @@ const renderComponent =

( theme, } - if (accessibility.focusZone) { - return renderWithFocusZone(render, accessibility.focusZone, resolvedConfig) - } - // conditionally add sources for evaluating debug information to component - if (isDebugEnabled) { - saveDebug( - new Debug({ - componentName: displayName, - themes: context ? context.originalThemes : [], - instanceStylesOverrides: props.styles, - instanceVariablesOverrides: props.variables, - resolveStyles: styles => resolveStyles(styles, styleParam), - resolveVariables: variables => callable(variables)(theme.siteVariables), + if (process.env.NODE_ENV !== 'production' && isDebugEnabled) { + saveDebug({ + componentName: displayName, + componentVariables: _.filter( + resolvedVariables._debug, + variables => !_.isEmpty(variables.resolved), + ), + componentStyles: _.mapValues(resolvedStylesDebug, v => + _.filter(v, v => { + return !_.isEmpty(v.styles) + }), + ), + siteVariables: _.filter(theme.siteVariables._debug, siteVars => { + if (_.isEmpty(siteVars) || _.isEmpty(siteVars.resolved)) { + return false + } + + const keys = Object.keys(siteVars.resolved) + if ( + keys.length === 1 && + keys.pop() === 'fontSizes' && + _.isEmpty(siteVars.resolved['fontSizes']) + ) { + return false + } + + return true }), - ) + }) + } + + if (accessibility.focusZone) { + return renderWithFocusZone(render, accessibility.focusZone, resolvedConfig) } return render(resolvedConfig) diff --git a/packages/react/src/lib/withDebugId.ts b/packages/react/src/lib/withDebugId.ts new file mode 100644 index 0000000000..989fb3803a --- /dev/null +++ b/packages/react/src/lib/withDebugId.ts @@ -0,0 +1,33 @@ +import { isEnabled as isDebugEnabled } from './debug/debugEnabled' + +const withDebugId = + process.env.NODE_ENV === 'production' + ? (data: T, debugId: string): T => data + : (data: T, debugId: string): T => { + if (!isDebugEnabled || debugId === undefined) { + return data + } + + if (typeof data === 'object' && data !== null) { + if (!Object.prototype.hasOwnProperty.call(data, '_debugId')) { + const copy = { ...data } + Object.defineProperty(copy, '_debugId', { + value: debugId, + writable: false, + enumerable: false, + }) + return copy + } + } + + if (typeof data === 'function') { + return (((...args) => { + const result = data(...args) + return withDebugId(result, debugId) + }) as unknown) as T + } + + return data + } + +export default withDebugId diff --git a/packages/react/src/themes/createTheme.ts b/packages/react/src/themes/createTheme.ts new file mode 100644 index 0000000000..f44d6d9008 --- /dev/null +++ b/packages/react/src/themes/createTheme.ts @@ -0,0 +1,6 @@ +import { ThemeInput, ThemePrepared } from './types' +import withDebugId from '../lib/withDebugId' + +export const createTheme = (themeInput: T, debugId): T => { + return withDebugId(themeInput, debugId) +} diff --git a/packages/react/src/themes/teams-dark/index.ts b/packages/react/src/themes/teams-dark/index.ts index 4cd2c1e2b5..0e883d6101 100644 --- a/packages/react/src/themes/teams-dark/index.ts +++ b/packages/react/src/themes/teams-dark/index.ts @@ -2,5 +2,15 @@ import mergeThemes from '../../lib/mergeThemes' import * as siteVariables from './siteVariables' import * as componentVariables from './componentVariables' import teams from '../teams' +import { createTheme } from '../createTheme' -export default mergeThemes(teams, { siteVariables, componentVariables }) +export default mergeThemes( + teams, + createTheme( + { + siteVariables, + componentVariables, + }, + 'teams-dark', + ), +) diff --git a/packages/react/src/themes/teams-high-contrast/index.ts b/packages/react/src/themes/teams-high-contrast/index.ts index 2bccfaec27..be1fbbf1fb 100644 --- a/packages/react/src/themes/teams-high-contrast/index.ts +++ b/packages/react/src/themes/teams-high-contrast/index.ts @@ -3,5 +3,16 @@ import * as siteVariables from './siteVariables' import * as componentVariables from './componentVariables' import * as componentStyles from './componentStyles' import teams from '../teams' +import { createTheme } from '../createTheme' -export default mergeThemes(teams, { siteVariables, componentVariables, componentStyles }) +export default mergeThemes( + teams, + createTheme( + { + siteVariables, + componentVariables, + componentStyles, + }, + 'teams-high-contrast', + ), +) diff --git a/packages/react/src/themes/teams/index.tsx b/packages/react/src/themes/teams/index.tsx index 0e9af83c1a..f894c18b1f 100644 --- a/packages/react/src/themes/teams/index.tsx +++ b/packages/react/src/themes/teams/index.tsx @@ -10,6 +10,7 @@ import staticStyles from './staticStyles' import { default as svgIconsAndStyles } from './components/Icon/svg' import { TeamsSvgIconSpec, SvgIconSpecWithStyles } from './components/Icon/svg/types' +import { createTheme } from '../createTheme' const declareSvg = (svgIcon: SvgIconSpec): ThemeIconSpec => ({ isSvg: true, @@ -46,14 +47,17 @@ const icons: ThemeIcons = { 'stardust-play': themeIcons['play'], } -const teamsTheme: ThemePrepared = { - siteVariables, - componentVariables, - componentStyles, - fontFaces, - staticStyles, - icons, - animations, -} +const teamsTheme: ThemePrepared = createTheme( + { + siteVariables, + componentVariables, + componentStyles, + fontFaces, + staticStyles, + icons, + animations, + }, + 'teams', +) export default teamsTheme diff --git a/packages/react/test/specs/components/Debug/utils-test.ts b/packages/react/test/specs/components/Debug/utils-test.ts new file mode 100644 index 0000000000..735f935c5b --- /dev/null +++ b/packages/react/test/specs/components/Debug/utils-test.ts @@ -0,0 +1,277 @@ +import { find, isOverridden, filter, getValues, removeNulls } from 'src/components/Debug/utils' + +describe('debugUtils', () => { + describe('find', () => { + test('returns true if key matches search', () => { + const search = 'color' + const key = 'color' + const obj = { [key]: 'red' } + + expect(find(obj, key, search)).toEqual(true) + }) + + test('returns true if value matches search', () => { + const search = 'red' + const key = 'color' + const obj = { [key]: 'red' } + + expect(find(obj, key, search)).toEqual(true) + }) + + test('returns false if value does not match search', () => { + const search = 'red' + const key = 'color' + const obj = { [key]: 'blue' } + + expect(find(obj, key, search)).toEqual(false) + }) + + test('returns true if key includes search', () => { + const search = 'color' + const key = 'backgroundColor' + const obj = { [key]: 'red' } + + expect(find(obj, key, search)).toEqual(true) + }) + + test('returns true if value includes search', () => { + const search = 'red' + const key = 'backgroundColor' + const obj = { [key]: 'darkred' } + + expect(find(obj, key, search)).toEqual(true) + }) + }) + + describe('isOverridden', () => { + test('returns true if there is override', () => { + const key = 'color' + const data = { + [key]: 'red', + } + + const overrides = { + [key]: 'blue', + } + + expect(isOverridden(data, key, overrides)).toEqual(true) + }) + + test('returns false if is not override', () => { + const key = 'color' + const data = { + [key]: 'red', + } + + const overrides = { + backgroundColor: 'blue', + } + + expect(isOverridden(data, key, overrides)).toEqual(false) + }) + + test('gracefully handles null and undefine', () => { + const key = 'color' + const data = { + [key]: 'red', + } + + let overrides = null + expect(isOverridden(data, key, overrides)).toEqual(false) + expect(() => isOverridden(data, key, overrides)).not.toThrow() + + overrides = undefined + expect(isOverridden(data, key, overrides)).toEqual(false) + expect(() => isOverridden(data, key, overrides)).not.toThrow() + + overrides = { + [key]: null, + } + expect(isOverridden(data, key, overrides)).toEqual(false) + expect(() => isOverridden(data, key, overrides)).not.toThrow() + + overrides = { + [key]: undefined, + } + expect(isOverridden(data, key, overrides)).toEqual(false) + expect(() => isOverridden(data, key, overrides)).not.toThrow() + }) + }) + + describe('filter', () => { + test('filters primitives correctly by keys', () => { + const search = 'backgroundColor' + const data = { + color: 'red', + backgroundColor: 'white', + } + + expect(filter(data, search)).toMatchObject({ + backgroundColor: 'white', + }) + }) + + test('filters primitives correctly by value', () => { + const search = 'white' + const data = { + color: 'red', + backgroundColor: 'white', + } + + expect(filter(data, search)).toMatchObject({ + backgroundColor: 'white', + }) + }) + + test('filters primitives correctly by key (includes)', () => { + const search = 'color' + const data = { + color: 'red', + backgroundColor: 'white', + } + + expect(filter(data, search)).toMatchObject(data) + }) + + test('filters objects correctly by key', () => { + const search = 'color' + const data = { + color: 'red', + backgroundColor: 'white', + ':hover': { + color: 'red', + border: '1px', + }, + } + + expect(filter(data, search)).toMatchObject(data) + }) + + test('filters objects correctly by object key', () => { + const search = ':hover' + const data = { + color: 'red', + backgroundColor: 'white', + ':hover': { + color: 'red', + border: '1px', + }, + } + + expect(filter(data, search)).toMatchObject({ + ':hover': { + color: 'red', + border: '1px', + }, + }) + }) + + test('filters objects correctly by value', () => { + const search = 'red' + const data = { + color: 'red', + backgroundColor: 'white', + ':hover': { + color: 'red', + border: '1px', + }, + } + + expect(filter(data, search)).toMatchObject({ + color: 'red', + ':hover': { + color: 'red', + border: '1px', + }, + }) + }) + }) + + describe('getValues', () => { + const prefix = 'prefix.' + const predicate = val => val.indexOf(prefix) === 0 + + test('returns value if it is string', () => { + const val = `${prefix}value` + + expect(getValues(val, predicate)).toEqual([val]) + }) + + test('returns value if it is object', () => { + const val = `${prefix}value` + + expect(getValues({ someKey: val }, predicate)).toEqual([val]) + }) + + test('returns empty array if predicate does not match on primitive value', () => { + const val = `value` + + expect(getValues(val, predicate)).toEqual([]) + }) + + test('returns empty array if predicate does not match on object', () => { + const val = `value` + + expect(getValues({ someKey: val }, predicate)).toEqual([]) + }) + + test('returns array with all matching values', () => { + const data = { + key1: `${prefix}value1`, + key2: 'value2', + key3: { + key4: 'value4', + key5: `${prefix}value5`, + key6: { + key7: `${prefix}value7`, + key8: `value8`, + }, + }, + } + + expect(getValues(data, predicate)).toEqual([ + `${prefix}value1`, + `${prefix}value5`, + `${prefix}value7`, + ]) + }) + }) + + describe('removeNulls', () => { + test('removes nulls values on first level', () => { + const data = { + key1: null, + key2: 'value2', + } + + expect(removeNulls(data)).toMatchObject({ key2: 'value2' }) + }) + + test('removes nested nulls values', () => { + const data = { + key1: { + key2: null, + key3: 'value2', + }, + } + + expect(removeNulls(data)).toMatchObject({ + key1: { + key3: 'value2', + }, + }) + }) + + test('removes nested object if all values are removed', () => { + const data = { + key1: { + key2: null, + key3: null, + }, + key4: 'value4', + } + + expect(removeNulls(data)).toMatchObject({ key4: 'value4' }) + }) + }) +}) diff --git a/packages/react/test/specs/lib/mergeThemes/mergeComponentStyles-test.ts b/packages/react/test/specs/lib/mergeThemes/mergeComponentStyles-test.ts index dd21889998..b21b8e5a21 100644 --- a/packages/react/test/specs/lib/mergeThemes/mergeComponentStyles-test.ts +++ b/packages/react/test/specs/lib/mergeThemes/mergeComponentStyles-test.ts @@ -1,105 +1,257 @@ -import { mergeComponentStyles } from '../../../../src/lib/mergeThemes' +import { + mergeComponentStyles__PROD, + mergeComponentStyles__DEV, +} from '../../../../src/lib/mergeThemes' import { ComponentStyleFunctionParam } from 'src/themes/types' +import * as debugEnabled from 'src/lib/debug/debugEnabled' +import { withDebugId } from 'src/lib' describe('mergeComponentStyles', () => { - test(`always returns an object`, () => { - expect(mergeComponentStyles({}, {})).toMatchObject({}) - expect(mergeComponentStyles(null, null)).toMatchObject({}) - expect(mergeComponentStyles(undefined, undefined)).toMatchObject({}) + let originalDebugEnabled - expect(mergeComponentStyles(null, undefined)).toMatchObject({}) - expect(mergeComponentStyles(undefined, null)).toMatchObject({}) - - expect(mergeComponentStyles({}, undefined)).toMatchObject({}) - expect(mergeComponentStyles(undefined, {})).toMatchObject({}) + beforeEach(() => { + originalDebugEnabled = debugEnabled.isEnabled + }) - expect(mergeComponentStyles({}, null)).toMatchObject({}) - expect(mergeComponentStyles(null, {})).toMatchObject({}) + afterEach(() => { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: () => originalDebugEnabled, + }) }) - test('gracefully handles null and undefined', () => { - const styles = { root: { color: 'black' } } - const stylesWithNull = { root: { color: null }, icon: null } - const stylesWithUndefined = { root: { color: undefined }, icon: undefined } + function mockIsDebugEnabled(enabled: boolean) { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: jest.fn(() => enabled), + }) + } - expect(() => mergeComponentStyles(styles, null)).not.toThrow() - expect(() => mergeComponentStyles(styles, stylesWithNull)).not.toThrow() + function testMergeComponentStyles(mergeComponentStyles) { + test(`always returns an object`, () => { + expect(mergeComponentStyles({}, {})).toMatchObject({}) + expect(mergeComponentStyles(null, null)).toMatchObject({}) + expect(mergeComponentStyles(undefined, undefined)).toMatchObject({}) - expect(() => mergeComponentStyles(null, styles)).not.toThrow() - expect(() => mergeComponentStyles(stylesWithNull, styles)).not.toThrow() + expect(mergeComponentStyles(null, undefined)).toMatchObject({}) + expect(mergeComponentStyles(undefined, null)).toMatchObject({}) - expect(() => mergeComponentStyles(styles, undefined)).not.toThrow() - expect(() => mergeComponentStyles(styles, stylesWithUndefined)).not.toThrow() + expect(mergeComponentStyles({}, undefined)).toMatchObject({}) + expect(mergeComponentStyles(undefined, {})).toMatchObject({}) - expect(() => mergeComponentStyles(undefined, styles)).not.toThrow() - expect(() => mergeComponentStyles(stylesWithUndefined, styles)).not.toThrow() - }) + expect(mergeComponentStyles({}, null)).toMatchObject({}) + expect(mergeComponentStyles(null, {})).toMatchObject({}) + }) - test('component parts are merged', () => { - const target = { root: {} } - const source = { icon: {} } + test('gracefully handles null and undefined', () => { + const styles = { root: { color: 'black' } } + const stylesWithNull = { root: { color: null }, icon: null } + const stylesWithUndefined = { root: { color: undefined }, icon: undefined } - const merged = mergeComponentStyles(target, source) + expect(() => mergeComponentStyles(styles, null)).not.toThrow() + expect(() => mergeComponentStyles(styles, stylesWithNull)).not.toThrow() - expect(merged).toHaveProperty('root') - expect(merged).toHaveProperty('icon') - }) + expect(() => mergeComponentStyles(null, styles)).not.toThrow() + expect(() => mergeComponentStyles(stylesWithNull, styles)).not.toThrow() - test('component part objects are converted to functions', () => { - const target = { root: {} } - const source = { root: {} } + expect(() => mergeComponentStyles(styles, undefined)).not.toThrow() + expect(() => mergeComponentStyles(styles, stylesWithUndefined)).not.toThrow() - const merged = mergeComponentStyles(target, source) + expect(() => mergeComponentStyles(undefined, styles)).not.toThrow() + expect(() => mergeComponentStyles(stylesWithUndefined, styles)).not.toThrow() + }) - expect(merged.root).toBeInstanceOf(Function) - expect(merged.root).toBeInstanceOf(Function) - }) + test('component parts are merged', () => { + const target = { root: {} } + const source = { icon: {} } - test('component part styles are deeply merged', () => { - const target = { - root: { - display: 'inline-block', - color: 'green', - '::before': { - content: 'before content', + const merged = mergeComponentStyles(target, source) + + expect(merged).toHaveProperty('root') + expect(merged).toHaveProperty('icon') + }) + + test('component part objects are converted to functions', () => { + const target = { root: {} } + const source = { root: {} } + + const merged = mergeComponentStyles(target, source) + + expect(merged.root).toBeInstanceOf(Function) + expect(merged.root).toBeInstanceOf(Function) + }) + + test('component part styles are deeply merged', () => { + const target = { + root: { + display: 'inline-block', + color: 'green', + '::before': { + content: 'before content', + }, + }, + } + const source = { + root: { + color: 'blue', + '::before': { + color: 'red', + }, }, - }, - } - const source = { - root: { + } + const merged = mergeComponentStyles(target, source) + expect(merged.root()).toMatchObject({ + display: 'inline-block', color: 'blue', '::before': { + content: 'before content', color: 'red', }, - }, - } - const merged = mergeComponentStyles(target, source) - - expect(merged.root()).toMatchObject({ - display: 'inline-block', - color: 'blue', - '::before': { - content: 'before content', - color: 'red', - }, + }) + }) + + test('functions can accept and apply params', () => { + const target = { root: param => ({ target: true, ...param }) } + const source = { root: param => ({ source: true, ...param }) } + + const merged = mergeComponentStyles(target, source) + + const styleParam: ComponentStyleFunctionParam = { + variables: { iconSize: 'large' }, + props: { primary: true }, + } as any + + expect(merged.root(styleParam)).toMatchObject({ + source: true, + target: true, + ...styleParam, + }) + }) + } + + describe('prod version', () => { + beforeEach(() => { + mockIsDebugEnabled(true) // it is not possible to enable debug in prod + }) + + testMergeComponentStyles(mergeComponentStyles__PROD) + + test('debug frames are not saved', () => { + const target = { root: { a: 'tA', b: 'tB' } } + const source = { root: { a: 'sA', c: { deep: 'c' } } } + + const merged = mergeComponentStyles__PROD(target, source) + + const resolvedRoot = merged.root() + expect(resolvedRoot._debug).toBe(undefined) }) }) - test('functions can accept and apply params', () => { - const target = { root: param => ({ target: true, ...param }) } - const source = { root: param => ({ source: true, ...param }) } + describe('dev version, debug disabled', () => { + beforeEach(() => { + mockIsDebugEnabled(false) + }) + + testMergeComponentStyles(mergeComponentStyles__DEV) + + test('debug frames are not saved', () => { + const target = { root: { a: 'tA', b: 'tB' } } + const source = { root: { a: 'sA', c: { deep: 'c' } } } + + const merged = mergeComponentStyles__DEV(target, source) + + const resolvedRoot = merged.root() + expect(resolvedRoot._debug).toBe(undefined) + }) + }) + + describe('dev version, debug enabled', () => { + beforeEach(() => { + mockIsDebugEnabled(true) + }) + + testMergeComponentStyles(mergeComponentStyles__DEV) + + describe('debug frames', () => { + test('are saved', () => { + const target = { root: { a: 'tA', b: 'tB' } } + const source = { + root: ({ variables }) => ({ a: 'sA', c: { deep: variables.varC } }), + icon: { d: 'sD' }, + } + + const merged = mergeComponentStyles__DEV(target, source) + + const resolvedRoot = merged.root({ variables: { varC: 'vC' } } as any) + expect(resolvedRoot).toMatchObject({ + _debug: [{ styles: { a: 'tA', b: 'tB' } }, { styles: { a: 'sA', c: { deep: 'vC' } } }], + }) + + const resolvedIcon = merged.icon() + expect(resolvedIcon).toMatchObject({ + _debug: [{ styles: { d: 'sD' } }], + }) + }) + + test('contain debugId', () => { + const target = withDebugId({ root: { a: 'tA', b: 'tB' } }, 'target') + const source = withDebugId({ root: { a: 'sA', c: { deep: 'c' } } }, 'source') + + const merged = mergeComponentStyles__DEV(target, source) + const resolvedRoot = merged.root() + expect(resolvedRoot).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source' }], + }) + }) + + test('are flat for recursive merge', () => { + const target = withDebugId( + { + root: { + a: 'tA', + }, + }, + 'target', + ) + const source1 = withDebugId( + { + root: { + a: 'tB', + }, + }, + 'source1', + ) + const source2 = withDebugId( + { + root: { + a: 'tC', + }, + }, + 'source2', + ) - const merged = mergeComponentStyles(target, source) + const merged1 = mergeComponentStyles__DEV(target, source1, source2) + const resolvedRoot1 = merged1.root() + expect(resolvedRoot1).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source1' }, { debugId: 'source2' }], + }) - const styleParam: ComponentStyleFunctionParam = { - variables: { iconSize: 'large' }, - props: { primary: true }, - } as any + const merged2 = mergeComponentStyles__DEV( + mergeComponentStyles__DEV(target, source1), + source2, + ) + const resolvedRoot2 = merged2.root() + expect(resolvedRoot2).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source1' }, { debugId: 'source2' }], + }) - expect(merged.root(styleParam)).toMatchObject({ - source: true, - target: true, - ...styleParam, + const merged3 = mergeComponentStyles__DEV( + target, + mergeComponentStyles__DEV(source1, source2), + ) + const resolvedRoot3 = merged3.root() + expect(resolvedRoot3).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source1' }, { debugId: 'source2' }], + }) + }) }) }) }) diff --git a/packages/react/test/specs/lib/mergeThemes/mergeComponentVariables-test.ts b/packages/react/test/specs/lib/mergeThemes/mergeComponentVariables-test.ts index d65cdc8373..314d4fc4b0 100644 --- a/packages/react/test/specs/lib/mergeThemes/mergeComponentVariables-test.ts +++ b/packages/react/test/specs/lib/mergeThemes/mergeComponentVariables-test.ts @@ -1,90 +1,254 @@ -import { mergeComponentVariables } from '../../../../src/lib/mergeThemes' +import { + mergeComponentVariables__PROD, + mergeComponentVariables__DEV, +} from '../../../../src/lib/mergeThemes' +import * as debugEnabled from 'src/lib/debug/debugEnabled' +import { withDebugId } from 'src/lib' +import objectKeyToValues from 'src/lib/objectKeysToValues' describe('mergeComponentVariables', () => { - test(`always returns a function that returns an object`, () => { - expect(mergeComponentVariables({}, {})()).toMatchObject({}) - expect(mergeComponentVariables(null, null)()).toMatchObject({}) - expect(mergeComponentVariables(undefined, undefined)()).toMatchObject({}) + let originalDebugEnabled - expect(mergeComponentVariables(null, undefined)()).toMatchObject({}) - expect(mergeComponentVariables(undefined, null)()).toMatchObject({}) - - expect(mergeComponentVariables({}, undefined)()).toMatchObject({}) - expect(mergeComponentVariables(undefined, {})()).toMatchObject({}) + beforeEach(() => { + originalDebugEnabled = debugEnabled.isEnabled + }) - expect(mergeComponentVariables({}, null)()).toMatchObject({}) - expect(mergeComponentVariables(null, {})()).toMatchObject({}) + afterEach(() => { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: () => originalDebugEnabled, + }) }) - test('gracefully handles null and undefined', () => { - expect(mergeComponentVariables({ color: 'black' }, null)).not.toThrow() - expect(mergeComponentVariables({ color: 'black' }, { color: null })).not.toThrow() + function mockIsDebugEnabled(enabled: boolean) { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: jest.fn(() => enabled), + }) + } + + function testMergeComponentVariables(mergeComponentVariables) { + test(`always returns a function that returns an object`, () => { + expect(mergeComponentVariables({}, {})()).toMatchObject({}) + expect(mergeComponentVariables(null, null)()).toMatchObject({}) + expect(mergeComponentVariables(undefined, undefined)()).toMatchObject({}) - expect(mergeComponentVariables(null, { color: 'black' })).not.toThrow() - expect(mergeComponentVariables({ color: null }, { color: 'black' })).not.toThrow() + expect(mergeComponentVariables(null, undefined)()).toMatchObject({}) + expect(mergeComponentVariables(undefined, null)()).toMatchObject({}) - expect(mergeComponentVariables({ color: 'black' }, undefined)).not.toThrow() - expect(mergeComponentVariables({ color: 'black' }, { color: undefined })).not.toThrow() + expect(mergeComponentVariables({}, undefined)()).toMatchObject({}) + expect(mergeComponentVariables(undefined, {})()).toMatchObject({}) - expect(mergeComponentVariables(undefined, { color: 'black' })).not.toThrow() - expect(mergeComponentVariables({ color: undefined }, { color: 'black' })).not.toThrow() - }) + expect(mergeComponentVariables({}, null)()).toMatchObject({}) + expect(mergeComponentVariables(null, {})()).toMatchObject({}) + }) + + test('gracefully handles null and undefined', () => { + expect(mergeComponentVariables({ color: 'black' }, null)).not.toThrow() + expect(mergeComponentVariables({ color: 'black' }, { color: null })).not.toThrow() - test('undefined overwrites previously set value', () => { - const merged = mergeComponentVariables({ color: 'black' }, { color: undefined }) + expect(mergeComponentVariables(null, { color: 'black' })).not.toThrow() + expect(mergeComponentVariables({ color: null }, { color: 'black' })).not.toThrow() - expect(merged()).toMatchObject({ - color: undefined, + expect(mergeComponentVariables({ color: 'black' }, undefined)).not.toThrow() + expect(mergeComponentVariables({ color: 'black' }, { color: undefined })).not.toThrow() + + expect(mergeComponentVariables(undefined, { color: 'black' })).not.toThrow() + expect(mergeComponentVariables({ color: undefined }, { color: 'black' })).not.toThrow() }) - }) - test('null overwrites previously set value', () => { - const merged = mergeComponentVariables({ color: 'black' }, { color: null }) + test('undefined overwrites previously set value', () => { + const merged = mergeComponentVariables({ color: 'black' }, { color: undefined }) - expect(merged()).toMatchObject({ - color: null, + expect(merged()).toMatchObject({ + color: undefined, + }) }) - }) - test('merged functions return merged variables', () => { - const target = () => ({ one: 1, three: 3 }) - const source = () => ({ one: 'one', two: 'two' }) + test('null overwrites previously set value', () => { + const merged = mergeComponentVariables({ color: 'black' }, { color: null }) + + expect(merged()).toMatchObject({ + color: null, + }) + }) + + test('merged functions return merged variables', () => { + const target = () => ({ one: 1, three: 3 }) + const source = () => ({ one: 'one', two: 'two' }) - const merged = mergeComponentVariables(target, source) + const merged = mergeComponentVariables(target, source) - expect(merged()).toMatchObject({ - one: 'one', - two: 'two', - three: 3, + expect(merged()).toMatchObject({ + one: 'one', + two: 'two', + three: 3, + }) }) - }) - test('merged functions accept and apply siteVariables', () => { - const target = siteVariables => ({ one: 1, target: true, ...siteVariables }) - const source = siteVariables => ({ two: 2, source: true, ...siteVariables }) + test('merged functions accept and apply siteVariables', () => { + const target = siteVariables => ({ one: 1, target: true, ...siteVariables }) + const source = siteVariables => ({ two: 2, source: true, ...siteVariables }) - const merged = mergeComponentVariables(target, source) + const merged = mergeComponentVariables(target, source) - const siteVariables = { one: 'one', two: 'two', fontSizes: {} } + const siteVariables = { one: 'one', two: 'two', fontSizes: {} } - expect(merged(siteVariables)).toMatchObject({ - one: 'one', - two: 'two', - source: true, - target: true, + expect(merged(siteVariables)).toMatchObject({ + one: 'one', + two: 'two', + source: true, + target: true, + }) + }) + + test('object values of variables are merged', () => { + const target = { foo: { bar: true, deep: { dOne: 1 } }, target: true } + const source = { foo: { baz: false, deep: { dTwo: 'two' } }, source: true } + + const merged = mergeComponentVariables(target, source) + + expect(merged()).toMatchObject({ + source: true, + target: true, + foo: { bar: true, baz: false, deep: { dOne: 1, dTwo: 'two' } }, + }) + }) + + test('merges multiple objects', () => { + const siteVariables = { + colors: { + colorForC: 'c_color', + }, + } + const target = { a: 1, b: 2, c: 3, d: 4, e: 5 } + const source1 = { b: 'bS1', d: false, bb: 'bbS1' } + const source2 = sv => ({ c: sv.colors.colorForC, cc: 'bbS2' }) + const source3 = { d: 'bS3', dd: 'bbS3' } + + expect( + mergeComponentVariables(target, source1, source2, source3)(siteVariables), + ).toMatchObject({ + a: 1, + b: 'bS1', + c: 'c_color', + d: 'bS3', + e: 5, + bb: 'bbS1', + cc: 'bbS2', + dd: 'bbS3', + }) + }) + } + + describe('prod version', () => { + beforeEach(() => { + mockIsDebugEnabled(true) // it is not possible to enable debug in prod + }) + testMergeComponentVariables(mergeComponentVariables__PROD) + + test('debug frames are not saved', () => { + const target = siteVariables => ({ one: 1, a: 'tA', target: true }) + const source = siteVariables => ({ two: 2, a: 'sA', source: true }) + + const merged = mergeComponentVariables__PROD(target, source) + expect(merged()._debug).toBe(undefined) }) }) - test('object values of variables are merged', () => { - const target = { foo: { bar: true, deep: { dOne: 1 } }, target: true } - const source = { foo: { baz: false, deep: { dTwo: 'two' } }, source: true } + describe('dev version, debug disabled', () => { + beforeEach(() => { + mockIsDebugEnabled(false) + }) + testMergeComponentVariables(mergeComponentVariables__DEV) - const merged = mergeComponentVariables(target, source) + test('debug frames are not saved', () => { + const target = siteVariables => ({ one: 1, a: 'tA', target: true }) + const source = siteVariables => ({ two: 2, a: 'sA', source: true }) - expect(merged()).toMatchObject({ - source: true, - target: true, - foo: { bar: true, baz: false, deep: { dOne: 1, dTwo: 'two' } }, + const merged = mergeComponentVariables__PROD(target, source) + expect(merged()._debug).toBe(undefined) + }) + }) + + describe('dev version, debug enabled', () => { + beforeEach(() => { + mockIsDebugEnabled(true) + }) + testMergeComponentVariables(mergeComponentVariables__DEV) + + describe('debug frames', () => { + test('are saved', () => { + const target = siteVariables => ({ one: 1, a: 'tA', target: true, ...siteVariables }) + const source = siteVariables => ({ two: 2, a: 'sA', source: true, ...siteVariables }) + + const merged = mergeComponentVariables__DEV(target, source) + + const siteVariables = { one: 'one', two: 'two', fontSizes: {} } + + expect(merged(siteVariables)).toMatchObject({ + _debug: [ + { resolved: { target: true, one: 'one', a: 'tA' } }, + { resolved: { source: true, two: 'two', a: 'sA' } }, + ], + }) + }) + + test('contain debugId', () => { + const target = siteVariables => withDebugId({ one: 1, a: 'tA', target: true }, 'target') + const source = siteVariables => withDebugId({ two: 2, a: 'sA', source: true }, 'source') + + const merged = mergeComponentVariables__DEV(target, source) + expect(merged()).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source' }], + }) + }) + + test('contain `input` with unresolved site variables', () => { + const target = siteVariables => ({ one: 1, a: siteVariables.varA }) + const source = siteVariables => ({ two: 2, a: siteVariables.nested.varA }) + + const merged = mergeComponentVariables__DEV(target, source) + + const siteVariables = { varA: 42, nested: { varA: 42 } } + siteVariables['_invertedKeys'] = objectKeyToValues(siteVariables, v => `siteVariables.${v}`) + + expect(merged(siteVariables)).toMatchObject({ + _debug: [ + { input: { a: 'siteVariables.varA' } }, + { input: { a: 'siteVariables.nested.varA' } }, + ], + }) + }) + + test('are flat for recursive merge', () => { + const siteVariables = { + colors: { + colorForC: 'c_color', + }, + } + const target = withDebugId({ a: 1, b: 2, c: 3, d: 4, e: 5 }, 'target') + const source1 = withDebugId({ b: 'bS1', d: false, bb: 'bbS1' }, 'source1') + const source2 = withDebugId(sv => ({ c: sv.colors.colorForC, cc: 'bbS2' }), 'source2') + + const merged1 = mergeComponentVariables__DEV(target, source1, source2)(siteVariables) + const merged2 = mergeComponentVariables__DEV( + mergeComponentVariables__DEV(target, source1), + source2, + )(siteVariables) + const merged3 = mergeComponentVariables__DEV( + target, + mergeComponentVariables__DEV(source1, source2), + )(siteVariables) + + expect(merged1).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source1' }, { debugId: 'source2' }], + }) + expect(merged2).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source1' }, { debugId: 'source2' }], + }) + expect(merged3).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source1' }, { debugId: 'source2' }], + }) + }) }) }) }) diff --git a/packages/react/test/specs/lib/mergeThemes/mergeSiteVariables-test.ts b/packages/react/test/specs/lib/mergeThemes/mergeSiteVariables-test.ts index 75d185a010..094d3268a0 100644 --- a/packages/react/test/specs/lib/mergeThemes/mergeSiteVariables-test.ts +++ b/packages/react/test/specs/lib/mergeThemes/mergeSiteVariables-test.ts @@ -1,75 +1,159 @@ -import { mergeSiteVariables } from '../../../../src/lib/mergeThemes' +import { mergeSiteVariables__PROD, mergeSiteVariables__DEV } from '../../../../src/lib/mergeThemes' +import * as debugEnabled from 'src/lib/debug/debugEnabled' +import { withDebugId } from 'src/lib' describe('mergeSiteVariables', () => { - test(`always returns an object`, () => { - expect(mergeSiteVariables({}, {})).toMatchObject({}) - expect(mergeSiteVariables(null, null)).toMatchObject({}) - expect(mergeSiteVariables(undefined, undefined)).toMatchObject({}) + let originalDebugEnabled - expect(mergeSiteVariables(null, undefined)).toMatchObject({}) - expect(mergeSiteVariables(undefined, null)).toMatchObject({}) - - expect(mergeSiteVariables({}, undefined)).toMatchObject({}) - expect(mergeSiteVariables(undefined, {})).toMatchObject({}) + beforeEach(() => { + originalDebugEnabled = debugEnabled.isEnabled + }) - expect(mergeSiteVariables({}, null)).toMatchObject({}) - expect(mergeSiteVariables(null, {})).toMatchObject({}) + afterEach(() => { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: () => originalDebugEnabled, + }) }) - test('always adds fontSizes', () => { - const target = {} - const source = {} + function mockIsDebugEnabled(enabled: boolean) { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: jest.fn(() => enabled), + }) + } - expect(mergeSiteVariables(target, source)).toMatchObject({ fontSizes: {} }) - }) + function testMergeSiteVariables(mergeSiteVariables) { + test(`always returns an object`, () => { + expect(mergeSiteVariables({}, {})).toMatchObject({}) + expect(mergeSiteVariables(null, null)).toMatchObject({}) + expect(mergeSiteVariables(undefined, undefined)).toMatchObject({}) - test('gracefully handles null and undefined', () => { - expect(() => mergeSiteVariables({ color: 'black' }, null)).not.toThrow() - expect(() => mergeSiteVariables({ color: 'black' }, { color: null })).not.toThrow() + expect(mergeSiteVariables(null, undefined)).toMatchObject({}) + expect(mergeSiteVariables(undefined, null)).toMatchObject({}) - expect(() => mergeSiteVariables(null, { color: 'black' })).not.toThrow() - expect(() => mergeSiteVariables({ color: null }, { color: 'black' })).not.toThrow() + expect(mergeSiteVariables({}, undefined)).toMatchObject({}) + expect(mergeSiteVariables(undefined, {})).toMatchObject({}) - expect(() => mergeSiteVariables({ color: 'black' }, undefined)).not.toThrow() - expect(() => mergeSiteVariables({ color: 'black' }, { color: undefined })).not.toThrow() + expect(mergeSiteVariables({}, null)).toMatchObject({}) + expect(mergeSiteVariables(null, {})).toMatchObject({}) + }) - expect(() => mergeSiteVariables(undefined, { color: 'black' })).not.toThrow() - expect(() => mergeSiteVariables({ color: undefined }, { color: 'black' })).not.toThrow() - }) + test('always adds fontSizes', () => { + const target = {} + const source = {} + + expect(mergeSiteVariables(target, source)).toMatchObject({ fontSizes: {} }) + }) - test('undefined overwrites previously set value', () => { - const merged = mergeSiteVariables({ color: 'black' }, { color: undefined }) + test('gracefully handles null and undefined', () => { + expect(() => mergeSiteVariables({ color: 'black' }, null)).not.toThrow() + expect(() => mergeSiteVariables({ color: 'black' }, { color: null })).not.toThrow() - expect(merged).toMatchObject({ - color: undefined, + expect(() => mergeSiteVariables(null, { color: 'black' })).not.toThrow() + expect(() => mergeSiteVariables({ color: null }, { color: 'black' })).not.toThrow() + + expect(() => mergeSiteVariables({ color: 'black' }, undefined)).not.toThrow() + expect(() => mergeSiteVariables({ color: 'black' }, { color: undefined })).not.toThrow() + + expect(() => mergeSiteVariables(undefined, { color: 'black' })).not.toThrow() + expect(() => mergeSiteVariables({ color: undefined }, { color: 'black' })).not.toThrow() + }) + + test('undefined overwrites previously set value', () => { + const merged = mergeSiteVariables({ color: 'black' }, { color: undefined }) + + expect(merged).toMatchObject({ + color: undefined, + }) + }) + + test('null overwrites previously set value', () => { + const merged = mergeSiteVariables({ color: 'black' }, { color: null }) + + expect(merged).toMatchObject({ + color: null, + }) }) - }) - test('null overwrites previously set value', () => { - const merged = mergeSiteVariables({ color: 'black' }, { color: null }) + test('merges top level keys', () => { + const target = { overridden: false, keep: true } + const source = { overridden: true, add: true } - expect(merged).toMatchObject({ - color: null, + expect(mergeSiteVariables(target, source)).toMatchObject({ + overridden: true, + keep: true, + add: true, + }) + }) + + test('deep merges nested keys', () => { + const target = { nested: { replaced: false, deep: { dOne: 1 } } } + const source = { nested: { other: 'value', deep: { dTwo: 'two' } } } + + expect(mergeSiteVariables(target, source)).toMatchObject({ + nested: { replaced: false, other: 'value', deep: { dOne: 1, dTwo: 'two' } }, + }) + }) + } + + describe('prod version', () => { + beforeEach(() => { + mockIsDebugEnabled(true) // it is not possible to enable debug in prod + }) + testMergeSiteVariables(mergeSiteVariables__PROD) + + test('debug frames are not saved', () => { + const target = { one: 1, a: 'tA' } + const source = { two: 2, a: 'sA' } + + const merged = mergeSiteVariables__PROD(target, source) + + expect(merged._debug).toBe(undefined) }) }) - test('merges top level keys', () => { - const target = { overridden: false, keep: true } - const source = { overridden: true, add: true } + describe('dev version, debug disabled', () => { + beforeEach(() => { + mockIsDebugEnabled(false) + }) + testMergeSiteVariables(mergeSiteVariables__DEV) + + test('debug frames are not saved', () => { + const target = { one: 1, a: 'tA' } + const source = { two: 2, a: 'sA' } - expect(mergeSiteVariables(target, source)).toMatchObject({ - overridden: true, - keep: true, - add: true, + const merged = mergeSiteVariables__DEV(target, source) + + expect(merged._debug).toBe(undefined) }) }) - test('deep merges nested keys', () => { - const target = { nested: { replaced: false, deep: { dOne: 1 } } } - const source = { nested: { other: 'value', deep: { dTwo: 'two' } } } + describe('dev version, debug enabled', () => { + beforeEach(() => { + mockIsDebugEnabled(true) + }) + testMergeSiteVariables(mergeSiteVariables__DEV) + + describe('debug frames', () => { + test('are saved', () => { + const target = { one: 1, a: 'tA' } + const source = { two: 2, a: 'sA' } + + const merged = mergeSiteVariables__DEV(target, source) + + expect(merged).toMatchObject({ + _debug: [{ resolved: { one: 1, a: 'tA' } }, { resolved: { two: 2, a: 'sA' } }], + }) + }) + + test('contain debugId', () => { + const target = withDebugId({ one: 1, a: 'tA', target: true }, 'target') + const source = withDebugId({ two: 2, a: 'sA', source: true }, 'source') - expect(mergeSiteVariables(target, source)).toMatchObject({ - nested: { replaced: false, other: 'value', deep: { dOne: 1, dTwo: 'two' } }, + const merged = mergeSiteVariables__DEV(target, source) + expect(merged).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source' }], + }) + }) }) }) }) diff --git a/packages/react/test/specs/lib/mergeThemes/mergeThemeVariables-test.ts b/packages/react/test/specs/lib/mergeThemes/mergeThemeVariables-test.ts new file mode 100644 index 0000000000..066c2930a8 --- /dev/null +++ b/packages/react/test/specs/lib/mergeThemes/mergeThemeVariables-test.ts @@ -0,0 +1,92 @@ +import { mergeThemeVariables__PROD, mergeThemeVariables__DEV } from 'src/lib/mergeThemes' +import * as _ from 'lodash' +import { withDebugId } from 'src/lib' +import * as debugEnabled from 'src/lib/debug/debugEnabled' + +describe('mergeThemeVariables', () => { + let originalDebugEnabled + + beforeEach(() => { + originalDebugEnabled = debugEnabled.isEnabled + }) + + afterEach(() => { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: () => originalDebugEnabled, + }) + }) + + function mockIsDebugEnabled(enabled: boolean) { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: jest.fn(() => enabled), + }) + } + + function testMergeThemeVariables(mergeThemeVariables) { + test('component variables are merged', () => { + const target = { Button: {} } + const source = { Icon: {} } + + const merged = mergeThemeVariables(target, source) + + expect(merged).toHaveProperty('Button') + expect(merged).toHaveProperty('Icon') + }) + + test('component variable objects are converted to functions', () => { + const target = { Button: {} } + const source = { Button: {} } + + const merged = mergeThemeVariables(target, source) + + expect(merged.Button).toBeInstanceOf(Function) + expect(merged.Button).toBeInstanceOf(Function) + }) + + test('component variable objects are deeply merged', () => { + const target = { Button: { a: 'a', b: 'b', c: 'c', d: 'd', e: 'e' } } + const source1 = withDebugId( + { + Button: siteVariables => ({ b: siteVariables.colors.colorForB }), + }, + 's1', + ) + const source2 = { Button: { c: 'cS2' } } + const source3 = { Button: { d: 'dS3' } } + + const siteVariables = { + fontSizes: {}, + colors: { + colorForB: 'b_color', + colorForC: 'c_color', + }, + } + const merged = mergeThemeVariables(target, mergeThemeVariables(source1, source2), source3) + const resolved = _.mapValues(merged, cv => cv(siteVariables)) + expect(resolved).toMatchObject({ + Button: { a: 'a', b: 'b_color', c: 'cS2', d: 'dS3', e: 'e' }, + }) + }) + } + + describe('prod version', () => { + beforeEach(() => { + mockIsDebugEnabled(true) // it is not possible to enable debug in prod + }) + testMergeThemeVariables(mergeThemeVariables__PROD) + }) + + describe('dev version, debug disabled', () => { + beforeEach(() => { + mockIsDebugEnabled(false) + }) + testMergeThemeVariables(mergeThemeVariables__DEV) + }) + + describe('dev version, debug enabled', () => { + beforeEach(() => { + mockIsDebugEnabled(true) + }) + testMergeThemeVariables(mergeThemeVariables__DEV) + }) +}) diff --git a/packages/react/test/specs/lib/mergeThemes/mergeThemes-test.ts b/packages/react/test/specs/lib/mergeThemes/mergeThemes-test.ts index 085646605b..8dd1f7dee9 100644 --- a/packages/react/test/specs/lib/mergeThemes/mergeThemes-test.ts +++ b/packages/react/test/specs/lib/mergeThemes/mergeThemes-test.ts @@ -1,5 +1,8 @@ import mergeThemes, { mergeStyles } from 'src/lib/mergeThemes' -import { ComponentStyleFunctionParam, ICSSInJSStyle } from 'src/themes/types' +import { ComponentStyleFunctionParam, ICSSInJSStyle, ThemeInput } from 'src/themes/types' +import * as _ from 'lodash' +import { callable, themes, withDebugId } from 'src/index' +import * as debugEnabled from 'src/lib/debug/debugEnabled' describe('mergeThemes', () => { test(`always returns an object`, () => { @@ -387,4 +390,191 @@ describe('mergeThemes', () => { }) }) }) + + describe('debug frames', () => { + let originalDebugEnabled + + beforeEach(() => { + originalDebugEnabled = debugEnabled.isEnabled + }) + + afterEach(() => { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: () => originalDebugEnabled, + }) + }) + + function mockIsDebugEnabled(enabled: boolean) { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: jest.fn(() => enabled), + }) + } + + test('are saved if debug is enabled', () => { + mockIsDebugEnabled(true) + const target: ThemeInput = { + siteVariables: { varA: 'tVarA' }, + componentVariables: { Button: { btnVar: 'tBtnVar' } }, + componentStyles: { Button: { root: { style: 'tStyleA' } } }, + } + const source = { + siteVariables: { varA: 'sVarA' }, + componentVariables: { Button: sv => ({ btnVar: sv.varA }) }, + componentStyles: { Button: { root: ({ variables }) => ({ style: variables.btnVar }) } }, + } + + const merged = mergeThemes(target, source) + + expect(merged.siteVariables).toMatchObject({ + _debug: [ + { + /* FIXME: unnecessary empty object */ + }, + { resolved: { varA: 'tVarA' } }, + { resolved: { varA: 'sVarA' } }, + ], + }) + + const buttonVariables = merged.componentVariables.Button(merged.siteVariables) + expect(buttonVariables).toMatchObject({ + _debug: [ + { + /* FIXME: unnecessary empty object */ + }, + { resolved: { btnVar: 'tBtnVar' } }, + { resolved: { btnVar: 'sVarA' } }, + ], + }) + + const buttonRootStyles = merged.componentStyles.Button.root({ variables: buttonVariables }) + expect(buttonRootStyles).toMatchObject({ + _debug: [{ styles: { style: 'tStyleA' } }, { styles: { style: 'sVarA' } }], + }) + }) + + test('are not saved if debug is disabled', () => { + mockIsDebugEnabled(false) + const target: ThemeInput = { + siteVariables: { varA: 'tVarA' }, + componentVariables: { Button: { btnVar: 'tBtnVar' } }, + componentStyles: { Button: { root: { style: 'tStyleA' } } }, + } + const source = { + siteVariables: { varA: 'sVarA' }, + componentVariables: { Button: sv => ({ btnVar: sv.varA }) }, + componentStyles: { Button: { root: ({ variables }) => ({ style: variables.btnVar }) } }, + } + + const merged = mergeThemes(target, source) + expect(merged.siteVariables._debug).toBe(undefined) + const buttonVariables = merged.componentVariables.Button(merged.siteVariables) + expect(buttonVariables._debug).toBe(undefined) + const buttonRootStyles = merged.componentStyles.Button.root({ variables: buttonVariables }) + expect(buttonRootStyles._debug).toBe(undefined) + }) + + test('contain debugId', () => { + mockIsDebugEnabled(true) + const target: ThemeInput = withDebugId( + { + siteVariables: { varA: 'tVarA' }, + componentVariables: { Button: { btnVar: 'tBtnVar' } }, + componentStyles: { Button: { root: { style: 'tStyleA' } } }, + }, + 'target', + ) + const source = withDebugId( + { + siteVariables: { varA: 'sVarA' }, + componentVariables: { Button: sv => ({ btnVar: sv.varA }) }, + componentStyles: { Button: { root: ({ variables }) => ({ style: variables.btnVar }) } }, + }, + 'source', + ) + + const merged = mergeThemes(target, source) + + expect(merged.siteVariables).toMatchObject({ + _debug: [ + { + /* FIXME: unnecessary empty object */ + }, + { debugId: 'target' }, + { debugId: 'source' }, + ], + }) + + const buttonVariables = merged.componentVariables.Button(merged.siteVariables) + expect(buttonVariables).toMatchObject({ + _debug: [ + { + /* FIXME: unnecessary empty object */ + }, + { debugId: 'target' }, + { debugId: 'source' }, + ], + }) + + const buttonRootStyles = merged.componentStyles.Button.root({ variables: buttonVariables }) + expect(buttonRootStyles).toMatchObject({ + _debug: [{ debugId: 'target' }, { debugId: 'source' }], + }) + }) + }) + + // This test is disabled by default + // It's purpose is to be executed manually to measure performance of mergeThemes + xdescribe('performance', () => { + let originalDebugEnabled + + beforeEach(() => { + originalDebugEnabled = debugEnabled.isEnabled + }) + + afterEach(() => { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: () => originalDebugEnabled, + }) + }) + + function mockIsDebugEnabled(enabled: boolean) { + Object.defineProperty(debugEnabled, 'isEnabled', { + get: jest.fn(() => enabled), + }) + } + + test('100 themes with debug disabled', () => { + mockIsDebugEnabled(false) + + const merged = mergeThemes(..._.times(100, n => themes.teams)) + const resolvedStyles = _.mapValues( + merged.componentStyles, + (componentStyle, componentName) => { + const compVariables = _.get(merged.componentVariables, componentName, callable({}))( + merged.siteVariables, + ) + const styleParam: ComponentStyleFunctionParam = { + displayName: componentName, + props: {}, + variables: compVariables, + theme: merged, + rtl: false, + disableAnimations: false, + } + return _.mapValues(componentStyle, (partStyle, partName) => { + if (partName === '_debug') { + // TODO: fix in code, happens only with mergeThemes(singleTheme) + return undefined + } + if (typeof partStyle !== 'function') { + fail(`Part style is not a function??? ${componentName} ${partStyle} ${partName}`) + } + return partStyle(styleParam) + }) + }, + ) + expect(resolvedStyles.Button.root).toMatchObject({}) + // console.log(resolvedStyles.Button.root) + }) + }) }) diff --git a/packages/react/test/specs/lib/objectKeysToValues-test.ts b/packages/react/test/specs/lib/objectKeysToValues-test.ts new file mode 100644 index 0000000000..db8d2b2133 --- /dev/null +++ b/packages/react/test/specs/lib/objectKeysToValues-test.ts @@ -0,0 +1,19 @@ +import objectKeyToValues from 'src/lib/objectKeysToValues' + +describe('objectKeyToValues', () => { + test('values are replaced by key paths', () => { + const input = { + a: 2, + b: { + c: [3, 4], + }, + } + + expect(objectKeyToValues(input)).toStrictEqual({ + a: 'a', + b: { + c: 'b.c', + }, + }) + }) +})