feat: Integrate ant d table component into DatasetPanel (#21948)

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:
Eric Briscoe 2022-11-10 16:02:51 -08:00 committed by GitHub
parent 35e0e5bfe6
commit defe5c8ba7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
15 changed files with 771 additions and 107 deletions

View File

@ -24,8 +24,8 @@ module.exports = {
builder: 'webpack5',
},
stories: [
'../src/@(components|common|filters|explore)/**/*.stories.@(tsx|jsx)',
'../src/@(components|common|filters|explore)/**/*.*.@(mdx)',
'../src/@(components|common|filters|explore|views)/**/*.stories.@(tsx|jsx)',
'../src/@(components|common|filters|explore|views)/**/*.*.@(mdx)',
],
addons: [
'@storybook/addon-essentials',

View File

@ -0,0 +1,22 @@
<!--
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.
-->
<svg width="152" height="152" viewBox="0 0 152 152" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M143.5 1H8.5C4.35786 1 1 4.35786 1 8.5V143.5C1 147.642 4.35786 151 8.5 151H143.5C147.642 151 151 147.642 151 143.5V8.5C151 4.35786 147.642 1 143.5 1ZM68.5 136H16V106H68.5V136ZM68.5 91H16V61H68.5V91ZM136 136H83.5V106H136V136ZM136 91H83.5V61H136V91ZM136 46H16V16H136V46Z" fill="#F7F7F7"/>
<path d="M68.5 136V136.5H69V136H68.5ZM16 136H15.5V136.5H16V136ZM16 106V105.5H15.5V106H16ZM68.5 106H69V105.5H68.5V106ZM68.5 91V91.5H69V91H68.5ZM16 91H15.5V91.5H16V91ZM16 61V60.5H15.5V61H16ZM68.5 61H69V60.5H68.5V61ZM136 136V136.5H136.5V136H136ZM83.5 136H83V136.5H83.5V136ZM83.5 106V105.5H83V106H83.5ZM136 106H136.5V105.5H136V106ZM136 91V91.5H136.5V91H136ZM83.5 91H83V91.5H83.5V91ZM83.5 61V60.5H83V61H83.5ZM136 61H136.5V60.5H136V61ZM136 46V46.5H136.5V46H136ZM16 46H15.5V46.5H16V46ZM16 16V15.5H15.5V16H16ZM136 16H136.5V15.5H136V16ZM143.5 0.5H8.5V1.5H143.5V0.5ZM8.5 0.5C4.08172 0.5 0.5 4.08172 0.5 8.5H1.5C1.5 4.63401 4.63401 1.5 8.5 1.5V0.5ZM0.5 8.5V143.5H1.5V8.5H0.5ZM0.5 143.5C0.5 147.918 4.08172 151.5 8.5 151.5V150.5C4.63401 150.5 1.5 147.366 1.5 143.5H0.5ZM8.5 151.5H143.5V150.5H8.5V151.5ZM143.5 151.5C147.918 151.5 151.5 147.918 151.5 143.5H150.5C150.5 147.366 147.366 150.5 143.5 150.5V151.5ZM151.5 143.5V8.5H150.5V143.5H151.5ZM151.5 8.5C151.5 4.08172 147.918 0.5 143.5 0.5V1.5C147.366 1.5 150.5 4.63401 150.5 8.5H151.5ZM68.5 135.5H16V136.5H68.5V135.5ZM16.5 136V106H15.5V136H16.5ZM16 106.5H68.5V105.5H16V106.5ZM68 106V136H69V106H68ZM68.5 90.5H16V91.5H68.5V90.5ZM16.5 91V61H15.5V91H16.5ZM16 61.5H68.5V60.5H16V61.5ZM68 61V91H69V61H68ZM136 135.5H83.5V136.5H136V135.5ZM84 136V106H83V136H84ZM83.5 106.5H136V105.5H83.5V106.5ZM135.5 106V136H136.5V106H135.5ZM136 90.5H83.5V91.5H136V90.5ZM84 91V61H83V91H84ZM83.5 61.5H136V60.5H83.5V61.5ZM135.5 61V91H136.5V61H135.5ZM136 45.5H16V46.5H136V45.5ZM16.5 46V16H15.5V46H16.5ZM16 16.5H136V15.5H16V16.5ZM135.5 16V46H136.5V16H135.5Z" fill="#E0E0E0"/>
</svg>

After

Width:  |  Height:  |  Size: 2.7 KiB

View File

@ -31,7 +31,7 @@ export enum EmptyStateSize {
export interface EmptyStateSmallProps {
title: ReactNode;
description?: ReactNode;
image: ReactNode;
image?: ReactNode;
}
export interface EmptyStateProps extends EmptyStateSmallProps {
@ -156,7 +156,7 @@ export const EmptyStateBig = ({
className,
}: EmptyStateProps) => (
<EmptyStateContainer className={className}>
<ImageContainer image={image} size={EmptyStateSize.Big} />
{image && <ImageContainer image={image} size={EmptyStateSize.Big} />}
<TextContainer
css={(theme: SupersetTheme) =>
css`
@ -187,7 +187,7 @@ export const EmptyStateMedium = ({
buttonText,
}: EmptyStateProps) => (
<EmptyStateContainer>
<ImageContainer image={image} size={EmptyStateSize.Medium} />
{image && <ImageContainer image={image} size={EmptyStateSize.Medium} />}
<TextContainer
css={(theme: SupersetTheme) =>
css`
@ -216,7 +216,7 @@ export const EmptyStateSmall = ({
description,
}: EmptyStateSmallProps) => (
<EmptyStateContainer>
<ImageContainer image={image} size={EmptyStateSize.Small} />
{image && <ImageContainer image={image} size={EmptyStateSize.Small} />}
<TextContainer
css={(theme: SupersetTheme) =>
css`

View File

@ -0,0 +1,44 @@
/**
* 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 { supersetTheme, ThemeProvider } from '@superset-ui/core';
import DatasetPanel from './DatasetPanel';
import { exampleColumns } from './fixtures';
export default {
title: 'Superset App/views/CRUD/data/dataset/DatasetPanel',
component: DatasetPanel,
} as ComponentMeta<typeof DatasetPanel>;
export const Basic: ComponentStory<typeof DatasetPanel> = args => (
<ThemeProvider theme={supersetTheme}>
<div style={{ height: '350px' }}>
<DatasetPanel {...args} />
</div>
</ThemeProvider>
);
Basic.args = {
tableName: 'example_table',
loading: false,
hasError: false,
columnList: exampleColumns,
};

View File

@ -18,24 +18,124 @@
*/
import React from 'react';
import { render, screen } from 'spec/helpers/testing-library';
import DatasetPanel from 'src/views/CRUD/data/dataset/AddDataset/DatasetPanel';
import DatasetPanel, {
REFRESHING,
ALT_LOADING,
tableColumnDefinition,
COLUMN_TITLE,
} from './DatasetPanel';
import { exampleColumns } from './fixtures';
import {
SELECT_MESSAGE,
CREATE_MESSAGE,
VIEW_DATASET_MESSAGE,
SELECT_TABLE_TITLE,
NO_COLUMNS_TITLE,
NO_COLUMNS_DESCRIPTION,
ERROR_TITLE,
ERROR_DESCRIPTION,
} from './MessageContent';
jest.mock(
'src/components/Icons/Icon',
() =>
({ fileName }: { fileName: string }) =>
<span role="img" aria-label={fileName.replace('_', '-')} />,
);
describe('DatasetPanel', () => {
it('renders a blank state DatasetPanel', () => {
render(<DatasetPanel />);
render(<DatasetPanel hasError={false} columnList={[]} loading={false} />);
const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
const blankDatasetTitle = screen.getByText(/select dataset source/i);
const blankDatasetDescription = screen.getByText(
/datasets can be created from database tables or sql queries\. select a database table to the left or to open sql lab\. from there you can save the query as a dataset\./i,
);
const sqlLabLink = screen.getByRole('button', {
name: /create dataset from sql query/i,
});
expect(blankDatasetImg).toBeVisible();
const blankDatasetTitle = screen.getByText(SELECT_TABLE_TITLE);
expect(blankDatasetTitle).toBeVisible();
expect(blankDatasetDescription).toBeVisible();
const blankDatasetDescription1 = screen.getByText(SELECT_MESSAGE, {
exact: false,
});
expect(blankDatasetDescription1).toBeVisible();
const blankDatasetDescription2 = screen.getByText(VIEW_DATASET_MESSAGE, {
exact: false,
});
expect(blankDatasetDescription2).toBeVisible();
const sqlLabLink = screen.getByRole('button', {
name: CREATE_MESSAGE,
});
expect(sqlLabLink).toBeVisible();
});
it('renders a no columns screen', () => {
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading={false}
/>,
);
const blankDatasetImg = screen.getByRole('img', { name: /empty/i });
expect(blankDatasetImg).toBeVisible();
const noColumnsTitle = screen.getByText(NO_COLUMNS_TITLE);
expect(noColumnsTitle).toBeVisible();
const noColumnsDescription = screen.getByText(NO_COLUMNS_DESCRIPTION);
expect(noColumnsDescription).toBeVisible();
});
it('renders a loading screen', () => {
render(
<DatasetPanel
tableName="Name"
hasError={false}
columnList={[]}
loading
/>,
);
const blankDatasetImg = screen.getByAltText(ALT_LOADING);
expect(blankDatasetImg).toBeVisible();
const blankDatasetTitle = screen.getByText(REFRESHING);
expect(blankDatasetTitle).toBeVisible();
});
it('renders an error screen', () => {
render(
<DatasetPanel
tableName="Name"
hasError
columnList={[]}
loading={false}
/>,
);
const errorTitle = screen.getByText(ERROR_TITLE);
expect(errorTitle).toBeVisible();
const errorDescription = screen.getByText(ERROR_DESCRIPTION);
expect(errorDescription).toBeVisible();
});
it('renders a table with columns displayed', async () => {
const tableName = 'example_name';
render(
<DatasetPanel
tableName={tableName}
hasError={false}
columnList={exampleColumns}
loading={false}
/>,
);
expect(await screen.findByText(tableName)).toBeVisible();
expect(screen.getByText(COLUMN_TITLE)).toBeVisible();
expect(
screen.getByText(tableColumnDefinition[0].title as string),
).toBeInTheDocument();
expect(
screen.getByText(tableColumnDefinition[1].title as string),
).toBeInTheDocument();
exampleColumns.forEach(row => {
expect(screen.getByText(row.name)).toBeInTheDocument();
expect(screen.getByText(row.type)).toBeInTheDocument();
});
});
});

View File

@ -0,0 +1,237 @@
/**
* 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 { supersetTheme, t, styled } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import Table, { ColumnsType, TableSize } from 'src/components/Table';
import { alphabeticalSort } from 'src/components/Table/sorters';
// @ts-ignore
import LOADING_GIF from 'src/assets/images/loading.gif';
import { ITableColumn } from './types';
import MessageContent from './MessageContent';
/**
* Enum defining CSS position options
*/
enum EPosition {
ABSOLUTE = 'absolute',
RELATIVE = 'relative',
}
/**
* Interface for StyledHeader
*/
interface StyledHeaderProps {
/**
* Determine the CSS positioning type
* Vertical centering of loader, No columns screen, and select table screen
* gets offset when the header position is relative and needs to be absolute, but table
* needs this positioned relative to render correctly
*/
position: EPosition;
}
const LOADER_WIDTH = 200;
const SPINNER_WIDTH = 120;
const HALF = 0.5;
const MARGIN_MULTIPLIER = 3;
const StyledHeader = styled.div<StyledHeaderProps>`
position: ${(props: StyledHeaderProps) => props.position};
margin: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px;
margin-top: ${({ theme }) => theme.gridUnit * (MARGIN_MULTIPLIER + 1)}px;
font-size: ${({ theme }) => theme.gridUnit * 6}px;
font-weight: ${({ theme }) => theme.typography.weights.medium};
padding-bottom: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
.anticon:first-of-type {
margin-right: ${({ theme }) => theme.gridUnit * (MARGIN_MULTIPLIER + 1)}px;
}
.anticon:nth-of-type(2) {
margin-left: ${({ theme }) => theme.gridUnit * (MARGIN_MULTIPLIER + 1)}px;
}
`;
const StyledTitle = styled.div`
margin-left: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px;
margin-bottom: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px;
font-weight: ${({ theme }) => theme.typography.weights.bold};
`;
const LoaderContainer = styled.div`
padding: ${({ theme }) => theme.gridUnit * 8}px
${({ theme }) => theme.gridUnit * 6}px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
`;
const StyledLoader = styled.div`
max-width: 50%;
width: ${LOADER_WIDTH}px;
img {
width: ${SPINNER_WIDTH}px;
margin-left: ${(LOADER_WIDTH - SPINNER_WIDTH) * HALF}px;
}
div {
width: 100%;
margin-top: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px;
text-align: center;
font-weight: ${({ theme }) => theme.typography.weights.normal};
font-size: ${({ theme }) => theme.typography.sizes.l}px;
color: ${({ theme }) => theme.colors.grayscale.light1};
}
`;
const TableContainer = styled.div`
position: relative;
margin: ${({ theme }) => theme.gridUnit * MARGIN_MULTIPLIER}px;
overflow: scroll;
height: calc(100% - ${({ theme }) => theme.gridUnit * 36}px);
`;
const StyledTable = styled(Table)`
position: absolute;
left: 0;
top: 0;
bottom: 0;
right: 0;
`;
export const REFRESHING = t('Refreshing columns');
export const COLUMN_TITLE = t('Table columns');
export const ALT_LOADING = t('Loading');
const pageSizeOptions = ['5', '10', '15', '25'];
// Define the columns for Table instance
export const tableColumnDefinition: ColumnsType<ITableColumn> = [
{
title: 'Column Name',
dataIndex: 'name',
key: 'name',
sorter: (a: ITableColumn, b: ITableColumn) =>
alphabeticalSort('name', a, b),
},
{
title: 'Datatype',
dataIndex: 'type',
key: 'type',
width: '100px',
sorter: (a: ITableColumn, b: ITableColumn) =>
alphabeticalSort('type', a, b),
},
];
/**
* Props interface for DatasetPanel
*/
export interface IDatasetPanelProps {
/**
* Name of the database table
*/
tableName?: string | null;
/**
* Array of ITableColumn instances with name and type attributes
*/
columnList: ITableColumn[];
/**
* Boolean indicating if there is an error state
*/
hasError: boolean;
/**
* Boolean indicating if the component is in a loading state
*/
loading: boolean;
}
const DatasetPanel = ({
tableName,
columnList,
loading,
hasError,
}: IDatasetPanelProps) => {
const hasColumns = columnList?.length > 0 ?? false;
let component;
if (loading) {
component = (
<LoaderContainer>
<StyledLoader>
<img alt={ALT_LOADING} src={LOADING_GIF} />
<div>{REFRESHING}</div>
</StyledLoader>
</LoaderContainer>
);
} else if (tableName && hasColumns && !hasError) {
component = (
<>
<StyledTitle>{COLUMN_TITLE}</StyledTitle>
<TableContainer>
<StyledTable
loading={loading}
size={TableSize.SMALL}
columns={tableColumnDefinition}
data={columnList}
pageSizeOptions={pageSizeOptions}
defaultPageSize={10}
/>
</TableContainer>
</>
);
} else {
component = (
<MessageContent
hasColumns={hasColumns}
hasError={hasError}
tableName={tableName}
/>
);
}
return (
<>
{tableName && (
<StyledHeader
position={
!loading && hasColumns ? EPosition.RELATIVE : EPosition.ABSOLUTE
}
title={tableName || ''}
>
{tableName && (
<Icons.Table iconColor={supersetTheme.colors.grayscale.base} />
)}
{tableName}
</StyledHeader>
)}
{component}
</>
);
};
export default DatasetPanel;

View File

@ -0,0 +1,107 @@
/**
* 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 { t, styled } from '@superset-ui/core';
import { EmptyStateBig } from 'src/components/EmptyState';
const StyledContainer = styled.div`
padding: ${({ theme }) => theme.gridUnit * 8}px
${({ theme }) => theme.gridUnit * 6}px;
display: flex;
align-items: center;
justify-content: center;
height: 100%;
`;
const StyledEmptyStateBig = styled(EmptyStateBig)`
max-width: 50%;
p {
width: ${({ theme }) => theme.gridUnit * 115}px;
}
`;
export const SELECT_MESSAGE = t(
'Datasets can be created from database tables or SQL queries. Select a database table to the left or ',
);
export const CREATE_MESSAGE = t('create dataset from SQL query');
export const VIEW_DATASET_MESSAGE = t(
' to open SQL Lab. From there you can save the query as a dataset.',
);
const renderEmptyDescription = () => (
<>
{SELECT_MESSAGE}
<span
role="button"
onClick={() => {
window.location.href = `/superset/sqllab`;
}}
tabIndex={0}
>
{CREATE_MESSAGE}
</span>
{VIEW_DATASET_MESSAGE}
</>
);
export const SELECT_TABLE_TITLE = t('Select dataset source');
export const NO_COLUMNS_TITLE = t('No table columns');
export const NO_COLUMNS_DESCRIPTION = t(
'This database table does not contain any data. Please select a different table.',
);
export const ERROR_TITLE = t('An Error Occurred');
export const ERROR_DESCRIPTION = t(
'Unable to load columns for the selected table. Please select a different table.',
);
interface MessageContentProps {
hasError: boolean;
tableName?: string | null;
hasColumns: boolean;
}
export const MessageContent = (props: MessageContentProps) => {
const { hasError, tableName, hasColumns } = props;
let currentImage: string | undefined = 'empty-dataset.svg';
let currentTitle = SELECT_TABLE_TITLE;
let currentDescription = renderEmptyDescription();
if (hasError) {
currentTitle = ERROR_TITLE;
currentDescription = <>{ERROR_DESCRIPTION}</>;
currentImage = undefined;
} else if (tableName && !hasColumns) {
currentImage = 'no-columns.svg';
currentTitle = NO_COLUMNS_TITLE;
currentDescription = <>{NO_COLUMNS_DESCRIPTION}</>;
}
return (
<StyledContainer>
<StyledEmptyStateBig
image={currentImage}
title={currentTitle}
description={currentDescription}
/>
</StyledContainer>
);
};
export default MessageContent;

View File

@ -0,0 +1,34 @@
/**
* 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 { ITableColumn } from './types';
export const exampleColumns: ITableColumn[] = [
{
name: 'name',
type: 'STRING',
},
{
name: 'height_in_inches',
type: 'NUMBER',
},
{
name: 'birth_date',
type: 'DATE',
},
];

View File

@ -16,78 +16,112 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { supersetTheme, t, styled } from '@superset-ui/core';
import Icons from 'src/components/Icons';
import { EmptyStateBig } from 'src/components/EmptyState';
import React, { useEffect, useState, useRef } from 'react';
import { SupersetClient } from '@superset-ui/core';
import DatasetPanel from './DatasetPanel';
import { ITableColumn, IDatabaseTable, isIDatabaseTable } from './types';
type DatasetPanelProps = {
/**
* Interface for the getTableMetadata API call
*/
interface IColumnProps {
/**
* Unique id of the database
*/
dbId: number;
/**
* Name of the table
*/
tableName: string;
/**
* Name of the schema
*/
schema: string;
}
export interface IDatasetPanelWrapperProps {
/**
* Name of the database table
*/
tableName?: string | null;
};
/**
* Database ID
*/
dbId?: number;
/**
* The selected schema for the database
*/
schema?: string | null;
setHasColumns?: Function;
}
const StyledEmptyStateBig = styled(EmptyStateBig)`
p {
width: ${({ theme }) => theme.gridUnit * 115}px;
}
`;
const DatasetPanelWrapper = ({
tableName,
dbId,
schema,
setHasColumns,
}: IDatasetPanelWrapperProps) => {
const [columnList, setColumnList] = useState<ITableColumn[]>([]);
const [loading, setLoading] = useState(false);
const [hasError, setHasError] = useState(false);
const tableNameRef = useRef(tableName);
const StyledDatasetPanel = styled.div`
padding: ${({ theme }) => theme.gridUnit * 8}px
${({ theme }) => theme.gridUnit * 6}px;
const getTableMetadata = async (props: IColumnProps) => {
const { dbId, tableName, schema } = props;
setLoading(true);
setHasColumns?.(false);
const path = `/api/v1/database/${dbId}/table/${tableName}/${schema}/`;
try {
const response = await SupersetClient.get({
endpoint: path,
});
.table-name {
font-size: ${({ theme }) => theme.gridUnit * 6}px;
font-weight: ${({ theme }) => theme.typography.weights.medium};
padding-bottom: ${({ theme }) => theme.gridUnit * 20}px;
margin: 0;
.anticon:first-of-type {
margin-right: ${({ theme }) => theme.gridUnit * 4}px;
if (isIDatabaseTable(response?.json)) {
const table: IDatabaseTable = response.json as IDatabaseTable;
/**
* The user is able to click other table columns while the http call for last selected table column is made
* This check ensures we process the response that matches the last selected table name and ignore the others
*/
if (table.name === tableNameRef.current) {
setColumnList(table.columns);
setHasColumns?.(table.columns.length > 0);
setHasError(false);
}
} else {
setColumnList([]);
setHasColumns?.(false);
setHasError(true);
// eslint-disable-next-line no-console
console.error(
`The API response from ${path} does not match the IDatabaseTable interface.`,
);
}
} catch (error) {
setColumnList([]);
setHasColumns?.(false);
setHasError(true);
} finally {
setLoading(false);
}
};
.anticon:nth-of-type(2) {
margin-left: ${({ theme }) => theme.gridUnit * 4}px;
useEffect(() => {
tableNameRef.current = tableName;
if (tableName && schema && dbId) {
getTableMetadata({ tableName, dbId, schema });
}
}
// getTableMetadata is a const and should not be independency array
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [tableName, dbId, schema]);
span {
font-weight: ${({ theme }) => theme.typography.weights.bold};
}
`;
const renderEmptyDescription = () => (
<>
{t(
'Datasets can be created from database tables or SQL queries. Select a database table to the left or ',
)}
<span
role="button"
onClick={() => {
window.location.href = `/superset/sqllab`;
}}
tabIndex={0}
>
{t('create dataset from SQL query')}
</span>
{t(' to open SQL Lab. From there you can save the query as a dataset.')}
</>
);
const DatasetPanel = ({ tableName }: DatasetPanelProps) =>
tableName ? (
<StyledDatasetPanel>
<div className="table-name">
<Icons.Table iconColor={supersetTheme.colors.grayscale.base} />
{tableName}
</div>
<span>{t('Table columns')}</span>
</StyledDatasetPanel>
) : (
<StyledEmptyStateBig
image="empty-dataset.svg"
title={t('Select dataset source')}
description={renderEmptyDescription()}
return (
<DatasetPanel
columnList={columnList}
hasError={hasError}
loading={loading}
tableName={tableName}
/>
);
};
export default DatasetPanel;
export default DatasetPanelWrapper;

View File

@ -0,0 +1,92 @@
/**
* 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.
*/
/**
* Interface for table columns dataset
*/
export interface ITableColumn {
/**
* Name of the column
*/
name: string;
/**
* Datatype of the column
*/
type: string;
}
/**
* Checks if a given item matches the ITableColumn interface
* @param item Object to check if it matches the ITableColumn interface
* @returns boolean true if matches interface
*/
export const isITableColumn = (item: any): boolean => {
let match = true;
const BASE_ERROR =
'The object provided to isITableColumn does match the interface.';
if (typeof item?.name !== 'string') {
match = false;
// eslint-disable-next-line no-console
console.error(
`${BASE_ERROR} The property 'name' is required and must be a string`,
);
}
if (match && typeof item?.type !== 'string') {
match = false;
// eslint-disable-next-line no-console
console.error(
`${BASE_ERROR} The property 'type' is required and must be a string`,
);
}
return match;
};
export interface IDatabaseTable {
name: string;
columns: ITableColumn[];
}
/**
* Checks if a given item matches the isIDatabsetTable interface
* @param item Object to check if it matches the isIDatabsetTable interface
* @returns boolean true if matches interface
*/
export const isIDatabaseTable = (item: any): boolean => {
let match = true;
if (typeof item?.name !== 'string') {
match = false;
}
if (match && !Array.isArray(item.columns)) {
match = false;
}
if (match && item.columns.length > 0) {
const invalid = item.columns.some((column: any, index: number) => {
const valid = isITableColumn(column);
if (!valid) {
// eslint-disable-next-line no-console
console.error(
`The provided object does not match the IDatabaseTable interface. columns[${index}] is invalid and does not match the ITableColumn interface`,
);
}
return !valid;
});
match = !invalid;
}
return match;
};

View File

@ -36,6 +36,7 @@ const mockPropsWithDataset = {
dataset_name: 'Untitled',
table_name: 'real_info',
},
hasColumns: true,
};
describe('Footer', () => {

View File

@ -36,6 +36,7 @@ interface FooterProps {
addDangerToast: () => void;
datasetObject?: Partial<DatasetObject> | null;
onDatasetAdd?: (dataset: DatasetObject) => void;
hasColumns?: boolean;
}
const INPUT_FIELDS = ['db', 'schema', 'table_name'];
@ -46,7 +47,12 @@ const LOG_ACTIONS = [
LOG_ACTIONS_DATASET_CREATION_TABLE_CANCELLATION,
];
function Footer({ url, datasetObject, addDangerToast }: FooterProps) {
function Footer({
url,
datasetObject,
addDangerToast,
hasColumns = false,
}: FooterProps) {
const { createResource } = useSingleViewResource<Partial<DatasetObject>>(
'dataset',
t('dataset'),
@ -107,7 +113,7 @@ function Footer({ url, datasetObject, addDangerToast }: FooterProps) {
<Button onClick={cancelButtonOnClick}>Cancel</Button>
<Button
buttonStyle="primary"
disabled={!datasetObject?.table_name}
disabled={!datasetObject?.table_name || !hasColumns}
tooltip={!datasetObject?.table_name ? tooltipText : undefined}
onClick={onSave}
>

View File

@ -16,14 +16,8 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, {
useEffect,
useState,
useMemo,
SetStateAction,
Dispatch,
} from 'react';
import { SupersetClient, t, styled, FAST_DEBOUNCE } from '@superset-ui/core';
import React, { useEffect, useState, SetStateAction, Dispatch } from 'react';
import { SupersetClient, t, styled } from '@superset-ui/core';
import { Input } from 'src/components/Input';
import { Form } from 'src/components/Form';
import Icons from 'src/components/Icons';
@ -34,7 +28,6 @@ import Loading from 'src/components/Loading';
import DatabaseSelector, {
DatabaseObject,
} from 'src/components/DatabaseSelector';
import { debounce } from 'lodash';
import { EmptyStateMedium } from 'src/components/EmptyState';
import { useToasts } from 'src/components/MessageToasts/withToasts';
import { DatasetActionType } from '../types';
@ -208,17 +201,6 @@ export default function LeftPanel({
}
}, [resetTables]);
const search = useMemo(
() =>
debounce((value: string) => {
const endpoint = encodeURI(
`/superset/tables/${dbId}/${encodedSchema}/`,
);
getTablesList(endpoint);
}, FAST_DEBOUNCE),
[dbId, encodedSchema],
);
const filteredOptions = tableOptions.filter(option =>
option?.value?.toLowerCase().includes(searchVal.toLowerCase()),
);
@ -266,7 +248,6 @@ export default function LeftPanel({
value={searchVal}
prefix={<SearchIcon iconSize="l" />}
onChange={evt => {
search(evt.target.value);
setSearchVal(evt.target.value);
}}
className="table-form"

View File

@ -16,7 +16,7 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useReducer, Reducer } from 'react';
import React, { useReducer, Reducer, useState } from 'react';
import Header from './Header';
import DatasetPanel from './DatasetPanel';
import LeftPanel from './LeftPanel';
@ -72,6 +72,7 @@ export default function AddDataset() {
const [dataset, setDataset] = useReducer<
Reducer<Partial<DatasetObject> | null, DSReducerActionType>
>(datasetReducer, null);
const [hasColumns, setHasColumns] = useState(false);
const HeaderComponent = () => (
<Header setDataset={setDataset} title={dataset?.table_name} />
@ -86,11 +87,16 @@ export default function AddDataset() {
);
const DatasetPanelComponent = () => (
<DatasetPanel tableName={dataset?.table_name} />
<DatasetPanel
tableName={dataset?.table_name}
dbId={dataset?.db?.id}
schema={dataset?.schema}
setHasColumns={setHasColumns}
/>
);
const FooterComponent = () => (
<Footer url={prevUrl} datasetObject={dataset} />
<Footer url={prevUrl} datasetObject={dataset} hasColumns={hasColumns} />
);
return (

View File

@ -40,7 +40,7 @@ export const RightColumn = styled(Column)`
height: auto;
display: flex;
flex: 1 0 auto;
width: auto;
width: calc(100% - ${({ theme }) => theme.gridUnit * 80}px);
`;
const Row = styled.div`