418 lines
12 KiB
JavaScript
418 lines
12 KiB
JavaScript
/**
|
|
* 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 from 'react';
|
|
import PropTypes from 'prop-types';
|
|
import { connect } from 'react-redux';
|
|
import cx from 'classnames';
|
|
|
|
import { css, styled, t, SafeMarkdown } from '@superset-ui/core';
|
|
import { Logger, LOG_ACTIONS_RENDER_CHART } from 'src/logger/LogUtils';
|
|
import { MarkdownEditor } from 'src/components/AsyncAceEditor';
|
|
|
|
import DeleteComponentButton from 'src/dashboard/components/DeleteComponentButton';
|
|
import DragDroppable from 'src/dashboard/components/dnd/DragDroppable';
|
|
import HoverMenu from 'src/dashboard/components/menu/HoverMenu';
|
|
import ResizableContainer from 'src/dashboard/components/resizable/ResizableContainer';
|
|
import MarkdownModeDropdown from 'src/dashboard/components/menu/MarkdownModeDropdown';
|
|
import WithPopoverMenu from 'src/dashboard/components/menu/WithPopoverMenu';
|
|
import { componentShape } from 'src/dashboard/util/propShapes';
|
|
import { ROW_TYPE, COLUMN_TYPE } from 'src/dashboard/util/componentTypes';
|
|
import {
|
|
GRID_MIN_COLUMN_COUNT,
|
|
GRID_MIN_ROW_UNITS,
|
|
GRID_BASE_UNIT,
|
|
} from 'src/dashboard/util/constants';
|
|
|
|
const propTypes = {
|
|
id: PropTypes.string.isRequired,
|
|
parentId: PropTypes.string.isRequired,
|
|
component: componentShape.isRequired,
|
|
parentComponent: componentShape.isRequired,
|
|
index: PropTypes.number.isRequired,
|
|
depth: PropTypes.number.isRequired,
|
|
editMode: PropTypes.bool.isRequired,
|
|
|
|
// from redux
|
|
logEvent: PropTypes.func.isRequired,
|
|
addDangerToast: PropTypes.func.isRequired,
|
|
undoLength: PropTypes.number.isRequired,
|
|
redoLength: PropTypes.number.isRequired,
|
|
|
|
// grid related
|
|
availableColumnCount: PropTypes.number.isRequired,
|
|
columnWidth: PropTypes.number.isRequired,
|
|
onResizeStart: PropTypes.func.isRequired,
|
|
onResize: PropTypes.func.isRequired,
|
|
onResizeStop: PropTypes.func.isRequired,
|
|
|
|
// dnd
|
|
deleteComponent: PropTypes.func.isRequired,
|
|
handleComponentDrop: PropTypes.func.isRequired,
|
|
updateComponents: PropTypes.func.isRequired,
|
|
|
|
// HTML sanitization
|
|
htmlSanitization: PropTypes.bool,
|
|
htmlSchemaOverrides: PropTypes.object,
|
|
};
|
|
|
|
const defaultProps = {};
|
|
|
|
const MARKDOWN_PLACE_HOLDER = `# ✨Header 1
|
|
## ✨Header 2
|
|
### ✨Header 3
|
|
|
|
<br />
|
|
|
|
Click here to learn more about [markdown formatting](https://bit.ly/1dQOfRK)`;
|
|
|
|
const MARKDOWN_ERROR_MESSAGE = t('This markdown component has an error.');
|
|
|
|
const MarkdownStyles = styled.div`
|
|
${({ theme }) => css`
|
|
&.dashboard-markdown {
|
|
overflow: hidden;
|
|
|
|
h4,
|
|
h5,
|
|
h6 {
|
|
font-weight: ${theme.typography.weights.normal};
|
|
}
|
|
|
|
h5 {
|
|
color: ${theme.colors.grayscale.base};
|
|
}
|
|
|
|
h6 {
|
|
font-size: ${theme.typography.sizes.s}px;
|
|
}
|
|
|
|
.dashboard-component-chart-holder {
|
|
overflow-y: auto;
|
|
overflow-x: hidden;
|
|
}
|
|
|
|
.dashboard--editing & {
|
|
cursor: move;
|
|
}
|
|
}
|
|
`}
|
|
`;
|
|
|
|
class Markdown extends React.PureComponent {
|
|
constructor(props) {
|
|
super(props);
|
|
this.state = {
|
|
isFocused: false,
|
|
markdownSource: props.component.meta.code,
|
|
editor: null,
|
|
editorMode: 'preview',
|
|
undoLength: props.undoLength,
|
|
redoLength: props.redoLength,
|
|
};
|
|
this.renderStartTime = Logger.getTimestamp();
|
|
|
|
this.handleChangeFocus = this.handleChangeFocus.bind(this);
|
|
this.handleChangeEditorMode = this.handleChangeEditorMode.bind(this);
|
|
this.handleMarkdownChange = this.handleMarkdownChange.bind(this);
|
|
this.handleDeleteComponent = this.handleDeleteComponent.bind(this);
|
|
this.handleResizeStart = this.handleResizeStart.bind(this);
|
|
this.setEditor = this.setEditor.bind(this);
|
|
}
|
|
|
|
componentDidMount() {
|
|
this.props.logEvent(LOG_ACTIONS_RENDER_CHART, {
|
|
viz_type: 'markdown',
|
|
start_offset: this.renderStartTime,
|
|
ts: new Date().getTime(),
|
|
duration: Logger.getTimestamp() - this.renderStartTime,
|
|
});
|
|
}
|
|
|
|
static getDerivedStateFromProps(nextProps, state) {
|
|
const { hasError, editorMode, markdownSource, undoLength, redoLength } =
|
|
state;
|
|
const {
|
|
component: nextComponent,
|
|
undoLength: nextUndoLength,
|
|
redoLength: nextRedoLength,
|
|
} = nextProps;
|
|
// user click undo or redo ?
|
|
if (nextUndoLength !== undoLength || nextRedoLength !== redoLength) {
|
|
return {
|
|
...state,
|
|
undoLength: nextUndoLength,
|
|
redoLength: nextRedoLength,
|
|
markdownSource: nextComponent.meta.code,
|
|
hasError: false,
|
|
};
|
|
}
|
|
if (
|
|
!hasError &&
|
|
editorMode === 'preview' &&
|
|
nextComponent.meta.code !== markdownSource
|
|
) {
|
|
return {
|
|
...state,
|
|
markdownSource: nextComponent.meta.code,
|
|
};
|
|
}
|
|
|
|
return state;
|
|
}
|
|
|
|
static getDerivedStateFromError() {
|
|
return {
|
|
hasError: true,
|
|
};
|
|
}
|
|
|
|
componentDidUpdate(prevProps) {
|
|
if (
|
|
this.state.editor &&
|
|
(prevProps.component.meta.width !== this.props.component.meta.width ||
|
|
prevProps.columnWidth !== this.props.columnWidth)
|
|
) {
|
|
this.state.editor.resize(true);
|
|
}
|
|
// pre-load AceEditor when entering edit mode
|
|
if (this.props.editMode) {
|
|
MarkdownEditor.preload();
|
|
}
|
|
}
|
|
|
|
componentDidCatch() {
|
|
if (this.state.editor && this.state.editorMode === 'preview') {
|
|
this.props.addDangerToast(
|
|
t(
|
|
'This markdown component has an error. Please revert your recent changes.',
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
setEditor(editor) {
|
|
editor.getSession().setUseWrapMode(true);
|
|
this.setState({
|
|
editor,
|
|
});
|
|
}
|
|
|
|
handleChangeFocus(nextFocus) {
|
|
const nextFocused = !!nextFocus;
|
|
const nextEditMode = nextFocused ? 'edit' : 'preview';
|
|
this.setState(() => ({ isFocused: nextFocused }));
|
|
this.handleChangeEditorMode(nextEditMode);
|
|
}
|
|
|
|
handleChangeEditorMode(mode) {
|
|
const nextState = {
|
|
...this.state,
|
|
editorMode: mode,
|
|
};
|
|
if (mode === 'preview') {
|
|
this.updateMarkdownContent();
|
|
nextState.hasError = false;
|
|
}
|
|
|
|
this.setState(nextState);
|
|
}
|
|
|
|
updateMarkdownContent() {
|
|
const { updateComponents, component } = this.props;
|
|
if (component.meta.code !== this.state.markdownSource) {
|
|
updateComponents({
|
|
[component.id]: {
|
|
...component,
|
|
meta: {
|
|
...component.meta,
|
|
code: this.state.markdownSource,
|
|
},
|
|
},
|
|
});
|
|
}
|
|
}
|
|
|
|
handleMarkdownChange(nextValue) {
|
|
this.setState({
|
|
markdownSource: nextValue,
|
|
});
|
|
}
|
|
|
|
handleDeleteComponent() {
|
|
const { deleteComponent, id, parentId } = this.props;
|
|
deleteComponent(id, parentId);
|
|
}
|
|
|
|
handleResizeStart(e) {
|
|
const { editorMode } = this.state;
|
|
const { editMode, onResizeStart } = this.props;
|
|
const isEditing = editorMode === 'edit';
|
|
onResizeStart(e);
|
|
if (editMode && isEditing) {
|
|
this.updateMarkdownContent();
|
|
}
|
|
}
|
|
|
|
renderEditMode() {
|
|
return (
|
|
<MarkdownEditor
|
|
onChange={this.handleMarkdownChange}
|
|
width="100%"
|
|
height="100%"
|
|
showGutter={false}
|
|
editorProps={{ $blockScrolling: true }}
|
|
value={
|
|
// this allows "select all => delete" to give an empty editor
|
|
typeof this.state.markdownSource === 'string'
|
|
? this.state.markdownSource
|
|
: MARKDOWN_PLACE_HOLDER
|
|
}
|
|
readOnly={false}
|
|
onLoad={this.setEditor}
|
|
data-test="editor"
|
|
/>
|
|
);
|
|
}
|
|
|
|
renderPreviewMode() {
|
|
const { hasError } = this.state;
|
|
|
|
return (
|
|
<SafeMarkdown
|
|
source={
|
|
hasError
|
|
? MARKDOWN_ERROR_MESSAGE
|
|
: this.state.markdownSource || MARKDOWN_PLACE_HOLDER
|
|
}
|
|
htmlSanitization={this.props.htmlSanitization}
|
|
htmlSchemaOverrides={this.props.htmlSchemaOverrides}
|
|
/>
|
|
);
|
|
}
|
|
|
|
render() {
|
|
const { isFocused, editorMode } = this.state;
|
|
|
|
const {
|
|
component,
|
|
parentComponent,
|
|
index,
|
|
depth,
|
|
availableColumnCount,
|
|
columnWidth,
|
|
onResize,
|
|
onResizeStop,
|
|
handleComponentDrop,
|
|
editMode,
|
|
} = this.props;
|
|
|
|
// inherit the size of parent columns
|
|
const widthMultiple =
|
|
parentComponent.type === COLUMN_TYPE
|
|
? parentComponent.meta.width || GRID_MIN_COLUMN_COUNT
|
|
: component.meta.width || GRID_MIN_COLUMN_COUNT;
|
|
|
|
const isEditing = editorMode === 'edit';
|
|
|
|
return (
|
|
<DragDroppable
|
|
component={component}
|
|
parentComponent={parentComponent}
|
|
orientation={parentComponent.type === ROW_TYPE ? 'column' : 'row'}
|
|
index={index}
|
|
depth={depth}
|
|
onDrop={handleComponentDrop}
|
|
disableDragDrop={isFocused}
|
|
editMode={editMode}
|
|
>
|
|
{({ dropIndicatorProps, dragSourceRef }) => (
|
|
<WithPopoverMenu
|
|
onChangeFocus={this.handleChangeFocus}
|
|
menuItems={[
|
|
<MarkdownModeDropdown
|
|
id={`${component.id}-mode`}
|
|
value={this.state.editorMode}
|
|
onChange={this.handleChangeEditorMode}
|
|
/>,
|
|
]}
|
|
editMode={editMode}
|
|
>
|
|
<MarkdownStyles
|
|
data-test="dashboard-markdown-editor"
|
|
className={cx(
|
|
'dashboard-markdown',
|
|
isEditing && 'dashboard-markdown--editing',
|
|
)}
|
|
id={component.id}
|
|
>
|
|
<ResizableContainer
|
|
id={component.id}
|
|
adjustableWidth={parentComponent.type === ROW_TYPE}
|
|
adjustableHeight
|
|
widthStep={columnWidth}
|
|
widthMultiple={widthMultiple}
|
|
heightStep={GRID_BASE_UNIT}
|
|
heightMultiple={component.meta.height}
|
|
minWidthMultiple={GRID_MIN_COLUMN_COUNT}
|
|
minHeightMultiple={GRID_MIN_ROW_UNITS}
|
|
maxWidthMultiple={availableColumnCount + widthMultiple}
|
|
onResizeStart={this.handleResizeStart}
|
|
onResize={onResize}
|
|
onResizeStop={onResizeStop}
|
|
editMode={isFocused ? false : editMode}
|
|
>
|
|
<div
|
|
ref={dragSourceRef}
|
|
className="dashboard-component dashboard-component-chart-holder"
|
|
data-test="dashboard-component-chart-holder"
|
|
>
|
|
{editMode && (
|
|
<HoverMenu position="top">
|
|
<DeleteComponentButton
|
|
onDelete={this.handleDeleteComponent}
|
|
/>
|
|
</HoverMenu>
|
|
)}
|
|
{editMode && isEditing
|
|
? this.renderEditMode()
|
|
: this.renderPreviewMode()}
|
|
</div>
|
|
</ResizableContainer>
|
|
</MarkdownStyles>
|
|
{dropIndicatorProps && <div {...dropIndicatorProps} />}
|
|
</WithPopoverMenu>
|
|
)}
|
|
</DragDroppable>
|
|
);
|
|
}
|
|
}
|
|
|
|
Markdown.propTypes = propTypes;
|
|
Markdown.defaultProps = defaultProps;
|
|
|
|
function mapStateToProps(state) {
|
|
return {
|
|
undoLength: state.dashboardLayout.past.length,
|
|
redoLength: state.dashboardLayout.future.length,
|
|
htmlSanitization: state.common.conf.HTML_SANITIZATION,
|
|
htmlSchemaOverrides: state.common.conf.HTML_SANITIZATION_SCHEMA_EXTENSIONS,
|
|
};
|
|
}
|
|
export default connect(mapStateToProps)(Markdown);
|