Skip to content

Commit b9e740f

Browse files
committed
wip(#7): add more text transformation functions and tests
close #8, close #9, close #10, close #11, close #12
1 parent 8882f3b commit b9e740f

File tree

6 files changed

+317
-87
lines changed

6 files changed

+317
-87
lines changed

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
"lint": "eslint",
1515
"lint:fix": "eslint --fix",
1616
"lint:inspect": "eslint --inspect-config",
17-
"check": "tsc --noEmit src/index.ts --jsx react-jsx --esModuleInterop --skipLibCheck",
17+
"check": "tsc --noEmit src/index.ts --jsx react-jsx --esModuleInterop --skipLibCheck --lib esnext" ,
1818
"test": "jest",
1919
"test:watch": "jest --watch --verbose",
2020
"test:ci": "jest --ci --noStackTrace --passWithNoTests -c jest.config.ci.ts",

src/components/TextRenderer/TextRenderer.mdx

Lines changed: 21 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,26 @@ The text representation can be customized using the appropriates tag attributes
2020

2121
Notes:
2222

23-
- This component is rendering text transformed by predefined, standard functions like "lowercase", "uppercase", "camelcase", "kebabcase" etc.
23+
- This component is rendering text transformed by predefined, standard `transform` functions:
24+
- **no-op**: the text is rendered as-is (_no operation_)
25+
- **lower-case**: use the standard `toLowerCase()` string transformation for rendition.
26+
- **upper-case**: use the standard `toUpperCase()` string transformation for rendition.
27+
- **camel-case**: starts by making the first word lowercase. Then, it capitalizes the first letter of each word that follows. Then It removes the non-letter chars from the text.
28+
`My name is Nobody` → `myNameIsNobody`.
29+
- **pascal-case**: capitalizes the first letter of each word. Then It removes the non-letter chars from the text.
30+
`My name is Nobody` → `MyNameIsNobody`.
31+
- **snake-case**: separates each word with an underscore character (`_`). The rendered result is lowercased.
32+
`My name is Nobody` → `my_name_is_nobody`.
33+
- **kebab-case**: separates each word with a dash character (`-`). The rendered result is lowercased.
34+
`My name is Nobody` → `my-name-is-nobody`.
35+
- **to-base64**: renders a valid UTF-16 string to it's Base64 encoded representation.
36+
`Hello there! Ā 文 🦄 ❤️` → `SGVsbG8gdGhlcmUhIMSAIOaWhyDwn6aEIOKdpO+4jw==`
37+
⚠ this may throw an Error if the input value is not a valid UTF-16 string. ⚠
38+
- **from-base64**: renders a valid Base64 string into it's UTF-16 decoded string representation.
39+
`SGVsbG8gdGhlcmUhIMSAIOaWhyDwn6aEIOKdpO+4jw==` → `Hello there! Ā 文 🦄 ❤️`
40+
⚠ this may throw an Error if the input value is not a valid Base64 string. ⚠
41+
- **camel-to-kebab**: renders a camel-cased string into it's kebab-case representation.
42+
- **pascal-to-kebab**: renders a pascal-cased string into it's kebab-case representation.
2443
- The values and options used/available within the "Control" column are for demonstration purpose only.
2544
- Use the "Reset controls" button on the top right side of the table in order to restore the initial demo values and options. Alternatively, you could reload the page.
2645

@@ -30,7 +49,7 @@ Notes:
3049
import { TextRenderer } from 'react-text-renderer-components';
3150
3251
const Example = () => {
33-
return <TextRenderer value={"SOME TEXT"} transform="lowercase" />
52+
return <TextRenderer value={"SOME TEXT"} transform="lower-case" />
3453
}`
3554
} />
3655

src/components/TextRenderer/TextRenderer.spec.tsx

Lines changed: 145 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -2,65 +2,175 @@ import React from "react";
22
import { render } from '@testing-library/react';
33
import { TextRenderer } from "./TextRenderer";
44

5+
// FIX "[ReferenceError: TextEncoder is not defined]" for Base64 transformations (see: https://stackoverflow.com/a/68468204)
6+
import { TextEncoder, TextDecoder } from 'util';
7+
Object.assign(global, { TextDecoder, TextEncoder });
8+
9+
const INVALID_CHARACTER_ERROR = "InvalidCharacterError";
10+
const INVALID_UTF16_ERROR_MSG = "The input value is not well formed: it is not a valid UTF-16 string, since it contains lone surrogates.";
11+
const INVALID_BASE64_ERROR_MSG = "The input value is not a valid BASE64 string and cannot be decoded.";
12+
13+
514
describe("TextRenderer component", () => {
615

7-
it("should render a text value to lowercase", () => {
16+
it("should render a 'spanned', lowercased text", () => {
817
const sourceText = "SOURCE TEXT";
9-
const r = render(<TextRenderer value={sourceText} transform="lowercase" pure />);
10-
expect(r.container.innerHTML).toEqual(sourceText.toLowerCase());
18+
const r = render(<TextRenderer value={sourceText} transform="lower-case" />);
19+
expect(r.container.innerHTML).toEqual(`<span>${sourceText.toLowerCase()}</span>`);
20+
});
21+
22+
it("should render an empty text when value is undefined or null", () => {
23+
const sourceTextUndefined = undefined;
24+
const sourceTextNull = null;
25+
expect.assertions(2);
26+
const r1 = render(<TextRenderer value={sourceTextUndefined} transform="upper-case" pure />);
27+
expect(r1.container.innerHTML).toEqual("");
28+
const r2 = render(<TextRenderer value={sourceTextNull} transform="upper-case" pure />);
29+
expect(r2.container.innerHTML).toEqual("");
30+
});
31+
32+
it("should render an umodified text value when transformation is invalid", () => {
33+
const sourceText = "I shall NOT be modified";
34+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
35+
const invalidTransformation: any = "I'm an invalid transformation";
36+
const r = render(<TextRenderer value={sourceText} transform={invalidTransformation} pure />);
37+
expect(r.container.innerHTML).toEqual(sourceText);
38+
});
39+
40+
it("should render an umodified text value when transformation is not defined or null", () => {
41+
const sourceText = "I shall NOT be modified";
42+
const invalidTransformationNull = null;
43+
const invalidTransformationUndefined = undefined;
44+
expect.assertions(4);
45+
const r1 = render(<TextRenderer value={sourceText} transform={invalidTransformationNull} pure />);
46+
expect(r1.container.innerHTML).toEqual(sourceText);
47+
const r2 = render(<TextRenderer value={sourceText} transform={invalidTransformationUndefined} pure />);
48+
expect(r2.container.innerHTML).toEqual(sourceText);
49+
const r3 = render(<TextRenderer value={sourceText} transform={null} pure />);
50+
expect(r3.container.innerHTML).toEqual(sourceText);
51+
const r4 = render(<TextRenderer value={sourceText} transform={undefined} pure />);
52+
expect(r4.container.innerHTML).toEqual(sourceText);
53+
});
54+
55+
it("protected method 'getFormatedText()' should return a string", () => {
56+
const sourceText = "SOURCE text";
57+
class TextRendererWrapper extends TextRenderer {
58+
public getFormatedText() {
59+
return super.getFormatedText();
60+
}
61+
}
62+
const component = new TextRendererWrapper({ value: sourceText, transform: "lower-case" });
63+
expect(component.getFormatedText()).toEqual(sourceText.toLowerCase());
64+
});
65+
66+
});
67+
describe("TextRenderer transformation", () => {
68+
69+
it("should render an umodified text value when transformation is 'no-op' (no-operation)", () => {
70+
const sourceText = "I shall NOT be modified";
71+
const r = render(<TextRenderer value={sourceText} transform="no-op" pure />);
72+
expect(r.container.innerHTML).toEqual(sourceText);
1173
});
1274

13-
it("should render a spanned, lowercased text", () => {
75+
it("should render a text value to lowercase", () => {
1476
const sourceText = "SOURCE TEXT";
15-
const r = render(<TextRenderer value={sourceText} transform="lowercase" />);
16-
expect(r.container.innerHTML).toEqual(`<span>${sourceText.toLowerCase()}</span>`);
77+
const r = render(<TextRenderer value={sourceText} transform="lower-case" pure />);
78+
expect(r.container.innerHTML).toEqual(sourceText.toLowerCase());
1779
});
1880

1981
it("should render a text value to uppercase", () => {
2082
const sourceText = "source text";
21-
const r = render(<TextRenderer value={sourceText} transform="uppercase" pure />);
83+
const r = render(<TextRenderer value={sourceText} transform="upper-case" pure />);
2284
expect(r.container.innerHTML).toEqual(sourceText.toUpperCase());
2385
});
2486

25-
it("should render an empty text when value is undefined", () => {
26-
const sourceText = undefined;
27-
const r = render(<TextRenderer value={sourceText} transform="noop" pure />);
28-
expect(r.container.innerHTML).toEqual("");
87+
it("should render a text value to camel-case", () => {
88+
const sourceText = "I Think Ruth's Dog maXIMUM is cuter than your-dog_John23!";
89+
const expected = "iThinkRuthSDogMaximumIsCuterThanYourDogJohn23";
90+
const r = render(<TextRenderer value={sourceText} transform="camel-case" pure />);
91+
expect(r.container.innerHTML).toEqual(expected);
2992
});
3093

31-
it("should render an empty text when value is null", () => {
32-
const sourceText = null;
33-
const r = render(<TextRenderer value={sourceText} transform="noop" pure />);
34-
expect(r.container.innerHTML).toEqual("");
94+
it("should render a text value to pascal-case", () => {
95+
const sourceText = "I Think Ruth's Dog maXIMUM is cuter than your-dog_John23!";
96+
const expected = "IThinkRuthSDogMaximumIsCuterThanYourDogJohn23";
97+
const r = render(<TextRenderer value={sourceText} transform="pascal-case" pure />);
98+
expect(r.container.innerHTML).toEqual(expected);
3599
});
36100

37-
it("should render an umodified text value when transformation is 'noop' (no-operation)", () => {
38-
const sourceText = "SOURCE text";
39-
const r = render(<TextRenderer value={sourceText} transform="noop" pure />);
40-
expect(r.container.innerHTML).toEqual(sourceText);
101+
it("should render a text value to kebab-case", () => {
102+
const sourceText = "I Think Ruth's Dog maXIMUM is cuter than your-dog_John23!";
103+
const expected = "i-think-ruth-s-dog-maximum-is-cuter-than-your-dog-john23";
104+
const r = render(<TextRenderer value={sourceText} transform="kebab-case" pure />);
105+
expect(r.container.innerHTML).toEqual(expected);
41106
});
42107

43-
it("should render an umodified text value when transformation is null", () => {
44-
const sourceText = null;
45-
const r = render(<TextRenderer value={sourceText} transform={null} pure />);
46-
expect(r.container.innerHTML).toEqual("");
108+
it("should render a text value to snake-case", () => {
109+
const sourceText = "I Think Ruth's Dog maXIMUM is cuter than your-dog_John23!";
110+
const expected = "I_Think_Ruth_s_Dog_maXIMUM_is_cuter_than_your_dog_John23";
111+
const r = render(<TextRenderer value={sourceText} transform="snake-case" pure />);
112+
expect(r.container.innerHTML).toEqual(expected);
47113
});
48114

49-
it("should render an umodified text value when transformation is undefined", () => {
50-
const sourceText = null;
51-
const r = render(<TextRenderer value={sourceText} transform={undefined} pure />);
52-
expect(r.container.innerHTML).toEqual("");
115+
it("should render a text value in camel-case to kebab-case", () => {
116+
const sourceText = "iThinkRuthSDogMaximumIsCuterThanYourDogJohn23";
117+
const expected = "i-think-ruth-s-dog-maximum-is-cuter-than-your-dog-john23";
118+
const r = render(<TextRenderer value={sourceText} transform="camel-to-kebab" pure />);
119+
expect(r.container.innerHTML).toEqual(expected);
53120
});
54121

55-
it("protected method 'getFormatedText()' should return a string", () => {
56-
const sourceText = "SOURCE text";
57-
class TextRendererWrapper extends TextRenderer {
58-
public getFormatedText() {
59-
return super.getFormatedText();
60-
}
122+
it("should render a text value in pascal-case to kebab-case", () => {
123+
const sourceText = "IThinkRuthSDogMaximumIsCuterThanYourDogJohn23";
124+
const expected = "i-think-ruth-s-dog-maximum-is-cuter-than-your-dog-john23";
125+
const r = render(<TextRenderer value={sourceText} transform="pascal-to-kebab" pure />);
126+
expect(r.container.innerHTML).toEqual(expected);
127+
});
128+
129+
it("should decode a Base64 string value to a UTF-16 string", () => {
130+
const sourceText = "YSDEgCDwkICAIOaWhyDwn6aE";
131+
const expected = "a Ā 𐀀 文 🦄";
132+
const r = render(<TextRenderer value={sourceText} transform="from-base64" pure />);
133+
expect(r.container.innerHTML).toEqual(expected);
134+
});
135+
136+
it("should encode an UTF-16 text value to a Base64 string", () => {
137+
const sourceText = "a Ā 𐀀 文 🦄";
138+
const expected = "YSDEgCDwkICAIOaWhyDwn6aE";
139+
const r = render(<TextRenderer value={sourceText} transform="to-base64" pure />);
140+
expect(r.container.innerHTML).toEqual(expected);
141+
});
142+
143+
it("should throw when attempting to encode an invalid UTF-16 text value to a Base64 string", () => {
144+
const invalidSourceText = "hello⛳❤️🧀\uDE75";
145+
const spy = jest.spyOn(console, 'error');
146+
spy.mockImplementation(() => { });
147+
148+
expect.assertions(3);
149+
try {
150+
render(<TextRenderer value={invalidSourceText} transform="to-base64" pure />);
151+
} catch (error) {
152+
expect(error).toBeInstanceOf(DOMException);
153+
expect(error).toHaveProperty('message', INVALID_UTF16_ERROR_MSG);
154+
expect(error).toHaveProperty('name', INVALID_CHARACTER_ERROR);
155+
}
156+
spy.mockRestore();
157+
});
158+
159+
it("should throw when attempting to decode an invalid Base64 text value to a UTF-16 string", () => {
160+
const invalidSourceText = "BANZAI!";
161+
const spy = jest.spyOn(console, 'error');
162+
spy.mockImplementation(() => { });
163+
164+
expect.assertions(3);
165+
try {
166+
render(<TextRenderer value={invalidSourceText} transform="from-base64" pure />);
167+
} catch (error) {
168+
expect(error).toBeInstanceOf(DOMException);
169+
expect(error).toHaveProperty('message', INVALID_BASE64_ERROR_MSG);
170+
expect(error).toHaveProperty('name', INVALID_CHARACTER_ERROR);
61171
}
62-
const component = new TextRendererWrapper({ value: sourceText, transform: "noop" });
63-
expect(component.getFormatedText()).toEqual(sourceText);
172+
spy.mockRestore();
64173
});
65174

175+
66176
});
Lines changed: 81 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,103 @@
1-
import React from 'react';
1+
import React, { Component, ReactNode } from "react";
2+
import { useArgs } from '@storybook/preview-api';
23
import { Meta, StoryObj } from '@storybook/react';
3-
import { TextRenderer } from './TextRenderer';
4+
import { ITextRendererProps, TextRenderer, TextTransformations } from './TextRenderer';
45
import { RemoveKeyAttribute } from "../../../.storybook/utils";
56

6-
const testtext = "some text";
7+
const testtext = "fancy-TEXT with_Unicode CharPoints Ā𐀀文🦄";
8+
// BASE64 = ZmFuY3ktVEVYVCB3aXRoX1VuaWNvZGUgQ2hhclBvaW50cyDEgPCQgIDmlofwn6aE
79

810
// +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
911

1012
const meta = {
11-
title: 'Components/TextRenderer',
12-
component: TextRenderer,
13-
argTypes: {
14-
value: {
15-
control: "text",
16-
description: "The text **value** (string) which should be transformed and rendered.",
13+
title: 'Components/TextRenderer',
14+
component: TextRenderer,
15+
argTypes: {
16+
value: {
17+
control: "text",
18+
description: "The text **value** (string) which should be transformed and rendered.",
19+
},
20+
pure: {
21+
control: "boolean",
22+
description: "If **pure** is set/defined, then the component will render nothing but the 'pure' text representation of the value. Otherwise, the text representation is embeded within a *&lt;span&gt;&lt;/span&gt;* tag.",
23+
},
24+
transform: {
25+
control: "select",
26+
description: "The type of transformation to be applied to the input values.",
27+
options: TextTransformations,
28+
}
1729
},
18-
pure: {
19-
control: "boolean",
20-
description: "If **pure** is set/defined, then the component will render nothing but the 'pure' text representation of the value. Otherwise, the text representation is embeded within a *&lt;span&gt;&lt;/span&gt;* tag.",
30+
parameters: {
31+
docs: {
32+
controls: { sort: "requiredFirst" },
33+
source: { language: 'tsx', type: 'auto', transform: RemoveKeyAttribute },
34+
description: { component: "The **TextRenderer** component renders an input *value* of type `string` to a pure/spanned representation showing the transformed text as defined by the `transform` attribute of the component." },
35+
},
36+
layout: 'centered'
2137
},
22-
transform: {
23-
control: "select",
24-
description: "The type of transformation to be applied to the input values.",
25-
options: ["noop", "lowercase", "uppercase"],
26-
}
27-
},
28-
parameters: {
29-
docs: {
30-
controls: { sort: "requiredFirst" },
31-
source: { language: 'tsx', type: 'auto', transform: RemoveKeyAttribute },
32-
description: { component: "The **TextRenderer** component renders an input *value* of type `string` to a pure/spanned representation showing the transformed text as defined by the `transform` attribute of the component." },
33-
},
34-
layout: 'centered'
35-
},
36-
render: RenderTextRenderer
38+
render: RenderTextRenderer
3739
} satisfies Meta<typeof TextRenderer>;
3840

3941
export default meta;
4042
type Story = StoryObj<typeof meta>;
4143

4244
// +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4345

44-
function RenderTextRenderer(args) {
45-
return <TextRenderer key={Math.random()} {...args} />
46+
interface ErrorBoundaryProps { children?: ReactNode; fallback: () => void; }
47+
interface ErrorState { hasError: boolean; error?: Error; }
48+
49+
class ErrorBoundary extends Component<ErrorBoundaryProps, ErrorState> {
50+
51+
state: ErrorState = { hasError: false, error: null };
52+
53+
constructor(props) {
54+
super(props);
55+
}
56+
57+
static getDerivedStateFromError(error: Error): ErrorState {
58+
return { hasError: true, error };
59+
}
60+
61+
componentDidCatch(error: Error) {
62+
console.error(`[CAPTURED] ${error.name}: ${error.message}`);
63+
}
64+
65+
render() {
66+
if (this.state.hasError) {
67+
return (<>
68+
<style>{`.errordisplay { padding: 15px; text-align: center; }`}</style>
69+
<div className='errordisplay' >
70+
<h2>{this.state.error.name ?? "UNKNOWN ERROR"}</h2>
71+
<h3>{this.state.error.message ?? "(no description available)"}</h3>
72+
<button onClick={() => this.props.fallback()}>reload this component</button>
73+
</div>
74+
</>);
75+
} else {
76+
return this.props.children;
77+
}
78+
}
79+
}
80+
81+
function RenderTextRenderer(args: ITextRendererProps) {
82+
const [, , resetArgs] = useArgs<ITextRendererProps>();
83+
const key = Math.random();
84+
return (
85+
<ErrorBoundary key={key} fallback={resetArgs}>
86+
<TextRenderer key={key} {...args} />
87+
</ErrorBoundary>
88+
);
4689
}
4790

91+
92+
4893
// +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4994

5095
export const Playground = {
51-
name: "Playground",
52-
args: {
53-
value: testtext,
54-
pure: true,
55-
transform: "noop"
56-
},
57-
tags: ['!dev']
96+
name: "Playground",
97+
args: {
98+
value: testtext,
99+
pure: true,
100+
transform: "no-op"
101+
},
102+
tags: ['!dev']
58103
} satisfies Story;

0 commit comments

Comments
 (0)