feat: Adds the MetadataBar component (#21090)
* feat: Adds the MetadataBar component * Addresses comments
This commit is contained in:
parent
db7e2b2e37
commit
151795663b
|
|
@ -115,6 +115,7 @@ module.exports = {
|
|||
'jsx-a11y/anchor-is-valid': 1,
|
||||
'jsx-a11y/click-events-have-key-events': 0, // re-enable up for discussion
|
||||
'jsx-a11y/mouse-events-have-key-events': 0, // re-enable up for discussion
|
||||
'max-classes-per-file': 0,
|
||||
'new-cap': 0,
|
||||
'no-bitwise': 0,
|
||||
'no-continue': 0,
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ module.exports = {
|
|||
builder: 'webpack5',
|
||||
},
|
||||
stories: [
|
||||
'../src/@(components|common|filters|explore)/**/*.stories.@(t|j)sx',
|
||||
'../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx|mdx)',
|
||||
],
|
||||
addons: [
|
||||
'@storybook/addon-essentials',
|
||||
|
|
@ -47,6 +47,6 @@ module.exports = {
|
|||
plugins: [...config.plugins, ...customConfig.plugins],
|
||||
}),
|
||||
typescript: {
|
||||
reactDocgen: 'none',
|
||||
reactDocgen: 'react-docgen-typescript',
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -66,4 +66,5 @@ addParameters({
|
|||
method: 'alphabetical',
|
||||
},
|
||||
},
|
||||
controls: { expanded: true },
|
||||
});
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -223,6 +223,7 @@
|
|||
"@hot-loader/react-dom": "^16.13.0",
|
||||
"@istanbuljs/nyc-config-typescript": "^1.0.1",
|
||||
"@storybook/addon-actions": "^6.4.22",
|
||||
"@storybook/addon-docs": "^6.5.10",
|
||||
"@storybook/addon-essentials": "^6.4.22",
|
||||
"@storybook/addon-knobs": "^6.3.1",
|
||||
"@storybook/addon-links": "^6.4.22",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,143 @@
|
|||
/**
|
||||
* 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 moment from 'moment';
|
||||
import { ensureIsArray, styled, t } from '@superset-ui/core';
|
||||
import Icons from 'src/components/Icons';
|
||||
import { ContentType, MetadataType } from './ContentType';
|
||||
|
||||
const Header = styled.div`
|
||||
font-weight: ${({ theme }) => theme.typography.weights.bold};
|
||||
`;
|
||||
|
||||
const Info = ({
|
||||
text,
|
||||
header,
|
||||
}: {
|
||||
text?: string | string[];
|
||||
header?: string;
|
||||
}) => {
|
||||
const values = ensureIsArray(text);
|
||||
return (
|
||||
<>
|
||||
{header && <Header>{header}</Header>}
|
||||
{values.map(value => (
|
||||
<div key={value}>{value}</div>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const config = (contentType: ContentType) => {
|
||||
const { type } = contentType;
|
||||
|
||||
/**
|
||||
* Tooltips are very similar. It's pretty much blocks
|
||||
* of header/text pairs. That's why they are implemented here.
|
||||
* If more complex tooltips emerge, then we should extract the different
|
||||
* types of tooltips to their own components and reference them here.
|
||||
*/
|
||||
|
||||
switch (type) {
|
||||
case MetadataType.DASHBOARDS:
|
||||
return {
|
||||
icon: Icons.FundProjectionScreenOutlined,
|
||||
title: contentType.title,
|
||||
tooltip: contentType.description ? (
|
||||
<div>
|
||||
<Info header={contentType.title} text={contentType.description} />
|
||||
</div>
|
||||
) : undefined,
|
||||
};
|
||||
|
||||
case MetadataType.DESCRIPTION:
|
||||
return {
|
||||
icon: Icons.BookOutlined,
|
||||
title: contentType.value,
|
||||
};
|
||||
|
||||
case MetadataType.LAST_MODIFIED:
|
||||
return {
|
||||
icon: Icons.EditOutlined,
|
||||
title: moment.utc(contentType.value).fromNow(),
|
||||
tooltip: (
|
||||
<div>
|
||||
<Info
|
||||
header={t('Last modified')}
|
||||
text={moment.utc(contentType.value).fromNow()}
|
||||
/>
|
||||
<Info header={t('Modified by')} text={contentType.modifiedBy} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
case MetadataType.OWNER:
|
||||
return {
|
||||
icon: Icons.UserOutlined,
|
||||
title: contentType.createdBy,
|
||||
tooltip: (
|
||||
<div>
|
||||
<Info header={t('Created by')} text={contentType.createdBy} />
|
||||
<Info header={t('Owners')} text={contentType.owners} />
|
||||
<Info
|
||||
header={t('Created on')}
|
||||
text={moment.utc(contentType.createdOn).fromNow()}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
case MetadataType.ROWS:
|
||||
return {
|
||||
icon: Icons.InsertRowBelowOutlined,
|
||||
title: contentType.title,
|
||||
tooltip: contentType.title,
|
||||
};
|
||||
|
||||
case MetadataType.SQL:
|
||||
return {
|
||||
icon: Icons.ConsoleSqlOutlined,
|
||||
title: contentType.title,
|
||||
tooltip: contentType.title,
|
||||
};
|
||||
|
||||
case MetadataType.TABLE:
|
||||
return {
|
||||
icon: Icons.Table,
|
||||
title: contentType.title,
|
||||
tooltip: contentType.title,
|
||||
};
|
||||
|
||||
case MetadataType.TAGS:
|
||||
return {
|
||||
icon: Icons.TagsOutlined,
|
||||
title: contentType.values.join(', '),
|
||||
tooltip: (
|
||||
<div>
|
||||
<Info header={t('Tags')} text={contentType.values} />
|
||||
</div>
|
||||
),
|
||||
};
|
||||
|
||||
default:
|
||||
throw Error(`Invalid type provided: ${type}`);
|
||||
}
|
||||
};
|
||||
|
||||
export { config };
|
||||
|
|
@ -0,0 +1,91 @@
|
|||
/**
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export enum MetadataType {
|
||||
DASHBOARDS = 'dashboards',
|
||||
DESCRIPTION = 'description',
|
||||
LAST_MODIFIED = 'lastModified',
|
||||
OWNER = 'owner',
|
||||
ROWS = 'rows',
|
||||
SQL = 'sql',
|
||||
TABLE = 'table',
|
||||
TAGS = 'tags',
|
||||
}
|
||||
|
||||
export type Dashboards = {
|
||||
type: MetadataType.DASHBOARDS;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClick?: (type: string) => void;
|
||||
};
|
||||
|
||||
export type Description = {
|
||||
type: MetadataType.DESCRIPTION;
|
||||
value: string;
|
||||
onClick?: (type: string) => void;
|
||||
};
|
||||
|
||||
export type LastModified = {
|
||||
type: MetadataType.LAST_MODIFIED;
|
||||
value: Date;
|
||||
modifiedBy: string;
|
||||
onClick?: (type: string) => void;
|
||||
};
|
||||
|
||||
export type Owner = {
|
||||
type: MetadataType.OWNER;
|
||||
createdBy: string;
|
||||
owners: string[];
|
||||
createdOn: Date;
|
||||
onClick?: (type: string) => void;
|
||||
};
|
||||
|
||||
export type Rows = {
|
||||
type: MetadataType.ROWS;
|
||||
title: string;
|
||||
onClick?: (type: string) => void;
|
||||
};
|
||||
|
||||
export type Sql = {
|
||||
type: MetadataType.SQL;
|
||||
title: string;
|
||||
onClick?: (type: string) => void;
|
||||
};
|
||||
|
||||
export type Table = {
|
||||
type: MetadataType.TABLE;
|
||||
title: string;
|
||||
onClick?: (type: string) => void;
|
||||
};
|
||||
|
||||
export type Tags = {
|
||||
type: MetadataType.TAGS;
|
||||
values: string[];
|
||||
onClick?: (type: string) => void;
|
||||
};
|
||||
|
||||
export type ContentType =
|
||||
| Dashboards
|
||||
| Description
|
||||
| LastModified
|
||||
| Owner
|
||||
| Rows
|
||||
| Sql
|
||||
| Table
|
||||
| Tags;
|
||||
|
|
@ -0,0 +1,109 @@
|
|||
/**
|
||||
* 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 { css } from '@superset-ui/core';
|
||||
import { useResizeDetector } from 'react-resize-detector';
|
||||
import MetadataBar, { MetadataBarProps } from './index';
|
||||
|
||||
export default {
|
||||
title: 'MetadataBar',
|
||||
component: MetadataBar,
|
||||
};
|
||||
|
||||
const A_WEEK_AGO = new Date(Date.now() - 7 * 24 * 3600 * 1000);
|
||||
|
||||
export const Component = ({
|
||||
items,
|
||||
onClick,
|
||||
}: MetadataBarProps & {
|
||||
onClick: (type: string) => void;
|
||||
}) => {
|
||||
const { width, height, ref } = useResizeDetector();
|
||||
// eslint-disable-next-line no-param-reassign
|
||||
items[0].onClick = onClick;
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
css={css`
|
||||
margin-top: 70px;
|
||||
margin-left: 80px;
|
||||
overflow: auto;
|
||||
min-width: ${168}px;
|
||||
max-width: ${740}px;
|
||||
resize: horizontal;
|
||||
`}
|
||||
>
|
||||
<MetadataBar items={items} />
|
||||
<span
|
||||
css={css`
|
||||
position: absolute;
|
||||
top: 150px;
|
||||
left: 115px;
|
||||
`}
|
||||
>{`${width}x${height}`}</span>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Component.story = {
|
||||
parameters: {
|
||||
knobs: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Component.args = {
|
||||
items: [
|
||||
{
|
||||
type: 'sql',
|
||||
title: 'Click to view query',
|
||||
},
|
||||
{
|
||||
type: 'owner',
|
||||
createdBy: 'Jane Smith',
|
||||
owners: ['John Doe', 'Mary Wilson'],
|
||||
createdOn: A_WEEK_AGO,
|
||||
},
|
||||
{
|
||||
type: 'lastModified',
|
||||
value: A_WEEK_AGO,
|
||||
modifiedBy: 'Jane Smith',
|
||||
},
|
||||
{
|
||||
type: 'tags',
|
||||
values: ['management', 'research', 'poc'],
|
||||
},
|
||||
{
|
||||
type: 'dashboards',
|
||||
title: 'Added to 452 dashboards',
|
||||
description:
|
||||
'To preview the list of dashboards go to "More" settings on the right.',
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Component.argTypes = {
|
||||
onClick: {
|
||||
action: 'onClick',
|
||||
table: {
|
||||
disable: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,257 @@
|
|||
/**
|
||||
* 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, within } from 'spec/helpers/testing-library';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
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';
|
||||
|
||||
const DASHBOARD_TITLE = 'Added to 452 dashboards';
|
||||
const DASHBOARD_DESCRIPTION =
|
||||
'To preview the list of dashboards go to "More" settings on the right.';
|
||||
const DESCRIPTION_VALUE = 'This is the description';
|
||||
const ROWS_TITLE = '500 rows';
|
||||
const SQL_TITLE = 'Click to view query';
|
||||
const TABLE_TITLE = 'database.schema.table';
|
||||
const CREATED_BY = 'Jane Smith';
|
||||
const DATE = new Date(Date.parse('2022-01-01'));
|
||||
const MODIFIED_BY = 'Jane Smith';
|
||||
const OWNERS = ['John Doe', 'Mary Wilson'];
|
||||
const TAGS = ['management', 'research', 'poc'];
|
||||
|
||||
const runWithBarCollapsed = async (func: Function) => {
|
||||
const spy = jest.spyOn(resizeDetector, 'useResizeDetector');
|
||||
spy.mockReturnValue({ width: 80, ref: { current: undefined } });
|
||||
await func();
|
||||
spy.mockRestore();
|
||||
};
|
||||
|
||||
const ITEMS: ContentType[] = [
|
||||
{
|
||||
type: MetadataType.DASHBOARDS,
|
||||
title: DASHBOARD_TITLE,
|
||||
description: DASHBOARD_DESCRIPTION,
|
||||
},
|
||||
{
|
||||
type: MetadataType.DESCRIPTION,
|
||||
value: DESCRIPTION_VALUE,
|
||||
},
|
||||
{
|
||||
type: MetadataType.LAST_MODIFIED,
|
||||
value: DATE,
|
||||
modifiedBy: MODIFIED_BY,
|
||||
},
|
||||
{
|
||||
type: MetadataType.OWNER,
|
||||
createdBy: CREATED_BY,
|
||||
owners: OWNERS,
|
||||
createdOn: DATE,
|
||||
},
|
||||
{
|
||||
type: MetadataType.ROWS,
|
||||
title: ROWS_TITLE,
|
||||
},
|
||||
{
|
||||
type: MetadataType.SQL,
|
||||
title: SQL_TITLE,
|
||||
},
|
||||
{
|
||||
type: MetadataType.TABLE,
|
||||
title: TABLE_TITLE,
|
||||
},
|
||||
{
|
||||
type: MetadataType.TAGS,
|
||||
values: TAGS,
|
||||
},
|
||||
];
|
||||
|
||||
test('renders an array of items', () => {
|
||||
render(<MetadataBar items={ITEMS.slice(0, 2)} />);
|
||||
expect(screen.getByText(DASHBOARD_TITLE)).toBeInTheDocument();
|
||||
expect(screen.getByText(DESCRIPTION_VALUE)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('throws errors when out of min/max restrictions', () => {
|
||||
const spy = jest.spyOn(console, 'error');
|
||||
spy.mockImplementation(() => {});
|
||||
expect(() =>
|
||||
render(<MetadataBar items={ITEMS.slice(0, MIN_NUMBER_ITEMS - 1)} />),
|
||||
).toThrow(
|
||||
`The minimum number of items for the metadata bar is ${MIN_NUMBER_ITEMS}.`,
|
||||
);
|
||||
expect(() =>
|
||||
render(<MetadataBar items={ITEMS.slice(0, MAX_NUMBER_ITEMS + 1)} />),
|
||||
).toThrow(
|
||||
`The maximum number of items for the metadata bar is ${MAX_NUMBER_ITEMS}.`,
|
||||
);
|
||||
spy.mockRestore();
|
||||
});
|
||||
|
||||
test('removes duplicated items when rendering', () => {
|
||||
render(<MetadataBar items={[...ITEMS.slice(0, 2), ...ITEMS.slice(0, 2)]} />);
|
||||
expect(screen.getAllByRole('img').length).toBe(2);
|
||||
});
|
||||
|
||||
test('collapses the bar when min width is reached', async () => {
|
||||
await runWithBarCollapsed(() => {
|
||||
render(<MetadataBar items={ITEMS.slice(0, 2)} />);
|
||||
expect(screen.queryByText(DASHBOARD_TITLE)).not.toBeInTheDocument();
|
||||
expect(screen.queryByText(DESCRIPTION_VALUE)).not.toBeInTheDocument();
|
||||
expect(screen.getAllByRole('img').length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
test('always renders a tooltip when the bar is collapsed', async () => {
|
||||
await runWithBarCollapsed(async () => {
|
||||
render(<MetadataBar items={ITEMS.slice(0, 2)} />);
|
||||
userEvent.hover(screen.getAllByRole('img')[0]);
|
||||
expect(await screen.findByText(DASHBOARD_DESCRIPTION)).toBeInTheDocument();
|
||||
userEvent.hover(screen.getAllByRole('img')[1]);
|
||||
expect(await screen.findByText(DESCRIPTION_VALUE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('renders a tooltip when one is provided even if not collapsed', async () => {
|
||||
render(<MetadataBar items={ITEMS.slice(0, 2)} />);
|
||||
expect(screen.getByText(DASHBOARD_TITLE)).toBeInTheDocument();
|
||||
userEvent.hover(screen.getAllByRole('img')[0]);
|
||||
expect(await screen.findByText(DASHBOARD_DESCRIPTION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders underlined text and emits event when clickable', () => {
|
||||
const onClick = jest.fn();
|
||||
const items = [{ ...ITEMS[0], onClick }, ITEMS[1]];
|
||||
render(<MetadataBar items={items} />);
|
||||
const element = screen.getByText(DASHBOARD_TITLE);
|
||||
userEvent.click(element);
|
||||
expect(onClick).toHaveBeenCalled();
|
||||
const style = window.getComputedStyle(element);
|
||||
expect(style.textDecoration).toBe('underline');
|
||||
});
|
||||
|
||||
test('renders clicable items with blue icons when the bar is collapsed', async () => {
|
||||
await runWithBarCollapsed(async () => {
|
||||
const onClick = jest.fn();
|
||||
const items = [{ ...ITEMS[0], onClick }, ITEMS[1]];
|
||||
render(<MetadataBar items={items} />);
|
||||
const images = screen.getAllByRole('img');
|
||||
const clickableColor = window.getComputedStyle(images[0]).color;
|
||||
const nonClickableColor = window.getComputedStyle(images[1]).color;
|
||||
expect(clickableColor).toBe(hexToRgb(supersetTheme.colors.primary.base));
|
||||
expect(nonClickableColor).toBeFalsy();
|
||||
});
|
||||
});
|
||||
|
||||
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[3]).getByText(DESCRIPTION_VALUE)).toBeInTheDocument();
|
||||
expect(within(nodes[4]).getByText(CREATED_BY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('correctly renders the dashboards tooltip', async () => {
|
||||
render(<MetadataBar items={ITEMS.slice(0, 2)} />);
|
||||
userEvent.hover(screen.getByText(DASHBOARD_TITLE));
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(DASHBOARD_TITLE)).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(DASHBOARD_DESCRIPTION)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('correctly renders the description tooltip', async () => {
|
||||
await runWithBarCollapsed(async () => {
|
||||
render(<MetadataBar items={ITEMS.slice(0, 2)} />);
|
||||
userEvent.hover(screen.getAllByRole('img')[1]);
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(DESCRIPTION_VALUE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('correctly renders the last modified tooltip', async () => {
|
||||
const dateText = moment.utc(DATE).fromNow();
|
||||
render(<MetadataBar items={ITEMS.slice(0, 3)} />);
|
||||
userEvent.hover(screen.getByText(dateText));
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(dateText)).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(MODIFIED_BY)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('correctly renders the owner tooltip', async () => {
|
||||
const dateText = moment.utc(DATE).fromNow();
|
||||
render(<MetadataBar items={ITEMS.slice(0, 4)} />);
|
||||
userEvent.hover(screen.getByText(CREATED_BY));
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(CREATED_BY)).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(dateText)).toBeInTheDocument();
|
||||
OWNERS.forEach(owner =>
|
||||
expect(within(tooltip).getByText(owner)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
test('correctly renders the rows tooltip', async () => {
|
||||
await runWithBarCollapsed(async () => {
|
||||
render(<MetadataBar items={ITEMS.slice(4, 8)} />);
|
||||
userEvent.hover(screen.getAllByRole('img')[0]);
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(ROWS_TITLE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('correctly renders the sql tooltip', async () => {
|
||||
await runWithBarCollapsed(async () => {
|
||||
render(<MetadataBar items={ITEMS.slice(4, 8)} />);
|
||||
userEvent.hover(screen.getAllByRole('img')[1]);
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(SQL_TITLE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('correctly renders the table tooltip', async () => {
|
||||
await runWithBarCollapsed(async () => {
|
||||
render(<MetadataBar items={ITEMS.slice(4, 8)} />);
|
||||
userEvent.hover(screen.getAllByRole('img')[2]);
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
expect(within(tooltip).getByText(TABLE_TITLE)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
test('correctly renders the tags tooltip', async () => {
|
||||
await runWithBarCollapsed(async () => {
|
||||
render(<MetadataBar items={ITEMS.slice(4, 8)} />);
|
||||
userEvent.hover(screen.getAllByRole('img')[3]);
|
||||
const tooltip = await screen.findByRole('tooltip');
|
||||
expect(tooltip).toBeInTheDocument();
|
||||
TAGS.forEach(tag =>
|
||||
expect(within(tooltip).getByText(tag)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,137 @@
|
|||
import { Meta, Source } from '@storybook/addon-docs';
|
||||
|
||||
<Meta title="MetadataBar/Overview" />
|
||||
|
||||
# Usage
|
||||
|
||||
The metadata bar component is used to display additional information about an entity. Some of the common applications in Superset are:
|
||||
|
||||
- Display the chart's metadata in Explore to help the user understand what dashboards this chart is added to and get
|
||||
to know the details of the chart
|
||||
- Display the database's metadata in a drill to detail modal to help the user understand what data they are looking
|
||||
at while accessing the feature in the dashboard
|
||||
|
||||
# Variations
|
||||
|
||||
The metadata bar is by default a static component (besides the links in text).
|
||||
The variations in this component are related to content and entity type as all of the details are predefined
|
||||
in the code and should be specific for each metadata object.
|
||||
|
||||
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. For example, the Owner content type will always
|
||||
have the same icon and when hovered it will present who created the entity, its current owners, and when the entity was created.
|
||||
|
||||
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.
|
||||
|
||||
To check each content type in detail and its interactions, check the [MetadataBar](/story/metadatabar--component) page.
|
||||
Below you can find the configurations for each content type:
|
||||
|
||||
<Source
|
||||
language="jsx"
|
||||
format={true}
|
||||
code={`
|
||||
export enum MetadataType {
|
||||
DASHBOARDS = 'dashboards',
|
||||
DESCRIPTION = 'description',
|
||||
LAST_MODIFIED = 'lastModified',
|
||||
OWNER = 'owner',
|
||||
ROWS = 'rows',
|
||||
SQL = 'sql',
|
||||
TABLE = 'table',
|
||||
TAGS = 'tags',
|
||||
}`}
|
||||
/>
|
||||
|
||||
<Source
|
||||
language="jsx"
|
||||
format={true}
|
||||
code={`
|
||||
export type Dashboards = {
|
||||
type: MetadataType.DASHBOARDS;
|
||||
title: string;
|
||||
description?: string;
|
||||
onClick?: (type: string) => void;
|
||||
};`}
|
||||
/>
|
||||
|
||||
<Source
|
||||
language="jsx"
|
||||
format={true}
|
||||
code={`
|
||||
export type Description = {
|
||||
type: MetadataType.DESCRIPTION;
|
||||
value: string;
|
||||
onClick?: (type: string) => void;
|
||||
};`}
|
||||
/>
|
||||
|
||||
<Source
|
||||
language="jsx"
|
||||
format={true}
|
||||
code={`
|
||||
export type LastModified = {
|
||||
type: MetadataType.LAST_MODIFIED;
|
||||
value: Date;
|
||||
modifiedBy: string;
|
||||
onClick?: (type: string) => void;
|
||||
};`}
|
||||
/>
|
||||
|
||||
<Source
|
||||
language="jsx"
|
||||
format={true}
|
||||
code={`
|
||||
export type Owner = {
|
||||
type: MetadataType.OWNER;
|
||||
createdBy: string;
|
||||
owners: string[];
|
||||
createdOn: Date;
|
||||
onClick?: (type: string) => void;
|
||||
};`}
|
||||
/>
|
||||
|
||||
<Source
|
||||
language="jsx"
|
||||
format={true}
|
||||
code={`
|
||||
export type Rows = {
|
||||
type: MetadataType.ROWS;
|
||||
title: string;
|
||||
onClick?: (type: string) => void;
|
||||
};`}
|
||||
/>
|
||||
|
||||
<Source
|
||||
language="jsx"
|
||||
format={true}
|
||||
code={`
|
||||
export type Sql = {
|
||||
type: MetadataType.SQL;
|
||||
title: string;
|
||||
onClick?: (type: string) => void;
|
||||
};`}
|
||||
/>
|
||||
|
||||
<Source
|
||||
language="jsx"
|
||||
format={true}
|
||||
code={`
|
||||
export type Table = {
|
||||
type: MetadataType.TABLE;
|
||||
title: string;
|
||||
onClick?: (type: string) => void;
|
||||
};`}
|
||||
/>
|
||||
|
||||
<Source
|
||||
language="jsx"
|
||||
format={true}
|
||||
code={`
|
||||
export type Tags = {
|
||||
type: MetadataType.TAGS;
|
||||
values: string[];
|
||||
onClick?: (type: string) => void;
|
||||
};`}
|
||||
/>
|
||||
|
|
@ -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,
|
||||
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;
|
||||
|
|
@ -39,7 +39,7 @@ const TruncatedTextWithTooltip: React.FC = ({ children, ...props }) => {
|
|||
const ref = useRef<HTMLDivElement>(null);
|
||||
useEffect(() => {
|
||||
setIsTruncated(
|
||||
ref.current ? ref.current.offsetWidth < ref.current.scrollWidth : false,
|
||||
ref.current ? ref.current.scrollWidth > ref.current.clientWidth : false,
|
||||
);
|
||||
}, [children]);
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,7 @@ const HtmlWebpackPlugin = require('html-webpack-plugin');
|
|||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
|
||||
const SpeedMeasurePlugin = require('speed-measure-webpack-plugin');
|
||||
const createMdxCompiler = require('@storybook/addon-docs/mdx-compiler-plugin');
|
||||
const {
|
||||
WebpackManifestPlugin,
|
||||
getCompilerHooks,
|
||||
|
|
@ -442,6 +443,24 @@ const config = {
|
|||
test: /\.geojson$/,
|
||||
type: 'asset/resource',
|
||||
},
|
||||
{
|
||||
test: /\.(stories|story)\.mdx$/,
|
||||
use: [
|
||||
{
|
||||
loader: 'babel-loader',
|
||||
// may or may not need this line depending on your app's setup
|
||||
options: {
|
||||
plugins: ['@babel/plugin-transform-react-jsx'],
|
||||
},
|
||||
},
|
||||
{
|
||||
loader: '@mdx-js/loader',
|
||||
options: {
|
||||
compilers: [createMdxCompiler({})],
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
externals: {
|
||||
|
|
|
|||
Loading…
Reference in New Issue