This is an automated email from the ASF dual-hosted git repository.
aafghahi pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/superset.git
The following commit(s) were added to refs/heads/master by this push:
new 736b53418a feat: create table component based on ant design Table
(#21520)
736b53418a is described below
commit 736b53418a3b3394dc967458d03d4c0ebcadabdd
Author: Eric Briscoe <[email protected]>
AuthorDate: Wed Nov 9 14:33:27 2022 -0800
feat: create table component based on ant design Table (#21520)
Co-authored-by: Lyndsi Kay Williams
<[email protected]>
Co-authored-by: Michael S. Molina
<[email protected]>
---
superset-frontend/.storybook/main.js | 3 +-
superset-frontend/.storybook/preview.jsx | 10 +-
superset-frontend/src/components/Button/index.tsx | 4 +-
.../src/components/DesignSystem.stories.mdx | 25 ++
.../src/components/Dropdown/index.tsx | 26 +-
.../src/components/Loading/Loading.stories.tsx | 4 +-
.../src/components/Loading/Loading.test.tsx | 4 +-
superset-frontend/src/components/Loading/index.tsx | 6 +-
...verview.stories.mdx => MetadataBar.stories.mdx} | 20 +-
.../components/MetadataBar/MetadataBar.stories.tsx | 10 +-
.../src/components/Table/Table.overview.mdx | 260 +++++++++++++
.../src/components/Table/Table.stories.tsx | 432 +++++++++++++++++++++
.../src/components/Table/Table.test.tsx | 80 ++++
.../ActionCell/ActionCell.overview.mdx | 69 ++++
.../ActionCell/ActionCell.stories.tsx} | 22 +-
.../cell-renderers/ActionCell/ActionCell.test.tsx | 50 +++
.../Table/cell-renderers/ActionCell/fixtures.ts | 47 +++
.../Table/cell-renderers/ActionCell/index.tsx | 145 +++++++
.../ButtonCell/ButtonCell.stories.tsx | 62 +++
.../cell-renderers/ButtonCell/ButtonCell.test.tsx} | 22 +-
.../Table/cell-renderers/ButtonCell/index.tsx} | 72 ++--
.../NumericCell/NumericCell.stories.tsx} | 29 +-
.../NumericCell/NumericCell.test.tsx | 49 +++
.../Table/cell-renderers/NumericCell/index.tsx | 418 ++++++++++++++++++++
.../Table/cell-renderers/fixtures.ts} | 13 +-
superset-frontend/src/components/Table/index.tsx | 326 ++++++++++++++++
.../src/components/Table/sorters.test.ts | 100 +++++
.../files.d.ts => components/Table/sorters.ts} | 18 +-
.../Table/utils/InteractiveTableUtils.ts | 233 +++++++++++
.../src/components/Table/utils/utils.test.ts | 48 +++
.../components/Table/utils/utils.ts} | 52 +--
superset-frontend/src/components/atomic-design.png | Bin 0 -> 163100 bytes
superset-frontend/src/types/files.d.ts | 1 +
superset-frontend/webpack.config.js | 2 +-
34 files changed, 2563 insertions(+), 99 deletions(-)
diff --git a/superset-frontend/.storybook/main.js
b/superset-frontend/.storybook/main.js
index 8a004ba3e2..b8f15b569f 100644
--- a/superset-frontend/.storybook/main.js
+++ b/superset-frontend/.storybook/main.js
@@ -24,7 +24,8 @@ module.exports = {
builder: 'webpack5',
},
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: [
'@storybook/addon-essentials',
diff --git a/superset-frontend/.storybook/preview.jsx
b/superset-frontend/.storybook/preview.jsx
index d98a55506e..fa0c908873 100644
--- a/superset-frontend/.storybook/preview.jsx
+++ b/superset-frontend/.storybook/preview.jsx
@@ -68,7 +68,15 @@ addParameters({
['Controls', 'Display', 'Feedback', 'Input', '*'],
['Overview', 'Examples', '*'],
'Design System',
- ['Foundations', 'Components', 'Patterns', '*'],
+ [
+ 'Introduction',
+ 'Foundations',
+ 'Components',
+ ['Overview', 'Examples', '*'],
+ 'Patterns',
+ '*',
+ ],
+ ['Overview', 'Examples', '*'],
'*',
],
},
diff --git a/superset-frontend/src/components/Button/index.tsx
b/superset-frontend/src/components/Button/index.tsx
index b4152ea98d..05a1a3ad79 100644
--- a/superset-frontend/src/components/Button/index.tsx
+++ b/superset-frontend/src/components/Button/index.tsx
@@ -39,11 +39,13 @@ export type ButtonStyle =
| 'link'
| 'dashed';
+export type ButtonSize = 'default' | 'small' | 'xsmall';
+
export type ButtonProps = Omit<AntdButtonProps, 'css'> &
Pick<TooltipProps, 'placement'> & {
tooltip?: string;
className?: string;
- buttonSize?: 'default' | 'small' | 'xsmall';
+ buttonSize?: ButtonSize;
buttonStyle?: ButtonStyle;
cta?: boolean;
showMarginRight?: boolean;
diff --git a/superset-frontend/src/components/DesignSystem.stories.mdx
b/superset-frontend/src/components/DesignSystem.stories.mdx
new file mode 100644
index 0000000000..e00612c5be
--- /dev/null
+++ b/superset-frontend/src/components/DesignSystem.stories.mdx
@@ -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"
+/>
diff --git a/superset-frontend/src/components/Dropdown/index.tsx
b/superset-frontend/src/components/Dropdown/index.tsx
index bd01aabb4d..c40f479579 100644
--- a/superset-frontend/src/components/Dropdown/index.tsx
+++ b/superset-frontend/src/components/Dropdown/index.tsx
@@ -20,6 +20,7 @@ import React, { RefObject } from 'react';
import { AntdDropdown } from 'src/components';
import { DropDownProps } from 'antd/lib/dropdown';
import { styled } from '@superset-ui/core';
+import Icons from 'src/components/Icons';
const MenuDots = styled.div`
width: ${({ theme }) => theme.gridUnit * 0.75}px;
@@ -66,14 +67,35 @@ const MenuDotsWrapper = styled.div`
padding-left: ${({ theme }) => theme.gridUnit}px;
`;
+export enum IconOrientation {
+ VERTICAL = 'vertical',
+ HORIZONTAL = 'horizontal',
+}
export interface DropdownProps extends DropDownProps {
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}>
<MenuDotsWrapper data-test="dropdown-trigger">
- <MenuDots />
+ {RenderIcon(iconOrientation)}
</MenuDotsWrapper>
</AntdDropdown>
);
diff --git a/superset-frontend/src/components/Loading/Loading.stories.tsx
b/superset-frontend/src/components/Loading/Loading.stories.tsx
index 9f079848b8..0c80c6f0ff 100644
--- a/superset-frontend/src/components/Loading/Loading.stories.tsx
+++ b/superset-frontend/src/components/Loading/Loading.stories.tsx
@@ -40,7 +40,7 @@ export const LoadingGallery = () => (
}}
>
<h4>{position}</h4>
- <Loading position={position} image="/src/assets/images/loading.gif" />
+ <Loading position={position} />
</div>
))}
</>
@@ -71,7 +71,7 @@ InteractiveLoading.story = {
};
InteractiveLoading.args = {
- image: '/src/assets/images/loading.gif',
+ image: '',
className: '',
};
diff --git a/superset-frontend/src/components/Loading/Loading.test.tsx
b/superset-frontend/src/components/Loading/Loading.test.tsx
index d6ea8581c5..7325c9304b 100644
--- a/superset-frontend/src/components/Loading/Loading.test.tsx
+++ b/superset-frontend/src/components/Loading/Loading.test.tsx
@@ -26,11 +26,9 @@ test('Rerendering correctly with default props', () => {
render(<Loading />);
const loading = screen.getByRole('status');
const classNames = loading.getAttribute('class')?.split(' ');
- const imagePath = loading.getAttribute('src');
const ariaLive = loading.getAttribute('aria-live');
const ariaLabel = loading.getAttribute('aria-label');
expect(loading).toBeInTheDocument();
- expect(imagePath).toBe('/static/assets/images/loading.gif');
expect(classNames).toContain('floating');
expect(classNames).toContain('loading');
expect(ariaLive).toContain('polite');
@@ -56,7 +54,7 @@ test('support for extra classes', () => {
expect(classNames).toContain('extra-class');
});
-test('Diferent image path', () => {
+test('Different image path', () => {
render(<Loading image="/src/assets/images/loading.gif" />);
const loading = screen.getByRole('status');
const imagePath = loading.getAttribute('src');
diff --git a/superset-frontend/src/components/Loading/index.tsx
b/superset-frontend/src/components/Loading/index.tsx
index 6ba6fb45c5..97cd553ad5 100644
--- a/superset-frontend/src/components/Loading/index.tsx
+++ b/superset-frontend/src/components/Loading/index.tsx
@@ -20,6 +20,7 @@
import React from 'react';
import { styled } from '@superset-ui/core';
import cls from 'classnames';
+import Loader from 'src/assets/images/loading.gif';
export type PositionOption =
| 'floating'
@@ -35,6 +36,7 @@ export interface Props {
const LoaderImg = styled.img`
z-index: 99;
width: 50px;
+ height: unset;
position: relative;
margin: 10px;
&.inline {
@@ -57,14 +59,14 @@ const LoaderImg = styled.img`
`;
export default function Loading({
position = 'floating',
- image = '/static/assets/images/loading.gif',
+ image,
className,
}: Props) {
return (
<LoaderImg
className={cls('loading', position, className)}
alt="Loading..."
- src={image}
+ src={image || Loader}
role="status"
aria-live="polite"
aria-label="Loading"
diff --git a/superset-frontend/src/components/MetadataBar/Overview.stories.mdx
b/superset-frontend/src/components/MetadataBar/MetadataBar.stories.mdx
similarity index 88%
rename from superset-frontend/src/components/MetadataBar/Overview.stories.mdx
rename to superset-frontend/src/components/MetadataBar/MetadataBar.stories.mdx
index b9ba919e41..d85c2c6226 100644
--- a/superset-frontend/src/components/MetadataBar/Overview.stories.mdx
+++ b/superset-frontend/src/components/MetadataBar/MetadataBar.stories.mdx
@@ -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
to know the details of the chart
- Display the database's metadata in a drill to detail modal to help the user
understand what data they are looking
at while accessing the feature in the dashboard
-# Variations
+## 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 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.
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:
<Source
diff --git
a/superset-frontend/src/components/MetadataBar/MetadataBar.stories.tsx
b/superset-frontend/src/components/MetadataBar/MetadataBar.stories.tsx
index 8501397b58..1b5fb33d96 100644
--- a/superset-frontend/src/components/MetadataBar/MetadataBar.stories.tsx
+++ b/superset-frontend/src/components/MetadataBar/MetadataBar.stories.tsx
@@ -22,13 +22,13 @@ import { useResizeDetector } from 'react-resize-detector';
import MetadataBar, { MetadataBarProps, MetadataType } from '.';
export default {
- title: 'MetadataBar',
+ title: 'Design System/Components/MetadataBar/Examples',
component: MetadataBar,
};
const A_WEEK_AGO = 'a week ago';
-export const Component = ({
+export const Basic = ({
items,
onClick,
}: MetadataBarProps & {
@@ -61,7 +61,7 @@ export const Component = ({
);
};
-Component.story = {
+Basic.story = {
parameters: {
knobs: {
disable: true,
@@ -69,7 +69,7 @@ Component.story = {
},
};
-Component.args = {
+Basic.args = {
items: [
{
type: MetadataType.SQL,
@@ -99,7 +99,7 @@ Component.args = {
],
};
-Component.argTypes = {
+Basic.argTypes = {
onClick: {
action: 'onClick',
table: {
diff --git a/superset-frontend/src/components/Table/Table.overview.mdx
b/superset-frontend/src/components/Table/Table.overview.mdx
new file mode 100644
index 0000000000..8341db879f
--- /dev/null
+++ b/superset-frontend/src/components/Table/Table.overview.mdx
@@ -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" />
diff --git a/superset-frontend/src/components/Table/Table.stories.tsx
b/superset-frontend/src/components/Table/Table.stories.tsx
new file mode 100644
index 0000000000..90ee3448c6
--- /dev/null
+++ b/superset-frontend/src/components/Table/Table.stories.tsx
@@ -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,
+};
diff --git a/superset-frontend/src/components/Table/Table.test.tsx
b/superset-frontend/src/components/Table/Table.test.tsx
new file mode 100644
index 0000000000..eded7efeb9
--- /dev/null
+++ b/superset-frontend/src/components/Table/Table.test.tsx
@@ -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();
+ });
+});
diff --git
a/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx
b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx
new file mode 100644
index 0000000000..09e1b5ed6b
--- /dev/null
+++
b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.overview.mdx
@@ -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} />,
+ }
+]
+```
diff --git a/superset-frontend/src/types/files.d.ts
b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx
similarity index 52%
copy from superset-frontend/src/types/files.d.ts
copy to
superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx
index c694d13cfb..d51dbcc559 100644
--- a/superset-frontend/src/types/files.d.ts
+++
b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.stories.tsx
@@ -4,17 +4,33 @@
* 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
+ * 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
+ * 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';
-declare module '*.svg';
+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,
+};
diff --git
a/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx
b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx
new file mode 100644
index 0000000000..5da7453aa9
--- /dev/null
+++
b/superset-frontend/src/components/Table/cell-renderers/ActionCell/ActionCell.test.tsx
@@ -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());
+});
diff --git
a/superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts
b/superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts
new file mode 100644
index 0000000000..a0569b6990
--- /dev/null
+++
b/superset-frontend/src/components/Table/cell-renderers/ActionCell/fixtures.ts
@@ -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,
+};
diff --git
a/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx
b/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx
new file mode 100644
index 0000000000..b6ba57420c
--- /dev/null
+++ b/superset-frontend/src/components/Table/cell-renderers/ActionCell/index.tsx
@@ -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;
diff --git
a/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx
b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx
new file mode 100644
index 0000000000..707e758eed
--- /dev/null
+++
b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.stories.tsx
@@ -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,
+ },
+};
diff --git a/superset-frontend/src/types/files.d.ts
b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx
similarity index 57%
copy from superset-frontend/src/types/files.d.ts
copy to
superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx
index c694d13cfb..dbdb8fd4f2 100644
--- a/superset-frontend/src/types/files.d.ts
+++
b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/ButtonCell.test.tsx
@@ -16,5 +16,25 @@
* 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';
-declare module '*.svg';
+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();
+});
diff --git a/superset-frontend/.storybook/main.js
b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx
similarity index 50%
copy from superset-frontend/.storybook/main.js
copy to
superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx
index 8a004ba3e2..c5739a386c 100644
--- a/superset-frontend/.storybook/main.js
+++ b/superset-frontend/src/components/Table/cell-renderers/ButtonCell/index.tsx
@@ -16,37 +16,43 @@
* specific language governing permissions and limitations
* under the License.
*/
-// Superset's webpack.config.js
-const customConfig = require('../webpack.config.js');
+import React from 'react';
+import Button, { ButtonStyle, ButtonSize } from 'src/components/Button';
-module.exports = {
- core: {
- builder: 'webpack5',
- },
- stories: [
- '../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx|mdx)',
- ],
- addons: [
- '@storybook/addon-essentials',
- '@storybook/addon-links',
- 'storybook-addon-jsx',
- '@storybook/addon-knobs',
- 'storybook-addon-paddings',
- ],
- staticDirs: ['../src/assets/images'],
- webpackFinal: config => ({
- ...config,
- module: {
- ...config.module,
- rules: customConfig.module.rules,
- },
- resolve: {
- ...config.resolve,
- ...customConfig.resolve,
- },
- plugins: [...config.plugins, ...customConfig.plugins],
- }),
- typescript: {
- reactDocgen: 'react-docgen-typescript',
- },
-};
+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;
diff --git a/superset-frontend/src/types/files.d.ts
b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx
similarity index 53%
copy from superset-frontend/src/types/files.d.ts
copy to
superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx
index c694d13cfb..bb0b52fe62 100644
--- a/superset-frontend/src/types/files.d.ts
+++
b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.stories.tsx
@@ -16,5 +16,32 @@
* 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';
-declare module '*.svg';
+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,
+ },
+};
diff --git
a/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx
b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx
new file mode 100644
index 0000000000..b76a5bef65
--- /dev/null
+++
b/superset-frontend/src/components/Table/cell-renderers/NumericCell/NumericCell.test.tsx
@@ -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();
+});
diff --git
a/superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx
b/superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx
new file mode 100644
index 0000000000..5e6d61aa47
--- /dev/null
+++
b/superset-frontend/src/components/Table/cell-renderers/NumericCell/index.tsx
@@ -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;
diff --git a/superset-frontend/src/types/files.d.ts
b/superset-frontend/src/components/Table/cell-renderers/fixtures.ts
similarity index 73%
copy from superset-frontend/src/types/files.d.ts
copy to superset-frontend/src/components/Table/cell-renderers/fixtures.ts
index c694d13cfb..9b2070b035 100644
--- a/superset-frontend/src/types/files.d.ts
+++ b/superset-frontend/src/components/Table/cell-renderers/fixtures.ts
@@ -4,17 +4,22 @@
* 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
+ * 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
+ * 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.
*/
-
-declare module '*.svg';
+export const exampleRow = {
+ key: 1,
+ buttonCell: 'Click Me',
+ textCell: 'Some text',
+ euroCell: 45.5,
+ dollarCell: 45.5,
+};
diff --git a/superset-frontend/src/components/Table/index.tsx
b/superset-frontend/src/components/Table/index.tsx
new file mode 100644
index 0000000000..d5f449c752
--- /dev/null
+++ b/superset-frontend/src/components/Table/index.tsx
@@ -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;
diff --git a/superset-frontend/src/components/Table/sorters.test.ts
b/superset-frontend/src/components/Table/sorters.test.ts
new file mode 100644
index 0000000000..80bc0a20c4
--- /dev/null
+++ b/superset-frontend/src/components/Table/sorters.test.ts
@@ -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);
+});
diff --git a/superset-frontend/src/types/files.d.ts
b/superset-frontend/src/components/Table/sorters.ts
similarity index 56%
copy from superset-frontend/src/types/files.d.ts
copy to superset-frontend/src/components/Table/sorters.ts
index c694d13cfb..3f06071aac 100644
--- a/superset-frontend/src/types/files.d.ts
+++ b/superset-frontend/src/components/Table/sorters.ts
@@ -17,4 +17,20 @@
* under the License.
*/
-declare module '*.svg';
+/**
+ * @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];
diff --git
a/superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts
b/superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts
new file mode 100644
index 0000000000..94977413e2
--- /dev/null
+++ b/superset-frontend/src/components/Table/utils/InteractiveTableUtils.ts
@@ -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);
+ }
+ }
+ }
+ };
+}
diff --git a/superset-frontend/src/components/Table/utils/utils.test.ts
b/superset-frontend/src/components/Table/utils/utils.test.ts
new file mode 100644
index 0000000000..eff50f1580
--- /dev/null
+++ b/superset-frontend/src/components/Table/utils/utils.test.ts
@@ -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();
+});
diff --git a/superset-frontend/.storybook/main.js
b/superset-frontend/src/components/Table/utils/utils.ts
similarity index 50%
copy from superset-frontend/.storybook/main.js
copy to superset-frontend/src/components/Table/utils/utils.ts
index 8a004ba3e2..5b4e4d13ba 100644
--- a/superset-frontend/.storybook/main.js
+++ b/superset-frontend/src/components/Table/utils/utils.ts
@@ -16,37 +16,25 @@
* specific language governing permissions and limitations
* under the License.
*/
-// Superset's webpack.config.js
-const customConfig = require('../webpack.config.js');
-module.exports = {
- core: {
- builder: 'webpack5',
- },
- stories: [
- '../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx|mdx)',
- ],
- addons: [
- '@storybook/addon-essentials',
- '@storybook/addon-links',
- 'storybook-addon-jsx',
- '@storybook/addon-knobs',
- 'storybook-addon-paddings',
- ],
- staticDirs: ['../src/assets/images'],
- webpackFinal: config => ({
- ...config,
- module: {
- ...config.module,
- rules: customConfig.module.rules,
- },
- resolve: {
- ...config.resolve,
- ...customConfig.resolve,
- },
- plugins: [...config.plugins, ...customConfig.plugins],
- }),
- typescript: {
- reactDocgen: 'react-docgen-typescript',
- },
+/**
+ * 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;
};
diff --git a/superset-frontend/src/components/atomic-design.png
b/superset-frontend/src/components/atomic-design.png
new file mode 100644
index 0000000000..e44c5f34a5
Binary files /dev/null and b/superset-frontend/src/components/atomic-design.png
differ
diff --git a/superset-frontend/src/types/files.d.ts
b/superset-frontend/src/types/files.d.ts
index c694d13cfb..c4f304b57f 100644
--- a/superset-frontend/src/types/files.d.ts
+++ b/superset-frontend/src/types/files.d.ts
@@ -18,3 +18,4 @@
*/
declare module '*.svg';
+declare module '*.gif';
diff --git a/superset-frontend/webpack.config.js
b/superset-frontend/webpack.config.js
index 10284097e8..9994b1dd79 100644
--- a/superset-frontend/webpack.config.js
+++ b/superset-frontend/webpack.config.js
@@ -447,7 +447,7 @@ const config = {
type: 'asset/resource',
},
{
- test: /\.(stories|story)\.mdx$/,
+ test: /\.mdx$/,
use: [
{
loader: 'babel-loader',