Skip to content

Commit

Permalink
Merge pull request #1238 from botpress/sp_emulator
Browse files Browse the repository at this point in the history
feat(studio): New chat Emulator
  • Loading branch information
slvnperron committed Dec 18, 2018
2 parents 34b2df6 + b2791ca commit b73ca7c
Show file tree
Hide file tree
Showing 13 changed files with 1,296 additions and 532 deletions.
2 changes: 2 additions & 0 deletions src/bp/ui-studio/package.json
Expand Up @@ -20,6 +20,7 @@
"mustache": "^2.3.0",
"nanoid": "^1.0.1",
"query-string": "^5.0.1",
"react-json-tree": "^0.11.0",
"react-jsonschema-form": "^1.0.3",
"react-loaders": "^3.0.1",
"react-router": "4.3.1",
Expand Down Expand Up @@ -64,6 +65,7 @@
"react": "^16.4.0",
"react-bootstrap": "^0.32.1",
"react-bootstrap-button-loader": "^1.0.11",
"react-dock": "^0.2.4",
"react-dom": "^16.4.0",
"react-emoji": "^0.4.4",
"react-fontawesome": "^1.2.0",
Expand Down
44 changes: 44 additions & 0 deletions src/bp/ui-studio/src/web/components/ChatEmulator/Dock.jsx
@@ -0,0 +1,44 @@
import React from 'react'
import Dock from 'react-dock'
import { HotKeys } from 'react-hotkeys'
import Emulator from './Emulator'
import { keyMap } from '~/keyboardShortcuts'

import style from './Dock.styl'

export default class EmulatorDock extends React.Component {
state = {
size: 500
}

handleSizeChange = e => this.setState({ size: Math.min(Math.max(100, e), 1000) }) // [100, 1000] px

keyHandlers = {
cancel: () => this.props.onToggle()
}

render() {
return (
<Dock
position="right"
isVisible={this.props.isOpen}
fluid={false}
zIndex={5}
dimMode="none"
duration={0}
size={this.state.size}
dockStyle={{ transition: 'none' }}
onSizeChange={this.handleSizeChange}
>
<HotKeys keyMap={keyMap} handlers={this.keyHandlers}>
<div className={style.container} tabIndex={-1}>
<div className={style.titleBar} onClick={this.props.onToggle}>
Chat Emulator
</div>
<Emulator isDockOpen={this.props.isOpen} />
</div>
</HotKeys>
</Dock>
)
}
}
33 changes: 33 additions & 0 deletions src/bp/ui-studio/src/web/components/ChatEmulator/Dock.styl
@@ -0,0 +1,33 @@
.container {
padding: 50px 0 24px 0;
height: 100%;
position: absolute;
width: 100%;

&:focus-within {
.titleBar {
background-color: var(--c-brand);

&:hover {
background-color: var(--c-brand--dark-1);
}
}
}

.titleBar {
height: 24px;
background-color: var(--c-background--dark-1);
width: 100%;
color: var(--c-text--light);
line-height: 24px;
font-size: 14px;
font-weight: bold;
padding-left: 10px;
user-select: none;
cursor: pointer;

&:hover {
background-color: var(--c-background--dark-2);
}
}
}
226 changes: 226 additions & 0 deletions src/bp/ui-studio/src/web/components/ChatEmulator/Emulator.jsx
@@ -0,0 +1,226 @@
import React from 'react'
import axios from 'axios'
import Promise from 'bluebird'
import SplitPane from 'react-split-pane'
import JSONTree from 'react-json-tree'
import _ from 'lodash'
import nanoid from 'nanoid'
import { Button } from 'react-bootstrap'

import { Glyphicon } from 'react-bootstrap'

import classnames from 'classnames'

import inspectorTheme from './inspectorTheme'
import Message from './Message'

import style from './Emulator.styl'

const USER_ID_KEY = `bp::${window.BOT_ID}::emulator::userId`
const SENT_HISTORY_KEY = `bp::${window.BOT_ID}::emulator::sentHistory`
const SENT_HISTORY_SIZE = 20

export default class EmulatorChat extends React.Component {
constructor(props) {
super(props)
this.textInputRef = React.createRef()
this.endOfMessagesRef = React.createRef()
}

state = {
textInputValue: '',
sending: false,
messages: [],
userId: this.getOrCreateUserId(),
sentHistory: JSON.parse(localStorage.getItem(SENT_HISTORY_KEY) || '[]'),
sentHistoryIndex: 0
}

getOrCreateUserId(forceNew = false) {
if (!forceNew && localStorage.getItem(USER_ID_KEY)) {
return localStorage.getItem(USER_ID_KEY)
}

const userId = 'emulator_' + nanoid(7)
localStorage.setItem(USER_ID_KEY, userId)
return userId
}

componentDidUpdate(prevProps) {
if (!prevProps.isDockOpen && this.props.isDockOpen) {
this.textInputRef.current.focus()
}
}

navigateSentHistory(step) {
if (!this.state.sentHistory.length) {
return
}

let newIndex = this.state.sentHistoryIndex + step

if (newIndex < 0) {
newIndex = this.state.sentHistory.length - 1
} else if (newIndex >= this.state.sentHistory.length) {
newIndex = 0
}

this.setState({
textInputValue: this.state.sentHistory[newIndex],
sentHistoryIndex: newIndex
})
}

sendText = async () => {
if (!this.state.textInputValue.length) {
return
}

const text = this.state.textInputValue

// Wait for state to be set fully to prevent race conditions
await Promise.fromCallback(cb => this.setState({ textInputValue: '', sending: true }, cb))

const sentAt = Date.now()
const res = await axios.post(
`${window.BOT_API_PATH}/converse/${this.state.userId}`,
{ text },
{ params: { include: 'nlu,state' } }
)
const duration = Date.now() - sentAt

const msg = { duration, sent: text, result: res.data }

// Only append to history if it's not the same as last one
let newSentHistory = this.state.newSentHistory
if (_.last(newSentHistory) !== text) {
newSentHistory = [...this.state.sentHistory, text]
}

this.setState(
{
messages: [...this.state.messages, msg],
sending: false,
selectedIndex: this.state.messages.length,
sentHistory: newSentHistory,
sentHistoryIndex: 0
},
() => localStorage.setItem(SENT_HISTORY_KEY, JSON.stringify(_.take(this.state.sentHistory, SENT_HISTORY_SIZE)))
)

this.endOfMessagesRef.current.scrollIntoView(false)
}

handleKeyPress = e => {
if (!e.shiftKey && e.key === 'Enter') {
this.sendText()
e.preventDefault()
}
}

handleKeyDown = e => {
const maps = { ArrowUp: -1, ArrowDown: 1 }
if (!e.shiftKey && e.key in maps) {
e.preventDefault()
this.navigateSentHistory(maps[e.key])
}
}

handleMsgChange = e => !this.state.sending && this.setState({ textInputValue: e.target.value })

get inspectorData() {
return this.state.selectedIndex >= 0 ? this.state.messages[this.state.selectedIndex].result : {}
}

handleInspectorShouldExpand(key, data, level) {
return level <= 1
}

handleChangeUserId = () => {
this.setState(
{
messages: [],
selectedIndex: -1,
userId: this.getOrCreateUserId(true)
},
() => this.textInputRef.current.focus()
)
}

renderHistory() {
return (
<div className={style.history}>
{this.state.messages.map((msg, idx) => (
<Message
tabIndex={this.state.messages.length - idx + 1}
key={`msg-${idx}`}
onFocus={() => this.setState({ selectedIndex: idx })}
selected={this.state.selectedIndex === idx}
message={msg}
/>
))}
{/* This is used to loop using Tab, we're going back to text input */}
{/* Also used to scroll to the end of the messages */}
<div
ref={this.endOfMessagesRef}
tabIndex={this.state.messages.length + 1}
onFocus={() => this.textInputRef.current.focus()}
/>
</div>
)
}

renderInspector() {
return (
<div className={style.inspector}>
<JSONTree
data={this.inspectorData}
theme={inspectorTheme}
invertTheme={false}
hideRoot={true}
shouldExpandNode={this.handleInspectorShouldExpand}
/>
</div>
)
}

renderMessageInput() {
return (
<textarea
tabIndex={1}
ref={this.textInputRef}
className={classnames(style.msgInput, { [style.disabled]: this.state.sending })}
type="text"
onKeyPress={this.handleKeyPress}
onKeyDown={this.handleKeyDown}
value={this.state.textInputValue}
placeholder="Type a message here"
onChange={this.handleMsgChange}
/>
)
}

render() {
return (
<div className={style.container}>
<div className={style.toolbar}>
<Button onClick={this.handleChangeUserId}>
<Glyphicon glyph="refresh" /> New session
</Button>
</div>
<div className={style.panes}>
<SplitPane
split="horizontal"
minSize={50}
defaultSize={'75%'}
pane2Style={{ 'overflow-y': 'auto', backgroundColor: 'var(--c-background--dark-1)' }}
>
{this.renderHistory()}
{this.renderInspector()}
</SplitPane>
</div>
{this.renderMessageInput()}
</div>
)
}
}

0 comments on commit b73ca7c

Please sign in to comment.