Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 33 additions & 20 deletions demo/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,34 +5,46 @@ import Terminal, { ColorMode, TerminalInput, TerminalOutput } from '../src/index
import './style.css';

const TerminalController = (props = {}) => {
const [isPasswordMode, setIsPasswordMode] = useState<boolean>(false);
const [colorMode, setColorMode] = useState(ColorMode.Dark);
const [lineData, setLineData] = useState([
<TerminalOutput>Welcome to the React Terminal UI Demo!&#128075;</TerminalOutput>,
<TerminalOutput></TerminalOutput>,
<TerminalOutput>The following example commands are provided:</TerminalOutput>,
<TerminalOutput>'view-source' will navigate to the React Terminal UI github source.</TerminalOutput>,
<TerminalOutput>'view-react-docs' will navigate to the react docs.</TerminalOutput>,
<TerminalOutput>'login' will show input password with "*" instead of real string</TerminalOutput>,
<TerminalOutput>'clear' will clear the terminal.</TerminalOutput>,
]);

function toggleColorMode (e: MouseEvent) {
function toggleColorMode(e: MouseEvent) {
e.preventDefault();
setColorMode(colorMode === ColorMode.Light ? ColorMode.Dark : ColorMode.Light);
}

function onInput (input: string) {
let ld = [...lineData];
function onInput(input: string) {
let ld = [...lineData];
if (isPasswordMode) {
ld.push(<TerminalInput>{'*'.repeat(input.length)}</TerminalInput>);
ld.push(<TerminalOutput>Your password received successfully</TerminalOutput>);
setIsPasswordMode(false);
setLineData(ld);
} else {
ld.push(<TerminalInput>{input}</TerminalInput>);
if (input.toLocaleLowerCase().trim() === 'view-source') {
window.open('https://github.com/jonmbake/react-terminal-ui', '_blank');
} else if (input.toLocaleLowerCase().trim() === 'view-react-docs') {
window.open('https://reactjs.org/docs/getting-started.html', '_blank');
} else if (input.toLocaleLowerCase().trim() === 'clear') {
ld = [];
} else if (input) {
ld.push(<TerminalOutput>Unrecognized command</TerminalOutput>);
if (input.toLocaleLowerCase().trim() === 'view-source') {
window.open('https://github.com/jonmbake/react-terminal-ui', '_blank');
} else if (input.toLocaleLowerCase().trim() === 'view-react-docs') {
window.open('https://reactjs.org/docs/getting-started.html', '_blank');
} else if (input.toLocaleLowerCase().trim() === 'clear') {
ld = [];
} else if (input.toLocaleLowerCase().trim() === 'login') {
ld.push(<TerminalOutput>Please enter your password:</TerminalOutput>);
setIsPasswordMode(true);
} else if (input) {
ld.push(<TerminalOutput>Unrecognized command</TerminalOutput>);
}
setLineData(ld);
}
setLineData(ld);
}

const redBtnClick = () => {
Expand All @@ -56,15 +68,16 @@ const TerminalController = (props = {}) => {
return (
<div className="container" >
<div className="d-flex flex-row-reverse p-2">
<button className={ btnClasses.join(' ') } onClick={ toggleColorMode } >Enable { colorMode === ColorMode.Light ? 'Dark' : 'Light' } Mode</button>
<button className={btnClasses.join(' ')} onClick={toggleColorMode} >Enable {colorMode === ColorMode.Light ? 'Dark' : 'Light'} Mode</button>
</div>
<Terminal
name='React Terminal UI'
colorMode={ colorMode }
onInput={ onInput }
redBtnCallback={ redBtnClick }
yellowBtnCallback={ yellowBtnClick }
greenBtnCallback={ greenBtnClick }
<Terminal
name='React Terminal UI'
colorMode={colorMode}
onInput={onInput}
redBtnCallback={redBtnClick}
yellowBtnCallback={yellowBtnClick}
greenBtnCallback={greenBtnClick}
passwordField={isPasswordMode}
>
{lineData}
</Terminal>
Expand Down
5 changes: 4 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export interface Props {
children?: ReactNode;
onInput?: ((input: string) => void) | null | undefined;
startingInputValue?: string;
passwordField?: boolean;
redBtnCallback?: () => void;
yellowBtnCallback?: () => void;
greenBtnCallback?: () => void;
Expand All @@ -42,6 +43,7 @@ const Terminal = ({
onInput,
children,
startingInputValue = "",
passwordField = false,
redBtnCallback,
yellowBtnCallback,
greenBtnCallback,
Expand Down Expand Up @@ -256,7 +258,7 @@ const Terminal = ({
data-terminal-prompt={prompt || "$"}
key="terminal-line-prompt"
>
{currentLineInput}
{passwordField ? "*".repeat(currentLineInput.length) : currentLineInput}
<span
className="cursor"
style={{ left: `${cursorPos + 1}px` }}
Expand All @@ -269,6 +271,7 @@ const Terminal = ({
className="terminal-hidden-input"
placeholder="Terminal Hidden Input"
value={currentLineInput}
type={passwordField ? "password" : "text"}
autoFocus={onInput != null}
onChange={updateCurrentLineInput}
onKeyDown={handleInputKeyDown}
Expand Down
63 changes: 52 additions & 11 deletions tests/index.spec.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import React from 'react';
import React, { useState } from 'react';
import Terminal, { ColorMode, TerminalInput, TerminalOutput } from '../src/index';
import { render, fireEvent, screen } from '@testing-library/react';
import '@testing-library/jest-dom';
Expand All @@ -14,14 +14,14 @@ describe('Terminal component', () => {
})

test('Should render prompt', () => {
const { container } = render(<Terminal onInput={ (input: string) => '' } />);
const { container } = render(<Terminal onInput={(input: string) => ''} />);
expect(container.querySelectorAll('.react-terminal-line')).toHaveLength(1);
expect(container.querySelector('.react-terminal-line.react-terminal-active-input[data-terminal-prompt="$"]')).not.toBeNull();
expect(screen.getByPlaceholderText('Terminal Hidden Input')).toBeInTheDocument();
});

test('Should not render prompt if onInput prop is null or not defined', () => {
const { container } = render(<Terminal onInput={ null }><TerminalOutput>Some terminal output</TerminalOutput></Terminal>);
const { container } = render(<Terminal onInput={null}><TerminalOutput>Some terminal output</TerminalOutput></Terminal>);
// Still renders output line...
expect(container.querySelectorAll('.react-terminal-line')).toHaveLength(1);
// ... but not the prompt
Expand All @@ -30,7 +30,7 @@ describe('Terminal component', () => {

test('Should render terminal lines', () => {
const { container } = render(
<Terminal onInput={ (input: string) => '' }>
<Terminal onInput={(input: string) => ''}>
<TerminalInput>Some terminal input</TerminalInput>,
<TerminalOutput>Some terminal output</TerminalOutput>
</Terminal>
Expand All @@ -44,7 +44,7 @@ describe('Terminal component', () => {

test('Input prompt should not scroll into view when component first loads', () => {
render(
<Terminal onInput={ (input: string) => '' }>
<Terminal onInput={(input: string) => ''}>
<TerminalInput>Some terminal input</TerminalInput>,
<TerminalOutput>Some terminal output</TerminalOutput>
</Terminal>
Expand All @@ -55,26 +55,26 @@ describe('Terminal component', () => {

test('Should accept input and scroll into view', () => {
const onInput = jest.fn();
const { rerender } = render(<Terminal onInput={ onInput }/>);
const { rerender } = render(<Terminal onInput={onInput} />);
const hiddenInput = screen.getByPlaceholderText('Terminal Hidden Input');
fireEvent.change(hiddenInput, { target: { value: 'a' } });
expect(screen.getByText('a').className).toEqual('react-terminal-line react-terminal-input react-terminal-active-input');
screen.getByDisplayValue('a');
expect(onInput.mock.calls.length).toEqual(0);
fireEvent.keyDown(hiddenInput, { key: 'Enter', code: 'Enter' });
expect(onInput).toHaveBeenCalledWith('a');
rerender(<Terminal onInput={ onInput }><TerminalInput>a</TerminalInput></Terminal>)
rerender(<Terminal onInput={onInput}><TerminalInput>a</TerminalInput></Terminal>)
jest.runAllTimers();
expect(scrollIntoViewFn).toHaveBeenCalledTimes(1);
});

test('Should support changing color mode', () => {
const { container } = render(<Terminal colorMode={ ColorMode.Light } onInput={ (input: string) => '' }/>);
const { container } = render(<Terminal colorMode={ColorMode.Light} onInput={(input: string) => ''} />);
expect(container.querySelector('.react-terminal-wrapper.react-terminal-light')).not.toBeNull();
});

test('Should focus if onInput is defined', () => {
const { container } = render(<Terminal onInput={ (input: string) => '' }/>)
const { container } = render(<Terminal onInput={(input: string) => ''} />)
expect(container.ownerDocument.activeElement?.nodeName).toEqual('INPUT');
expect(container.ownerDocument.activeElement?.className).toEqual('terminal-hidden-input');
});
Expand All @@ -85,7 +85,7 @@ describe('Terminal component', () => {
});

test('Should take starting input value', () => {
render(<Terminal onInput={ (input: string) => '' } startingInputValue="cat file.txt " />)
render(<Terminal onInput={(input: string) => ''} startingInputValue="cat file.txt " />)
const renderedLine = screen.getByText('cat file.txt');
expect(renderedLine.className).toContain('react-terminal-line');
});
Expand All @@ -96,7 +96,48 @@ describe('Terminal component', () => {
});

test('Should not render top button panel if null props passed', () => {
const { container } = render(<Terminal TopButtonsPanel={()=> null} />);
const { container } = render(<Terminal TopButtonsPanel={() => null} />);
expect(container.querySelector('.react-terminal-window-buttons')).toBeNull();
});

test('renders input as password and handles masked input', () => {
const TerminalWrapper = () => {
const [lines, setLines] = useState<React.ReactNode[]>([
<TerminalOutput key="welcome">Welcome!</TerminalOutput>
]);
const [isPasswordMode, setIsPasswordMode] = useState(true);

return (
<Terminal
passwordField={isPasswordMode}
onInput={(input: string) => {
const newLines = [...lines];
if (isPasswordMode) {
newLines.push(<TerminalInput key="masked">{'*'.repeat(input.length)}</TerminalInput>);
newLines.push(<TerminalOutput key="success">Password received</TerminalOutput>);
setIsPasswordMode(false);
} else {
newLines.push(<TerminalInput key={input}>{input}</TerminalInput>);
}
setLines(newLines);
}}
>
{lines}
</Terminal>
);
};

render(<TerminalWrapper />);

const hiddenInput = screen.getByPlaceholderText('Terminal Hidden Input') as HTMLInputElement;

expect(hiddenInput.type).toBe('password');

fireEvent.change(hiddenInput, { target: { value: 'mypassword' } });
expect(hiddenInput.value).toBe('mypassword');

fireEvent.keyDown(hiddenInput, { key: 'Enter', code: 'Enter' });

expect(screen.getByText('Password received')).toBeInTheDocument();
});
});
Loading