mirror of
https://github.com/apache/superset.git
synced 2026-05-11 19:05:24 +00:00
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:
committed by
GitHub
parent
1eef923b31
commit
602afbaa31
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
' ',
|
||||
);
|
||||
}
|
||||
}, [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>
|
||||
);
|
||||
};
|
||||
@@ -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()}
|
||||
/>
|
||||
|
||||
Reference in New Issue
Block a user