feat: Cross-referenced Dashboards in Chart list (Column + Filter) (#21760)

Co-authored-by: Kamil Gabryjelski <kamil.gabryjelski@gmail.com>
This commit is contained in:
Geido 2022-10-13 14:50:03 +03:00 committed by GitHub
parent d5b4bdeb72
commit 49b48eeca4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 550 additions and 23 deletions

View File

@ -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);
});
});
});

View File

@ -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', () => {

View File

@ -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);
});

View File

@ -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>
);
}

View File

@ -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();
});

View File

@ -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>
);
}

View File

@ -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>

View File

@ -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) => {

View File

@ -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>

View File

@ -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);
}
}, [

View File

@ -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} />