feat: Adds MetadataBar to Drill to Detail modal (#21343)

This commit is contained in:
Michael S. Molina 2022-09-08 08:49:03 -03:00 committed by GitHub
parent 0601b2db99
commit 8ebf4ed3ff
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
9 changed files with 427 additions and 202 deletions

View File

@ -20,7 +20,7 @@ import React from 'react';
import moment from 'moment';
import { ensureIsArray, styled, t } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { ContentType, MetadataType } from './ContentType';
import { ContentType, MetadataType } from '.';
const Header = styled.div`
font-weight: ${({ theme }) => theme.typography.weights.bold};

View File

@ -19,7 +19,7 @@
import React from 'react';
import { css } from '@superset-ui/core';
import { useResizeDetector } from 'react-resize-detector';
import MetadataBar, { MetadataBarProps } from './index';
import MetadataBar, { MetadataBarProps, MetadataType } from '.';
export default {
title: 'MetadataBar',
@ -72,26 +72,26 @@ Component.story = {
Component.args = {
items: [
{
type: 'sql',
type: MetadataType.SQL,
title: 'Click to view query',
},
{
type: 'owner',
type: MetadataType.OWNER,
createdBy: 'Jane Smith',
owners: ['John Doe', 'Mary Wilson'],
createdOn: A_WEEK_AGO,
},
{
type: 'lastModified',
type: MetadataType.LAST_MODIFIED,
value: A_WEEK_AGO,
modifiedBy: 'Jane Smith',
},
{
type: 'tags',
type: MetadataType.TAGS,
values: ['management', 'research', 'poc'],
},
{
type: 'dashboards',
type: MetadataType.DASHBOARDS,
title: 'Added to 452 dashboards',
description:
'To preview the list of dashboards go to "More" settings on the right.',

View File

@ -23,8 +23,12 @@ import * as resizeDetector from 'react-resize-detector';
import moment from 'moment';
import { supersetTheme } from '@superset-ui/core';
import { hexToRgb } from 'src/utils/colorUtils';
import MetadataBar, { MIN_NUMBER_ITEMS, MAX_NUMBER_ITEMS } from '.';
import { ContentType, MetadataType } from './ContentType';
import MetadataBar, {
MIN_NUMBER_ITEMS,
MAX_NUMBER_ITEMS,
ContentType,
MetadataType,
} from '.';
const DASHBOARD_TITLE = 'Added to 452 dashboards';
const DASHBOARD_DESCRIPTION =
@ -166,8 +170,8 @@ test('renders the items sorted', () => {
const { container } = render(<MetadataBar items={ITEMS.slice(0, 6)} />);
const nodes = container.firstChild?.childNodes as NodeListOf<HTMLElement>;
expect(within(nodes[0]).getByText(DASHBOARD_TITLE)).toBeInTheDocument();
expect(within(nodes[1]).getByText(ROWS_TITLE)).toBeInTheDocument();
expect(within(nodes[2]).getByText(SQL_TITLE)).toBeInTheDocument();
expect(within(nodes[1]).getByText(SQL_TITLE)).toBeInTheDocument();
expect(within(nodes[2]).getByText(ROWS_TITLE)).toBeInTheDocument();
expect(within(nodes[3]).getByText(DESCRIPTION_VALUE)).toBeInTheDocument();
expect(within(nodes[4]).getByText(CREATED_BY)).toBeInTheDocument();
});
@ -217,7 +221,7 @@ test('correctly renders the owner tooltip', async () => {
test('correctly renders the rows tooltip', async () => {
await runWithBarCollapsed(async () => {
render(<MetadataBar items={ITEMS.slice(4, 8)} />);
userEvent.hover(screen.getAllByRole('img')[0]);
userEvent.hover(screen.getAllByRole('img')[2]);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(within(tooltip).getByText(ROWS_TITLE)).toBeInTheDocument();
@ -237,7 +241,7 @@ test('correctly renders the sql tooltip', async () => {
test('correctly renders the table tooltip', async () => {
await runWithBarCollapsed(async () => {
render(<MetadataBar items={ITEMS.slice(4, 8)} />);
userEvent.hover(screen.getAllByRole('img')[2]);
userEvent.hover(screen.getAllByRole('img')[0]);
const tooltip = await screen.findByRole('tooltip');
expect(tooltip).toBeInTheDocument();
expect(within(tooltip).getByText(TABLE_TITLE)).toBeInTheDocument();

View File

@ -0,0 +1,190 @@
/**
* 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, { useEffect, useRef, useState } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import { uniqWith } from 'lodash';
import { styled } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import { ContentType } from './ContentType';
import { config } from './ContentConfig';
export const MIN_NUMBER_ITEMS = 2;
export const MAX_NUMBER_ITEMS = 6;
const HORIZONTAL_PADDING = 12;
const VERTICAL_PADDING = 8;
const ICON_PADDING = 8;
const SPACE_BETWEEN_ITEMS = 16;
const ICON_WIDTH = 16;
const TEXT_MIN_WIDTH = 70;
const TEXT_MAX_WIDTH = 150;
const ORDER = {
dashboards: 0,
table: 1,
sql: 2,
rows: 3,
tags: 4,
description: 5,
owner: 6,
lastModified: 7,
};
const Bar = styled.div<{ count: number }>`
${({ theme, count }) => `
display: flex;
align-items: center;
padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px;
background-color: ${theme.colors.grayscale.light4};
color: ${theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.s}px;
min-width: ${
HORIZONTAL_PADDING * 2 +
(ICON_WIDTH + SPACE_BETWEEN_ITEMS) * count -
SPACE_BETWEEN_ITEMS
}px;
`}
`;
const StyledItem = styled.div<{
collapsed: boolean;
last: boolean;
onClick?: () => void;
}>`
${({ theme, collapsed, last, onClick }) => `
max-width: ${
ICON_WIDTH +
ICON_PADDING +
TEXT_MAX_WIDTH +
(last ? 0 : SPACE_BETWEEN_ITEMS)
}px;
min-width: ${ICON_WIDTH + (last ? 0 : SPACE_BETWEEN_ITEMS)}px;
overflow: hidden;
text-overflow: ${collapsed ? 'unset' : 'ellipsis'};
white-space: nowrap;
padding-right: ${last ? 0 : SPACE_BETWEEN_ITEMS}px;
text-decoration: ${onClick ? 'underline' : 'none'};
cursor: ${onClick ? 'pointer' : 'default'};
& > span {
color: ${onClick && collapsed ? theme.colors.primary.base : 'undefined'};
padding-right: ${collapsed ? 0 : ICON_PADDING}px;
}
`}
`;
// Make sure big tootips are truncated
const TootipContent = styled.div`
display: -webkit-box;
-webkit-line-clamp: 20;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
`;
const Item = ({
barWidth,
contentType,
collapsed,
last = false,
}: {
barWidth: number | undefined;
contentType: ContentType;
collapsed: boolean;
last?: boolean;
}) => {
const { icon, title, tooltip = title } = config(contentType);
const [isTruncated, setIsTruncated] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const Icon = icon;
const { type, onClick } = contentType;
useEffect(() => {
setIsTruncated(
ref.current ? ref.current.scrollWidth > ref.current.clientWidth : false,
);
}, [barWidth, setIsTruncated, contentType]);
const content = (
<StyledItem
collapsed={collapsed}
last={last}
onClick={onClick ? () => onClick(type) : undefined}
ref={ref}
>
<Icon iconSize="l" />
{!collapsed && title}
</StyledItem>
);
return isTruncated || collapsed || (tooltip && tooltip !== title) ? (
<Tooltip title={<TootipContent>{tooltip}</TootipContent>}>
{content}
</Tooltip>
) : (
content
);
};
export interface MetadataBarProps {
/**
* Array of content type configurations. To see the available properties
* for each content type, check {@link ContentType}
*/
items: ContentType[];
}
/**
* The metadata bar component is used to display additional information about an entity.
* Content types are predefined and consistent across the whole app. This means that
* they will be displayed and behave in a consistent manner, keeping the same ordering,
* information formatting, and interactions.
* To extend the list of content types, a developer needs to request the inclusion of the new type in the design system.
* This process is important to make sure the new type is reviewed by the design team, improving Superset consistency.
*/
const MetadataBar = ({ items }: MetadataBarProps) => {
const { width, ref } = useResizeDetector();
const uniqueItems = uniqWith(items, (a, b) => a.type === b.type);
const sortedItems = uniqueItems.sort((a, b) => ORDER[a.type] - ORDER[b.type]);
const count = sortedItems.length;
if (count < MIN_NUMBER_ITEMS) {
throw Error('The minimum number of items for the metadata bar is 2.');
}
if (count > MAX_NUMBER_ITEMS) {
throw Error('The maximum number of items for the metadata bar is 6.');
}
// Calculates the breakpoint width to collapse the bar.
// The last item does not have a space, so we subtract SPACE_BETWEEN_ITEMS from the total.
const breakpoint =
(ICON_WIDTH + ICON_PADDING + TEXT_MIN_WIDTH + SPACE_BETWEEN_ITEMS) * count -
SPACE_BETWEEN_ITEMS;
const collapsed = Boolean(width && width < breakpoint);
return (
<Bar ref={ref} count={count}>
{sortedItems.map((item, index) => (
<Item
barWidth={width}
key={index}
contentType={item}
collapsed={collapsed}
last={index === count - 1}
/>
))}
</Bar>
);
};
export default MetadataBar;

View File

@ -16,175 +16,14 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useRef, useState } from 'react';
import { useResizeDetector } from 'react-resize-detector';
import { uniqWith } from 'lodash';
import { styled } from '@superset-ui/core';
import { Tooltip } from 'src/components/Tooltip';
import { ContentType } from './ContentType';
import { config } from './ContentConfig';
export const MIN_NUMBER_ITEMS = 2;
export const MAX_NUMBER_ITEMS = 6;
const HORIZONTAL_PADDING = 12;
const VERTICAL_PADDING = 8;
const ICON_PADDING = 8;
const SPACE_BETWEEN_ITEMS = 16;
const ICON_WIDTH = 16;
const TEXT_MIN_WIDTH = 70;
const TEXT_MAX_WIDTH = 150;
const ORDER = {
dashboards: 0,
rows: 1,
sql: 2,
table: 3,
tags: 4,
description: 5,
owner: 6,
lastModified: 7,
};
const Bar = styled.div<{ count: number }>`
${({ theme, count }) => `
display: flex;
align-items: center;
padding: ${VERTICAL_PADDING}px ${HORIZONTAL_PADDING}px;
background-color: ${theme.colors.grayscale.light4};
color: ${theme.colors.grayscale.base};
font-size: ${theme.typography.sizes.s}px;
min-width: ${
HORIZONTAL_PADDING * 2 +
(ICON_WIDTH + SPACE_BETWEEN_ITEMS) * count -
SPACE_BETWEEN_ITEMS
}px;
`}
`;
const StyledItem = styled.div<{
collapsed: boolean;
last: boolean;
onClick?: () => void;
}>`
${({ theme, collapsed, last, onClick }) => `
max-width: ${
ICON_WIDTH +
ICON_PADDING +
TEXT_MAX_WIDTH +
(last ? 0 : SPACE_BETWEEN_ITEMS)
}px;
min-width: ${ICON_WIDTH + (last ? 0 : SPACE_BETWEEN_ITEMS)}px;
overflow: hidden;
text-overflow: ${collapsed ? 'unset' : 'ellipsis'};
white-space: nowrap;
padding-right: ${last ? 0 : SPACE_BETWEEN_ITEMS}px;
text-decoration: ${onClick ? 'underline' : 'none'};
cursor: ${onClick ? 'pointer' : 'default'};
& > span {
color: ${onClick && collapsed ? theme.colors.primary.base : 'undefined'};
padding-right: ${collapsed ? 0 : ICON_PADDING}px;
}
`}
`;
// Make sure big tootips are truncated
const TootipContent = styled.div`
display: -webkit-box;
-webkit-line-clamp: 20;
-webkit-box-orient: vertical;
overflow: hidden;
text-overflow: ellipsis;
`;
const Item = ({
barWidth,
contentType,
collapsed,
last = false,
}: {
barWidth: number | undefined;
contentType: ContentType;
collapsed: boolean;
last?: boolean;
}) => {
const { icon, title, tooltip = title } = config(contentType);
const [isTruncated, setIsTruncated] = useState(false);
const ref = useRef<HTMLDivElement>(null);
const Icon = icon;
const { type, onClick } = contentType;
useEffect(() => {
setIsTruncated(
ref.current ? ref.current.scrollWidth > ref.current.clientWidth : false,
);
}, [barWidth, setIsTruncated, contentType]);
const content = (
<StyledItem
collapsed={collapsed}
last={last}
onClick={onClick ? () => onClick(type) : undefined}
ref={ref}
>
<Icon iconSize="l" />
{!collapsed && title}
</StyledItem>
);
return isTruncated || collapsed || (tooltip && tooltip !== title) ? (
<Tooltip title={<TootipContent>{tooltip}</TootipContent>}>
{content}
</Tooltip>
) : (
content
);
};
export interface MetadataBarProps {
/**
* Array of content type configurations. To see the available properties
* for each content type, check {@link ContentType}
*/
items: ContentType[];
}
/**
* The metadata bar component is used to display additional information about an entity.
* Content types are predefined and consistent across the whole app. This means that
* they will be displayed and behave in a consistent manner, keeping the same ordering,
* information formatting, and interactions.
* To extend the list of content types, a developer needs to request the inclusion of the new type in the design system.
* This process is important to make sure the new type is reviewed by the design team, improving Superset consistency.
*/
const MetadataBar = ({ items }: MetadataBarProps) => {
const { width, ref } = useResizeDetector();
const uniqueItems = uniqWith(items, (a, b) => a.type === b.type);
const sortedItems = uniqueItems.sort((a, b) => ORDER[a.type] - ORDER[b.type]);
const count = sortedItems.length;
if (count < MIN_NUMBER_ITEMS) {
throw Error('The minimum number of items for the metadata bar is 2.');
}
if (count > MAX_NUMBER_ITEMS) {
throw Error('The maximum number of items for the metadata bar is 6.');
}
// Calculates the breakpoint width to collapse the bar.
// The last item does not have a space, so we subtract SPACE_BETWEEN_ITEMS from the total.
const breakpoint =
(ICON_WIDTH + ICON_PADDING + TEXT_MIN_WIDTH + SPACE_BETWEEN_ITEMS) * count -
SPACE_BETWEEN_ITEMS;
const collapsed = Boolean(width && width < breakpoint);
return (
<Bar ref={ref} count={count}>
{sortedItems.map((item, index) => (
<Item
barWidth={width}
key={index}
contentType={item}
collapsed={collapsed}
last={index === count - 1}
/>
))}
</Bar>
);
};
import MetadataBar, {
MetadataBarProps,
MIN_NUMBER_ITEMS,
MAX_NUMBER_ITEMS,
} from './MetadataBar';
export default MetadataBar;
export { MetadataBarProps, MIN_NUMBER_ITEMS, MAX_NUMBER_ITEMS };
export * from './ContentType';

View File

@ -22,6 +22,7 @@ import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore';
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
import { QueryFormData, SupersetClient } from '@superset-ui/core';
import fetchMock from 'fetch-mock';
import moment from 'moment';
import DrillDetailPane from './DrillDetailPane';
const chart = chartQueries[sliceId];
@ -37,12 +38,46 @@ const setup = (overrides: Record<string, any> = {}) => {
store,
});
};
const waitForRender = (overrides: Record<string, any> = {}) =>
waitFor(() => setup(overrides));
const samplesEndpoint =
const SAMPLES_ENDPOINT =
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=7&per_page=50&page=1';
const fetchWithNoData = () =>
fetchMock.post(samplesEndpoint, {
const DATASET_ENDPOINT = 'glob:*/api/v1/dataset/*';
const MOCKED_DATASET = {
changed_on: new Date(Date.parse('2022-01-01')),
created_on: new Date(Date.parse('2022-01-01')),
description: 'Simple description',
table_name: 'test_table',
changed_by: {
first_name: 'John',
last_name: 'Doe',
},
created_by: {
first_name: 'John',
last_name: 'Doe',
},
owners: [
{
first_name: 'John',
last_name: 'Doe',
},
],
};
const setupDatasetEndpoint = () => {
fetchMock.get(DATASET_ENDPOINT, {
status: 'complete',
result: MOCKED_DATASET,
});
};
const fetchWithNoData = () => {
setupDatasetEndpoint();
fetchMock.post(SAMPLES_ENDPOINT, {
result: {
total_count: 0,
data: [],
@ -50,8 +85,11 @@ const fetchWithNoData = () =>
coltypes: [],
},
});
const fetchWithData = () =>
fetchMock.post(samplesEndpoint, {
};
const fetchWithData = () => {
setupDatasetEndpoint();
fetchMock.post(SAMPLES_ENDPOINT, {
result: {
total_count: 3,
data: [
@ -75,7 +113,7 @@ const fetchWithData = () =>
coltypes: [0, 0, 0],
},
});
const SupersetClientPost = jest.spyOn(SupersetClient, 'post');
};
afterEach(fetchMock.restore);
@ -85,12 +123,12 @@ test('should render', async () => {
expect(container).toBeInTheDocument();
});
test('should render the loading component', async () => {
test('should render metadata and table loading indicators', async () => {
fetchWithData();
setup();
await waitFor(() => {
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
});
await waitFor(() =>
expect(screen.getAllByLabelText('Loading').length).toBe(2),
);
});
test('should render the table with results', async () => {
@ -117,8 +155,42 @@ test('should render the "No results" components', async () => {
).toBeInTheDocument();
});
test('should render the metadata bar', async () => {
fetchWithNoData();
setup();
expect(
await screen.findByText(MOCKED_DATASET.table_name),
).toBeInTheDocument();
expect(
await screen.findByText(MOCKED_DATASET.description),
).toBeInTheDocument();
expect(
await screen.findByText(
`${MOCKED_DATASET.created_by.first_name} ${MOCKED_DATASET.created_by.last_name}`,
),
).toBeInTheDocument();
expect(
await screen.findByText(moment.utc(MOCKED_DATASET.changed_on).fromNow()),
).toBeInTheDocument();
});
test('should render an error message when fails to load the metadata', async () => {
fetchWithNoData();
fetchMock.get(
DATASET_ENDPOINT,
{ status: 'error', error: 'Some error' },
{ overwriteRoutes: true },
);
setup();
expect(
await screen.findByText('There was an error loading the dataset metadata'),
).toBeInTheDocument();
});
test('should render the error', async () => {
SupersetClientPost.mockRejectedValue(new Error('Something went wrong'));
jest
.spyOn(SupersetClient, 'post')
.mockRejectedValue(new Error('Something went wrong'));
await waitForRender();
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
});

View File

@ -28,7 +28,6 @@ import {
BinaryQueryObjectFilterClause,
css,
ensureIsArray,
GenericDataType,
t,
useTheme,
QueryFormData,
@ -39,15 +38,15 @@ import { EmptyStateMedium } from 'src/components/EmptyState';
import TableView, { EmptyWrapperType } from 'src/components/TableView';
import { useTableColumns } from 'src/explore/components/DataTableControl';
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
import MetadataBar, {
ContentType,
MetadataType,
} from 'src/components/MetadataBar';
import Alert from 'src/components/Alert';
import { useApiV1Resource } from 'src/hooks/apiResources';
import TableControls from './TableControls';
import { getDrillPayload } from './utils';
type ResultsPage = {
total: number;
data: Record<string, any>[];
colNames: string[];
colTypes: GenericDataType[];
};
import { Dataset, ResultsPage } from './types';
const PAGE_SIZE = 50;
@ -242,8 +241,78 @@ export default function DrillDetailPane({
);
}
// Get datasource metadata
const response = useApiV1Resource<Dataset>(`/api/v1/dataset/${datasourceId}`);
const metadata = useMemo(() => {
const { status, result } = response;
const items: ContentType[] = [];
if (result) {
const {
changed_on,
created_on,
description,
table_name,
changed_by,
created_by,
owners,
} = result;
const notAvailable = t('Not available');
const createdBy =
`${created_by?.first_name ?? ''} ${
created_by?.last_name ?? ''
}`.trim() || notAvailable;
const modifiedBy = changed_by
? `${changed_by.first_name} ${changed_by.last_name}`
: notAvailable;
const formattedOwners =
owners.length > 0
? owners.map(owner => `${owner.first_name} ${owner.last_name}`)
: [notAvailable];
items.push({
type: MetadataType.TABLE,
title: table_name,
});
items.push({
type: MetadataType.LAST_MODIFIED,
value: changed_on,
modifiedBy,
});
items.push({
type: MetadataType.OWNER,
createdBy,
owners: formattedOwners,
createdOn: created_on,
});
if (description) {
items.push({
type: MetadataType.DESCRIPTION,
value: description,
});
}
}
return (
<div
css={css`
display: flex;
margin-bottom: ${theme.gridUnit * 4}px;
`}
>
{status === 'loading' && <Loading position="inline-centered" />}
{status === 'complete' && <MetadataBar items={items} />}
{status === 'error' && (
<Alert
type="error"
message={t('There was an error loading the dataset metadata')}
/>
)}
</div>
);
}, [response, theme.gridUnit]);
return (
<>
{metadata}
<TableControls
filters={filters}
setFilters={setFilters}

View File

@ -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 { GenericDataType } from '@superset-ui/core';
export type ResultsPage = {
total: number;
data: Record<string, any>[];
colNames: string[];
colTypes: GenericDataType[];
};
export type Dataset = {
changed_by?: {
first_name: string;
last_name: string;
};
created_by?: {
first_name: string;
last_name: string;
};
changed_on: Date;
created_on: Date;
description: string;
table_name: string;
owners: {
first_name: string;
last_name: string;
}[];
};

View File

@ -178,6 +178,12 @@ class DatasetRestApi(BaseSupersetModelRestApi):
"url",
"extra",
"kind",
"created_on",
"created_by.first_name",
"created_by.last_name",
"changed_on",
"changed_by.first_name",
"changed_by.last_name",
]
show_columns = show_select_columns + [
"columns.type_generic",