Skip to content

Commit

Permalink
Merge pull request #2020 from botpress/ya-code-editor-mobx
Browse files Browse the repository at this point in the history
feat(code-editor): added some enhancements and mobx
  • Loading branch information
allardy committed Jun 28, 2019
2 parents 59917fa + 1fddfa0 commit aabc8f2
Show file tree
Hide file tree
Showing 25 changed files with 914 additions and 329 deletions.
2 changes: 1 addition & 1 deletion modules/code-editor/build.extras.js
@@ -1,3 +1,3 @@
module.exports = {
copyFiles: ['src/botpress.d.ts', 'src/typings/node.d.ts']
copyFiles: ['src/botpress.d.ts', 'src/typings/node.d.ts', 'src/typings/bot.config.schema.json']
}
2 changes: 2 additions & 0 deletions modules/code-editor/package.json
Expand Up @@ -20,6 +20,8 @@
"monaco-editor-webpack-plugin": "^1.7.0"
},
"dependencies": {
"mobx": "^5.10.1",
"mobx-react": "^6.1.1",
"monaco-editor": "^0.17.0",
"react-icons": "^3.7.0"
}
Expand Down
37 changes: 14 additions & 23 deletions modules/code-editor/src/backend/api.ts
@@ -1,77 +1,68 @@
import * as sdk from 'botpress/sdk'

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

export default async (bp: typeof sdk, editorByBot: EditorByBot) => {
const router = bp.http.createRouterForBot('code-editor')

router.get('/files', async (req, res) => {
router.get('/files', async (req, res, next) => {
try {
res.send(await editorByBot[req.params.botId].fetchFiles())
} catch (err) {
bp.logger.attachError(err).error('Error fetching files')
res.sendStatus(500)
next(err)
}
})

router.get('/config', async (req, res) => {
router.get('/config', async (req, res, next) => {
const { allowGlobal, includeBotConfig } = editorByBot[req.params.botId].getConfig()
try {
res.send({ isGlobalAllowed: await editorByBot[req.params.botId].isGlobalAllowed() })
res.send({ isGlobalAllowed: allowGlobal, isBotConfigIncluded: includeBotConfig })
} catch (err) {
bp.logger.attachError(err).error('Error fetching config')
res.sendStatus(500)
next(err)
}
})

router.post('/save', async (req, res) => {
router.post('/save', async (req, res, next) => {
try {
await editorByBot[req.params.botId].saveFile(req.body)
res.sendStatus(200)
} catch (err) {
bp.logger.attachError(err).error('Could not save file')
res.sendStatus(500)
next(err)
}
})

router.put('/rename', async (req, res) => {
router.put('/rename', async (req, res, next) => {
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)
next(err)
}
})

// not REST, but need the whole file info in the body
router.post('/remove', async (req, res) => {
router.post('/remove', async (req, res, next) => {
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)
next(err)
}
})

router.get('/typings', async (req, res) => {
router.get('/typings', async (req, res, next) => {
try {
res.send(await editorByBot[req.params.botId].loadTypings())
} catch (err) {
bp.logger.attachError(err).error('Could not load typings. Code completion will not be available')
res.sendStatus(500)
next(err)
}
})
}
64 changes: 53 additions & 11 deletions modules/code-editor/src/backend/editor.ts
Expand Up @@ -11,6 +11,7 @@ import { EditorError, EditorErrorStatus } from './editorError'
import { EditableFile, FileType, TypingDefinitions } from './typings'

const FILENAME_REGEX = /^[0-9a-zA-Z_\-.]+$/
const ALLOWED_TYPES = ['hook', 'action', 'bot_config']

export default class Editor {
private bp: typeof sdk
Expand All @@ -28,19 +29,24 @@ export default class Editor {
return this._config.allowGlobal
}

getConfig(): Config {
return this._config
}

async fetchFiles() {
return {
actionsGlobal: this._config.allowGlobal && this._filterBuiltin(await this._loadActions()),
hooksGlobal: this._config.allowGlobal && this._filterBuiltin(await this._loadHooks()),
actionsBot: this._filterBuiltin(await this._loadActions(this._botId))
actionsBot: this._filterBuiltin(await this._loadActions(this._botId)),
botConfigs: this._config.includeBotConfig && (await this._loadBotConfigs(this._config.allowGlobal))
}
}

private _filterBuiltin(files: EditableFile[]) {
return this._config.includeBuiltin ? files : files.filter(x => !x.content.includes('//CHECKSUM:'))
}

_validateMetadata({ name, botId, type, hookType }: Partial<EditableFile>) {
_validateMetadata({ name, botId, type, hookType, content }: Partial<EditableFile>) {
if (!botId || !botId.length) {
if (!this._config.allowGlobal) {
throw new Error(`Global files are restricted, please check your configuration`)
Expand All @@ -51,43 +57,62 @@ export default class Editor {
}
}

if (type !== 'action' && type !== 'hook') {
throw new Error('Invalid file type, only actions/hooks are allowed at the moment')
if (!ALLOWED_TYPES.includes(type)) {
throw new Error(`Invalid file type, only ${ALLOWED_TYPES} are allowed at the moment`)
}

if (type === 'hook' && !HOOK_SIGNATURES[hookType]) {
throw new Error('Invalid hook type.')
}

if (type === 'bot_config') {
if (!this._config.includeBotConfig) {
throw new Error(`Enable "includeBotConfig" in the Code Editor configuration to save those files.`)
}

this._validateBotConfig(content)
}

this._validateFilename(name)
}

private _validateBotConfig(config: string) {
try {
JSON.parse(config)
return true
} catch (err) {
throw new EditorError(`Invalid JSON file. ${err}`, EditorErrorStatus.INVALID_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 {
private _getGhost(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, content, hookType, type } = file
const ghost = this._loadGhostForBotId(file)
const ghost = this._getGhost(file)

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

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

if (type === 'action') {
return ghost.deleteFile('/actions', location)
Expand All @@ -102,7 +127,7 @@ export default class Editor {
this._validateFilename(newName)

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

const newLocation = location.replace(name, newName)
if (type === 'action' && !(await ghost.fileExists('/actions', newLocation))) {
Expand All @@ -127,11 +152,14 @@ export default class Editor {

const sdkTyping = fs.readFileSync(path.join(__dirname, '/../botpress.d.js'), 'utf-8')
const nodeTyping = fs.readFileSync(path.join(__dirname, `/../typings/node.d.js`), 'utf-8')
// Ideally we should fetch them locally, but for now it's safer to bundle it
const botSchema = fs.readFileSync(path.join(__dirname, '/../typings/bot.config.schema.json'), 'utf-8')

this._typings = {
'process.d.ts': this._buildRestrictedProcessVars(),
'node.d.ts': nodeTyping.toString(),
'botpress.d.ts': sdkTyping.toString().replace(`'botpress/sdk'`, `sdk`)
'botpress.d.ts': sdkTyping.toString().replace(`'botpress/sdk'`, `sdk`),
'bot.config.schema.json': botSchema.toString()
}

return this._typings
Expand All @@ -140,7 +168,7 @@ export default class Editor {
private async _loadActions(botId?: string): Promise<EditableFile[]> {
const ghost = botId ? this.bp.ghost.forBot(botId) : this.bp.ghost.forGlobal()

return Promise.map(ghost.directoryListing('/actions', '*.js'), async (filepath: string) => {
return Promise.map(ghost.directoryListing('/actions', '*.js', undefined, true), async (filepath: string) => {
return {
name: path.basename(filepath),
type: 'action' as FileType,
Expand All @@ -154,7 +182,7 @@ export default class Editor {
private async _loadHooks(): Promise<EditableFile[]> {
const ghost = this.bp.ghost.forGlobal()

return Promise.map(ghost.directoryListing('/hooks', '*.js'), async (filepath: string) => {
return Promise.map(ghost.directoryListing('/hooks', '*.js', undefined, true), async (filepath: string) => {
return {
name: path.basename(filepath),
type: 'hook' as FileType,
Expand All @@ -165,6 +193,20 @@ export default class Editor {
})
}

private async _loadBotConfigs(includeAllBots: boolean): Promise<EditableFile[]> {
const ghost = includeAllBots ? this.bp.ghost.forBots() : this.bp.ghost.forBot(this._botId)

return Promise.map(ghost.directoryListing('/', 'bot.config.json', undefined, true), async (filepath: string) => {
return {
name: path.basename(filepath),
type: 'bot_config' as FileType,
botId: filepath.substr(0, filepath.indexOf('/')),
location: filepath,
content: await ghost.readFileAsString('/', filepath)
}
})
}

private _buildRestrictedProcessVars() {
const exposedEnv = {
..._.pickBy(process.env, (_value, name) => name.startsWith('EXPOSED_')),
Expand Down
3 changes: 1 addition & 2 deletions modules/code-editor/src/backend/index.ts
Expand Up @@ -33,8 +33,7 @@ const entryPoint: sdk.ModuleEntryPoint = {
menuText: 'Code Editor',
noInterface: false,
fullName: 'Code Editor',
homepage: 'https://botpress.io',
experimental: true
homepage: 'https://botpress.io'
}
}

Expand Down
3 changes: 2 additions & 1 deletion modules/code-editor/src/backend/typings.d.ts
Expand Up @@ -6,7 +6,7 @@ export interface TypingDefinitions {
[file: string]: string
}

export type FileType = 'action' | 'hook'
export type FileType = 'action' | 'hook' | 'bot_config'

export interface EditableFile {
/** The name of the file, extracted from its location */
Expand All @@ -25,4 +25,5 @@ export interface FilesDS {
actionsGlobal: EditableFile[]
hooksGlobal: EditableFile[]
actionsBot: EditableFile[]
botConfigs: EditableFile[]
}
5 changes: 5 additions & 0 deletions modules/code-editor/src/config.ts
Expand Up @@ -9,4 +9,9 @@ export interface Config {
* @default false
*/
includeBuiltin: boolean
/**
* When enabled, bot configurations are also editable on the UI
* @default false
*/
includeBotConfig: boolean
}

0 comments on commit aabc8f2

Please sign in to comment.