mirror of https://github.com/apache/superset.git
feat: create table component based on ant design Table (#21520)
Co-authored-by: Lyndsi Kay Williams <55605634+lyndsiWilliams@users.noreply.github.com> Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
This commit is contained in:
parent
9f7bd1e63f
commit
736b53418a
|
@ -24,7 +24,8 @@ module.exports = {
|
||||||
builder: 'webpack5',
|
builder: 'webpack5',
|
||||||
},
|
},
|
||||||
stories: [
|
stories: [
|
||||||
'../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx|mdx)',
|
'../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx)',
|
||||||
|
'../src/@(components|common|filters|explore)/**/*.*.@(mdx)',
|
||||||
],
|
],
|
||||||
addons: [
|
addons: [
|
||||||
'@storybook/addon-essentials',
|
'@storybook/addon-essentials',
|
||||||
|
|
|
@ -68,7 +68,15 @@ addParameters({
|
||||||
['Controls', 'Display', 'Feedback', 'Input', '*'],
|
['Controls', 'Display', 'Feedback', 'Input', '*'],
|
||||||
['Overview', 'Examples', '*'],
|
['Overview', 'Examples', '*'],
|
||||||
'Design System',
|
'Design System',
|
||||||
['Foundations', 'Components', 'Patterns', '*'],
|
[
|
||||||
|
'Introduction',
|
||||||
|
'Foundations',
|
||||||
|
'Components',
|
||||||
|
['Overview', 'Examples', '*'],
|
||||||
|
'Patterns',
|
||||||
|
'*',
|
||||||
|
],
|
||||||
|
['Overview', 'Examples', '*'],
|
||||||
'*',
|
'*',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
|
@ -39,11 +39,13 @@ export type ButtonStyle =
|
||||||
| 'link'
|
| 'link'
|
||||||
| 'dashed';
|
| 'dashed';
|
||||||
|
|
||||||
|
export type ButtonSize = 'default' | 'small' | 'xsmall';
|
||||||
|
|
||||||
export type ButtonProps = Omit<AntdButtonProps, 'css'> &
|
export type ButtonProps = Omit<AntdButtonProps, 'css'> &
|
||||||
Pick<TooltipProps, 'placement'> & {
|
Pick<TooltipProps, 'placement'> & {
|
||||||
tooltip?: string;
|
tooltip?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
buttonSize?: 'default' | 'small' | 'xsmall';
|
buttonSize?: ButtonSize;
|
||||||
buttonStyle?: ButtonStyle;
|
buttonStyle?: ButtonStyle;
|
||||||
cta?: boolean;
|
cta?: boolean;
|
||||||
showMarginRight?: boolean;
|
showMarginRight?: boolean;
|
||||||
|
|
|
@ -0,0 +1,25 @@
|
||||||
|
import { Meta, Source } from '@storybook/addon-docs';
|
||||||
|
import AtomicDesign from './atomic-design.png';
|
||||||
|
|
||||||
|
<Meta title="Design System/Introduction" />
|
||||||
|
|
||||||
|
# Superset Design System
|
||||||
|
|
||||||
|
A design system is a complete set of standards intended to manage design at scale using reusable components and patterns.
|
||||||
|
|
||||||
|
You can get an overview of Atomic Design concepts and a link to the full book on the topic here:
|
||||||
|
|
||||||
|
<a href="https://bradfrost.com/blog/post/atomic-web-design/" target="_blank">
|
||||||
|
Intro to Atomic Design
|
||||||
|
</a>
|
||||||
|
|
||||||
|
While the Superset Design System will use Atomic Design principles, we choose a different language to describe the elements.
|
||||||
|
|
||||||
|
| Atomic Design | Atoms | Molecules | Organisms | Templates | Pages / Screens |
|
||||||
|
| :-------------- | :---------: | :--------: | :-------: | :-------: | :-------------: |
|
||||||
|
| Superset Design | Foundations | Components | Patterns | Templates | Features |
|
||||||
|
|
||||||
|
<img
|
||||||
|
src={AtomicDesign}
|
||||||
|
alt="Atoms = Foundations, Molecules = Components, Organisms = Patterns, Templates = Templates, Pages / Screens = Features"
|
||||||
|
/>
|
|
@ -20,6 +20,7 @@ import React, { RefObject } from 'react';
|
||||||
import { AntdDropdown } from 'src/components';
|
import { AntdDropdown } from 'src/components';
|
||||||
import { DropDownProps } from 'antd/lib/dropdown';
|
import { DropDownProps } from 'antd/lib/dropdown';
|
||||||
import { styled } from '@superset-ui/core';
|
import { styled } from '@superset-ui/core';
|
||||||
|
import Icons from 'src/components/Icons';
|
||||||
|
|
||||||
const MenuDots = styled.div`
|
const MenuDots = styled.div`
|
||||||
width: ${({ theme }) => theme.gridUnit * 0.75}px;
|
width: ${({ theme }) => theme.gridUnit * 0.75}px;
|
||||||
|
@ -66,14 +67,35 @@ const MenuDotsWrapper = styled.div`
|
||||||
padding-left: ${({ theme }) => theme.gridUnit}px;
|
padding-left: ${({ theme }) => theme.gridUnit}px;
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
export enum IconOrientation {
|
||||||
|
VERTICAL = 'vertical',
|
||||||
|
HORIZONTAL = 'horizontal',
|
||||||
|
}
|
||||||
export interface DropdownProps extends DropDownProps {
|
export interface DropdownProps extends DropDownProps {
|
||||||
overlay: React.ReactElement;
|
overlay: React.ReactElement;
|
||||||
|
iconOrientation?: IconOrientation;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Dropdown = ({ overlay, ...rest }: DropdownProps) => (
|
const RenderIcon = (
|
||||||
|
iconOrientation: IconOrientation = IconOrientation.VERTICAL,
|
||||||
|
) => {
|
||||||
|
const component =
|
||||||
|
iconOrientation === IconOrientation.HORIZONTAL ? (
|
||||||
|
<Icons.MoreHoriz iconSize="xl" />
|
||||||
|
) : (
|
||||||
|
<MenuDots />
|
||||||
|
);
|
||||||
|
return component;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Dropdown = ({
|
||||||
|
overlay,
|
||||||
|
iconOrientation = IconOrientation.VERTICAL,
|
||||||
|
...rest
|
||||||
|
}: DropdownProps) => (
|
||||||
<AntdDropdown overlay={overlay} {...rest}>
|
<AntdDropdown overlay={overlay} {...rest}>
|
||||||
<MenuDotsWrapper data-test="dropdown-trigger">
|
<MenuDotsWrapper data-test="dropdown-trigger">
|
||||||
<MenuDots />
|
{RenderIcon(iconOrientation)}
|
||||||
</MenuDotsWrapper>
|
</MenuDotsWrapper>
|
||||||
</AntdDropdown>
|
</AntdDropdown>
|
||||||
);
|
);
|
||||||
|
|
|
@ -40,7 +40,7 @@ export const LoadingGallery = () => (
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<h4>{position}</h4>
|
<h4>{position}</h4>
|
||||||
<Loading position={position} image="/src/assets/images/loading.gif" />
|
<Loading position={position} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</>
|
</>
|
||||||
|
@ -71,7 +71,7 @@ InteractiveLoading.story = {
|
||||||
};
|
};
|
||||||
|
|
||||||
InteractiveLoading.args = {
|
InteractiveLoading.args = {
|
||||||
image: '/src/assets/images/loading.gif',
|
image: '',
|
||||||
className: '',
|
className: '',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -26,11 +26,9 @@ test('Rerendering correctly with default props', () => {
|
||||||
render(<Loading />);
|
render(<Loading />);
|
||||||
const loading = screen.getByRole('status');
|
const loading = screen.getByRole('status');
|
||||||
const classNames = loading.getAttribute('class')?.split(' ');
|
const classNames = loading.getAttribute('class')?.split(' ');
|
||||||
const imagePath = loading.getAttribute('src');
|
|
||||||
const ariaLive = loading.getAttribute('aria-live');
|
const ariaLive = loading.getAttribute('aria-live');
|
||||||
const ariaLabel = loading.getAttribute('aria-label');
|
const ariaLabel = loading.getAttribute('aria-label');
|
||||||
expect(loading).toBeInTheDocument();
|
expect(loading).toBeInTheDocument();
|
||||||
expect(imagePath).toBe('/static/assets/images/loading.gif');
|
|
||||||
expect(classNames).toContain('floating');
|
expect(classNames).toContain('floating');
|
||||||
expect(classNames).toContain('loading');
|
expect(classNames).toContain('loading');
|
||||||
expect(ariaLive).toContain('polite');
|
expect(ariaLive).toContain('polite');
|
||||||
|
@ -56,7 +54,7 @@ test('support for extra classes', () => {
|
||||||
expect(classNames).toContain('extra-class');
|
expect(classNames).toContain('extra-class');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('Diferent image path', () => {
|
test('Different image path', () => {
|
||||||
render(<Loading image="/src/assets/images/loading.gif" />);
|
render(<Loading image="/src/assets/images/loading.gif" />);
|
||||||
const loading = screen.getByRole('status');
|
const loading = screen.getByRole('status');
|
||||||
const imagePath = loading.getAttribute('src');
|
const imagePath = loading.getAttribute('src');
|
||||||
|
|
|
@ -20,6 +20,7 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { styled } from '@superset-ui/core';
|
import { styled } from '@superset-ui/core';
|
||||||
import cls from 'classnames';
|
import cls from 'classnames';
|
||||||
|
import Loader from 'src/assets/images/loading.gif';
|
||||||
|
|
||||||
export type PositionOption =
|
export type PositionOption =
|
||||||
| 'floating'
|
| 'floating'
|
||||||
|
@ -35,6 +36,7 @@ export interface Props {
|
||||||
const LoaderImg = styled.img`
|
const LoaderImg = styled.img`
|
||||||
z-index: 99;
|
z-index: 99;
|
||||||
width: 50px;
|
width: 50px;
|
||||||
|
height: unset;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin: 10px;
|
margin: 10px;
|
||||||
&.inline {
|
&.inline {
|
||||||
|
@ -57,14 +59,14 @@ const LoaderImg = styled.img`
|
||||||
`;
|
`;
|
||||||
export default function Loading({
|
export default function Loading({
|
||||||
position = 'floating',
|
position = 'floating',
|
||||||
image = '/static/assets/images/loading.gif',
|
image,
|
||||||
className,
|
className,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
return (
|
return (
|
||||||
<LoaderImg
|
<LoaderImg
|
||||||
className={cls('loading', position, className)}
|
className={cls('loading', position, className)}
|
||||||
alt="Loading..."
|
alt="Loading..."
|
||||||
src={image}
|
src={image || Loader}
|
||||||
role="status"
|
role="status"
|
||||||
aria-live="polite"
|
aria-live="polite"
|
||||||
aria-label="Loading"
|
aria-label="Loading"
|
||||||
|
|
|
@ -1,17 +1,25 @@
|
||||||
import { Meta, Source } from '@storybook/addon-docs';
|
import { Meta, Source, Story } from '@storybook/addon-docs';
|
||||||
|
|
||||||
<Meta title="MetadataBar/Overview" />
|
<Meta title="Design System/Components/MetadataBar/Overview" />
|
||||||
|
|
||||||
# Usage
|
# Metadata bar
|
||||||
|
|
||||||
The metadata bar component is used to display additional information about an entity. Some of the common applications in Superset are:
|
The metadata bar component is used to display additional information about an entity.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
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
|
- 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
|
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
|
- 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
|
at while accessing the feature in the dashboard
|
||||||
|
|
||||||
# Variations
|
## Basic example
|
||||||
|
|
||||||
|
<Story id="design-system-components-metadatabar-examples--basic" />
|
||||||
|
|
||||||
|
## Variations
|
||||||
|
|
||||||
The metadata bar is by default a static component (besides the links in text).
|
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
|
The variations in this component are related to content and entity type as all of the details are predefined
|
||||||
|
@ -25,7 +33,7 @@ have the same icon and when hovered it will present who created the entity, its
|
||||||
To extend the list of content types, a developer needs to request the inclusion of the new type in the design system.
|
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.
|
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.
|
To check each content type in detail and its interactions, check the [MetadataBar](/story/design-system-components-metadatabar-examples--basic) page.
|
||||||
Below you can find the configurations for each content type:
|
Below you can find the configurations for each content type:
|
||||||
|
|
||||||
<Source
|
<Source
|
|
@ -22,13 +22,13 @@ import { useResizeDetector } from 'react-resize-detector';
|
||||||
import MetadataBar, { MetadataBarProps, MetadataType } from '.';
|
import MetadataBar, { MetadataBarProps, MetadataType } from '.';
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'MetadataBar',
|
title: 'Design System/Components/MetadataBar/Examples',
|
||||||
component: MetadataBar,
|
component: MetadataBar,
|
||||||
};
|
};
|
||||||
|
|
||||||
const A_WEEK_AGO = 'a week ago';
|
const A_WEEK_AGO = 'a week ago';
|
||||||
|
|
||||||
export const Component = ({
|
export const Basic = ({
|
||||||
items,
|
items,
|
||||||
onClick,
|
onClick,
|
||||||
}: MetadataBarProps & {
|
}: MetadataBarProps & {
|
||||||
|
@ -61,7 +61,7 @@ export const Component = ({
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
Component.story = {
|
Basic.story = {
|
||||||
parameters: {
|
parameters: {
|
||||||
knobs: {
|
knobs: {
|
||||||
disable: true,
|
disable: true,
|
||||||
|
@ -69,7 +69,7 @@ Component.story = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
Component.args = {
|
Basic.args = {
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
type: MetadataType.SQL,
|
type: MetadataType.SQL,
|
||||||
|
@ -99,7 +99,7 @@ Component.args = {
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
Component.argTypes = {
|
Basic.argTypes = {
|
||||||
onClick: {
|
onClick: {
|
||||||
action: 'onClick',
|
action: 'onClick',
|
||||||
table: {
|
table: {
|
||||||
|
|
|
@ -0,0 +1,260 @@
|
||||||
|
import { Meta, Source, Story, ArgsTable } from '@storybook/addon-docs';
|
||||||
|
|
||||||
|
<Meta title="Design System/Components/Table/Overview" />
|
||||||
|
|
||||||
|
# Table
|
||||||
|
|
||||||
|
A table is UI that allows the user to explore data in a tabular format.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
Common table applications in Superset:
|
||||||
|
|
||||||
|
- Display lists of user-generated entities (e.g. dashboard, charts, queries) for further exploration and use
|
||||||
|
- Display data that can help the user make a decision (e.g. query results)
|
||||||
|
|
||||||
|
This component provides a general use Table.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### [Basic example](./?path=/docs/design-system-components-table-examples--basic)
|
||||||
|
|
||||||
|
<Story id="design-system-components-table-examples--basic" />
|
||||||
|
|
||||||
|
### Data and Columns
|
||||||
|
|
||||||
|
To set the visible columns and data for the table you use the `columns` and `data` props.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
The basic table example for the `columns` prop is:
|
||||||
|
|
||||||
|
```
|
||||||
|
const basicColumns: = [
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 150,
|
||||||
|
sorter: (a: BasicData, b: BasicData) =>
|
||||||
|
alphabeticalSort('name', a, b),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Category',
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category',
|
||||||
|
sorter: (a: BasicData, b: BasicData) =>
|
||||||
|
alphabeticalSort('category', a, b),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Price',
|
||||||
|
dataIndex: 'price',
|
||||||
|
key: 'price',
|
||||||
|
sorter: (a: BasicData, b: BasicData) =>
|
||||||
|
numericalSort('price', a, b),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Description',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
The data prop is:
|
||||||
|
|
||||||
|
```
|
||||||
|
const basicData: = [
|
||||||
|
{
|
||||||
|
key: 1,
|
||||||
|
name: 'Floppy Disk 10 pack',
|
||||||
|
category: 'Disk Storage',
|
||||||
|
price: '9.99'
|
||||||
|
description: 'A real blast from the past',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 2,
|
||||||
|
name: 'DVD 100 pack',
|
||||||
|
category: 'Optical Storage',
|
||||||
|
price: '27.99'
|
||||||
|
description: 'Still pretty ancient',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 3,
|
||||||
|
name: '128 GB SSD',
|
||||||
|
category: 'Hardrive',
|
||||||
|
price: '49.99'
|
||||||
|
description: 'Reliable and fast data storage',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### Column Sort Functions
|
||||||
|
|
||||||
|
To ensure consistency for column sorting and to avoid redundant definitions for common column sorters, reusable sort functions are provided.
|
||||||
|
When defining the object for the `columns` prop you can provide an optional attribute `sorter`.
|
||||||
|
The function provided in the `sorter` prop is given the entire record representing a row as props `a` and `b`.
|
||||||
|
When using a provided sorter function the pattern is to wrap the call to the sorter with an inline function, then specify the specific attribute value from `dataIndex`, representing a column
|
||||||
|
of the data object for that row, as the first argument of the sorter function.
|
||||||
|
|
||||||
|
#### alphabeticalSort
|
||||||
|
|
||||||
|
The alphabeticalSort is for columns that display a string of text.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
```
|
||||||
|
import { alphabeticalSort } from 'src/components/Table/sorters';
|
||||||
|
|
||||||
|
const basicColumns = [
|
||||||
|
{
|
||||||
|
title: 'Column Name',
|
||||||
|
dataIndex: 'columnName',
|
||||||
|
key: 'columnName',
|
||||||
|
sorter: (a, b) =>
|
||||||
|
alphabeticalSort('columnName', a, b),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
#### numericSort
|
||||||
|
|
||||||
|
The numericalSort is for columns that display a numeric value.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
```
|
||||||
|
import { numericalSort } from './sorters';
|
||||||
|
|
||||||
|
const basicColumns = [
|
||||||
|
{
|
||||||
|
title: 'Height',
|
||||||
|
dataIndex: 'height',
|
||||||
|
key: 'height',
|
||||||
|
sorter: (a, b) =>
|
||||||
|
numericalSort('height', a, b),
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
If a different sort option is needed, consider adding it as a reusable sort function following the pattern provided above.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Cell Content Renderers
|
||||||
|
|
||||||
|
By default, each column will render the value as simple text. Often you will want to show formatted values, such as a numeric column showing as currency, or a more complex component such as a button or action menu as a cell value.
|
||||||
|
Cell Renderers are React components provided to the optional `render` attribute on a column definition that enables injecting a specific React component to enable this.
|
||||||
|
|
||||||
|
<Story id="design-system-components-table-examples--cell-renderers" />
|
||||||
|
|
||||||
|
For convenience and consistency, the Table component provides pre-built Cell Renderers for:
|
||||||
|
The following data types can be displayed in table cells.
|
||||||
|
|
||||||
|
- Text (default)
|
||||||
|
- [Button Cell](./?path=/docs/design-system-components-table-cell-renderers-buttoncell--basic)
|
||||||
|
- [Numeric Cell](./docs/design-system-components-table-cell-renderers-numericcell--basic)
|
||||||
|
- Support Locale and currency formatting
|
||||||
|
- w/ icons - Coming Soon
|
||||||
|
- [Action Menu Cell](./?path=/docs/design-system-components-table-cell-renderers-actioncell-overview--page)
|
||||||
|
- Provide a list of menu options with callback functions that retain a reference to the row the menu is defined for
|
||||||
|
- Custom
|
||||||
|
- You can provide your own React component as a cell renderer in cases not supported
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Loading
|
||||||
|
|
||||||
|
The table can be set to a loading state simply by setting the loading prop to true | false
|
||||||
|
|
||||||
|
<Story id="design-system-components-table-examples--loading" />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Pagination
|
||||||
|
|
||||||
|
The table displays a set number of rows at a time, the user navigates the table via pagination. Use in scenarios where the user is searching for a specific piece of content.
|
||||||
|
The default page size and page size options for the menu are configurable via the `pageSizeOptions` and `defaultPageSize` props.
|
||||||
|
NOTE: Pagination controls will only display when the data for the table has more records than the default page size.
|
||||||
|
|
||||||
|
<Story id="design-system-components-table-examples--many-columns" />
|
||||||
|
|
||||||
|
```
|
||||||
|
<Table pageSizeOptions={[5, 10, 15, 20, 25] defaultPageSize={10} />
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Integration Checklist
|
||||||
|
|
||||||
|
The following specifications are required every time a table is used. These choices should be intentional based on the specific user needs for the table instance.
|
||||||
|
|
||||||
|
<details>
|
||||||
|
|
||||||
|
- [ ] Size
|
||||||
|
- Large
|
||||||
|
- Small
|
||||||
|
- Columns
|
||||||
|
- [ ] Number of
|
||||||
|
- [ ] Contents
|
||||||
|
- [ ] Order
|
||||||
|
- [ ] Widths
|
||||||
|
- Column headers
|
||||||
|
- [ ] Labels
|
||||||
|
- [ ] Has tooltip
|
||||||
|
- [ ] Tooltip text
|
||||||
|
- [ ] Default sort
|
||||||
|
- Functionality
|
||||||
|
- [ ] Can sort columns
|
||||||
|
- [ ] Can filter columns
|
||||||
|
- [ ] Loading
|
||||||
|
- Pagination
|
||||||
|
- [ ] Number of rows per page
|
||||||
|
- Infinite scroll
|
||||||
|
- [ ] Has toolbar
|
||||||
|
- [ ] Has table title
|
||||||
|
- [ ] Label
|
||||||
|
- [ ] Has buttons
|
||||||
|
- [ ] Labels
|
||||||
|
- [ ] Actions
|
||||||
|
- [ ] Has search
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Experimental features
|
||||||
|
|
||||||
|
The Table component has features that are still experimental and can be used at your own risk.
|
||||||
|
These features are intended to be made fully stable in future releases.
|
||||||
|
|
||||||
|
### Resizable Columns
|
||||||
|
|
||||||
|
The prop `resizable` enables table columns to be resized by the user dragging from the right edge of each
|
||||||
|
column to increase or decrease the columns' width
|
||||||
|
|
||||||
|
<Story id="design-system-components-table-examples--resizable-columns" />
|
||||||
|
|
||||||
|
### Drag & Drop Columns
|
||||||
|
|
||||||
|
The prop `reorderable` can enable column drag and drop reordering as well as dragging a column to another component. If you want to accept the drop event of a Table Column
|
||||||
|
you can register `onDragOver` and `onDragDrop` event handlers on the destination component. In the `onDragDrop` handler you can check for `SUPERSET_TABLE_COLUMN`
|
||||||
|
as the getData key as shown below.
|
||||||
|
|
||||||
|
```
|
||||||
|
import { SUPERSET_TABLE_COLUMN } from 'src/components/table';
|
||||||
|
|
||||||
|
const handleDrop = (ev:Event) => {
|
||||||
|
const json = ev.dataTransfer?.getData?.(SUPERSET_TABLE_COLUMN);
|
||||||
|
const data = JSON.parse(json);
|
||||||
|
// ... do something with the data here
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
<Story id="design-system-components-table-examples--reorderable-columns" />
|
|
@ -0,0 +1,432 @@
|
||||||
|
/**
|
||||||
|
* 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, { useState } from 'react';
|
||||||
|
import { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
|
||||||
|
import { Table, TableSize, SUPERSET_TABLE_COLUMN, ColumnsType } from './index';
|
||||||
|
import { numericalSort, alphabeticalSort } from './sorters';
|
||||||
|
import ButtonCell from './cell-renderers/ButtonCell';
|
||||||
|
import ActionCell from './cell-renderers/ActionCell';
|
||||||
|
import { exampleMenuOptions } from './cell-renderers/ActionCell/fixtures';
|
||||||
|
import NumericCell, {
|
||||||
|
CurrencyCode,
|
||||||
|
LocaleCode,
|
||||||
|
Style,
|
||||||
|
} from './cell-renderers/NumericCell';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Design System/Components/Table/Examples',
|
||||||
|
component: Table,
|
||||||
|
argTypes: { onClick: { action: 'clicked' } },
|
||||||
|
} as ComponentMeta<typeof Table>;
|
||||||
|
|
||||||
|
export interface BasicData {
|
||||||
|
name: string;
|
||||||
|
category: string;
|
||||||
|
price: number;
|
||||||
|
description?: string;
|
||||||
|
key: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RendererData {
|
||||||
|
key: number;
|
||||||
|
buttonCell: string;
|
||||||
|
textCell: string;
|
||||||
|
euroCell: number;
|
||||||
|
dollarCell: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExampleData {
|
||||||
|
title: string;
|
||||||
|
name: string;
|
||||||
|
age: number;
|
||||||
|
address: string;
|
||||||
|
tags?: string[];
|
||||||
|
key: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateValues(amount: number): object {
|
||||||
|
const cells = {};
|
||||||
|
for (let i = 0; i < amount; i += 1) {
|
||||||
|
cells[`col-${i}`] = `Text ${i}`;
|
||||||
|
}
|
||||||
|
return cells;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateColumns(amount: number): ColumnsType<ExampleData>[] {
|
||||||
|
const newCols: any[] = [];
|
||||||
|
for (let i = 0; i < amount; i += 1) {
|
||||||
|
newCols.push({
|
||||||
|
title: `Column Header ${i}`,
|
||||||
|
dataIndex: `col-${i}`,
|
||||||
|
key: `col-${i}`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return newCols as ColumnsType<ExampleData>[];
|
||||||
|
}
|
||||||
|
const recordCount = 200;
|
||||||
|
const columnCount = 12;
|
||||||
|
const randomCols: ColumnsType<ExampleData>[] = generateColumns(columnCount);
|
||||||
|
|
||||||
|
const basicData: BasicData[] = [
|
||||||
|
{
|
||||||
|
key: 1,
|
||||||
|
name: 'Floppy Disk 10 pack',
|
||||||
|
category: 'Disk Storage',
|
||||||
|
price: 9.99,
|
||||||
|
description: 'A real blast from the past',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 2,
|
||||||
|
name: 'DVD 100 pack',
|
||||||
|
category: 'Optical Storage',
|
||||||
|
price: 27.99,
|
||||||
|
description: 'Still pretty ancient',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 3,
|
||||||
|
name: '128 GB SSD',
|
||||||
|
category: 'Hardrive',
|
||||||
|
price: 49.99,
|
||||||
|
description: 'Reliable and fast data storage',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const basicColumns: ColumnsType<BasicData> = [
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
width: 150,
|
||||||
|
sorter: (a: BasicData, b: BasicData) => alphabeticalSort('name', a, b),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Category',
|
||||||
|
dataIndex: 'category',
|
||||||
|
key: 'category',
|
||||||
|
sorter: (a: BasicData, b: BasicData) => alphabeticalSort('category', a, b),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Price',
|
||||||
|
dataIndex: 'price',
|
||||||
|
key: 'price',
|
||||||
|
sorter: (a: BasicData, b: BasicData) => numericalSort('price', a, b),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Description',
|
||||||
|
dataIndex: 'description',
|
||||||
|
key: 'description',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bigColumns: ColumnsType<ExampleData> = [
|
||||||
|
{
|
||||||
|
title: 'Name',
|
||||||
|
dataIndex: 'name',
|
||||||
|
key: 'name',
|
||||||
|
render: (text: string, row: object, index: number) => (
|
||||||
|
<ButtonCell
|
||||||
|
label={text}
|
||||||
|
onClick={action('button-cell-click')}
|
||||||
|
row={row}
|
||||||
|
index={index}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Age',
|
||||||
|
dataIndex: 'age',
|
||||||
|
key: 'age',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Address',
|
||||||
|
dataIndex: 'address',
|
||||||
|
key: 'address',
|
||||||
|
},
|
||||||
|
...(randomCols as ColumnsType<ExampleData>),
|
||||||
|
];
|
||||||
|
|
||||||
|
const rendererColumns: ColumnsType<RendererData> = [
|
||||||
|
{
|
||||||
|
title: 'Button Cell',
|
||||||
|
dataIndex: 'buttonCell',
|
||||||
|
key: 'buttonCell',
|
||||||
|
width: 150,
|
||||||
|
render: (text: string, data: object, index: number) => (
|
||||||
|
<ButtonCell
|
||||||
|
label={text}
|
||||||
|
row={data}
|
||||||
|
index={index}
|
||||||
|
onClick={action('button-cell-click')}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Text Cell',
|
||||||
|
dataIndex: 'textCell',
|
||||||
|
key: 'textCell',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Euro Cell',
|
||||||
|
dataIndex: 'euroCell',
|
||||||
|
key: 'euroCell',
|
||||||
|
render: (value: number) => (
|
||||||
|
<NumericCell
|
||||||
|
options={{ style: Style.CURRENCY, currency: CurrencyCode.EUR }}
|
||||||
|
value={value}
|
||||||
|
locale={LocaleCode.en_US}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Dollar Cell',
|
||||||
|
dataIndex: 'dollarCell',
|
||||||
|
key: 'dollarCell',
|
||||||
|
render: (value: number) => (
|
||||||
|
<NumericCell
|
||||||
|
options={{ style: Style.CURRENCY, currency: CurrencyCode.USD }}
|
||||||
|
value={value}
|
||||||
|
locale={LocaleCode.en_US}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
dataIndex: 'actions',
|
||||||
|
key: 'actions',
|
||||||
|
render: (text: string, row: object) => (
|
||||||
|
<ActionCell row={row} menuOptions={exampleMenuOptions} />
|
||||||
|
),
|
||||||
|
width: 32,
|
||||||
|
fixed: 'right',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const baseData: any[] = [
|
||||||
|
{
|
||||||
|
key: 1,
|
||||||
|
name: 'John Brown',
|
||||||
|
age: 32,
|
||||||
|
address: 'New York No. 1 Lake Park',
|
||||||
|
tags: ['nice', 'developer'],
|
||||||
|
...generateValues(columnCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 2,
|
||||||
|
name: 'Jim Green',
|
||||||
|
age: 42,
|
||||||
|
address: 'London No. 1 Lake Park',
|
||||||
|
tags: ['loser'],
|
||||||
|
...generateValues(columnCount),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 3,
|
||||||
|
name: 'Joe Black',
|
||||||
|
age: 32,
|
||||||
|
address: 'Sidney No. 1 Lake Park',
|
||||||
|
tags: ['cool', 'teacher'],
|
||||||
|
...generateValues(columnCount),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const bigdata: any[] = [];
|
||||||
|
for (let i = 0; i < recordCount; i += 1) {
|
||||||
|
bigdata.push({
|
||||||
|
key: i + baseData.length,
|
||||||
|
name: `Dynamic record ${i}`,
|
||||||
|
age: 32 + i,
|
||||||
|
address: `DynamoCity, Dynamic Lane no. ${i}`,
|
||||||
|
...generateValues(columnCount),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export const Basic: ComponentStory<typeof Table> = args => (
|
||||||
|
<ThemeProvider theme={supersetTheme}>
|
||||||
|
<div>
|
||||||
|
<Table {...args} />
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
function handlers(record: object, rowIndex: number) {
|
||||||
|
return {
|
||||||
|
onClick: action(
|
||||||
|
`row onClick, row: ${rowIndex}, record: ${JSON.stringify(record)}`,
|
||||||
|
), // click row
|
||||||
|
onDoubleClick: action(
|
||||||
|
`row onDoubleClick, row: ${rowIndex}, record: ${JSON.stringify(record)}`,
|
||||||
|
), // double click row
|
||||||
|
onContextMenu: action(
|
||||||
|
`row onContextMenu, row: ${rowIndex}, record: ${JSON.stringify(record)}`,
|
||||||
|
), // right button click row
|
||||||
|
onMouseEnter: action(`Mouse Enter, row: ${rowIndex}`), // mouse enter row
|
||||||
|
onMouseLeave: action(`Mouse Leave, row: ${rowIndex}`), // mouse leave row
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Basic.args = {
|
||||||
|
data: basicData,
|
||||||
|
columns: basicColumns,
|
||||||
|
size: TableSize.SMALL,
|
||||||
|
onRow: handlers,
|
||||||
|
pageSizeOptions: ['5', '10', '15', '20', '25'],
|
||||||
|
defaultPageSize: 10,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ManyColumns: ComponentStory<typeof Table> = args => (
|
||||||
|
<ThemeProvider theme={supersetTheme}>
|
||||||
|
<div style={{ height: '350px' }}>
|
||||||
|
<Table {...args} />
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
ManyColumns.args = {
|
||||||
|
data: bigdata,
|
||||||
|
columns: bigColumns,
|
||||||
|
size: TableSize.SMALL,
|
||||||
|
resizable: true,
|
||||||
|
reorderable: true,
|
||||||
|
height: 350,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Loading: ComponentStory<typeof Table> = args => (
|
||||||
|
<ThemeProvider theme={supersetTheme}>
|
||||||
|
<Table {...args} />
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
Loading.args = {
|
||||||
|
data: basicData,
|
||||||
|
columns: basicColumns,
|
||||||
|
size: TableSize.SMALL,
|
||||||
|
loading: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ResizableColumns: ComponentStory<typeof Table> = args => (
|
||||||
|
<ThemeProvider theme={supersetTheme}>
|
||||||
|
<div>
|
||||||
|
<Table {...args} />
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
ResizableColumns.args = {
|
||||||
|
data: basicData,
|
||||||
|
columns: basicColumns,
|
||||||
|
size: TableSize.SMALL,
|
||||||
|
resizable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const ReorderableColumns: ComponentStory<typeof Table> = args => {
|
||||||
|
const [droppedItem, setDroppedItem] = useState<string | undefined>();
|
||||||
|
const dragOver = (ev: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const element: HTMLElement | null = ev?.currentTarget as HTMLElement;
|
||||||
|
if (element?.style) {
|
||||||
|
element.style.border = '1px dashed green';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragOut = (ev: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
ev.preventDefault();
|
||||||
|
const element: HTMLElement | null = ev?.currentTarget as HTMLElement;
|
||||||
|
if (element?.style) {
|
||||||
|
element.style.border = '1px solid grey';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const dragDrop = (ev: React.DragEvent<HTMLDivElement>) => {
|
||||||
|
const data = ev.dataTransfer?.getData?.(SUPERSET_TABLE_COLUMN);
|
||||||
|
const element: HTMLElement | null = ev?.currentTarget as HTMLElement;
|
||||||
|
if (element?.style) {
|
||||||
|
element.style.border = '1px solid grey';
|
||||||
|
}
|
||||||
|
setDroppedItem(data);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<ThemeProvider theme={supersetTheme}>
|
||||||
|
<div>
|
||||||
|
<div
|
||||||
|
onDragOver={(ev: React.DragEvent<HTMLDivElement>) => dragOver(ev)}
|
||||||
|
onDragLeave={(ev: React.DragEvent<HTMLDivElement>) => dragOut(ev)}
|
||||||
|
onDrop={(ev: React.DragEvent<HTMLDivElement>) => dragDrop(ev)}
|
||||||
|
style={{
|
||||||
|
width: '100%',
|
||||||
|
height: '40px',
|
||||||
|
border: '1px solid grey',
|
||||||
|
marginBottom: '8px',
|
||||||
|
padding: '8px',
|
||||||
|
borderRadius: '4px',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{droppedItem ?? 'Drop column here...'}
|
||||||
|
</div>
|
||||||
|
<Table {...args} />
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
ReorderableColumns.args = {
|
||||||
|
data: basicData,
|
||||||
|
columns: basicColumns,
|
||||||
|
size: TableSize.SMALL,
|
||||||
|
reorderable: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
const rendererData: RendererData[] = [
|
||||||
|
{
|
||||||
|
key: 1,
|
||||||
|
buttonCell: 'Click Me',
|
||||||
|
textCell: 'Some text',
|
||||||
|
euroCell: 45.5,
|
||||||
|
dollarCell: 45.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 2,
|
||||||
|
buttonCell: 'I am a button',
|
||||||
|
textCell: 'More text',
|
||||||
|
euroCell: 1700,
|
||||||
|
dollarCell: 1700,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 3,
|
||||||
|
buttonCell: 'Button 3',
|
||||||
|
textCell: 'The third string of text',
|
||||||
|
euroCell: 500.567,
|
||||||
|
dollarCell: 500.567,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const CellRenderers: ComponentStory<typeof Table> = args => (
|
||||||
|
<ThemeProvider theme={supersetTheme}>
|
||||||
|
<div>
|
||||||
|
<Table {...args} />
|
||||||
|
</div>
|
||||||
|
</ThemeProvider>
|
||||||
|
);
|
||||||
|
|
||||||
|
CellRenderers.args = {
|
||||||
|
data: rendererData,
|
||||||
|
columns: rendererColumns,
|
||||||
|
size: TableSize.SMALL,
|
||||||
|
reorderable: true,
|
||||||
|
};
|
|
@ -0,0 +1,80 @@
|
||||||
|
/**
|
||||||
|
* 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, waitFor } from 'spec/helpers/testing-library';
|
||||||
|
import type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { Table, TableSize } from './index';
|
||||||
|
|
||||||
|
interface BasicData {
|
||||||
|
columnName: string;
|
||||||
|
columnType: string;
|
||||||
|
dataType: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const testData: BasicData[] = [
|
||||||
|
{
|
||||||
|
columnName: 'Number',
|
||||||
|
columnType: 'Numerical',
|
||||||
|
dataType: 'number',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnName: 'String',
|
||||||
|
columnType: 'Physical',
|
||||||
|
dataType: 'string',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
columnName: 'Date',
|
||||||
|
columnType: 'Virtual',
|
||||||
|
dataType: 'date',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const testColumns: ColumnsType<BasicData> = [
|
||||||
|
{
|
||||||
|
title: 'Column Name',
|
||||||
|
dataIndex: 'columnName',
|
||||||
|
key: 'columnName',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Column Type',
|
||||||
|
dataIndex: 'columnType',
|
||||||
|
key: 'columnType',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Data Type',
|
||||||
|
dataIndex: 'dataType',
|
||||||
|
key: 'dataType',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
test('renders with default props', async () => {
|
||||||
|
render(
|
||||||
|
<Table size={TableSize.MIDDLE} columns={testColumns} data={testData} />,
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
testColumns.forEach(column =>
|
||||||
|
expect(screen.getByText(column.title as string)).toBeInTheDocument(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
testData.forEach(row => {
|
||||||
|
expect(screen.getByText(row.columnName)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(row.columnType)).toBeInTheDocument();
|
||||||
|
expect(screen.getByText(row.dataType)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
|
@ -0,0 +1,69 @@
|
||||||
|
import { Meta, Source, Story, ArgsTable } from '@storybook/addon-docs';
|
||||||
|
|
||||||
|
<Meta title="Design System/Components/Table/Cell Renderers/ActionCell/Overview" />
|
||||||
|
|
||||||
|
# ActionCell
|
||||||
|
|
||||||
|
An ActionCell is used to display an overflow icon that opens a menu allowing the user to take actions
|
||||||
|
specific to the data in the table row that the cell is a member of.
|
||||||
|
|
||||||
|
### [Basic example](./?path=/docs/design-system-components-table-cell-renderers-actioncell--basic)
|
||||||
|
|
||||||
|
<Story id="design-system-components-table-cell-renderers-actioncell--basic" />
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
The action cell accepts an array of objects that define the label, tooltip, onClick callback functions,
|
||||||
|
and an optional data payload to be provided back to the onClick handler function.
|
||||||
|
|
||||||
|
### [Basic example](./?path=/docs/design-system-components-table-cell-renderers-actioncell--basic)
|
||||||
|
|
||||||
|
<Story id="design-system-components-table-cell-renderers-actioncell--basic" />
|
||||||
|
|
||||||
|
```
|
||||||
|
import { ActionMenuItem } from 'src/components/Table/cell-renderers/index';
|
||||||
|
|
||||||
|
export const exampleMenuOptions: ActionMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Action 1',
|
||||||
|
tooltip: "This is a tip, don't spend it all in one place",
|
||||||
|
onClick: (item: ActionMenuItem) => {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(JSON.stringify(item));
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
taco: 'spicy chicken',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Action 2',
|
||||||
|
tooltip: 'This is another tip',
|
||||||
|
onClick: (item: ActionMenuItem) => {
|
||||||
|
// eslint-disable-next-line no-alert
|
||||||
|
alert(JSON.stringify(item));
|
||||||
|
},
|
||||||
|
payload: {
|
||||||
|
taco: 'saucy tofu',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
Within the context of adding an action cell to cell definitions provided to the table using the ActionCell component
|
||||||
|
for the return value from the render function on the cell definition. See the [Basic example](./?path=/docs/design-system-components-table-examples--basic)
|
||||||
|
|
||||||
|
```
|
||||||
|
import ActionCell from './index';
|
||||||
|
|
||||||
|
const cellExample = [
|
||||||
|
{
|
||||||
|
title: 'Actions',
|
||||||
|
dataIndex: 'actions',
|
||||||
|
key: 'actions',
|
||||||
|
render: () => <ActionCell menuOptions={exampleMenuOptions} />,
|
||||||
|
}
|
||||||
|
]
|
||||||
|
```
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* 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 { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import ActionCell from './index';
|
||||||
|
import { exampleMenuOptions, exampleRow } from './fixtures';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Design System/Components/Table/Cell Renderers/ActionCell',
|
||||||
|
component: ActionCell,
|
||||||
|
} as ComponentMeta<typeof ActionCell>;
|
||||||
|
|
||||||
|
export const Basic: ComponentStory<typeof ActionCell> = args => (
|
||||||
|
<ActionCell {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
Basic.args = {
|
||||||
|
menuOptions: exampleMenuOptions,
|
||||||
|
row: exampleRow,
|
||||||
|
};
|
|
@ -0,0 +1,50 @@
|
||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from 'spec/helpers/testing-library';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import ActionCell, { appendDataToMenu } from './index';
|
||||||
|
import { exampleMenuOptions, exampleRow } from './fixtures';
|
||||||
|
|
||||||
|
test('renders with default props', async () => {
|
||||||
|
const clickHandler = jest.fn();
|
||||||
|
exampleMenuOptions[0].onClick = clickHandler;
|
||||||
|
render(<ActionCell menuOptions={exampleMenuOptions} row={exampleRow} />);
|
||||||
|
// Open the menu
|
||||||
|
userEvent.click(await screen.findByTestId('dropdown-trigger'));
|
||||||
|
// verify all of the menu items are being displayed
|
||||||
|
exampleMenuOptions.forEach((item, index) => {
|
||||||
|
expect(screen.getByText(item.label)).toBeInTheDocument();
|
||||||
|
if (index === 0) {
|
||||||
|
// verify the menu items' onClick gets invoked
|
||||||
|
userEvent.click(screen.getByText(item.label));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
expect(clickHandler).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that the appendDataToMenu utility function used within the
|
||||||
|
* Action cell menu rendering works as expected
|
||||||
|
*/
|
||||||
|
test('appendDataToMenu utility', () => {
|
||||||
|
exampleMenuOptions.forEach(item => expect(item?.row).toBeUndefined());
|
||||||
|
const modifiedMenuOptions = appendDataToMenu(exampleMenuOptions, exampleRow);
|
||||||
|
modifiedMenuOptions.forEach(item => expect(item?.row).toBeDefined());
|
||||||
|
});
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* 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 { action } from '@storybook/addon-actions';
|
||||||
|
import { ActionMenuItem } from './index';
|
||||||
|
|
||||||
|
export const exampleMenuOptions: ActionMenuItem[] = [
|
||||||
|
{
|
||||||
|
label: 'Action 1',
|
||||||
|
tooltip: "This is a tip, don't spend it all in one place",
|
||||||
|
onClick: action('menu item onClick'),
|
||||||
|
payload: {
|
||||||
|
taco: 'spicy chicken',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Action 2',
|
||||||
|
tooltip: 'This is another tip',
|
||||||
|
onClick: action('menu item onClick'),
|
||||||
|
payload: {
|
||||||
|
taco: 'saucy tofu',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export const exampleRow = {
|
||||||
|
key: 1,
|
||||||
|
buttonCell: 'Click Me',
|
||||||
|
textCell: 'Some text',
|
||||||
|
euroCell: 45.5,
|
||||||
|
dollarCell: 45.5,
|
||||||
|
};
|
|
@ -0,0 +1,145 @@
|
||||||
|
/**
|
||||||
|
* 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, { useState, useEffect } from 'react';
|
||||||
|
import { styled } from '@superset-ui/core';
|
||||||
|
import { Dropdown, IconOrientation } from 'src/components/Dropdown';
|
||||||
|
import { Menu } from 'src/components/Menu';
|
||||||
|
import { MenuProps } from 'antd/lib/menu';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props interface for Action Cell Renderer
|
||||||
|
*/
|
||||||
|
export interface ActionCellProps {
|
||||||
|
/**
|
||||||
|
* The Menu option presented to user when menu displays
|
||||||
|
*/
|
||||||
|
menuOptions: ActionMenuItem[];
|
||||||
|
/**
|
||||||
|
* Object representing the data rendering the Table row with attribute for each column
|
||||||
|
*/
|
||||||
|
row: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ActionMenuItem {
|
||||||
|
/**
|
||||||
|
* Click handler specific to the menu item
|
||||||
|
* @param menuItem The definition of the menu item that was clicked
|
||||||
|
* @returns ActionMenuItem
|
||||||
|
*/
|
||||||
|
onClick: (menuItem: ActionMenuItem) => void;
|
||||||
|
/**
|
||||||
|
* Label user will see displayed in the list of menu options
|
||||||
|
*/
|
||||||
|
label: string;
|
||||||
|
/**
|
||||||
|
* Optional tooltip user will see if they hover over the menu option to get more context
|
||||||
|
*/
|
||||||
|
tooltip?: string;
|
||||||
|
/**
|
||||||
|
* Optional variable that can contain data relevant to the menu item that you
|
||||||
|
* want easy access to in the callback function for the menu
|
||||||
|
*/
|
||||||
|
payload?: any;
|
||||||
|
/**
|
||||||
|
* Object representing the data rendering the Table row with attribute for each column
|
||||||
|
*/
|
||||||
|
row?: object;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Props interface for ActionMenu
|
||||||
|
*/
|
||||||
|
export interface ActionMenuProps {
|
||||||
|
menuOptions: ActionMenuItem[];
|
||||||
|
setVisible: (visible: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const SHADOW =
|
||||||
|
'box-shadow: 0px 3px 6px -4px rgba(0, 0, 0, 0.12), 0px 9px 28px 8px rgba(0, 0, 0, 0.05)';
|
||||||
|
const FILTER = 'drop-shadow(0px 6px 16px rgba(0, 0, 0, 0.08))';
|
||||||
|
|
||||||
|
const StyledMenu = styled(Menu)`
|
||||||
|
box-shadow: ${SHADOW} !important;
|
||||||
|
filter: ${FILTER} !important;
|
||||||
|
border-radius: 2px !important;
|
||||||
|
-webkit-box-shadow: ${SHADOW} !important;
|
||||||
|
`;
|
||||||
|
|
||||||
|
export const appendDataToMenu = (
|
||||||
|
options: ActionMenuItem[],
|
||||||
|
row: object,
|
||||||
|
): ActionMenuItem[] => {
|
||||||
|
const newOptions = options?.map?.(option => ({
|
||||||
|
...option,
|
||||||
|
row,
|
||||||
|
}));
|
||||||
|
return newOptions;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ActionMenu(props: ActionMenuProps) {
|
||||||
|
const { menuOptions, setVisible } = props;
|
||||||
|
const handleClick: MenuProps['onClick'] = ({ key }) => {
|
||||||
|
setVisible?.(false);
|
||||||
|
const menuItem = menuOptions[key];
|
||||||
|
if (menuItem) {
|
||||||
|
menuItem?.onClick?.(menuItem);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<StyledMenu onClick={handleClick}>
|
||||||
|
{menuOptions?.map?.((option: ActionMenuItem, index: number) => (
|
||||||
|
<Menu.Item key={index}>{option?.label}</Menu.Item>
|
||||||
|
))}
|
||||||
|
</StyledMenu>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ActionCell(props: ActionCellProps) {
|
||||||
|
const { menuOptions, row } = props;
|
||||||
|
const [visible, setVisible] = useState(false);
|
||||||
|
const [appendedMenuOptions, setAppendedMenuOptions] = useState(
|
||||||
|
appendDataToMenu(menuOptions, row),
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const newOptions = appendDataToMenu(menuOptions, row);
|
||||||
|
setAppendedMenuOptions(newOptions);
|
||||||
|
}, [menuOptions, row]);
|
||||||
|
|
||||||
|
const handleVisibleChange = (flag: boolean) => {
|
||||||
|
setVisible(flag);
|
||||||
|
};
|
||||||
|
return (
|
||||||
|
<Dropdown
|
||||||
|
iconOrientation={IconOrientation.HORIZONTAL}
|
||||||
|
onVisibleChange={handleVisibleChange}
|
||||||
|
trigger={['click']}
|
||||||
|
overlay={
|
||||||
|
<ActionMenu menuOptions={appendedMenuOptions} setVisible={setVisible} />
|
||||||
|
}
|
||||||
|
disabled={
|
||||||
|
!(appendedMenuOptions?.length && appendedMenuOptions.length > 0)
|
||||||
|
}
|
||||||
|
visible={visible}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ActionCell;
|
|
@ -0,0 +1,62 @@
|
||||||
|
/**
|
||||||
|
* 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 { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import { action } from '@storybook/addon-actions';
|
||||||
|
import { ButtonCell } from './index';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Design System/Components/Table/Cell Renderers/ButtonCell',
|
||||||
|
component: ButtonCell,
|
||||||
|
} as ComponentMeta<typeof ButtonCell>;
|
||||||
|
|
||||||
|
const clickHandler = action('button cell onClick');
|
||||||
|
|
||||||
|
export const Basic: ComponentStory<typeof ButtonCell> = args => (
|
||||||
|
<ButtonCell {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
Basic.args = {
|
||||||
|
onClick: clickHandler,
|
||||||
|
label: 'Primary',
|
||||||
|
row: {
|
||||||
|
key: 1,
|
||||||
|
buttonCell: 'Click Me',
|
||||||
|
textCell: 'Some text',
|
||||||
|
euroCell: 45.5,
|
||||||
|
dollarCell: 45.5,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export const Secondary: ComponentStory<typeof ButtonCell> = args => (
|
||||||
|
<ButtonCell {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
Secondary.args = {
|
||||||
|
onClick: clickHandler,
|
||||||
|
label: 'Secondary',
|
||||||
|
buttonStyle: 'secondary',
|
||||||
|
row: {
|
||||||
|
key: 1,
|
||||||
|
buttonCell: 'Click Me',
|
||||||
|
textCell: 'Some text',
|
||||||
|
euroCell: 45.5,
|
||||||
|
dollarCell: 45.5,
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from 'spec/helpers/testing-library';
|
||||||
|
import userEvent from '@testing-library/user-event';
|
||||||
|
import ButtonCell from './index';
|
||||||
|
import { exampleRow } from '../fixtures';
|
||||||
|
|
||||||
|
test('renders with default props', async () => {
|
||||||
|
const clickHandler = jest.fn();
|
||||||
|
const BUTTON_LABEL = 'Button Label';
|
||||||
|
|
||||||
|
render(
|
||||||
|
<ButtonCell
|
||||||
|
label={BUTTON_LABEL}
|
||||||
|
key={5}
|
||||||
|
index={0}
|
||||||
|
row={exampleRow}
|
||||||
|
onClick={clickHandler}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
await userEvent.click(screen.getByText(BUTTON_LABEL));
|
||||||
|
expect(clickHandler).toHaveBeenCalled();
|
||||||
|
});
|
|
@ -0,0 +1,58 @@
|
||||||
|
/**
|
||||||
|
* 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 Button, { ButtonStyle, ButtonSize } from 'src/components/Button';
|
||||||
|
|
||||||
|
type onClickFunction = (row: object, index: number) => void;
|
||||||
|
|
||||||
|
export interface ButtonCellProps {
|
||||||
|
label: string;
|
||||||
|
onClick: onClickFunction;
|
||||||
|
row: object;
|
||||||
|
index: number;
|
||||||
|
tooltip?: string;
|
||||||
|
buttonStyle?: ButtonStyle;
|
||||||
|
buttonSize?: ButtonSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ButtonCell(props: ButtonCellProps) {
|
||||||
|
const {
|
||||||
|
label,
|
||||||
|
onClick,
|
||||||
|
row,
|
||||||
|
index,
|
||||||
|
tooltip,
|
||||||
|
buttonStyle = 'primary',
|
||||||
|
buttonSize = 'small',
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
buttonStyle={buttonStyle}
|
||||||
|
buttonSize={buttonSize}
|
||||||
|
onClick={() => onClick?.(row, index)}
|
||||||
|
key={`${buttonStyle}_${buttonSize}`}
|
||||||
|
tooltip={tooltip}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default ButtonCell;
|
|
@ -0,0 +1,47 @@
|
||||||
|
/**
|
||||||
|
* 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 { ComponentStory, ComponentMeta } from '@storybook/react';
|
||||||
|
import { CurrencyCode, LocaleCode, NumericCell, Style } from './index';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
title: 'Design System/Components/Table/Cell Renderers/NumericCell',
|
||||||
|
component: NumericCell,
|
||||||
|
} as ComponentMeta<typeof NumericCell>;
|
||||||
|
|
||||||
|
export const Basic: ComponentStory<typeof NumericCell> = args => (
|
||||||
|
<NumericCell {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
Basic.args = {
|
||||||
|
value: 5678943,
|
||||||
|
};
|
||||||
|
|
||||||
|
export const FrenchLocale: ComponentStory<typeof NumericCell> = args => (
|
||||||
|
<NumericCell {...args} />
|
||||||
|
);
|
||||||
|
|
||||||
|
FrenchLocale.args = {
|
||||||
|
value: 5678943,
|
||||||
|
locale: LocaleCode.fr,
|
||||||
|
options: {
|
||||||
|
style: Style.CURRENCY,
|
||||||
|
currency: CurrencyCode.EUR,
|
||||||
|
},
|
||||||
|
};
|
|
@ -0,0 +1,49 @@
|
||||||
|
/**
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing,
|
||||||
|
* software distributed under the License is distributed on an
|
||||||
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
||||||
|
* KIND, either express or implied. See the License for the
|
||||||
|
* specific language governing permissions and limitations
|
||||||
|
* under the License.
|
||||||
|
*/
|
||||||
|
import React from 'react';
|
||||||
|
import { render, screen } from 'spec/helpers/testing-library';
|
||||||
|
import NumericCell, { CurrencyCode, LocaleCode, Style } from './index';
|
||||||
|
|
||||||
|
test('renders with French locale and Euro currency format', () => {
|
||||||
|
render(
|
||||||
|
<NumericCell
|
||||||
|
value={5678943}
|
||||||
|
locale={LocaleCode.fr}
|
||||||
|
options={{
|
||||||
|
style: Style.CURRENCY,
|
||||||
|
currency: CurrencyCode.EUR,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('5 678 943,00 €')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('renders with English US locale and USD currency format', () => {
|
||||||
|
render(
|
||||||
|
<NumericCell
|
||||||
|
value={5678943}
|
||||||
|
locale={LocaleCode.en_US}
|
||||||
|
options={{
|
||||||
|
style: Style.CURRENCY,
|
||||||
|
currency: CurrencyCode.USD,
|
||||||
|
}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('$5,678,943.00')).toBeInTheDocument();
|
||||||
|
});
|
|
@ -0,0 +1,418 @@
|
||||||
|
/**
|
||||||
|
* 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 { logging } from '@superset-ui/core';
|
||||||
|
|
||||||
|
export interface NumericCellProps {
|
||||||
|
/**
|
||||||
|
* The number to display (before optional formatting applied)
|
||||||
|
*/
|
||||||
|
value: number;
|
||||||
|
/**
|
||||||
|
* ISO 639-1 language code with optional region or script modifier (e.g. en_US).
|
||||||
|
*/
|
||||||
|
locale?: LocaleCode;
|
||||||
|
/**
|
||||||
|
* Options for number formatting
|
||||||
|
*/
|
||||||
|
options?: NumberOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NumberOptions {
|
||||||
|
/**
|
||||||
|
* Style of number to display
|
||||||
|
*/
|
||||||
|
style?: Style;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* ISO 4217 currency code
|
||||||
|
*/
|
||||||
|
currency?: CurrencyCode;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Languages in the form of a ISO 639-1 language code with optional region or script modifier (e.g. de_AT).
|
||||||
|
*/
|
||||||
|
maximumFractionDigits?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A number from 1 to 21 (default is 21)
|
||||||
|
*/
|
||||||
|
maximumSignificantDigits?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A number from 0 to 20 (default is 3)
|
||||||
|
*/
|
||||||
|
minimumFractionDigits?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A number from 1 to 21 (default is 1)
|
||||||
|
*/
|
||||||
|
minimumIntegerDigits?: number;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A number from 1 to 21 (default is 21)
|
||||||
|
*/
|
||||||
|
minimumSignificantDigits?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum Style {
|
||||||
|
CURRENCY = 'currency',
|
||||||
|
DECIMAL = 'decimal',
|
||||||
|
PERCENT = 'percent',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CurrencyDisplay {
|
||||||
|
SYMBOL = 'symbol',
|
||||||
|
CODE = 'code',
|
||||||
|
NAME = 'name',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum LocaleCode {
|
||||||
|
af = 'af',
|
||||||
|
ak = 'ak',
|
||||||
|
sq = 'sq',
|
||||||
|
am = 'am',
|
||||||
|
ar = 'ar',
|
||||||
|
hy = 'hy',
|
||||||
|
as = 'as',
|
||||||
|
az = 'az',
|
||||||
|
bm = 'bm',
|
||||||
|
bn = 'bn',
|
||||||
|
eu = 'eu',
|
||||||
|
be = 'be',
|
||||||
|
bs = 'bs',
|
||||||
|
br = 'br',
|
||||||
|
bg = 'bg',
|
||||||
|
my = 'my',
|
||||||
|
ca = 'ca',
|
||||||
|
ce = 'ce',
|
||||||
|
zh = 'zh',
|
||||||
|
zh_Hans = 'zh-Hans',
|
||||||
|
zh_Hant = 'zh-Hant',
|
||||||
|
cu = 'cu',
|
||||||
|
kw = 'kw',
|
||||||
|
co = 'co',
|
||||||
|
hr = 'hr',
|
||||||
|
cs = 'cs',
|
||||||
|
da = 'da',
|
||||||
|
nl = 'nl',
|
||||||
|
nl_BE = 'nl-BE',
|
||||||
|
dz = 'dz',
|
||||||
|
en = 'en',
|
||||||
|
en_AU = 'en-AU',
|
||||||
|
en_CA = 'en-CA',
|
||||||
|
en_GB = 'en-GB',
|
||||||
|
en_US = 'en-US',
|
||||||
|
eo = 'eo',
|
||||||
|
et = 'et',
|
||||||
|
ee = 'ee',
|
||||||
|
fo = 'fo',
|
||||||
|
fi = 'fi',
|
||||||
|
fr = 'fr',
|
||||||
|
fr_CA = 'fr-CA',
|
||||||
|
fr_CH = 'fr-CH',
|
||||||
|
ff = 'ff',
|
||||||
|
gl = 'gl',
|
||||||
|
lg = 'lg',
|
||||||
|
ka = 'ka',
|
||||||
|
de = 'de',
|
||||||
|
de_AT = 'de-AT',
|
||||||
|
de_CH = 'de-CH',
|
||||||
|
el = 'el',
|
||||||
|
gu = 'gu',
|
||||||
|
ht = 'ht',
|
||||||
|
ha = 'ha',
|
||||||
|
he = 'he',
|
||||||
|
hi = 'hi',
|
||||||
|
hu = 'hu',
|
||||||
|
is = 'is',
|
||||||
|
ig = 'ig',
|
||||||
|
id = 'id',
|
||||||
|
ia = 'ia',
|
||||||
|
ga = 'ga',
|
||||||
|
it = 'it',
|
||||||
|
ja = 'ja',
|
||||||
|
jv = 'jv',
|
||||||
|
kl = 'kl',
|
||||||
|
kn = 'kn',
|
||||||
|
ks = 'ks',
|
||||||
|
kk = 'kk',
|
||||||
|
km = 'km',
|
||||||
|
ki = 'ki',
|
||||||
|
rw = 'rw',
|
||||||
|
ko = 'ko',
|
||||||
|
ku = 'ku',
|
||||||
|
ky = 'ky',
|
||||||
|
lo = 'lo',
|
||||||
|
la = 'la',
|
||||||
|
lv = 'lv',
|
||||||
|
ln = 'ln',
|
||||||
|
lt = 'lt',
|
||||||
|
lu = 'lu',
|
||||||
|
lb = 'lb',
|
||||||
|
mk = 'mk',
|
||||||
|
mg = 'mg',
|
||||||
|
ms = 'ms',
|
||||||
|
ml = 'ml',
|
||||||
|
mt = 'mt',
|
||||||
|
gv = 'gv',
|
||||||
|
mi = 'mi',
|
||||||
|
mr = 'mr',
|
||||||
|
mn = 'mn',
|
||||||
|
ne = 'ne',
|
||||||
|
nd = 'nd',
|
||||||
|
se = 'se',
|
||||||
|
nb = 'nb',
|
||||||
|
nn = 'nn',
|
||||||
|
ny = 'ny',
|
||||||
|
or = 'or',
|
||||||
|
om = 'om',
|
||||||
|
os = 'os',
|
||||||
|
ps = 'ps',
|
||||||
|
fa = 'fa',
|
||||||
|
fa_AF = 'fa-AF',
|
||||||
|
pl = 'pl',
|
||||||
|
pt = 'pt',
|
||||||
|
pt_BR = 'pt-BR',
|
||||||
|
pt_PT = 'pt-PT',
|
||||||
|
pa = 'pa',
|
||||||
|
qu = 'qu',
|
||||||
|
ro = 'ro',
|
||||||
|
ro_MD = 'ro-MD',
|
||||||
|
rm = 'rm',
|
||||||
|
rn = 'rn',
|
||||||
|
ru = 'ru',
|
||||||
|
sm = 'sm',
|
||||||
|
sg = 'sg',
|
||||||
|
sa = 'sa',
|
||||||
|
gd = 'gd',
|
||||||
|
sr = 'sr',
|
||||||
|
sn = 'sn',
|
||||||
|
ii = 'ii',
|
||||||
|
sd = 'sd',
|
||||||
|
si = 'si',
|
||||||
|
sk = 'sk',
|
||||||
|
sl = 'sl',
|
||||||
|
so = 'so',
|
||||||
|
st = 'st',
|
||||||
|
es = 'es',
|
||||||
|
es_ES = 'es-ES',
|
||||||
|
es_MX = 'es-MX',
|
||||||
|
su = 'su',
|
||||||
|
sw = 'sw',
|
||||||
|
sw_CD = 'sw-CD',
|
||||||
|
sv = 'sv',
|
||||||
|
tg = 'tg',
|
||||||
|
ta = 'ta',
|
||||||
|
tt = 'tt',
|
||||||
|
te = 'te',
|
||||||
|
th = 'th',
|
||||||
|
bo = 'bo',
|
||||||
|
ti = 'ti',
|
||||||
|
to = 'to',
|
||||||
|
tr = 'tr',
|
||||||
|
tk = 'tk',
|
||||||
|
uk = 'uk',
|
||||||
|
ur = 'ur',
|
||||||
|
ug = 'ug',
|
||||||
|
uz = 'uz',
|
||||||
|
vi = 'vi',
|
||||||
|
vo = 'vo',
|
||||||
|
cy = 'cy',
|
||||||
|
fy = 'fy',
|
||||||
|
wo = 'wo',
|
||||||
|
xh = 'xh',
|
||||||
|
yi = 'yi',
|
||||||
|
yo = 'yo',
|
||||||
|
zu = 'zu',
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum CurrencyCode {
|
||||||
|
AED = 'AED',
|
||||||
|
AFN = 'AFN',
|
||||||
|
ALL = 'ALL',
|
||||||
|
AMD = 'AMD',
|
||||||
|
ANG = 'ANG',
|
||||||
|
AOA = 'AOA',
|
||||||
|
ARS = 'ARS',
|
||||||
|
AUD = 'AUD',
|
||||||
|
AWG = 'AWG',
|
||||||
|
AZN = 'AZN',
|
||||||
|
BAM = 'BAM',
|
||||||
|
BBD = 'BBD',
|
||||||
|
BDT = 'BDT',
|
||||||
|
BGN = 'BGN',
|
||||||
|
BHD = 'BHD',
|
||||||
|
BIF = 'BIF',
|
||||||
|
BMD = 'BMD',
|
||||||
|
BND = 'BND',
|
||||||
|
BOB = 'BOB',
|
||||||
|
BRL = 'BRL',
|
||||||
|
BSD = 'BSD',
|
||||||
|
BTN = 'BTN',
|
||||||
|
BWP = 'BWP',
|
||||||
|
BYN = 'BYN',
|
||||||
|
BZD = 'BZD',
|
||||||
|
CAD = 'CAD',
|
||||||
|
CDF = 'CDF',
|
||||||
|
CHF = 'CHF',
|
||||||
|
CLP = 'CLP',
|
||||||
|
CNY = 'CNY',
|
||||||
|
COP = 'COP',
|
||||||
|
CRC = 'CRC',
|
||||||
|
CUC = 'CUC',
|
||||||
|
CUP = 'CUP',
|
||||||
|
CVE = 'CVE',
|
||||||
|
CZK = 'CZK',
|
||||||
|
DJF = 'DJF',
|
||||||
|
DKK = 'DKK',
|
||||||
|
DOP = 'DOP',
|
||||||
|
DZD = 'DZD',
|
||||||
|
EGP = 'EGP',
|
||||||
|
ERN = 'ERN',
|
||||||
|
ETB = 'ETB',
|
||||||
|
EUR = 'EUR',
|
||||||
|
FJD = 'FJD',
|
||||||
|
FKP = 'FKP',
|
||||||
|
GBP = 'GBP',
|
||||||
|
GEL = 'GEL',
|
||||||
|
GHS = 'GHS',
|
||||||
|
GIP = 'GIP',
|
||||||
|
GMD = 'GMD',
|
||||||
|
GNF = 'GNF',
|
||||||
|
GTQ = 'GTQ',
|
||||||
|
GYD = 'GYD',
|
||||||
|
HKD = 'HKD',
|
||||||
|
HNL = 'HNL',
|
||||||
|
HRK = 'HRK',
|
||||||
|
HTG = 'HTG',
|
||||||
|
HUF = 'HUF',
|
||||||
|
IDR = 'IDR',
|
||||||
|
ILS = 'ILS',
|
||||||
|
INR = 'INR',
|
||||||
|
IQD = 'IQD',
|
||||||
|
IRR = 'IRR',
|
||||||
|
ISK = 'ISK',
|
||||||
|
JMD = 'JMD',
|
||||||
|
JOD = 'JOD',
|
||||||
|
JPY = 'JPY',
|
||||||
|
KES = 'KES',
|
||||||
|
KGS = 'KGS',
|
||||||
|
KHR = 'KHR',
|
||||||
|
KMF = 'KMF',
|
||||||
|
KPW = 'KPW',
|
||||||
|
KRW = 'KRW',
|
||||||
|
KWD = 'KWD',
|
||||||
|
KYD = 'KYD',
|
||||||
|
KZT = 'KZT',
|
||||||
|
LAK = 'LAK',
|
||||||
|
LBP = 'LBP',
|
||||||
|
LKR = 'LKR',
|
||||||
|
LRD = 'LRD',
|
||||||
|
LSL = 'LSL',
|
||||||
|
LYD = 'LYD',
|
||||||
|
MAD = 'MAD',
|
||||||
|
MDL = 'MDL',
|
||||||
|
MGA = 'MGA',
|
||||||
|
MKD = 'MKD',
|
||||||
|
MMK = 'MMK',
|
||||||
|
MNT = 'MNT',
|
||||||
|
MOP = 'MOP',
|
||||||
|
MRU = 'MRU',
|
||||||
|
MUR = 'MUR',
|
||||||
|
MVR = 'MVR',
|
||||||
|
MWK = 'MWK',
|
||||||
|
MXN = 'MXN',
|
||||||
|
MYR = 'MYR',
|
||||||
|
MZN = 'MZN',
|
||||||
|
NAD = 'NAD',
|
||||||
|
NGN = 'NGN',
|
||||||
|
NIO = 'NIO',
|
||||||
|
NOK = 'NOK',
|
||||||
|
NPR = 'NPR',
|
||||||
|
NZD = 'NZD',
|
||||||
|
OMR = 'OMR',
|
||||||
|
PAB = 'PAB',
|
||||||
|
PEN = 'PEN',
|
||||||
|
PGK = 'PGK',
|
||||||
|
PHP = 'PHP',
|
||||||
|
PKR = 'PKR',
|
||||||
|
PLN = 'PLN',
|
||||||
|
PYG = 'PYG',
|
||||||
|
QAR = 'QAR',
|
||||||
|
RON = 'RON',
|
||||||
|
RSD = 'RSD',
|
||||||
|
RUB = 'RUB',
|
||||||
|
RWF = 'RWF',
|
||||||
|
SAR = 'SAR',
|
||||||
|
SBD = 'SBD',
|
||||||
|
SCR = 'SCR',
|
||||||
|
SDG = 'SDG',
|
||||||
|
SEK = 'SEK',
|
||||||
|
SGD = 'SGD',
|
||||||
|
SHP = 'SHP',
|
||||||
|
SLL = 'SLL',
|
||||||
|
SOS = 'SOS',
|
||||||
|
SRD = 'SRD',
|
||||||
|
SSP = 'SSP',
|
||||||
|
STN = 'STN',
|
||||||
|
SVC = 'SVC',
|
||||||
|
SYP = 'SYP',
|
||||||
|
SZL = 'SZL',
|
||||||
|
THB = 'THB',
|
||||||
|
TJS = 'TJS',
|
||||||
|
TMT = 'TMT',
|
||||||
|
TND = 'TND',
|
||||||
|
TOP = 'TOP',
|
||||||
|
TRY = 'TRY',
|
||||||
|
TTD = 'TTD',
|
||||||
|
TWD = 'TWD',
|
||||||
|
TZS = 'TZS',
|
||||||
|
UAH = 'UAH',
|
||||||
|
UGX = 'UGX',
|
||||||
|
USD = 'USD',
|
||||||
|
UYU = 'UYU',
|
||||||
|
UZS = 'UZS',
|
||||||
|
VES = 'VES',
|
||||||
|
VND = 'VND',
|
||||||
|
VUV = 'VUV',
|
||||||
|
WST = 'WST',
|
||||||
|
XAF = 'XAF',
|
||||||
|
XCD = 'XCD',
|
||||||
|
XOF = 'XOF',
|
||||||
|
XPF = 'XPF',
|
||||||
|
YER = 'YER',
|
||||||
|
ZAR = 'ZAR',
|
||||||
|
ZMW = 'ZMW',
|
||||||
|
ZWL = 'ZWL',
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NumericCell(props: NumericCellProps) {
|
||||||
|
const { value, locale = LocaleCode.en_US, options } = props;
|
||||||
|
let displayValue = value?.toString() ?? value;
|
||||||
|
try {
|
||||||
|
displayValue = value?.toLocaleString?.(locale, options);
|
||||||
|
} catch (e) {
|
||||||
|
logging.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
return <span>{displayValue}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default NumericCell;
|
|
@ -0,0 +1,25 @@
|
||||||
|
/**
|
||||||
|
* 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 const exampleRow = {
|
||||||
|
key: 1,
|
||||||
|
buttonCell: 'Click Me',
|
||||||
|
textCell: 'Some text',
|
||||||
|
euroCell: 45.5,
|
||||||
|
dollarCell: 45.5,
|
||||||
|
};
|
|
@ -0,0 +1,326 @@
|
||||||
|
/**
|
||||||
|
* 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, { useState, useEffect, useRef, ReactElement } from 'react';
|
||||||
|
import { Table as AntTable, ConfigProvider } from 'antd';
|
||||||
|
import type {
|
||||||
|
ColumnType,
|
||||||
|
ColumnGroupType,
|
||||||
|
TableProps as AntTableProps,
|
||||||
|
} from 'antd/es/table';
|
||||||
|
import { t, useTheme, logging } from '@superset-ui/core';
|
||||||
|
import Loading from 'src/components/Loading';
|
||||||
|
import styled, { StyledComponent } from '@emotion/styled';
|
||||||
|
import InteractiveTableUtils from './utils/InteractiveTableUtils';
|
||||||
|
|
||||||
|
export const SUPERSET_TABLE_COLUMN = 'superset/table-column';
|
||||||
|
export interface TableDataType {
|
||||||
|
key: React.Key;
|
||||||
|
}
|
||||||
|
|
||||||
|
export declare type ColumnsType<RecordType = unknown> = (
|
||||||
|
| ColumnGroupType<RecordType>
|
||||||
|
| ColumnType<RecordType>
|
||||||
|
)[];
|
||||||
|
|
||||||
|
export enum SelectionType {
|
||||||
|
'DISABLED' = 'disabled',
|
||||||
|
'SINGLE' = 'single',
|
||||||
|
'MULTI' = 'multi',
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Locale {
|
||||||
|
/**
|
||||||
|
* Text contained within the Table UI.
|
||||||
|
*/
|
||||||
|
filterTitle: string;
|
||||||
|
filterConfirm: string;
|
||||||
|
filterReset: string;
|
||||||
|
filterEmptyText: string;
|
||||||
|
filterCheckall: string;
|
||||||
|
filterSearchPlaceholder: string;
|
||||||
|
emptyText: string;
|
||||||
|
selectAll: string;
|
||||||
|
selectInvert: string;
|
||||||
|
selectNone: string;
|
||||||
|
selectionAll: string;
|
||||||
|
sortTitle: string;
|
||||||
|
expand: string;
|
||||||
|
collapse: string;
|
||||||
|
triggerDesc: string;
|
||||||
|
triggerAsc: string;
|
||||||
|
cancelSort: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TableProps extends AntTableProps<TableProps> {
|
||||||
|
/**
|
||||||
|
* Data that will populate the each row and map to the column key.
|
||||||
|
*/
|
||||||
|
data: object[];
|
||||||
|
/**
|
||||||
|
* Table column definitions.
|
||||||
|
*/
|
||||||
|
columns: ColumnsType<any>;
|
||||||
|
/**
|
||||||
|
* Array of row keys to represent list of selected rows.
|
||||||
|
*/
|
||||||
|
selectedRows?: React.Key[];
|
||||||
|
/**
|
||||||
|
* Callback function invoked when a row is selected by user.
|
||||||
|
*/
|
||||||
|
handleRowSelection?: Function;
|
||||||
|
/**
|
||||||
|
* Controls the size of the table.
|
||||||
|
*/
|
||||||
|
size: TableSize;
|
||||||
|
/**
|
||||||
|
* Adjusts the padding around elements for different amounts of spacing between elements.
|
||||||
|
*/
|
||||||
|
selectionType?: SelectionType;
|
||||||
|
/*
|
||||||
|
* Places table in visual loading state. Use while waiting to retrieve data or perform an async operation that will update the table.
|
||||||
|
*/
|
||||||
|
loading?: boolean;
|
||||||
|
/**
|
||||||
|
* Uses a sticky header which always displays when vertically scrolling the table. Default: true
|
||||||
|
*/
|
||||||
|
sticky?: boolean;
|
||||||
|
/**
|
||||||
|
* Controls if columns are resizable by user.
|
||||||
|
*/
|
||||||
|
resizable?: boolean;
|
||||||
|
/**
|
||||||
|
* EXPERIMENTAL: Controls if columns are re-orderable by user drag drop.
|
||||||
|
*/
|
||||||
|
reorderable?: boolean;
|
||||||
|
/**
|
||||||
|
* Default number of rows table will display per page of data.
|
||||||
|
*/
|
||||||
|
defaultPageSize?: number;
|
||||||
|
/**
|
||||||
|
* Array of numeric options for the number of rows table will display per page of data.
|
||||||
|
* The user can select from these options in the page size drop down menu.
|
||||||
|
*/
|
||||||
|
pageSizeOptions?: string[];
|
||||||
|
/**
|
||||||
|
* Set table to display no data even if data has been provided
|
||||||
|
*/
|
||||||
|
hideData?: boolean;
|
||||||
|
/**
|
||||||
|
* emptyComponent
|
||||||
|
*/
|
||||||
|
emptyComponent?: ReactElement;
|
||||||
|
/**
|
||||||
|
* Enables setting the text displayed in various components and tooltips within the Table UI.
|
||||||
|
*/
|
||||||
|
locale?: Locale;
|
||||||
|
/**
|
||||||
|
* Restricts the visible height of the table and allows for internal scrolling within the table
|
||||||
|
* when the number of rows exceeds the visible space.
|
||||||
|
*/
|
||||||
|
height?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum TableSize {
|
||||||
|
SMALL = 'small',
|
||||||
|
MIDDLE = 'middle',
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultRowSelection: React.Key[] = [];
|
||||||
|
// This accounts for the tables header and pagination if user gives table instance a height. this is a temp solution
|
||||||
|
const HEIGHT_OFFSET = 108;
|
||||||
|
|
||||||
|
const StyledTable: StyledComponent<any> = styled(AntTable)<any>`
|
||||||
|
${({ theme, height }) => `
|
||||||
|
.ant-table-body {
|
||||||
|
overflow: scroll;
|
||||||
|
height: ${height ? `${height - HEIGHT_OFFSET}px` : undefined};
|
||||||
|
}
|
||||||
|
|
||||||
|
th.ant-table-cell {
|
||||||
|
font-weight: ${theme.typography.weights.bold};
|
||||||
|
color: ${theme.colors.grayscale.dark1};
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ant-pagination-item-active {
|
||||||
|
border-color: ${theme.colors.primary.base};
|
||||||
|
}
|
||||||
|
`}
|
||||||
|
`;
|
||||||
|
|
||||||
|
const defaultLocale = {
|
||||||
|
filterTitle: t('Filter menu'),
|
||||||
|
filterConfirm: t('OK'),
|
||||||
|
filterReset: t('Reset'),
|
||||||
|
filterEmptyText: t('No filters'),
|
||||||
|
filterCheckall: t('Select all items'),
|
||||||
|
filterSearchPlaceholder: t('Search in filters'),
|
||||||
|
emptyText: t('No data'),
|
||||||
|
selectAll: t('Select current page'),
|
||||||
|
selectInvert: t('Invert current page'),
|
||||||
|
selectNone: t('Clear all data'),
|
||||||
|
selectionAll: t('Select all data'),
|
||||||
|
sortTitle: t('Sort'),
|
||||||
|
expand: t('Expand row'),
|
||||||
|
collapse: t('Collapse row'),
|
||||||
|
triggerDesc: t('Click to sort descending'),
|
||||||
|
triggerAsc: t('Click to sort ascending'),
|
||||||
|
cancelSort: t('Click to cancel sorting'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectionMap = {};
|
||||||
|
selectionMap[SelectionType.MULTI] = 'checkbox';
|
||||||
|
selectionMap[SelectionType.SINGLE] = 'radio';
|
||||||
|
selectionMap[SelectionType.DISABLED] = null;
|
||||||
|
|
||||||
|
export function Table(props: TableProps) {
|
||||||
|
const {
|
||||||
|
data,
|
||||||
|
columns,
|
||||||
|
selectedRows = defaultRowSelection,
|
||||||
|
handleRowSelection,
|
||||||
|
size,
|
||||||
|
selectionType = SelectionType.DISABLED,
|
||||||
|
sticky = true,
|
||||||
|
loading = false,
|
||||||
|
resizable = false,
|
||||||
|
reorderable = false,
|
||||||
|
defaultPageSize = 15,
|
||||||
|
pageSizeOptions = ['5', '15', '25', '50', '100'],
|
||||||
|
hideData = false,
|
||||||
|
emptyComponent,
|
||||||
|
locale,
|
||||||
|
...rest
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
const wrapperRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const [derivedColumns, setDerivedColumns] = useState(columns);
|
||||||
|
const [pageSize, setPageSize] = useState(defaultPageSize);
|
||||||
|
const [mergedLocale, setMergedLocale] = useState({ ...defaultLocale });
|
||||||
|
const [selectedRowKeys, setSelectedRowKeys] =
|
||||||
|
useState<React.Key[]>(selectedRows);
|
||||||
|
const interactiveTableUtils = useRef<InteractiveTableUtils | null>(null);
|
||||||
|
|
||||||
|
const onSelectChange = (newSelectedRowKeys: React.Key[]) => {
|
||||||
|
setSelectedRowKeys(newSelectedRowKeys);
|
||||||
|
handleRowSelection?.(newSelectedRowKeys);
|
||||||
|
};
|
||||||
|
|
||||||
|
const selectionTypeValue = selectionMap[selectionType];
|
||||||
|
const rowSelection = {
|
||||||
|
type: selectionTypeValue,
|
||||||
|
selectedRowKeys,
|
||||||
|
onChange: onSelectChange,
|
||||||
|
};
|
||||||
|
|
||||||
|
const renderEmpty = () =>
|
||||||
|
emptyComponent ?? <div>{mergedLocale.emptyText}</div>;
|
||||||
|
|
||||||
|
// Log use of experimental features
|
||||||
|
useEffect(() => {
|
||||||
|
if (reorderable === true) {
|
||||||
|
logging.warn(
|
||||||
|
'EXPERIMENTAL FEATURE ENABLED: The "reorderable" prop of Table is experimental and NOT recommended for use in production deployments.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resizable === true) {
|
||||||
|
logging.warn(
|
||||||
|
'EXPERIMENTAL FEATURE ENABLED: The "resizable" prop of Table is experimental and NOT recommended for use in production deployments.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, [reorderable, resizable]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let updatedLocale;
|
||||||
|
if (locale) {
|
||||||
|
// This spread allows for locale to only contain a subset of locale overrides on props
|
||||||
|
updatedLocale = { ...defaultLocale, ...locale };
|
||||||
|
} else {
|
||||||
|
updatedLocale = { ...defaultLocale };
|
||||||
|
}
|
||||||
|
setMergedLocale(updatedLocale);
|
||||||
|
}, [locale]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (interactiveTableUtils.current) {
|
||||||
|
interactiveTableUtils.current?.clearListeners();
|
||||||
|
}
|
||||||
|
const table = wrapperRef.current?.getElementsByTagName('table')[0];
|
||||||
|
if (table) {
|
||||||
|
interactiveTableUtils.current = new InteractiveTableUtils(
|
||||||
|
table,
|
||||||
|
derivedColumns,
|
||||||
|
setDerivedColumns,
|
||||||
|
);
|
||||||
|
if (reorderable) {
|
||||||
|
interactiveTableUtils?.current?.initializeDragDropColumns(
|
||||||
|
reorderable,
|
||||||
|
table,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (resizable) {
|
||||||
|
interactiveTableUtils?.current?.initializeResizableColumns(
|
||||||
|
resizable,
|
||||||
|
table,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
interactiveTableUtils?.current?.clearListeners?.();
|
||||||
|
};
|
||||||
|
/**
|
||||||
|
* We DO NOT want this effect to trigger when derivedColumns changes as it will break functionality
|
||||||
|
* The exclusion from the effect dependencies is intentional and should not be modified
|
||||||
|
*/
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [wrapperRef, reorderable, resizable, interactiveTableUtils]);
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ConfigProvider renderEmpty={renderEmpty}>
|
||||||
|
<div ref={wrapperRef}>
|
||||||
|
<StyledTable
|
||||||
|
{...rest}
|
||||||
|
loading={{ spinning: loading ?? false, indicator: <Loading /> }}
|
||||||
|
hasData={hideData ? false : data}
|
||||||
|
rowSelection={selectionTypeValue ? rowSelection : undefined}
|
||||||
|
columns={derivedColumns}
|
||||||
|
dataSource={hideData ? [undefined] : data}
|
||||||
|
size={size}
|
||||||
|
sticky={sticky}
|
||||||
|
pagination={{
|
||||||
|
hideOnSinglePage: true,
|
||||||
|
pageSize,
|
||||||
|
pageSizeOptions,
|
||||||
|
onShowSizeChange: (page: number, size: number) => setPageSize(size),
|
||||||
|
}}
|
||||||
|
showSorterTooltip={false}
|
||||||
|
locale={mergedLocale}
|
||||||
|
theme={theme}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ConfigProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default Table;
|
|
@ -0,0 +1,100 @@
|
||||||
|
/**
|
||||||
|
* 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 { alphabeticalSort, numericalSort } from './sorters';
|
||||||
|
|
||||||
|
const rows = [
|
||||||
|
{
|
||||||
|
name: 'Deathstar Lamp',
|
||||||
|
category: 'Lamp',
|
||||||
|
cost: 75.99,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Desk Lamp',
|
||||||
|
category: 'Lamp',
|
||||||
|
cost: 15.99,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Bedside Lamp',
|
||||||
|
category: 'Lamp',
|
||||||
|
cost: 15.99,
|
||||||
|
},
|
||||||
|
{ name: 'Drafting Desk', category: 'Desk', cost: 125 },
|
||||||
|
{ name: 'Sit / Stand Desk', category: 'Desk', cost: 275.99 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* NOTE: Sorters for antd table use < 0, 0, > 0 for sorting
|
||||||
|
* -1 or less means the first item comes after the second item
|
||||||
|
* 0 means the items sort values is equivalent
|
||||||
|
* 1 or greater means the first item comes before the second item
|
||||||
|
*/
|
||||||
|
test('alphabeticalSort sorts correctly', () => {
|
||||||
|
expect(alphabeticalSort('name', rows[0], rows[1])).toBe(-1);
|
||||||
|
expect(alphabeticalSort('name', rows[1], rows[0])).toBe(1);
|
||||||
|
expect(alphabeticalSort('category', rows[1], rows[0])).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numericalSort sorts correctly', () => {
|
||||||
|
expect(numericalSort('cost', rows[1], rows[2])).toBe(0);
|
||||||
|
expect(numericalSort('cost', rows[1], rows[0])).toBeLessThan(0);
|
||||||
|
expect(numericalSort('cost', rows[4], rows[1])).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* We want to make sure our sorters do not throw runtime errors given bad inputs.
|
||||||
|
* Runtime Errors in a sorter will cause a catastrophic React lifecycle error and produce white screen of death
|
||||||
|
* In the case the sorter cannot perform the comparison it should return undefined and the next sort step will proceed without error
|
||||||
|
*/
|
||||||
|
test('alphabeticalSort bad inputs no errors', () => {
|
||||||
|
// @ts-ignore
|
||||||
|
expect(alphabeticalSort('name', null, null)).toBe(undefined);
|
||||||
|
// incorrect non-object values
|
||||||
|
// @ts-ignore
|
||||||
|
expect(alphabeticalSort('name', 3, [])).toBe(undefined);
|
||||||
|
// incorrect object values without specificed key
|
||||||
|
expect(alphabeticalSort('name', {}, {})).toBe(undefined);
|
||||||
|
// Object as value for name when it should be a string
|
||||||
|
expect(
|
||||||
|
alphabeticalSort(
|
||||||
|
'name',
|
||||||
|
{ name: { title: 'the name attribute should not be an object' } },
|
||||||
|
{ name: 'Doug' },
|
||||||
|
),
|
||||||
|
).toBe(undefined);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('numericalSort bad inputs no errors', () => {
|
||||||
|
// @ts-ignore
|
||||||
|
expect(numericalSort('name', undefined, undefined)).toBe(NaN);
|
||||||
|
// @ts-ignore
|
||||||
|
expect(numericalSort('name', null, null)).toBe(NaN);
|
||||||
|
// incorrect non-object values
|
||||||
|
// @ts-ignore
|
||||||
|
expect(numericalSort('name', 3, [])).toBe(NaN);
|
||||||
|
// incorrect object values without specified key
|
||||||
|
expect(numericalSort('name', {}, {})).toBe(NaN);
|
||||||
|
// Object as value for name when it should be a string
|
||||||
|
expect(
|
||||||
|
numericalSort(
|
||||||
|
'name',
|
||||||
|
{ name: { title: 'the name attribute should not be an object' } },
|
||||||
|
{ name: 'Doug' },
|
||||||
|
),
|
||||||
|
).toBe(NaN);
|
||||||
|
});
|
|
@ -0,0 +1,36 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param key The name of the row's attribute used to compare values for alphabetical sorting
|
||||||
|
* @param a First row object to compare
|
||||||
|
* @param b Second row object to compare
|
||||||
|
* @returns number
|
||||||
|
*/
|
||||||
|
export const alphabeticalSort = (key: string, a: object, b: object): number =>
|
||||||
|
a?.[key]?.localeCompare?.(b?.[key]);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param key The name of the row's attribute used to compare values for numerical sorting
|
||||||
|
* @param a First row object to compare
|
||||||
|
* @param b Second row object to compare
|
||||||
|
* @returns number
|
||||||
|
*/
|
||||||
|
export const numericalSort = (key: string, a: object, b: object): number =>
|
||||||
|
a?.[key] - b?.[key];
|
|
@ -0,0 +1,233 @@
|
||||||
|
/**
|
||||||
|
* 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 type { ColumnsType } from 'antd/es/table';
|
||||||
|
import { SUPERSET_TABLE_COLUMN } from 'src/components/Table';
|
||||||
|
import { withinRange } from './utils';
|
||||||
|
|
||||||
|
interface IInteractiveColumn extends HTMLElement {
|
||||||
|
mouseDown: boolean;
|
||||||
|
oldX: number;
|
||||||
|
oldWidth: number;
|
||||||
|
draggable: boolean;
|
||||||
|
}
|
||||||
|
export default class InteractiveTableUtils {
|
||||||
|
tableRef: HTMLTableElement | null;
|
||||||
|
|
||||||
|
columnRef: IInteractiveColumn | null;
|
||||||
|
|
||||||
|
setDerivedColumns: Function;
|
||||||
|
|
||||||
|
isDragging: boolean;
|
||||||
|
|
||||||
|
resizable: boolean;
|
||||||
|
|
||||||
|
reorderable: boolean;
|
||||||
|
|
||||||
|
derivedColumns: ColumnsType<any>;
|
||||||
|
|
||||||
|
RESIZE_INDICATOR_THRESHOLD: number;
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
tableRef: HTMLTableElement,
|
||||||
|
derivedColumns: ColumnsType<any>,
|
||||||
|
setDerivedColumns: Function,
|
||||||
|
) {
|
||||||
|
this.setDerivedColumns = setDerivedColumns;
|
||||||
|
this.tableRef = tableRef;
|
||||||
|
this.isDragging = false;
|
||||||
|
this.RESIZE_INDICATOR_THRESHOLD = 8;
|
||||||
|
this.resizable = false;
|
||||||
|
this.reorderable = false;
|
||||||
|
this.derivedColumns = [...derivedColumns];
|
||||||
|
document.addEventListener('mouseup', this.handleMouseup);
|
||||||
|
}
|
||||||
|
|
||||||
|
clearListeners = () => {
|
||||||
|
document.removeEventListener('mouseup', this.handleMouseup);
|
||||||
|
this.initializeResizableColumns(false, this.tableRef);
|
||||||
|
this.initializeDragDropColumns(false, this.tableRef);
|
||||||
|
};
|
||||||
|
|
||||||
|
setTableRef = (table: HTMLTableElement) => {
|
||||||
|
this.tableRef = table;
|
||||||
|
};
|
||||||
|
|
||||||
|
getColumnIndex = (): number => {
|
||||||
|
let index = -1;
|
||||||
|
const parent = this.columnRef?.parentNode;
|
||||||
|
if (parent) {
|
||||||
|
index = Array.prototype.indexOf.call(parent.children, this.columnRef);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
};
|
||||||
|
|
||||||
|
handleColumnDragStart = (ev: DragEvent): void => {
|
||||||
|
const target = ev?.currentTarget as IInteractiveColumn;
|
||||||
|
if (target) {
|
||||||
|
this.columnRef = target;
|
||||||
|
}
|
||||||
|
this.isDragging = true;
|
||||||
|
const index = this.getColumnIndex();
|
||||||
|
const columnData = this.derivedColumns[index];
|
||||||
|
const dragData = { index, columnData };
|
||||||
|
ev?.dataTransfer?.setData(SUPERSET_TABLE_COLUMN, JSON.stringify(dragData));
|
||||||
|
};
|
||||||
|
|
||||||
|
handleDragDrop = (ev: DragEvent): void => {
|
||||||
|
const data = ev.dataTransfer?.getData?.(SUPERSET_TABLE_COLUMN);
|
||||||
|
if (data) {
|
||||||
|
ev.preventDefault();
|
||||||
|
const parent = (ev.currentTarget as HTMLElement)
|
||||||
|
?.parentNode as HTMLElement;
|
||||||
|
const dropIndex = Array.prototype.indexOf.call(
|
||||||
|
parent.children,
|
||||||
|
ev.currentTarget,
|
||||||
|
);
|
||||||
|
const dragIndex = this.getColumnIndex();
|
||||||
|
const columnsCopy = [...this.derivedColumns];
|
||||||
|
const removedItem = columnsCopy.slice(dragIndex, dragIndex + 1);
|
||||||
|
columnsCopy.splice(dragIndex, 1);
|
||||||
|
columnsCopy.splice(dropIndex, 0, removedItem[0]);
|
||||||
|
this.derivedColumns = [...columnsCopy];
|
||||||
|
this.setDerivedColumns(columnsCopy);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
allowDrop = (ev: DragEvent): void => {
|
||||||
|
ev.preventDefault();
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseDown = (event: MouseEvent) => {
|
||||||
|
const target = event?.currentTarget as IInteractiveColumn;
|
||||||
|
if (target) {
|
||||||
|
this.columnRef = target;
|
||||||
|
if (
|
||||||
|
event &&
|
||||||
|
withinRange(
|
||||||
|
event.offsetX,
|
||||||
|
target.offsetWidth,
|
||||||
|
this.RESIZE_INDICATOR_THRESHOLD,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
target.mouseDown = true;
|
||||||
|
target.oldX = event.x;
|
||||||
|
target.oldWidth = target.offsetWidth;
|
||||||
|
target.draggable = false;
|
||||||
|
} else if (this.reorderable) {
|
||||||
|
target.draggable = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseMove = (event: MouseEvent) => {
|
||||||
|
if (this.resizable === true && !this.isDragging) {
|
||||||
|
const target = event.currentTarget as IInteractiveColumn;
|
||||||
|
if (
|
||||||
|
event &&
|
||||||
|
withinRange(
|
||||||
|
event.offsetX,
|
||||||
|
target.offsetWidth,
|
||||||
|
this.RESIZE_INDICATOR_THRESHOLD,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
target.style.cursor = 'col-resize';
|
||||||
|
} else {
|
||||||
|
target.style.cursor = 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
const column = this.columnRef;
|
||||||
|
if (column?.mouseDown) {
|
||||||
|
let width = column.oldWidth;
|
||||||
|
const diff = event.x - column.oldX;
|
||||||
|
if (column.oldWidth + (event.x - column.oldX) > 0) {
|
||||||
|
width = column.oldWidth + diff;
|
||||||
|
}
|
||||||
|
const colIndex = this.getColumnIndex();
|
||||||
|
if (!Number.isNaN(colIndex)) {
|
||||||
|
const columnDef = { ...this.derivedColumns[colIndex] };
|
||||||
|
columnDef.width = width;
|
||||||
|
this.derivedColumns[colIndex] = columnDef;
|
||||||
|
this.setDerivedColumns([...this.derivedColumns]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
handleMouseup = () => {
|
||||||
|
if (this.columnRef) {
|
||||||
|
this.columnRef.mouseDown = false;
|
||||||
|
this.columnRef.style.cursor = 'default';
|
||||||
|
this.columnRef.draggable = false;
|
||||||
|
}
|
||||||
|
this.isDragging = false;
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeResizableColumns = (
|
||||||
|
resizable = false,
|
||||||
|
table: HTMLTableElement | null,
|
||||||
|
) => {
|
||||||
|
this.tableRef = table;
|
||||||
|
const header: HTMLTableRowElement | undefined = this.tableRef?.rows?.[0];
|
||||||
|
if (header) {
|
||||||
|
const { cells } = header;
|
||||||
|
const len = cells.length;
|
||||||
|
for (let i = 0; i < len; i += 1) {
|
||||||
|
const cell = cells[i];
|
||||||
|
if (resizable === true) {
|
||||||
|
this.resizable = true;
|
||||||
|
cell.addEventListener('mousedown', this.handleMouseDown);
|
||||||
|
cell.addEventListener('mousemove', this.handleMouseMove, true);
|
||||||
|
} else {
|
||||||
|
this.resizable = false;
|
||||||
|
cell.removeEventListener('mousedown', this.handleMouseDown);
|
||||||
|
cell.removeEventListener('mousemove', this.handleMouseMove, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
initializeDragDropColumns = (
|
||||||
|
reorderable = false,
|
||||||
|
table: HTMLTableElement | null,
|
||||||
|
) => {
|
||||||
|
this.tableRef = table;
|
||||||
|
const header: HTMLTableRowElement | undefined = this.tableRef?.rows?.[0];
|
||||||
|
if (header) {
|
||||||
|
const { cells } = header;
|
||||||
|
const len = cells.length;
|
||||||
|
for (let i = 0; i < len; i += 1) {
|
||||||
|
const cell = cells[i];
|
||||||
|
if (reorderable === true) {
|
||||||
|
this.reorderable = true;
|
||||||
|
cell.addEventListener('mousedown', this.handleMouseDown);
|
||||||
|
cell.addEventListener('dragover', this.allowDrop);
|
||||||
|
cell.addEventListener('dragstart', this.handleColumnDragStart);
|
||||||
|
cell.addEventListener('drop', this.handleDragDrop);
|
||||||
|
} else {
|
||||||
|
this.reorderable = false;
|
||||||
|
cell.draggable = false;
|
||||||
|
cell.removeEventListener('mousedown', this.handleMouseDown);
|
||||||
|
cell.removeEventListener('dragover', this.allowDrop);
|
||||||
|
cell.removeEventListener('dragstart', this.handleColumnDragStart);
|
||||||
|
cell.removeEventListener('drop', this.handleDragDrop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
|
@ -0,0 +1,48 @@
|
||||||
|
/**
|
||||||
|
* 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 { withinRange } from './utils';
|
||||||
|
|
||||||
|
test('withinRange supported positive numbers', () => {
|
||||||
|
// Valid inputs within range
|
||||||
|
expect(withinRange(50, 60, 16)).toBeTruthy();
|
||||||
|
|
||||||
|
// Valid inputs outside of range
|
||||||
|
expect(withinRange(40, 60, 16)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('withinRange unsupported negative numbers', () => {
|
||||||
|
// Negative numbers not supported
|
||||||
|
expect(withinRange(65, 60, -16)).toBeFalsy();
|
||||||
|
expect(withinRange(-60, -65, 16)).toBeFalsy();
|
||||||
|
expect(withinRange(-60, -65, 16)).toBeFalsy();
|
||||||
|
expect(withinRange(-60, 65, 16)).toBeFalsy();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('withinRange invalid inputs', () => {
|
||||||
|
// Invalid inputs should return falsy and not throw an error
|
||||||
|
// We need ts-ignore here to be able to pass invalid values and pass linting
|
||||||
|
// @ts-ignore
|
||||||
|
expect(withinRange(null, 60, undefined)).toBeFalsy();
|
||||||
|
// @ts-ignore
|
||||||
|
expect(withinRange([], 'hello', {})).toBeFalsy();
|
||||||
|
// @ts-ignore
|
||||||
|
expect(withinRange([], undefined, {})).toBeFalsy();
|
||||||
|
// @ts-ignore
|
||||||
|
expect(withinRange([], 'hello', {})).toBeFalsy();
|
||||||
|
});
|
|
@ -0,0 +1,40 @@
|
||||||
|
/**
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Method to check if a number is within inclusive range between a maximum value minus a threshold
|
||||||
|
* Invalid non numeric inputs will not error, but will return false
|
||||||
|
*
|
||||||
|
* @param value number coordinate to determine if it is within bounds of the targetCoordinate - threshold. Must be positive and less than maximum.
|
||||||
|
* @param maximum number max value for the test range. Must be positive and greater than value
|
||||||
|
* @param threshold number values to determine a range from maximum - threshold. Must be positive and greater than zero.
|
||||||
|
* @returns boolean
|
||||||
|
*/
|
||||||
|
export const withinRange = (
|
||||||
|
value: number,
|
||||||
|
maximum: number,
|
||||||
|
threshold: number,
|
||||||
|
): boolean => {
|
||||||
|
let within = false;
|
||||||
|
const diff = maximum - value;
|
||||||
|
if (diff > 0 && diff <= threshold) {
|
||||||
|
within = true;
|
||||||
|
}
|
||||||
|
return within;
|
||||||
|
};
|
Binary file not shown.
After Width: | Height: | Size: 159 KiB |
|
@ -18,3 +18,4 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
declare module '*.svg';
|
declare module '*.svg';
|
||||||
|
declare module '*.gif';
|
||||||
|
|
|
@ -447,7 +447,7 @@ const config = {
|
||||||
type: 'asset/resource',
|
type: 'asset/resource',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
test: /\.(stories|story)\.mdx$/,
|
test: /\.mdx$/,
|
||||||
use: [
|
use: [
|
||||||
{
|
{
|
||||||
loader: 'babel-loader',
|
loader: 'babel-loader',
|
||||||
|
|
Loading…
Reference in New Issue