feat: dataset editor improvements (#10444)

This commit is contained in:
Lily Kuang 2020-08-04 11:52:35 -07:00 committed by GitHub
parent 9c5b0e1c86
commit fa07506d0d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 309 additions and 343 deletions

View File

@ -55,7 +55,7 @@ describe('Datasource control', () => {
cy.get('a').contains('Edit Datasource').click();
cy.get(`input[value="${newMetricName}"]`)
.closest('tr')
.find('.fa-close')
.find('.fa-trash')
.click();
cy.get('.modal-footer button').contains('Save').click();
cy.get('.modal-footer button').contains('OK').click();

View File

@ -34,7 +34,7 @@ describe('EditableTitle', () => {
value: 'new title',
},
};
const editableWrapper = shallow(<EditableTable {...mockProps} />);
let editableWrapper = shallow(<EditableTable {...mockProps} />);
const notEditableWrapper = shallow(
<EditableTable title="my title" onSaveTitle={callback} />,
);
@ -60,8 +60,7 @@ describe('EditableTitle', () => {
describe('should handle change', () => {
afterEach(() => {
editableWrapper.setState({ title: 'my title' });
editableWrapper.setState({ lastTitle: 'my title' });
editableWrapper = shallow(<EditableTable {...mockProps} />);
});
it('should change title', () => {
editableWrapper.find('input').simulate('change', mockEvent);
@ -79,8 +78,7 @@ describe('EditableTitle', () => {
});
afterEach(() => {
callback.resetHistory();
editableWrapper.setState({ title: 'my title' });
editableWrapper.setState({ lastTitle: 'my title' });
editableWrapper = shallow(<EditableTable {...mockProps} />);
});
it('default input type should be text', () => {
@ -88,7 +86,7 @@ describe('EditableTitle', () => {
});
it('should trigger callback', () => {
editableWrapper.setState({ title: 'new title' });
editableWrapper.find('input').simulate('change', mockEvent);
editableWrapper.find('input').simulate('blur');
expect(editableWrapper.find('input').props().type).toBe('button');
expect(callback.callCount).toBe(1);
@ -101,7 +99,6 @@ describe('EditableTitle', () => {
expect(callback.callCount).toBe(0);
});
it('should not save empty title', () => {
editableWrapper.setState({ title: '' });
editableWrapper.find('input').simulate('blur');
expect(editableWrapper.find('input').props().type).toBe('button');
expect(editableWrapper.find('input').props().value).toBe('my title');

View File

@ -16,8 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import React, { ReactNode } from 'react';
import shortid from 'shortid';
import { t } from '@superset-ui/translation';
import Button from '../components/Button';
@ -25,47 +24,42 @@ import Fieldset from './Fieldset';
import { recurseReactClone } from './utils';
import './crud.less';
const propTypes = {
collection: PropTypes.arrayOf(PropTypes.object).isRequired,
itemGenerator: PropTypes.func,
columnLabels: PropTypes.object,
tableColumns: PropTypes.array.isRequired,
onChange: PropTypes.func,
itemRenderers: PropTypes.object,
allowDeletes: PropTypes.bool,
expandFieldset: PropTypes.node,
emptyMessage: PropTypes.node,
extraButtons: PropTypes.node,
allowAddItem: PropTypes.bool,
};
const defaultProps = {
onChange: () => {},
itemRenderers: {},
columnLabels: {},
allowDeletes: false,
emptyMessage: 'No entries',
allowAddItem: false,
itemGenerator: () => ({}),
expandFieldset: null,
extraButtons: null,
};
const Frame = props => <div className="frame">{props.children}</div>;
Frame.propTypes = { children: PropTypes.node };
interface CRUDCollectionProps {
allowAddItem?: boolean;
allowDeletes?: boolean;
collection: Array<object>;
columnLabels?: object;
emptyMessage: ReactNode;
expandFieldset: ReactNode;
extraButtons: ReactNode;
itemGenerator?: () => any;
itemRenderers?: any;
onChange?: (arg0: any) => void;
tableColumns: Array<any>;
}
function createKeyedCollection(arr) {
const newArr = arr.map(o => ({
interface CRUDCollectionState {
collection: object;
expandedColumns: object;
}
function createKeyedCollection(arr: Array<object>) {
const newArr = arr.map((o: any) => ({
...o,
id: o.id || shortid.generate(),
}));
const map = {};
newArr.forEach(o => {
newArr.forEach((o: any) => {
map[o.id] = o;
});
return map;
}
export default class CRUDCollection extends React.PureComponent {
constructor(props) {
export default class CRUDCollection extends React.PureComponent<
CRUDCollectionProps,
CRUDCollectionState
> {
constructor(props: CRUDCollectionProps) {
super(props);
this.state = {
expandedColumns: {},
@ -79,14 +73,14 @@ export default class CRUDCollection extends React.PureComponent {
this.renderTableBody = this.renderTableBody.bind(this);
this.changeCollection = this.changeCollection.bind(this);
}
UNSAFE_componentWillReceiveProps(nextProps) {
UNSAFE_componentWillReceiveProps(nextProps: CRUDCollectionProps) {
if (nextProps.collection !== this.props.collection) {
this.setState({
collection: createKeyedCollection(nextProps.collection),
});
}
}
onCellChange(id, col, val) {
onCellChange(id: number, col: string, val: boolean) {
this.changeCollection({
...this.state.collection,
[id]: {
@ -96,35 +90,39 @@ export default class CRUDCollection extends React.PureComponent {
});
}
onAddItem() {
let newItem = this.props.itemGenerator();
if (!newItem.id) {
newItem = { ...newItem, id: shortid.generate() };
if (this.props.itemGenerator) {
let newItem = this.props.itemGenerator();
if (!newItem.id) {
newItem = { ...newItem, id: shortid.generate() };
}
this.changeCollection({
...this.state.collection,
[newItem.id]: newItem,
});
}
this.changeCollection({
...this.state.collection,
[newItem.id]: newItem,
});
}
onFieldsetChange(item) {
onFieldsetChange(item: any) {
this.changeCollection({
...this.state.collection,
[item.id]: item,
});
}
getLabel(col) {
getLabel(col: any) {
const { columnLabels } = this.props;
let label = columnLabels[col] ? columnLabels[col] : col;
let label = columnLabels && columnLabels[col] ? columnLabels[col] : col;
if (label.startsWith('__')) {
// special label-free columns (ie: caret for expand, delete cross)
label = '';
}
return label;
}
changeCollection(collection) {
changeCollection(collection: any) {
this.setState({ collection });
this.props.onChange(Object.keys(collection).map(k => collection[k]));
if (this.props.onChange) {
this.props.onChange(Object.keys(collection).map(k => collection[k]));
}
}
deleteItem(id) {
deleteItem(id: number) {
const newColl = { ...this.state.collection };
delete newColl[id];
this.changeCollection(newColl);
@ -136,7 +134,7 @@ export default class CRUDCollection extends React.PureComponent {
: tableColumns;
return expandFieldset ? ['__expand'].concat(cols) : cols;
}
toggleExpand(id) {
toggleExpand(id: any) {
this.onCellChange(id, '__expanded', false);
this.setState({
expandedColumns: {
@ -147,19 +145,33 @@ export default class CRUDCollection extends React.PureComponent {
}
renderHeaderRow() {
const cols = this.effectiveTableColumns();
const {
allowAddItem,
allowDeletes,
expandFieldset,
extraButtons,
} = this.props;
return (
<thead>
<tr>
{this.props.expandFieldset && <th className="tiny-cell" />}
{expandFieldset && <th className="tiny-cell" />}
{cols.map(col => (
<th key={col}>{this.getLabel(col)}</th>
))}
{this.props.allowDeletes && <th className="tiny-cell" />}
{extraButtons}
{allowDeletes && !allowAddItem && <th className="tiny-cell" />}
{allowAddItem && (
<th>
<Button bsStyle="primary" onClick={this.onAddItem}>
<i className="fa fa-plus" /> {t('Add Item')}
</Button>
</th>
)}
</tr>
</thead>
);
}
renderExpandableSection(item) {
renderExpandableSection(item: any) {
const propsGenerator = () => ({ item, onChange: this.onFieldsetChange });
return recurseReactClone(
this.props.expandFieldset,
@ -167,14 +179,19 @@ export default class CRUDCollection extends React.PureComponent {
propsGenerator,
);
}
renderCell(record, col) {
const renderer = this.props.itemRenderers[col];
renderCell(record: any, col: any) {
const renderer = this.props.itemRenderers && this.props.itemRenderers[col];
const val = record[col];
const onChange = this.onCellChange.bind(this, record.id, col);
return renderer ? renderer(val, onChange, this.getLabel(col)) : val;
}
renderItem(record) {
const { tableColumns, allowDeletes, expandFieldset } = this.props;
renderItem(record: any) {
const {
allowAddItem,
allowDeletes,
expandFieldset,
tableColumns,
} = this.props;
/* eslint-disable no-underscore-dangle */
const isExpanded =
!!this.state.expandedColumns[record.id] || record.__expanded;
@ -198,13 +215,16 @@ export default class CRUDCollection extends React.PureComponent {
<td key={col}>{this.renderCell(record, col)}</td>
)),
);
if (allowAddItem) {
tds.push(<td />);
}
if (allowDeletes) {
tds.push(
<td key="__actions">
<i
role="button"
tabIndex={0}
className="fa fa-close text-primary pointer"
className="fa fa-trash text-primary pointer"
onClick={this.deleteItem.bind(this, record.id)}
/>
</td>,
@ -252,17 +272,7 @@ export default class CRUDCollection extends React.PureComponent {
{this.renderHeaderRow()}
{this.renderTableBody()}
</table>
<div>
{this.props.allowAddItem && (
<Button bsStyle="primary" onClick={this.onAddItem}>
<i className="fa fa-plus" /> {t('Add Item')}
</Button>
)}
{this.props.extraButtons}
</div>
</div>
);
}
}
CRUDCollection.defaultProps = defaultProps;
CRUDCollection.propTypes = propTypes;

View File

@ -1,236 +0,0 @@
/**
* 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 cx from 'classnames';
import { t } from '@superset-ui/translation';
import TooltipWrapper from './TooltipWrapper';
const propTypes = {
title: PropTypes.string,
canEdit: PropTypes.bool,
multiLine: PropTypes.bool,
onSaveTitle: PropTypes.func,
noPermitTooltip: PropTypes.string,
showTooltip: PropTypes.bool,
emptyText: PropTypes.node,
style: PropTypes.object,
extraClasses: PropTypes.oneOfType([
PropTypes.arrayOf(PropTypes.string),
PropTypes.string,
]),
};
const defaultProps = {
title: t('Title'),
canEdit: false,
multiLine: false,
showTooltip: true,
onSaveTitle: () => {},
emptyText: '<empty>',
style: null,
extraClasses: null,
};
export default class EditableTitle extends React.PureComponent {
constructor(props) {
super(props);
this.state = {
isEditing: false,
title: this.props.title,
lastTitle: this.props.title,
};
this.handleClick = this.handleClick.bind(this);
this.handleBlur = this.handleBlur.bind(this);
this.handleChange = this.handleChange.bind(this);
this.handleKeyPress = this.handleKeyPress.bind(this);
// Used so we can access the DOM element if a user clicks on this component.
this.contentRef = React.createRef();
}
UNSAFE_componentWillReceiveProps(nextProps) {
if (nextProps.title !== this.state.title) {
this.setState({
lastTitle: this.state.title,
title: nextProps.title,
});
}
}
handleClick() {
if (!this.props.canEdit || this.state.isEditing) {
return;
}
// For multi-line values, save the actual rendered size of the displayed text.
// Later, if a textarea is constructed for editing the value, we'll need this.
const contentBoundingRect = this.contentRef.current
? this.contentRef.current.getBoundingClientRect()
: null;
this.setState({ isEditing: true, contentBoundingRect });
}
handleBlur() {
const title = this.state.title.trim();
if (!this.props.canEdit) {
return;
}
this.setState({
isEditing: false,
});
if (!title.length) {
this.setState({
title: this.state.lastTitle,
});
return;
}
if (this.state.lastTitle !== title) {
this.setState({
lastTitle: title,
});
}
if (this.props.title !== title) {
this.props.onSaveTitle(title);
}
}
// this entire method exists to support using EditableTitle as the title of a
// react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
//
// tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
// clicked and is focused/active. for accessibility, when focused the Tab <a /> intercepts
// the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
// keydown is still called so we can detect this and manually add a ' ' to the current title
handleKeyDown(event) {
if (event.key === ' ') {
event.stopPropagation();
}
}
handleChange(ev) {
if (!this.props.canEdit) {
return;
}
this.setState({
title: ev.target.value,
});
}
handleKeyPress(ev) {
if (ev.key === 'Enter') {
ev.preventDefault();
this.handleBlur();
}
}
render() {
const { isEditing, title, contentBoundingRect } = this.state;
const {
emptyText,
multiLine,
showTooltip,
canEdit,
noPermitTooltip,
style,
extraClasses,
} = this.props;
let value;
if (title) {
value = title;
} else if (!isEditing) {
value = emptyText;
}
// Construct an inline style based on previously-saved height of the rendered label. Only
// used in multi-line contexts.
const editStyle =
isEditing && contentBoundingRect
? { height: `${contentBoundingRect.height}px` }
: null;
// Create a textarea when we're editing a multi-line value, otherwise create an input (which may
// be text or a button).
let input =
multiLine && isEditing ? (
<textarea
ref={this.contentRef}
required
value={value}
className={!title ? 'text-muted' : null}
onKeyDown={this.handleKeyDown}
onChange={this.handleChange}
onBlur={this.handleBlur}
onClick={this.handleClick}
onKeyPress={this.handleKeyPress}
style={editStyle}
/>
) : (
<input
ref={this.contentRef}
required
type={isEditing ? 'text' : 'button'}
value={value}
className={!title ? 'text-muted' : null}
onKeyDown={this.handleKeyDown}
onChange={this.handleChange}
onBlur={this.handleBlur}
onClick={this.handleClick}
onKeyPress={this.handleKeyPress}
/>
);
if (showTooltip && !isEditing) {
input = (
<TooltipWrapper
label="title"
tooltip={
canEdit
? t('click to edit')
: noPermitTooltip ||
t("You don't have the rights to alter this title.")
}
>
{input}
</TooltipWrapper>
);
}
return (
<span
className={cx(
'editable-title',
extraClasses,
canEdit && 'editable-title--editable',
isEditing && 'editable-title--editing',
)}
style={style}
>
{input}
</span>
);
}
}
EditableTitle.propTypes = propTypes;
EditableTitle.defaultProps = defaultProps;

View File

@ -0,0 +1,201 @@
/**
* 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, { useEffect, useState, useRef } from 'react';
import cx from 'classnames';
import { t } from '@superset-ui/translation';
import TooltipWrapper from './TooltipWrapper';
interface EditableTitleProps {
canEdit?: boolean;
emptyText?: string;
extraClasses?: Array<string> | string;
multiLine?: boolean;
noPermitTooltip?: string;
onSaveTitle: (arg0: string) => {};
showTooltip?: boolean;
style?: object;
title: string;
}
export default function EditableTitle({
canEdit = false,
emptyText,
extraClasses,
multiLine = false,
noPermitTooltip,
onSaveTitle,
showTooltip = true,
style,
title,
}: EditableTitleProps) {
const [isEditing, setIsEditing] = useState(false);
const [currentTitle, setCurrentTitle] = useState(title);
const [lastTitle, setLastTitle] = useState(title);
const [
contentBoundingRect,
setContentBoundingRect,
] = useState<DOMRect | null>(null);
// Used so we can access the DOM element if a user clicks on this component.
const contentRef = useRef<any | HTMLInputElement | HTMLTextAreaElement>();
useEffect(() => {
if (currentTitle !== lastTitle) {
setLastTitle(currentTitle);
setCurrentTitle(title);
}
}, [title]);
function handleClick() {
if (!canEdit || isEditing) {
return;
}
// For multi-line values, save the actual rendered size of the displayed text.
// Later, if a textarea is constructed for editing the value, we'll need this.
const contentBounding = contentRef.current
? contentRef.current.getBoundingClientRect()
: null;
setIsEditing(true);
setContentBoundingRect(contentBounding);
}
function handleBlur() {
const formattedTitle = currentTitle.trim();
if (!canEdit) {
return;
}
setIsEditing(false);
if (!formattedTitle.length) {
setCurrentTitle(lastTitle);
return;
}
if (lastTitle !== formattedTitle) {
setLastTitle(formattedTitle);
}
if (title !== formattedTitle) {
onSaveTitle(formattedTitle);
}
}
// this entire method exists to support using EditableTitle as the title of a
// react-bootstrap Tab, as a workaround for this line in react-bootstrap https://goo.gl/ZVLmv4
//
// tl;dr when a Tab EditableTitle is being edited, typically the Tab it's within has been
// clicked and is focused/active. for accessibility, when focused the Tab <a /> intercepts
// the ' ' key (among others, including all arrows) and onChange() doesn't fire. somehow
// keydown is still called so we can detect this and manually add a ' ' to the current title
function handleKeyDown(event: any) {
if (event.key === ' ') {
event.stopPropagation();
}
}
function handleChange(ev: any) {
if (!canEdit) {
return;
}
setCurrentTitle(ev.target.value);
}
function handleKeyPress(ev: any) {
if (ev.key === 'Enter') {
ev.preventDefault();
handleBlur();
}
}
let value: string | undefined;
if (currentTitle) {
value = currentTitle;
} else if (!isEditing) {
value = emptyText;
}
// Construct an inline style based on previously-saved height of the rendered label. Only
// used in multi-line contexts.
const editStyle =
isEditing && contentBoundingRect
? { height: `${contentBoundingRect.height}px` }
: undefined;
// Create a textarea when we're editing a multi-line value, otherwise create an input (which may
// be text or a button).
let input =
multiLine && isEditing ? (
<textarea
ref={contentRef}
required
value={value}
className={!title ? 'text-muted' : undefined}
onKeyDown={handleKeyDown}
onChange={handleChange}
onBlur={handleBlur}
onClick={handleClick}
onKeyPress={handleKeyPress}
style={editStyle}
/>
) : (
<input
ref={contentRef}
required
type={isEditing ? 'text' : 'button'}
value={value}
className={!title ? 'text-muted' : undefined}
onKeyDown={handleKeyDown}
onChange={handleChange}
onBlur={handleBlur}
onClick={handleClick}
onKeyPress={handleKeyPress}
/>
);
if (showTooltip && !isEditing) {
input = (
<TooltipWrapper
label="title"
tooltip={
canEdit
? t('click to edit')
: noPermitTooltip ||
t("You don't have the rights to alter this title.")
}
>
{input}
</TooltipWrapper>
);
}
return (
<span
className={cx(
'editable-title',
extraClasses,
canEdit && 'editable-title--editable',
isEditing && 'editable-title--editing',
)}
style={style}
>
{input}
</span>
);
}

View File

@ -98,18 +98,23 @@ function ColumnCollectionTable({
<Field
fieldKey="expression"
label={t('SQL Expression')}
control={<TextControl />}
control={
<TextAreaControl
language="markdown"
offerEditInModal={false}
/>
}
/>
)}
<Field
fieldKey="verbose_name"
label={t('Label')}
control={<TextControl />}
control={<TextControl placeholder={t('Label')} />}
/>
<Field
fieldKey="description"
label={t('Description')}
control={<TextControl />}
control={<TextControl placeholder={t('Description')} />}
/>
{allowEditDataType && (
<Field
@ -145,7 +150,7 @@ function ColumnCollectionTable({
database/column name level via the extra parameter.`)}
</div>
}
control={<TextControl />}
control={<TextControl placeholder={'%y/%m/%d'} />}
/>
</Fieldset>
</FormContainer>
@ -603,12 +608,12 @@ export class DatasourceEditor extends React.PureComponent {
<Field
fieldKey="description"
label={t('Description')}
control={<TextControl />}
control={<TextControl placeholder={t('Description')} />}
/>
<Field
fieldKey="d3format"
label={t('D3 Format')}
control={<TextControl />}
control={<TextControl placeholder="%y/%m/%d" />}
/>
<Field
label={t('Warning Message')}
@ -616,7 +621,7 @@ export class DatasourceEditor extends React.PureComponent {
description={t(
'Warning message to display in the metric selector',
)}
control={<TextControl />}
control={<TextControl placeholder={t('Warning Message')} />}
/>
</Fieldset>
</FormContainer>

View File

@ -17,7 +17,6 @@
* under the License.
*/
import React from 'react';
import PropTypes from 'prop-types';
import { FormGroup, FormControl } from 'react-bootstrap';
import {
legacyValidateNumber,
@ -25,30 +24,22 @@ import {
} from '@superset-ui/validator';
import ControlHeader from '../ControlHeader';
const propTypes = {
onChange: PropTypes.func,
onFocus: PropTypes.func,
value: PropTypes.oneOfType([PropTypes.string, PropTypes.number]),
isFloat: PropTypes.bool,
isInt: PropTypes.bool,
disabled: PropTypes.bool,
};
interface TextControlProps {
disabled: boolean;
isFloat: boolean;
isInt: boolean;
onChange: (value: any, errors: any) => {};
onFocus: () => {};
placeholder: string;
value: string | number;
}
const defaultProps = {
onChange: () => {},
onFocus: () => {},
value: '',
isInt: false,
isFloat: false,
disabled: false,
};
export default class TextControl extends React.Component {
constructor(props) {
export default class TextControl extends React.Component<TextControlProps> {
constructor(props: TextControlProps) {
super(props);
this.onChange = this.onChange.bind(this);
}
onChange(event) {
onChange(event: any) {
let value = event.target.value;
// Validation & casting
@ -83,7 +74,7 @@ export default class TextControl extends React.Component {
<FormGroup controlId="formInlineName" bsSize="small">
<FormControl
type="text"
placeholder=""
placeholder={this.props.placeholder}
onChange={this.onChange}
onFocus={this.props.onFocus}
value={value}
@ -94,6 +85,3 @@ export default class TextControl extends React.Component {
);
}
}
TextControl.propTypes = propTypes;
TextControl.defaultProps = defaultProps;

View File

@ -233,6 +233,7 @@ table.table-no-hover tr:hover {
}
.editable-title.datasource-sql-expression textarea {
min-height: 100px;
width: 95%;
}