From def6f5098c68910473ef20cca4723d288e6c5e95 Mon Sep 17 00:00:00 2001 From: Vince Howard Date: Wed, 23 Oct 2024 20:38:32 -0600 Subject: [PATCH] feat(3417): sensitive text component (#11965) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## **Description** ### Overview This PR introduces a new `SensitiveText` component to our component library. The `SensitiveText` component extends our existing `Text` component to handle sensitive information, providing the ability to hide or show text content as needed. ### Features - Extends the existing `Text` component functionality - Allows toggling between visible and hidden states for sensitive information - Supports different lengths of hidden text (Short, Medium, Long) - Maintains all styling capabilities of the `Text` component (variants, colors, etc) ## **Related issues** Feature: [#3417](https://github.com/MetaMask/MetaMask-planning/issues/3417) ## **Manual testing steps** 1. Follow instructions on getting started with storybook 2. Play with new component there ## **Screenshots/Recordings** ![sensitive_text](https://github.com/user-attachments/assets/f737d1d3-f0d7-4202-b5cd-9bbefb430ffe) ### **Before** NA ### **After** NA ## **Pre-merge author checklist** - [x] I’ve followed [MetaMask Contributor Docs](https://github.com/MetaMask/contributor-docs) and [MetaMask Mobile Coding Standards](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/CODING_GUIDELINES.md). - [x] I've completed the PR template to the best of my ability - [x] I’ve included tests if applicable - [x] I’ve documented my code using [JSDoc](https://jsdoc.app/) format if applicable - [x] I’ve applied the right labels on the PR (see [labeling guidelines](https://github.com/MetaMask/metamask-mobile/blob/main/.github/guidelines/LABELING_GUIDELINES.md)). Not required for external contributors. ## **Pre-merge reviewer checklist** - [x] I've manually tested the PR (e.g. pull and build branch, run the app, test code being changed). - [x] I confirm that this PR addresses all acceptance criteria described in the ticket it closes and includes the necessary testing evidence such as recordings and or screenshots. --- .storybook/storybook.requires.js | 2 +- .../components/Texts/SensitiveText/README.md | 57 +++++++++ .../SensitiveText/SensitiveText.stories.tsx | 89 ++++++++++++++ .../SensitiveText/SensitiveText.test.tsx | 116 ++++++++++++++++++ .../Texts/SensitiveText/SensitiveText.tsx | 39 ++++++ .../SensitiveText/SensitiveText.types.ts | 46 +++++++ .../__snapshots__/SensitiveText.test.tsx.snap | 19 +++ .../components/Texts/SensitiveText/index.ts | 3 + 8 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 app/component-library/components/Texts/SensitiveText/README.md create mode 100644 app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx create mode 100644 app/component-library/components/Texts/SensitiveText/SensitiveText.test.tsx create mode 100644 app/component-library/components/Texts/SensitiveText/SensitiveText.tsx create mode 100644 app/component-library/components/Texts/SensitiveText/SensitiveText.types.ts create mode 100644 app/component-library/components/Texts/SensitiveText/__snapshots__/SensitiveText.test.tsx.snap create mode 100644 app/component-library/components/Texts/SensitiveText/index.ts diff --git a/.storybook/storybook.requires.js b/.storybook/storybook.requires.js index a1ca7774ac8..c08c085d9a9 100644 --- a/.storybook/storybook.requires.js +++ b/.storybook/storybook.requires.js @@ -126,7 +126,7 @@ const getStories = () => { "./app/components/Views/confirmations/components/UI/InfoRow/InfoRow.stories.tsx": require("../app/components/Views/confirmations/components/UI/InfoRow/InfoRow.stories.tsx"), "./app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.stories.tsx": require("../app/components/Views/confirmations/components/UI/ExpandableSection/ExpandableSection.stories.tsx"), "./app/components/Views/confirmations/components/UI/Tooltip/Tooltip.stories.tsx": require("../app/components/Views/confirmations/components/UI/Tooltip/Tooltip.stories.tsx"), - "./app/components/Views/confirmations/components/UI/CopyButton/CopyButton.stories.tsx": require("../app/components/Views/confirmations/components/UI/CopyButton/CopyButton.stories.tsx"), + "./app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx": require("../app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx"), }; }; diff --git a/app/component-library/components/Texts/SensitiveText/README.md b/app/component-library/components/Texts/SensitiveText/README.md new file mode 100644 index 00000000000..dd81879e398 --- /dev/null +++ b/app/component-library/components/Texts/SensitiveText/README.md @@ -0,0 +1,57 @@ +# SensitiveText + +SensitiveText is a component that extends the Text component to handle sensitive information. It provides the ability to hide or show the text content, replacing it with dots when hidden. + +## Props + +This component extends all props from the [Text](../Text/README.md) component and adds the following: + +### `isHidden` + +Boolean to determine whether the text should be hidden or visible. + +| TYPE | REQUIRED | DEFAULT | +| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | +| boolean | Yes | false | + +### `length` + +Determines the length of the hidden text (number of dots). Can be a predefined SensitiveTextLength or a custom string number. + +| TYPE | REQUIRED | DEFAULT | +| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | +| [SensitiveTextLengthType](./SensitiveText.types.ts#L14) \| [CustomLength](./SensitiveText.types.ts#L19) | No | SensitiveTextLength.Short | + +### `children` + +The text content to be displayed or hidden. + +| TYPE | REQUIRED | DEFAULT | +| :-------------------------------------------------- | :------------------------------------------------------ | :----------------------------------------------------- | +| string | Yes | - | + +## Usage + +```javascript +import SensitiveText from 'app/component-library/components/Texts/SensitiveText'; +import { TextVariant } from 'app/component-library/components/Texts/Text'; +import { SensitiveTextLength } from 'app/component-library/components/Texts/SensitiveText/SensitiveText.types'; + + + Sensitive Information + + + + Custom Length Hidden Text + +``` + +This will render a Text component with dots instead of the actual text when `isHidden` is true, and the original text when `isHidden` is false. The number of asterisks is determined by the `length` prop. diff --git a/app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx b/app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx new file mode 100644 index 00000000000..c881014ed43 --- /dev/null +++ b/app/component-library/components/Texts/SensitiveText/SensitiveText.stories.tsx @@ -0,0 +1,89 @@ +// Third party dependencies +import React from 'react'; + +// External dependencies +import { TextVariant, TextColor } from '../Text/Text.types'; + +// Internal dependencies +import SensitiveText from './SensitiveText'; +import { SensitiveTextProps, SensitiveTextLength } from './SensitiveText.types'; + +const SensitiveTextMeta = { + title: 'Component Library / Texts', + component: SensitiveText, + argTypes: { + isHidden: { + control: 'boolean', + }, + length: { + options: SensitiveTextLength, + control: { + type: 'select', + }, + }, + variant: { + options: TextVariant, + control: { + type: 'select', + }, + }, + color: { + options: TextColor, + control: { + type: 'select', + }, + }, + children: { + control: { type: 'text' }, + }, + }, +}; +export default SensitiveTextMeta; + +export const SensitiveTextExample = { + args: { + isHidden: false, + length: SensitiveTextLength.Short, + variant: TextVariant.BodyMD, + color: TextColor.Default, + children: 'Sensitive Information', + }, +}; + +export const SensitiveTextVariants = ( + args: React.JSX.IntrinsicAttributes & + SensitiveTextProps & { children?: React.ReactNode | undefined }, +) => ( + <> + + Visible Sensitive Text + + {Object.values(SensitiveTextLength).map((length) => ( + + {`Hidden (${length})`} + + ))} + +); +SensitiveTextVariants.argTypes = { + isHidden: { control: false }, + length: { control: false }, + children: { control: false }, +}; +SensitiveTextVariants.args = { + variant: TextVariant.BodyMD, + color: TextColor.Default, +}; diff --git a/app/component-library/components/Texts/SensitiveText/SensitiveText.test.tsx b/app/component-library/components/Texts/SensitiveText/SensitiveText.test.tsx new file mode 100644 index 00000000000..2e5b158263c --- /dev/null +++ b/app/component-library/components/Texts/SensitiveText/SensitiveText.test.tsx @@ -0,0 +1,116 @@ +// Third party dependencies +import React from 'react'; +import { render } from '@testing-library/react-native'; + +// External dependencies +import { mockTheme } from '../../../../util/theme'; + +// Internal dependencies +import SensitiveText from './SensitiveText'; +import { SensitiveTextLength } from './SensitiveText.types'; +import { TextVariant, TextColor } from '../Text/Text.types'; + +describe('SensitiveText', () => { + const testProps = { + isHidden: false, + length: SensitiveTextLength.Short, + variant: TextVariant.BodyMD, + color: TextColor.Default, + children: 'Sensitive Information', + }; + + it('should render correctly', () => { + const wrapper = render(); + expect(wrapper).toMatchSnapshot(); + }); + + it('should display the text when isHidden is false', () => { + const { getByText } = render(); + expect(getByText('Sensitive Information')).toBeTruthy(); + }); + + it('should hide the text when isHidden is true', () => { + const { queryByText, getByText } = render( + , + ); + expect(queryByText('Sensitive Information')).toBeNull(); + expect(getByText('••••••')).toBeTruthy(); + }); + + it('should render the correct number of asterisks for different lengths', () => { + const { getByText: getShort } = render( + , + ); + expect(getShort('••••••')).toBeTruthy(); + + const { getByText: getMedium } = render( + , + ); + expect(getMedium('•••••••••')).toBeTruthy(); + + const { getByText: getLong } = render( + , + ); + expect(getLong('••••••••••••')).toBeTruthy(); + + const { getByText: getExtraLong } = render( + , + ); + expect(getExtraLong('••••••••••••••••••••')).toBeTruthy(); + }); + + it('should apply the correct text color', () => { + const { getByText } = render( + , + ); + const textElement = getByText('Sensitive Information'); + expect(textElement.props.style.color).toBe(mockTheme.colors.text.default); + }); + it('should handle all predefined SensitiveTextLength values', () => { + Object.entries(SensitiveTextLength).forEach(([_, value]) => { + const { getByText } = render( + , + ); + expect(getByText('•'.repeat(Number(value)))).toBeTruthy(); + }); + }); + + it('should handle custom length as a string', () => { + const { getByText } = render( + , + ); + expect(getByText('•••••••••••••••')).toBeTruthy(); + }); + + it('should fall back to Short length for invalid custom length', () => { + const { getByText } = render( + , + ); + expect(getByText('••••••')).toBeTruthy(); + }); + + it('should log a warning for invalid custom length', () => { + const consoleSpy = jest.spyOn(console, 'warn').mockImplementation(); + render(); + expect(consoleSpy).toHaveBeenCalledWith( + 'Invalid length provided: abc. Falling back to Short.', + ); + consoleSpy.mockRestore(); + }); +}); diff --git a/app/component-library/components/Texts/SensitiveText/SensitiveText.tsx b/app/component-library/components/Texts/SensitiveText/SensitiveText.tsx new file mode 100644 index 00000000000..6e1f512c35b --- /dev/null +++ b/app/component-library/components/Texts/SensitiveText/SensitiveText.tsx @@ -0,0 +1,39 @@ +// external dependencies +import React, { useMemo } from 'react'; +import Text from '../Text/Text'; + +// internal dependencies +import { SensitiveTextProps, SensitiveTextLength } from './SensitiveText.types'; + +const SensitiveText: React.FC = ({ + isHidden = false, + children = '', + length = SensitiveTextLength.Short, + ...props +}) => { + const getFallbackLength = useMemo( + () => (len: string) => { + const numLength = Number(len); + return Number.isNaN(numLength) ? 0 : numLength; + }, + [], + ); + + const isValidCustomLength = (value: string): boolean => { + const num = Number(value); + return !Number.isNaN(num) && num > 0; + }; + + if (!(length in SensitiveTextLength) && !isValidCustomLength(length)) { + console.warn(`Invalid length provided: ${length}. Falling back to Short.`); + length = SensitiveTextLength.Short; + } + + const fallback = useMemo( + () => '•'.repeat(getFallbackLength(length)), + [length, getFallbackLength], + ); + return {isHidden ? fallback : children}; +}; + +export default SensitiveText; diff --git a/app/component-library/components/Texts/SensitiveText/SensitiveText.types.ts b/app/component-library/components/Texts/SensitiveText/SensitiveText.types.ts new file mode 100644 index 00000000000..1c6f4688b78 --- /dev/null +++ b/app/component-library/components/Texts/SensitiveText/SensitiveText.types.ts @@ -0,0 +1,46 @@ +// External dependencies. +import { TextProps } from '../Text/Text.types'; + +/** + * SensitiveText length options. + */ +export const SensitiveTextLength = { + Short: '6', + Medium: '9', + Long: '12', + ExtraLong: '20', +} as const; + +/** + * Type for SensitiveTextLength values. + */ +export type SensitiveTextLengthType = + (typeof SensitiveTextLength)[keyof typeof SensitiveTextLength]; + +/** + * Type for custom length values. + */ +export type CustomLength = string; + +/** + * SensitiveText component props. + */ +export interface SensitiveTextProps extends TextProps { + /** + * Boolean to determine whether the text should be hidden or visible. + * + * @default false + */ + isHidden?: boolean; + /** + * Determines the length of the hidden text (number of asterisks). + * Can be a predefined SensitiveTextLength or a custom string number. + * + * @default SensitiveTextLength.Short + */ + length?: SensitiveTextLengthType | CustomLength; + /** + * The text content to be displayed or hidden. + */ + children: string; +} diff --git a/app/component-library/components/Texts/SensitiveText/__snapshots__/SensitiveText.test.tsx.snap b/app/component-library/components/Texts/SensitiveText/__snapshots__/SensitiveText.test.tsx.snap new file mode 100644 index 00000000000..baa2e5148bf --- /dev/null +++ b/app/component-library/components/Texts/SensitiveText/__snapshots__/SensitiveText.test.tsx.snap @@ -0,0 +1,19 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SensitiveText should render correctly 1`] = ` + + Sensitive Information + +`; diff --git a/app/component-library/components/Texts/SensitiveText/index.ts b/app/component-library/components/Texts/SensitiveText/index.ts new file mode 100644 index 00000000000..4ea8f25dad4 --- /dev/null +++ b/app/component-library/components/Texts/SensitiveText/index.ts @@ -0,0 +1,3 @@ +export { default } from './SensitiveText'; +export { SensitiveTextLength } from './SensitiveText.types'; +export type { SensitiveTextProps } from './SensitiveText.types';