chore(newchart): update chart creation dataset selection help text, styles (#20369)

* Update dataset selection help text.

* Update 'Create a new chart' flow styles.

* Add support for linking directly to Create Dataset modal via URL hash.

* Add support for linking directly to Create Dataset modal via URL hash.

* Update dataset help text to not include spaces in translated strings and only include an 'Add dataset' link when user has permission to add dataset.

* Clean up test file

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>

Co-authored-by: Michael S. Molina <70410625+michael-s-molina@users.noreply.github.com>
This commit is contained in:
Cody Leff 2022-06-21 11:01:43 -06:00 committed by GitHub
parent f3b289d3c3
commit 44c5e2879b
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 190 additions and 75 deletions

View File

@ -27,61 +27,97 @@ import AddSliceContainer, {
import VizTypeGallery from 'src/explore/components/controls/VizTypeControl/VizTypeGallery'; import VizTypeGallery from 'src/explore/components/controls/VizTypeControl/VizTypeGallery';
import { styledMount as mount } from 'spec/helpers/theming'; import { styledMount as mount } from 'spec/helpers/theming';
import { act } from 'spec/helpers/testing-library'; import { act } from 'spec/helpers/testing-library';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
const datasource = { const datasource = {
value: '1', value: '1',
label: 'table', label: 'table',
}; };
describe('AddSliceContainer', () => { const mockUser: UserWithPermissionsAndRoles = {
let wrapper: ReactWrapper< createdOn: '2021-04-27T18:12:38.952304',
email: 'admin',
firstName: 'admin',
isActive: true,
lastName: 'admin',
permissions: {},
roles: { Admin: Array(173) },
userId: 1,
username: 'admin',
isAnonymous: false,
};
const mockUserWithDatasetWrite: UserWithPermissionsAndRoles = {
createdOn: '2021-04-27T18:12:38.952304',
email: 'admin',
firstName: 'admin',
isActive: true,
lastName: 'admin',
permissions: {},
roles: { Admin: [['can_write', 'Dataset']] },
userId: 1,
username: 'admin',
isAnonymous: false,
};
async function getWrapper(user = mockUser) {
const wrapper = mount(<AddSliceContainer user={user} />) as ReactWrapper<
AddSliceContainerProps, AddSliceContainerProps,
AddSliceContainerState, AddSliceContainerState,
AddSliceContainer AddSliceContainer
>; >;
await act(() => new Promise(resolve => setTimeout(resolve, 0)));
return wrapper;
}
beforeEach(async () => { test('renders a select and a VizTypeControl', async () => {
wrapper = mount(<AddSliceContainer />) as ReactWrapper< const wrapper = await getWrapper();
AddSliceContainerProps, expect(wrapper.find(Select)).toExist();
AddSliceContainerState, expect(wrapper.find(VizTypeGallery)).toExist();
AddSliceContainer });
>;
// suppress a warning caused by some unusual async behavior in Icon test('renders dataset help text when user lacks dataset write permissions', async () => {
await act(() => new Promise(resolve => setTimeout(resolve, 0))); const wrapper = await getWrapper();
}); expect(wrapper.find('[data-test="dataset-write"]')).not.toExist();
expect(wrapper.find('[data-test="no-dataset-write"]')).toExist();
it('renders a select and a VizTypeControl', () => { });
expect(wrapper.find(Select)).toExist();
expect(wrapper.find(VizTypeGallery)).toExist(); test('renders dataset help text when user has dataset write permissions', async () => {
}); const wrapper = await getWrapper(mockUserWithDatasetWrite);
expect(wrapper.find('[data-test="dataset-write"]')).toExist();
it('renders a button', () => { expect(wrapper.find('[data-test="no-dataset-write"]')).not.toExist();
expect(wrapper.find(Button)).toExist(); });
});
test('renders a button', async () => {
it('renders a disabled button if no datasource is selected', () => { const wrapper = await getWrapper();
expect( expect(wrapper.find(Button)).toExist();
wrapper.find(Button).find({ disabled: true }).hostNodes(), });
).toHaveLength(1);
}); test('renders a disabled button if no datasource is selected', async () => {
const wrapper = await getWrapper();
it('renders an enabled button if datasource and viz type is selected', () => { expect(
wrapper.setState({ wrapper.find(Button).find({ disabled: true }).hostNodes(),
datasource, ).toHaveLength(1);
visType: 'table', });
});
expect( test('renders an enabled button if datasource and viz type are selected', async () => {
wrapper.find(Button).find({ disabled: true }).hostNodes(), const wrapper = await getWrapper();
).toHaveLength(0); wrapper.setState({
}); datasource,
visType: 'table',
it('formats explore url', () => { });
wrapper.setState({ expect(
datasource, wrapper.find(Button).find({ disabled: true }).hostNodes(),
visType: 'table', ).toHaveLength(0);
}); });
const formattedUrl =
'/superset/explore/?form_data=%7B%22viz_type%22%3A%22table%22%2C%22datasource%22%3A%221%22%7D'; test('formats Explore url', async () => {
expect(wrapper.instance().exploreUrl()).toBe(formattedUrl); const wrapper = await getWrapper();
}); wrapper.setState({
datasource,
visType: 'table',
});
const formattedUrl =
'/superset/explore/?form_data=%7B%22viz_type%22%3A%22table%22%2C%22datasource%22%3A%221%22%7D';
expect(wrapper.instance().exploreUrl()).toBe(formattedUrl);
}); });

View File

@ -24,12 +24,13 @@ import { URL_PARAMS } from 'src/constants';
import { isNullish } from 'src/utils/common'; import { isNullish } from 'src/utils/common';
import Button from 'src/components/Button'; import Button from 'src/components/Button';
import { Select, Steps } from 'src/components'; import { Select, Steps } from 'src/components';
import { FormLabel } from 'src/components/Form';
import { Tooltip } from 'src/components/Tooltip'; import { Tooltip } from 'src/components/Tooltip';
import VizTypeGallery, { import VizTypeGallery, {
MAX_ADVISABLE_VIZ_GALLERY_WIDTH, MAX_ADVISABLE_VIZ_GALLERY_WIDTH,
} from 'src/explore/components/controls/VizTypeControl/VizTypeGallery'; } from 'src/explore/components/controls/VizTypeControl/VizTypeGallery';
import findPermission from 'src/dashboard/util/findPermission';
import { UserWithPermissionsAndRoles } from 'src/types/bootstrapTypes';
type Dataset = { type Dataset = {
id: number; id: number;
@ -38,11 +39,14 @@ type Dataset = {
datasource_type: string; datasource_type: string;
}; };
export type AddSliceContainerProps = {}; export type AddSliceContainerProps = {
user: UserWithPermissionsAndRoles;
};
export type AddSliceContainerState = { export type AddSliceContainerState = {
datasource?: { label: string; value: string }; datasource?: { label: string; value: string };
visType: string | null; visType: string | null;
canCreateDataset: boolean;
}; };
const ESTIMATED_NAV_HEIGHT = 56; const ESTIMATED_NAV_HEIGHT = 56;
@ -73,7 +77,6 @@ const StyledContainer = styled.div`
display: flex; display: flex;
flex-direction: row; flex-direction: row;
align-items: center; align-items: center;
margin-bottom: ${theme.gridUnit * 2}px;
& > div { & > div {
min-width: 200px; min-width: 200px;
@ -180,6 +183,24 @@ const StyledLabel = styled.span`
`} `}
`; `;
const StyledStepTitle = styled.span`
${({
theme: {
typography: { sizes, weights },
},
}) => `
font-size: ${sizes.m}px;
font-weight: ${weights.bold};
`}
`;
const StyledStepDescription = styled.div`
${({ theme: { gridUnit } }) => `
margin-top: ${gridUnit * 4}px;
margin-bottom: ${gridUnit * 3}px;
`}
`;
export default class AddSliceContainer extends React.PureComponent< export default class AddSliceContainer extends React.PureComponent<
AddSliceContainerProps, AddSliceContainerProps,
AddSliceContainerState AddSliceContainerState
@ -188,6 +209,11 @@ export default class AddSliceContainer extends React.PureComponent<
super(props); super(props);
this.state = { this.state = {
visType: null, visType: null,
canCreateDataset: findPermission(
'can_write',
'Dataset',
props.user.roles,
),
}; };
this.changeDatasource = this.changeDatasource.bind(this); this.changeDatasource = this.changeDatasource.bind(this);
@ -276,15 +302,49 @@ export default class AddSliceContainer extends React.PureComponent<
render() { render() {
const isButtonDisabled = this.isBtnDisabled(); const isButtonDisabled = this.isBtnDisabled();
const datasetHelpText = this.state.canCreateDataset ? (
<span data-test="dataset-write">
<a
href="/tablemodelview/list/#create"
rel="noopener noreferrer"
target="_blank"
>
{t('Add a dataset')}
</a>
{` ${t('or')} `}
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
>
{`${t('view instructions')} `}
<i className="fa fa-external-link" />
</a>
.
</span>
) : (
<span data-test="no-dataset-write">
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
>
{`${t('View instructions')} `}
<i className="fa fa-external-link" />
</a>
.
</span>
);
return ( return (
<StyledContainer> <StyledContainer>
<h3>{t('Create a new chart')}</h3> <h3>{t('Create a new chart')}</h3>
<Steps direction="vertical" size="small"> <Steps direction="vertical" size="small">
<Steps.Step <Steps.Step
title={<FormLabel>{t('Choose a dataset')}</FormLabel>} title={<StyledStepTitle>{t('Choose a dataset')}</StyledStepTitle>}
status={this.state.datasource?.value ? 'finish' : 'process'} status={this.state.datasource?.value ? 'finish' : 'process'}
description={ description={
<div className="dataset"> <StyledStepDescription className="dataset">
<Select <Select
autoFocus autoFocus
ariaLabel={t('Dataset')} ariaLabel={t('Dataset')}
@ -296,30 +356,21 @@ export default class AddSliceContainer extends React.PureComponent<
showSearch showSearch
value={this.state.datasource} value={this.state.datasource}
/> />
<span> {datasetHelpText}
{t( </StyledStepDescription>
'Instructions to add a dataset are available in the Superset tutorial.',
)}{' '}
<a
href="https://superset.apache.org/docs/creating-charts-dashboards/creating-your-first-dashboard/#registering-a-new-table"
rel="noopener noreferrer"
target="_blank"
>
<i className="fa fa-external-link" />
</a>
</span>
</div>
} }
/> />
<Steps.Step <Steps.Step
title={<FormLabel>{t('Choose chart type')}</FormLabel>} title={<StyledStepTitle>{t('Choose chart type')}</StyledStepTitle>}
status={this.state.visType ? 'finish' : 'process'} status={this.state.visType ? 'finish' : 'process'}
description={ description={
<VizTypeGallery <StyledStepDescription>
className="viz-gallery" <VizTypeGallery
onChange={this.changeVisType} className="viz-gallery"
selectedViz={this.state.visType} onChange={this.changeVisType}
/> selectedViz={this.state.visType}
/>
</StyledStepDescription>
} }
/> />
</Steps> </Steps>

View File

@ -41,7 +41,7 @@ const App = () => (
<ThemeProvider theme={theme}> <ThemeProvider theme={theme}>
<GlobalStyles /> <GlobalStyles />
<DynamicPluginProvider> <DynamicPluginProvider>
<AddSliceContainer /> <AddSliceContainer user={bootstrapData.user} />
</DynamicPluginProvider> </DynamicPluginProvider>
</ThemeProvider> </ThemeProvider>
); );

View File

@ -183,6 +183,12 @@ describe('DatasetList', () => {
}); });
}); });
jest.mock('react-router-dom', () => ({
...jest.requireActual('react-router-dom'),
useLocation: () => ({}),
useHistory: () => ({}),
}));
describe('RTL', () => { describe('RTL', () => {
async function renderAndWait() { async function renderAndWait() {
const mounted = act(async () => { const mounted = act(async () => {
@ -191,7 +197,7 @@ describe('RTL', () => {
<QueryParamProvider> <QueryParamProvider>
<DatasetList {...mockedProps} user={mockUser} /> <DatasetList {...mockedProps} user={mockUser} />
</QueryParamProvider>, </QueryParamProvider>,
{ useRedux: true }, { useRedux: true, useRouter: true },
); );
}); });

View File

@ -22,8 +22,10 @@ import React, {
useState, useState,
useMemo, useMemo,
useCallback, useCallback,
useEffect,
} from 'react'; } from 'react';
import rison from 'rison'; import rison from 'rison';
import { useHistory, useLocation } from 'react-router-dom';
import { import {
createFetchRelated, createFetchRelated,
createFetchDistinct, createFetchDistinct,
@ -553,6 +555,26 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
}); });
} }
const CREATE_HASH = '#create';
const location = useLocation();
const history = useHistory();
// Sync Dataset Add modal with #create hash
useEffect(() => {
const modalOpen = location.hash === CREATE_HASH && canCreate;
setDatasetAddModalOpen(modalOpen);
}, [canCreate, location.hash]);
// Add #create hash
const openDatasetAddModal = useCallback(() => {
history.replace(`${location.pathname}${location.search}${CREATE_HASH}`);
}, [history, location.pathname, location.search]);
// Remove #create hash
const closeDatasetAddModal = useCallback(() => {
history.replace(`${location.pathname}${location.search}`);
}, [history, location.pathname, location.search]);
if (canCreate) { if (canCreate) {
buttonArr.push({ buttonArr.push({
name: ( name: (
@ -560,7 +582,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
<i className="fa fa-plus" /> {t('Dataset')}{' '} <i className="fa fa-plus" /> {t('Dataset')}{' '}
</> </>
), ),
onClick: () => setDatasetAddModalOpen(true), onClick: openDatasetAddModal,
buttonStyle: 'primary', buttonStyle: 'primary',
}); });
@ -631,7 +653,7 @@ const DatasetList: FunctionComponent<DatasetListProps> = ({
<SubMenu {...menuData} /> <SubMenu {...menuData} />
<AddDatasetModal <AddDatasetModal
show={datasetAddModalOpen} show={datasetAddModalOpen}
onHide={() => setDatasetAddModalOpen(false)} onHide={closeDatasetAddModal}
onDatasetAdd={refreshData} onDatasetAdd={refreshData}
/> />
{datasetCurrentlyDeleting && ( {datasetCurrentlyDeleting && (

View File

@ -63,7 +63,7 @@ class SliceModelView(
def add(self) -> FlaskResponse: def add(self) -> FlaskResponse:
payload = { payload = {
"common": common_bootstrap_payload(), "common": common_bootstrap_payload(),
"user": bootstrap_user_data(g.user), "user": bootstrap_user_data(g.user, include_perms=True),
} }
return self.render_template( return self.render_template(
"superset/add_slice.html", bootstrap_data=json.dumps(payload) "superset/add_slice.html", bootstrap_data=json.dumps(payload)