Skip to content

Commit

Permalink
feat(explore): Move chart header to top of the page (#19529)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
kgabryje committed Apr 5, 2022
1 parent 1eef923 commit 602afba
Show file tree
Hide file tree
Showing 8 changed files with 602 additions and 300 deletions.
10 changes: 6 additions & 4 deletions superset-frontend/src/components/FaveStar/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@
*/

import React, { useCallback } from 'react';
import { t, styled } from '@superset-ui/core';
import { css, t, styled } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import { useComponentDidMount } from 'src/hooks/useComponentDidMount';
import Icons from 'src/components/Icons';
Expand All @@ -32,9 +32,11 @@ interface FaveStarProps {
}

const StyledLink = styled.a`
font-size: ${({ theme }) => theme.typography.sizes.xl}px;
display: flex;
padding: 0 0 0 0.5em;
${({ theme }) => css`
font-size: ${theme.typography.sizes.xl}px;
display: flex;
padding: 0 0 0 ${theme.gridUnit * 2}px;
`};
`;

const FaveStar = ({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ const DatasourceContainer = styled.div`
color: ${theme.colors.grayscale.light1};
}
.form-control.input-md {
width: calc(100% - ${theme.gridUnit * 4}px);
width: calc(100% - ${theme.gridUnit * 8}px);
height: ${theme.gridUnit * 8}px;
margin: ${theme.gridUnit * 2}px auto;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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();
});
});
Original file line number Diff line number Diff line change
@@ -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>
);
};

0 comments on commit 602afba

Please sign in to comment.