diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts new file mode 100644 index 0000000000000..10a42b7a194a4 --- /dev/null +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.ts @@ -0,0 +1,47 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +export const WORLD_HEALTH_DASHBOARD = '/superset/dashboard/world_health/'; +export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/'; + +export const CHECK_DASHBOARD_FAVORITE_ENDPOINT = + '/superset/favstar/Dashboard/*/count'; + +/** + * Drag an element and drop it to another element. + * Usage: + * drag(source).to(target); + */ +export function drag(selector: string, content: string | number | RegExp) { + const dataTransfer = { data: {} }; + return { + to(target: string | Cypress.Chainable) { + cy.get('.dragdroppable') + .contains(selector, content) + .trigger('mousedown', { which: 1 }) + .trigger('dragstart', { dataTransfer }) + .trigger('drag', {}); + + (typeof target === 'string' ? cy.get(target) : target) + .trigger('dragover', { dataTransfer }) + .trigger('drop', { dataTransfer }) + .trigger('dragend', { dataTransfer }) + .trigger('mouseup', { which: 1 }); + }, + }; +} diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js index 1db86b5cda794..6202eb9f853e6 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/edit_mode.test.js @@ -16,7 +16,7 @@ * specific language governing permissions and limitations * under the License. */ -import { WORLD_HEALTH_DASHBOARD } from './dashboard.helper'; +import { WORLD_HEALTH_DASHBOARD, drag } from './dashboard.helper'; describe('Dashboard edit mode', () => { beforeEach(() => { @@ -45,19 +45,9 @@ describe('Dashboard edit mode', () => { .find('.chart-card-container') .contains('Box plot'); - // drag-n-drop - const dataTransfer = { data: {} }; - cy.get('.dragdroppable') - .contains('Box plot') - .trigger('mousedown', { which: 1 }) - .trigger('dragstart', { dataTransfer }) - .trigger('drag', {}); - cy.get('.grid-content div.grid-row.background--transparent') - .last() - .trigger('dragover', { dataTransfer }) - .trigger('drop', { dataTransfer }) - .trigger('dragend', { dataTransfer }) - .trigger('mouseup', { which: 1 }); + drag('.chart-card', 'Box plot').to( + '.grid-row.background--transparent:last', + ); // add back to dashboard cy.get('.grid-container .box_plot').should('be.exist'); diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts b/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts new file mode 100644 index 0000000000000..a5f470fa52dd4 --- /dev/null +++ b/superset-frontend/cypress-base/cypress/integration/dashboard/markdown.test.ts @@ -0,0 +1,64 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import { TABBED_DASHBOARD, drag } from './dashboard.helper'; + +describe('Dashboard edit markdown', () => { + beforeEach(() => { + cy.server(); + cy.login(); + cy.visit(TABBED_DASHBOARD); + }); + + it('should load AceEditor on demand', () => { + let numScripts = 0; + cy.get('script').then(nodes => { + numScripts = nodes.length; + }); + cy.get('.dashboard-header [data-test=pencil]').click(); + cy.get('script').then(nodes => { + // load 5 new script chunks for css editor + expect(nodes.length).to.greaterThan(numScripts); + numScripts = nodes.length; + }); + + // add new markdown component + drag('.new-component', 'Markdown').to( + '.grid-row.background--transparent:first', + ); + cy.get('script').then(nodes => { + // load more scripts for markdown editor + expect(nodes.length).to.greaterThan(numScripts); + numScripts = nodes.length; + }); + + cy.contains('h3', '✨Markdown').click(); + cy.get('.ace_content').contains( + 'Click here to edit [markdown](https://bit.ly/1dQOfRK)', + ); + + // entering edit mode does not add new scripts + // (though scripts may still be removed by others) + cy.get('script').then(nodes => { + expect(nodes.length).to.most(numScripts); + }); + + cy.get('.grid-row.background--transparent:first').click('right'); + cy.get('.ace_content').should('not.exist'); + }); +}); diff --git a/superset-frontend/cypress-base/cypress/integration/explore/AdhocFilters.test.ts b/superset-frontend/cypress-base/cypress/integration/explore/AdhocFilters.test.ts index c51da29acd849..d1969f341d862 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/AdhocFilters.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/AdhocFilters.test.ts @@ -25,14 +25,31 @@ describe('AdhocFilters', () => { cy.route('GET', '/superset/filter/table/*/name').as('filterValues'); }); - it('Set simple adhoc filter', () => { - cy.visitChartByName('Num Births Trend'); + it('Should not load mathjs when not needed', () => { + cy.visitChartByName('Boys'); // a table chart cy.verifySliceSuccess({ waitAlias: '@postJson' }); + cy.get('script[src*="mathjs"]').should('have.length', 0); + }); + + let numScripts = 0; + + it('Should load AceEditor scripts when needed', () => { + cy.get('script').then(nodes => { + numScripts = nodes.length; + }); cy.get('[data-test=adhoc_filters]').within(() => { - cy.get('.Select__control').click(); + cy.get('.Select__control').scrollIntoView().click(); cy.get('input[type=text]').focus().type('name{enter}'); }); + + cy.get('script').then(nodes => { + // should load new script chunks for SQL editor + expect(nodes.length).to.greaterThan(numScripts); + }); + }); + + it('Set simple adhoc filter', () => { cy.get('#filter-edit-popover').within(() => { cy.get('[data-test=adhoc-filter-simple-value]').within(() => { cy.get('.Select__control').click(); @@ -40,7 +57,6 @@ describe('AdhocFilters', () => { }); cy.get('button').contains('Save').click(); }); - cy.get('button[data-test="run-query-button"]').click(); cy.verifySliceSuccess({ waitAlias: '@postJson', @@ -52,19 +68,21 @@ describe('AdhocFilters', () => { cy.visitChartByName('Num Births Trend'); cy.verifySliceSuccess({ waitAlias: '@postJson' }); - cy.get('[data-test=adhoc_filters]').within(() => { - cy.get('.Select__control').click(); - cy.get('input[type=text]').focus().type('name{enter}'); - }); + cy.get('[data-test=adhoc_filters] .Select__control') + .scrollIntoView() + .click(); + cy.get('[data-test=adhoc_filters] input[type=text]') + .focus() + .type('name{enter}'); cy.wait('@filterValues'); - cy.get('#filter-edit-popover').within(() => { - cy.get('#adhoc-filter-edit-tabs-tab-SQL').click(); - cy.get('.ace_content').click(); - cy.get('.ace_text-input').type("'Amy' OR name = 'Bob'"); - cy.get('button').contains('Save').click(); - }); + cy.get('#filter-edit-popover #adhoc-filter-edit-tabs-tab-SQL').click(); + cy.get('#filter-edit-popover .ace_content').click(); + cy.get('#filter-edit-popover .ace_text-input').type( + "'Amy' OR name = 'Bob'", + ); + cy.get('#filter-edit-popover button').contains('Save').click(); cy.get('button[data-test="run-query-button"]').click(); cy.verifySliceSuccess({ diff --git a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts index 7a08611497354..647b31f7065b9 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/control.test.ts @@ -32,12 +32,25 @@ describe('Datasource control', () => { }); it('should allow edit datasource', () => { + let numScripts = 0; + cy.visitChartByName('Num Births Trend'); cy.verifySliceSuccess({ waitAlias: '@postJson' }); cy.get('#datasource_menu').click(); + + cy.get('script').then(nodes => { + numScripts = nodes.length; + }); + cy.get('a').contains('Edit Datasource').click(); + + // should load additional scripts for the modal + cy.get('script').then(nodes => { + expect(nodes.length).to.greaterThan(numScripts); + }); + // create new metric - cy.get('button').contains('Add Item').click(); + cy.get('table button').contains('Add Item', { timeout: 10000 }).click(); cy.get('input[value=""]').click(); cy.get('input[value=""]') .focus() @@ -65,19 +78,33 @@ describe('Datasource control', () => { }); }); -describe('Groupby control', () => { - it('Set groupby', () => { - cy.server(); +describe('VizType control', () => { + beforeEach(() => { cy.login(); + cy.server(); cy.route('GET', '/superset/explore_json/**').as('getJson'); cy.route('POST', '/superset/explore_json/**').as('postJson'); - cy.visitChartByName('Num Births Trend'); + }); + + it('Can change vizType', () => { + cy.visitChartByName('Daily Totals'); cy.verifySliceSuccess({ waitAlias: '@postJson' }); - cy.get('[data-test=groupby]').within(() => { - cy.get('.Select__control').click(); - cy.get('input[type=text]').type('state{enter}'); + let numScripts = 0; + cy.get('script').then(nodes => { + numScripts = nodes.length; }); + + cy.get('.Control .label').contains('Table').click(); + + cy.get('[role="button"]').contains('Line Chart').click(); + + // should load mathjs for line chart + cy.get('script[src*="mathjs"]').should('have.length', 1); + cy.get('script').then(nodes => { + expect(nodes.length).to.greaterThan(numScripts); + }); + cy.get('button[data-test="run-query-button"]').click(); cy.verifySliceSuccess({ waitAlias: '@postJson', chartSelector: 'svg' }); }); @@ -118,3 +145,21 @@ describe('Time range filter', () => { cy.get('#filter-popover').should('not.exist'); }); }); + +describe('Groupby control', () => { + it('Set groupby', () => { + cy.server(); + cy.login(); + cy.route('GET', '/superset/explore_json/**').as('getJson'); + cy.route('POST', '/superset/explore_json/**').as('postJson'); + cy.visitChartByName('Num Births Trend'); + cy.verifySliceSuccess({ waitAlias: '@postJson' }); + + cy.get('[data-test=groupby]').within(() => { + cy.get('.Select__control').click(); + cy.get('input[type=text]').type('state{enter}'); + }); + cy.get('button[data-test="run-query-button"]').click(); + cy.verifySliceSuccess({ waitAlias: '@postJson', chartSelector: 'svg' }); + }); +}); diff --git a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/line.test.ts b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/line.test.ts index 34f80f2b4916f..9467c71f5a4e0 100644 --- a/superset-frontend/cypress-base/cypress/integration/explore/visualizations/line.test.ts +++ b/superset-frontend/cypress-base/cypress/integration/explore/visualizations/line.test.ts @@ -33,6 +33,14 @@ describe('Visualization > Line', () => { cy.get('.alert-warning').contains(`"Metrics" cannot be empty`); }); + it('should preload mathjs', () => { + cy.get('script[src*="mathjs"]').should('have.length', 1); + cy.contains('Add Annotation Layer').scrollIntoView().click(); + // should not load additional mathjs + cy.get('script[src*="mathjs"]').should('have.length', 1); + cy.contains('Layer Configuration'); + }); + it('should not show validator error when metric added', () => { const formData = { ...LINE_CHART_DEFAULTS, metrics: [] }; cy.visitChartByParams(JSON.stringify(formData)); @@ -68,6 +76,7 @@ describe('Visualization > Line', () => { const formData = { ...LINE_CHART_DEFAULTS, metrics: [NUM_METRIC] }; cy.visitChartByParams(JSON.stringify(formData)); cy.verifySliceSuccess({ waitAlias: '@getJson', chartSelector: 'svg' }); + cy.get('script[src*="mathjs"]').should('have.length', 1); }); it('should work with groupby', () => { diff --git a/superset-frontend/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx b/superset-frontend/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx index e73c48f68372e..ad82b52f82c9a 100644 --- a/superset-frontend/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx +++ b/superset-frontend/spec/javascripts/dashboard/components/gridComponents/Markdown_spec.jsx @@ -20,9 +20,9 @@ import { Provider } from 'react-redux'; import React from 'react'; import { mount } from 'enzyme'; import sinon from 'sinon'; -import AceEditor from 'react-ace'; import ReactMarkdown from 'react-markdown'; +import { MarkdownEditor } from 'src/components/AsyncAceEditor'; import Markdown from 'src/dashboard/components/gridComponents/Markdown'; import MarkdownModeDropdown from 'src/dashboard/components/menu/MarkdownModeDropdown'; import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton'; @@ -105,23 +105,23 @@ describe('Markdown', () => { it('should render an Markdown when NOT focused', () => { const wrapper = setup(); - expect(wrapper.find(AceEditor)).not.toExist(); + expect(wrapper.find(MarkdownEditor)).not.toExist(); expect(wrapper.find(ReactMarkdown)).toExist(); }); it('should render an AceEditor when focused and editMode=true and editorMode=edit', () => { const wrapper = setup({ editMode: true }); - expect(wrapper.find(AceEditor)).not.toExist(); + expect(wrapper.find(MarkdownEditor)).not.toExist(); expect(wrapper.find(ReactMarkdown)).toExist(); wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit - expect(wrapper.find(AceEditor)).toExist(); + expect(wrapper.find(MarkdownEditor)).toExist(); expect(wrapper.find(ReactMarkdown)).not.toExist(); }); it('should render a ReactMarkdown when focused and editMode=true and editorMode=preview', () => { const wrapper = setup({ editMode: true }); wrapper.find(WithPopoverMenu).simulate('click'); // focus + edit - expect(wrapper.find(AceEditor)).toExist(); + expect(wrapper.find(MarkdownEditor)).toExist(); expect(wrapper.find(ReactMarkdown)).not.toExist(); // we can't call setState on Markdown bc it's not the root component, so call @@ -131,7 +131,7 @@ describe('Markdown', () => { wrapper.update(); expect(wrapper.find(ReactMarkdown)).toExist(); - expect(wrapper.find(AceEditor)).not.toExist(); + expect(wrapper.find(MarkdownEditor)).not.toExist(); }); it('should call updateComponents when editMode changes from edit => preview, and there are markdownSource changes', () => { @@ -148,7 +148,7 @@ describe('Markdown', () => { dropdown.prop('onChange')('edit'); // because we can't call setState on Markdown, change it through the editor // then go back to preview mode to invoke updateComponents - const editor = wrapper.find(AceEditor); + const editor = wrapper.find(MarkdownEditor); editor.prop('onChange')('new markdown!'); dropdown.prop('onChange')('preview'); expect(updateComponents.callCount).toBe(1); diff --git a/superset-frontend/spec/javascripts/explore/components/TextArea_spec.jsx b/superset-frontend/spec/javascripts/explore/components/TextArea_spec.jsx index 4c656b5e1af74..57c34b24559f2 100644 --- a/superset-frontend/spec/javascripts/explore/components/TextArea_spec.jsx +++ b/superset-frontend/spec/javascripts/explore/components/TextArea_spec.jsx @@ -21,7 +21,7 @@ import React from 'react'; import { FormControl } from 'react-bootstrap'; import sinon from 'sinon'; import { shallow } from 'enzyme'; -import AceEditor from 'react-ace'; +import { TextAreaEditor } from 'src/components/AsyncAceEditor'; import TextAreaControl from 'src/explore/components/controls/TextAreaControl'; @@ -52,6 +52,6 @@ describe('SelectControl', () => { props.language = 'markdown'; wrapper = shallow(); expect(wrapper.find(FormControl)).not.toExist(); - expect(wrapper.find(AceEditor)).toExist(); + expect(wrapper.find(TextAreaEditor)).toExist(); }); }); diff --git a/superset-frontend/src/CRUD/CollectionTable.tsx b/superset-frontend/src/CRUD/CollectionTable.tsx index 661547fbe55f5..f1620f43e9563 100644 --- a/superset-frontend/src/CRUD/CollectionTable.tsx +++ b/superset-frontend/src/CRUD/CollectionTable.tsx @@ -175,10 +175,10 @@ export default class CRUDCollection extends React.PureComponent< ))} {extraButtons} {allowDeletes && !allowAddItem && ( - + )} {allowAddItem && ( - + @@ -237,7 +237,7 @@ export default class CRUDCollection extends React.PureComponent< )), ); if (allowAddItem) { - tds.push(); + tds.push(); } if (allowDeletes) { tds.push( diff --git a/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx b/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx index a05ef3cdfae77..95c6eb8cfcfb1 100644 --- a/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx +++ b/superset-frontend/src/SqlLab/components/AceEditorWrapper.tsx @@ -17,11 +17,6 @@ * under the License. */ import React from 'react'; -import AceEditor from 'react-ace'; -import 'brace/mode/sql'; -import 'brace/theme/github'; -import 'brace/ext/language_tools'; -import ace from 'brace'; import { areArraysShallowEqual } from 'src/reduxUtils'; import sqlKeywords from 'src/SqlLab/utils/sqlKeywords'; import { @@ -30,8 +25,11 @@ import { COLUMN_AUTOCOMPLETE_SCORE, SQL_FUNCTIONS_AUTOCOMPLETE_SCORE, } from 'src/SqlLab/constants'; - -const langTools = ace.acequire('ace/ext/language_tools'); +import { + Editor, + AceCompleterKeyword, + FullSQLEditor as AceEditor, +} from 'src/components/AsyncAceEditor'; type HotKey = { key: string; @@ -61,7 +59,7 @@ interface Props { interface State { sql: string; selectedText: string; - words: any[]; + words: AceCompleterKeyword[]; } class AceEditorWrapper extends React.PureComponent { @@ -151,43 +149,6 @@ class AceEditorWrapper extends React.PureComponent { this.props.onChange(text); } - getCompletions( - aceEditor: any, - session: any, - pos: any, - prefix: string, - callback: (p0: any, p1: any[]) => void, - ) { - // If the prefix starts with a number, don't try to autocomplete with a - // table name or schema or anything else - if (!Number.isNaN(parseInt(prefix, 10))) { - return; - } - const completer = { - insertMatch: (editor: any, data: any) => { - if (data.meta === 'table') { - this.props.actions.addTable( - this.props.queryEditor, - data.value, - this.props.queryEditor.schema, - ); - } - editor.completer.insertMatch({ - value: `${data.caption}${ - ['function', 'schema'].includes(data.meta) ? '' : ' ' - }`, - }); - }, - }; - // Mutate instead of object spread here for performance - const words = this.state.words.map(word => { - /* eslint-disable-next-line no-param-reassign */ - word.completer = completer; - return word; - }); - callback(null, words); - } - setAutoCompleter(props: Props) { // Loading schema, table and column names as auto-completable words const schemas = props.schemas || []; @@ -229,20 +190,35 @@ class AceEditorWrapper extends React.PureComponent { meta: 'function', })); + const completer = { + insertMatch: (editor: Editor, data: any) => { + if (data.meta === 'table') { + this.props.actions.addTable( + this.props.queryEditor, + data.value, + this.props.queryEditor.schema, + ); + } + // executing https://github.com/thlorenz/brace/blob/3a00c5d59777f9d826841178e1eb36694177f5e6/ext/language_tools.js#L1448 + editor.completer.insertMatch( + `${data.caption}${ + ['function', 'schema'].includes(data.meta) ? '' : ' ' + }`, + ); + }, + }; + const words = schemaWords .concat(tableWords) .concat(columnWords) .concat(functionWords) - .concat(sqlKeywords); + .concat(sqlKeywords) + .map(word => ({ + ...word, + completer, + })); - this.setState({ words }, () => { - const completer = { - getCompletions: this.getCompletions.bind(this), - }; - if (langTools) { - langTools.setCompleters([completer]); - } - }); + this.setState({ words }); } getAceAnnotations() { @@ -263,8 +239,7 @@ class AceEditorWrapper extends React.PureComponent { render() { return ( {this.renderDoc()} - void; +} + +export type Editor = OrigEditor & { + completer: AceCompleter; + completers: AceCompleter[]; +}; + +export interface AceCompleterKeyword extends AceCompleterKeywordData { + completer?: AceCompleter; +} + +/** + * Async loaders to import brace modules. Must manually create call `import(...)` + * promises because webpack can only analyze asycn imports statically. + */ +const aceModuleLoaders = { + 'mode/sql': () => import('brace/mode/sql'), + 'mode/markdown': () => import('brace/mode/markdown'), + 'mode/css': () => import('brace/mode/css'), + 'mode/json': () => import('brace/mode/json'), + 'mode/yaml': () => import('brace/mode/yaml'), + 'mode/html': () => import('brace/mode/html'), + 'mode/javascript': () => import('brace/mode/javascript'), + 'theme/textmate': () => import('brace/theme/textmate'), + 'theme/github': () => import('brace/theme/github'), + 'ext/language_tools': () => import('brace/ext/language_tools'), +}; + +export type AceModule = keyof typeof aceModuleLoaders; + +export type AsyncAceEditorProps = AceEditorProps & { + keywords?: AceCompleterKeyword[]; +}; + +export type AceEditorMode = 'sql'; +export type AceEditorTheme = 'textmate' | 'github'; +export type AsyncAceEditorOptions = { + defaultMode?: AceEditorMode; + defaultTheme?: AceEditorTheme; + defaultTabSize?: number; + placeholder?: React.ComponentType< + PlaceholderProps & Partial + > | null; +}; + +/** + * Get an async AceEditor with automatical loading of specified ace modules. + */ +export default function AsyncAceEditor( + aceModules: AceModule[], + { + defaultMode, + defaultTheme, + defaultTabSize = 2, + placeholder, + }: AsyncAceEditorOptions = {}, +) { + return AsyncEsmComponent(async () => { + const { default: ace } = await import('brace'); + const { default: ReactAceEditor } = await import('react-ace'); + + await Promise.all(aceModules.map(x => aceModuleLoaders[x]())); + + const inferredMode = + defaultMode || + aceModules.find(x => x.startsWith('mode/'))?.replace('mode/', ''); + const inferredTheme = + defaultTheme || + aceModules.find(x => x.startsWith('theme/'))?.replace('theme/', ''); + + return React.forwardRef( + function ExtendedAceEditor( + { + keywords, + mode = inferredMode, + theme = inferredTheme, + tabSize = defaultTabSize, + ...props + }, + ref, + ) { + if (keywords) { + const langTools = ace.acequire('ace/ext/language_tools'); + const completer = { + getCompletions: ( + editor: AceEditor, + session: IEditSession, + pos: Position, + prefix: string, + callback: (error: null, wordList: object[]) => void, + ) => { + // If the prefix starts with a number, don't try to autocomplete + if (!Number.isNaN(parseInt(prefix, 10))) { + return; + } + if ((session.getMode() as TextMode).$id === `ace/mode/${mode}`) { + callback(null, keywords); + } + }, + }; + langTools.setCompleters([completer]); + } + return ( + + ); + }, + ); + }, placeholder); +} + +export const SQLEditor = AsyncAceEditor([ + 'mode/sql', + 'theme/github', + 'ext/language_tools', +]); + +export const FullSQLEditor = AsyncAceEditor( + ['mode/sql', 'theme/github', 'ext/language_tools'], + { + // a custom placeholder in SQL lab for less jumpy re-renders + placeholder: () => { + const gutterBackground = '#e8e8e8'; // from ace-github theme + return ( +
+
+ {/* make it possible to resize the placeholder */} +
+
+ ); + }, + }, +); + +export const MarkdownEditor = AsyncAceEditor([ + 'mode/markdown', + 'theme/textmate', +]); + +export const TextAreaEditor = AsyncAceEditor([ + 'mode/markdown', + 'mode/sql', + 'mode/json', + 'mode/html', + 'mode/javascript', + 'theme/textmate', +]); + +export const CssEditor = AsyncAceEditor(['mode/css', 'theme/github']); + +export const JsonEditor = AsyncAceEditor(['mode/json', 'theme/github']); + +/** + * JSON or Yaml config editor. + */ +export const ConfigEditor = AsyncAceEditor([ + 'mode/json', + 'mode/yaml', + 'theme/github', +]); diff --git a/superset-frontend/src/components/AsyncEsmComponent.tsx b/superset-frontend/src/components/AsyncEsmComponent.tsx new file mode 100644 index 0000000000000..a502926f31d22 --- /dev/null +++ b/superset-frontend/src/components/AsyncEsmComponent.tsx @@ -0,0 +1,132 @@ +/** + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +import React, { CSSProperties, useEffect, useState, RefObject } from 'react'; +import Loading from './Loading'; + +export type PlaceholderProps = { + showLoadingForImport?: boolean; + width?: string | number; + height?: string | number; + placeholderStyle?: CSSProperties; +} & { + [key: string]: any; +}; + +function DefaultPlaceholder({ + width, + height, + showLoadingForImport = false, + placeholderStyle: style, +}: PlaceholderProps) { + return ( + // since `width` defaults to 100%, we can display the placeholder once + // height is specified. + (height && ( +
+ {showLoadingForImport && } +
+ )) || + // `|| null` is for in case of height=0. + null + ); +} + +/** + * Asynchronously import an ES module as a React component, render a placeholder + * first (if provided) and re-render once import is complete. + */ +export default function AsyncEsmComponent< + P = PlaceholderProps, + M = React.ComponentType

| { default: React.ComponentType

} +>( + /** + * A promise generator that returns the React component to render. + */ + loadComponent: Promise | (() => Promise), + /** + * Placeholder while still importing. + */ + placeholder: React.ComponentType< + PlaceholderProps & Partial

+ > | null = DefaultPlaceholder, +) { + // component props + placeholder props + type FullProps = P & PlaceholderProps; + let promise: Promise | undefined; + let component: React.ComponentType; + + /** + * Safely wait for promise, make sure the loader function only execute once. + */ + function waitForPromise() { + if (!promise) { + // load component on initialization + promise = + loadComponent instanceof Promise ? loadComponent : loadComponent(); + } + if (!component) { + promise.then(result => { + component = ((result as { default?: React.ComponentType

}).default || + result) as React.ComponentType; + }); + } + return promise; + } + + type AsyncComponent = React.ForwardRefExoticComponent< + React.PropsWithoutRef & + React.RefAttributes> + > & { + preload?: typeof waitForPromise; + }; + + const AsyncComponent: AsyncComponent = React.forwardRef( + function AsyncComponent( + props: FullProps, + ref: RefObject>, + ) { + const [loaded, setLoaded] = useState(component !== undefined); + useEffect(() => { + let isMounted = true; + if (!loaded) { + // update state to trigger a re-render + waitForPromise().then(() => { + if (isMounted) { + setLoaded(true); + } + }); + } + return () => { + isMounted = false; + }; + }); + const Component = component || placeholder; + return Component ? ( + // placeholder does not get the ref + + ) : null; + }, + ); + // preload the async component before rendering + AsyncComponent.preload = waitForPromise; + + return AsyncComponent as AsyncComponent & { + preload: typeof waitForPromise; + }; +} diff --git a/superset-frontend/src/dashboard/components/CssEditor.jsx b/superset-frontend/src/dashboard/components/CssEditor.jsx index a412f6dfd6223..69f2500233d09 100644 --- a/superset-frontend/src/dashboard/components/CssEditor.jsx +++ b/superset-frontend/src/dashboard/components/CssEditor.jsx @@ -19,12 +19,9 @@ import React from 'react'; import PropTypes from 'prop-types'; import Select from 'src/components/Select'; -import AceEditor from 'react-ace'; -import 'brace/mode/css'; -import 'brace/theme/github'; import { t } from '@superset-ui/core'; - -import ModalTrigger from '../../components/ModalTrigger'; +import ModalTrigger from 'src/components/ModalTrigger'; +import { CssEditor as AceCssEditor } from 'src/components/AsyncAceEditor'; const propTypes = { initialCss: PropTypes.string, @@ -49,6 +46,10 @@ class CssEditor extends React.PureComponent { this.changeCssTemplate = this.changeCssTemplate.bind(this); } + componentDidMount() { + AceCssEditor.preload(); + } + changeCss(css) { this.setState({ css }, () => { this.props.onChange(css); @@ -87,10 +88,8 @@ class CssEditor extends React.PureComponent {

{t('Live CSS Editor')}
- {t('JSON Metadata')} - import('./DatasourceEditor')); interface DatasourceModalProps { addSuccessToast: (msg: string) => void; @@ -150,6 +152,8 @@ const DatasourceModal: FunctionComponent = ({ {show && ( diff --git a/superset-frontend/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx b/superset-frontend/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx index 49d616bd465bf..c8ea487422ec5 100644 --- a/superset-frontend/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx +++ b/superset-frontend/src/explore/components/AdhocFilterEditPopoverSqlTabContent.jsx @@ -18,16 +18,12 @@ */ import React from 'react'; import PropTypes from 'prop-types'; -import AceEditor from 'react-ace'; -import ace from 'brace'; -import 'brace/mode/sql'; -import 'brace/theme/github'; -import 'brace/ext/language_tools'; import { FormGroup } from 'react-bootstrap'; import Select from 'src/components/Select'; import { t } from '@superset-ui/core'; +import { SQLEditor } from 'src/components/AsyncAceEditor'; +import sqlKeywords from 'src/SqlLab/utils/sqlKeywords'; -import sqlKeywords from '../../SqlLab/utils/sqlKeywords'; import AdhocFilter, { EXPRESSION_TYPES, CLAUSES } from '../AdhocFilter'; import adhocMetricType from '../propTypes/adhocMetricType'; import columnType from '../propTypes/columnType'; @@ -45,8 +41,6 @@ const propTypes = { height: PropTypes.number.isRequired, }; -const langTools = ace.acequire('ace/ext/language_tools'); - export default class AdhocFilterEditPopoverSqlTabContent extends React.Component { constructor(props) { super(props); @@ -63,32 +57,12 @@ export default class AdhocFilterEditPopoverSqlTabContent extends React.Component autosize: false, clearable: false, }; - - if (langTools) { - const words = sqlKeywords.concat( - this.props.options.map(option => { - if (option.column_name) { - return { - name: option.column_name, - value: option.column_name, - score: 50, - meta: 'option', - }; - } - return null; - }), - ); - const completer = { - getCompletions: (aceEditor, session, pos, prefix, callback) => { - callback(null, words); - }, - }; - langTools.setCompleters([completer]); - } } componentDidUpdate() { - this.aceEditorRef.editor.resize(); + if (this.aceEditorRef) { + this.aceEditorRef.editor.resize(); + } } onSqlExpressionClauseChange(clause) { @@ -116,7 +90,7 @@ export default class AdhocFilterEditPopoverSqlTabContent extends React.Component } render() { - const { adhocFilter, height } = this.props; + const { adhocFilter, height, options } = this.props; const clauseSelectProps = { placeholder: t('choose WHERE or HAVING...'), @@ -124,6 +98,21 @@ export default class AdhocFilterEditPopoverSqlTabContent extends React.Component value: adhocFilter.clause, onChange: this.onSqlExpressionClauseChange, }; + const keywords = sqlKeywords.concat( + options + .map(option => { + if (option.column_name) { + return { + name: option.column_name, + value: option.column_name, + score: 50, + meta: 'option', + }; + } + return null; + }) + .filter(Boolean), + ); return ( @@ -140,10 +129,9 @@ export default class AdhocFilterEditPopoverSqlTabContent extends React.Component - ({ - name: column.column_name, - value: column.column_name, - score: 50, - meta: 'column', - })), - ); - const completer = { - getCompletions: (aceEditor, session, pos, prefix, callback) => { - callback(null, words); - }, - }; - langTools.setCompleters([completer]); - } document.addEventListener('mouseup', this.onMouseUp); } @@ -179,7 +157,11 @@ export default class AdhocMetricEditPopover extends React.Component { } refreshAceEditor() { - setTimeout(() => this.aceEditorRef.editor.resize(), 0); + setTimeout(() => { + if (this.aceEditorRef) { + this.aceEditorRef.editor.resize(); + } + }, 0); } renderColumnOption(option) { @@ -199,6 +181,14 @@ export default class AdhocMetricEditPopover extends React.Component { } = this.props; const { adhocMetric } = this.state; + const keywords = sqlKeywords.concat( + columns.map(column => ({ + name: column.column_name, + value: column.column_name, + score: 50, + meta: 'column', + })), + ); const columnSelectProps = { placeholder: t('%s column(s)', columns.length), @@ -279,10 +269,10 @@ export default class AdhocMetricEditPopover extends React.Component { > {this.props.datasourceType !== 'druid' ? ( - import('./AnnotationLayer'), + // size of overlay inner content + () =>
, +); const propTypes = { colorScheme: PropTypes.string.isRequired, @@ -61,6 +66,11 @@ class AnnotationLayerControl extends React.PureComponent { this.removeAnnotationLayer = this.removeAnnotationLayer.bind(this); } + componentDidMount() { + // preload the AnotationLayer component and dependent libraries i.e. mathjs + AnnotationLayer.preload(); + } + UNSAFE_componentWillReceiveProps(nextProps) { const { name, annotationError, validationErrors, value } = nextProps; if (Object.keys(annotationError).length && !validationErrors.length) { @@ -111,6 +121,7 @@ class AnnotationLayerControl extends React.PureComponent { > { + this.onAceChange(value); + }, 300); + } onControlChange(event) { this.props.onChange(event.target.value); } @@ -75,18 +75,18 @@ export default class TextAreaControl extends React.Component { renderEditor(inModal = false) { const value = this.props.value || ''; + const minLines = inModal ? 40 : this.props.minLines || 12; if (this.props.language) { return ( - diff --git a/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.js b/superset-frontend/src/types/brace.d.ts similarity index 68% rename from superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.js rename to superset-frontend/src/types/brace.d.ts index 250eb5e15c0a2..70d9275e41d4f 100644 --- a/superset-frontend/cypress-base/cypress/integration/dashboard/dashboard.helper.js +++ b/superset-frontend/src/types/brace.d.ts @@ -16,8 +16,13 @@ * specific language governing permissions and limitations * under the License. */ -export const WORLD_HEALTH_DASHBOARD = '/superset/dashboard/world_health/'; -export const TABBED_DASHBOARD = '/superset/dashboard/tabbed_dash/'; - -export const CHECK_DASHBOARD_FAVORITE_ENDPOINT = - '/superset/favstar/Dashboard/*/count'; +declare module 'brace/mode/sql'; +declare module 'brace/mode/markdown'; +declare module 'brace/mode/json'; +declare module 'brace/mode/css'; +declare module 'brace/mode/html'; +declare module 'brace/mode/yaml'; +declare module 'brace/mode/javascript'; +declare module 'brace/theme/textmate'; +declare module 'brace/theme/github'; +declare module 'brace/ext/language_tools'; diff --git a/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx b/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx index 51dfc417c1cc4..311b7171999c7 100644 --- a/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx +++ b/superset-frontend/src/visualizations/FilterBox/FilterBox.jsx @@ -26,20 +26,20 @@ import { t, styled, SupersetClient } from '@superset-ui/core'; import FormLabel from 'src/components/FormLabel'; -import DateFilterControl from '../../explore/components/controls/DateFilterControl'; -import ControlRow from '../../explore/components/ControlRow'; -import Control from '../../explore/components/Control'; -import controls from '../../explore/controls'; -import { getExploreUrl } from '../../explore/exploreUtils'; -import OnPasteSelect from '../../components/Select/OnPasteSelect'; -import { getDashboardFilterKey } from '../../dashboard/util/getDashboardFilterKey'; -import { getFilterColorMap } from '../../dashboard/util/dashboardFiltersColorMap'; +import DateFilterControl from 'src/explore/components/controls/DateFilterControl'; +import ControlRow from 'src/explore/components/ControlRow'; +import Control from 'src/explore/components/Control'; +import controls from 'src/explore/controls'; +import { getExploreUrl } from 'src/explore/exploreUtils'; +import OnPasteSelect from 'src/components/Select/OnPasteSelect'; +import { getDashboardFilterKey } from 'src/dashboard/util/getDashboardFilterKey'; +import { getFilterColorMap } from 'src/dashboard/util/dashboardFiltersColorMap'; import { FILTER_CONFIG_ATTRIBUTES, FILTER_OPTIONS_LIMIT, TIME_FILTER_LABELS, -} from '../../explore/constants'; -import FilterBadgeIcon from '../../components/FilterBadgeIcon'; +} from 'src/explore/constants'; +import FilterBadgeIcon from 'src/components/FilterBadgeIcon'; import './FilterBox.less'; diff --git a/superset-frontend/webpack.config.js b/superset-frontend/webpack.config.js index 500a129b49927..081b98fdaa9f8 100644 --- a/superset-frontend/webpack.config.js +++ b/superset-frontend/webpack.config.js @@ -55,9 +55,12 @@ const output = { if (isDevMode) { output.filename = '[name].[hash:8].entry.js'; output.chunkFilename = '[name].[hash:8].chunk.js'; -} else { +} else if (nameChunks) { output.filename = '[name].[chunkhash].entry.js'; output.chunkFilename = '[name].[chunkhash].chunk.js'; +} else { + output.filename = '[name].[chunkhash].entry.js'; + output.chunkFilename = '[chunkhash].chunk.js'; } const plugins = [ @@ -199,6 +202,8 @@ const config = { sideEffects: true, splitChunks: { chunks: 'all', + // increase minSize for devMode to 1000kb because of sourcemap + minSize: isDevMode ? 1000000 : 20000, name: nameChunks, automaticNameDelimiter: '-', minChunks: 2, @@ -214,6 +219,8 @@ const config = { 'react', 'react-dom', 'prop-types', + 'react-prop-types', + 'prop-types-extra', 'redux', 'react-redux', 'react-hot-loader', @@ -221,6 +228,7 @@ const config = { 'react-sortable-hoc', 'react-virtualized', 'react-table', + 'react-ace', '@hot-loader.*', 'webpack.*', '@?babel.*', @@ -228,20 +236,17 @@ const config = { 'antd', '@ant-design.*', '.*bootstrap', + 'react-bootstrap-slider', 'moment', 'jquery', 'core-js.*', '@emotion.*', - 'd3.*', + 'd3', + 'd3-(array|color|scale|interpolate|format|selection|collection|time|time-format)', ].join('|')})/`, ), }, // bundle large libraries separately - brace: { - name: 'brace', - test: /\/node_modules\/(brace|react-ace)\//, - priority: 40, - }, mathjs: { name: 'mathjs', test: /\/node_modules\/mathjs\//,