mirror of
https://github.com/apache/superset.git
synced 2024-09-18 19:49:37 -04:00
feat: Adds MetadataBar to Drill to Detail modal (#21343)
This commit is contained in:
parent
0601b2db99
commit
8ebf4ed3ff
@ -20,7 +20,7 @@ import React from 'react';
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
||||||
import Icons from 'src/components/Icons';
|
import Icons from 'src/components/Icons';
|
||||||
import { ContentType, MetadataType } from './ContentType';
|
import { ContentType, MetadataType } from '.';
|
||||||
|
|
||||||
const Header = styled.div`
|
const Header = styled.div`
|
||||||
font-weight: ${({ theme }) => theme.typography.weights.bold};
|
font-weight: ${({ theme }) => theme.typography.weights.bold};
|
||||||
|
@ -19,7 +19,7 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { css } from '@superset-ui/core';
|
import { css } from '@superset-ui/core';
|
||||||
import { useResizeDetector } from 'react-resize-detector';
|
import { useResizeDetector } from 'react-resize-detector';
|
||||||
import MetadataBar, { MetadataBarProps } from './index';
|
import MetadataBar, { MetadataBarProps, MetadataType } from '.';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'MetadataBar',
|
title: 'MetadataBar',
|
||||||
@ -72,26 +72,26 @@ Component.story = {
|
|||||||
Component.args = {
|
Component.args = {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: 'sql',
|
type: MetadataType.SQL,
|
||||||
title: 'Click to view query',
|
title: 'Click to view query',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'owner',
|
type: MetadataType.OWNER,
|
||||||
createdBy: 'Jane Smith',
|
createdBy: 'Jane Smith',
|
||||||
owners: ['John Doe', 'Mary Wilson'],
|
owners: ['John Doe', 'Mary Wilson'],
|
||||||
createdOn: A_WEEK_AGO,
|
createdOn: A_WEEK_AGO,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'lastModified',
|
type: MetadataType.LAST_MODIFIED,
|
||||||
value: A_WEEK_AGO,
|
value: A_WEEK_AGO,
|
||||||
modifiedBy: 'Jane Smith',
|
modifiedBy: 'Jane Smith',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'tags',
|
type: MetadataType.TAGS,
|
||||||
values: ['management', 'research', 'poc'],
|
values: ['management', 'research', 'poc'],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
type: 'dashboards',
|
type: MetadataType.DASHBOARDS,
|
||||||
title: 'Added to 452 dashboards',
|
title: 'Added to 452 dashboards',
|
||||||
description:
|
description:
|
||||||
'To preview the list of dashboards go to "More" settings on the right.',
|
'To preview the list of dashboards go to "More" settings on the right.',
|
||||||
|
@ -23,8 +23,12 @@ import * as resizeDetector from 'react-resize-detector';
|
|||||||
import moment from 'moment';
|
import moment from 'moment';
|
||||||
import { supersetTheme } from '@superset-ui/core';
|
import { supersetTheme } from '@superset-ui/core';
|
||||||
import { hexToRgb } from 'src/utils/colorUtils';
|
import { hexToRgb } from 'src/utils/colorUtils';
|
||||||
import MetadataBar, { MIN_NUMBER_ITEMS, MAX_NUMBER_ITEMS } from '.';
|
import MetadataBar, {
|
||||||
import { ContentType, MetadataType } from './ContentType';
|
MIN_NUMBER_ITEMS,
|
||||||
|
MAX_NUMBER_ITEMS,
|
||||||
|
ContentType,
|
||||||
|
MetadataType,
|
||||||
|
} from '.';
|
||||||
|
|
||||||
const DASHBOARD_TITLE = 'Added to 452 dashboards';
|
const DASHBOARD_TITLE = 'Added to 452 dashboards';
|
||||||
const DASHBOARD_DESCRIPTION =
|
const DASHBOARD_DESCRIPTION =
|
||||||
@ -166,8 +170,8 @@ test('renders the items sorted', () => {
|
|||||||
const { container } = render(<MetadataBar items={ITEMS.slice(0, 6)} />);
|
const { container } = render(<MetadataBar items={ITEMS.slice(0, 6)} />);
|
||||||
const nodes = container.firstChild?.childNodes as NodeListOf<HTMLElement>;
|
const nodes = container.firstChild?.childNodes as NodeListOf<HTMLElement>;
|
||||||
expect(within(nodes[0]).getByText(DASHBOARD_TITLE)).toBeInTheDocument();
|
expect(within(nodes[0]).getByText(DASHBOARD_TITLE)).toBeInTheDocument();
|
||||||
expect(within(nodes[1]).getByText(ROWS_TITLE)).toBeInTheDocument();
|
expect(within(nodes[1]).getByText(SQL_TITLE)).toBeInTheDocument();
|
||||||
expect(within(nodes[2]).getByText(SQL_TITLE)).toBeInTheDocument();
|
expect(within(nodes[2]).getByText(ROWS_TITLE)).toBeInTheDocument();
|
||||||
expect(within(nodes[3]).getByText(DESCRIPTION_VALUE)).toBeInTheDocument();
|
expect(within(nodes[3]).getByText(DESCRIPTION_VALUE)).toBeInTheDocument();
|
||||||
expect(within(nodes[4]).getByText(CREATED_BY)).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 () => {
|
test('correctly renders the rows tooltip', async () => {
|
||||||
await runWithBarCollapsed(async () => {
|
await runWithBarCollapsed(async () => {
|
||||||
render(<MetadataBar items={ITEMS.slice(4, 8)} />);
|
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');
|
const tooltip = await screen.findByRole('tooltip');
|
||||||
expect(tooltip).toBeInTheDocument();
|
expect(tooltip).toBeInTheDocument();
|
||||||
expect(within(tooltip).getByText(ROWS_TITLE)).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 () => {
|
test('correctly renders the table tooltip', async () => {
|
||||||
await runWithBarCollapsed(async () => {
|
await runWithBarCollapsed(async () => {
|
||||||
render(<MetadataBar items={ITEMS.slice(4, 8)} />);
|
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');
|
const tooltip = await screen.findByRole('tooltip');
|
||||||
expect(tooltip).toBeInTheDocument();
|
expect(tooltip).toBeInTheDocument();
|
||||||
expect(within(tooltip).getByText(TABLE_TITLE)).toBeInTheDocument();
|
expect(within(tooltip).getByText(TABLE_TITLE)).toBeInTheDocument();
|
||||||
|
190
superset-frontend/src/components/MetadataBar/MetadataBar.tsx
Normal file
190
superset-frontend/src/components/MetadataBar/MetadataBar.tsx
Normal 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;
|
@ -16,175 +16,14 @@
|
|||||||
* specific language governing permissions and limitations
|
* specific language governing permissions and limitations
|
||||||
* under the License.
|
* under the License.
|
||||||
*/
|
*/
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import MetadataBar, {
|
||||||
import { useResizeDetector } from 'react-resize-detector';
|
MetadataBarProps,
|
||||||
import { uniqWith } from 'lodash';
|
MIN_NUMBER_ITEMS,
|
||||||
import { styled } from '@superset-ui/core';
|
MAX_NUMBER_ITEMS,
|
||||||
import { Tooltip } from 'src/components/Tooltip';
|
} from './MetadataBar';
|
||||||
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>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
export default MetadataBar;
|
export default MetadataBar;
|
||||||
|
|
||||||
|
export { MetadataBarProps, MIN_NUMBER_ITEMS, MAX_NUMBER_ITEMS };
|
||||||
|
|
||||||
|
export * from './ContentType';
|
||||||
|
@ -22,6 +22,7 @@ import { getMockStoreWithNativeFilters } from 'spec/fixtures/mockStore';
|
|||||||
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
import chartQueries, { sliceId } from 'spec/fixtures/mockChartQueries';
|
||||||
import { QueryFormData, SupersetClient } from '@superset-ui/core';
|
import { QueryFormData, SupersetClient } from '@superset-ui/core';
|
||||||
import fetchMock from 'fetch-mock';
|
import fetchMock from 'fetch-mock';
|
||||||
|
import moment from 'moment';
|
||||||
import DrillDetailPane from './DrillDetailPane';
|
import DrillDetailPane from './DrillDetailPane';
|
||||||
|
|
||||||
const chart = chartQueries[sliceId];
|
const chart = chartQueries[sliceId];
|
||||||
@ -37,12 +38,46 @@ const setup = (overrides: Record<string, any> = {}) => {
|
|||||||
store,
|
store,
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const waitForRender = (overrides: Record<string, any> = {}) =>
|
const waitForRender = (overrides: Record<string, any> = {}) =>
|
||||||
waitFor(() => setup(overrides));
|
waitFor(() => setup(overrides));
|
||||||
const samplesEndpoint =
|
|
||||||
|
const SAMPLES_ENDPOINT =
|
||||||
'end:/datasource/samples?force=false&datasource_type=table&datasource_id=7&per_page=50&page=1';
|
'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: {
|
result: {
|
||||||
total_count: 0,
|
total_count: 0,
|
||||||
data: [],
|
data: [],
|
||||||
@ -50,8 +85,11 @@ const fetchWithNoData = () =>
|
|||||||
coltypes: [],
|
coltypes: [],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const fetchWithData = () =>
|
};
|
||||||
fetchMock.post(samplesEndpoint, {
|
|
||||||
|
const fetchWithData = () => {
|
||||||
|
setupDatasetEndpoint();
|
||||||
|
fetchMock.post(SAMPLES_ENDPOINT, {
|
||||||
result: {
|
result: {
|
||||||
total_count: 3,
|
total_count: 3,
|
||||||
data: [
|
data: [
|
||||||
@ -75,7 +113,7 @@ const fetchWithData = () =>
|
|||||||
coltypes: [0, 0, 0],
|
coltypes: [0, 0, 0],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const SupersetClientPost = jest.spyOn(SupersetClient, 'post');
|
};
|
||||||
|
|
||||||
afterEach(fetchMock.restore);
|
afterEach(fetchMock.restore);
|
||||||
|
|
||||||
@ -85,12 +123,12 @@ test('should render', async () => {
|
|||||||
expect(container).toBeInTheDocument();
|
expect(container).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render the loading component', async () => {
|
test('should render metadata and table loading indicators', async () => {
|
||||||
fetchWithData();
|
fetchWithData();
|
||||||
setup();
|
setup();
|
||||||
await waitFor(() => {
|
await waitFor(() =>
|
||||||
expect(screen.getByRole('status', { name: 'Loading' })).toBeInTheDocument();
|
expect(screen.getAllByLabelText('Loading').length).toBe(2),
|
||||||
});
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('should render the table with results', async () => {
|
test('should render the table with results', async () => {
|
||||||
@ -117,8 +155,42 @@ test('should render the "No results" components', async () => {
|
|||||||
).toBeInTheDocument();
|
).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 () => {
|
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();
|
await waitForRender();
|
||||||
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
|
expect(screen.getByText('Error: Something went wrong')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
@ -28,7 +28,6 @@ import {
|
|||||||
BinaryQueryObjectFilterClause,
|
BinaryQueryObjectFilterClause,
|
||||||
css,
|
css,
|
||||||
ensureIsArray,
|
ensureIsArray,
|
||||||
GenericDataType,
|
|
||||||
t,
|
t,
|
||||||
useTheme,
|
useTheme,
|
||||||
QueryFormData,
|
QueryFormData,
|
||||||
@ -39,15 +38,15 @@ import { EmptyStateMedium } from 'src/components/EmptyState';
|
|||||||
import TableView, { EmptyWrapperType } from 'src/components/TableView';
|
import TableView, { EmptyWrapperType } from 'src/components/TableView';
|
||||||
import { useTableColumns } from 'src/explore/components/DataTableControl';
|
import { useTableColumns } from 'src/explore/components/DataTableControl';
|
||||||
import { getDatasourceSamples } from 'src/components/Chart/chartAction';
|
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 TableControls from './TableControls';
|
||||||
import { getDrillPayload } from './utils';
|
import { getDrillPayload } from './utils';
|
||||||
|
import { Dataset, ResultsPage } from './types';
|
||||||
type ResultsPage = {
|
|
||||||
total: number;
|
|
||||||
data: Record<string, any>[];
|
|
||||||
colNames: string[];
|
|
||||||
colTypes: GenericDataType[];
|
|
||||||
};
|
|
||||||
|
|
||||||
const PAGE_SIZE = 50;
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
|
{metadata}
|
||||||
<TableControls
|
<TableControls
|
||||||
filters={filters}
|
filters={filters}
|
||||||
setFilters={setFilters}
|
setFilters={setFilters}
|
||||||
|
@ -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;
|
||||||
|
}[];
|
||||||
|
};
|
@ -178,6 +178,12 @@ class DatasetRestApi(BaseSupersetModelRestApi):
|
|||||||
"url",
|
"url",
|
||||||
"extra",
|
"extra",
|
||||||
"kind",
|
"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 + [
|
show_columns = show_select_columns + [
|
||||||
"columns.type_generic",
|
"columns.type_generic",
|
||||||
|
Loading…
Reference in New Issue
Block a user