Skip to content

Commit

Permalink
Add <Transform> component (#277)
Browse files Browse the repository at this point in the history
This component allows transforming a string representation of React components
before they are written to output. It's not a new functionality, previously it
was exposed via undocumented `unstable__transformChildren` prop in <Box> and
<Text> components.

I think it has been proven to be stable and there are definitely use cases
for it, so it's time to let anyone leverage this feature.

Now deprecated `unstable_transformChildren` prop is still going to be available
and work as before to maintain backwards compatibility. However, it will be
removed in future major versions.
  • Loading branch information
Vadim Demedes committed Apr 26, 2020
1 parent 4aefaa0 commit 9ed46a5
Show file tree
Hide file tree
Showing 12 changed files with 175 additions and 53 deletions.
3 changes: 2 additions & 1 deletion package.json
Expand Up @@ -134,7 +134,8 @@
"error",
{
"allow": [
"^unstable__"
"^unstable__",
"^internal_"
]
}
],
Expand Down
28 changes: 28 additions & 0 deletions readme.md
Expand Up @@ -673,6 +673,34 @@ Jest continuously writes the list of completed tests to the output, while updati
See [examples/jest](examples/jest/jest.js) for a basic implementation of Jest's UI.

#### `<Transform>`

Transform a string representation of React components before they are written to output.
For example, you might want to apply a [gradient to text](https://github.com/sindresorhus/ink-gradient), [add a clickable link](https://github.com/sindresorhus/ink-link) or [create some text effects](https://github.com/sindresorhus/ink-big-text).
These use cases can't accept React nodes as input, they are expecting a string.
That's what `<Transform>` component does, it gives you an output string of its child components and lets you transform it in any way.

```jsx
<Transform transform={output => output.toUpperCase()}>
<Text>Hello World</Text>
</Transform>
```

Since `transform` function converts all characters to upper case, final output that's rendered to the terminal will be "HELLO WORLD", not "Hello World".

##### transform(children)

Type: `Function`

Function which transforms children output.
It accepts children and must return transformed children too.

##### children

Type: `string`

Output of child components.

#### `<AppContext>`

`<AppContext>` is a [React context](https://reactjs.org/docs/context.html#reactcreatecontext), which exposes a method to manually exit the app (unmount).
Expand Down
2 changes: 1 addition & 1 deletion src/components/Box.tsx
Expand Up @@ -80,7 +80,7 @@ export class Box extends PureComponent<BoxProps> {
ref={this.nodeRef}
style={style}
// @ts-ignore
unstable__transformChildren={unstable__transformChildren}
internal_transform={unstable__transformChildren}
>
{children}
</div>
Expand Down
13 changes: 3 additions & 10 deletions src/components/Color.tsx
Expand Up @@ -3,6 +3,7 @@ import PropTypes from 'prop-types';
import arrify from 'arrify';
import chalk, {Chalk} from 'chalk';
import {Except} from 'type-fest';
import {Transform} from './Transform';

type Colors =
| typeof chalk.ForegroundColor
Expand Down Expand Up @@ -50,7 +51,7 @@ export const Color: FC<ColorProps> = ({children, ...colorProps}) => {
return null;
}

const transformChildren = (children: ReactNode) => {
const transform = (children: ReactNode) => {
// @ts-ignore
Object.keys(colorProps).forEach((method: keyof ChalkProps) => {
if (colorProps[method]) {
Expand All @@ -67,15 +68,7 @@ export const Color: FC<ColorProps> = ({children, ...colorProps}) => {
return children;
};

return (
<span
style={{flexDirection: 'row'}}
// @ts-ignore
unstable__transformChildren={transformChildren}
>
{children}
</span>
);
return <Transform transform={transform}>{children}</Transform>;
};

Color.propTypes = {
Expand Down
15 changes: 4 additions & 11 deletions src/components/Text.tsx
@@ -1,6 +1,7 @@
import React, {FC, ReactNode} from 'react';
import PropTypes from 'prop-types';
import chalk from 'chalk';
import {Transform} from './Transform';

export interface TextProps {
readonly bold?: boolean;
Expand All @@ -22,7 +23,7 @@ export const Text: FC<TextProps> = ({
children,
unstable__transformChildren
}) => {
const transformChildren = (children: ReactNode) => {
const transform = (children: ReactNode) => {
if (bold) {
children = chalk.bold(children);
}
Expand All @@ -39,22 +40,14 @@ export const Text: FC<TextProps> = ({
children = chalk.strikethrough(children);
}

if (unstable__transformChildren) {
if (typeof unstable__transformChildren === 'function') {
children = unstable__transformChildren(children);
}

return children;
};

return (
<span
style={{flexDirection: 'row'}}
// @ts-ignore
unstable__transformChildren={transformChildren}
>
{children}
</span>
);
return <Transform transform={transform}>{children}</Transform>;
};

/* eslint-disable react/boolean-prop-naming */
Expand Down
25 changes: 25 additions & 0 deletions src/components/Transform.tsx
@@ -0,0 +1,25 @@
import React, {FC, ReactNode} from 'react';
import PropTypes from 'prop-types';

export interface TransformProps {
readonly transform: (children: ReactNode) => ReactNode;
readonly children: ReactNode;
}

/*
* Transform a string representation of React components before they are written to output.
*/
export const Transform: FC<TransformProps> = ({children, transform}) => (
<span
style={{flexDirection: 'row'}}
// @ts-ignore
internal_transform={transform}
>
{children}
</span>
);

Transform.propTypes = {
transform: PropTypes.func.isRequired,
children: PropTypes.node.isRequired
};
2 changes: 1 addition & 1 deletion src/dom.ts
Expand Up @@ -24,7 +24,7 @@ export type DOMElement = {
};
textContent?: string;
childNodes: DOMNode[];
unstable__transformChildren?: OutputTransformer;
internal_transform?: OutputTransformer;
onRender?: () => void;

// Experimental properties
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Expand Up @@ -6,6 +6,7 @@ export {AppContext, AppContextProps} from './components/AppContext';
export {StdinContext, StdinContextProps} from './components/StdinContext';
export {StdoutContext, StdoutContextProps} from './components/StdoutContext';
export {Static} from './components/Static';
export {Transform, TransformProps} from './components/Transform';
export {useInput, Key} from './hooks/use-input';
export {useApp} from './hooks/use-app';
export {useStdin} from './hooks/use-stdin';
Expand Down
8 changes: 4 additions & 4 deletions src/reconciler.ts
Expand Up @@ -93,8 +93,8 @@ export const reconciler = createReconciler<
}
} else if (key === 'style') {
setStyle(node, value);
} else if (key === 'unstable__transformChildren') {
node.unstable__transformChildren = value;
} else if (key === 'internal_transform') {
node.internal_transform = value;
} else if (key === 'unstable__static') {
node.unstable__static = true;
} else {
Expand Down Expand Up @@ -176,8 +176,8 @@ export const reconciler = createReconciler<
}
} else if (key === 'style') {
setStyle(node, value);
} else if (key === 'unstable__transformChildren') {
node.unstable__transformChildren = value;
} else if (key === 'internal_transform') {
node.internal_transform = value;
} else if (key === 'unstable__static') {
node.unstable__static = true;
} else {
Expand Down
8 changes: 4 additions & 4 deletions src/render-node-to-output.ts
Expand Up @@ -57,8 +57,8 @@ const squashTextNodes = (node: DOMElement): string => {

// Since these text nodes are being concatenated, `Output` instance won't be able to
// apply children transform, so we have to do it manually here for each text node
if (childNode.unstable__transformChildren) {
nodeText = childNode.unstable__transformChildren(nodeText);
if (typeof childNode.internal_transform === 'function') {
nodeText = childNode.internal_transform(nodeText);
}
}

Expand Down Expand Up @@ -115,8 +115,8 @@ export const renderNodeToOutput = (
return;
}

if (node.unstable__transformChildren) {
newTransformers = [node.unstable__transformChildren, ...transformers];
if (typeof node.internal_transform === 'function') {
newTransformers = [node.internal_transform, ...transformers];
}

// Nodes with only text inside
Expand Down
58 changes: 37 additions & 21 deletions test/components.tsx
Expand Up @@ -6,7 +6,15 @@ import chalk from 'chalk';
import {spy} from 'sinon';
import {renderToString} from './helpers/render-to-string';
import {run} from './helpers/run';
import {Box, Color, Text, Static, StdinContext, render} from '../src';
import {
Box,
Color,
Text,
Static,
StdinContext,
Transform,
render
} from '../src';

test('text', t => {
const output = renderToString(<Box>Hello World</Box>);
Expand Down Expand Up @@ -120,48 +128,56 @@ test('fragment', t => {

test('transform children', t => {
const output = renderToString(
<Box unstable__transformChildren={(string: string) => `[${string}]`}>
<Box unstable__transformChildren={(string: string) => `{${string}}`}>
test
<Transform transform={(string: string) => `[${string}]`}>
<Box>
<Transform transform={(string: string) => `{${string}}`}>
<Box>test</Box>
</Transform>
</Box>
</Box>
</Transform>
);

t.is(output, '[{test}]');
});

test('squash multiple text nodes', t => {
const output = renderToString(
<Box unstable__transformChildren={(string: string) => `[${string}]`}>
<Box unstable__transformChildren={(string: string) => `{${string}}`}>
hello world
</Box>
</Box>
<Transform transform={(string: string) => `[${string}]`}>
<Text>
<Transform transform={(string: string) => `{${string}}`}>
<Text>hello world</Text>
</Transform>
</Text>
</Transform>
);

t.is(output, '[{hello world}]');
});

test('squash multiple nested text nodes', t => {
const output = renderToString(
<Box unstable__transformChildren={(string: string) => `[${string}]`}>
<Box unstable__transformChildren={(string: string) => `{${string}}`}>
hello
<Text> world</Text>
</Box>
</Box>
<Transform transform={(string: string) => `[${string}]`}>
<Text>
<Transform transform={(string: string) => `{${string}}`}>
hello
<Text> world</Text>
</Transform>
</Text>
</Transform>
);

t.is(output, '[{hello world}]');
});

test('squash empty `<Text>` nodes', t => {
const output = renderToString(
<Box unstable__transformChildren={(string: string) => `[${string}]`}>
<Box unstable__transformChildren={(string: string) => `{${string}}`}>
<Text>{[]}</Text>
</Box>
</Box>
<Transform transform={(string: string) => `[${string}]`}>
<Text>
<Transform transform={(string: string) => `{${string}}`}>
<Text>{[]}</Text>
</Transform>
</Text>
</Transform>
);

t.is(output, '');
Expand Down
65 changes: 65 additions & 0 deletions test/deprecated.tsx
@@ -0,0 +1,65 @@
import React from 'react';
import test from 'ava';
import {renderToString} from './helpers/render-to-string';
import {Box, Text} from '../src';

test('transform children of <Box>', t => {
const output = renderToString(
<Box unstable__transformChildren={(string: string) => `[${string}]`}>
<Box unstable__transformChildren={(string: string) => `{${string}}`}>
test
</Box>
</Box>
);

t.is(output, '[{test}]');
});

test('transform children of <Text>', t => {
const output = renderToString(
<Text unstable__transformChildren={(string: string) => `[${string}]`}>
<Text unstable__transformChildren={(string: string) => `{${string}}`}>
test
</Text>
</Text>
);

t.is(output, '[{test}]');
});

test('squash multiple text nodes', t => {
const output = renderToString(
<Box unstable__transformChildren={(string: string) => `[${string}]`}>
<Box unstable__transformChildren={(string: string) => `{${string}}`}>
hello world
</Box>
</Box>
);

t.is(output, '[{hello world}]');
});

test('squash multiple nested text nodes', t => {
const output = renderToString(
<Box unstable__transformChildren={(string: string) => `[${string}]`}>
<Box unstable__transformChildren={(string: string) => `{${string}}`}>
hello
<Text> world</Text>
</Box>
</Box>
);

t.is(output, '[{hello world}]');
});

test('squash empty `<Text>` nodes', t => {
const output = renderToString(
<Box unstable__transformChildren={(string: string) => `[${string}]`}>
<Box unstable__transformChildren={(string: string) => `{${string}}`}>
<Text>{[]}</Text>
</Box>
</Box>
);

t.is(output, '');
});

0 comments on commit 9ed46a5

Please sign in to comment.