You are viewing a plain text version of this content. The canonical link for it is here.
Posted to commits@superset.apache.org by di...@apache.org on 2022/10/13 11:50:28 UTC

[superset] branch master updated: feat: Cross-referenced Dashboards in Chart list (Column + Filter) (#21760)

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

diegopucci 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 49b48eeca4 feat: Cross-referenced Dashboards in Chart list (Column + Filter) (#21760)
49b48eeca4 is described below

commit 49b48eeca484fafa2f1c5ef2520694b1bdf5c522
Author: Geido <60...@users.noreply.github.com>
AuthorDate: Thu Oct 13 14:50:03 2022 +0300

    feat: Cross-referenced Dashboards in Chart list (Column + Filter) (#21760)
    
    Co-authored-by: Kamil Gabryjelski <ka...@gmail.com>
---
 .../cypress/integration/chart_list/filter.test.ts  |  14 +++
 .../cypress/integration/chart_list/list.test.ts    |   9 +-
 .../src/components/ListView/CrossLinks.test.tsx    |  97 ++++++++++++++++
 .../src/components/ListView/CrossLinks.tsx         | 122 +++++++++++++++++++++
 .../components/ListView/CrossLinksTooltip.test.tsx |  89 +++++++++++++++
 .../src/components/ListView/CrossLinksTooltip.tsx  |  73 ++++++++++++
 .../nativeFilters/FilterCard/DependenciesRow.tsx   |  12 +-
 .../nativeFilters/FilterCard/NameRow.tsx           |   2 +-
 .../nativeFilters/FilterCard/ScopeRow.tsx          |  12 +-
 .../useTruncation/index.ts}                        |  44 ++++++--
 .../src/views/CRUD/chart/ChartList.tsx             |  99 +++++++++++++++++
 11 files changed, 550 insertions(+), 23 deletions(-)

diff --git a/superset-frontend/cypress-base/cypress/integration/chart_list/filter.test.ts b/superset-frontend/cypress-base/cypress/integration/chart_list/filter.test.ts
index eff4151079..7bd0891cbf 100644
--- a/superset-frontend/cypress-base/cypress/integration/chart_list/filter.test.ts
+++ b/superset-frontend/cypress-base/cypress/integration/chart_list/filter.test.ts
@@ -62,6 +62,13 @@ describe('Charts filters', () => {
       setFilter('Dataset', 'unicode_test');
       cy.getBySel('styled-card').should('have.length', 1);
     });
+
+    it('should filter by dashboards correctly', () => {
+      setFilter('Dashboards', 'Unicode Test');
+      cy.getBySel('styled-card').should('have.length', 1);
+      setFilter('Dashboards', 'Tabbed Dashboard');
+      cy.getBySel('styled-card').should('have.length', 8);
+    });
   });
 
   describe('list-view', () => {
@@ -96,5 +103,12 @@ describe('Charts filters', () => {
       setFilter('Dataset', 'unicode_test');
       cy.getBySel('table-row').should('have.length', 1);
     });
+
+    it('should filter by dashboards correctly', () => {
+      setFilter('Dashboards', 'Unicode Test');
+      cy.getBySel('table-row').should('have.length', 1);
+      setFilter('Dashboards', 'Tabbed Dashboard');
+      cy.getBySel('table-row').should('have.length', 8);
+    });
   });
 });
diff --git a/superset-frontend/cypress-base/cypress/integration/chart_list/list.test.ts b/superset-frontend/cypress-base/cypress/integration/chart_list/list.test.ts
index e3837445d9..6981ead73a 100644
--- a/superset-frontend/cypress-base/cypress/integration/chart_list/list.test.ts
+++ b/superset-frontend/cypress-base/cypress/integration/chart_list/list.test.ts
@@ -59,10 +59,11 @@ describe('Charts list', () => {
       cy.getBySel('sort-header').eq(1).contains('Chart');
       cy.getBySel('sort-header').eq(2).contains('Visualization type');
       cy.getBySel('sort-header').eq(3).contains('Dataset');
-      cy.getBySel('sort-header').eq(4).contains('Modified by');
-      cy.getBySel('sort-header').eq(5).contains('Last modified');
-      cy.getBySel('sort-header').eq(6).contains('Created by');
-      cy.getBySel('sort-header').eq(7).contains('Actions');
+      cy.getBySel('sort-header').eq(4).contains('Dashboards added to');
+      cy.getBySel('sort-header').eq(5).contains('Modified by');
+      cy.getBySel('sort-header').eq(6).contains('Last modified');
+      cy.getBySel('sort-header').eq(7).contains('Created by');
+      cy.getBySel('sort-header').eq(8).contains('Actions');
     });
 
     it('should sort correctly in list mode', () => {
diff --git a/superset-frontend/src/components/ListView/CrossLinks.test.tsx b/superset-frontend/src/components/ListView/CrossLinks.test.tsx
new file mode 100644
index 0000000000..ad7eb4e0dd
--- /dev/null
+++ b/superset-frontend/src/components/ListView/CrossLinks.test.tsx
@@ -0,0 +1,97 @@
+/**
+ * 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 { render, screen } from 'spec/helpers/testing-library';
+import CrossLinks, { CrossLinksProps } from './CrossLinks';
+
+const mockedProps = {
+  crossLinks: [
+    {
+      id: 1,
+      title: 'Test dashboard',
+    },
+    {
+      id: 2,
+      title: 'Test dashboard 2',
+    },
+    {
+      id: 3,
+      title: 'Test dashboard 3',
+    },
+    {
+      id: 4,
+      title: 'Test dashboard 4',
+    },
+  ],
+};
+
+function setup(overrideProps: CrossLinksProps | {} = {}) {
+  return render(<CrossLinks {...mockedProps} {...overrideProps} />, {
+    useRouter: true,
+  });
+}
+
+test('should render', () => {
+  const { container } = setup();
+  expect(container).toBeInTheDocument();
+});
+
+test('should not render links', () => {
+  setup({
+    crossLinks: [],
+  });
+  expect(screen.queryByRole('link')).not.toBeInTheDocument();
+});
+
+test('should render the link with just one item', () => {
+  setup({
+    crossLinks: [
+      {
+        id: 1,
+        title: 'Test dashboard',
+      },
+    ],
+  });
+  expect(screen.getByText('Test dashboard')).toBeInTheDocument();
+  expect(screen.getByRole('link')).toHaveAttribute(
+    'href',
+    `/superset/dashboard/1`,
+  );
+});
+
+test('should render a custom prefix link', () => {
+  setup({
+    crossLinks: [
+      {
+        id: 1,
+        title: 'Test dashboard',
+      },
+    ],
+    linkPrefix: '/custom/dashboard/',
+  });
+  expect(screen.getByRole('link')).toHaveAttribute(
+    'href',
+    `/custom/dashboard/1`,
+  );
+});
+
+test('should render multiple links', () => {
+  setup();
+  expect(screen.getAllByRole('link')).toHaveLength(4);
+});
diff --git a/superset-frontend/src/components/ListView/CrossLinks.tsx b/superset-frontend/src/components/ListView/CrossLinks.tsx
new file mode 100644
index 0000000000..3941bcf6ca
--- /dev/null
+++ b/superset-frontend/src/components/ListView/CrossLinks.tsx
@@ -0,0 +1,122 @@
+/**
+ * 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, { useMemo, useRef } from 'react';
+import { styled } from '@superset-ui/core';
+import { Link } from 'react-router-dom';
+import { useTruncation } from 'src/hooks/useTruncation';
+import CrossLinksTooltip from './CrossLinksTooltip';
+
+export type CrossLinkProps = {
+  title: string;
+  id: number;
+};
+
+export type CrossLinksProps = {
+  crossLinks: Array<CrossLinkProps>;
+  maxLinks?: number;
+  linkPrefix?: string;
+};
+
+const StyledCrossLinks = styled.div`
+  ${({ theme }) => `
+    & > span {
+      width: 100%;
+      display: flex;
+
+      .ant-tooltip-open {
+        display: inline;
+      }
+
+      .truncated {
+        overflow: hidden;
+        text-overflow: ellipsis;
+        white-space: nowrap;
+        display: inline-block;
+        width: 100%;
+        vertical-align: bottom;
+      }
+
+      .count {
+        cursor: pointer;
+        color: ${theme.colors.grayscale.base};
+        font-weight: ${theme.typography.weights.bold};
+      }
+    }
+  `}
+`;
+
+export default function CrossLinks({
+  crossLinks,
+  maxLinks = 20,
+  linkPrefix = '/superset/dashboard/',
+}: CrossLinksProps) {
+  const crossLinksRef = useRef<HTMLDivElement>(null);
+  const plusRef = useRef<HTMLDivElement>(null);
+  const [elementsTruncated, hasHiddenElements] = useTruncation(
+    crossLinksRef,
+    plusRef,
+  );
+  const hasMoreItems = useMemo(
+    () =>
+      crossLinks.length > maxLinks ? crossLinks.length - maxLinks : undefined,
+    [crossLinks, maxLinks],
+  );
+  const links = useMemo(
+    () => (
+      <span className="truncated" ref={crossLinksRef} data-test="crosslinks">
+        {crossLinks.map((link, index) => (
+          <Link
+            key={link.id}
+            to={linkPrefix + link.id}
+            target="_blank"
+            rel="noreferer noopener"
+          >
+            {index === 0 ? link.title : `, ${link.title}`}
+          </Link>
+        ))}
+      </span>
+    ),
+    [crossLinks],
+  );
+  const tooltipLinks = useMemo(
+    () =>
+      crossLinks.slice(0, maxLinks).map(l => ({
+        title: l.title,
+        to: linkPrefix + l.id,
+      })),
+    [crossLinks, maxLinks],
+  );
+
+  return (
+    <StyledCrossLinks>
+      <CrossLinksTooltip
+        moreItems={hasMoreItems}
+        crossLinks={tooltipLinks}
+        show={!!elementsTruncated}
+      >
+        {links}
+        {hasHiddenElements && (
+          <span ref={plusRef} className="count">
+            +{elementsTruncated}
+          </span>
+        )}
+      </CrossLinksTooltip>
+    </StyledCrossLinks>
+  );
+}
diff --git a/superset-frontend/src/components/ListView/CrossLinksTooltip.test.tsx b/superset-frontend/src/components/ListView/CrossLinksTooltip.test.tsx
new file mode 100644
index 0000000000..96723e7bf6
--- /dev/null
+++ b/superset-frontend/src/components/ListView/CrossLinksTooltip.test.tsx
@@ -0,0 +1,89 @@
+/**
+ * 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 userEvent from '@testing-library/user-event';
+import React from 'react';
+import { render, screen, waitFor } from 'spec/helpers/testing-library';
+import CrossLinksTooltip, { CrossLinksTooltipProps } from './CrossLinksTooltip';
+
+const mockedProps = {
+  crossLinks: [
+    {
+      to: 'somewhere/1',
+      title: 'Test dashboard',
+    },
+    {
+      to: 'somewhere/2',
+      title: 'Test dashboard 2',
+    },
+    {
+      to: 'somewhere/3',
+      title: 'Test dashboard 3',
+    },
+    {
+      to: 'somewhere/4',
+      title: 'Test dashboard 4',
+    },
+  ],
+  moreItems: 0,
+  show: true,
+};
+
+function setup(overrideProps: CrossLinksTooltipProps | {} = {}) {
+  return render(
+    <CrossLinksTooltip {...mockedProps} {...overrideProps}>
+      Hover me
+    </CrossLinksTooltip>,
+    {
+      useRouter: true,
+    },
+  );
+}
+
+test('should render', () => {
+  const { container } = setup();
+  expect(container).toBeInTheDocument();
+});
+
+test('should render multiple links', async () => {
+  setup();
+  userEvent.hover(screen.getByText('Hover me'));
+
+  await waitFor(() => {
+    expect(screen.getByText('Test dashboard')).toBeInTheDocument();
+    expect(screen.getByText('Test dashboard 2')).toBeInTheDocument();
+    expect(screen.getByText('Test dashboard 3')).toBeInTheDocument();
+    expect(screen.getByText('Test dashboard 4')).toBeInTheDocument();
+    expect(screen.getAllByRole('link')).toHaveLength(4);
+  });
+});
+
+test('should not render the "+ {x} more"', () => {
+  setup();
+  userEvent.hover(screen.getByText('Hover me'));
+  expect(screen.queryByTestId('plus-more')).not.toBeInTheDocument();
+});
+
+test('should render the "+ {x} more"', async () => {
+  setup({
+    moreItems: 3,
+  });
+  userEvent.hover(screen.getByText('Hover me'));
+  expect(await screen.findByTestId('plus-more')).toBeInTheDocument();
+  expect(await screen.findByText('+ 3 more')).toBeInTheDocument();
+});
diff --git a/superset-frontend/src/components/ListView/CrossLinksTooltip.tsx b/superset-frontend/src/components/ListView/CrossLinksTooltip.tsx
new file mode 100644
index 0000000000..cc552cd8b4
--- /dev/null
+++ b/superset-frontend/src/components/ListView/CrossLinksTooltip.tsx
@@ -0,0 +1,73 @@
+/**
+ * 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 { styled, t } from '@superset-ui/core';
+import { Tooltip } from 'src/components/Tooltip';
+import { Link } from 'react-router-dom';
+
+export type CrossLinksTooltipProps = {
+  children: React.ReactNode;
+  crossLinks: { to: string; title: string }[];
+  moreItems?: number;
+  show: boolean;
+};
+
+const StyledLinkedTooltip = styled.div`
+  .link {
+    color: ${({ theme }) => theme.colors.grayscale.light5};
+    display: block;
+    text-decoration: underline;
+  }
+`;
+
+export default function CrossLinksTooltip({
+  children,
+  crossLinks = [],
+  moreItems = undefined,
+  show = false,
+}: CrossLinksTooltipProps) {
+  return (
+    <Tooltip
+      placement="top"
+      data-test="crosslinks-tooltip"
+      title={
+        show && (
+          <StyledLinkedTooltip>
+            {crossLinks.map(link => (
+              <Link
+                className="link"
+                key={link.to}
+                to={link.to}
+                target="_blank"
+                rel="noreferer noopener"
+              >
+                {link.title}
+              </Link>
+            ))}
+            {moreItems && (
+              <span data-test="plus-more">{t('+ %s more', moreItems)}</span>
+            )}
+          </StyledLinkedTooltip>
+        )
+      }
+    >
+      {children}
+    </Tooltip>
+  );
+}
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/DependenciesRow.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/DependenciesRow.tsx
index 18a1c257b4..704357c134 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/DependenciesRow.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/DependenciesRow.tsx
@@ -21,6 +21,7 @@ import { useDispatch } from 'react-redux';
 import { css, t, useTheme } from '@superset-ui/core';
 import { setDirectPathToChild } from 'src/dashboard/actions/dashboardState';
 import Icons from 'src/components/Icons';
+import { useTruncation } from 'src/hooks/useTruncation';
 import {
   DependencyItem,
   Row,
@@ -30,7 +31,6 @@ import {
   TooltipList,
 } from './Styles';
 import { useFilterDependencies } from './useFilterDependencies';
-import { useTruncation } from './useTruncation';
 import { DependencyValueProps, FilterCardRowProps } from './types';
 import { TooltipWithTruncation } from './TooltipWithTruncation';
 
@@ -55,7 +55,11 @@ const DependencyValue = ({
 export const DependenciesRow = React.memo(({ filter }: FilterCardRowProps) => {
   const dependencies = useFilterDependencies(filter);
   const dependenciesRef = useRef<HTMLDivElement>(null);
-  const [elementsTruncated, hasHiddenElements] = useTruncation(dependenciesRef);
+  const plusRef = useRef<HTMLDivElement>(null);
+  const [elementsTruncated, hasHiddenElements] = useTruncation(
+    dependenciesRef,
+    plusRef,
+  );
   const theme = useTheme();
 
   const tooltipText = useMemo(
@@ -108,7 +112,9 @@ export const DependenciesRow = React.memo(({ filter }: FilterCardRowProps) => {
           ))}
         </RowValue>
         {hasHiddenElements && (
-          <RowTruncationCount>+{elementsTruncated}</RowTruncationCount>
+          <RowTruncationCount ref={plusRef}>
+            +{elementsTruncated}
+          </RowTruncationCount>
         )}
       </TooltipWithTruncation>
     </Row>
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx
index 05cb811948..f6268296ef 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/NameRow.tsx
@@ -19,9 +19,9 @@
 import React, { useRef } from 'react';
 import { css, SupersetTheme } from '@superset-ui/core';
 import Icons from 'src/components/Icons';
+import { useTruncation } from 'src/hooks/useTruncation';
 import { Row, FilterName } from './Styles';
 import { FilterCardRowProps } from './types';
-import { useTruncation } from './useTruncation';
 import { TooltipWithTruncation } from './TooltipWithTruncation';
 
 export const NameRow = ({ filter }: FilterCardRowProps) => {
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/ScopeRow.tsx b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/ScopeRow.tsx
index 66656f0ba5..8da224c0e7 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/ScopeRow.tsx
+++ b/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/ScopeRow.tsx
@@ -18,6 +18,7 @@
  */
 import React, { useMemo, useRef } from 'react';
 import { t } from '@superset-ui/core';
+import { useTruncation } from 'src/hooks/useTruncation';
 import { useFilterScope } from './useFilterScope';
 import {
   Row,
@@ -27,7 +28,6 @@ import {
   TooltipList,
   TooltipSectionLabel,
 } from './Styles';
-import { useTruncation } from './useTruncation';
 import { FilterCardRowProps } from './types';
 import { TooltipWithTruncation } from './TooltipWithTruncation';
 
@@ -46,8 +46,12 @@ const getTooltipSection = (items: string[] | undefined, label: string) =>
 export const ScopeRow = React.memo(({ filter }: FilterCardRowProps) => {
   const scope = useFilterScope(filter);
   const scopeRef = useRef<HTMLDivElement>(null);
+  const plusRef = useRef<HTMLDivElement>(null);
 
-  const [elementsTruncated, hasHiddenElements] = useTruncation(scopeRef);
+  const [elementsTruncated, hasHiddenElements] = useTruncation(
+    scopeRef,
+    plusRef,
+  );
   const tooltipText = useMemo(() => {
     if (elementsTruncated === 0 || !scope) {
       return null;
@@ -77,7 +81,9 @@ export const ScopeRow = React.memo(({ filter }: FilterCardRowProps) => {
             : t('None')}
         </RowValue>
         {hasHiddenElements > 0 && (
-          <RowTruncationCount>+{elementsTruncated}</RowTruncationCount>
+          <RowTruncationCount ref={plusRef}>
+            +{elementsTruncated}
+          </RowTruncationCount>
         )}
       </TooltipWithTruncation>
     </Row>
diff --git a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/useTruncation.ts b/superset-frontend/src/hooks/useTruncation/index.ts
similarity index 71%
rename from superset-frontend/src/dashboard/components/nativeFilters/FilterCard/useTruncation.ts
rename to superset-frontend/src/hooks/useTruncation/index.ts
index a4a893463f..7f3e1bcade 100644
--- a/superset-frontend/src/dashboard/components/nativeFilters/FilterCard/useTruncation.ts
+++ b/superset-frontend/src/hooks/useTruncation/index.ts
@@ -18,17 +18,23 @@
  */
 import { RefObject, useLayoutEffect, useState, useRef } from 'react';
 
-export const useTruncation = (elementRef: RefObject<HTMLElement>) => {
+export const useTruncation = (
+  elementRef: RefObject<HTMLElement>,
+  plusRef?: RefObject<HTMLElement>,
+) => {
   const [elementsTruncated, setElementsTruncated] = useState(0);
   const [hasHiddenElements, setHasHiddenElements] = useState(false);
 
   const previousEffectInfoRef = useRef({
     scrollWidth: 0,
     parentElementWidth: 0,
+    plusRefWidth: 0,
   });
 
   useLayoutEffect(() => {
     const currentElement = elementRef.current;
+    const plusRefElement = plusRef?.current;
+
     if (!currentElement) {
       return;
     }
@@ -45,36 +51,50 @@ export const useTruncation = (elementRef: RefObject<HTMLElement>) => {
     // the child nodes changes.
     const previousEffectInfo = previousEffectInfoRef.current;
     const parentElementWidth = currentElement.parentElement?.clientWidth || 0;
+    const plusRefWidth = plusRefElement?.offsetWidth || 0;
     previousEffectInfoRef.current = {
       scrollWidth,
       parentElementWidth,
+      plusRefWidth,
     };
 
     if (
       previousEffectInfo.parentElementWidth === parentElementWidth &&
-      previousEffectInfo.scrollWidth === scrollWidth
+      previousEffectInfo.scrollWidth === scrollWidth &&
+      previousEffectInfo.plusRefWidth === plusRefWidth
     ) {
       return;
     }
 
     if (scrollWidth > clientWidth) {
       // "..." is around 6px wide
-      const maxWidth = clientWidth - 6;
+      const truncationWidth = 6;
+      const plusSize = plusRefElement?.offsetWidth || 0;
+      const maxWidth = clientWidth - truncationWidth;
       const elementsCount = childNodes.length;
+
       let width = 0;
-      let i = 0;
-      while (width < maxWidth) {
-        width += (childNodes[i] as HTMLElement).offsetWidth;
-        i += 1;
+      let hiddenElements = 0;
+      for (let i = 0; i < elementsCount; i += 1) {
+        const itemWidth = (childNodes[i] as HTMLElement).offsetWidth;
+        const remainingWidth = maxWidth - truncationWidth - width - plusSize;
+
+        // assures it shows +{number} only when the item is not visible
+        if (remainingWidth <= 0) {
+          hiddenElements += 1;
+        }
+        width += itemWidth;
       }
-      if (i === elementsCount) {
-        setElementsTruncated(1);
-        setHasHiddenElements(false);
-      } else {
-        setElementsTruncated(elementsCount - i);
+
+      if (elementsCount > 1 && hiddenElements) {
         setHasHiddenElements(true);
+        setElementsTruncated(hiddenElements);
+      } else {
+        setHasHiddenElements(false);
+        setElementsTruncated(1);
       }
     } else {
+      setHasHiddenElements(false);
       setElementsTruncated(0);
     }
   }, [
diff --git a/superset-frontend/src/views/CRUD/chart/ChartList.tsx b/superset-frontend/src/views/CRUD/chart/ChartList.tsx
index 8fbf37392f..7600dfbf5d 100644
--- a/superset-frontend/src/views/CRUD/chart/ChartList.tsx
+++ b/superset-frontend/src/views/CRUD/chart/ChartList.tsx
@@ -17,7 +17,9 @@
  * under the License.
  */
 import {
+  ensureIsArray,
   getChartMetadataRegistry,
+  JsonResponse,
   styled,
   SupersetClient,
   t,
@@ -49,6 +51,7 @@ import ListView, {
   ListViewProps,
   SelectOption,
 } from 'src/components/ListView';
+import CrossLinks from 'src/components/ListView/CrossLinks';
 import Loading from 'src/components/Loading';
 import { dangerouslyGetItemDoNotUse } from 'src/utils/localStorageHelpers';
 import withToasts from 'src/components/MessageToasts/withToasts';
@@ -145,6 +148,11 @@ interface ChartListProps {
   };
 }
 
+type ChartLinkedDashboard = {
+  id: number;
+  dashboard_title: string;
+};
+
 const Actions = styled.div`
   color: ${({ theme }) => theme.colors.grayscale.base};
 `;
@@ -217,6 +225,7 @@ function ChartList(props: ChartListProps) {
   const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
   const enableBroadUserAccess =
     bootstrapData?.common?.conf?.ENABLE_BROAD_ACTIVITY_ACCESS;
+  const crossRefEnabled = isFeatureEnabled(FeatureFlag.CROSS_REFERENCES);
   const handleBulkChartExport = (chartsToExport: Chart[]) => {
     const ids = chartsToExport.map(({ id }) => id);
     handleResourceExport('chart', ids, () => {
@@ -246,6 +255,80 @@ function ChartList(props: ChartListProps) {
       ),
     );
   }
+  const fetchDashboards = async (
+    filterValue = '',
+    page: number,
+    pageSize: number,
+  ) => {
+    // add filters if filterValue
+    const filters = filterValue
+      ? {
+          filters: [
+            {
+              col: 'dashboards',
+              opr: FilterOperator.relationManyMany,
+              value: filterValue,
+            },
+          ],
+        }
+      : {};
+    const queryParams = rison.encode({
+      columns: ['dashboard_title', 'id'],
+      keys: ['none'],
+      order_column: 'dashboard_title',
+      order_direction: 'asc',
+      page,
+      page_size: pageSize,
+      ...filters,
+    });
+    const response: void | JsonResponse = await SupersetClient.get({
+      endpoint: !filterValue
+        ? `/api/v1/dashboard/?q=${queryParams}`
+        : `/api/v1/chart/?q=${queryParams}`,
+    }).catch(() =>
+      addDangerToast(t('An error occurred while fetching dashboards')),
+    );
+    const dashboards = response?.json?.result?.map(
+      ({
+        dashboard_title: dashboardTitle,
+        id,
+      }: {
+        dashboard_title: string;
+        id: number;
+      }) => ({
+        label: dashboardTitle,
+        value: id,
+      }),
+    );
+    return {
+      data: uniqBy<SelectOption>(dashboards, 'value'),
+      totalCount: response?.json?.count,
+    };
+  };
+
+  const dashboardsCol = useMemo(
+    () => ({
+      Cell: ({
+        row: {
+          original: { dashboards },
+        },
+      }: any) => (
+        <CrossLinks
+          crossLinks={ensureIsArray(dashboards).map(
+            (d: ChartLinkedDashboard) => ({
+              title: d.dashboard_title,
+              id: d.id,
+            }),
+          )}
+        />
+      ),
+      Header: t('Dashboards added to'),
+      accessor: 'dashboards',
+      disableSortBy: true,
+      size: 'xxl',
+    }),
+    [],
+  );
 
   const columns = useMemo(
     () => [
@@ -324,6 +407,7 @@ function ChartList(props: ChartListProps) {
         disableSortBy: true,
         size: 'xl',
       },
+      ...(crossRefEnabled ? [dashboardsCol] : []),
       {
         Cell: ({
           row: {
@@ -490,6 +574,19 @@ function ChartList(props: ChartListProps) {
     [],
   );
 
+  const dashboardsFilter: Filter = useMemo(
+    () => ({
+      Header: t('Dashboards'),
+      id: 'dashboards',
+      input: 'select',
+      operator: FilterOperator.relationManyMany,
+      unfilteredLabel: t('All'),
+      fetchSelects: fetchDashboards,
+      paginate: true,
+    }),
+    [],
+  );
+
   const filters: Filters = useMemo(
     () => [
       {
@@ -568,6 +665,7 @@ function ChartList(props: ChartListProps) {
         fetchSelects: createFetchDatasets,
         paginate: true,
       },
+      ...(crossRefEnabled ? [dashboardsFilter] : []),
       ...(userId ? [favoritesFilter] : []),
       {
         Header: t('Certified'),
@@ -682,6 +780,7 @@ function ChartList(props: ChartListProps) {
       });
     }
   }
+
   return (
     <>
       <SubMenu name={t('Charts')} buttons={subMenuButtons} />