-
-
Notifications
You must be signed in to change notification settings - Fork 251
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat(suite): Enhance HiddenPlaceholder #13914
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,8 @@ | ||
const baseConfig = require('../../jest.config.base'); | ||
|
||
module.exports = { | ||
...baseConfig, | ||
collectCoverage: true, | ||
collectCoverageFrom: ['src/**/*.ts'], | ||
testEnvironment: '../../JestCustomEnv.js', | ||
}; | ||
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import { ReactNode, isValidElement, cloneElement, Key } from 'react'; | ||
|
||
type RewriteReactNodeRecursivelyCallback = (stringSubNode: string) => string; | ||
|
||
/** | ||
* Recursively crawls ReactNode and applies callback on all string subnodes | ||
* @param node ReactNode to be recursively crawled & modified | ||
* @param callback function to be applied on all string subnodes | ||
* @param reactKey optional React key applied on the root node, if it is a ReactElement. Used internally. | ||
* @returns modified ReactNode | ||
*/ | ||
export const rewriteReactNodeRecursively = ( | ||
node: ReactNode, | ||
callback: RewriteReactNodeRecursivelyCallback, | ||
reactKey?: Key, | ||
): ReactNode => { | ||
if (typeof node === 'string') return callback(node); | ||
if (typeof node === 'number') return callback(node.toString()); | ||
|
||
if (Array.isArray(node)) { | ||
return node.map((child, index) => { | ||
const newReactKey: Key | undefined = isValidElement(child) | ||
? child.key ?? index | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. When rewriting the |
||
: undefined; | ||
|
||
return rewriteReactNodeRecursively(child, callback, newReactKey); | ||
}); | ||
} | ||
|
||
if (isValidElement(node)) { | ||
const rewrittenChildren = rewriteReactNodeRecursively(node.props.children, callback); | ||
|
||
return cloneElement(node, { ...node.props, key: reactKey, children: rewrittenChildren }); | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TL;DR: Should I scrap this? 🤔 My idea was to have But this procedure simply stops when it encounters more complex components, which calculate their content instead of just wrapping children, like So those components have to do the redacting themselves: see I'm not happy with this mixed approach, where sometimes it does things automagically under the hood, but sometimes you just have to do it yourself. I fear this might lead to confusion and mistakes later on. |
||
} | ||
|
||
return node; | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,11 @@ | ||
// Jest Snapshot v1, https://goo.gl/fbAQLP | ||
|
||
exports[`rewriteReactNodeRecursively should recursively rewrite string subnodes in a ReactElement 1`] = ` | ||
<span> | ||
This is barbar, | ||
<i> | ||
it has a bar | ||
</i> | ||
. | ||
</span> | ||
`; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,55 @@ | ||
import { rewriteReactNodeRecursively } from '../src/rewriteReactNodeRecursively'; | ||
|
||
describe('rewriteReactNodeRecursively', () => { | ||
const callback = (stringSubNode: string) => stringSubNode.replace(/foo/g, 'bar'); | ||
|
||
it('should rewrite a string subnode', () => { | ||
const inputNode = 'This is foobar.'; | ||
const expectedNode = 'This is barbar.'; | ||
expect(rewriteReactNodeRecursively(inputNode, callback)).toBe(expectedNode); | ||
}); | ||
|
||
it('should cast a number subnode to string and rewrite it', () => { | ||
const callbackForNumbers = (stringifiedNumber: string) => | ||
stringifiedNumber.replace(/34/g, '***'); | ||
const inputNode = 123456; | ||
const expectedNode = '12***56'; | ||
expect(rewriteReactNodeRecursively(inputNode, callbackForNumbers)).toBe(expectedNode); | ||
}); | ||
|
||
it('should return the node if it is a primitive other than string or number', () => { | ||
expect(rewriteReactNodeRecursively(null, callback)).toBe(null); | ||
expect(rewriteReactNodeRecursively(undefined, callback)).toBe(undefined); | ||
expect(rewriteReactNodeRecursively(true, callback)).toBe(true); | ||
}); | ||
|
||
it('should rewrite all string subnodes in an array', () => { | ||
const inputNode = ['This is foobar.', 'It has a foo.']; | ||
const expectedNode = ['This is barbar.', 'It has a bar.']; | ||
expect(rewriteReactNodeRecursively(inputNode, callback)).toEqual(expectedNode); | ||
}); | ||
|
||
it('should recursively rewrite string subnodes in a ReactElement', () => { | ||
const inputNode = ( | ||
<span> | ||
This is foobar, <i>it has a foo</i>. | ||
</span> | ||
); | ||
expect(rewriteReactNodeRecursively(inputNode, callback)).toMatchSnapshot(); | ||
}); | ||
|
||
it('should iterate through a collection of ReactNodes, preserving keys or setting them to index if necessary', () => { | ||
const inputNode = [ | ||
// eslint-disable-next-line react/jsx-key | ||
<span>Foo does not have key!.</span>, | ||
<span key="id234">This is foo with key.</span>, | ||
false, | ||
] as const; | ||
|
||
const result = rewriteReactNodeRecursively(inputNode, callback) as typeof inputNode; | ||
|
||
expect(result.length).toBe(3); | ||
expect(result[0].key).toBe('0'); | ||
expect(result[1].key).toBe('id234'); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,7 +1,9 @@ | ||
import { ReactNode, useEffect, useRef, useState } from 'react'; | ||
import { ReactNode, useEffect, useMemo, useRef, useState } from 'react'; | ||
import styled, { css } from 'styled-components'; | ||
import { useSelector } from 'src/hooks/suite'; | ||
import { selectIsDiscreteModeActive } from 'src/reducers/wallet/settingsReducer'; | ||
import { rewriteReactNodeRecursively } from '@trezor/react-utils'; | ||
import { redactNumericalSubstring, RedactNumbersContext } from '@suite-common/wallet-utils'; | ||
|
||
interface WrapperProps { | ||
$intensity: number; | ||
|
@@ -34,11 +36,13 @@ export const HiddenPlaceholder = ({ | |
children, | ||
enforceIntensity, | ||
className, | ||
...rest | ||
'data-testid': dataTestId, | ||
}: HiddenPlaceholderProps) => { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. incidental cleanup, |
||
const ref = useRef<HTMLSpanElement>(null); | ||
const [automaticIntensity, setAutomaticIntensity] = useState(10); | ||
const discreetMode = useSelector(selectIsDiscreteModeActive); | ||
const [isHovered, setIsHovered] = useState(false); | ||
const shouldRedactNumbers = discreetMode && !isHovered; | ||
|
||
useEffect(() => { | ||
if (ref.current === null) { | ||
|
@@ -54,15 +58,33 @@ export const HiddenPlaceholder = ({ | |
setAutomaticIntensity(fontSize / 5); | ||
}, []); | ||
|
||
/* | ||
Recursively redact all numbers in children hierarchy of HiddenPlaceholder. | ||
This works for children hierarchy that consists of simple components which wrap another 'children'. | ||
When applied to complex components (Formatters), only the blur is applied from HiddenPlaceholder. | ||
Redaction is handled in prepareFiatAmountFormatter, prepareCryptoAmountFormatter. | ||
*/ | ||
const modifiedChildren = useMemo( | ||
() => | ||
shouldRedactNumbers | ||
? rewriteReactNodeRecursively(children, redactNumericalSubstring) | ||
: children, | ||
[children, shouldRedactNumbers], | ||
); | ||
|
||
return ( | ||
<Wrapper | ||
onMouseEnter={() => setIsHovered(true)} | ||
onMouseLeave={() => setIsHovered(false)} | ||
$discreetMode={discreetMode} | ||
$intensity={enforceIntensity !== undefined ? enforceIntensity : automaticIntensity} | ||
className={className} | ||
ref={ref} | ||
data-testid={rest['data-testid']} | ||
data-testid={dataTestId} | ||
> | ||
{children} | ||
<RedactNumbersContext.Provider value={{ shouldRedactNumbers }}> | ||
{modifiedChildren} | ||
</RedactNumbersContext.Provider> | ||
</Wrapper> | ||
); | ||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -1,3 +1,5 @@ | ||
import { useShouldRedactNumbers } from '@suite-common/wallet-utils'; | ||
|
||
export type DataContext = Record<string, unknown>; | ||
|
||
interface FormatDefinition<TInput, TOutput, TDataContext extends DataContext> { | ||
|
@@ -6,6 +8,8 @@ interface FormatDefinition<TInput, TOutput, TDataContext extends DataContext> { | |
value: TInput, | ||
/** Additional data context to be used by the formatter. */ | ||
dataContext: Partial<TDataContext>, | ||
/** Whether a component above has requested to redact the numbers for discreet mode */ | ||
shouldRedactNumbers?: boolean, | ||
): TOutput; | ||
} | ||
|
||
|
@@ -40,11 +44,11 @@ export const makeFormatter = <TInput, TOutput, TDataContext extends DataContext | |
format: FormatDefinition<TInput, TOutput, TDataContext>, | ||
displayName = 'Formatter', | ||
): Formatter<TInput, TOutput, TDataContext> => { | ||
const formatter: Formatter<TInput, TOutput, TDataContext> = props => | ||
<>{format(props.value, props)}</> ?? null; | ||
formatter.displayName = displayName; | ||
const FormatterComponent: Formatter<TInput, TOutput, TDataContext> = props => | ||
<>{format(props.value, props, useShouldRedactNumbers())}</> ?? null; | ||
FormatterComponent.displayName = displayName; | ||
|
||
formatter.format = (value, dataContext = {}) => format(value, dataContext); | ||
FormatterComponent.format = (value, dataContext = {}) => format(value, dataContext); | ||
|
||
return formatter; | ||
return FormatterComponent; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It is actually a React component, so its name should be in CamelCase. |
||
}; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,29 @@ | ||
import { redactNumericalSubstring } from '../discreetModeUtils'; | ||
|
||
const MAX_PLACEHOLDER = '####'; // placeholder with maximal length of 4 characters | ||
|
||
describe('redactNumericalSubstring', () => { | ||
it('replaces sole stringified number with placeholder', () => { | ||
expect(redactNumericalSubstring('123')).toBe('###'); | ||
expect(redactNumericalSubstring('0.001234')).toBe(MAX_PLACEHOLDER); | ||
expect(redactNumericalSubstring('-9,876,543.210001')).toBe(MAX_PLACEHOLDER); | ||
expect(redactNumericalSubstring('-.1')).toBe('###'); | ||
}); | ||
|
||
it('redacts only the number, not an accompanying symboll', () => { | ||
expect(redactNumericalSubstring('CZK 123')).toBe(`CZK ###`); | ||
expect(redactNumericalSubstring('0.001234 BTC')).toBe(`${MAX_PLACEHOLDER} BTC`); | ||
expect(redactNumericalSubstring('-9,876,543.210001€')).toBe(`${MAX_PLACEHOLDER}€`); | ||
}); | ||
|
||
it('redacts all number occurrences', () => { | ||
expect(redactNumericalSubstring('CZK 123 is 12345 satoshi')).toBe( | ||
`CZK ### is ${MAX_PLACEHOLDER} satoshi`, | ||
); | ||
}); | ||
|
||
it('does not replace non-numerical strings', () => { | ||
expect(redactNumericalSubstring('foo')).toBe('foo'); | ||
expect(redactNumericalSubstring('. , -.')).toBe('. , -.'); | ||
}); | ||
}); |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,32 @@ | ||
import { createContext, useContext } from 'react'; | ||
|
||
const numericalSubstringRegex = /[\d\-\.,]*\d+[\.\d]*/g; | ||
/** | ||
* Search the input for a numerical substring and replace it with DISCREET_PLACEHOLDER. | ||
* @param value whole string value, usually number with symbol | ||
* @returns redacted string | ||
*/ | ||
export const redactNumericalSubstring = (value: string): string => { | ||
const PLACEHOLDER_CHAR = '#'; | ||
const MAX_PLACEHOLDER_LENGTH = 4; | ||
|
||
const matches = value.match(numericalSubstringRegex); | ||
if (!matches) return value; | ||
|
||
return matches.reduce((redactedValue, match) => { | ||
const newLength = Math.min(MAX_PLACEHOLDER_LENGTH, match.length); | ||
|
||
return redactedValue.replace(match, PLACEHOLDER_CHAR.repeat(newLength)); | ||
}, value); | ||
}; | ||
|
||
type RedactNumbersContextData = { shouldRedactNumbers: boolean }; | ||
export const RedactNumbersContext = createContext<RedactNumbersContextData>({ | ||
shouldRedactNumbers: false, | ||
}); | ||
/** | ||
* Determine whether we are under a component which currently requests to redact the numbers for discreet mode (see HiddenPlaceholder). | ||
* @returns shouldRedactNumbers whether numbers should be redacted in displayed output | ||
*/ | ||
export const useShouldRedactNumbers = () => | ||
useContext(RedactNumbersContext)?.shouldRedactNumbers ?? false; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Not sure if all of this belongs to |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
react-utils
did not have unit tests at all. I set it up for the first test. Code coverage is not great 😄