diff --git a/jest.config.js b/jest.config.js index cda3d8a0986..a349c3f17e7 100644 --- a/jest.config.js +++ b/jest.config.js @@ -1,4 +1,4 @@ -const esModule = '@iconscout/react-unicons'; +const esModule = '@iconscout/react-unicons|monaco-editor/esm/vs'; module.exports = { verbose: false, @@ -16,5 +16,6 @@ module.exports = { globals: { 'ts-jest': { isolatedModules: true } }, moduleNameMapper: { '\\.svg': '/public/test/mocks/svg.ts', + '\\.css': '/public/test/mocks/style.ts', }, }; diff --git a/package.json b/package.json index 7c870a47af7..df565a59519 100644 --- a/package.json +++ b/package.json @@ -163,7 +163,8 @@ "mini-css-extract-plugin": "0.9.0", "mocha": "7.0.1", "module-alias": "2.2.2", - "monaco-editor": "0.15.6", + "monaco-editor": "0.20.0", + "monaco-editor-webpack-plugin": "1.9.0", "mutationobserver-shim": "0.3.3", "ngtemplate-loader": "2.0.1", "node-sass": "4.13.1", diff --git a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts index 2aed1e8981f..ae373082cc6 100644 --- a/packages/grafana-toolkit/src/config/webpack.plugin.config.ts +++ b/packages/grafana-toolkit/src/config/webpack.plugin.config.ts @@ -180,6 +180,8 @@ const getBaseWebpackConfig: WebpackConfigurationGetter = async options => { '@grafana/ui', '@grafana/runtime', '@grafana/data', + 'monaco-editor', + 'react-monaco-editor', // @ts-ignore (context, request, callback) => { const prefix = 'grafana/'; diff --git a/packages/grafana-ui/.storybook/webpack.config.js b/packages/grafana-ui/.storybook/webpack.config.js index 79b968a0735..77e235c324f 100644 --- a/packages/grafana-ui/.storybook/webpack.config.js +++ b/packages/grafana-ui/.storybook/webpack.config.js @@ -78,12 +78,36 @@ module.exports = ({ config, mode }) => { config.optimization = { nodeEnv: 'production', + moduleIds: 'hashed', + runtimeChunk: 'single', + splitChunks: { + chunks: 'all', + minChunks: 1, + cacheGroups: { + monaco: { + test: /[\\/]node_modules[\\/](monaco-editor)[\\/].*[jt]sx?$/, + chunks: 'initial', + priority: 20, + enforce: true, + }, + vendors: { + test: /[\\/]node_modules[\\/].*[jt]sx?$/, + chunks: 'initial', + priority: -10, + reuseExistingChunk: true, + enforce: true, + }, + default: { + priority: -20, + chunks: 'all', + test: /.*[jt]sx?$/, + reuseExistingChunk: true, + }, + }, + }, + minimize: true, minimizer: [ - new TerserPlugin({ - cache: false, - parallel: false, - sourceMap: false, - }), + new TerserPlugin({ cache: false, parallel: false, sourceMap: false, exclude: /monaco/ }), new OptimizeCSSAssetsPlugin({}), ], }; diff --git a/packages/grafana-ui/package.json b/packages/grafana-ui/package.json index 552a0baf663..415eeb2cd4b 100644 --- a/packages/grafana-ui/package.json +++ b/packages/grafana-ui/package.json @@ -47,6 +47,8 @@ "immutable": "3.8.2", "jquery": "3.5.1", "lodash": "4.17.15", + "monaco-editor": "0.20.0", + "react-monaco-editor": "0.36.0", "moment": "2.24.0", "papaparse": "4.6.3", "rc-cascader": "1.0.1", diff --git a/packages/grafana-ui/rollup.config.ts b/packages/grafana-ui/rollup.config.ts index 3389d7b5cf7..32190d735d2 100644 --- a/packages/grafana-ui/rollup.config.ts +++ b/packages/grafana-ui/rollup.config.ts @@ -25,7 +25,16 @@ const buildCjsPackage = ({ env }) => { }, }, ], - external: ['react', 'react-dom', '@grafana/data', 'moment', '@grafana/e2e-selectors'], + external: [ + 'react', + 'react-dom', + '@grafana/data', + '@grafana/e2e-selectors', + 'moment', + 'monaco-editor', // Monaco should not be used directly + 'monaco-editor/esm/vs/editor/editor.api', // Monaco should not be used directly + 'react-monaco-editor', + ], plugins: [ commonjs({ include: /node_modules/, diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.mdx b/packages/grafana-ui/src/components/Monaco/CodeEditor.mdx new file mode 100644 index 00000000000..c7d157c7b99 --- /dev/null +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.mdx @@ -0,0 +1,8 @@ +import { Meta, Story, Preview, Props } from '@storybook/addon-docs/blocks'; +import { CodeEditor } from './CodeEditor'; + + + +# CodeEditor + +Monaco Code editor \ No newline at end of file diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.story.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditor.story.tsx new file mode 100644 index 00000000000..067b707f30a --- /dev/null +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.story.tsx @@ -0,0 +1,42 @@ +import React from 'react'; +import { text } from '@storybook/addon-knobs'; +import { action } from '@storybook/addon-actions'; +import { withCenteredStory } from '../../utils/storybook/withCenteredStory'; +import mdx from './CodeEditor.mdx'; +import CodeEditor from './CodeEditor'; + +const getKnobs = () => { + return { + text: text('Body', 'SELECT * FROM table LIMIT 10'), + language: text('Language', 'sql'), + }; +}; + +export default { + title: 'CodeEditor', + component: CodeEditor, + decorators: [withCenteredStory], + parameters: { + docs: { + page: mdx, + }, + }, +}; + +export const basic = () => { + const { text, language } = getKnobs(); + return ( + { + console.log('Blur: ', text); + action('code blur')(text); + }} + onSave={(text: string) => { + console.log('Save: ', text); + action('code saved')(text); + }} + /> + ); +}; diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx new file mode 100644 index 00000000000..4dc786c1492 --- /dev/null +++ b/packages/grafana-ui/src/components/Monaco/CodeEditor.tsx @@ -0,0 +1,92 @@ +import React from 'react'; +import { withTheme } from '../../themes'; +import { Themeable } from '../../types'; +import { KeyCode, editor, KeyMod } from 'monaco-editor/esm/vs/editor/editor.api'; +import ReactMonaco from 'react-monaco-editor'; + +export interface CodeEditorProps { + value: string; + language: string; + width?: number | string; + height?: number | string; + + readOnly?: boolean; + showMiniMap?: boolean; + + /** + * Callback after the editor has mounted that gives you raw access to monaco + * + * @experimental + */ + onEditorDidMount?: (editor: editor.IStandaloneCodeEditor) => void; + + /** Handler to be performed when editor is blurred */ + onBlur?: CodeEditorChangeHandler; + + /** Handler to be performed when Cmd/Ctrl+S is pressed */ + onSave?: CodeEditorChangeHandler; +} + +type Props = CodeEditorProps & Themeable; + +class UnthemedCodeEditor extends React.PureComponent { + getEditorValue = () => ''; + + onBlur = () => { + const { onBlur } = this.props; + if (onBlur) { + onBlur(this.getEditorValue()); + } + }; + + editorDidMount = (editor: editor.IStandaloneCodeEditor) => { + const { onSave, onEditorDidMount } = this.props; + + this.getEditorValue = () => editor.getValue(); + + if (onSave) { + editor.addCommand(KeyMod.CtrlCmd | KeyCode.KEY_S, () => { + onSave(this.getEditorValue()); + }); + } + + if (onEditorDidMount) { + onEditorDidMount(editor); + } + }; + + render() { + const { theme, language, width, height, showMiniMap, readOnly } = this.props; + const value = this.props.value ?? ''; + const longText = value.length > 100; + + return ( +
+ +
+ ); + } +} + +export type CodeEditorChangeHandler = (value: string) => void; +export default withTheme(UnthemedCodeEditor); diff --git a/packages/grafana-ui/src/components/Monaco/CodeEditorLazy.tsx b/packages/grafana-ui/src/components/Monaco/CodeEditorLazy.tsx new file mode 100644 index 00000000000..b32cd2c936c --- /dev/null +++ b/packages/grafana-ui/src/components/Monaco/CodeEditorLazy.tsx @@ -0,0 +1,29 @@ +import React from 'react'; +import { useAsyncDependency } from '../../utils/useAsyncDependency'; +import { ErrorWithStack, LoadingPlaceholder } from '..'; +import { CodeEditorProps } from './CodeEditor'; + +export type CodeEditorChangeHandler = (value: string) => void; + +export const CodeEditor: React.FC = props => { + const { loading, error, dependency } = useAsyncDependency( + import(/* webpackChunkName: "code-editor" */ './CodeEditor') + ); + + if (loading) { + return ; + } + + if (error) { + return ( + + ); + } + + const CodeEditor = dependency.default; + return ; +}; diff --git a/packages/grafana-ui/src/components/index.ts b/packages/grafana-ui/src/components/index.ts index 8571cbff66d..f5d9f95c329 100644 --- a/packages/grafana-ui/src/components/index.ts +++ b/packages/grafana-ui/src/components/index.ts @@ -34,6 +34,7 @@ export { FilterPill } from './FilterPill/FilterPill'; export { ConfirmModal } from './ConfirmModal/ConfirmModal'; export { QueryField } from './QueryField/QueryField'; +export { CodeEditor } from './Monaco/CodeEditorLazy'; // TODO: namespace export { Modal } from './Modal/Modal'; diff --git a/packages/grafana-ui/src/utils/useAsyncDependency.ts b/packages/grafana-ui/src/utils/useAsyncDependency.ts new file mode 100644 index 00000000000..18028e0a96b --- /dev/null +++ b/packages/grafana-ui/src/utils/useAsyncDependency.ts @@ -0,0 +1,13 @@ +import { useAsync } from 'react-use'; + +// Allows simple dynamic imports in the components +export const useAsyncDependency = (importStatement: Promise) => { + const state = useAsync(async () => { + return await importStatement; + }); + + return { + ...state, + dependency: state.value, + }; +}; diff --git a/public/app/features/dashboard/components/Inspector/InspectJSONTab.tsx b/public/app/features/dashboard/components/Inspector/InspectJSONTab.tsx index fe46f155068..0e236b62b96 100644 --- a/public/app/features/dashboard/components/Inspector/InspectJSONTab.tsx +++ b/public/app/features/dashboard/components/Inspector/InspectJSONTab.tsx @@ -1,9 +1,9 @@ import React, { PureComponent } from 'react'; import { chain } from 'lodash'; import { AppEvents, PanelData, SelectableValue } from '@grafana/data'; -import { Button, ClipboardButton, Field, JSONFormatter, Select, TextArea } from '@grafana/ui'; +import { Button, CodeEditor, Field, Select } from '@grafana/ui'; +import AutoSizer from 'react-virtualized-auto-sizer'; import { selectors } from '@grafana/e2e-selectors'; - import { appEvents } from 'app/core/core'; import { DashboardModel, PanelModel } from '../../state'; import { getPanelInspectorStyles } from './styles'; @@ -49,20 +49,18 @@ export class InspectJSONTab extends PureComponent { super(props); this.state = { show: ShowContent.PanelJSON, - text: getSaveModelJSON(props.panel), + text: getPrettyJSON(props.panel.getSaveModel()), }; } onSelectChanged = (item: SelectableValue) => { - let text = ''; - if (item.value === ShowContent.PanelJSON) { - text = getSaveModelJSON(this.props.panel); - } + const show = this.getJSONObject(item.value); + const text = getPrettyJSON(show); this.setState({ text, show: item.value }); }; - onTextChanged = (e: React.FormEvent) => { - const text = e.currentTarget.value; + // Called onBlur + onTextChanged = (text: string) => { this.setState({ text }); }; @@ -93,17 +91,7 @@ export class InspectJSONTab extends PureComponent { return this.props.panel.getSaveModel(); } - return { note: 'Unknown Object', show }; - }; - - getClipboardText = () => { - const { show } = this.state; - const obj = this.getJSONObject(show); - return JSON.stringify(obj, null, 2); - }; - - onClipboardCopied = () => { - appEvents.emit(AppEvents.alertSuccess, ['Content copied to clipboard']); + return { note: `Unknown Object: ${show}` }; }; onApplyPanelModel = () => { @@ -126,12 +114,6 @@ export class InspectJSONTab extends PureComponent { onClose(); }; - renderPanelJSON(styles: any) { - return ( -