mirror of
https://github.com/apache/superset.git
synced 2024-09-07 06:17:45 -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 { 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};
|
||||
|
@ -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.',
|
||||
|
@ -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();
|
||||
|
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
|
||||
* 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';
|
||||
|
@ -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();
|
||||
});
|
||||
|
@ -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}
|
||||
|
@ -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",
|
||||
"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",
|
||||
|
Loading…
Reference in New Issue
Block a user