feat: home screen mvp (#11206)

* step 1: broken stuff!

* first steps

* more adding and slicing

* step 1: broken stuff!

* can now filter dashboards/charts for "Edited" tabs (filter by changed_by o_m)

* more updates

* update recent card

* add icon

* Adding Expand Icon to Collapse component

* more updates

* clean up code

* remove lock file

* remove consoles

* fixing subnav button height shift

* lil' ascii arrows

* update branch

* update test part 1

* remove consoles

* fix typescript

* add images and update emptystate

* add changes

* update chart card

* fix css issues from rebase

* add suggestions

* more changes

* update tests and clear typescript errors

* Update superset-frontend/src/views/CRUD/welcome/ActivityTable.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* update from comments

* more updates..

* fix rebase

* fix pesky type errors

* test fixes

* lint fix

* Update superset-frontend/spec/javascripts/views/CRUD/welcome/Welcome_spec.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/views/CRUD/welcome/EmptyState.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/components/Menu/SubMenu.tsx

Co-authored-by: Evan Rusackas <evan@preset.io>

* Update superset-frontend/src/components/ListViewCard/index.tsx

Co-authored-by: ʈᵃᵢ <tdupreetan@gmail.com>

* Update superset-frontend/src/components/ListViewCard/index.tsx

Co-authored-by: ʈᵃᵢ <tdupreetan@gmail.com>

* add suggestions

* fix lint

* remove unused code

* toast getrecentActivityobjs

* add some suggestions

* remove types for now

* cypress fix

* remove unused type

Co-authored-by: Evan Rusackas <evan@preset.io>
Co-authored-by: ʈᵃᵢ <tdupreetan@gmail.com>
This commit is contained in:
Phillip Kelley-Dotson 2020-10-29 21:59:31 -07:00 committed by GitHub
parent a8eb3fe8e7
commit f7051eaade
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 2184 additions and 574 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -24,7 +24,7 @@ import SubMenu from 'src/components/Menu/SubMenu';
const defaultProps = {
name: 'Title',
children: [
tabs: [
{
name: 'Page1',
label: 'Page1',

View File

@ -54,6 +54,7 @@ const mockCharts = [...new Array(3)].map((_, i) => ({
fetchMock.get(chartsInfoEndpoint, {
permissions: ['can_list', 'can_edit', 'can_delete'],
});
fetchMock.get(chartssOwnersEndpoint, {
result: [],
});

View File

@ -0,0 +1,87 @@
/**
* 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 { styledMount as mount } from 'spec/helpers/theming';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import configureStore from 'redux-mock-store';
import ActivityTable from 'src/views/CRUD/welcome/ActivityTable';
const mockStore = configureStore([thunk]);
const store = mockStore({});
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
const dashboardEndpoint = 'glob:*/api/v1/dashboard/?*';
const savedQueryEndpoint = 'glob:*/api/v1/saved_query/?*';
fetchMock.get(chartsEndpoint, {
result: [
{
slice_name: 'ChartyChart',
changed_on_utc: '24 Feb 2014 10:13:14',
url: '/fakeUrl/explore',
id: '4',
table: {},
},
],
});
fetchMock.get(dashboardEndpoint, {
result: [
{
dashboard_title: 'Dashboard_Test',
changed_on_utc: '24 Feb 2014 10:13:14',
url: '/fakeUrl/dashboard',
id: '3',
},
],
});
fetchMock.get(savedQueryEndpoint, {
result: [],
});
describe('ActivityTable', () => {
const activityProps = {
user: {
userId: '1',
},
activityFilter: 'Edited',
};
const wrapper = mount(<ActivityTable {...activityProps} />, {
context: { store },
});
beforeAll(async () => {
await waitForComponentToPaint(wrapper);
});
it('the component renders ', () => {
expect(wrapper.find(ActivityTable)).toExist();
});
it('calls batch method and renders ListViewCArd', async () => {
const chartCall = fetchMock.calls(/chart\/\?q/);
const dashboardCall = fetchMock.calls(/dashboard\/\?q/);
expect(chartCall).toHaveLength(2);
expect(dashboardCall).toHaveLength(2);
});
});

View File

@ -0,0 +1,79 @@
/**
* 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 { styledMount as mount } from 'spec/helpers/theming';
import thunk from 'redux-thunk';
import fetchMock from 'fetch-mock';
import configureStore from 'redux-mock-store';
import ChartTable from 'src/views/CRUD/welcome/ChartTable';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
const mockStore = configureStore([thunk]);
const store = mockStore({});
const chartsEndpoint = 'glob:*/api/v1/chart/?*';
const chartsInfoEndpoint = 'glob:*/api/v1/chart/_info*';
const mockCharts = [...new Array(3)].map((_, i) => ({
changed_on_utc: new Date().toISOString(),
created_by: 'super user',
id: i,
slice_name: `cool chart ${i}`,
url: 'url',
viz_type: 'bar',
datasource_title: `ds${i}`,
thumbnail_url: '',
}));
fetchMock.get(chartsEndpoint, {
result: mockCharts,
});
fetchMock.get(chartsInfoEndpoint, {
permissions: ['can_add', 'can_edit', 'can_delete'],
});
describe('ChartTable', () => {
const mockedProps = {
user: {
userId: '2',
},
};
const wrapper = mount(<ChartTable {...mockedProps} />, {
context: { store },
});
it('it renders', () => {
expect(wrapper.find(ChartTable)).toExist();
});
it('fetches chart favorites and renders chart cards ', async () => {
expect(fetchMock.calls(chartsEndpoint)).toHaveLength(1);
await waitForComponentToPaint(wrapper);
expect(wrapper.find('ChartCard')).toExist();
});
it('display EmptyState if there is no data', () => {
fetchMock.resetHistory();
const wrapper = mount(<ChartTable {...mockedProps} />, {
context: { store },
});
expect(wrapper.find('EmptyState')).toExist();
});
});

View File

@ -17,48 +17,78 @@
* under the License.
*/
import React from 'react';
import { mount } from 'enzyme';
import { styledMount as mount } from 'spec/helpers/theming';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import fetchMock from 'fetch-mock';
import { supersetTheme, ThemeProvider } from '@superset-ui/core';
import { act } from 'react-dom/test-utils';
import ListView from 'src/components/ListView';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import SubMenu from 'src/components/Menu/SubMenu';
import DashboardTable from 'src/views/CRUD/welcome/DashboardTable';
import DashboardCard from 'src/views/CRUD/dashboard/DashboardCard';
// store needed for withToasts(DashboardTable)
const mockStore = configureStore([thunk]);
const store = mockStore({});
const dashboardsEndpoint = 'glob:*/api/v1/dashboard/*';
const mockDashboards = [{ id: 1, url: 'url', dashboard_title: 'title' }];
const dashboardsEndpoint = 'glob:*/api/v1/dashboard/?*';
const dashboardInfoEndpoint = 'glob:*/api/v1/dashboard/_info*';
const mockDashboards = [
{
id: 1,
url: 'url',
dashboard_title: 'title',
changed_on_utc: '24 Feb 2014 10:13:14',
},
];
fetchMock.get(dashboardsEndpoint, { result: mockDashboards });
function setup() {
// use mount because data fetching is triggered on mount
return mount(<DashboardTable />, {
context: { store },
wrappingComponent: ThemeProvider,
wrappingComponentProps: { theme: supersetTheme },
});
}
fetchMock.get(dashboardInfoEndpoint, {
permissions: ['can_list', 'can_edit', 'can_delete'],
});
describe('DashboardTable', () => {
beforeEach(fetchMock.resetHistory);
const dashboardProps = {
dashboardFilter: 'Favorite',
user: {
userId: '2',
},
};
const wrapper = mount(<DashboardTable {...dashboardProps} />, {
context: { store },
});
it('fetches dashboards and renders a ListView', () => {
return new Promise(done => {
const wrapper = setup();
beforeAll(async () => {
await waitForComponentToPaint(wrapper);
});
setTimeout(() => {
expect(fetchMock.calls(dashboardsEndpoint)).toHaveLength(1);
// there's a delay between response and updating state, so manually set it
// rather than adding a timeout which could introduce flakiness
wrapper.setState({ dashboards: mockDashboards });
expect(wrapper.find(ListView)).toExist();
done();
});
it('renders', () => {
expect(wrapper.find(DashboardTable)).toExist();
});
it('render a submenu with clickable tabs and buttons', async () => {
expect(wrapper.find(SubMenu)).toExist();
expect(wrapper.find('MenuItem')).toHaveLength(2);
expect(wrapper.find('Button')).toHaveLength(4);
act(() => {
wrapper.find('MenuItem').at(1).simulate('click');
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(/dashboard\/\?q/)).toHaveLength(1);
});
it('fetches dashboards and renders a card', () => {
expect(fetchMock.calls(/dashboard\/\?q/)).toHaveLength(1);
wrapper.setState({ dashboards: mockDashboards });
expect(wrapper.find(DashboardCard)).toExist();
});
it('display EmptyState if there is no data', () => {
fetchMock.resetHistory();
const wrapper = mount(<DashboardTable {...dashboardProps} />, {
context: { store },
});
expect(wrapper.find('EmptyState')).toExist();
});
});

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.
*/
import React from 'react';
import { styledMount as mount } from 'spec/helpers/theming';
import EmptyState from 'src/views/CRUD/welcome/EmptyState';
describe('EmptyState', () => {
const variants = [
{
tab: 'Favorite',
tableName: 'DASHBOARDS',
},
{
tab: 'Mine',
tableName: 'DASHBOARDS',
},
{
tab: 'Favorite',
tableName: 'CHARTS',
},
{
tab: 'Mine',
tableName: 'CHARTS',
},
{
tab: 'Favorite',
tableName: 'SAVED_QUERIES',
},
{
tab: 'Mine',
tableName: 'SAVED_QUEREIS',
},
];
const recents = [
{
tab: 'Viewed',
tableName: 'RECENTS',
},
{
tab: 'Edited',
tableName: 'RECENTS',
},
{
tab: 'Created',
tableName: 'RECENTS',
},
];
variants.forEach(variant => {
it(`it renders an ${variant.tab} ${variant.tableName} empty state`, () => {
const wrapper = mount(<EmptyState {...variant} />);
expect(wrapper).toExist();
const textContainer = wrapper.find('.ant-empty-description');
expect(textContainer.text()).toEqual(
variant.tab === 'Favorite'
? "You don't have any favorites yet!"
: `No ${
variant.tableName === 'SAVED_QUERIES'
? 'saved queries'
: variant.tableName.toLowerCase()
} yet`,
);
expect(wrapper.find('button')).toHaveLength(1);
});
});
recents.forEach(recent => {
it(`it renders an ${recent.tab} ${recent.tableName} empty state`, () => {
const wrapper = mount(<EmptyState {...recent} />);
expect(wrapper).toExist();
const textContainer = wrapper.find('.ant-empty-description');
expect(wrapper.find('.ant-empty-image').children()).toHaveLength(1);
expect(textContainer.text()).toContain(
`Recently ${recent.tab.toLowerCase()} charts, dashboards, and saved queries will appear here`,
);
});
});
});

View File

@ -0,0 +1,106 @@
/**
* 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 thunk from 'redux-thunk';
import { styledMount as mount } from 'spec/helpers/theming';
import fetchMock from 'fetch-mock';
import configureStore from 'redux-mock-store';
import { act } from 'react-dom/test-utils';
import waitForComponentToPaint from 'spec/helpers/waitForComponentToPaint';
import SubMenu from 'src/components/Menu/SubMenu';
import SavedQueries from 'src/views/CRUD/welcome/SavedQueries';
// store needed for withToasts(DashboardTable)
const mockStore = configureStore([thunk]);
const store = mockStore({});
const queriesEndpoint = 'glob:*/api/v1/saved_query/?*';
const savedQueriesInfo = 'glob:*/api/v1/saved_query/_info';
const mockqueries = [...new Array(3)].map((_, i) => ({
created_by: {
id: i,
first_name: `user`,
last_name: `${i}`,
},
created_on: `${i}-2020`,
database: {
database_name: `db ${i}`,
id: i,
},
changed_on_delta_humanized: '1 day ago',
db_id: i,
description: `SQL for ${i}`,
id: i,
label: `query ${i}`,
schema: 'public',
sql: `SELECT ${i} FROM table`,
sql_tables: [
{
catalog: null,
schema: null,
table: `${i}`,
},
],
}));
fetchMock.get(queriesEndpoint, {
result: mockqueries,
});
fetchMock.get(savedQueriesInfo, {
permissions: ['can_list', 'can_edit', 'can_delete'],
});
describe('SavedQueries', () => {
const savedQueryProps = {
user: {
userId: '1',
},
};
const wrapper = mount(<SavedQueries {...savedQueryProps} />, {
context: { store },
});
beforeAll(async () => {
await waitForComponentToPaint(wrapper);
});
it('is valid', () => {
expect(wrapper.find(SavedQueries)).toExist();
});
it('it renders a submenu with clickable tables and buttons', async () => {
expect(wrapper.find(SubMenu)).toExist();
expect(wrapper.find('MenuItem')).toHaveLength(2);
expect(wrapper.find('button')).toHaveLength(2);
act(() => {
wrapper.find('MenuItem').at(1).simulate('click');
});
await waitForComponentToPaint(wrapper);
expect(fetchMock.calls(/saved_query\/\?q/)).toHaveLength(1);
});
it('fetches queries favorites and renders listviewcard cards', () => {
expect(fetchMock.calls(/saved_query\/\?q/)).toHaveLength(1);
expect(wrapper.find('ListViewCard')).toExist();
});
});

View File

@ -17,11 +17,14 @@
* under the License.
*/
import React from 'react';
import { Panel, Row, Tab } from 'react-bootstrap';
import { shallow } from 'enzyme';
import thunk from 'redux-thunk';
import configureStore from 'redux-mock-store';
import Welcome from 'src/views/CRUD/welcome/Welcome';
const mockStore = configureStore([thunk]);
const store = mockStore({});
describe('Welcome', () => {
const mockedProps = {
user: {
@ -34,13 +37,15 @@ describe('Welcome', () => {
isActive: true,
},
};
it('is valid', () => {
expect(React.isValidElement(<Welcome {...mockedProps} />)).toBe(true);
const wrapper = shallow(<Welcome {...mockedProps} />, {
context: { store },
});
it('renders 3 Tab, Panel, and Row components', () => {
const wrapper = shallow(<Welcome {...mockedProps} />);
expect(wrapper.find(Tab)).toHaveLength(3);
expect(wrapper.find(Panel)).toHaveLength(3);
expect(wrapper.find(Row)).toHaveLength(3);
it('renders', () => {
expect(wrapper).toExist();
});
it('renders all panels on the page on page load', () => {
expect(wrapper.find('CollapsePanel')).toHaveLength(4);
});
});

View File

@ -143,15 +143,20 @@ const paragraphConfig = { rows: 1, width: 150 };
interface CardProps {
title: React.ReactNode;
url?: string;
imgURL: string;
imgFallbackURL: string;
imgURL?: string;
imgFallbackURL?: string;
imgPosition?: BackgroundPosition;
description: string;
loading: boolean;
titleRight?: React.ReactNode;
coverLeft?: React.ReactNode;
coverRight?: React.ReactNode;
actions: React.ReactNode;
actions: React.ReactNode | null;
showImg?: boolean;
rows?: number | string;
avatar?: string;
isRecent?: boolean;
renderCover?: React.ReactNode | null;
}
function ListViewCard({
@ -162,35 +167,42 @@ function ListViewCard({
imgFallbackURL,
description,
coverLeft,
isRecent,
coverRight,
actions,
avatar,
loading,
imgPosition = 'top',
renderCover,
}: CardProps) {
return (
<StyledCard
data-test="styled-card"
cover={
<Cover>
<a href={url}>
<div className="gradient-container">
<ImageLoader
src={imgURL}
fallback={imgFallbackURL}
isLoading={loading}
position={imgPosition}
/>
</div>
</a>
<CoverFooter className="cover-footer">
{!loading && coverLeft && (
<CoverFooterLeft>{coverLeft}</CoverFooterLeft>
)}
{!loading && coverRight && (
<CoverFooterRight>{coverRight}</CoverFooterRight>
)}
</CoverFooter>
</Cover>
!isRecent
? renderCover || (
<Cover>
<a href={url}>
<div className="gradient-container">
<ImageLoader
src={imgURL || ''}
fallback={imgFallbackURL || ''}
isLoading={loading}
position={imgPosition}
/>
</div>
</a>
<CoverFooter className="cover-footer">
{!loading && coverLeft && (
<CoverFooterLeft>{coverLeft}</CoverFooterLeft>
)}
{!loading && coverRight && (
<CoverFooterRight>{coverRight}</CoverFooterRight>
)}
</CoverFooter>
</Cover>
)
: null
}
>
{loading && (
@ -230,6 +242,8 @@ function ListViewCard({
</>
}
description={description}
// @ts-ignore
avatar={avatar ? <Icon name={avatar} /> : null}
/>
)}
</StyledCard>

View File

@ -53,10 +53,23 @@ const StyledHeader = styled.header`
li.active > a,
li.active > div,
li > a:hover,
li > a:focus,
li > div:hover {
background-color: ${({ theme }) => theme.colors.secondary.light4};
background: ${({ theme }) => theme.colors.secondary.light4};
border-bottom: none;
border-radius: 4px;
border-radius: ${({ theme }) => theme.borderRadius}px;
margin-bottom: ${({ theme }) => theme.gridUnit * 2}px;
}
}
.navbar-inverse {
.navbar-nav {
& > .active > a {
background: ${({ theme }) => theme.colors.secondary.light4};
&:hover,
&:focus {
background: ${({ theme }) => theme.colors.secondary.light4};
}
}
}
}
`;
@ -64,8 +77,9 @@ const StyledHeader = styled.header`
type MenuChild = {
label: string;
name: string;
url: string;
url?: string;
usesRouter?: boolean;
onClick?: () => void;
};
export interface ButtonProps {
@ -83,8 +97,8 @@ export interface ButtonProps {
export interface SubMenuProps {
buttons?: Array<ButtonProps>;
name: string;
children?: MenuChild[];
name?: string;
tabs?: MenuChild[];
activeChild?: MenuChild['name'];
/* If usesRouter is true, a react-router <Link> component will be used instead of href.
* ONLY set usesRouter to true if SubMenu is wrapped in a react-router <Router>;
@ -108,16 +122,16 @@ const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
<Navbar.Brand>{props.name}</Navbar.Brand>
</Navbar.Header>
<Nav>
{props.children &&
props.children.map(child => {
if ((props.usesRouter || hasHistory) && !!child.usesRouter) {
{props.tabs &&
props.tabs.map(tab => {
if ((props.usesRouter || hasHistory) && !!tab.usesRouter) {
return (
<li
className={child.name === props.activeChild ? 'active' : ''}
key={`${child.label}`}
className={tab.name === props.activeChild ? 'active' : ''}
key={`${tab.label}`}
>
<div>
<Link to={child.url}>{child.label}</Link>
<Link to={tab.url || ''}>{tab.label}</Link>
</div>
</li>
);
@ -126,11 +140,12 @@ const SubMenu: React.FunctionComponent<SubMenuProps> = props => {
return (
<MenuItem
className="no-router"
active={child.name === props.activeChild}
key={`${child.label}`}
href={child.url}
active={tab.name === props.activeChild}
key={`${tab.label}`}
href={tab.url}
onClick={tab.onClick}
>
{child.label}
{tab.label}
</MenuItem>
);
})}

View File

@ -0,0 +1,138 @@
/**
* 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 { useFavoriteStatus } from 'src/views/CRUD/hooks';
import { t } from '@superset-ui/core';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import Icon from 'src/components/Icon';
import Chart from 'src/types/Chart';
import ListViewCard from 'src/components/ListViewCard';
import Label from 'src/components/Label';
import { Dropdown, Menu } from 'src/common/components';
import FaveStar from 'src/components/FaveStar';
import FacePile from 'src/components/FacePile';
import { handleChartDelete } from '../utils';
const FAVESTAR_BASE_URL = '/superset/favstar/slice';
interface ChartCardProps {
chart: Chart;
hasPerm: (perm: string) => boolean;
openChartEditModal: (chart: Chart) => void;
bulkSelectEnabled: boolean;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
refreshData: () => void;
loading: boolean;
}
export default function ChartCard({
chart,
hasPerm,
openChartEditModal,
bulkSelectEnabled,
addDangerToast,
addSuccessToast,
refreshData,
loading,
}: ChartCardProps) {
const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete');
const [, fetchFaveStar, saveFaveStar, favoriteStatus] = useFavoriteStatus(
{},
FAVESTAR_BASE_URL,
addDangerToast,
);
const menu = (
<Menu>
{canDelete && (
<Menu.Item>
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')} <b>{chart.slice_name}</b>
?
</>
}
onConfirm={() =>
handleChartDelete(
chart,
addSuccessToast,
addDangerToast,
refreshData,
)
}
>
{confirmDelete => (
<div
data-test="chart-list-delete-option"
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<ListViewCard.MenuIcon name="trash" /> {t('Delete')}
</div>
)}
</ConfirmStatusChange>
</Menu.Item>
)}
{canEdit && (
<Menu.Item
data-test="chart-list-edit-option"
role="button"
tabIndex={0}
onClick={() => openChartEditModal(chart)}
>
<ListViewCard.MenuIcon name="edit-alt" /> {t('Edit')}
</Menu.Item>
)}
</Menu>
);
return (
<ListViewCard
loading={loading}
title={chart.slice_name}
url={bulkSelectEnabled ? undefined : chart.url}
imgURL={chart.thumbnail_url || ''}
imgFallbackURL="/static/assets/images/chart-card-fallback.png"
description={t('Last modified %s', chart.changed_on_delta_humanized)}
coverLeft={<FacePile users={chart.owners || []} />}
coverRight={
<Label bsStyle="secondary">{chart.datasource_name_text}</Label>
}
actions={
<ListViewCard.Actions>
<FaveStar
itemId={chart.id}
fetchFaveStar={fetchFaveStar}
saveFaveStar={saveFaveStar}
isStarred={!!favoriteStatus[chart.id]}
/>
<Dropdown overlay={menu}>
<Icon name="more-horiz" />
</Dropdown>
</ListViewCard.Actions>
}
/>
);
}

View File

@ -17,15 +17,22 @@
* under the License.
*/
import { SupersetClient, getChartMetadataRegistry, t } from '@superset-ui/core';
import React, { useState, useMemo } from 'react';
import React, { useMemo } from 'react';
import rison from 'rison';
import { uniqBy } from 'lodash';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
import {
createFetchRelated,
createErrorHandler,
handleChartDelete,
} from 'src/views/CRUD/utils';
import {
useListViewResource,
useFavoriteStatus,
useChartEditModal,
} from 'src/views/CRUD/hooks';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
import FacePile from 'src/components/FacePile';
import Icon from 'src/components/Icon';
import FaveStar from 'src/components/FaveStar';
import ListView, {
@ -35,11 +42,9 @@ import ListView, {
} from 'src/components/ListView';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import PropertiesModal from 'src/explore/components/PropertiesModal';
import Chart, { Slice } from 'src/types/Chart';
import ListViewCard from 'src/components/ListViewCard';
import Label from 'src/components/Label';
import { Dropdown, Menu } from 'src/common/components';
import Chart from 'src/types/Chart';
import TooltipWrapper from 'src/components/TooltipWrapper';
import ChartCard from './ChartCard';
const PAGE_SIZE = 25;
const FAVESTAR_BASE_URL = '/superset/favstar/slice';
@ -105,51 +110,18 @@ function ChartList(props: ChartListProps) {
FAVESTAR_BASE_URL,
props.addDangerToast,
);
const [
const {
sliceCurrentlyEditing,
setSliceCurrentlyEditing,
] = useState<Slice | null>(null);
handleChartUpdated,
openChartEditModal,
closeChartEditModal,
} = useChartEditModal(setCharts, charts);
const canCreate = hasPerm('can_add');
const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete');
const initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
function openChartEditModal(chart: Chart) {
setSliceCurrentlyEditing({
slice_id: chart.id,
slice_name: chart.slice_name,
description: chart.description,
cache_timeout: chart.cache_timeout,
});
}
function closeChartEditModal() {
setSliceCurrentlyEditing(null);
}
function handleChartUpdated(edits: Chart) {
// update the chart in our state with the edited info
const newCharts = charts.map(chart =>
chart.id === edits.id ? { ...chart, ...edits } : chart,
);
setCharts(newCharts);
}
function handleChartDelete({ id, slice_name: sliceName }: Chart) {
SupersetClient.delete({
endpoint: `/api/v1/chart/${id}`,
}).then(
() => {
refreshData();
props.addSuccessToast(t('Deleted: %s', sliceName));
},
() => {
props.addDangerToast(t('There was an issue deleting: %s', sliceName));
},
);
}
function handleBulkChartDelete(chartsToDelete: Chart[]) {
SupersetClient.delete({
endpoint: `/api/v1/chart/?q=${rison.encode(
@ -266,7 +238,13 @@ function ChartList(props: ChartListProps) {
},
{
Cell: ({ row: { original } }: any) => {
const handleDelete = () => handleChartDelete(original);
const handleDelete = () =>
handleChartDelete(
original,
props.addSuccessToast,
props.addDangerToast,
refreshData,
);
const openEditModal = () => openChartEditModal(original);
return (
@ -426,69 +404,17 @@ function ChartList(props: ChartListProps) {
},
];
function renderCard(chart: Chart & { loading: boolean }) {
const menu = (
<Menu>
{canDelete && (
<Menu.Item>
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{chart.slice_name}</b>?
</>
}
onConfirm={() => handleChartDelete(chart)}
>
{confirmDelete => (
<div
data-test="chart-list-delete-option"
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<ListViewCard.MenuIcon name="trash" /> Delete
</div>
)}
</ConfirmStatusChange>
</Menu.Item>
)}
{canEdit && (
<Menu.Item
data-test="chart-list-edit-option"
role="button"
tabIndex={0}
onClick={() => openChartEditModal(chart)}
>
<ListViewCard.MenuIcon name="edit-alt" /> Edit
</Menu.Item>
)}
</Menu>
);
function renderCard(chart: Chart) {
return (
<ListViewCard
loading={chart.loading}
title={chart.slice_name}
url={bulkSelectEnabled ? undefined : chart.url}
imgURL={chart.thumbnail_url ?? ''}
imgFallbackURL="/static/assets/images/chart-card-fallback.png"
imgPosition="bottom"
description={t('Last modified %s', chart.changed_on_delta_humanized)}
coverLeft={<FacePile users={chart.owners || []} />}
coverRight={
<Label bsStyle="secondary">{chart.datasource_name_text}</Label>
}
actions={
<ListViewCard.Actions>
{renderFaveStar(chart.id)}
<Dropdown data-test="dropdown-options" overlay={menu}>
<Icon name="more-horiz" />
</Dropdown>
</ListViewCard.Actions>
}
<ChartCard
chart={chart}
hasPerm={hasPerm}
openChartEditModal={openChartEditModal}
bulkSelectEnabled={bulkSelectEnabled}
addDangerToast={props.addDangerToast}
addSuccessToast={props.addSuccessToast}
refreshData={refreshData}
loading={loading}
/>
);
}

View File

@ -0,0 +1,140 @@
/**
* 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 } from '@superset-ui/core';
import {
handleDashboardDelete,
handleBulkDashboardExport,
} from 'src/views/CRUD/utils';
import { Dropdown, Menu } from 'src/common/components';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import ListViewCard from 'src/components/ListViewCard';
import Icon from 'src/components/Icon';
import Label from 'src/components/Label';
import FacePile from 'src/components/FacePile';
import FaveStar from 'src/components/FaveStar';
import { DashboardCardProps } from 'src/views/CRUD/types';
import { useFavoriteStatus } from 'src/views/CRUD/hooks';
const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
function DashboardCard({
dashboard,
hasPerm,
bulkSelectEnabled,
refreshData,
addDangerToast,
addSuccessToast,
openDashboardEditModal,
}: DashboardCardProps) {
const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete');
const canExport = hasPerm('can_mulexport');
const [, fetchFaveStar, saveFaveStar, favoriteStatus] = useFavoriteStatus(
{},
FAVESTAR_BASE_URL,
addDangerToast,
);
const menu = (
<Menu>
{canEdit && openDashboardEditModal && (
<Menu.Item
role="button"
tabIndex={0}
onClick={() =>
openDashboardEditModal && openDashboardEditModal(dashboard)
}
>
<ListViewCard.MenuIcon name="edit-alt" /> Edit
</Menu.Item>
)}
{canExport && (
<Menu.Item
role="button"
tabIndex={0}
onClick={() => handleBulkDashboardExport([dashboard])}
>
<ListViewCard.MenuIcon name="share" /> Export
</Menu.Item>
)}
{canDelete && (
<Menu.Item>
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{dashboard.dashboard_title}</b>?
</>
}
onConfirm={() =>
handleDashboardDelete(
dashboard,
refreshData,
addSuccessToast,
addDangerToast,
)
}
>
{confirmDelete => (
<div
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<ListViewCard.MenuIcon name="trash" /> Delete
</div>
)}
</ConfirmStatusChange>
</Menu.Item>
)}
</Menu>
);
return (
<ListViewCard
loading={dashboard.loading || false}
title={dashboard.dashboard_title}
titleRight={<Label>{dashboard.published ? 'published' : 'draft'}</Label>}
url={bulkSelectEnabled ? undefined : dashboard.url}
imgURL={dashboard.thumbnail_url}
imgFallbackURL="/static/assets/images/dashboard-card-fallback.png"
description={t('Last modified %s', dashboard.changed_on_delta_humanized)}
coverLeft={<FacePile users={dashboard.owners || []} />}
actions={
<ListViewCard.Actions>
<FaveStar
itemId={dashboard.id}
fetchFaveStar={fetchFaveStar}
saveFaveStar={saveFaveStar}
isStarred={!!favoriteStatus[dashboard.id]}
/>
<Dropdown overlay={menu}>
<Icon name="more-horiz" />
</Dropdown>
</ListViewCard.Actions>
}
showImg
/>
);
}
export default DashboardCard;

View File

@ -20,22 +20,27 @@ import { SupersetClient, t } from '@superset-ui/core';
import React, { useState, useMemo } from 'react';
import rison from 'rison';
import { isFeatureEnabled, FeatureFlag } from 'src/featureFlags';
import { createFetchRelated, createErrorHandler } from 'src/views/CRUD/utils';
import {
createFetchRelated,
createErrorHandler,
handleDashboardDelete,
handleBulkDashboardExport,
} from 'src/views/CRUD/utils';
import { useListViewResource, useFavoriteStatus } from 'src/views/CRUD/hooks';
import ConfirmStatusChange from 'src/components/ConfirmStatusChange';
import SubMenu, { SubMenuProps } from 'src/components/Menu/SubMenu';
import FacePile from 'src/components/FacePile';
import ListView, { ListViewProps, Filters } from 'src/components/ListView';
import Owner from 'src/types/Owner';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import FacePile from 'src/components/FacePile';
import Icon from 'src/components/Icon';
import Label from 'src/components/Label';
import FaveStar from 'src/components/FaveStar';
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
import ListViewCard from 'src/components/ListViewCard';
import { Dropdown, Menu } from 'src/common/components';
import TooltipWrapper from 'src/components/TooltipWrapper';
import Dashboard from 'src/dashboard/containers/Dashboard';
import DashboardCard from './DashboardCard';
const PAGE_SIZE = 25;
const FAVESTAR_BASE_URL = '/superset/favstar/Dashboard';
@ -81,7 +86,6 @@ function DashboardList(props: DashboardListProps) {
FAVESTAR_BASE_URL,
props.addDangerToast,
);
const [dashboardToEdit, setDashboardToEdit] = useState<Dashboard | null>(
null,
);
@ -119,25 +123,6 @@ function DashboardList(props: DashboardListProps) {
);
}
function handleDashboardDelete({
id,
dashboard_title: dashboardTitle,
}: Dashboard) {
return SupersetClient.delete({
endpoint: `/api/v1/dashboard/${id}`,
}).then(
() => {
refreshData();
props.addSuccessToast(t('Deleted: %s', dashboardTitle));
},
createErrorHandler(errMsg =>
props.addDangerToast(
t('There was an issue deleting %s: %s', dashboardTitle, errMsg),
),
),
);
}
function handleBulkDashboardDelete(dashboardsToDelete: Dashboard[]) {
return SupersetClient.delete({
endpoint: `/api/v1/dashboard/?q=${rison.encode(
@ -155,14 +140,6 @@ function DashboardList(props: DashboardListProps) {
);
}
function handleBulkDashboardExport(dashboardsToExport: Dashboard[]) {
return window.location.assign(
`/api/v1/dashboard/export/?q=${rison.encode(
dashboardsToExport.map(({ id }) => id),
)}`,
);
}
function renderFaveStar(id: number) {
return (
<FaveStar
@ -255,7 +232,13 @@ function DashboardList(props: DashboardListProps) {
},
{
Cell: ({ row: { original } }: any) => {
const handleDelete = () => handleDashboardDelete(original);
const handleDelete = () =>
handleDashboardDelete(
original,
refreshData,
props.addSuccessToast,
props.addDangerToast,
);
const handleEdit = () => openDashboardEditModal(original);
const handleExport = () => handleBulkDashboardExport([original]);
@ -418,83 +401,18 @@ function DashboardList(props: DashboardListProps) {
},
];
function renderCard(dashboard: Dashboard & { loading: boolean }) {
const menu = (
<Menu>
{canDelete && (
<Menu.Item>
<ConfirmStatusChange
title={t('Please Confirm')}
description={
<>
{t('Are you sure you want to delete')}{' '}
<b>{dashboard.dashboard_title}</b>?
</>
}
onConfirm={() => handleDashboardDelete(dashboard)}
>
{confirmDelete => (
<div
role="button"
tabIndex={0}
className="action-button"
onClick={confirmDelete}
>
<ListViewCard.MenuIcon
data-test="dashboard-list-view-card-trash-icon"
name="trash"
/>{' '}
Delete
</div>
)}
</ConfirmStatusChange>
</Menu.Item>
)}
{canExport && (
<Menu.Item
role="button"
tabIndex={0}
onClick={() => handleBulkDashboardExport([dashboard])}
>
<ListViewCard.MenuIcon name="share" /> Export
</Menu.Item>
)}
{canEdit && (
<Menu.Item
data-test="dashboard-list-edit-option"
role="button"
tabIndex={0}
onClick={() => openDashboardEditModal(dashboard)}
>
<ListViewCard.MenuIcon name="edit-alt" /> Edit
</Menu.Item>
)}
</Menu>
);
function renderCard(dashboard: Dashboard) {
return (
<ListViewCard
loading={dashboard.loading}
title={dashboard.dashboard_title}
titleRight={
<Label>{dashboard.published ? 'published' : 'draft'}</Label>
}
url={bulkSelectEnabled ? undefined : dashboard.url}
imgURL={dashboard.thumbnail_url}
imgFallbackURL="/static/assets/images/dashboard-card-fallback.png"
description={t(
'Last modified %s',
dashboard.changed_on_delta_humanized,
)}
coverLeft={<FacePile users={dashboard.owners || []} />}
actions={
<ListViewCard.Actions>
{renderFaveStar(dashboard.id)}
<Dropdown overlay={menu}>
<Icon name="more-horiz" />
</Dropdown>
</ListViewCard.Actions>
}
<DashboardCard
{...{
dashboard,
hasPerm,
bulkSelectEnabled,
refreshData,
addDangerToast: props.addDangerToast,
addSuccessToast: props.addSuccessToast,
openDashboardEditModal,
}}
/>
);
}

View File

@ -20,7 +20,7 @@ import { t } from '@superset-ui/core';
export const commonMenuData = {
name: t('Data'),
children: [
tabs: [
{
name: 'Datasets',
label: t('Datasets'),

View File

@ -39,6 +39,7 @@ import DeleteModal from 'src/components/DeleteModal';
import ActionsBar, { ActionProps } from 'src/components/ListView/ActionsBar';
import { IconName } from 'src/components/Icon';
import { commonMenuData } from 'src/views/CRUD/data/common';
import { SavedQueryObject } from 'src/views/CRUD/types';
import SavedQueryPreviewModal from './SavedQueryPreviewModal';
const PAGE_SIZE = 25;
@ -48,20 +49,6 @@ interface SavedQueryListProps {
addSuccessToast: (msg: string) => void;
}
type SavedQueryObject = {
database: {
database_name: string;
id: number;
};
db_id: number;
description?: string;
id: number;
label: string;
schema: string;
sql: string;
sql_tables: Array<{ catalog?: string; schema: string; table: string }>;
};
const StyledTableLabel = styled.div`
.count {
margin-left: 5px;

View File

@ -22,6 +22,7 @@ import { SupersetClient, t } from '@superset-ui/core';
import { createErrorHandler } from 'src/views/CRUD/utils';
import { FetchDataConfig } from 'src/components/ListView';
import Chart, { Slice } from 'src/types/Chart';
import { FavoriteStatus } from './types';
interface ListViewResourceState<D extends object = any> {
@ -350,5 +351,87 @@ export function useFavoriteStatus(
);
};
return [favoriteStatusRef, fetchFaveStar, saveFaveStar] as const;
return [
favoriteStatusRef,
fetchFaveStar,
saveFaveStar,
favoriteStatus,
] as const;
}
export const useChartEditModal = (
setCharts: (charts: Array<Chart>) => void,
charts: Array<Chart>,
) => {
const [
sliceCurrentlyEditing,
setSliceCurrentlyEditing,
] = useState<Slice | null>(null);
function openChartEditModal(chart: Chart) {
setSliceCurrentlyEditing({
slice_id: chart.id,
slice_name: chart.slice_name,
description: chart.description,
cache_timeout: chart.cache_timeout,
});
}
function closeChartEditModal() {
setSliceCurrentlyEditing(null);
}
function handleChartUpdated(edits: Chart) {
// update the chart in our state with the edited info
const newCharts = charts.map((chart: Chart) =>
chart.id === edits.id ? { ...chart, ...edits } : chart,
);
setCharts(newCharts);
}
return {
sliceCurrentlyEditing,
handleChartUpdated,
openChartEditModal,
closeChartEditModal,
};
};
export const copyQueryLink = (
id: number,
addDangerToast: (arg0: string) => void,
addSuccessToast: (arg0: string) => void,
) => {
const selection: Selection | null = document.getSelection();
if (selection) {
selection.removeAllRanges();
const range = document.createRange();
const span = document.createElement('span');
span.textContent = `${window.location.origin}/superset/sqllab?savedQueryId=${id}`;
span.style.position = 'fixed';
span.style.top = '0';
span.style.clip = 'rect(0, 0, 0, 0)';
span.style.whiteSpace = 'pre';
document.body.appendChild(span);
range.selectNode(span);
selection.addRange(range);
try {
if (!document.execCommand('copy')) {
throw new Error(t('Not successful'));
}
} catch (err) {
addDangerToast(t('Sorry, your browser does not support copying.'));
}
document.body.removeChild(span);
if (selection.removeRange) {
selection.removeRange(range);
} else {
selection.removeAllRanges();
}
addSuccessToast(t('Link Copied!'));
}
};

View File

@ -16,7 +16,56 @@
* specific language governing permissions and limitations
* under the License.
*/
import { User } from 'src/types/bootstrapTypes';
import Owner from 'src/types/Owner';
export type FavoriteStatus = {
[id: number]: boolean;
};
export interface DashboardTableProps {
addDangerToast: (message: string) => void;
addSuccessToast: (message: string) => void;
search: string;
user?: User;
}
export interface Dashboard {
changed_by_name: string;
changed_by_url: string;
changed_on_delta_humanized: string;
changed_by: string;
dashboard_title: string;
slice_name?: string;
id: number;
published: boolean;
url: string;
thumbnail_url: string;
owners: Owner[];
loading?: boolean;
}
export interface DashboardCardProps {
isChart?: boolean;
dashboard: Dashboard;
hasPerm: (name: string) => boolean;
bulkSelectEnabled: boolean;
refreshData: () => void;
addDangerToast: (msg: string) => void;
addSuccessToast: (msg: string) => void;
openDashboardEditModal?: (d: Dashboard) => void;
}
export type SavedQueryObject = {
database: {
database_name: string;
id: number;
};
db_id: number;
description?: string;
id: number;
label: string;
schema: string;
sql: string;
sql_tables: Array<{ catalog?: string; schema: string; table: string }>;
};

View File

@ -17,12 +17,16 @@
* under the License.
*/
import {
t,
SupersetClient,
SupersetClientResponse,
logging,
styled,
} from '@superset-ui/core';
import Chart from 'src/types/Chart';
import rison from 'rison';
import getClientErrorObject from 'src/utils/getClientErrorObject';
import { Dashboard } from './types';
const createFetchResourceMethod = (method: string) => (
resource: string,
@ -53,6 +57,102 @@ const createFetchResourceMethod = (method: string) => (
return [];
};
export const getRecentAcitivtyObjs = (
userId: string | number,
recent: string,
addDangerToast: (arg0: string, arg1: string) => void,
) => {
const getParams = (filters?: Array<any>) => {
const params = {
order_column: 'changed_on_delta_humanized',
order_direction: 'desc',
page: 0,
page_size: 3,
filters,
};
if (!filters) delete params.filters;
return rison.encode(params);
};
const filters = {
// chart and dashbaord uses same filters
// for edited and created
edited: [
{
col: 'changed_by',
opr: 'rel_o_m',
value: `${userId}`,
},
],
created: [
{
col: 'created_by',
opr: 'rel_o_m',
value: `${userId}`,
},
],
};
const baseBatch = [
SupersetClient.get({ endpoint: recent }),
SupersetClient.get({
endpoint: `/api/v1/dashboard/?q=${getParams(filters.edited)}`,
}),
SupersetClient.get({
endpoint: `/api/v1/chart/?q=${getParams(filters.edited)}`,
}),
SupersetClient.get({
endpoint: `/api/v1/dashboard/?q=${getParams(filters.created)}`,
}),
SupersetClient.get({
endpoint: `/api/v1/chart/?q=${getParams(filters.created)}`,
}),
SupersetClient.get({
endpoint: `/api/v1/saved_query/?q=${getParams(filters.created)}`,
}),
];
return Promise.all(baseBatch).then(
([
recentsRes,
editedDash,
editedChart,
createdByDash,
createdByChart,
createdByQuery,
]) => {
const res: any = {
editedDash: editedDash.json?.result.slice(0, 3),
editedChart: editedChart.json?.result.slice(0, 3),
createdByDash: createdByDash.json?.result.slice(0, 3),
createdByChart: createdByChart.json?.result.slice(0, 3),
createdByQuery: createdByQuery.json?.result.slice(0, 3),
};
if (recentsRes.json.length === 0) {
const newBatch = [
SupersetClient.get({ endpoint: `/api/v1/chart/?q=${getParams()}` }),
SupersetClient.get({
endpoint: `/api/v1/dashboard/?q=${getParams()}`,
}),
];
return Promise.all(newBatch)
.then(([chartRes, dashboardRes]) => {
res.examples = [
...chartRes.json.result,
...dashboardRes.json.result,
];
return res;
})
.catch(e =>
addDangerToast(
'There was an error fetching you recent activity:',
e,
),
);
}
res.viewed = recentsRes.json;
return res;
},
);
};
export const createFetchRelated = createFetchResourceMethod('related');
export const createFetchDistinct = createFetchResourceMethod('distinct');
@ -63,3 +163,100 @@ export function createErrorHandler(handleErrorFunc: (errMsg?: string) => void) {
handleErrorFunc(parsedError.message || parsedError.error);
};
}
export function handleChartDelete(
{ id, slice_name: sliceName }: Chart,
addSuccessToast: (arg0: string) => void,
addDangerToast: (arg0: string) => void,
refreshData: () => void,
) {
SupersetClient.delete({
endpoint: `/api/v1/chart/${id}`,
}).then(
() => {
refreshData();
addSuccessToast(t('Deleted: %s', sliceName));
},
() => {
addDangerToast(t('There was an issue deleting: %s', sliceName));
},
);
}
export function handleBulkDashboardExport(dashboardsToExport: Dashboard[]) {
return window.location.assign(
`/api/v1/dashboard/export/?q=${rison.encode(
dashboardsToExport.map(({ id }) => id),
)}`,
);
}
export function handleDashboardDelete(
{ id, dashboard_title: dashboardTitle }: Dashboard,
refreshData: () => void,
addSuccessToast: (arg0: string) => void,
addDangerToast: (arg0: string) => void,
) {
return SupersetClient.delete({
endpoint: `/api/v1/dashboard/${id}`,
}).then(
() => {
refreshData();
addSuccessToast(t('Deleted: %s', dashboardTitle));
},
createErrorHandler(errMsg =>
addDangerToast(
t('There was an issue deleting %s: %s', dashboardTitle, errMsg),
),
),
);
}
export function createChartDeleteFunction(
{ id, slice_name: sliceName }: Chart,
addSuccessToast: (arg0: string) => void,
addDangerToast: (arg0: string) => void,
refreshData: () => void,
) {
SupersetClient.delete({
endpoint: `/api/v1/chart/${id}`,
}).then(
() => {
refreshData();
addSuccessToast(t('Deleted: %s', sliceName));
},
() => {
addDangerToast(t('There was an issue deleting: %s', sliceName));
},
);
}
const breakpoints = [576, 768, 992, 1200];
export const mq = breakpoints.map(bp => `@media (max-width: ${bp}px)`);
export const CardContainer = styled.div`
display: grid;
grid-template-columns: repeat(auto-fit, minmax(31%, max-content));
${[mq[3]]} {
grid-template-columns: repeat(auto-fit, minmax(31%, max-content));
}
${[mq[2]]} {
grid-template-columns: repeat(auto-fit, minmax(48%, max-content));
}
${[mq[1]]} {
grid-template-columns: repeat(auto-fit, minmax(50%, max-content));
}
grid-gap: ${({ theme }) => theme.gridUnit * 8}px;
justify-content: left;
padding: ${({ theme }) => theme.gridUnit * 2}px
${({ theme }) => theme.gridUnit * 6}px;
`;
export const IconContainer = styled.div`
svg {
vertical-align: -7px;
color: ${({ theme }) => theme.colors.primary.dark1};
}
`;

View File

@ -0,0 +1,209 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useState } from 'react';
import moment from 'antd/node_modules/moment';
import { styled, t } from '@superset-ui/core';
import ListViewCard from 'src/components/ListViewCard';
import { addDangerToast } from 'src/messageToasts/actions';
import SubMenu from 'src/components/Menu/SubMenu';
import { reject } from 'lodash';
import { getRecentAcitivtyObjs, mq } from '../utils';
import EmptyState from './EmptyState';
interface ActivityObjects {
action?: string;
item_title?: string;
slice_name: string;
time: string;
changed_on_utc: string;
url: string;
sql: string;
dashboard_title: string;
label: string;
id: string;
table: object;
item_url: string;
}
interface ActivityProps {
user: {
userId: string | number;
};
}
interface ActivityData {
Created?: Array<object>;
Edited?: Array<object>;
Viewed?: Array<object>;
Examples?: Array<object>;
}
const ActivityContainer = styled.div`
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
margin-top: ${({ theme }) => theme.gridUnit * -4}px;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(31%, max-content));
${[mq[3]]} {
grid-template-columns: repeat(auto-fit, minmax(31%, max-content));
}
${[mq[2]]} {
grid-template-columns: repeat(auto-fit, minmax(42%, max-content));
}
${[mq[1]]} {
grid-template-columns: repeat(auto-fit, minmax(63%, max-content));
}
grid-gap: ${({ theme }) => theme.gridUnit * 8}px;
justify-content: left;
padding: ${({ theme }) => theme.gridUnit * 2}px
${({ theme }) => theme.gridUnit * 4}px;
.ant-card-meta-avatar {
margin-top: ${({ theme }) => theme.gridUnit * 3}px;
margin-left: ${({ theme }) => theme.gridUnit * 2}px;
}
.ant-card-meta-title {
font-weight: ${({ theme }) => theme.typography.weights.bold};
}
`;
export default function ActivityTable({ user }: ActivityProps) {
const [activityData, setActivityData] = useState<ActivityData>({});
const [loading, setLoading] = useState(true);
const [activeChild, setActiveChild] = useState('Viewed');
// this api uses log for data which in some cases can be empty
const recent = `/superset/recent_activity/${user.userId}/?limit=5`;
const getFilterTitle = (e: ActivityObjects) => {
if (e.dashboard_title) return e.dashboard_title;
if (e.label) return e.label;
if (e.url && !e.table) return e.item_title;
if (e.item_title) return e.item_title;
return e.slice_name;
};
const getIconName = (e: ActivityObjects) => {
if (e.sql) return 'sql';
if (e.url?.includes('dashboard')) {
return 'nav-dashboard';
}
if (e.url?.includes('explore') || e.item_url?.includes('explore')) {
return 'nav-charts';
}
return '';
};
const tabs = [
{
name: 'Edited',
label: t('Edited'),
onClick: () => {
setActiveChild('Edited');
},
},
{
name: 'Created',
label: t('Created'),
onClick: () => {
setActiveChild('Created');
},
},
];
if (activityData.Viewed) {
tabs.unshift({
name: 'Viewed',
label: t('Viewed'),
onClick: () => {
setActiveChild('Viewed');
},
});
} else {
tabs.unshift({
name: 'Examples',
label: t('Examples'),
onClick: () => {
setActiveChild('Examples');
},
});
}
useEffect(() => {
getRecentAcitivtyObjs(user.userId, recent, addDangerToast)
.then(res => {
const data: any = {
Created: [
...res.createdByChart,
...res.createdByDash,
...res.createdByQuery,
],
Edited: [...res.editedChart, ...res.editedDash],
};
if (res.viewed) {
const filtered = reject(res.viewed, ['item_url', null]).map(r => r);
data.Viewed = filtered;
setActiveChild('Viewed');
} else {
data.Examples = res.examples;
setActiveChild('Examples');
}
setActivityData(data);
setLoading(false);
})
.catch(e => {
setLoading(false);
addDangerToast(
`There was an issue fetching your recent Acitivity: ${e}`,
);
});
}, []);
const renderActivity = () => {
return activityData[activeChild].map((e: ActivityObjects) => (
<ListViewCard
key={`${e.id}`}
isRecent
loading={loading}
url={e.sql ? `/supserset/sqllab?queryId=${e.id}` : e.url}
title={getFilterTitle(e)}
description={`Last Edited: ${moment(e.changed_on_utc).format(
'MM/DD/YYYY HH:mm:ss',
)}`}
avatar={getIconName(e)}
actions={null}
/>
));
};
if (loading) return <>loading ...</>;
return (
<>
<SubMenu
activeChild={activeChild}
// eslint-disable-next-line react/no-children-prop
tabs={tabs}
/>
<>
{activityData[activeChild]?.length > 0 ? (
<ActivityContainer>{renderActivity()}</ActivityContainer>
) : (
<EmptyState tableName="RECENTS" tab={activeChild} />
)}
</>
</>
);
}

View File

@ -0,0 +1,167 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useState } from 'react';
import { t } from '@superset-ui/core';
import { useListViewResource, useChartEditModal } from 'src/views/CRUD/hooks';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import PropertiesModal from 'src/explore/components/PropertiesModal';
import { User } from 'src/types/bootstrapTypes';
import Icon from 'src/components/Icon';
import ChartCard from 'src/views/CRUD/chart/ChartCard';
import Chart from 'src/types/Chart';
import SubMenu from 'src/components/Menu/SubMenu';
import EmptyState from './EmptyState';
import { CardContainer, IconContainer } from '../utils';
const PAGE_SIZE = 3;
interface ChartTableProps {
addDangerToast: (message: string) => void;
addSuccessToast: (message: string) => void;
search: string;
chartFilter?: string;
user?: User;
}
function ChartTable({
user,
addDangerToast,
addSuccessToast,
}: ChartTableProps) {
const {
state: { loading, resourceCollection: charts, bulkSelectEnabled },
setResourceCollection: setCharts,
hasPerm,
refreshData,
fetchData,
} = useListViewResource<Chart>('chart', t('chart'), addDangerToast);
const {
sliceCurrentlyEditing,
openChartEditModal,
handleChartUpdated,
closeChartEditModal,
} = useChartEditModal(setCharts, charts);
const [chartFilter, setChartFilter] = useState('Mine');
const getFilters = () => {
const filters = [];
if (chartFilter === 'Mine') {
filters.push({
id: 'created_by',
operator: 'rel_o_m',
value: `${user?.userId}`,
});
} else {
filters.push({
id: 'id',
operator: 'chart_is_fav',
value: true,
});
}
return filters;
};
useEffect(() => {
fetchData({
pageIndex: 0,
pageSize: PAGE_SIZE,
sortBy: [
{
id: 'changed_on_delta_humanized',
desc: true,
},
],
filters: getFilters(),
});
}, [chartFilter]);
return (
<>
{sliceCurrentlyEditing && (
<PropertiesModal
onHide={closeChartEditModal}
onSave={handleChartUpdated}
show
slice={sliceCurrentlyEditing}
/>
)}
<SubMenu
activeChild={chartFilter}
// eslint-disable-next-line react/no-children-prop
tabs={[
{
name: 'Favorite',
label: t('Favorite'),
onClick: () => setChartFilter('Favorite'),
},
{
name: 'Mine',
label: t('Mine'),
onClick: () => setChartFilter('Mine'),
},
]}
buttons={[
{
name: (
<IconContainer>
<Icon name="plus-small" />
{t('Chart')}
</IconContainer>
),
buttonStyle: 'tertiary',
onClick: () => {
window.location.href = '/chart/add';
},
},
{
name: 'View All »',
buttonStyle: 'link',
onClick: () => {
window.location.href = '/chart/list';
},
},
]}
/>
{charts?.length ? (
<CardContainer>
{charts.map(e => (
<ChartCard
key={`${e.id}`}
openChartEditModal={openChartEditModal}
loading={loading}
chart={e}
hasPerm={hasPerm}
bulkSelectEnabled={bulkSelectEnabled}
refreshData={refreshData}
addDangerToast={addDangerToast}
addSuccessToast={addSuccessToast}
/>
))}
</CardContainer>
) : (
<EmptyState tableName="CHARTS" tab={chartFilter} />
)}
</>
);
}
export default withToasts(ChartTable);

View File

@ -16,169 +16,176 @@
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import { t, SupersetClient } from '@superset-ui/core';
import { debounce } from 'lodash';
import ListView, { FetchDataConfig } from 'src/components/ListView';
import React, { useEffect, useState } from 'react';
import { SupersetClient, t } from '@superset-ui/core';
import { useListViewResource } from 'src/views/CRUD/hooks';
import { Dashboard, DashboardTableProps } from 'src/views/CRUD/types';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import { Dashboard } from 'src/types/bootstrapTypes';
import PropertiesModal from 'src/dashboard/components/PropertiesModal';
import DashboardCard from 'src/views/CRUD/dashboard/DashboardCard';
import SubMenu from 'src/components/Menu/SubMenu';
import Icon from 'src/components/Icon';
import EmptyState from './EmptyState';
import { createErrorHandler, CardContainer, IconContainer } from '../utils';
const PAGE_SIZE = 25;
const PAGE_SIZE = 3;
interface DashboardTableProps {
addDangerToast: (message: string) => void;
search?: string;
export interface FilterValue {
col: string;
operator: string;
value: string | boolean | number | null | undefined;
}
interface DashboardTableState {
dashboards: Dashboard[];
dashboard_count: number;
loading: boolean;
}
function DashboardTable({
user,
addDangerToast,
addSuccessToast,
}: DashboardTableProps) {
const {
state: { loading, resourceCollection: dashboards, bulkSelectEnabled },
setResourceCollection: setDashboards,
hasPerm,
refreshData,
fetchData,
} = useListViewResource<Dashboard>(
'dashboard',
t('dashboard'),
addDangerToast,
);
class DashboardTable extends React.PureComponent<
DashboardTableProps,
DashboardTableState
> {
columns = [
{
accessor: 'dashboard_title',
Header: 'Dashboard',
Cell: ({
row: {
original: { url, dashboard_title: dashboardTitle },
},
}: {
row: {
original: {
url: string;
dashboard_title: string;
};
};
}) => <a href={url}>{dashboardTitle}</a>,
},
{
accessor: 'changed_by.first_name',
Header: 'Modified By',
Cell: ({
row: {
original: { changed_by_name: changedByName, changedByUrl },
},
}: {
row: {
original: {
changed_by_name: string;
changedByUrl: string;
};
};
}) => <a href={changedByUrl}>{changedByName}</a>,
},
{
accessor: 'changed_on_delta_humanized',
Header: 'Modified',
Cell: ({
row: {
original: { changed_on_delta_humanized: changedOn },
},
}: {
row: {
original: {
changed_on_delta_humanized: string;
};
};
}) => <span className="no-wrap">{changedOn}</span>,
},
];
initialSort = [{ id: 'changed_on_delta_humanized', desc: true }];
constructor(props: DashboardTableProps) {
super(props);
this.state = {
dashboards: [],
dashboard_count: 0,
loading: false,
};
}
componentDidUpdate(prevProps: DashboardTableProps) {
if (prevProps.search !== this.props.search) {
this.fetchDataDebounced({
pageSize: PAGE_SIZE,
pageIndex: 0,
sortBy: this.initialSort,
filters: [],
});
}
}
fetchData = ({ pageIndex, pageSize, sortBy, filters }: FetchDataConfig) => {
this.setState({ loading: true });
const filterExps = Object.keys(filters)
.map(fk => ({
col: fk,
opr: filters[fk].filterId,
value: filters[fk].filterValue,
}))
.concat(
this.props.search
? [
{
col: 'dashboard_title',
opr: 'ct',
value: this.props.search,
},
]
: [],
);
const queryParams = JSON.stringify({
order_column: sortBy[0].id,
order_direction: sortBy[0].desc ? 'desc' : 'asc',
page: pageIndex,
page_size: pageSize,
...(filterExps.length ? { filters: filterExps } : {}),
});
const [editModal, setEditModal] = useState<Dashboard>();
const [dashboardFilter, setDashboardFilter] = useState('Mine');
const handleDashboardEdit = (edits: Dashboard) => {
return SupersetClient.get({
endpoint: `/api/v1/dashboard/?q=${queryParams}`,
})
.then(({ json }) => {
this.setState({ dashboards: json.result, dashboard_count: json.count });
})
.catch(response => {
if (response.status === 401) {
this.props.addDangerToast(
t(
"You don't have the necessary permissions to load dashboards. Please contact your administrator.",
),
);
} else {
this.props.addDangerToast(
t('An error occurred while fetching Dashboards'),
);
}
})
.finally(() => this.setState({ loading: false }));
endpoint: `/api/v1/dashboard/${edits.id}`,
}).then(
({ json = {} }) => {
setDashboards(
dashboards.map(dashboard => {
if (dashboard.id === json.id) {
return json.result;
}
return dashboard;
}),
);
},
createErrorHandler(errMsg =>
addDangerToast(
t('An error occurred while fetching dashboards: %s', errMsg),
),
),
);
};
// sort-comp disabled because of conflict with no-use-before-define rule
// eslint-disable-next-line react/sort-comp
fetchDataDebounced = debounce(this.fetchData, 200);
const getFilters = () => {
const filters = [];
render() {
return (
<ListView
columns={this.columns}
data={this.state.dashboards}
count={this.state.dashboard_count}
pageSize={PAGE_SIZE}
fetchData={this.fetchData}
loading={this.state.loading}
initialSort={this.initialSort}
/>
);
if (dashboardFilter === 'Mine') {
filters.push({
id: 'owners',
operator: 'rel_m_m',
value: `${user?.userId}`,
});
} else {
filters.push({
id: 'id',
operator: 'dashboard_is_fav',
value: true,
});
}
return filters;
};
const subMenus = [];
if (dashboards.length > 0 && dashboardFilter === 'favorite') {
subMenus.push({
name: 'Favorite',
label: t('Favorite'),
onClick: () => setDashboardFilter('Favorite'),
});
}
useEffect(() => {
fetchData({
pageIndex: 0,
pageSize: PAGE_SIZE,
sortBy: [
{
id: 'changed_on_delta_humanized',
desc: true,
},
],
filters: getFilters(),
});
}, [dashboardFilter]);
return (
<>
<SubMenu
activeChild={dashboardFilter}
tabs={[
{
name: 'Favorite',
label: t('Favorite'),
onClick: () => setDashboardFilter('Favorite'),
},
{
name: 'Mine',
label: t('Mine'),
onClick: () => setDashboardFilter('Mine'),
},
]}
buttons={[
{
name: (
<IconContainer>
<Icon name="plus-small" /> Dashboard{' '}
</IconContainer>
),
buttonStyle: 'tertiary',
onClick: () => {
window.location.href = '/dashboard/new';
},
},
{
name: 'View All »',
buttonStyle: 'link',
onClick: () => {
window.location.href = '/dashboard/list/';
},
},
]}
/>
{editModal && (
<PropertiesModal
dashboardId={editModal?.id}
show
onHide={() => setEditModal(undefined)}
onSubmit={handleDashboardEdit}
/>
)}
{dashboards.length > 0 ? (
<CardContainer>
{dashboards.map(e => (
<DashboardCard
{...{
dashboard: e,
hasPerm,
bulkSelectEnabled,
refreshData,
addDangerToast,
addSuccessToast,
loading,
openDashboardEditModal: dashboard => setEditModal(dashboard),
}}
/>
))}
</CardContainer>
) : (
<EmptyState tableName="DASHBOARDS" tab={dashboardFilter} />
)}
</>
);
}
export default withToasts(DashboardTable);

View File

@ -0,0 +1,144 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React from 'react';
import Button from 'src/components/Button';
import { Empty } from 'src/common/components';
import { t, styled } from '@superset-ui/core';
import Icon from 'src/components/Icon';
import { IconContainer } from '../utils';
interface EmptyStateProps {
tableName: string;
tab?: string;
}
const ButtonContainer = styled.div`
Button {
svg {
color: ${({ theme }) => theme.colors.grayscale.light5};
}
}
`;
export default function EmptyState({ tableName, tab }: EmptyStateProps) {
const mineRedirects = {
DASHBOARDS: '/dashboard/new',
CHARTS: '/chart/add',
SAVED_QUERIES: '/superset/sqllab',
};
const favRedirects = {
DASHBOARDS: '/dashboard/list/',
CHARTS: '/chart/list',
SAVED_QUERIES: '/savedqueryview/list/',
};
const tableIcon = {
RECENTS: 'union.png',
DASHBOARDS: 'empty-dashboard.png',
CHARTS: 'empty-charts.png',
SAVED_QUERIES: 'empty-queries.png',
};
const mine = (
<div>{`No ${
tableName === 'SAVED_QUERIES'
? t('saved queries')
: t(`${tableName.toLowerCase()}`)
} yet`}</div>
);
const recent = (
<div className="no-recents">
{(() => {
if (tab === 'Viewed') {
return t(
`Recently viewed charts, dashboards, and saved queries will appear here`,
);
}
if (tab === 'Created') {
return t(
'Recently created charts, dashboards, and saved queries will appear here',
);
}
if (tab === 'Examples') {
return t(
`Recent example charts, dashboards, and saved queries will appear here`,
);
}
if (tab === 'Edited') {
return t(
`Recently edited charts, dashboards, and saved queries will appear here`,
);
}
return null;
})()}
</div>
);
// Mine and Recent Activity(all tabs) tab empty state
if (tab === 'Mine' || tableName === 'RECENTS') {
return (
<Empty
image={`/static/assets/images/${tableIcon[tableName]}`}
description={tableName === 'RECENTS' ? recent : mine}
>
{tableName !== 'RECENTS' && (
<ButtonContainer>
<Button
buttonStyle="primary"
onClick={() => {
window.location = mineRedirects[tableName];
}}
>
<IconContainer>
<Icon name="plus-small" />{' '}
{tableName === 'SAVED_QUERIES'
? t('SQL QUERY')
: t(`${tableName
.split('')
.slice(0, tableName.length - 1)
.join('')}
`)}
</IconContainer>
</Button>
</ButtonContainer>
)}
</Empty>
);
}
// Favorite tab empty state
return (
<Empty
image="/static/assets/images/star-circle.png"
description={
<div className="no-favorites">
{t("You don't have any favorites yet!")}
</div>
}
>
<Button
buttonStyle="primary"
onClick={() => {
window.location = favRedirects[tableName];
}}
>
SEE ALL{' '}
{tableName === 'SAVED_QUERIES'
? t('SQL LAB QUERIES')
: t(`${tableName}`)}
</Button>
</Empty>
);
}

View File

@ -0,0 +1,260 @@
/**
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing,
* software distributed under the License is distributed on an
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
* KIND, either express or implied. See the License for the
* specific language governing permissions and limitations
* under the License.
*/
import React, { useEffect, useState } from 'react';
import { t, SupersetClient, styled } from '@superset-ui/core';
import withToasts from 'src/messageToasts/enhancers/withToasts';
import { Dropdown, Menu } from 'src/common/components';
import { useListViewResource, copyQueryLink } from 'src/views/CRUD/hooks';
import ListViewCard from 'src/components/ListViewCard';
import DeleteModal from 'src/components/DeleteModal';
import Icon from 'src/components/Icon';
import SubMenu from 'src/components/Menu/SubMenu';
import EmptyState from './EmptyState';
import { IconContainer, CardContainer, createErrorHandler } from '../utils';
const PAGE_SIZE = 3;
interface Query {
id?: number;
sql_tables?: Array<any>;
database?: {
database_name: string;
};
rows?: string;
description?: string;
end_time?: string;
label?: string;
}
interface SavedQueriesProps {
user: {
userId: string | number;
};
queryFilter: string;
addDangerToast: (arg0: string) => void;
addSuccessToast: (arg0: string) => void;
}
const QueryData = styled.div`
display: flex;
flex-direction: row;
justify-content: flex-start;
border-bottom: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
.title {
font-weight: ${({ theme }) => theme.typography.weights.normal};
color: ${({ theme }) => theme.colors.grayscale.light2};
}
.holder {
margin: ${({ theme }) => theme.gridUnit * 2}px;
}
`;
const SavedQueries = ({
user,
addDangerToast,
addSuccessToast,
}: SavedQueriesProps) => {
const {
state: { loading, resourceCollection: queries },
hasPerm,
fetchData,
refreshData,
} = useListViewResource<Query>('saved_query', t('query'), addDangerToast);
const [queryFilter, setQueryFilter] = useState('Mine');
const [queryDeleteModal, setQueryDeleteModal] = useState(false);
const [currentlyEdited, setCurrentlyEdited] = useState<Query>({});
const canEdit = hasPerm('can_edit');
const canDelete = hasPerm('can_delete');
const handleQueryDelete = ({ id, label }: Query) => {
SupersetClient.delete({
endpoint: `/api/v1/saved_query/${id}`,
}).then(
() => {
refreshData();
setQueryDeleteModal(false);
addSuccessToast(t('Deleted: %s', label));
},
createErrorHandler(errMsg =>
addDangerToast(t('There was an issue deleting %s: %s', label, errMsg)),
),
);
};
const getFilters = () => {
const filters = [];
if (queryFilter === 'Mine') {
filters.push({
id: 'created_by',
operator: 'rel_o_m',
value: `${user?.userId}`,
});
} else {
filters.push({
id: 'id',
operator: 'saved_query_is_fav',
value: true,
});
}
return filters;
};
useEffect(() => {
fetchData({
pageIndex: 0,
pageSize: PAGE_SIZE,
sortBy: [
{
id: 'changed_on_delta_humanized',
desc: true,
},
],
filters: getFilters(),
});
}, [queryFilter]);
const renderMenu = (query: Query) => (
<Menu>
{canEdit && (
<Menu.Item
onClick={() => {
window.location.href = `/superset/sqllab?savedQueryId=${query.id}`;
}}
>
{t('Edit')}
</Menu.Item>
)}
<Menu.Item
onClick={() => {
if (query.id)
copyQueryLink(query.id, addDangerToast, addSuccessToast);
}}
>
{t('Share')}
</Menu.Item>
{canDelete && (
<Menu.Item
onClick={() => {
setQueryDeleteModal(true);
setCurrentlyEdited(query);
}}
>
{t('Delete')}
</Menu.Item>
)}
</Menu>
);
return (
<>
{queryDeleteModal && (
<DeleteModal
description={t(
'This action will permanently delete the saved query.',
)}
onConfirm={() => {
if (queryDeleteModal) {
handleQueryDelete(currentlyEdited);
}
}}
onHide={() => {
setQueryDeleteModal(false);
}}
open
title={t('Delete Query?')}
/>
)}
<SubMenu
activeChild={queryFilter}
tabs={[
{
name: 'Favorite',
label: t('Favorite'),
onClick: () => setQueryFilter('Favorite'),
},
{
name: 'Mine',
label: t('Mine'),
onClick: () => setQueryFilter('Mine'),
},
]}
buttons={[
{
name: (
<IconContainer>
<Icon name="plus-small" /> SQL Query{' '}
</IconContainer>
),
buttonStyle: 'tertiary',
onClick: () => {
window.location.href = '/superset/sqllab';
},
},
{
name: 'View All »',
buttonStyle: 'link',
onClick: () => {
window.location.href = '/savedqueryview/list';
},
},
]}
/>
{queries.length > 0 ? (
<CardContainer>
{queries.map(q => (
<ListViewCard
key={`${q.id}`}
imgFallbackURL=""
imgURL=""
url={`/superset/sqllab?savedQueryId=${q.id}`}
title={q.label}
rows={q.rows}
loading={loading}
description={t('Last run ', q.end_time)}
showImg={false}
renderCover={
<QueryData>
<div className="holder">
<div className="title">{t('Tables')}</div>
<div>{q?.sql_tables?.length}</div>
</div>
<div className="holder">
<div className="title">{t('Datasource Name')}</div>
<div>{q?.sql_tables && q.sql_tables[0]?.table}</div>
</div>
</QueryData>
}
actions={
<ListViewCard.Actions>
<Dropdown overlay={renderMenu(q)}>
<Icon name="more-horiz" />
</Dropdown>
</ListViewCard.Actions>
}
/>
))}
</CardContainer>
) : (
<EmptyState tableName="SAVED_QUERIES" tab={queryFilter} />
)}
</>
);
};
export default withToasts(SavedQueries);

View File

@ -16,128 +16,79 @@
* specific language governing permissions and limitations
* under the License.
*/
import React, { useCallback, useState } from 'react';
import {
Panel,
Row,
Col,
Tabs,
Tab,
FormControl,
FormControlProps,
} from 'react-bootstrap';
import { t } from '@superset-ui/core';
import { useQueryParam, StringParam, QueryParamConfig } from 'use-query-params';
import React from 'react';
import { styled, t } from '@superset-ui/core';
import { Collapse } from 'src/common/components';
import { User } from 'src/types/bootstrapTypes';
import RecentActivity from 'src/profile/components/RecentActivity';
import Favorites from 'src/profile/components/Favorites';
import { mq } from '../utils';
import ActivityTable from './ActivityTable';
import ChartTable from './ChartTable';
import SavedQueries from './SavedQueries';
import DashboardTable from './DashboardTable';
const { Panel } = Collapse;
interface WelcomeProps {
user: User;
}
function useSyncQueryState(
queryParam: string,
queryParamType: QueryParamConfig<
string | null | undefined,
string | undefined
>,
defaultState: string,
): [string, (val: string) => void] {
const [queryState, setQueryState] = useQueryParam(queryParam, queryParamType);
const [state, setState] = useState(queryState || defaultState);
const setQueryStateAndState = (val: string) => {
setQueryState(val);
setState(val);
};
return [state, setQueryStateAndState];
}
const WelcomeContainer = styled.div`
background-color: ${({ theme }) => theme.colors.grayscale.light4};
nav {
margin-top: -15px;
background-color: ${({ theme }) => theme.colors.grayscale.light4};
&:after {
content: '';
display: block;
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
margin: 0px ${({ theme }) => theme.gridUnit * 6}px;
position: relative;
${[mq[1]]} {
margin-top: 5px;
margin: 0px 2px;
}
}
.nav.navbar-nav {
& > li:nth-child(1),
& > li:nth-child(2),
& > li:nth-child(3) {
margin-top: ${({ theme }) => theme.gridUnit * 2}px;
}
}
button {
padding: 3px 21px;
}
.navbar-right {
position: relative;
top: 11px;
}
}
.ant-card.ant-card-bordered {
border: 1px solid ${({ theme }) => theme.colors.grayscale.light2};
}
.ant-collapse-header {
font-weight: ${({ theme }) => theme.typography.weights.normal};
font-size: ${({ theme }) => theme.gridUnit * 4}px;
}
`;
export default function Welcome({ user }: WelcomeProps) {
const [activeTab, setActiveTab] = useSyncQueryState(
'activeTab',
StringParam,
'all',
);
const [searchQuery, setSearchQuery] = useSyncQueryState(
'search',
StringParam,
'',
);
const onFormControlChange = useCallback(
(e: React.FormEvent<FormControl & FormControlProps>) => {
const { value } = e.currentTarget;
setSearchQuery((value as string) ?? '');
},
[],
);
const onTabsSelect = useCallback((e: any) => {
setActiveTab(e as string);
}, []);
return (
<div className="container welcome">
<Tabs
activeKey={activeTab}
onSelect={onTabsSelect}
id="uncontrolled-tab-example"
>
<Tab eventKey="all" title={t('Dashboards')}>
<Panel>
<Panel.Body>
<Row>
<Col md={8}>
<h2>{t('Dashboards')}</h2>
</Col>
<Col md={4}>
<FormControl
type="text"
bsSize="sm"
style={{ marginTop: '25px' }}
placeholder="Search"
value={searchQuery}
onChange={onFormControlChange}
/>
</Col>
</Row>
<hr />
<DashboardTable search={searchQuery} />
</Panel.Body>
</Panel>
</Tab>
<Tab eventKey="recent" title={t('Recently Viewed')}>
<Panel>
<Panel.Body>
<Row>
<Col md={8}>
<h2>{t('Recently Viewed')}</h2>
</Col>
</Row>
<hr />
<RecentActivity user={user} />
</Panel.Body>
</Panel>
</Tab>
<Tab eventKey="favorites" title={t('Favorites')}>
<Panel>
<Panel.Body>
<Row>
<Col md={8}>
<h2>{t('Favorites')}</h2>
</Col>
</Row>
<hr />
<Favorites user={user} />
</Panel.Body>
</Panel>
</Tab>
</Tabs>
</div>
<WelcomeContainer>
<Collapse defaultActiveKey={['1', '2', '3', '4']} ghost>
<Panel header={t('Recents')} key="1">
<ActivityTable user={user} />
</Panel>
<Panel header={t('Dashboards')} key="2">
<DashboardTable user={user} />
</Panel>
<Panel header={t('Saved Queries')} key="3">
<SavedQueries user={user} />
</Panel>
<Panel header={t('Charts')} key="4">
<ChartTable user={user} />
</Panel>
</Collapse>
</WelcomeContainer>
);
}

View File

@ -144,6 +144,7 @@ class ChartRestApi(BaseSupersetModelRestApi):
]
search_columns = [
"created_by",
"changed_by",
"datasource_id",
"datasource_name",
"datasource_type",

View File

@ -157,6 +157,7 @@ class DashboardRestApi(BaseSupersetModelRestApi):
"owners",
"published",
"slug",
"changed_by",
)
search_filters = {
"dashboard_title": [DashboardTitleOrSlugFilter],

View File

@ -42,6 +42,9 @@ class QueryRestApi(BaseSupersetModelRestApi):
"status",
"start_time",
"end_time",
"rows",
"tmp_table_name",
"tracking_url",
]
show_columns = [
"client_id",

View File

@ -106,7 +106,7 @@ class SavedQueryRestApi(BaseSupersetModelRestApi):
"last_run_delta_humanized",
]
search_columns = ["id", "database", "label", "schema"]
search_columns = ["id", "database", "label", "schema", "created_by"]
search_filters = {
"id": [SavedQueryFavoriteFilter],
"label": [SavedQueryAllTextFilter],