Skip to content

Commit

Permalink
Merge pull request #1996 from botpress/fl_delete_rename_code_editor
Browse files Browse the repository at this point in the history
feat(code-editor): delete and rename actions and hooks in code-editor
  • Loading branch information
allardy committed Jun 27, 2019
2 parents 17f2235 + 42f14f8 commit ec7046a
Show file tree
Hide file tree
Showing 11 changed files with 304 additions and 9 deletions.
1 change: 1 addition & 0 deletions modules/code-editor/package.json
Expand Up @@ -15,6 +15,7 @@
"@blueprintjs/core": "^3.15.1",
"@types/node": "^10.11.7",
"@types/react": "^16.8.17",
"@types/react-dom": "^16.8.4",
"module-builder": "../../build/module-builder",
"monaco-editor-webpack-plugin": "^1.7.0"
},
Expand Down
33 changes: 33 additions & 0 deletions modules/code-editor/src/backend/api.ts
@@ -1,5 +1,6 @@
import * as sdk from 'botpress/sdk'

import { EditorErrorStatus } from './editorError'
import { EditorByBot } from './typings'

export default async (bp: typeof sdk, editorByBot: EditorByBot) => {
Expand Down Expand Up @@ -33,6 +34,38 @@ export default async (bp: typeof sdk, editorByBot: EditorByBot) => {
}
})

router.put('/rename', async (req, res) => {
const { file, newName } = req.body
try {
await editorByBot[req.params.botId].renameFile(file, newName)
res.sendStatus(200)
} catch (err) {
if (err.status === EditorErrorStatus.FILE_ALREADY_EXIST) {
res.sendStatus(409) // conflict, file already exists
return
}
if (err.status === EditorErrorStatus.INVALID_NAME) {
res.sendStatus(412) // pre-condition fail, invalid filename
return
}

bp.logger.attachError(err).error('Could not rename file')
res.sendStatus(500)
}
})

// not REST, but need the whole file info in the body
router.post('/remove', async (req, res) => {
const file = req.body
try {
await editorByBot[req.params.botId].deleteFile(file)
res.sendStatus(200)
} catch (err) {
bp.logger.attachError(err).error('Could not delete file')
res.sendStatus(500)
}
})

router.get('/typings', async (req, res) => {
try {
res.send(await editorByBot[req.params.botId].loadTypings())
Expand Down
57 changes: 51 additions & 6 deletions modules/code-editor/src/backend/editor.ts
Expand Up @@ -7,6 +7,7 @@ import path from 'path'
import { Config } from '../config'
import { HOOK_SIGNATURES } from '../typings/hooks'

import { EditorError, EditorErrorStatus } from './editorError'
import { EditableFile, FileType, TypingDefinitions } from './typings'

const FILENAME_REGEX = /^[0-9a-zA-Z_\-.]+$/
Expand Down Expand Up @@ -58,23 +59,67 @@ export default class Editor {
throw new Error('Invalid hook type.')
}

if (!FILENAME_REGEX.test(name)) {
throw new Error('Filename has invalid characters')
this._validateFilename(name)
}

private _validateFilename(filename: string) {
if (!FILENAME_REGEX.test(filename)) {
throw new EditorError('Filename has invalid characters', EditorErrorStatus.INVALID_NAME)
}
}

private _loadGhostForBotId(file: EditableFile): sdk.ScopedGhostService {
return file.botId ? this.bp.ghost.forBot(this._botId) : this.bp.ghost.forGlobal()
}

async saveFile(file: EditableFile): Promise<void> {
this._validateMetadata(file)
const { location, botId, content, hookType } = file
const ghost = botId ? this.bp.ghost.forBot(this._botId) : this.bp.ghost.forGlobal()
const { location, content, hookType, type } = file
const ghost = this._loadGhostForBotId(file)

if (file.type === 'action') {
if (type === 'action') {
return ghost.upsertFile('/actions', location, content)
} else if (file.type === 'hook') {
} else if (type === 'hook') {
return ghost.upsertFile(`/hooks/${hookType}`, location.replace(hookType, ''), content)
}
}

async deleteFile(file: EditableFile): Promise<void> {
this._validateMetadata(file)
const { location, hookType, type } = file
const ghost = this._loadGhostForBotId(file)

if (type === 'action') {
return ghost.deleteFile('/actions', location)
}
if (type === 'hook') {
return ghost.deleteFile(`/hooks/${hookType}`, location.replace(hookType, ''))
}
}

async renameFile(file: EditableFile, newName: string): Promise<void> {
this._validateMetadata(file)
this._validateFilename(newName)

const { location, hookType, type, name } = file
const ghost = this._loadGhostForBotId(file)

const newLocation = location.replace(name, newName)
if (type === 'action' && !(await ghost.fileExists('/actions', newLocation))) {
await ghost.renameFile('/actions', location, newLocation)
return
}

const hookLocation = location.replace(hookType, '')
const newHookLocation = hookLocation.replace(name, newName)
if (type === 'hook' && !(await ghost.fileExists(`/hooks/${hookType}`, newHookLocation))) {
await ghost.renameFile(`/hooks/${hookType}`, hookLocation, newHookLocation)
return
}

throw new EditorError('File already exists', EditorErrorStatus.FILE_ALREADY_EXIST)
}

async loadTypings() {
if (this._typings) {
return this._typings
Expand Down
18 changes: 18 additions & 0 deletions modules/code-editor/src/backend/editorError.ts
@@ -0,0 +1,18 @@
export enum EditorErrorStatus {
INVALID_NAME = 'INVALID_NAME',
FILE_ALREADY_EXIST = 'FILE_ALREADY_EXIST'
}

export class EditorError extends Error {
private _status: EditorErrorStatus

constructor(message: string, status: EditorErrorStatus) {
super(message)

this._status = status
}

public get status() {
return this._status
}
}
6 changes: 6 additions & 0 deletions modules/code-editor/src/backend/typings.d.ts
Expand Up @@ -20,3 +20,9 @@ export interface EditableFile {
botId?: string
hookType?: string
}

export interface FilesDS {
actionsGlobal: EditableFile[]
hooksGlobal: EditableFile[]
actionsBot: EditableFile[]
}
63 changes: 62 additions & 1 deletion modules/code-editor/src/views/full/FileNavigator.tsx
@@ -1,16 +1,24 @@
import { Classes, ITreeNode, Tree } from '@blueprintjs/core'
import { Classes, ContextMenu, ITreeNode, Menu, MenuItem, Tree } from '@blueprintjs/core'
import React from 'react'
import ReactDOM from 'react-dom'

import { EditableFile } from '../../backend/typings'

import { buildTree } from './utils/tree'
import { TreeNodeRenameInput } from './TreeNodeRenameInput'

export default class FileNavigator extends React.Component<Props, State> {
state = {
files: undefined,
nodes: []
}

treeRef: React.RefObject<Tree<NodeData>>
constructor(props) {
super(props)
this.treeRef = React.createRef()
}

async componentDidMount() {
await this.refreshNodes()
}
Expand Down Expand Up @@ -77,14 +85,61 @@ export default class FileNavigator extends React.Component<Props, State> {
}
}

handleContextMenu = (node: ITreeNode<NodeData>, path, e) => {
e.preventDefault()

if (!node.nodeData) {
return null
}

ContextMenu.show(
<Menu>
<MenuItem icon="edit" text="Rename" onClick={() => this.renameTreeNode(node)} />
<MenuItem icon="delete" text="Delete" onClick={() => this.props.onNodeDelete(node.nodeData as EditableFile)} />
</Menu>,
{ left: e.clientX, top: e.clientY }
)
}

renameTreeNode = async (node: ITreeNode) => {
const nodeDomElement = this.treeRef.current.getNodeContentElement(node.id)
const renamer = document.createElement('div')

const handleCloseComponent = async (newName: string, cancel: boolean) => {
ReactDOM.unmountComponentAtNode(renamer)
renamer.replaceWith(nodeDomElement)

if (cancel || !newName || !newName.length || newName === node.label) {
return
}

try {
await this.props.onNodeRename(node.nodeData as EditableFile, newName)
} catch (err) {
console.error('could not rename file')
return
}

node.label = newName
}

ReactDOM.render(
<TreeNodeRenameInput node={node} nodeDomElement={nodeDomElement} handleCloseComponent={handleCloseComponent} />,
renamer
)
nodeDomElement.replaceWith(renamer)
}

render() {
if (!this.state.nodes) {
return null
}

return (
<Tree
ref={this.treeRef}
contents={this.state.nodes}
onNodeContextMenu={this.handleContextMenu}
onNodeClick={this.handleNodeClick}
onNodeCollapse={this.handleNodeCollapse}
onNodeExpand={this.handleNodeExpand}
Expand All @@ -99,8 +154,14 @@ interface Props {
onFileSelected: (file: EditableFile) => void
onNodeStateChanged: (id: string, isExpanded: boolean) => void
expandedNodes: object
onNodeDelete: (file: EditableFile) => Promise<void>
onNodeRename: (file: EditableFile, newName: string) => Promise<void>
}

interface State {
nodes: ITreeNode[]
}

interface NodeData {
name: string
}
6 changes: 6 additions & 0 deletions modules/code-editor/src/views/full/SidePanel.tsx
Expand Up @@ -75,6 +75,8 @@ export default class PanelContent extends React.Component<Props> {
expandedNodes={this.expandedNodes}
onNodeStateChanged={this.updateNodeState}
onFileSelected={this.props.handleFileChanged}
onNodeDelete={this.props.removeFile}
onNodeRename={this.props.renameFile}
/>
</SidePanelSection>
)
Expand Down Expand Up @@ -129,6 +131,8 @@ export default class PanelContent extends React.Component<Props> {
expandedNodes={this.expandedNodes}
onNodeStateChanged={this.updateNodeState}
onFileSelected={this.props.handleFileChanged}
onNodeDelete={this.props.removeFile}
onNodeRename={this.props.renameFile}
/>
</SidePanelSection>
)
Expand Down Expand Up @@ -161,4 +165,6 @@ interface Props {
discardChanges: () => void
createFilePrompt: (type: string, isGlobal?: boolean, hookType?: string) => void
onSaveClicked: () => void
removeFile: (file: EditableFile) => Promise<void>
renameFile: (file: EditableFile, newName: string) => Promise<void>
}
76 changes: 76 additions & 0 deletions modules/code-editor/src/views/full/TreeNodeRenameInput.tsx
@@ -0,0 +1,76 @@
import { ITreeNode } from '@blueprintjs/core'
import React from 'react'

interface Props {
nodeDomElement: HTMLElement
node: ITreeNode
handleCloseComponent: (newName: string, cancel: boolean) => Promise<void>
}

interface State {
newName: string
}

interface Preventable {
preventDefault: () => void
}

const FILENAME_REGEX = /[^0-9a-zA-Z_\-.]/

export class TreeNodeRenameInput extends React.Component<Props, State> {
state = {
newName: ''
}

componentDidMount() {
this.setState({ newName: this.props.node.label as string })
}

closeRename = async (e: Preventable, cancel: boolean) => {
e.preventDefault()
let newName = this.state.newName
newName = newName.endsWith('.js') ? newName : newName + '.js'
this.setState({ newName })
await this.props.handleCloseComponent(newName, cancel)
}

handleKeyPress = async (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === 'Enter') {
await this.closeRename(e, false)
return
}
if (e.key === 'Escape') {
await this.closeRename(e, true)
return
}
}

focusRef = ref => {
if (ref) {
setTimeout(() => {
ref.focus()
ref.select()
})
}
}

onValueChange = e => {
this.setState({ newName: e.target.value.replace(FILENAME_REGEX, '') })
}

render() {
return (
<div className={this.props.nodeDomElement && this.props.nodeDomElement.className}>
<input
onBlur={e => this.closeRename(e, false)}
onKeyDown={this.handleKeyPress}
ref={this.focusRef}
type="text"
className="bp3-input bp3-small"
value={this.state.newName}
onChange={this.onValueChange}
/>
</div>
)
}
}

0 comments on commit ec7046a

Please sign in to comment.