diff --git a/modules/code-editor/build.extras.js b/modules/code-editor/build.extras.js index 3765cec6839..cdb67413a60 100644 --- a/modules/code-editor/build.extras.js +++ b/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'] } diff --git a/modules/code-editor/package.json b/modules/code-editor/package.json index 2882d73d7f8..241f4221359 100644 --- a/modules/code-editor/package.json +++ b/modules/code-editor/package.json @@ -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" } diff --git a/modules/code-editor/src/backend/api.ts b/modules/code-editor/src/backend/api.ts index 0d3c74fe6ff..5d2ad72573a 100644 --- a/modules/code-editor/src/backend/api.ts +++ b/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) } }) } diff --git a/modules/code-editor/src/backend/editor.ts b/modules/code-editor/src/backend/editor.ts index ec0d578afa7..7da30f69408 100644 --- a/modules/code-editor/src/backend/editor.ts +++ b/modules/code-editor/src/backend/editor.ts @@ -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 @@ -28,11 +29,16 @@ 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)) } } @@ -40,7 +46,7 @@ export default class Editor { return this._config.includeBuiltin ? files : files.filter(x => !x.content.includes('//CHECKSUM:')) } - _validateMetadata({ name, botId, type, hookType }: Partial) { + _validateMetadata({ name, botId, type, hookType, content }: Partial) { if (!botId || !botId.length) { if (!this._config.allowGlobal) { throw new Error(`Global files are restricted, please check your configuration`) @@ -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 { 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 { 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) @@ -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))) { @@ -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 @@ -140,7 +168,7 @@ export default class Editor { private async _loadActions(botId?: string): Promise { 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, @@ -154,7 +182,7 @@ export default class Editor { private async _loadHooks(): Promise { 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, @@ -165,6 +193,20 @@ export default class Editor { }) } + private async _loadBotConfigs(includeAllBots: boolean): Promise { + 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_')), diff --git a/modules/code-editor/src/backend/index.ts b/modules/code-editor/src/backend/index.ts index 908606d2fa2..9e0a693cf2b 100644 --- a/modules/code-editor/src/backend/index.ts +++ b/modules/code-editor/src/backend/index.ts @@ -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' } } diff --git a/modules/code-editor/src/backend/typings.d.ts b/modules/code-editor/src/backend/typings.d.ts index e03093307f8..2a4ee680cb6 100644 --- a/modules/code-editor/src/backend/typings.d.ts +++ b/modules/code-editor/src/backend/typings.d.ts @@ -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 */ @@ -25,4 +25,5 @@ export interface FilesDS { actionsGlobal: EditableFile[] hooksGlobal: EditableFile[] actionsBot: EditableFile[] + botConfigs: EditableFile[] } diff --git a/modules/code-editor/src/config.ts b/modules/code-editor/src/config.ts index 024775919da..3b5f87610a9 100644 --- a/modules/code-editor/src/config.ts +++ b/modules/code-editor/src/config.ts @@ -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 } diff --git a/modules/code-editor/src/typings/bot.config.schema.json b/modules/code-editor/src/typings/bot.config.schema.json new file mode 100644 index 00000000000..6cb9650526a --- /dev/null +++ b/modules/code-editor/src/typings/bot.config.schema.json @@ -0,0 +1,187 @@ +{ + "type": "object", + "properties": { + "$schema": { + "type": "string" + }, + "id": { + "type": "string" + }, + "name": { + "type": "string" + }, + "description": { + "type": "string" + }, + "category": { + "type": "string" + }, + "details": { + "$ref": "#/definitions/BotDetails" + }, + "author": { + "type": "string" + }, + "disabled": { + "type": "boolean" + }, + "private": { + "type": "boolean" + }, + "version": { + "type": "string" + }, + "imports": { + "type": "object", + "properties": { + "contentTypes": { + "description": "Defines the list of content types supported by the bot", + "type": "array", + "items": { + "type": "string" + } + } + }, + "required": [ + "contentTypes" + ] + }, + "dialog": { + "$ref": "#/definitions/DialogConfig" + }, + "logs": { + "$ref": "#/definitions/LogsConfig" + }, + "defaultLanguage": { + "type": "string" + }, + "languages": { + "type": "array", + "items": { + "type": "string" + } + }, + "locked": { + "type": "boolean" + }, + "pipeline_status": { + "$ref": "#/definitions/BotPipelineStatus" + } + }, + "required": [ + "defaultLanguage", + "details", + "id", + "imports", + "languages", + "locked", + "name", + "pipeline_status", + "version" + ], + "definitions": { + "BotDetails": { + "type": "object", + "properties": { + "website": { + "type": "string" + }, + "phoneNumber": { + "type": "string" + }, + "termsConditions": { + "type": "string" + }, + "privacyPolicy": { + "type": "string" + }, + "emailAddress": { + "type": "string" + } + } + }, + "DialogConfig": { + "type": "object", + "properties": { + "timeoutInterval": { + "type": "string" + }, + "sessionTimeoutInterval": { + "type": "string" + } + }, + "required": [ + "sessionTimeoutInterval", + "timeoutInterval" + ] + }, + "LogsConfig": { + "type": "object", + "properties": { + "expiration": { + "type": "string" + } + }, + "required": [ + "expiration" + ] + }, + "BotPipelineStatus": { + "type": "object", + "properties": { + "current_stage": { + "type": "object", + "properties": { + "promoted_by": { + "type": "string" + }, + "promoted_on": { + "description": "Enables basic storage and retrieval of dates and times.", + "type": "string", + "format": "date-time" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "promoted_by", + "promoted_on" + ] + }, + "stage_request": { + "type": "object", + "properties": { + "requested_on": { + "description": "Enables basic storage and retrieval of dates and times.", + "type": "string", + "format": "date-time" + }, + "expires_on": { + "description": "Enables basic storage and retrieval of dates and times.", + "type": "string", + "format": "date-time" + }, + "requested_by": { + "type": "string" + }, + "id": { + "type": "string" + } + }, + "required": [ + "id", + "requested_by", + "requested_on" + ] + } + }, + "required": [ + "current_stage" + ] + } + }, + "$schema": "http://json-schema.org/draft-07/schema#" +} + diff --git a/modules/code-editor/src/views/full/Editor.tsx b/modules/code-editor/src/views/full/Editor.tsx index 2e8e91b54f8..5ab3a894788 100644 --- a/modules/code-editor/src/views/full/Editor.tsx +++ b/modules/code-editor/src/views/full/Editor.tsx @@ -1,41 +1,30 @@ import { Icon, Position, Tooltip } from '@blueprintjs/core' +import { observe } from 'mobx' +import { inject, observer } from 'mobx-react' import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' import React from 'react' -import { EditableFile } from '../../backend/typings' - +import SplashScreen from './components/SplashScreen' +import { RootStore, StoreDef } from './store' +import { EditorStore } from './store/editor' import style from './style.scss' -import { calculateHash } from './utils/crypto' -import { wrapper } from './utils/wrapper' -export default class Editor extends React.Component { - private editor - private editorContainer - private _fileOriginalHash: string +class Editor extends React.Component { + private editor: monaco.editor.IStandaloneCodeEditor + private editorContainer: HTMLDivElement async componentDidMount() { this.setupEditor() - await this.loadTypings() + // tslint:disable-next-line: no-floating-promises + this.loadTypings() - if (this.props.selectedFile) { - await this.loadFile(this.props.selectedFile) - } + observe(this.props.editor, 'currentFile', this.loadFile, true) } componentWillUnmount() { this.editor && this.editor.dispose() } - async componentDidUpdate(prevProps) { - if (this.props.selectedFile === prevProps.selectedFile) { - return - } - - if (this.props.selectedFile) { - await this.loadFile(this.props.selectedFile) - } - } - setupEditor() { monaco.languages.typescript.typescriptDefaults.setCompilerOptions({ target: monaco.languages.typescript.ScriptTarget.ESNext, @@ -46,83 +35,101 @@ export default class Editor extends React.Component { }) this.editor = monaco.editor.create(this.editorContainer, { theme: 'vs-light', automaticLayout: true }) - this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S, this.props.onSaveClicked) - this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt | monaco.KeyCode.KEY_N, () => - this.props.onCreateNewClicked('action') - ) + this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyCode.KEY_S, this.props.editor.saveChanges) + this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Alt | monaco.KeyCode.KEY_N, this.props.createNewAction) this.editor.addCommand(monaco.KeyMod.CtrlCmd | monaco.KeyMod.Shift | monaco.KeyCode.KEY_P, () => - this.editor.trigger('', 'editor.action.quickCommand') + this.editor.trigger('', 'editor.action.quickCommand', '') ) this.editor.onDidChangeModelContent(this.handleContentChanged) this.editor.onDidChangeModelDecorations(this.handleDecorationChanged) + + this.props.store.editor.setMonacoEditor(this.editor) } - loadFile(selectedFile: EditableFile, noWrapper?: boolean) { - const { content, location, type, hookType } = selectedFile - const uri = monaco.Uri.parse('bp://files/' + location.replace('.js', '.ts')) + loadFile = () => { + if (!this.props.editor.currentFile) { + return + } + + const { location } = this.props.editor.currentFile + const fileType = location.endsWith('.json') ? 'json' : 'typescript' + const filepath = fileType === 'json' ? location : location.replace('.js', '.ts') + + const uri = monaco.Uri.parse(`bp://files/${filepath}`) const oldModel = monaco.editor.getModel(uri) if (oldModel) { oldModel.dispose() } - const fileContent = noWrapper ? content : wrapper.add(content, type, hookType) - this._fileOriginalHash = calculateHash(fileContent) - const model = monaco.editor.createModel(fileContent, 'typescript', uri) - + const model = monaco.editor.createModel(this.props.editor.fileContentWrapped, fileType, uri) this.editor && this.editor.setModel(model) } - async loadTypings() { - const { data: typings } = await this.props.bp.axios.get('/mod/code-editor/typings') + loadTypings = async () => { + const typings = await this.props.fetchTypings() + if (!typings) { + return + } Object.keys(typings).forEach(name => { const uri = 'bp://types/' + name const content = typings[name] - monaco.languages.typescript.typescriptDefaults.addExtraLib(content, uri) + if (name.endsWith('.json')) { + monaco.languages.json.jsonDefaults.setDiagnosticsOptions({ + schemas: [{ uri, fileMatch: ['bot.config.json'], schema: JSON.parse(content) }], + validate: true + }) + } else { + monaco.languages.typescript.typescriptDefaults.addExtraLib(content, uri) + } }) } handleContentChanged = () => { - const hasChanges = this._fileOriginalHash !== calculateHash(this.editor.getValue()) - const content = wrapper.remove(this.editor.getValue()) - this.props.onContentChanged && this.props.onContentChanged(content, hasChanges) + this.props.editor.updateContent(this.editor.getValue()) } handleDecorationChanged = () => { const uri = this.editor.getModel().uri const markers = monaco.editor.getModelMarkers({ resource: uri }) - this.props.onProblemsChanged && this.props.onProblemsChanged(markers) + this.props.editor.setFileProblems(markers) } render() { return ( -
-
-
- {this.props.selectedFile.name} - -
- - - + + {!this.props.editor.isOpenedFile && } +
+
+
+ {this.props.editor.currentFile && this.props.editor.currentFile.name} + +
+ + + +
+
(this.editorContainer = ref)} className={style.editor} />
-
(this.editorContainer = ref)} className={style.editor} /> -
+ ) } } -interface Props { - onContentChanged: (code: string, hasChanges: boolean) => void - onDiscardChanges: () => void - onCreateNewClicked: (type: string) => void - onProblemsChanged: (markers: monaco.editor.IMarker[]) => void - onSaveClicked: () => void - selectedFile: EditableFile - bp: any -} +export default inject(({ store }: { store: RootStore }) => ({ + store, + createNewAction: store.createNewAction, + typings: store.typings, + fetchTypings: store.fetchTypings, + editor: store.editor +}))(observer(Editor)) + +type Props = { store?: RootStore; editor?: EditorStore } & Pick< + StoreDef, + 'typings' | 'fetchTypings' | 'createNewAction' +> diff --git a/modules/code-editor/src/views/full/FileNavigator.tsx b/modules/code-editor/src/views/full/FileNavigator.tsx index 15470e6d3b5..ca698f8da7e 100644 --- a/modules/code-editor/src/views/full/FileNavigator.tsx +++ b/modules/code-editor/src/views/full/FileNavigator.tsx @@ -1,13 +1,17 @@ -import { Classes, ContextMenu, ITreeNode, Menu, MenuItem, Tree } from '@blueprintjs/core' +import { Classes, ContextMenu, ITreeNode, Menu, MenuDivider, MenuItem, Tree } from '@blueprintjs/core' +import { observe } from 'mobx' +import { inject, observer } from 'mobx-react' import React from 'react' import ReactDOM from 'react-dom' import { EditableFile } from '../../backend/typings' +import { RootStore, StoreDef } from './store' +import { EditorStore } from './store/editor' import { buildTree } from './utils/tree' import { TreeNodeRenameInput } from './TreeNodeRenameInput' -export default class FileNavigator extends React.Component { +class FileNavigator extends React.Component { state = { files: undefined, nodes: [] @@ -19,28 +23,28 @@ export default class FileNavigator extends React.Component { this.treeRef = React.createRef() } - async componentDidMount() { - await this.refreshNodes() + componentDidMount() { + observe(this.props.filters, 'filename', this.refreshNodes, true) } - async componentDidUpdate(prevProps) { - if (prevProps.files !== this.props.files && this.props.files) { - await this.refreshNodes() + componentDidUpdate(prevProps) { + if (this.props.files && prevProps.files !== this.props.files) { + this.refreshNodes() } } - async refreshNodes() { + refreshNodes = () => { if (!this.props.files) { return } - + const filter = this.props.filters && this.props.filters.filename.toLowerCase() const nodes: ITreeNode[] = this.props.files.map(dir => ({ id: dir.label, label: dir.label, icon: 'folder-close', hasCaret: true, isExpanded: true, - childNodes: buildTree(dir.files, this.props.expandedNodes) + childNodes: buildTree(dir.files, this.props.expandedNodes, filter) })) this.setState({ nodes }) @@ -53,7 +57,7 @@ export default class FileNavigator extends React.Component { // If nodeData is set, it's a file, otherwise a folder if (node.nodeData) { - this.props.onFileSelected && this.props.onFileSelected(node.nodeData as EditableFile) + this.props.editor.openFile(node.nodeData as EditableFile) this.forceUpdate() } else { node.isExpanded ? this.handleNodeCollapse(node) : this.handleNodeExpand(node) @@ -88,14 +92,22 @@ export default class FileNavigator extends React.Component { handleContextMenu = (node: ITreeNode, path, e) => { e.preventDefault() - if (!node.nodeData) { + if (!node.nodeData || this.props.disableContextMenu) { return null } + const isDisabled = node.nodeData.name.startsWith('.') + const file = node.nodeData as EditableFile + ContextMenu.show( this.renameTreeNode(node)} /> - this.props.onNodeDelete(node.nodeData as EditableFile)} /> + this.props.deleteFile(file)} /> + + this.props.duplicateFile(file)} /> + + this.props.enableFile(file)} /> + this.props.disableFile(file)} /> , { left: e.clientX, top: e.clientY } ) @@ -114,7 +126,7 @@ export default class FileNavigator extends React.Component { } try { - await this.props.onNodeRename(node.nodeData as EditableFile, newName) + await this.props.renameFile(node.nodeData as EditableFile, newName) } catch (err) { console.error('could not rename file') return @@ -149,14 +161,26 @@ export default class FileNavigator extends React.Component { } } -interface Props { +export default inject(({ store }: { store: RootStore }) => ({ + store, + editor: store.editor, + filters: store.filters, + deleteFile: store.deleteFile, + renameFile: store.renameFile, + enableFile: store.enableFile, + disableFile: store.disableFile, + duplicateFile: store.duplicateFile, + isGlobalAllowed: store.config && store.config.isGlobalAllowed +}))(observer(FileNavigator)) + +type Props = { files: any - onFileSelected: (file: EditableFile) => void + store?: RootStore + editor?: EditorStore + disableContextMenu?: boolean onNodeStateChanged: (id: string, isExpanded: boolean) => void expandedNodes: object - onNodeDelete: (file: EditableFile) => Promise - onNodeRename: (file: EditableFile, newName: string) => Promise -} +} & Pick interface State { nodes: ITreeNode[] diff --git a/modules/code-editor/src/views/full/SidePanel.tsx b/modules/code-editor/src/views/full/SidePanel.tsx index 0fb6279b909..919bc4a1371 100644 --- a/modules/code-editor/src/views/full/SidePanel.tsx +++ b/modules/code-editor/src/views/full/SidePanel.tsx @@ -1,20 +1,22 @@ import { Icon } from '@blueprintjs/core' -import { SectionAction, SidePanel, SidePanelSection } from 'botpress/ui' -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' +import { SearchBar, SectionAction, SidePanel, SidePanelSection } from 'botpress/ui' +import { inject, observer } from 'mobx-react' import React from 'react' -import { EditableFile } from '../../backend/typings' import { HOOK_SIGNATURES } from '../../typings/hooks' import FileStatus from './components/FileStatus' +import { RootStore, StoreDef } from './store' +import { EditorStore } from './store/editor' import FileNavigator from './FileNavigator' -export default class PanelContent extends React.Component { +class PanelContent extends React.Component { private expandedNodes = {} state = { actionFiles: [], - hookFiles: [] + hookFiles: [], + botConfigs: [] } componentDidMount() { @@ -32,7 +34,7 @@ export default class PanelContent extends React.Component { return } - const { actionsBot, actionsGlobal, hooksGlobal } = this.props.files + const { actionsBot, actionsGlobal, hooksGlobal, botConfigs } = this.props.files const actionFiles = [] actionsBot && actionFiles.push({ label: `Bot (${window['BOT_NAME']})`, files: actionsBot }) @@ -40,7 +42,13 @@ export default class PanelContent extends React.Component { const hookFiles = [hooksGlobal && { label: 'Global', files: hooksGlobal }] - this.setState({ actionFiles, hookFiles }) + const botConfigFiles = [ + botConfigs && botConfigs.length === 1 + ? { label: 'Current Bot', files: botConfigs } + : { label: 'All Bots', files: botConfigs } + ] + + this.setState({ actionFiles, hookFiles, botConfigs: botConfigFiles }) } updateNodeState = (id: string, isExpanded: boolean) => { @@ -51,6 +59,23 @@ export default class PanelContent extends React.Component { } } + renderSectionBotsConfig() { + if (!this.props.isBotConfigIncluded) { + return null + } + + return ( + + + + ) + } + renderSectionActions() { const items: SectionAction[] = [ { @@ -74,15 +99,16 @@ export default class PanelContent extends React.Component { files={this.state.actionFiles} expandedNodes={this.expandedNodes} onNodeStateChanged={this.updateNodeState} - onFileSelected={this.props.handleFileChanged} - onNodeDelete={this.props.removeFile} - onNodeRename={this.props.renameFile} /> ) } renderSectionHooks() { + if (!this.props.isGlobalAllowed) { + return null + } + const hooks = Object.keys(HOOK_SIGNATURES).map(hookType => ({ id: hookType, label: hookType @@ -130,9 +156,6 @@ export default class PanelContent extends React.Component { files={this.state.hookFiles} expandedNodes={this.expandedNodes} onNodeStateChanged={this.updateNodeState} - onFileSelected={this.props.handleFileChanged} - onNodeDelete={this.props.removeFile} - onNodeRename={this.props.renameFile} /> ) @@ -141,30 +164,39 @@ export default class PanelContent extends React.Component { render() { return ( - {this.props.isEditing && ( - + {this.props.editor.isOpenedFile && this.props.editor.isDirty ? ( + + ) : ( + + + + {this.renderSectionActions()} + {this.renderSectionHooks()} + {this.renderSectionBotsConfig()} + )} - - {!this.props.isEditing && this.renderSectionActions()} - {!this.props.isEditing && this.props.isGlobalAllowed && this.renderSectionHooks()} ) } } -interface Props { - isEditing: boolean - isGlobalAllowed: boolean - files: any - errors: monaco.editor.IMarker[] - handleFileChanged: (file: EditableFile) => void - discardChanges: () => void - createFilePrompt: (type: string, isGlobal?: boolean, hookType?: string) => void - onSaveClicked: () => void - removeFile: (file: EditableFile) => Promise - renameFile: (file: EditableFile, newName: string) => Promise -} +export default inject(({ store }: { store: RootStore }) => ({ + store, + editor: store.editor, + files: store.files, + isDirty: store.editor.isDirty, + setFilenameFilter: store.setFilenameFilter, + createFilePrompt: store.createFilePrompt, + isGlobalAllowed: store.config && store.config.isGlobalAllowed, + isBotConfigIncluded: store.config && store.config.isBotConfigIncluded +}))(observer(PanelContent)) + +type Props = { store?: RootStore; editor?: EditorStore } & Pick< + StoreDef, + 'files' | 'isGlobalAllowed' | 'isBotConfigIncluded' | 'createFilePrompt' | 'setFilenameFilter' +> diff --git a/modules/code-editor/src/views/full/components/FileStatus.tsx b/modules/code-editor/src/views/full/components/FileStatus.tsx index a4de25fe5a1..36d001fa254 100644 --- a/modules/code-editor/src/views/full/components/FileStatus.tsx +++ b/modules/code-editor/src/views/full/components/FileStatus.tsx @@ -1,16 +1,18 @@ +import { Collapse, Icon } from '@blueprintjs/core' import { SidePanelSection } from 'botpress/ui' +import { RootStore } from 'full/store' +import { inject, observer } from 'mobx-react' import React, { useState } from 'react' -import { Collapse, Icon } from '@blueprintjs/core' - const FileStatus = props => { const [showErrors, setErrorDisplayed] = useState(false) const actions = [ - { label: 'Discard', icon: , onClick: props.discardChanges }, - { label: 'Save', icon: , onClick: props.onSaveClicked } + { label: 'Discard', icon: , onClick: props.editor.discardChanges }, + { label: 'Save', icon: , onClick: props.editor.saveChanges } ] - if (!props.errors || !props.errors.length) { + const problems = props.editor.fileProblems + if (!problems || !problems.length) { return ( @@ -22,7 +24,7 @@ const FileStatus = props => {
Warning -

There are {props.errors.length} errors in your file.

+

There are {problems.length} errors in your file.

Please make sure to fix them before saving.

setErrorDisplayed(!showErrors)} style={{ cursor: 'pointer' }}> @@ -33,8 +35,8 @@ const FileStatus = props => {
- {props.errors.map(x => ( -
+ {problems.map(x => ( +
Line {x.startLineNumber}
{x.message} @@ -47,4 +49,7 @@ const FileStatus = props => { ) } -export default FileStatus +export default inject(({ store }: { store: RootStore }) => ({ + store, + editor: store.editor +}))(observer(FileStatus)) diff --git a/modules/code-editor/src/views/full/components/SplashScreen.tsx b/modules/code-editor/src/views/full/components/SplashScreen.tsx index 244a526530d..9c07d3cf546 100644 --- a/modules/code-editor/src/views/full/components/SplashScreen.tsx +++ b/modules/code-editor/src/views/full/components/SplashScreen.tsx @@ -1,6 +1,5 @@ -import React from 'react' - import { KeyboardShortcut, SplashScreen } from 'botpress/ui' +import React from 'react' import { MdCode } from 'react-icons/md' export default () => ( diff --git a/modules/code-editor/src/views/full/index.tsx b/modules/code-editor/src/views/full/index.tsx index 42c78382e04..e2a286bdfaf 100644 --- a/modules/code-editor/src/views/full/index.tsx +++ b/modules/code-editor/src/views/full/index.tsx @@ -1,171 +1,38 @@ -import { Intent, Position, Toaster } from '@blueprintjs/core' import { Container } from 'botpress/ui' -import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' +import { configure, observe } from 'mobx' +import { Provider } from 'mobx-react' import React from 'react' -import { EditableFile, FilesDS, FileType } from '../../backend/typings' - -import SplashScreen from './components/SplashScreen' -import { baseAction, baseHook } from './utils/templates' +import { RootStore } from './store' import Editor from './Editor' import SidePanel from './SidePanel' -const FILENAME_REGEX = /^[0-9a-zA-Z_\-.]+$/ - -export default class CodeEditor extends React.Component { - state = { - errors: undefined, - files: undefined, - isEditing: false, - editedContent: undefined, - selectedFile: undefined, - isGlobalAllowed: false - } - - componentDidMount() { - // tslint:disable-next-line: no-floating-promises - this.initialize() - document.addEventListener('keydown', this.createNewFileShortcut) - } - - componentWillUnmount() { - document.removeEventListener('keydown', this.createNewFileShortcut) - } - - createNewFileShortcut = e => { - // The editor normally handles keybindings, so this one is only active while it is closed - if (e.ctrlKey && e.altKey && e.key === 'n' && !this.state.selectedFile) { - this.createFilePrompt('action') - } - } - - async initialize() { - const { data: files } = await this.props.bp.axios.get('/mod/code-editor/files') - const { data: config } = await this.props.bp.axios.get('/mod/code-editor/config') - this.setState({ files, ...config }) - } - - createFilePrompt = (type: FileType, isGlobal?: boolean, hookType?: string) => { - let name = window.prompt(`Choose the name of your ${type}. No special chars. Use camel case`) - if (!name) { - return - } - - if (!FILENAME_REGEX.test(name)) { - alert('Invalid filename') - return - } - name = name.endsWith('.js') ? name : name + '.js' - this.setState({ - isEditing: true, - selectedFile: { - name, - location: name, - content: type === 'action' ? baseAction : baseHook, - type, - hookType, - botId: isGlobal ? undefined : window.BOT_ID - } - }) - } - - saveChanges = async () => { - if (!this.state.editedContent) { - return - } - - await this.props.bp.axios.post('/mod/code-editor/save', { - ...this.state.selectedFile, - content: this.state.editedContent - }) - - this.setState({ isEditing: false, editedContent: undefined }, this.initialize) - } +configure({ enforceActions: 'observed' }) - removeFile = async (file: EditableFile) => { - if (window.confirm('Are you sure you want to delete this file?')) { - await this.props.bp.axios.post('/mod/code-editor/remove', file) - await this.initialize() - } - } +export default class CodeEditor extends React.Component<{ bp: any }> { + private store: RootStore - renameFile = async (file, newName) => { - try { - await this.props.bp.axios.put('/mod/code-editor/rename', { file, newName }) - } catch (e) { - if (e.response && e.response.status === 409) { - Toaster.create({ className: 'recipe-toaster', position: Position.TOP }).show({ - message: `File with name "${newName} already exists in the same location"`, - intent: Intent.DANGER, - timeout: 2000 - }) - } - if (e.response && e.response.status === 412) { - Toaster.create({ className: 'recipe-toaster', position: Position.TOP }).show({ - message: `Name "${newName} is invalid"`, - intent: Intent.DANGER, - timeout: 2000 - }) - } - throw e - } - await this.initialize() + constructor(props) { + super(props) + this.store = new RootStore({ bp: this.props.bp }) } - handleFileChanged = selectedFile => this.setState({ isEditing: false, selectedFile }) - handleContentChanged = (editedContent, hasChanges) => this.setState({ isEditing: hasChanges, editedContent }) - handleProblemsChanged = errors => this.setState({ errors }) - - handleDiscardChanges = async () => { - if (this.state.isEditing && this.state.editedContent) { - if (window.confirm(`Do you want to save the changes you made to ${this.state.selectedFile.name}?`)) { - await this.saveChanges() - } - } - - this.setState({ isEditing: false, selectedFile: undefined }) + componentDidMount() { + // tslint:disable-next-line: no-floating-promises + this.store.initialize() } render() { + const keyMap = { newFile: 'ctrl+alt+n' } + const keyHandlers = { newFile: this.store.createNewAction } + return ( - - - {this.state.selectedFile && ( - - )} - {!this.state.selectedFile && } - + + + + + + ) } } - -interface Props { - bp: any -} - -interface State { - files: FilesDS - selectedFile: EditableFile - isEditing: boolean - editedContent: string - errors: monaco.editor.IMarker[] -} diff --git a/modules/code-editor/src/views/full/store/api.ts b/modules/code-editor/src/views/full/store/api.ts new file mode 100644 index 00000000000..44a10c83961 --- /dev/null +++ b/modules/code-editor/src/views/full/store/api.ts @@ -0,0 +1,73 @@ +import _ from 'lodash' + +import { EditableFile, FilesDS } from '../../../backend/typings' +import { Config } from '../typings' +import { toastFailure } from '../utils' + +export default class CodeEditorApi { + private axios + + constructor(axiosInstance) { + this.axios = axiosInstance + } + + async fetchConfig(): Promise { + try { + const { data } = await this.axios.get('/mod/code-editor/config') + return data + } catch (err) { + console.error(`Error while fetching code editor config`, err) + } + } + + async fetchFiles(): Promise { + try { + const { data } = await this.axios.get('/mod/code-editor/files') + return data + } catch (err) { + this.handleApiError(err, 'Could not fetch files from server') + } + } + + async fetchTypings(): Promise { + try { + const { data } = await this.axios.get('/mod/code-editor/typings') + return data + } catch (err) { + console.error(`Error while fetching typings`, err) + } + } + + async deleteFile(file: EditableFile): Promise { + try { + await this.axios.post('/mod/code-editor/remove', file) + return true + } catch (err) { + this.handleApiError(err, 'Could not delete your file') + } + } + + async renameFile(file: EditableFile, newName: string): Promise { + try { + await this.axios.put('/mod/code-editor/rename', { file, newName }) + return true + } catch (err) { + this.handleApiError(err, 'Could not rename file') + } + } + + async saveFile(file: EditableFile): Promise { + try { + await this.axios.post('/mod/code-editor/save', file) + return true + } catch (err) { + this.handleApiError(err, 'Could not save your file') + } + } + + handleApiError = (error, customMessage?: string) => { + const data = _.get(error, 'response.data', {}) + const errorInfo = data.full || data.message + customMessage ? toastFailure(`${customMessage}: ${errorInfo}`) : toastFailure(errorInfo) + } +} diff --git a/modules/code-editor/src/views/full/store/editor.ts b/modules/code-editor/src/views/full/store/editor.ts new file mode 100644 index 00000000000..d152a462899 --- /dev/null +++ b/modules/code-editor/src/views/full/store/editor.ts @@ -0,0 +1,117 @@ +import { action, computed, observable } from 'mobx' +import * as monaco from 'monaco-editor/esm/vs/editor/editor.api' + +import { EditableFile } from '../../../backend/typings' +import { calculateHash, toastSuccess } from '../utils' +import { wrapper } from '../utils/wrapper' + +import { RootStore } from '.' + +class EditorStore { + /** Reference to monaco the editor so we can call triggers */ + private _editorRef: monaco.editor.IStandaloneCodeEditor + private rootStore: RootStore + + @observable + public currentFile: EditableFile + + @observable + public fileContentWrapped: string + + @observable + public fileContent: string + + @observable + public fileProblems: monaco.editor.IMarker[] + + @observable + private _isFileLoaded: boolean + + @observable + private _originalHash: string + + constructor(rootStore) { + this.rootStore = rootStore + } + + @computed + get isDirty() { + return this.fileContentWrapped && this._originalHash !== calculateHash(this.fileContentWrapped) + } + + @computed + get isOpenedFile() { + return !!this.currentFile && this._isFileLoaded + } + + @action.bound + openFile(file: EditableFile) { + const { content, type, hookType } = file + + this.fileContent = content + this.fileContentWrapped = wrapper.add(content, type, hookType) + + this.currentFile = file + this._isFileLoaded = true + + this.resetOriginalHash() + } + + @action.bound + updateContent(newContent: string) { + this.fileContentWrapped = newContent + this.fileContent = wrapper.remove(newContent, this.currentFile.type) + } + + @action.bound + resetOriginalHash() { + this._originalHash = calculateHash(this.fileContentWrapped) + } + + @action.bound + setFileProblems(problems) { + this.fileProblems = problems + } + + @action.bound + async saveChanges() { + if (!this.fileContent) { + return + } + + await this._editorRef.getAction('editor.action.formatDocument').run() + + if (await this.rootStore.api.saveFile({ ...this.currentFile, content: this.fileContent })) { + toastSuccess('File saved successfully!') + + await this.rootStore.fetchFiles() + this.resetOriginalHash() + } + } + + @action.bound + async discardChanges() { + if (this.isDirty && this.fileContent) { + if (window.confirm(`Do you want to save the changes you made to ${this.currentFile.name}?`)) { + await this.saveChanges() + } + } + + this.closeFile() + } + + @action.bound + closeFile() { + this._isFileLoaded = false + this.currentFile = undefined + this.fileContent = undefined + this.fileContentWrapped = undefined + } + + @action.bound + setMonacoEditor(editor) { + this._editorRef = editor + } +} + +export { EditorStore } diff --git a/modules/code-editor/src/views/full/store/index.ts b/modules/code-editor/src/views/full/store/index.ts new file mode 100644 index 00000000000..25cf1c69dcb --- /dev/null +++ b/modules/code-editor/src/views/full/store/index.ts @@ -0,0 +1,169 @@ +import { action, observable, runInAction } from 'mobx' +import path from 'path' + +import { EditableFile, FilesDS, FileType } from '../../../backend/typings' +import { Config, FileFilters, StudioConnector } from '../typings' +import { FILENAME_REGEX, toastSuccess } from '../utils' +import { baseAction, baseHook } from '../utils/templates' + +import CodeEditorApi from './api' +import { EditorStore } from './editor' + +/** Includes the partial definitions of all classes */ +export type StoreDef = Partial & Partial & Partial + +class RootStore { + public api: CodeEditorApi + public editor: EditorStore + + public config: Config + public typings: { [fileName: string]: string } = {} + + @observable + public files: FilesDS + + @observable + public filters: FileFilters + + @observable + public fileFilter: string + + constructor({ bp }) { + this.api = new CodeEditorApi(bp.axios) + this.editor = new EditorStore(this) + // Object required for the observer to be useful. + this.filters = { + filename: '' + } + } + + @action.bound + async initialize(): Promise { + try { + await this.fetchConfig() + await this.fetchFiles() + await this.fetchTypings() + } catch (err) { + console.error('Error while fetching data', err) + } + } + + @action.bound + async fetchConfig() { + const config = await this.api.fetchConfig() + runInAction('-> setEditorConfig', () => { + this.config = config + }) + } + + @action.bound + async fetchFiles() { + const files = await this.api.fetchFiles() + runInAction('-> setFiles', () => { + this.files = files + }) + } + + @action.bound + async fetchTypings() { + const typings = await this.api.fetchTypings() + runInAction('-> setTypings', () => { + this.typings = typings + }) + + return this.typings + } + + @action.bound + setFiles(messages) { + this.files = messages + } + + @action.bound + setFilenameFilter(filter: string) { + this.filters.filename = filter + } + + @action.bound + createFilePrompt(type: FileType, isGlobal?: boolean, hookType?: string) { + let name = window.prompt(`Choose the name of your ${type}. No special chars. Use camel case`) + if (!name) { + return + } + + if (!FILENAME_REGEX.test(name)) { + alert('Invalid filename') + return + } + + name = name.endsWith('.js') ? name : name + '.js' + + this.editor.openFile({ + name, + location: name, + content: type === 'action' ? baseAction : baseHook, + type, + hookType, + botId: isGlobal ? undefined : window.BOT_ID + }) + } + + @action.bound + createNewAction() { + // This is called by the code editor & the shortcut, so it's the default create + return this.createFilePrompt('action', false) + } + + @action.bound + async deleteFile(file: EditableFile): Promise { + if (window.confirm(`Are you sure you want to delete the file named ${file.name}?`)) { + if (await this.api.deleteFile(file)) { + toastSuccess('File deleted successfully!') + await this.fetchFiles() + } + } + } + + @action.bound + async disableFile(file: EditableFile): Promise { + const newName = file.name.charAt(0) !== '.' ? '.' + file.name : file.name + if (await this.api.renameFile(file, newName)) { + toastSuccess('File disabled successfully!') + await this.fetchFiles() + } + } + + @action.bound + async enableFile(file: EditableFile): Promise { + const newName = file.name.charAt(0) === '.' ? file.name.substr(1) : file.name + + if (await this.api.renameFile(file, newName)) { + toastSuccess('File enabled successfully!') + await this.fetchFiles() + } + } + + @action.bound + async renameFile(file: EditableFile, newName: string) { + if (await this.api.renameFile(file, newName)) { + toastSuccess('File renamed successfully!') + await this.fetchFiles() + } + } + + @action.bound + async duplicateFile(file: EditableFile) { + const fileExt = path.extname(file.location) + const duplicate = { + ...file, + location: file.location.replace(fileExt, '_copy' + fileExt) + } + + if (await this.api.saveFile(duplicate)) { + toastSuccess('File duplicated successfully!') + await this.fetchFiles() + } + } +} + +export { RootStore } diff --git a/modules/code-editor/src/views/full/typings.d.ts b/modules/code-editor/src/views/full/typings.d.ts new file mode 100644 index 00000000000..518027733ee --- /dev/null +++ b/modules/code-editor/src/views/full/typings.d.ts @@ -0,0 +1,18 @@ +export interface StudioConnector { + /** Event emitter */ + events: any + /** An axios instance */ + axios: any + toast: any + getModuleInjector: any + loadModuleView: any +} + +export type Config = { + isGlobalAllowed: boolean + isBotConfigIncluded: boolean +} + +export interface FileFilters { + filename?: string +} diff --git a/modules/code-editor/src/views/full/utils/crypto.ts b/modules/code-editor/src/views/full/utils/crypto.ts deleted file mode 100644 index 11eab416261..00000000000 --- a/modules/code-editor/src/views/full/utils/crypto.ts +++ /dev/null @@ -1,8 +0,0 @@ -import crypto from 'crypto' - -export const calculateHash = content => { - return crypto - .createHash('sha256') - .update(content) - .digest('hex') -} diff --git a/modules/code-editor/src/views/full/utils/hotkey.ts b/modules/code-editor/src/views/full/utils/hotkey.ts deleted file mode 100644 index dcd7231711b..00000000000 --- a/modules/code-editor/src/views/full/utils/hotkey.ts +++ /dev/null @@ -1 +0,0 @@ -export const ACTION_KEY = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? 'cmd' : 'ctrl' diff --git a/modules/code-editor/src/views/full/utils/index.ts b/modules/code-editor/src/views/full/utils/index.ts new file mode 100644 index 00000000000..cb57d16418e --- /dev/null +++ b/modules/code-editor/src/views/full/utils/index.ts @@ -0,0 +1,30 @@ +import { Intent, Position, Toaster } from '@blueprintjs/core' +import crypto from 'crypto' + +export const FILENAME_REGEX = /^[0-9a-zA-Z_\-.]+$/ +export const ACTION_KEY = navigator.platform.toUpperCase().indexOf('MAC') >= 0 ? 'cmd' : 'ctrl' + +export const calculateHash = (content?: string) => { + if (!content) { + return + } + + return crypto + .createHash('sha256') + .update(content) + .digest('hex') +} + +export const toastSuccess = message => + Toaster.create({ className: 'recipe-toaster', position: Position.TOP }).show({ + message, + intent: Intent.SUCCESS, + timeout: 1000 + }) + +export const toastFailure = message => + Toaster.create({ className: 'recipe-toaster', position: Position.TOP }).show({ + message, + intent: Intent.DANGER, + timeout: 3500 + }) diff --git a/modules/code-editor/src/views/full/utils/tree.ts b/modules/code-editor/src/views/full/utils/tree.ts index 94e43bb53a1..8870fb1f8ef 100644 --- a/modules/code-editor/src/views/full/utils/tree.ts +++ b/modules/code-editor/src/views/full/utils/tree.ts @@ -44,12 +44,14 @@ export const splitPath = (location: string, expandedNodeIds: object) => { } } -export const buildTree = (files: EditableFile[], expandedNodeIds: object) => { +export const buildTree = (files: EditableFile[], expandedNodeIds: object, filterFileName: string | undefined) => { const tree: ITreeNode = { id: 'root', label: '', childNodes: [] } files.forEach(fileData => { const { folders, file } = splitPath(fileData.location, expandedNodeIds) - addNode(tree, folders, file, { nodeData: fileData }) + if (!filterFileName || !filterFileName.length || file.label.includes(filterFileName)) { + addNode(tree, folders, file, { nodeData: fileData }) + } }) return tree.childNodes diff --git a/modules/code-editor/src/views/full/utils/wrapper.ts b/modules/code-editor/src/views/full/utils/wrapper.ts index 137700dad0d..3498d0c91ed 100644 --- a/modules/code-editor/src/views/full/utils/wrapper.ts +++ b/modules/code-editor/src/views/full/utils/wrapper.ts @@ -12,11 +12,17 @@ const wrapper = { return `${ACTION_SIGNATURE}{\n${START_COMMENT}\n\n${content}\n${END_COMMENT}\n}` } else if (type === 'hook' && HOOK_SIGNATURES[hookType]) { return `${HOOK_SIGNATURES[hookType]}{\n${START_COMMENT}\n\n${content}\n${END_COMMENT}\n}` + } else if (type === 'bot_config') { + return content.replace('../../bot.config.schema.json', 'bp://types/bot.config.schema.json') } else { return `// Unknown file type` } }, - remove: content => { + remove: (content: string, type: string) => { + if (type === 'bot_config') { + return content.replace('bp://types/bot.config.schema.json', '../../bot.config.schema.json') + } + const contentStart = content.indexOf(START_COMMENT) + START_COMMENT.length const contentEnd = content.indexOf(END_COMMENT) diff --git a/modules/code-editor/yarn.lock b/modules/code-editor/yarn.lock index 519c4caf6a9..ee4774c48b8 100644 --- a/modules/code-editor/yarn.lock +++ b/modules/code-editor/yarn.lock @@ -3568,6 +3568,23 @@ mixin-object@^2.0.1: dependencies: minimist "0.0.8" +mobx-react-lite@1.4.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/mobx-react-lite/-/mobx-react-lite-1.4.0.tgz#193beb5fdddf17ae61542f65ff951d84db402351" + integrity sha512-5xCuus+QITQpzKOjAOIQ/YxNhOl/En+PlNJF+5QU4Qxn9gnNMJBbweAdEW3HnuVQbfqDYEUnkGs5hmkIIStehg== + +mobx-react@^6.1.1: + version "6.1.1" + resolved "https://registry.yarnpkg.com/mobx-react/-/mobx-react-6.1.1.tgz#24a2c8a3393890fa732b4efd34cc6dcccf6e0e7a" + integrity sha512-hjACWCTpxZf9Sv1YgWF/r6HS6Nsly1SYF22qBJeUE3j+FMfoptgjf8Zmcx2d6uzA07Cezwap5Cobq9QYa0MKUw== + dependencies: + mobx-react-lite "1.4.0" + +mobx@^5.10.1: + version "5.10.1" + resolved "https://registry.yarnpkg.com/mobx/-/mobx-5.10.1.tgz#eb0e2ff76fe376d0a42c941533260deeae5956d8" + integrity sha512-L+akEGxdkKYssejgW9ayRPx5cZYJfxvTmdBUeR3S9oeumScV7Jj57yPeh9WMEk6NWeG8Wx3H0cWhqs0pftbtmg== + module-builder@../../build/module-builder: version "1.0.0" dependencies: diff --git a/src/bp/ui-studio/src/web/components/Shared/Interface/style.scss b/src/bp/ui-studio/src/web/components/Shared/Interface/style.scss index 8673eeba17f..33c112d475d 100644 --- a/src/bp/ui-studio/src/web/components/Shared/Interface/style.scss +++ b/src/bp/ui-studio/src/web/components/Shared/Interface/style.scss @@ -36,6 +36,7 @@ $status-bar-height: 24px; .sidePanel { background-color: #fcfcfc; height: 100%; + overflow-y: auto; } .sidePanel_hidden {