feat(explore): Move chart header to top of the page (#19529)

* Move chart header to top of the page

* Implement truncating and dynamic input

* fix typing

* Prevent cmd+z undoing changes when not in edit mode

* Fix tests, add missing types

* Show changed title in altered
This commit is contained in:
Kamil Gabryjelski
2022-04-05 15:20:29 +02:00
committed by GitHub
parent 1eef923b31
commit 602afbaa31
8 changed files with 604 additions and 302 deletions

View File

@@ -0,0 +1,68 @@
/**
* 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 userEvent from '@testing-library/user-event';
import { render, screen } from 'spec/helpers/testing-library';
import { ChartEditableTitle } from './index';
const createProps = (overrides: Record<string, any> = {}) => ({
title: 'Chart title',
placeholder: 'Add the name of the chart',
canEdit: true,
onSave: jest.fn(),
...overrides,
});
describe('Chart editable title', () => {
it('renders chart title', () => {
const props = createProps();
render(<ChartEditableTitle {...props} />);
expect(screen.getByText('Chart title')).toBeVisible();
});
it('renders placeholder', () => {
const props = createProps({
title: '',
});
render(<ChartEditableTitle {...props} />);
expect(screen.getByText('Add the name of the chart')).toBeVisible();
});
it('click, edit and save title', () => {
const props = createProps();
render(<ChartEditableTitle {...props} />);
const textboxElement = screen.getByRole('textbox');
userEvent.click(textboxElement);
userEvent.type(textboxElement, ' edited');
expect(screen.getByText('Chart title edited')).toBeVisible();
userEvent.type(textboxElement, '{enter}');
expect(props.onSave).toHaveBeenCalled();
});
it('renders in non-editable mode', () => {
const props = createProps({ canEdit: false });
render(<ChartEditableTitle {...props} />);
const titleElement = screen.getByLabelText('Chart title');
expect(screen.queryByRole('textbox')).not.toBeInTheDocument();
expect(titleElement).toBeVisible();
userEvent.click(titleElement);
userEvent.type(titleElement, ' edited{enter}');
expect(props.onSave).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,213 @@
/**
* 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, {
ChangeEvent,
KeyboardEvent,
useCallback,
useEffect,
useLayoutEffect,
useRef,
useState,
} from 'react';
import { css, styled, t } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import { useResizeDetector } from 'react-resize-detector';
export type ChartEditableTitleProps = {
title: string;
placeholder: string;
onSave: (title: string) => void;
canEdit: boolean;
};
const Styles = styled.div`
${({ theme }) => css`
display: flex;
font-size: ${theme.typography.sizes.xl}px;
font-weight: ${theme.typography.weights.bold};
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
& .chart-title,
& .chart-title-input {
display: inline-block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
& .chart-title {
cursor: default;
}
& .chart-title-input {
border: none;
padding: 0;
outline: none;
&::placeholder {
color: ${theme.colors.grayscale.light1};
}
}
& .input-sizer {
position: absolute;
left: -9999px;
display: inline-block;
}
`}
`;
export const ChartEditableTitle = ({
title,
placeholder,
onSave,
canEdit,
}: ChartEditableTitleProps) => {
const [isEditing, setIsEditing] = useState(false);
const [currentTitle, setCurrentTitle] = useState(title || '');
const contentRef = useRef<HTMLInputElement>(null);
const [showTooltip, setShowTooltip] = useState(false);
const { width: inputWidth, ref: sizerRef } = useResizeDetector();
const { width: containerWidth, ref: containerRef } = useResizeDetector({
refreshMode: 'debounce',
});
useEffect(() => {
if (isEditing && contentRef?.current) {
contentRef.current.focus();
// move cursor and scroll to the end
if (contentRef.current.setSelectionRange) {
const { length } = contentRef.current.value;
contentRef.current.setSelectionRange(length, length);
contentRef.current.scrollLeft = contentRef.current.scrollWidth;
}
}
}, [isEditing]);
// a trick to make the input grow when user types text
// we make additional span component, place it somewhere out of view and copy input
// then we can measure the width of that span to resize the input element
useLayoutEffect(() => {
if (sizerRef?.current) {
sizerRef.current.innerHTML = (currentTitle || placeholder).replace(
/\s/g,
'&nbsp;',
);
}
}, [currentTitle, placeholder, sizerRef]);
useEffect(() => {
if (
contentRef.current &&
contentRef.current.scrollWidth > contentRef.current.clientWidth
) {
setShowTooltip(true);
} else {
setShowTooltip(false);
}
}, [inputWidth, containerWidth]);
const handleClick = useCallback(() => {
if (!canEdit || isEditing) {
return;
}
setIsEditing(true);
}, [canEdit, isEditing]);
const handleBlur = useCallback(() => {
if (!canEdit) {
return;
}
const formattedTitle = currentTitle.trim();
setCurrentTitle(formattedTitle);
if (title !== formattedTitle) {
onSave(formattedTitle);
}
setIsEditing(false);
}, [canEdit, currentTitle, onSave, title]);
const handleChange = useCallback(
(ev: ChangeEvent<HTMLInputElement>) => {
if (!canEdit || !isEditing) {
return;
}
setCurrentTitle(ev.target.value);
},
[canEdit, isEditing],
);
const handleKeyPress = useCallback(
(ev: KeyboardEvent<HTMLInputElement>) => {
if (!canEdit) {
return;
}
if (ev.key === 'Enter') {
ev.preventDefault();
contentRef.current?.blur();
}
},
[canEdit],
);
return (
<Styles ref={containerRef}>
<Tooltip
id="title-tooltip"
title={showTooltip && currentTitle && !isEditing ? currentTitle : null}
>
{canEdit ? (
<input
data-test="editable-title-input"
className="chart-title-input"
aria-label={t('Chart title')}
ref={contentRef}
onChange={handleChange}
onBlur={handleBlur}
onClick={handleClick}
onKeyPress={handleKeyPress}
placeholder={placeholder}
value={currentTitle}
css={css`
cursor: ${isEditing ? 'text' : 'pointer'};
${inputWidth &&
inputWidth > 0 &&
css`
width: ${inputWidth}px;
`}
`}
/>
) : (
<span
className="chart-title"
aria-label={t('Chart title')}
ref={contentRef}
>
{currentTitle}
</span>
)}
</Tooltip>
<span ref={sizerRef} className="input-sizer" aria-hidden tabIndex={-1} />
</Styles>
);
};

View File

@@ -22,6 +22,7 @@ import { bindActionCreators } from 'redux';
import PropTypes from 'prop-types';
import {
CategoricalColorNamespace,
css,
SupersetClient,
styled,
t,
@@ -33,7 +34,6 @@ import {
} from 'src/reports/actions/reports';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import { chartPropShape } from 'src/dashboard/util/propShapes';
import EditableTitle from 'src/components/EditableTitle';
import AlteredSliceTag from 'src/components/AlteredSliceTag';
import FaveStar from 'src/components/FaveStar';
import Timer from 'src/components/Timer';
@@ -44,6 +44,7 @@ import CertifiedBadge from 'src/components/CertifiedBadge';
import withToasts from 'src/components/MessageToasts/withToasts';
import RowCountLabel from '../RowCountLabel';
import ExploreAdditionalActionsMenu from '../ExploreAdditionalActionsMenu';
import { ChartEditableTitle } from './ChartEditableTitle';
const CHART_STATUS_MAP = {
failed: 'danger',
@@ -53,8 +54,8 @@ const CHART_STATUS_MAP = {
const propTypes = {
actions: PropTypes.object.isRequired,
can_overwrite: PropTypes.bool.isRequired,
can_download: PropTypes.bool.isRequired,
canOverwrite: PropTypes.bool.isRequired,
canDownload: PropTypes.bool.isRequired,
dashboardId: PropTypes.number,
isStarred: PropTypes.bool.isRequired,
slice: PropTypes.object,
@@ -67,37 +68,41 @@ const propTypes = {
};
const StyledHeader = styled.div`
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: wrap;
justify-content: space-between;
span[role='button'] {
${({ theme }) => css`
display: flex;
flex-direction: row;
align-items: center;
flex-wrap: nowrap;
justify-content: space-between;
height: 100%;
}
.title-panel {
display: flex;
align-items: center;
}
.right-button-panel {
display: flex;
align-items: center;
> .btn-group {
flex: 0 0 auto;
margin-left: ${({ theme }) => theme.gridUnit}px;
span[role='button'] {
display: flex;
height: 100%;
}
}
.action-button {
color: ${({ theme }) => theme.colors.grayscale.base};
margin: 0 ${({ theme }) => theme.gridUnit * 1.5}px 0
${({ theme }) => theme.gridUnit}px;
}
.title-panel {
display: flex;
align-items: center;
min-width: 0;
margin-right: ${theme.gridUnit * 6}px;
}
.right-button-panel {
display: flex;
align-items: center;
> .btn-group {
flex: 0 0 auto;
margin-left: ${theme.gridUnit}px;
}
}
.action-button {
color: ${theme.colors.grayscale.base};
margin: 0 ${theme.gridUnit * 1.5}px 0 ${theme.gridUnit}px;
}
`}
`;
const StyledButtons = styled.span`
@@ -173,13 +178,6 @@ export class ExploreChartHeader extends React.PureComponent {
.catch(() => {});
}
getSliceName() {
const { sliceName, table_name: tableName } = this.props;
const title = sliceName || t('%s - untitled', tableName);
return title;
}
postChartFormData() {
this.props.actions.postChartFormData(
this.props.form_data,
@@ -221,22 +219,45 @@ export class ExploreChartHeader extends React.PureComponent {
}
render() {
const { user, form_data: formData, slice } = this.props;
const {
actions,
chart,
user,
formData,
slice,
canOverwrite,
canDownload,
isStarred,
sliceUpdated,
sliceName,
} = this.props;
const {
chartStatus,
chartUpdateEndTime,
chartUpdateStartTime,
latestQueryFormData,
queriesResponse,
} = this.props.chart;
sliceFormData,
} = chart;
// TODO: when will get appropriate design for multi queries use all results and not first only
const queryResponse = queriesResponse?.[0];
const oldSliceName = slice?.slice_name;
const chartFinished = ['failed', 'rendered', 'success'].includes(
this.props.chart.chartStatus,
chartStatus,
);
return (
<StyledHeader id="slice-header" className="panel-title-large">
<StyledHeader id="slice-header">
<div className="title-panel">
<ChartEditableTitle
title={sliceName}
canEdit={
!slice ||
canOverwrite ||
(slice?.owners || []).includes(user?.userId)
}
onSave={actions.updateChartTitle}
placeholder={t('Add the name of the chart')}
/>
{slice?.certified_by && (
<>
<CertifiedBadge
@@ -245,26 +266,14 @@ export class ExploreChartHeader extends React.PureComponent {
/>{' '}
</>
)}
<EditableTitle
title={this.getSliceName()}
canEdit={
!this.props.slice ||
this.props.can_overwrite ||
(this.props.slice?.owners || []).includes(
this.props?.user?.userId,
)
}
onSaveTitle={this.props.actions.updateChartTitle}
/>
{this.props.slice && (
{slice && (
<StyledButtons>
{user.userId && (
<FaveStar
itemId={this.props.slice.slice_id}
fetchFaveStar={this.props.actions.fetchFaveStar}
saveFaveStar={this.props.actions.saveFaveStar}
isStarred={this.props.isStarred}
itemId={slice.slice_id}
fetchFaveStar={actions.fetchFaveStar}
saveFaveStar={actions.saveFaveStar}
isStarred={isStarred}
showTooltip
/>
)}
@@ -272,15 +281,15 @@ export class ExploreChartHeader extends React.PureComponent {
<PropertiesModal
show={this.state.isPropertiesModalOpen}
onHide={this.closePropertiesModal}
onSave={this.props.sliceUpdated}
slice={this.props.slice}
onSave={sliceUpdated}
slice={slice}
/>
)}
{this.props.chart.sliceFormData && (
{sliceFormData && (
<AlteredSliceTag
className="altered"
origFormData={this.props.chart.sliceFormData}
currentFormData={formData}
origFormData={{ ...sliceFormData, chartTitle: oldSliceName }}
currentFormData={{ ...formData, chartTitle: sliceName }}
/>
)}
</StyledButtons>
@@ -306,10 +315,10 @@ export class ExploreChartHeader extends React.PureComponent {
status={CHART_STATUS_MAP[chartStatus]}
/>
<ExploreAdditionalActionsMenu
onOpenInEditor={this.props.actions.redirectSQLLab}
onOpenInEditor={actions.redirectSQLLab}
onOpenPropertiesModal={this.openPropertiesModal}
slice={this.props.slice}
canDownloadCSV={this.props.can_download}
slice={slice}
canDownloadCSV={canDownload}
latestQueryFormData={latestQueryFormData}
canAddReports={this.canAddReports()}
/>