You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by ru...@apache.org on 2026/08/30 19:25:44 UTC

(superset) branch master updated: feat(dashboard): add a tooltip displaying chart descriptions (#43271)

This is an automated email from the ASF dual-hosted git repository.

rusackas pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git


The following commit(s) were added to refs/heads/master by this push:
     new b310f469c20 feat(dashboard): add a tooltip displaying chart descriptions (#43271)
b310f469c20 is described below

commit b310f469c208cb7f5885189f21558935b4b66687
Author: SBIN2010 <Sb...@mail.ru>
AuthorDate: Sun Aug 30 22:25:37 2026 +0300

    feat(dashboard): add a tooltip displaying chart descriptions (#43271)
---
 .../components/SliceHeader/SliceHeader.test.tsx    | 180 ++++++++++++++++++++-
 .../components/SliceHeader/SliceInfo.test.tsx      |  64 ++++++++
 .../dashboard/components/SliceHeader/SliceInfo.tsx |  45 ++++++
 .../src/dashboard/components/SliceHeader/index.tsx |  32 +++-
 4 files changed, 319 insertions(+), 2 deletions(-)

diff --git a/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx b/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx
index 5ccb7fc546d..73bce191e29 100644
--- a/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeader/SliceHeader.test.tsx
@@ -19,7 +19,12 @@
 import { Router } from 'react-router-dom';
 import { createMemoryHistory } from 'history';
 import { getExtensionsRegistry, VizType } from '@superset-ui/core';
-import { render, screen, userEvent } from 'spec/helpers/testing-library';
+import {
+  fireEvent,
+  render,
+  screen,
+  userEvent,
+} from 'spec/helpers/testing-library';
 import {
   enableMobileConsumptionFlag,
   mockMobileMatchMedia,
@@ -106,6 +111,13 @@ jest.mock('src/dashboard/components/FiltersBadge', () => ({
   ),
 }));
 
+jest.mock('./SliceInfo', () => ({
+  __esModule: true,
+  default: ({ slice }: { slice: { description: string } }) => (
+    <div data-test="slice-info">{slice.description}</div>
+  ),
+}));
+
 jest.mock('src/dashboard/util/isEmbedded', () => ({
   isEmbedded: jest.fn().mockReturnValue(false),
 }));
@@ -571,6 +583,172 @@ test('Correct actions to "SliceHeaderControls"', () => {
   expect(props.handleToggleFullSize).toHaveBeenCalledTimes(1);
 });
 
+test('Should show chart description info icon when description exists and is collapsed', () => {
+  const props = createProps({
+    slice: {
+      ...createProps().slice,
+      description: 'Test chart description',
+    },
+    isExpanded: false,
+  });
+  render(<SliceHeader {...props} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState,
+  });
+  expect(screen.getByTestId('chart-description-info-icon')).toBeInTheDocument();
+});
+
+test('Should hide chart description info icon when description is expanded', () => {
+  const props = createProps({
+    slice: {
+      ...createProps().slice,
+      description: 'Test chart description',
+    },
+    isExpanded: true,
+  });
+  render(<SliceHeader {...props} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState,
+  });
+  expect(
+    screen.queryByTestId('chart-description-info-icon'),
+  ).not.toBeInTheDocument();
+});
+
+test('Should hide chart description info icon when chart has no description', () => {
+  const props = createProps({
+    slice: {
+      ...createProps().slice,
+      description: '',
+    },
+    isExpanded: false,
+  });
+  render(<SliceHeader {...props} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState,
+  });
+  expect(
+    screen.queryByTestId('chart-description-info-icon'),
+  ).not.toBeInTheDocument();
+});
+
+test('Chart description icon is a keyboard-focusable button', () => {
+  const props = createProps({
+    slice: {
+      ...createProps().slice,
+      description: 'Test chart description',
+    },
+    isExpanded: false,
+  });
+  render(<SliceHeader {...props} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState,
+  });
+  const icon = screen.getByRole('button', { name: 'Chart description' });
+  icon.focus();
+  expect(icon).toHaveFocus();
+});
+
+test('Should show chart description in popover on hover', async () => {
+  const props = createProps({
+    slice: {
+      ...createProps().slice,
+      description: 'Test chart description',
+    },
+    isExpanded: false,
+  });
+  render(<SliceHeader {...props} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState,
+  });
+
+  expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
+
+  await userEvent.hover(screen.getByTestId('chart-description-info-icon'));
+
+  expect(await screen.findByTestId('slice-info')).toHaveTextContent(
+    'Test chart description',
+  );
+});
+
+test('Should show chart description in popover on click', async () => {
+  const props = createProps({
+    slice: {
+      ...createProps().slice,
+      description: 'Test chart description',
+    },
+    isExpanded: false,
+  });
+  render(<SliceHeader {...props} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState,
+  });
+
+  expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
+
+  await userEvent.click(screen.getByTestId('chart-description-info-icon'));
+
+  expect(await screen.findByTestId('slice-info')).toHaveTextContent(
+    'Test chart description',
+  );
+});
+
+test('Should open chart description popover with Enter', async () => {
+  const props = createProps({
+    slice: {
+      ...createProps().slice,
+      description: 'Test chart description',
+    },
+    isExpanded: false,
+  });
+  render(<SliceHeader {...props} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState,
+  });
+
+  const icon = screen.getByRole('button', { name: 'Chart description' });
+  expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
+
+  // user-event v12 (pinned in this repo) doesn't expose .keyboard(); use
+  // fireEvent to dispatch keydown directly to the focused icon.
+  icon.focus();
+  fireEvent.keyDown(icon, { key: 'Enter' });
+  expect(await screen.findByTestId('slice-info')).toHaveTextContent(
+    'Test chart description',
+  );
+});
+
+test('Should open chart description popover with Space', async () => {
+  const props = createProps({
+    slice: {
+      ...createProps().slice,
+      description: 'Test chart description',
+    },
+    isExpanded: false,
+  });
+  render(<SliceHeader {...props} />, {
+    useRedux: true,
+    useRouter: true,
+    initialState,
+  });
+
+  const icon = screen.getByRole('button', { name: 'Chart description' });
+  expect(screen.queryByTestId('slice-info')).not.toBeInTheDocument();
+
+  icon.focus();
+  fireEvent.keyDown(icon, { key: ' ' });
+  expect(await screen.findByTestId('slice-info')).toHaveTextContent(
+    'Test chart description',
+  );
+});
+
 test('Add extension to SliceHeader', () => {
   const extensionsRegistry = getExtensionsRegistry();
   extensionsRegistry.set('dashboard.slice.header', () => (
diff --git a/superset-frontend/src/dashboard/components/SliceHeader/SliceInfo.test.tsx b/superset-frontend/src/dashboard/components/SliceHeader/SliceInfo.test.tsx
new file mode 100644
index 00000000000..9306174a37a
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/SliceHeader/SliceInfo.test.tsx
@@ -0,0 +1,64 @@
+/**
+ * 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 { render, screen } from 'spec/helpers/testing-library';
+import SliceInfo from './SliceInfo';
+
+jest.mock('@superset-ui/core/components/SafeMarkdown/SafeMarkdown', () => ({
+  SafeMarkdown: ({ source }: { source: string }) => (
+    <div data-test="safe-markdown">{source}</div>
+  ),
+}));
+
+const setup = (description = 'Default description') =>
+  render(<SliceInfo slice={{ description }} />);
+
+test('Should render chart description', () => {
+  setup('Hello world');
+  expect(screen.getByTestId('safe-markdown')).toHaveTextContent('Hello world');
+});
+
+test('Should pass markdown source to SafeMarkdown', () => {
+  const markdown = [
+    '# Chart overview',
+    '',
+    'This chart shows **revenue** by region.',
+    '',
+    '- North',
+    '- South',
+    '',
+    '[Learn more](https://superset.apache.org)',
+  ].join('\n');
+
+  setup(markdown);
+  expect(screen.getByTestId('safe-markdown').textContent).toBe(markdown);
+});
+
+test('Should render long markdown description without crashing', () => {
+  const longMarkdown = `# Summary\n\n${'Long description paragraph. '.repeat(100)}`;
+
+  setup(longMarkdown);
+  const content = screen.getByTestId('safe-markdown').textContent ?? '';
+  expect(content).toContain('# Summary');
+  expect(content.match(/Long description paragraph\./g)).toHaveLength(100);
+});
+
+test('Should render empty description without crashing', () => {
+  setup('');
+  expect(screen.getByTestId('safe-markdown')).toBeEmptyDOMElement();
+});
diff --git a/superset-frontend/src/dashboard/components/SliceHeader/SliceInfo.tsx b/superset-frontend/src/dashboard/components/SliceHeader/SliceInfo.tsx
new file mode 100644
index 00000000000..cd452280d0c
--- /dev/null
+++ b/superset-frontend/src/dashboard/components/SliceHeader/SliceInfo.tsx
@@ -0,0 +1,45 @@
+/**
+ * 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 { FC } from 'react';
+import { css, styled } from '@apache-superset/core/theme';
+import { SafeMarkdown } from '@superset-ui/core/components';
+
+const SliceInfoContainer = styled.div`
+  ${({ theme }) => css`
+    max-width: 350px;
+    max-height: 400px;
+    overflow-y: auto;
+    overflow-x: auto;
+    font-size: ${theme.fontSize}px;
+  `}
+`;
+
+interface SliceInfoProps {
+  slice: {
+    description: string;
+  };
+}
+
+const SliceInfo: FC<SliceInfoProps> = ({ slice }) => (
+  <SliceInfoContainer>
+    <SafeMarkdown source={slice.description} />
+  </SliceInfoContainer>
+);
+
+export default SliceInfo;
diff --git a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
index ba8e7b27617..f674d91ee79 100644
--- a/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
+++ b/superset-frontend/src/dashboard/components/SliceHeader/index.tsx
@@ -28,6 +28,7 @@ import {
 import { t } from '@apache-superset/core/translation';
 import {
   getExtensionsRegistry,
+  handleKeyboardActivation,
   JsonObject,
   QueryData,
   VizType,
@@ -40,7 +41,12 @@ import {
 } from '@apache-superset/core/theme';
 import { useUiConfig } from 'src/components/UiConfigContext';
 import { isEmbedded } from 'src/dashboard/util/isEmbedded';
-import { Tooltip, EditableTitle, Icons } from '@superset-ui/core/components';
+import {
+  Tooltip,
+  EditableTitle,
+  Icons,
+  Popover,
+} from '@superset-ui/core/components';
 import { useSelector } from 'react-redux';
 import SliceHeaderControls from 'src/dashboard/components/SliceHeaderControls';
 import { useIsMobile } from 'src/hooks/useIsMobile';
@@ -52,6 +58,7 @@ import { getSliceHeaderTooltip } from 'src/dashboard/util/getSliceHeaderTooltip'
 import { DashboardPageIdContext } from 'src/dashboard/containers/DashboardPage';
 import RowCountLabel from 'src/components/RowCountLabel';
 import { Link } from 'react-router-dom';
+import SliceInfo from './SliceInfo';
 
 const extensionsRegistry = getExtensionsRegistry();
 
@@ -210,6 +217,8 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
       state => state.charts[slice.slice_id].queriesResponse?.[1],
     );
 
+    const [isDescriptionOpen, setIsDescriptionOpen] = useState(false);
+
     const theme = useTheme();
 
     const rowLimit = Number(formData.row_limit ?? 0);
@@ -339,6 +348,27 @@ const SliceHeader = forwardRef<HTMLDivElement, SliceHeaderProps>(
                   <CrossFilterIcon iconSize="m" />
                 </Tooltip>
               )}
+              {slice.description && !isExpanded && (
+                <Popover
+                  trigger={['hover', 'click']}
+                  content={<SliceInfo slice={slice} />}
+                  placement="leftBottom"
+                  open={isDescriptionOpen}
+                  onOpenChange={setIsDescriptionOpen}
+                >
+                  <Icons.InfoCircleOutlined
+                    iconSize="m"
+                    // eslint-disable-next-line jsx-a11y/prefer-tag-over-role
+                    role="button"
+                    tabIndex={0}
+                    aria-label={t('Chart description')}
+                    data-test="chart-description-info-icon"
+                    onKeyDown={handleKeyboardActivation(() =>
+                      setIsDescriptionOpen(open => !open),
+                    )}
+                  />
+                </Popover>
+              )}
               {!uiConfig.hideChartControls && (
                 <MemoizedCustomizationsBadge chartId={slice.slice_id} />
               )}